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;
|
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). */
|
/** Rechtegruppen, die der Akteur vergeben darf (≤ eigene Ebene). */
|
||||||
public function assignableGroups(): array
|
public function assignableGroups(): array
|
||||||
{
|
{
|
||||||
|
|||||||
@ -29,6 +29,11 @@ function logout() {
|
|||||||
auth.logout()
|
auth.logout()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function stopImpersonation() {
|
||||||
|
await auth.stopImpersonation()
|
||||||
|
router.push('/app')
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -48,6 +53,10 @@ function logout() {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="main">
|
<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">
|
<header class="topbar">
|
||||||
<div class="topbar__ctx">
|
<div class="topbar__ctx">
|
||||||
<span class="muted">Mandant</span>
|
<span class="muted">Mandant</span>
|
||||||
@ -82,6 +91,8 @@ function logout() {
|
|||||||
.navlink.active { background: var(--psc-orange); color: #fff; }
|
.navlink.active { background: var(--psc-orange); color: #fff; }
|
||||||
|
|
||||||
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
.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 {
|
.topbar {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 1rem 1.6rem; background: #fff; border-bottom: 1px solid var(--line);
|
padding: 1rem 1.6rem; background: #fff; border-bottom: 1px solid var(--line);
|
||||||
|
|||||||
@ -15,17 +15,28 @@ export interface CurrentUser {
|
|||||||
company: TenantRef | null
|
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', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const token = ref<string | null>(localStorage.getItem('token'))
|
const token = ref<string | null>(localStorage.getItem('token'))
|
||||||
const user = ref<CurrentUser | null>(
|
const user = ref<CurrentUser | null>(JSON.parse(localStorage.getItem('user') || '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 isAuthenticated = computed(() => !!token.value)
|
||||||
const roles = computed(() => user.value?.roles ?? [])
|
const roles = computed(() => user.value?.roles ?? [])
|
||||||
const isPlatformAdmin = computed(() => roles.value.includes('ROLE_PLATFORM_ADMIN'))
|
const isPlatformAdmin = computed(() => roles.value.includes('ROLE_PLATFORM_ADMIN'))
|
||||||
const isResellerAdmin = computed(() => roles.value.includes('ROLE_RESELLER_ADMIN'))
|
const isResellerAdmin = computed(() => roles.value.includes('ROLE_RESELLER_ADMIN'))
|
||||||
const isCompanyAdmin = computed(() => roles.value.includes('ROLE_COMPANY_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) {
|
async function login(email: string, password: string) {
|
||||||
const { data } = await client.post('/login', { email, password })
|
const { data } = await client.post('/login', { email, password })
|
||||||
@ -40,16 +51,45 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
localStorage.setItem('user', JSON.stringify(data))
|
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() {
|
function logout() {
|
||||||
token.value = null
|
token.value = null
|
||||||
user.value = null
|
user.value = null
|
||||||
|
actingAs.value = null
|
||||||
|
originalToken.value = null
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
localStorage.removeItem('user')
|
localStorage.removeItem('user')
|
||||||
|
localStorage.removeItem('token_original')
|
||||||
|
localStorage.removeItem('actingAs')
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token, user, isAuthenticated, roles,
|
token, user, actingAs, isAuthenticated, roles, level,
|
||||||
isPlatformAdmin, isResellerAdmin, isCompanyAdmin,
|
isPlatformAdmin, isResellerAdmin, isCompanyAdmin, isImpersonating,
|
||||||
login, fetchMe, logout,
|
login, fetchMe, impersonate, stopImpersonation, logout,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import client from '@/api/client'
|
import client from '@/api/client'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
interface LoginRow {
|
interface LoginRow {
|
||||||
employeeId: string; email: string; name: string; group: string
|
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',
|
platform_admin: 'Plattform-Admin', reseller_admin: 'Reseller-Admin',
|
||||||
company_admin: 'Firmen-Admin', employee: 'Mitarbeiter',
|
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 rows = ref<LoginRow[]>([])
|
||||||
const loading = ref(true)
|
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() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
rows.value = (await client.get('/users')).data.member
|
rows.value = (await client.get('/users')).data.member
|
||||||
@ -47,7 +62,10 @@ onMounted(load)
|
|||||||
<td>{{ r.name }}</td>
|
<td>{{ r.name }}</td>
|
||||||
<td><span class="badge badge-role">{{ GROUP_LABEL[r.group] ?? r.group }}</span></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="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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user