vcard4reseller/backend/src/State/TenantStampProcessor.php
Thomas Peterson ebaf509a2f Fundament: Symfony+API-Platform-Backend & Vue-SPA (Phase 0–2)
Stack & Setup
- Dockerisierte Dev-Umgebung (PHP 8.4-FPM, Nginx, MariaDB 11.4)
- Symfony 7.4 + API Platform 4.3, Doctrine ORM, LexikJWT, Messenger
- Vue 3 + TS (Vite), Vue Router, Pinia, Axios

Kern-Domäne & Auth
- Entitäten: User, PlatformPlan, Reseller, Company, Domain, Location,
  Employee, ContactLink (UUIDv7)
- JWT-Login (/api/login), Rollen-Hierarchie, /api/me
- Mandantentrennung via API-Platform-Query-Extension (Lesen) +
  TenantStampProcessor (Schreiben)

Öffentliche Profile (SSR)
- Profil-Landingpage, vCard-Download, QR-Code im Marken-Look
- Stabiler NFC/QR-Kurz-Link /t/{code} -> Redirect aufs aktuelle Profil
- Firmenspezifisches Branding (Farben/Logo) auf der Profilseite

Verwaltungsoberfläche (SPA)
- Brand-Look (dunkle Sidebar), rollenbasierte Navigation
- Dashboard, Reseller (+Provisioning), Firmen, Mitarbeiter, Standorte,
  Domains, Design/Branding mit Live-Vorschau

Konzept & Doku: docs/KONZEPT.md (inkl. Wallet/Sync §12), README.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 11:12:53 +02:00

103 lines
3.6 KiB
PHP

<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Company;
use App\Entity\ContactLink;
use App\Entity\Domain;
use App\Entity\Employee;
use App\Entity\Location;
use App\Security\TenantContext;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* Stempelt beim Schreiben (POST/PATCH) den Mandanten automatisch aus dem
* eingeloggten Kontext und verhindert Cross-Tenant-Referenzen.
* Plattform-Admins sind nicht eingeschränkt.
*
* Dekoriert den Doctrine-Persist-Processor von API Platform.
*/
#[AsDecorator('api_platform.doctrine.orm.state.persist_processor')]
final class TenantStampProcessor implements ProcessorInterface
{
public function __construct(
#[AutowireDecorated]
private readonly ProcessorInterface $inner,
private readonly TenantContext $tenant,
) {
}
/**
* @param array<string, mixed> $uriVariables
* @param array<string, mixed> $context
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
$this->stampAndValidate($data);
return $this->inner->process($data, $operation, $uriVariables, $context);
}
private function stampAndValidate(mixed $data): void
{
// Plattform-Admins dürfen mandantenübergreifend schreiben
if ($this->tenant->isPlatformAdmin()) {
return;
}
$reseller = $this->tenant->getReseller();
if (null === $reseller && $this->isTenantOwned($data)) {
throw new AccessDeniedHttpException('Kein Mandantenkontext.');
}
match (true) {
$data instanceof Company => $data->setReseller($reseller),
$data instanceof Location,
$data instanceof Domain => $this->assertCompany($data->getCompany()),
$data instanceof Employee => $this->assertEmployee($data),
$data instanceof ContactLink => $this->assertCompany($data->getEmployee()->getCompany()),
default => null,
};
}
private function assertEmployee(Employee $employee): void
{
$this->assertCompany($employee->getCompany());
// Standort muss zur selben Firma gehören
$location = $employee->getLocation();
if (null !== $location && !$location->getCompany()->getId()->equals($employee->getCompany()->getId())) {
throw new AccessDeniedHttpException('Standort gehört nicht zur Firma.');
}
}
/** Prüft, dass die referenzierte Firma im Mandanten des Nutzers liegt. */
private function assertCompany(Company $company): void
{
$reseller = $this->tenant->getReseller();
if (null === $reseller || null === $company->getReseller()
|| !$company->getReseller()->getId()->equals($reseller->getId())) {
throw new AccessDeniedHttpException('Firma gehört nicht zum eigenen Reseller.');
}
// Firmen-Admins dürfen nur in ihrer eigenen Firma schreiben
$own = $this->tenant->getCompany();
if (null !== $own && !$company->getId()->equals($own->getId())) {
throw new AccessDeniedHttpException('Schreibzugriff nur auf die eigene Firma.');
}
}
private function isTenantOwned(mixed $data): bool
{
return $data instanceof Company
|| $data instanceof Location
|| $data instanceof Domain
|| $data instanceof Employee
|| $data instanceof ContactLink;
}
}