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>
103 lines
3.6 KiB
PHP
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;
|
|
}
|
|
}
|