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:
parent
bcc06e697b
commit
ae9936586b
85
backend/src/Controller/ImpersonationController.php
Normal file
85
backend/src/Controller/ImpersonationController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user