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>
This commit is contained in:
Thomas Peterson 2026-06-01 17:40:37 +02:00
parent bcc06e697b
commit ae9936586b
5 changed files with 173 additions and 6 deletions

View File

@ -0,0 +1,85 @@
<?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.');
}
}
}
}

View File

@ -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
{

View File

@ -29,6 +29,11 @@ function logout() {
auth.logout()
router.push('/login')
}
async function stopImpersonation() {
await auth.stopImpersonation()
router.push('/app')
}
</script>
<template>
@ -48,6 +53,10 @@ function logout() {
</aside>
<div class="main">
<div v-if="auth.isImpersonating" class="imp-banner">
<span>Du arbeitest als <strong>{{ auth.actingAs?.name }}</strong> ({{ auth.actingAs?.email }})</span>
<button class="btn btn-sm" @click="stopImpersonation">Beenden</button>
</div>
<header class="topbar">
<div class="topbar__ctx">
<span class="muted">Mandant</span>
@ -82,6 +91,8 @@ function logout() {
.navlink.active { background: var(--psc-orange); color: #fff; }
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.imp-banner { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: .55rem 1.6rem; background: var(--psc-orange); color: #fff; font-size: .9rem; font-weight: 600; }
.imp-banner .btn { background: #fff; color: var(--psc-orange-dark); }
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 1rem 1.6rem; background: #fff; border-bottom: 1px solid var(--line);

View File

@ -15,17 +15,28 @@ export interface CurrentUser {
company: TenantRef | null
}
const ROLE_LEVEL: Record<string, number> = {
ROLE_PLATFORM_ADMIN: 3,
ROLE_RESELLER_ADMIN: 2,
ROLE_COMPANY_ADMIN: 1,
ROLE_EMPLOYEE: 0,
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('token'))
const user = ref<CurrentUser | null>(
JSON.parse(localStorage.getItem('user') || 'null'),
const user = ref<CurrentUser | null>(JSON.parse(localStorage.getItem('user') || 'null'))
const actingAs = ref<{ name: string; email: string } | null>(
JSON.parse(localStorage.getItem('actingAs') || 'null'),
)
const originalToken = ref<string | null>(localStorage.getItem('token_original'))
const isAuthenticated = computed(() => !!token.value)
const roles = computed(() => user.value?.roles ?? [])
const isPlatformAdmin = computed(() => roles.value.includes('ROLE_PLATFORM_ADMIN'))
const isResellerAdmin = computed(() => roles.value.includes('ROLE_RESELLER_ADMIN'))
const isCompanyAdmin = computed(() => roles.value.includes('ROLE_COMPANY_ADMIN'))
const level = computed(() => Math.max(0, ...roles.value.map((r) => ROLE_LEVEL[r] ?? -1)))
const isImpersonating = computed(() => !!originalToken.value)
async function login(email: string, password: string) {
const { data } = await client.post('/login', { email, password })
@ -40,16 +51,45 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.setItem('user', JSON.stringify(data))
}
// „Arbeiten als" tauscht das Token gegen ein impersoniertes (absteigend, backend-geprüft)
async function impersonate(employeeId: string) {
const { data } = await client.post(`/impersonate/${employeeId}`)
if (!originalToken.value) {
originalToken.value = token.value ?? ''
localStorage.setItem('token_original', originalToken.value)
}
token.value = data.token
localStorage.setItem('token', data.token)
actingAs.value = data.actingAs
localStorage.setItem('actingAs', JSON.stringify(data.actingAs))
await fetchMe()
}
async function stopImpersonation() {
if (!originalToken.value) return
token.value = originalToken.value
localStorage.setItem('token', originalToken.value)
originalToken.value = null
localStorage.removeItem('token_original')
localStorage.removeItem('actingAs')
actingAs.value = null
await fetchMe()
}
function logout() {
token.value = null
user.value = null
actingAs.value = null
originalToken.value = null
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('token_original')
localStorage.removeItem('actingAs')
}
return {
token, user, isAuthenticated, roles,
isPlatformAdmin, isResellerAdmin, isCompanyAdmin,
login, fetchMe, logout,
token, user, actingAs, isAuthenticated, roles, level,
isPlatformAdmin, isResellerAdmin, isCompanyAdmin, isImpersonating,
login, fetchMe, impersonate, stopImpersonation, logout,
}
})

View File

@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import client from '@/api/client'
import { useAuthStore } from '@/stores/auth'
interface LoginRow {
employeeId: string; email: string; name: string; group: string
@ -12,10 +14,23 @@ const GROUP_LABEL: Record<string, string> = {
platform_admin: 'Plattform-Admin', reseller_admin: 'Reseller-Admin',
company_admin: 'Firmen-Admin', employee: 'Mitarbeiter',
}
const GROUP_LEVEL: Record<string, number> = {
platform_admin: 3, reseller_admin: 2, company_admin: 1, employee: 0,
}
const auth = useAuthStore()
const router = useRouter()
const rows = ref<LoginRow[]>([])
const loading = ref(true)
function canWorkAs(r: LoginRow) {
return GROUP_LEVEL[r.group] < auth.level && r.employeeId !== auth.user?.id
}
async function workAs(r: LoginRow) {
await auth.impersonate(r.employeeId)
router.push('/app')
}
async function load() {
loading.value = true
rows.value = (await client.get('/users')).data.member
@ -47,7 +62,10 @@ onMounted(load)
<td>{{ r.name }}</td>
<td><span class="badge badge-role">{{ GROUP_LABEL[r.group] ?? r.group }}</span></td>
<td class="muted">{{ r.company?.name ?? r.reseller?.name ?? 'Plattform' }}</td>
<td class="right"><button class="btn btn-ghost btn-sm" @click="revoke(r)">Login entfernen</button></td>
<td class="right">
<button v-if="canWorkAs(r)" class="btn btn-soft btn-sm" @click="workAs(r)">Arbeiten als</button>
<button class="btn btn-ghost btn-sm" @click="revoke(r)">Login entfernen</button>
</td>
</tr>
</tbody>
</table>