- R-01: ApiNewsroomAssignment hängt an eine bestehende Firma nur noch Owner, Mitglieder oder Nutzer aus NEWSROOM_API_ASSIGNMENT_USER_IDS; Pivot mit granted_via = api; Befehl company-memberships:api-assignments (Liste, --remove mit Vorschau) - K-04: OAuth-Verifikation eines offenen Kontos verwirft Passwort, remember_token und Sanctum-Tokens; SessionRevocation + Middleware EnsureSessionIsNotRevoked widerruft alte Sessions (Redis-tauglich) - K-08: LoginCompletion für Passwort, Magic Link und Google — last_login_at und Anmeldemethode auch nach 2FA, Landeziel über die Challenge, Google ohne erzwungenes remember, magic-login ohne guest für eigene Links - B-10: eigener Magic-Link-Zweck image_confirmation (Migration) - K-07: legalAcceptanceVersions #[Locked], Versionswechsel beim Absenden wird abgelehnt, Gate-Abfragen je Request zwischengespeichert Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
224 lines
8.8 KiB
PHP
224 lines
8.8 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Enums\ActivityType;
|
|
use App\Models\ActivityEvent;
|
|
use App\Models\Company;
|
|
use App\Scopes\PortalScope;
|
|
use App\Services\ActivityLog;
|
|
use App\Services\Company\ApiNewsroomAssignment;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Database\Query\Builder;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Bestandsprüfung der Mitgliedschaften aus der API-Zuordnung (Roadmap 1a.4,
|
|
* Umsetzungsaudit 29.09.2026, R-01).
|
|
*
|
|
* Bis zum Fix hängte `POST /service/users/me/companies` jeden Token-Nutzer
|
|
* mit passender Firmen-E-Mail und passendem Namen als `member` an eine
|
|
* bestehende Firma — also auch fremde Einlieferer. Grundlage der Liste sind
|
|
* die Aktivitäts-Einträge „API-Firma … zugeordnet" seit dem Deploy von 1a.4
|
|
* (15.09.2026) und alle Pivots mit `granted_via = api`. Als „über die
|
|
* API entstanden" gilt ein `member`-Pivot, der mit oder nach der ersten
|
|
* Zuordnung angelegt wurde; wer vorher schon Mitglied war, wird nicht
|
|
* angefasst.
|
|
*
|
|
* Standard ist die reine Liste. `--remove` entfernt nach Vorschau und
|
|
* Rückfrage die Pivots von Nutzern, die weder freigeschaltet
|
|
* (`newsroom.api_assignment.allowed_user_ids`) noch Owner der Firma sind.
|
|
* Die Meldungen, die sie dort angelegt haben, bleiben über die Autorenschaft
|
|
* erreichbar.
|
|
*/
|
|
class ReviewApiCompanyAssignments extends Command
|
|
{
|
|
protected $signature = 'company-memberships:api-assignments
|
|
{--since=2026-09-15 : Aktivitäts-Einträge der API-Zuordnung ab diesem Datum auswerten (Deploy von 1a.4)}
|
|
{--remove : Mitgliedschaften nicht freigeschalteter Einlieferer entfernen (mit Vorschau und Rückfrage)}
|
|
{--force : Rückfrage bei --remove überspringen (nicht interaktiv)}';
|
|
|
|
protected $description = 'Listet Firmen-Mitgliedschaften aus der API-Zuordnung und entfernt auf Wunsch die nicht freigeschalteter Einlieferer.';
|
|
|
|
/**
|
|
* Karenz zwischen Pivot und Aktivitäts-Eintrag: beide entstehen im selben
|
|
* Request, der Pivot wenige Millisekunden vorher.
|
|
*/
|
|
private const EVENT_TOLERANCE_SECONDS = 60;
|
|
|
|
public function handle(ActivityLog $activityLog): int
|
|
{
|
|
$rows = $this->assignmentRows(Carbon::parse((string) $this->option('since'))->startOfDay());
|
|
$foreign = $rows->reject(fn (object $row): bool => $row->allowed)->values();
|
|
|
|
$this->info(sprintf(
|
|
'%d über die API-Zuordnung entstandene Mitgliedschaft(en), davon %d ohne Freischaltung.',
|
|
$rows->count(),
|
|
$foreign->count(),
|
|
));
|
|
|
|
if ($rows->isNotEmpty()) {
|
|
$this->table(
|
|
['Firma', 'Firmenname', 'User', 'E-Mail', 'Herkunft', 'Angelegt', 'Freigeschaltet'],
|
|
$rows->take(100)->map(fn (object $row): array => $this->tableRow($row))->all(),
|
|
);
|
|
}
|
|
|
|
if (! $this->option('remove')) {
|
|
if ($foreign->isNotEmpty()) {
|
|
$this->line('Entfernen mit --remove (zeigt vorher die betroffenen Zeilen).');
|
|
}
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
if ($foreign->isEmpty()) {
|
|
$this->info('Nichts zu entfernen.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$this->warn(sprintf('Zu entfernen (%d):', $foreign->count()));
|
|
$this->table(
|
|
['Firma', 'Firmenname', 'User', 'E-Mail', 'Herkunft', 'Angelegt', 'Freigeschaltet'],
|
|
$foreign->map(fn (object $row): array => $this->tableRow($row))->all(),
|
|
);
|
|
|
|
if (! $this->option('force') && ! $this->confirm(sprintf('%d Mitgliedschaft(en) entfernen?', $foreign->count()))) {
|
|
$this->line('Abgebrochen, nichts geändert.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$removed = 0;
|
|
|
|
foreach ($foreign as $row) {
|
|
$deleted = DB::table('company_user')
|
|
->where('company_id', $row->company_id)
|
|
->where('user_id', $row->user_id)
|
|
->where('role', 'member')
|
|
->delete();
|
|
|
|
if ($deleted === 0) {
|
|
continue;
|
|
}
|
|
|
|
$removed++;
|
|
|
|
$activityLog->record(
|
|
ActivityType::Correction,
|
|
sprintf('API-Mitgliedschaft von User #%d (%s) an Firma #%d „%s“ entfernt (Audit R-01)', $row->user_id, $row->email, $row->company_id, $row->company_name),
|
|
subject: Company::withoutGlobalScope(PortalScope::class)->find($row->company_id),
|
|
meta: ['api_assignment_removed' => true, 'user_id' => $row->user_id, 'granted_via' => $row->granted_via],
|
|
actorLabel: 'console',
|
|
);
|
|
}
|
|
|
|
$this->info(sprintf('%d Mitgliedschaft(en) entfernt.', $removed));
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* Pivots aus der API-Zuordnung: aus dem Aktivitäts-Log (Altfälle ohne
|
|
* Herkunft) und mit `granted_via = api` (ab dem Fix).
|
|
*
|
|
* @return Collection<int, object>
|
|
*/
|
|
private function assignmentRows(Carbon $since): Collection
|
|
{
|
|
/** @var array<string, Carbon> $firstEvents "company:user" => erste Zuordnung */
|
|
$firstEvents = [];
|
|
|
|
ActivityEvent::query()
|
|
->where('type', ActivityType::Correction->value)
|
|
->where('subject_type', (new Company)->getMorphClass())
|
|
->where('description', 'like', 'API-Firma %')
|
|
->where('occurred_at', '>=', $since)
|
|
->whereNotNull('user_id')
|
|
->orderBy('id')
|
|
->select(['id', 'user_id', 'subject_id', 'occurred_at'])
|
|
->chunkById(1000, function (Collection $events) use (&$firstEvents): void {
|
|
foreach ($events as $event) {
|
|
$key = $event->subject_id.':'.$event->user_id;
|
|
$firstEvents[$key] ??= $event->occurred_at;
|
|
}
|
|
});
|
|
|
|
$pairs = collect(array_keys($firstEvents))->map(fn (string $key): array => array_map('intval', explode(':', $key)));
|
|
|
|
$rows = collect();
|
|
|
|
foreach ($pairs->chunk(500) as $chunk) {
|
|
$rows = $rows->merge($this->pivotQuery()
|
|
->where(function ($query) use ($chunk): void {
|
|
foreach ($chunk as [$companyId, $userId]) {
|
|
$query->orWhere(fn ($pair) => $pair->where('company_user.company_id', $companyId)->where('company_user.user_id', $userId));
|
|
}
|
|
})
|
|
->get());
|
|
}
|
|
|
|
$rows = $rows->merge($this->pivotQuery()->where('company_user.granted_via', ApiNewsroomAssignment::GRANTED_VIA)->get());
|
|
|
|
return $rows
|
|
->unique(fn (object $row): string => $row->company_id.':'.$row->user_id)
|
|
->filter(function (object $row) use ($firstEvents): bool {
|
|
if ((int) $row->owner_user_id === (int) $row->user_id) {
|
|
return false;
|
|
}
|
|
|
|
if ($row->granted_via === ApiNewsroomAssignment::GRANTED_VIA) {
|
|
return true;
|
|
}
|
|
|
|
// Schon vor der ersten Zuordnung Mitglied: nicht durch die API entstanden.
|
|
$first = $firstEvents[$row->company_id.':'.$row->user_id] ?? null;
|
|
|
|
return $first !== null
|
|
&& $row->created_at !== null
|
|
&& Carbon::parse($row->created_at)->greaterThanOrEqualTo($first->copy()->subSeconds(self::EVENT_TOLERANCE_SECONDS));
|
|
})
|
|
->map(function (object $row): object {
|
|
$row->allowed = ApiNewsroomAssignment::isAllowed((int) $row->user_id);
|
|
|
|
return $row;
|
|
})
|
|
->sortBy(['company_id', 'user_id'])
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* `member`-Pivots samt Firma und Nutzer — `responsible`/`owner` stammen
|
|
* nie aus der API-Zuordnung (eine Hochstufung hat eine E-Mail-Grundlage).
|
|
*/
|
|
private function pivotQuery(): Builder
|
|
{
|
|
return DB::table('company_user')
|
|
->join('companies', 'companies.id', '=', 'company_user.company_id')
|
|
->join('users', 'users.id', '=', 'company_user.user_id')
|
|
->where('company_user.role', 'member')
|
|
->select([
|
|
'company_user.company_id', 'company_user.user_id', 'company_user.granted_via', 'company_user.created_at',
|
|
'companies.name as company_name', 'companies.owner_user_id', 'users.email',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function tableRow(object $row): array
|
|
{
|
|
return [
|
|
(string) $row->company_id,
|
|
mb_strimwidth((string) $row->company_name, 0, 40, '…'),
|
|
(string) $row->user_id,
|
|
(string) $row->email,
|
|
(string) $row->granted_via,
|
|
(string) $row->created_at,
|
|
$row->allowed ? 'ja' : 'nein',
|
|
];
|
|
}
|
|
}
|