vcard4reseller/backend/src/Controller/ImpersonationController.php
Thomas Peterson ae9936586b Rechte: 'Arbeiten als' (Impersonation, nur absteigend)
- ImpersonationController POST /api/impersonate/{id}: gibt JWT für Ziel-
  Mitarbeiter aus (imp-Claim für Audit); nur niedrigere Ebene + eigener
  Mandanten-Teilbaum (RoleService.levelOfRoles)
- Frontend: auth-Store impersonate/stopImpersonation (Original-Token gesichert),
  'Arbeiten als'-Buttons in der Logins-Übersicht (nur erlaubte Ziele),
  Impersonation-Banner mit 'Beenden' im Layout

Verifiziert: Admin→Reseller/Firma/Mitarbeiter, Eskalation/Cross-Tenant→403,
Kontextwechsel + Banner im Browser.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 17:40:37 +02:00

86 lines
3.1 KiB
PHP

<?php
namespace App\Controller;
use App\Entity\Employee;
use App\Security\TenantContext;
use App\Service\RoleService;
use Doctrine\ORM\EntityManagerInterface;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* „Arbeiten als" (Impersonation), nur absteigend & im eigenen Mandanten-Teilbaum:
* gibt ein JWT für den Ziel-Mitarbeiter aus (mit imp-Claim für Audit).
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class ImpersonationController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly RoleService $roles,
private readonly TenantContext $tenant,
private readonly JWTTokenManagerInterface $jwt,
private readonly Security $security,
) {
}
#[Route('/api/impersonate/{id}', name: 'impersonate', methods: ['POST'])]
public function __invoke(string $id): JsonResponse
{
$target = $this->em->getRepository(Employee::class)->find(Uuid::fromString($id));
if (!$target instanceof Employee) {
throw new NotFoundHttpException('Mitarbeiter nicht gefunden.');
}
if (!$target->hasLogin()) {
throw new BadRequestHttpException('Dieser Mitarbeiter hat kein Login.');
}
$this->assertInScope($target);
// Nur absteigend: Ziel-Ebene muss unter der eigenen liegen
if ($this->roles->levelOfRoles($target->getRoles()) >= $this->roles->actorLevel()) {
throw new AccessDeniedHttpException('Nur als niedrigere Ebene möglich.');
}
$actor = $this->security->getUser();
$impersonator = $actor instanceof Employee ? $actor->getUserIdentifier() : '';
$token = $this->jwt->createFromPayload($target, ['imp' => $impersonator]);
return new JsonResponse([
'token' => $token,
'actingAs' => [
'name' => trim($target->getFirstName().' '.$target->getLastName()),
'email' => $target->getLoginEmail(),
],
]);
}
private function assertInScope(Employee $target): void
{
if ($this->tenant->isPlatformAdmin()) {
return;
}
if (null !== $company = $this->tenant->getCompany()) {
if (!$target->getCompany()->getId()->equals($company->getId())) {
throw new AccessDeniedHttpException('Außerhalb der eigenen Firma.');
}
return;
}
if (null !== $reseller = $this->tenant->getReseller()) {
if ($target->getCompany()->getReseller()?->getId()->equals($reseller->getId()) !== true) {
throw new AccessDeniedHttpException('Außerhalb des eigenen Resellers.');
}
}
}
}