diff --git a/backend/src/Controller/UserAdminController.php b/backend/src/Controller/UserAdminController.php new file mode 100644 index 0000000..ec59e86 --- /dev/null +++ b/backend/src/Controller/UserAdminController.php @@ -0,0 +1,217 @@ + '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, + ]; + } +} diff --git a/backend/src/Service/RoleService.php b/backend/src/Service/RoleService.php new file mode 100644 index 0000000..b42558f --- /dev/null +++ b/backend/src/Service/RoleService.php @@ -0,0 +1,112 @@ + ['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.'); + } + } + } +}