presseportale/app/Console/Commands/ReviewApiCompanyAssignments.php
Kevin Adametz 1ccb4a8479 auth/security: API-Firmenzuordnung nur für Mitglieder oder Allowlist, Google-Vorabübernahme geschlossen, gemeinsamer Login-Abschluss (Umsetzungsaudit 29.09.: R-01, K-04, K-07, K-08, B-10)
- 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>
2026-10-02 13:09:00 +00:00

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',
];
}
}