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:
parent
46a75f859b
commit
25370ebfbc
217
backend/src/Controller/UserAdminController.php
Normal file
217
backend/src/Controller/UserAdminController.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
112
backend/src/Service/RoleService.php
Normal file
112
backend/src/Service/RoleService.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user