Rechte: delegierte Benutzerverwaltung (Backend)

- RoleService: Ebenen-Modell + assignableGroups + assertCanAssign
  (Rolle <= eigene Ebene UND nur eigener Mandanten-Teilbaum)
- UserAdminController: /api/users CRUD + /assignable-groups, scope-gefiltert,
  mehrere Logins pro Ebene, Mitarbeiter-Login via employeeId
- Schutz gegen Privilege-Escalation & Cross-Tenant verifiziert (403)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-06-01 15:15:03 +02:00
parent 46a75f859b
commit 25370ebfbc
2 changed files with 329 additions and 0 deletions

View File

@ -0,0 +1,217 @@
<?php
namespace App\Controller;
use App\Entity\Company;
use App\Entity\Employee;
use App\Entity\Reseller;
use App\Entity\User;
use App\Security\TenantContext;
use App\Service\RoleService;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Uid\Uuid;
/**
* Benutzerverwaltung mit delegierter Rechtevergabe (KONZEPT §2).
* Ab Firmen-Admin; jede Rollenvergabe wird vom RoleService scope-/level-geprüft.
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
#[Route('/api/users')]
final class UserAdminController
{
private const ROLE_TO_GROUP = [
User::ROLE_PLATFORM_ADMIN => 'platform_admin',
User::ROLE_RESELLER_ADMIN => 'reseller_admin',
User::ROLE_COMPANY_ADMIN => 'company_admin',
User::ROLE_EMPLOYEE => 'employee',
];
public function __construct(
private readonly EntityManagerInterface $em,
private readonly RoleService $roles,
private readonly TenantContext $tenant,
private readonly UserPasswordHasherInterface $hasher,
private readonly Security $security,
) {
}
#[Route('/assignable-groups', name: 'users_assignable_groups', methods: ['GET'])]
public function assignableGroups(): JsonResponse
{
return new JsonResponse(['groups' => $this->roles->assignableGroups()]);
}
#[Route('', name: 'users_list', methods: ['GET'])]
public function list(): JsonResponse
{
$qb = $this->em->getRepository(User::class)->createQueryBuilder('u');
// Scope: Plattform sieht alle; Reseller nur eigenen Reseller; Firma nur eigene Firma
if (!$this->tenant->isPlatformAdmin()) {
if (null !== $company = $this->tenant->getCompany()) {
$qb->andWhere('u.company = :company')->setParameter('company', $company->getId(), 'uuid');
} elseif (null !== $reseller = $this->tenant->getReseller()) {
$qb->andWhere('u.reseller = :reseller')->setParameter('reseller', $reseller->getId(), 'uuid');
}
}
$users = array_map($this->serialize(...), $qb->getQuery()->getResult());
return new JsonResponse(['member' => $users, 'totalItems' => count($users)]);
}
#[Route('', name: 'users_create', methods: ['POST'])]
public function create(Request $request): JsonResponse
{
$d = json_decode($request->getContent(), true) ?? [];
$email = trim((string) ($d['email'] ?? ''));
$password = (string) ($d['password'] ?? '');
$group = (string) ($d['group'] ?? '');
if ('' === $email || '' === $password || '' === $group) {
throw new BadRequestHttpException('email, password und group sind erforderlich.');
}
[$reseller, $company, $employee] = $this->resolveTarget($d, $group);
$this->roles->assertCanAssign($group, $reseller, $company);
$user = (new User())
->setEmail($email)
->setRoles([$this->roles->roleForGroup($group)])
->setReseller($reseller)
->setCompany($company)
->setEmployee($employee);
$user->setPassword($this->hasher->hashPassword($user, $password));
try {
$this->em->persist($user);
$this->em->flush();
} catch (UniqueConstraintViolationException) {
throw new BadRequestHttpException('E-Mail bereits vergeben.');
}
return new JsonResponse($this->serialize($user), 201);
}
#[Route('/{id}', name: 'users_update', methods: ['PATCH'])]
public function update(string $id, Request $request): JsonResponse
{
$user = $this->find($id);
$this->assertInScope($user);
$d = json_decode($request->getContent(), true) ?? [];
if (isset($d['group'])) {
$group = (string) $d['group'];
$this->roles->assertCanAssign($group, $user->getReseller(), $user->getCompany());
$user->setRoles([$this->roles->roleForGroup($group)]);
}
if (isset($d['status'])) {
$user->setStatus((string) $d['status']);
}
if (!empty($d['password'])) {
$user->setPassword($this->hasher->hashPassword($user, (string) $d['password']));
}
$this->em->flush();
return new JsonResponse($this->serialize($user));
}
#[Route('/{id}', name: 'users_delete', methods: ['DELETE'])]
public function delete(string $id): JsonResponse
{
$user = $this->find($id);
$this->assertInScope($user);
if ($this->security->getUser() === $user) {
throw new AccessDeniedHttpException('Eigenen Zugang nicht löschbar.');
}
$this->em->remove($user);
$this->em->flush();
return new JsonResponse(null, 204);
}
/** @return array{0: ?Reseller, 1: ?Company, 2: ?Employee} */
private function resolveTarget(array $d, string $group): array
{
$employee = !empty($d['employeeId'])
? $this->em->getRepository(Employee::class)->find(Uuid::fromString($d['employeeId']))
: null;
$company = $employee?->getCompany()
?? (!empty($d['companyId']) ? $this->em->getRepository(Company::class)->find(Uuid::fromString($d['companyId'])) : null);
$reseller = $company?->getReseller()
?? (!empty($d['resellerId']) ? $this->em->getRepository(Reseller::class)->find(Uuid::fromString($d['resellerId'])) : null);
// Reseller-Admin legt Peer/Untergebene im eigenen Reseller an, ohne resellerId
if (null === $reseller && null === $company && 'reseller_admin' === $group) {
$reseller = $this->tenant->getReseller();
}
// Firmen-Admin ohne Angabe → eigene Firma
if (null === $company && in_array($group, ['company_admin', 'employee'], true)) {
$company = $this->tenant->getCompany();
$reseller = $company?->getReseller() ?? $reseller;
}
return [$reseller, $company, $employee];
}
private function find(string $id): User
{
$user = $this->em->getRepository(User::class)->find(Uuid::fromString($id));
if (!$user instanceof User) {
throw new NotFoundHttpException('Benutzer nicht gefunden.');
}
return $user;
}
/** Ziel-Benutzer muss im Mandanten-Teilbaum des Akteurs liegen. */
private function assertInScope(User $user): void
{
if ($this->tenant->isPlatformAdmin()) {
return;
}
if (null !== $company = $this->tenant->getCompany()) {
if (null === $user->getCompany() || !$user->getCompany()->getId()->equals($company->getId())) {
throw new AccessDeniedHttpException('Außerhalb der eigenen Firma.');
}
return;
}
if (null !== $reseller = $this->tenant->getReseller()) {
if (null === $user->getReseller() || !$user->getReseller()->getId()->equals($reseller->getId())) {
throw new AccessDeniedHttpException('Außerhalb des eigenen Resellers.');
}
}
}
private function serialize(User $u): array
{
$group = 'employee';
foreach (self::ROLE_TO_GROUP as $role => $g) {
if (in_array($role, $u->getRoles(), true)) {
$group = $g;
break;
}
}
return [
'id' => (string) $u->getId(),
'email' => $u->getEmail(),
'group' => $group,
'status' => $u->getStatus(),
'reseller' => $u->getReseller() ? ['id' => (string) $u->getReseller()->getId(), 'name' => $u->getReseller()->getName()] : null,
'company' => $u->getCompany() ? ['id' => (string) $u->getCompany()->getId(), 'name' => $u->getCompany()->getName()] : null,
'employeeId' => $u->getEmployee() ? (string) $u->getEmployee()->getId() : null,
];
}
}

View File

@ -0,0 +1,112 @@
<?php
namespace App\Service;
use App\Entity\Company;
use App\Entity\Reseller;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* Setzt das delegierte Rechte-Konzept durch (KONZEPT §2): Ein Akteur darf nur
* Rollen der eigenen Ebene und nur im eigenen Mandanten-Teilbaum vergeben.
*/
final class RoleService
{
/** Rechtegruppen-Schlüssel (UI) → Symfony-Rolle + Ebene (höher = mehr Rechte). */
private const GROUPS = [
'platform_admin' => ['role' => User::ROLE_PLATFORM_ADMIN, 'level' => 3],
'reseller_admin' => ['role' => User::ROLE_RESELLER_ADMIN, 'level' => 2],
'company_admin' => ['role' => User::ROLE_COMPANY_ADMIN, 'level' => 1],
'employee' => ['role' => User::ROLE_EMPLOYEE, 'level' => 0],
];
public function __construct(private readonly Security $security)
{
}
public function isValidGroup(string $group): bool
{
return isset(self::GROUPS[$group]);
}
public function roleForGroup(string $group): string
{
return self::GROUPS[$group]['role'];
}
/** Höchste Ebene des aktuell eingeloggten Akteurs (respektiert Rollen-Hierarchie). */
public function actorLevel(): int
{
if ($this->security->isGranted(User::ROLE_PLATFORM_ADMIN)) {
return 3;
}
if ($this->security->isGranted(User::ROLE_RESELLER_ADMIN)) {
return 2;
}
if ($this->security->isGranted(User::ROLE_COMPANY_ADMIN)) {
return 1;
}
return 0;
}
/** Rechtegruppen, die der Akteur vergeben darf (≤ eigene Ebene). */
public function assignableGroups(): array
{
$level = $this->actorLevel();
return array_keys(array_filter(self::GROUPS, fn ($g) => $g['level'] <= $level));
}
/**
* Prüft, ob der Akteur diese Rechtegruppe für den Ziel-Mandanten vergeben darf.
* Wirft AccessDenied bei Verstoß (Schutz vor Privilege-Escalation).
*/
public function assertCanAssign(string $group, ?Reseller $targetReseller, ?Company $targetCompany): void
{
if (!$this->isValidGroup($group)) {
throw new AccessDeniedHttpException('Unbekannte Rechtegruppe.');
}
$actorLevel = $this->actorLevel();
$targetLevel = self::GROUPS[$group]['level'];
// Regel 1: nie über die eigene Ebene hinaus
if ($targetLevel > $actorLevel) {
throw new AccessDeniedHttpException('Keine Berechtigung, diese Rechtegruppe zu vergeben.');
}
if ($actorLevel < 1) {
throw new AccessDeniedHttpException('Keine Berechtigung zur Benutzerverwaltung.');
}
// Plattform-Admin: keine Mandantengrenze
if ($actorLevel >= 3) {
return;
}
/** @var User $actor */
$actor = $this->security->getUser();
// Regel 2: nur im eigenen Mandanten-Teilbaum
if ($actorLevel === 2) { // Reseller-Admin
$actorReseller = $actor->getReseller();
$effectiveReseller = $targetCompany?->getReseller() ?? $targetReseller;
if (null === $actorReseller || null === $effectiveReseller
|| !$effectiveReseller->getId()->equals($actorReseller->getId())) {
throw new AccessDeniedHttpException('Außerhalb des eigenen Resellers.');
}
return;
}
if ($actorLevel === 1) { // Firmen-Admin
$actorCompany = $actor->getCompany();
if (null === $actorCompany || null === $targetCompany
|| !$targetCompany->getId()->equals($actorCompany->getId())) {
throw new AccessDeniedHttpException('Nur die eigene Firma.');
}
}
}
}