presseportale/app/Services/Company/ApiNewsroomAssignment.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

138 lines
5.5 KiB
PHP

<?php
namespace App\Services\Company;
use App\Enums\ActivityType;
use App\Enums\CompanyAddressClass;
use App\Enums\Portal;
use App\Models\Company;
use App\Models\CompanyEmailGroup;
use App\Models\User;
use App\Scopes\PortalScope;
use App\Services\ActivityLog;
/**
* Laufende Zuordnung neuer API-Firmen zu einem bestehenden Newsroom
* (Roadmap 1a.4).
*
* Der Alt-Vertrag der API legt vor jeder Meldung eine Firma an — connektar
* schickt je Meldung `POST /service/users/me/companies`. Ohne diese Klasse
* entsteht so je Meldung ein neuer Newsroom, und die Zusammenführung 1a.3
* müsste nachts nachlaufen. Hier wird stattdessen VOR dem Anlegen geprüft,
* ob unter derselben Firmen-E-Mail schon eine Firma mit demselben (oder
* normalisiert demselben) Namen steht. Dann bekommt der Aufrufer deren ID
* zurück — der Vertrag (201 + Location) bleibt, nur die Firma ist die alte.
*
* Dieselben Regeln wie die Klassifizierung (1a.2): Schlüssel ist die
* Firmen-E-Mail, „derselbe Name" ist `CompanyAddressClassifier::namesMatch()`.
* Unter einer Sammeladresse (Agentur) wird nur die Firma mit passendem Namen
* wiederverwendet — ein neuer Kunde der Agentur bekommt wie bisher eine neue
* Firma. Unter einer nie-veröffentlicht-Adresse wird nie zugeordnet.
*
* Berechtigung (Umsetzungsaudit 29.09.2026, R-01): Firmenname und
* Kontaktadresse stehen öffentlich im Newsroom. Zugeordnet wird deshalb nur,
* wenn der Token-Nutzer der Firma schon angehört (Owner oder Pivot) oder als
* Einlieferer freigeschaltet ist (`newsroom.api_assignment.allowed_user_ids`).
* Sonst entsteht wie vor 1a.4 eine neue Firma — ein fremdes Token wird nie
* Mitglied eines fremden Newsrooms.
*/
class ApiNewsroomAssignment
{
/**
* Herkunft des Pivots (`company_user.granted_via`) einer API-Zuordnung.
*/
public const GRANTED_VIA = 'api';
public function __construct(
private readonly CompanyAddressClassifier $classifier,
private readonly ActivityLog $activityLog,
) {}
/**
* Bestehende Firma für diese Anfrage — oder null, dann legt der Aufrufer
* wie bisher eine neue an.
*
* Nebenwirkungen bei Treffer: Ein freigeschalteter API-Nutzer wird als
* Mitglied (`granted_via = api`) an die Firma gehängt (sonst dürfte er
* dort keine Meldung anlegen), und liegt die Firma auf dem anderen Portal,
* wird sie `both` — ihre Meldungen behalten ihr Portal, die neue bekommt
* das angefragte. Ohne Zugehörigkeit und ohne Freischaltung: null.
*/
public function assign(User $user, ?string $email, string $name, Portal $portal): ?Company
{
$key = Company::emailKey($email);
if ($key === null) {
return null;
}
$group = CompanyEmailGroup::forEmail($key);
if ($group?->effectiveClassification() === CompanyAddressClass::NeverPublished) {
return null;
}
// Schlüssel UND Rohwert abfragen: Der Index auf `email` greift so auf
// jeder Datenbank, und die Kleinschreibung entscheidet der Filter
// danach — nicht die Kollation.
$matches = Company::query()
->withoutGlobalScope(PortalScope::class)
->whereIn('email', array_values(array_unique([$key, trim((string) $email)])))
->whereNull('gone_at')
->orderBy('id')
->get()
->filter(fn (Company $company): bool => Company::emailKey($company->email) === $key
&& $this->classifier->namesMatch($company->name, $name));
if ($matches->isEmpty()) {
return null;
}
// Mehrere Treffer gibt es nach 1a.3 nur noch als Slug-Kollision oder
// Handentscheidung. Dann die, die auf dem angefragten Portal schon
// erreichbar ist, sonst die älteste.
$company = $matches->first(fn (Company $company): bool => $company->portal === $portal || $company->portal === Portal::Both)
?? $matches->first();
// R-01: vor jeder Nebenwirkung (Pivot, Portal-Erweiterung) prüfen.
$belongs = $company->owner_user_id === $user->id || $company->users()->whereKey($user->id)->exists();
if (! $belongs && ! self::isAllowed((int) $user->getKey())) {
return null;
}
if ($portal !== Portal::Both && $company->portal !== Portal::Both && $company->portal !== $portal) {
if ($company->slugTakenOnOtherPortals()) {
return null;
}
$company->forceFill(['portal' => Portal::Both->value])->save();
}
if (! $belongs) {
// Eigene Herkunft: der Login-Sync nimmt nur `auto`-Zuordnungen
// zurück, und die Bestandsprüfung (company-memberships:api-assignments)
// findet diese Pivots wieder.
$company->users()->attach($user->id, ['role' => 'member', 'granted_via' => self::GRANTED_VIA]);
}
$this->activityLog->record(
ActivityType::Correction,
sprintf('API-Firma „%s“ dem bestehenden Newsroom #%d „%s“ zugeordnet (%s)', $name, $company->getKey(), $company->name, $key),
$user,
$company,
['api_newsroom_assignment' => true, 'requested_name' => $name, 'portal' => $portal->value],
actorLabel: 'api',
);
return $company;
}
/**
* Freigeschalteter Einlieferer (Allowlist in config/newsroom.php).
*/
public static function isAllowed(int $userId): bool
{
return in_array($userId, array_map('intval', (array) config('newsroom.api_assignment.allowed_user_ids', [])), true);
}
}