From ae9936586bba0e62a04d4577be18ec96946b5e88 Mon Sep 17 00:00:00 2001 From: Thomas Peterson Date: Mon, 1 Jun 2026 17:40:37 +0200 Subject: [PATCH] Rechte: 'Arbeiten als' (Impersonation, nur absteigend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../Controller/ImpersonationController.php | 85 +++++++++++++++++++ backend/src/Service/RoleService.php | 13 +++ frontend/src/layouts/DashboardLayout.vue | 11 +++ frontend/src/stores/auth.ts | 50 +++++++++-- frontend/src/views/UsersView.vue | 20 ++++- 5 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 backend/src/Controller/ImpersonationController.php diff --git a/backend/src/Controller/ImpersonationController.php b/backend/src/Controller/ImpersonationController.php new file mode 100644 index 0000000..3f137ca --- /dev/null +++ b/backend/src/Controller/ImpersonationController.php @@ -0,0 +1,85 @@ +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.'); + } + } + } +} diff --git a/backend/src/Service/RoleService.php b/backend/src/Service/RoleService.php index 42e7b40..9a9d1de 100644 --- a/backend/src/Service/RoleService.php +++ b/backend/src/Service/RoleService.php @@ -52,6 +52,19 @@ final class RoleService return 0; } + /** Höchste Ebene aus einer Rollenliste (z. B. eines Ziel-Mitarbeiters). */ + public function levelOfRoles(array $roles): int + { + $level = 0; + foreach (self::GROUPS as $g) { + if (in_array($g['role'], $roles, true)) { + $level = max($level, $g['level']); + } + } + + return $level; + } + /** Rechtegruppen, die der Akteur vergeben darf (≤ eigene Ebene). */ public function assignableGroups(): array { diff --git a/frontend/src/layouts/DashboardLayout.vue b/frontend/src/layouts/DashboardLayout.vue index 586bceb..dad0db0 100644 --- a/frontend/src/layouts/DashboardLayout.vue +++ b/frontend/src/layouts/DashboardLayout.vue @@ -29,6 +29,11 @@ function logout() { auth.logout() router.push('/login') } + +async function stopImpersonation() { + await auth.stopImpersonation() + router.push('/app') +}