- 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>
86 lines
3.1 KiB
PHP
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.');
|
|
}
|
|
}
|
|
}
|
|
}
|