feat: pending roles from crm to be confirmed by superadmins

This commit is contained in:
Björn Fromme
2026-08-10 12:26:21 +02:00
parent 681734533f
commit b01c2e84c7
26 changed files with 907 additions and 150 deletions
+23 -21
View File
@@ -9,26 +9,28 @@ parameters:
teamer_inactive_period: '-2 years'
destinations:
- Schwendi
- Waldschlössli
- Spinabad
- Schweizerhaus
- Weißfluh
- Victoria
- Klein Tirol
- Jenatsch
- Lederer
- Steinachhof
- Val Thorens
- Les Deux Alpes
- Ranalt
- Jolimont
- Christiler
- Heuberghaus
- Silvana
- Rotbach
- Mitterlengau
# Houses, hotel code: name. The name is used both as a label and to match
# destination.hotel, the code to match destination.hotelCode.
houses:
AGR: 'Rotbach'
ASC: 'Christiler'
DGS: 'Spinabad'
DKI: 'Schwendi'
DKS: 'Schweizerhaus'
DPW: 'Waldschlössli'
DWW: 'Weißfluh'
KHH: 'Heuberghaus'
L2A: 'Les Deux Alpes'
LPJ: 'Jenatsch'
MVK: 'Klein Tirol'
PCJ: 'Jolimont'
PMV: 'Victoria'
SBW: 'Val Thorens'
SHM: 'Mitterlengau'
SRR: 'Ranalt'
SSL: 'Lederer'
SST: 'Steinachhof'
SVS: 'Silvana'
services:
_defaults:
@@ -37,7 +39,7 @@ services:
bind:
$tempDir: '%kernel.project_dir%/temp'
$logger: '@monolog.logger.myep'
$destinations: '%destinations%'
$houses: '%houses%'
$xmlExport: '@xml_export.storage'
$xmlDump: '@xml_dump.storage'
$teamerInactivePeriod: '%teamer_inactive_period%'
+79 -14
View File
@@ -19,18 +19,19 @@ class UserDataHandler
) {
}
public function collectRoles(CrmAttributesResponse $crmAttributes, ?string $preferredRole): array
/**
* Collects the roles to grant on initial user creation.
*
* Administrative roles are deliberately not importable: they may only be granted
* manually by a super admin, so that nobody can escalate their own privileges via
* the BusPro CRM. They are imported as pending markers instead, which grant nothing
* but mark the user for approval. ROLE_TEAMER carries no privileges of its own and
* is granted directly.
*/
public function collectRoles(CrmAttributesResponse $crmAttributes): array
{
// Collect user's roles from CRM attributes
$roles = [];
if ($crmAttributes->isAdmin() && (null === $preferredRole || 'admin' === $preferredRole)) {
$roles[] = 'ROLE_ADMIN';
} elseif ($crmAttributes->isManager() && (null === $preferredRole || 'manager' === $preferredRole)) {
$roles[] = 'ROLE_MANAGER';
} elseif ($crmAttributes->isHouseManager() && (null === $preferredRole || 'house_manager' === $preferredRole)) {
$roles[] = 'ROLE_HOUSE_MANAGER';
}
$roles = $this->collectPendingRoles($crmAttributes);
if ($crmAttributes->isTeamer()) {
$roles[] = 'ROLE_TEAMER';
@@ -39,6 +40,26 @@ class UserDataHandler
return $roles;
}
/**
* Collects the pending markers for the administrative roles claimed in the CRM.
*/
public function collectPendingRoles(CrmAttributesResponse $crmAttributes): array
{
$roles = [];
if ($crmAttributes->isAdmin()) {
$roles[] = User::PENDING_ROLES['ROLE_ADMIN'];
}
if ($crmAttributes->isManager()) {
$roles[] = User::PENDING_ROLES['ROLE_MANAGER'];
} elseif ($crmAttributes->isHouseManager()) {
$roles[] = User::PENDING_ROLES['ROLE_HOUSE_MANAGER'];
}
return $roles;
}
public function findLocalUser(ProfileResponse $profileResponse): ?User
{
// Check if user is already present in local database
@@ -141,22 +162,31 @@ class UserDataHandler
return $user;
}
/**
* Updates an existing user from BusPro data.
*
* Roles and hotel codes are imported once on user creation only and are managed
* manually afterwards, so they are intentionally left untouched here. The only
* exception are the privilege-free pending markers, which keep tracking the
* administrative roles claimed in the CRM.
*
* @param string[] $claimedRoles pending markers as returned by collectPendingRoles()
*/
public function updateLocalUser(
User $user,
ProfileResponse $profileResponse,
array $roles,
bool $isTeamer = false,
array $crmSelections = [],
array $hotelCodes = [],
array $claimedRoles = [],
): void {
$user
->setFirstName($profileResponse->getFirstName())
->setLastName($profileResponse->getName())
->setEmail($profileResponse->getCommunication()->getEmail())
->setRoles($roles)
->setHotelCodes($hotelCodes)
;
$this->refreshPendingRoles($user, $claimedRoles);
if (true === $isTeamer) {
$address = Address::fromApiResponse($profileResponse);
$communication = Communication::fromApiResponse($profileResponse);
@@ -187,4 +217,39 @@ class UserDataHandler
$this->entityManager->flush();
}
/**
* Keeps the pending markers in sync with the administrative roles claimed in the CRM.
* The markers grant no privileges, so tracking them on every login is safe: only a
* super admin can turn one into an actual role, and an already granted role is never
* marked as pending again.
*
* @param string[] $claimedRoles
*/
private function refreshPendingRoles(User $user, array $claimedRoles): void
{
// an approved role needs no marker anymore
$grantedRoles = $user->getAssignedRoles();
$pendingRoles = array_values(array_filter(
$claimedRoles,
static fn (string $pendingRole): bool => false === in_array(
array_search($pendingRole, User::PENDING_ROLES, true),
$grantedRoles,
true,
),
));
if ($pendingRoles === $user->getPendingRoles()) {
return;
}
// getAssignedRoles() returns the manually assignable roles without the markers
$user->setRoles([...$grantedRoles, ...$pendingRoles]);
$this->logger->info('Refresh pending roles', [
'user_id' => $user->getId(),
'user_email' => $user->getEmail(),
'pending_roles' => $pendingRoles,
]);
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Config;
/**
* The houses the application works with, as configured by the "houses" parameter.
*
* Every house is identified by its hotel code and its name. Both are used to match
* destinations, but against different columns: the name matches destination.hotel
* (substring), the code matches destination.hotelCode (prefix or suffix, see
* App\Entity\User::hasHotelCodeMatch()). They therefore do not necessarily select the
* same destinations, which is why both are kept.
*/
class HouseCatalog
{
/**
* @param array<string, string> $houses hotel code => name
*/
public function __construct(private readonly array $houses)
{
}
/**
* Choices for filtering destinations by house name: label => name.
*
* @return array<string, string>
*/
public function getNameChoices(): array
{
$names = array_values($this->houses);
sort($names);
return array_combine($names, $names);
}
/**
* Choices for assigning hotel codes to a user: label => code.
*
* @return array<string, string>
*/
public function getCodeChoices(): array
{
$codes = $this->houses;
asort($codes);
return array_flip($codes);
}
public function getName(string $code): ?string
{
return $this->houses[$code] ?? null;
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Controller\Admin\System\User;
use App\Entity\User;
use App\Form\UserType;
use App\Security\Voter\UserVoter;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class EditController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/system/user/edit/{id}', name: 'app_admin_system_user_edit')]
#[IsGranted(UserVoter::EDIT, subject: 'user')]
public function index(User $user, Request $request): Response
{
$form = $this->createForm(UserType::class, $user);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->flush();
$this->addFlash('success', 'Die Benutzer:in wurde aktualisiert');
$this->logger->info('Edit user', [
'user_id' => $user->getId(),
'user_email' => $user->getEmail(),
'roles' => $user->getRoles(),
'super_admin' => $user->isSuperAdmin(),
'hotel_codes' => $user->getHotelCodes(),
]);
return $this->redirectToRoute('app_admin_system_user_index');
}
return $this->render('admin/system/user/edit.html.twig', [
'form' => $form,
'user' => $user,
]);
}
}
+1 -5
View File
@@ -4,7 +4,6 @@ namespace App\Controller\Security;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
@@ -12,7 +11,7 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
class LoginController extends AbstractController
{
#[Route('/login', name: 'app_security_login')]
public function login(AuthenticationUtils $authenticationUtils, Request $request): Response
public function login(AuthenticationUtils $authenticationUtils): Response
{
// redirect to default route in case of active session
if (null !== $user = $this->getUser()) {
@@ -26,12 +25,9 @@ class LoginController extends AbstractController
// last username entered by the user
$lastUsername = $authenticationUtils->getLastUsername();
$role = $request->query->get('role');
return $this->render('security/login.html.twig', [
'last_username' => $lastUsername,
'error' => $error,
'role' => $role,
]);
}
+71 -12
View File
@@ -8,12 +8,36 @@ use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface, TimestampableEntityInterface
{
use TimestampableEntity;
/**
* Assignable roles and their labels.
*/
public const ROLES = [
'ROLE_ADMIN' => 'Admin',
'ROLE_MANAGER' => 'Reisemanager',
'ROLE_HOUSE_MANAGER' => 'Hausleitung',
'ROLE_TEAMER' => 'Teamer',
];
/**
* Markers for administrative roles a user holds in the BusPro CRM, keyed by the role
* they stand for. They grant no privileges whatsoever and merely make the user show
* up for approval, because administrative roles may only ever be granted manually by
* a super admin.
*/
public const PENDING_ROLES = [
'ROLE_ADMIN' => 'ROLE_ADMIN_PENDING',
'ROLE_MANAGER' => 'ROLE_MANAGER_PENDING',
'ROLE_HOUSE_MANAGER' => 'ROLE_HOUSE_MANAGER_PENDING',
];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -176,19 +200,16 @@ class User implements UserInterface, TimestampableEntityInterface
public function getRolesLabels(): array
{
$labels = [];
$labels = self::ROLES;
foreach ($this->roles as $role) {
$labels[] = match ($role) {
'ROLE_ADMIN' => 'Admin',
'ROLE_MANAGER' => 'Reisemanager',
'ROLE_HOUSE_MANAGER' => 'Hausleitung',
'ROLE_TEAMER' => 'Teamer',
default => $role,
};
foreach (self::PENDING_ROLES as $role => $pendingRole) {
$labels[$pendingRole] = self::ROLES[$role].' (nicht freigeschaltet)';
}
return $labels;
return array_map(
static fn (string $role): string => $labels[$role] ?? $role,
$this->roles,
);
}
public function setRoles(array $roles): static
@@ -198,6 +219,29 @@ class User implements UserInterface, TimestampableEntityInterface
return $this;
}
/**
* The manually assignable roles held by the user, i.e. without the implicit ROLE_USER
* and ROLE_SUPER_ADMIN added by getRoles() and without any pending marker. Used to
* edit role assignments: saving them resolves the pending approvals.
*/
public function getAssignedRoles(): array
{
return array_values(array_intersect($this->roles, array_keys(self::ROLES)));
}
/**
* The pending markers currently held by the user.
*/
public function getPendingRoles(): array
{
return array_values(array_intersect($this->roles, array_values(self::PENDING_ROLES)));
}
public function setAssignedRoles(array $roles): static
{
return $this->setRoles($roles);
}
public function hasRole(string $role): bool
{
return in_array($role, $this->getRoles());
@@ -215,6 +259,21 @@ class User implements UserInterface, TimestampableEntityInterface
return $this;
}
/**
* Super admin is an elevation of ROLE_ADMIN, never a standalone grant.
*/
#[Assert\Callback]
public function validateSuperAdmin(ExecutionContextInterface $context): void
{
if (true === $this->superAdmin && false === in_array('ROLE_ADMIN', $this->roles, true)) {
$context
->buildViolation('Nur Admins können zu Superadmins ernannt werden.')
->atPath('superAdmin')
->addViolation()
;
}
}
public function getLastLoginAt(): ?\DateTimeImmutable
{
return $this->lastLoginAt;
@@ -235,9 +294,9 @@ class User implements UserInterface, TimestampableEntityInterface
return 'app_manager_index';
} elseif ($this->hasRole('ROLE_HOUSE_MANAGER')) {
return 'app_house_manager_index';
} else {
return 'app_teamer_index';
}
return 'app_teamer_index';
}
public function eraseCredentials(): void
+3 -9
View File
@@ -2,6 +2,7 @@
namespace App\Form;
use App\Config\HouseCatalog;
use App\Entity\Assignment;
use App\Entity\JobProfile;
use App\Model\ApplicationFilterDto;
@@ -17,15 +18,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class ApplicationFilterType extends AbstractType
{
private array $hotelChoices = [];
public function __construct(private readonly Security $security, private readonly array $destinations)
public function __construct(private readonly Security $security, private readonly HouseCatalog $houseCatalog)
{
$destinations = $this->destinations;
sort($destinations);
foreach ($destinations as $item) {
$this->hotelChoices[$item] = $item;
}
}
public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -126,7 +120,7 @@ class ApplicationFilterType extends AbstractType
'label' => 'Haus/Destination',
'required' => false,
'empty_label' => 'nicht filtern',
'choices' => $this->hotelChoices,
'choices' => $this->houseCatalog->getNameChoices(),
])
;
}
+3 -9
View File
@@ -2,6 +2,7 @@
namespace App\Form;
use App\Config\HouseCatalog;
use App\Entity\Assignment;
use App\Entity\JobProfile;
use App\Model\AssignmentFilterDto;
@@ -17,15 +18,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class AssignmentFilterType extends AbstractType
{
private array $hotelChoices = [];
public function __construct(private readonly Security $security, private readonly array $destinations)
public function __construct(private readonly Security $security, private readonly HouseCatalog $houseCatalog)
{
$destinations = $this->destinations;
sort($destinations);
foreach ($destinations as $item) {
$this->hotelChoices[$item] = $item;
}
}
public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -130,7 +124,7 @@ class AssignmentFilterType extends AbstractType
'label' => 'Haus/Destination',
'required' => false,
'empty_label' => 'nicht filtern',
'choices' => $this->hotelChoices,
'choices' => $this->houseCatalog->getNameChoices(),
])
;
}
+3 -9
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Form;
use App\Config\HouseCatalog;
use App\Model\DestinationFilterDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
@@ -12,15 +13,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class DestinationFilterType extends AbstractType
{
private array $hotelChoices = [];
public function __construct(private readonly array $destinations)
public function __construct(private readonly HouseCatalog $houseCatalog)
{
$destinations = $this->destinations;
sort($destinations);
foreach ($destinations as $item) {
$this->hotelChoices[$item] = $item;
}
}
public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -30,7 +24,7 @@ class DestinationFilterType extends AbstractType
'label' => 'Haus/Destination',
'required' => false,
'empty_label' => 'nicht filtern',
'choices' => $this->hotelChoices,
'choices' => $this->houseCatalog->getNameChoices(),
])
->add('apply', SubmitType::class, [
'label' => 'filtern',
+3 -9
View File
@@ -2,6 +2,7 @@
namespace App\Form;
use App\Config\HouseCatalog;
use App\Entity\JobProfile;
use App\Entity\Teamer;
use App\Entity\Upload;
@@ -14,15 +15,8 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
class DocumentFilterType extends AbstractType
{
private array $hotelChoices = [];
public function __construct(private readonly array $destinations)
public function __construct(private readonly HouseCatalog $houseCatalog)
{
$destinations = $this->destinations;
sort($destinations);
foreach ($destinations as $item) {
$this->hotelChoices[$item] = $item;
}
}
public function buildForm(FormBuilderInterface $builder, array $options): void
@@ -56,7 +50,7 @@ class DocumentFilterType extends AbstractType
'label' => 'Haus/Destination',
'required' => false,
'empty_label' => 'nicht filtern',
'choices' => $this->hotelChoices,
'choices' => $this->houseCatalog->getNameChoices(),
])
->add('dateFrom', DatepickerType::class, [
'label' => 'Einsatzzeitraum von',
+3 -9
View File
@@ -2,6 +2,7 @@
namespace App\Form;
use App\Config\HouseCatalog;
use App\Model\TimelineFilterDto;
use App\Service\Assignment\TimelineService;
use Symfony\Bundle\SecurityBundle\Security;
@@ -15,7 +16,7 @@ class TimelineFilterType extends AbstractType
public function __construct(
private readonly TimelineService $timelineService,
private readonly Security $security,
private readonly array $destinations,
private readonly HouseCatalog $houseCatalog,
) {
}
@@ -53,19 +54,12 @@ class TimelineFilterType extends AbstractType
;
if (false === $this->security->isGranted('ROLE_HOUSE_MANAGER')) {
$destinations = $this->destinations;
$hotelChoices = [];
sort($destinations);
foreach ($destinations as $item) {
$hotelChoices[$item] = $item;
}
$builder
->add('hotels', MultiselectType::class, [
'label' => 'Haus/Destination',
'required' => false,
'empty_label' => 'nicht filtern',
'choices' => $hotelChoices,
'choices' => $this->houseCatalog->getNameChoices(),
])
;
}
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace App\Form;
use App\Config\HouseCatalog;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
class UserType extends AbstractType
{
public function __construct(private readonly HouseCatalog $houseCatalog)
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('roles', MultiselectType::class, [
'label' => 'Rollen',
'required' => false,
'property_path' => 'assignedRoles',
'choices' => array_flip(User::ROLES),
'empty_label' => 'keine Rolle',
])
->add('superAdmin', CheckboxType::class, [
'label' => 'Superadmin',
'required' => false,
'help' => 'Setzt die Rolle Admin voraus.',
])
;
// hotel codes already assigned to the user may predate the catalog, so they are
// added as choices to keep them selectable instead of failing
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
$user = $event->getData();
$choices = $this->houseCatalog->getCodeChoices();
foreach ($user instanceof User ? $user->getHotelCodes() : [] as $code) {
if (false === in_array($code, $choices, true)) {
$choices[$code] = $code;
}
}
$event->getForm()->add('hotelCodes', MultiselectType::class, [
'label' => 'Häuser',
'required' => false,
'choices' => $choices,
'empty_label' => 'keine Häuser',
]);
});
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}
+5
View File
@@ -259,6 +259,11 @@ class AdminMenuBuilder extends AbstractMenuBuilder
'linkAttributes' => [
'title' => 'Administrative Benutzer:innen',
],
'extras' => [
'routes' => [
['pattern' => '/^app_admin_system_user_/'],
],
],
]);
$menu->addChild('Logs', [
'route' => 'app_admin_log_index',
+10 -8
View File
@@ -31,16 +31,18 @@ class UserRepository extends ServiceEntityRepository
{
$qb = $this->createQueryBuilder('u');
// administrative roles, plus the pending markers standing in for them
$roles = [...array_keys(User::PENDING_ROLES), ...array_values(User::PENDING_ROLES)];
foreach ($roles as $index => $role) {
$qb
->orWhere('JSON_CONTAINS(u.roles, :role'.$index.') = 1')
->setParameter('role'.$index, json_encode($role))
;
}
return $qb
->where('JSON_CONTAINS(u.roles, :role_admin) = 1')
->orWhere('JSON_CONTAINS(u.roles, :role_manager) = 1')
->orWhere('JSON_CONTAINS(u.roles, :role_house_manager) = 1')
->orderBy('u.lastName', 'ASC')
->setParameters([
'role_admin' => json_encode('ROLE_ADMIN'),
'role_manager' => json_encode('ROLE_MANAGER'),
'role_house_manager' => json_encode('ROLE_HOUSE_MANAGER'),
])
->getQuery()
->getResult()
;
+18 -24
View File
@@ -64,8 +64,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
}
// Final checks and local user loading/creation
$preferredRole = $request->request->get('_role');
$user = $this->getOrCreateLocalUser($response, $email, $password, $preferredRole);
$user = $this->getOrCreateLocalUser($response, $email, $password);
if (null === $user) {
return null;
@@ -110,7 +109,6 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
ProfileResponse $profileResponse,
string $email,
string $password,
?string $preferredRole,
): ?User {
// Fetch CRM attributes, early return in case of an API error
try {
@@ -120,24 +118,6 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
return null;
}
// Collect user's roles from CRM attributes
$roles = $this
->userDataHandler
->collectRoles($crmAttributes, $preferredRole)
;
// User is expected to have at least one role
if (0 === count($roles)) {
// Revoke roles on existing local user to invalidate any active session
$existingUser = $this->userDataHandler->findLocalUser($profileResponse);
if (null !== $existingUser) {
$existingUser->setRoles([]);
$this->entityManager->flush();
}
return null;
}
// Flatten selected CRM attributes
$crmSelections = $crmAttributes->toArray();
@@ -150,19 +130,33 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
->findLocalUser($profileResponse)
;
// Update existing user's roles and teamer data and return it
// Update existing user's teamer data and return it, leaving roles and hotel
// codes alone: they are imported once on creation and managed manually after
if (null !== $user) {
$this
->userDataHandler
->updateLocalUser($user, $profileResponse, $roles, $isTeamer, $crmSelections, $crmAttributes->getHotelCodes())
->updateLocalUser(
$user,
$profileResponse,
$isTeamer,
$crmSelections,
$this->userDataHandler->collectPendingRoles($crmAttributes),
)
;
return $user;
}
// Initial import of roles and hotel codes on user creation
return $this
->userDataHandler
->createLocalUser($profileResponse, $roles, $isTeamer, $crmSelections, $crmAttributes->getHotelCodes())
->createLocalUser(
$profileResponse,
$this->userDataHandler->collectRoles($crmAttributes),
$isTeamer,
$crmSelections,
$crmAttributes->getHotelCodes(),
)
;
}
}
+5 -8
View File
@@ -9,13 +9,6 @@ use Symfony\Component\Security\Core\User\UserInterface;
class UserChecker implements UserCheckerInterface
{
private const APPLICATION_ROLES = [
'ROLE_ADMIN',
'ROLE_MANAGER',
'ROLE_HOUSE_MANAGER',
'ROLE_TEAMER',
];
public function checkPreAuth(UserInterface $user): void
{
if (!$user instanceof User) {
@@ -26,7 +19,11 @@ class UserChecker implements UserCheckerInterface
throw new CustomUserMessageAccountStatusException('Dein Account wurde gesperrt: '.$user->getDisabledReason());
}
if ([] === array_intersect($user->getRoles(), self::APPLICATION_ROLES)) {
if ([] === array_intersect($user->getRoles(), array_keys(User::ROLES))) {
if ([] !== $user->getPendingRoles()) {
throw new CustomUserMessageAccountStatusException('Deine Rolle wurde noch nicht freigeschaltet.');
}
throw new CustomUserMessageAccountStatusException('Keine gültige Rolle zugewiesen.');
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Security\Voter;
use App\Entity\User;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
class UserVoter extends Voter
{
public const EDIT = 'CAN_EDIT_USER';
public function __construct(private readonly Security $security)
{
}
protected function supports(string $attribute, mixed $subject): bool
{
return self::EDIT === $attribute && $subject instanceof User;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$currentUser = $token->getUser();
$targetUser = $subject;
if (!$currentUser instanceof User || !$targetUser instanceof User) {
return false;
}
// editing your own roles or super admin flag is not allowed
if ($currentUser === $targetUser) {
return false;
}
// if the current user is impersonating, do not grant access
if ($this->security->isGranted('IS_IMPERSONATOR')) {
return false;
}
return $currentUser->isSuperAdmin();
}
}
@@ -0,0 +1,16 @@
{{ form_start(form) }}
<div class="flex flex-col space-y-4 pb-8">
{{ form_row(form.roles) }}
{{ form_row(form.superAdmin) }}
{{ form_row(form.hotelCodes) }}
</div>
<div class="flex items-center space-x-2">
<button type="submit" class="btn">
Speichern
</button>
<a href="{{ path('app_admin_system_user_index') }}" class="btn btn--secondary">
Abbrechen
</a>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
@@ -0,0 +1,12 @@
{% extends 'admin/layout.html.twig' %}
{% block title %}Einstellungen - Benutzer:in bearbeiten{% endblock %}
{% block content %}
<div class="max-w-screen-sm">
<h1 class="text-2xl font-bold pb-8">
{{ user.fullName }} bearbeiten
</h1>
{% include 'admin/system/user/_form.html.twig' %}
</div>
{% endblock %}
+13 -6
View File
@@ -56,16 +56,23 @@
{{ user.lastLoginAt ? user.lastLoginAt|date('d.m.Y, H:i') : '-' }}
</td>
<td>
{% if is_granted('CAN_IMPERSONATE', user) %}
<a href="{{ path('app_index', { '_switch_user': user.email }) }}">
{{ icon('mask', 'w-4 h-4') }}
</a>
{% endif %}
<div class="flex items-center space-x-2">
{% if is_granted('CAN_IMPERSONATE', user) %}
<a href="{{ path('app_index', { '_switch_user': user.email }) }}">
{{ icon('mask', 'w-4 h-4') }}
</a>
{% endif %}
{% if is_granted('CAN_EDIT_USER', user) %}
<a href="{{ path('app_admin_system_user_edit', { 'id': user.id }) }}">
{{ icon('edit') }}
</a>
{% endif %}
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="6">
<td colspan="7">
Keine Daten...
</td>
</tr>
-3
View File
@@ -44,9 +44,6 @@
Login
</button>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
{% if role is not null %}
<input type="hidden" name="_role" value="{{ role }}">
{% endif %}
</form>
<div class="pt-4">
<a href="{{ path('app_security_password_reset') }}" class="text-sm underline">
+128 -4
View File
@@ -6,6 +6,7 @@ namespace App\Tests\BusProNet;
use App\BusProNet\Model\Address as BusProAddress;
use App\BusProNet\Model\Communication as BusProCommunication;
use App\BusProNet\Model\CrmAttributesResponse;
use App\BusProNet\Model\ProfileResponse;
use App\BusProNet\UserDataHandler;
use App\Entity\Embeddable\Address;
@@ -29,6 +30,59 @@ class UserDataHandlerTest extends TestCase
$this->logger = $this->createMock(LoggerInterface::class);
}
/**
* @dataProvider collectRolesProvider
*/
public function testCollectRolesImportsAdministrativeRolesAsPendingOnly(CrmAttributesResponse $crmAttributes, array $expectedRoles): void
{
$handler = new UserDataHandler($this->entityManager, $this->logger);
$this->assertSame($expectedRoles, $handler->collectRoles($crmAttributes));
}
public static function collectRolesProvider(): iterable
{
yield 'admin only yields the pending marker' => [
(new CrmAttributesResponse())->setAdmin(true),
[User::PENDING_ROLES['ROLE_ADMIN']],
];
yield 'manager only yields the pending marker' => [
(new CrmAttributesResponse())->setManager(true),
[User::PENDING_ROLES['ROLE_MANAGER']],
];
yield 'house manager only yields the pending marker' => [
(new CrmAttributesResponse())->setHouseManager(true),
[User::PENDING_ROLES['ROLE_HOUSE_MANAGER']],
];
yield 'teamer is granted directly' => [
(new CrmAttributesResponse())->setTeamer(true),
['ROLE_TEAMER'],
];
yield 'admin and teamer' => [
(new CrmAttributesResponse())->setAdmin(true)->setTeamer(true),
[User::PENDING_ROLES['ROLE_ADMIN'], 'ROLE_TEAMER'],
];
yield 'admin and manager yield both markers' => [
(new CrmAttributesResponse())->setAdmin(true)->setManager(true),
[User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_MANAGER']],
];
yield 'manager takes precedence over house manager' => [
(new CrmAttributesResponse())->setManager(true)->setHouseManager(true),
[User::PENDING_ROLES['ROLE_MANAGER']],
];
yield 'house manager and teamer' => [
(new CrmAttributesResponse())->setHouseManager(true)->setTeamer(true),
[User::PENDING_ROLES['ROLE_HOUSE_MANAGER'], 'ROLE_TEAMER'],
];
}
public function testUpdateLocalUserSyncsUserAndTeamerDataFromBusPro(): void
{
$user = (new User())
@@ -37,6 +91,8 @@ class UserDataHandlerTest extends TestCase
->setEmail('[email protected]')
->setBusProAddressId(1)
->setBusProPersonId(2)
->setRoles(['ROLE_ADMIN'])
->setHotelCodes(['XYZ'])
;
$teamer = (new Teamer())
@@ -61,17 +117,17 @@ class UserDataHandlerTest extends TestCase
$handler->updateLocalUser(
$user,
$profileResponse,
['ROLE_TEAMER'],
true,
['team' => ['selected' => true]],
['ABC'],
);
$this->assertSame('New', $user->getFirstName());
$this->assertSame('Lastname', $user->getLastName());
$this->assertSame('[email protected]', $user->getEmail());
$this->assertSame(['ABC'], $user->getHotelCodes());
$this->assertTrue($user->hasRole('ROLE_TEAMER'));
// roles and hotel codes are imported on creation only and stay under manual control
$this->assertSame(['XYZ'], $user->getHotelCodes());
$this->assertTrue($user->hasRole('ROLE_ADMIN'));
$this->assertSame('New', $teamer->getFirstName());
$this->assertSame('Lastname', $teamer->getLastName());
@@ -89,6 +145,74 @@ class UserDataHandlerTest extends TestCase
$this->assertSame(['team' => ['selected' => true]], $teamer->getCrmSelections());
}
/**
* @dataProvider pendingRolesProvider
*/
public function testUpdateLocalUserRefreshesThePendingRoles(
array $roles,
array $claimedRoles,
array $expectedAssignedRoles,
array $expectedPendingRoles,
): void {
$user = (new User())
->setFirstName('Old')
->setLastName('Name')
->setEmail('[email protected]')
->setRoles($roles)
;
$handler = new UserDataHandler($this->entityManager, $this->logger);
$handler->updateLocalUser($user, $this->createProfileResponse(), false, [], $claimedRoles);
$this->assertSame($expectedAssignedRoles, $user->getAssignedRoles());
$this->assertSame($expectedPendingRoles, $user->getPendingRoles());
}
public static function pendingRolesProvider(): iterable
{
yield 'marker is added when the CRM claims a manager' => [
['ROLE_TEAMER'],
[User::PENDING_ROLES['ROLE_MANAGER']],
['ROLE_TEAMER'],
[User::PENDING_ROLES['ROLE_MANAGER']],
];
yield 'marker is dropped when the CRM attribute is gone' => [
[User::PENDING_ROLES['ROLE_HOUSE_MANAGER'], 'ROLE_TEAMER'],
[],
['ROLE_TEAMER'],
[],
];
yield 'an approved role is never marked again' => [
['ROLE_MANAGER'],
[User::PENDING_ROLES['ROLE_MANAGER']],
['ROLE_MANAGER'],
[],
];
yield 'a claim beyond the approved role stays pending' => [
['ROLE_MANAGER'],
[User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_MANAGER']],
['ROLE_MANAGER'],
[User::PENDING_ROLES['ROLE_ADMIN']],
];
yield 'the claimed role changes' => [
[User::PENDING_ROLES['ROLE_HOUSE_MANAGER']],
[User::PENDING_ROLES['ROLE_MANAGER']],
[],
[User::PENDING_ROLES['ROLE_MANAGER']],
];
yield 'granted roles are untouched without any claim' => [
['ROLE_ADMIN'],
[],
['ROLE_ADMIN'],
[],
];
}
public function testFindLocalUserFallsBackToUniqueEmailAndRefreshesBusProIds(): void
{
$user = (new User())
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Tests\Config;
use App\Config\HouseCatalog;
use PHPUnit\Framework\TestCase;
class HouseCatalogTest extends TestCase
{
private const HOUSES = [
'SVS' => 'Silvana',
'AGR' => 'Rotbach',
'DPW' => 'Waldschlössli',
];
public function testNameChoicesAreSortedAndMapLabelToName(): void
{
$catalog = new HouseCatalog(self::HOUSES);
$this->assertSame([
'Rotbach' => 'Rotbach',
'Silvana' => 'Silvana',
'Waldschlössli' => 'Waldschlössli',
], $catalog->getNameChoices());
}
public function testCodeChoicesAreSortedByLabelAndMapLabelToCode(): void
{
$catalog = new HouseCatalog(self::HOUSES);
$this->assertSame([
'Rotbach' => 'AGR',
'Silvana' => 'SVS',
'Waldschlössli' => 'DPW',
], $catalog->getCodeChoices());
}
public function testGetName(): void
{
$catalog = new HouseCatalog(self::HOUSES);
$this->assertSame('Silvana', $catalog->getName('SVS'));
$this->assertNull($catalog->getName('XYZ'));
}
public function testEmptyCatalog(): void
{
$catalog = new HouseCatalog([]);
$this->assertSame([], $catalog->getNameChoices());
$this->assertSame([], $catalog->getCodeChoices());
}
}
+62
View File
@@ -6,6 +6,8 @@ namespace App\Tests\Entity;
use App\Entity\User;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validation;
class UserTest extends TestCase
{
@@ -23,4 +25,64 @@ class UserTest extends TestCase
$isMatch = $user->hasHotelCodeMatch('XXDEFXX');
$this->assertFalse($isMatch);
}
public function testPendingMarkersAreLabelledButNotAssignable(): void
{
$user = (new User())->setRoles([
User::PENDING_ROLES['ROLE_ADMIN'],
User::PENDING_ROLES['ROLE_HOUSE_MANAGER'],
'ROLE_TEAMER',
]);
$this->assertSame(
['Admin (nicht freigeschaltet)', 'Hausleitung (nicht freigeschaltet)', 'Teamer'],
$user->getRolesLabels(),
);
$this->assertSame(['ROLE_TEAMER'], $user->getAssignedRoles());
$this->assertSame(
[User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_HOUSE_MANAGER']],
$user->getPendingRoles(),
);
}
public function testSuperAdminRequiresRoleAdmin(): void
{
$user = (new User())
->setRoles(['ROLE_MANAGER'])
->setSuperAdmin(true)
;
$violations = $this->validate($user);
$this->assertCount(1, $violations);
$this->assertSame('superAdmin', $violations[0]->getPropertyPath());
}
public function testSuperAdminWithRoleAdminIsValid(): void
{
$user = (new User())
->setRoles(['ROLE_ADMIN'])
->setSuperAdmin(true)
;
$this->assertCount(0, $this->validate($user));
}
public function testNonSuperAdminWithoutRoleAdminIsValid(): void
{
$user = (new User())
->setRoles(['ROLE_TEAMER'])
;
$this->assertCount(0, $this->validate($user));
}
private function validate(User $user): ConstraintViolationListInterface
{
return Validation::createValidatorBuilder()
->enableAttributeMapping()
->getValidator()
->validate($user)
;
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form;
use App\Entity\User;
use App\Form\UserType;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Form\FormFactoryInterface;
class UserTypeTest extends KernelTestCase
{
public function testRendersRolesWithoutSynthesizedRoles(): void
{
$user = (new User())
->setRoles(['ROLE_ADMIN'])
->setSuperAdmin(true)
;
$view = $this->createForm($user)->createView();
// ROLE_USER and ROLE_SUPER_ADMIN are synthesized by getRoles() and must not leak in
$this->assertSame(['ROLE_ADMIN'], $view->children['roles']->vars['data']);
}
public function testApprovingAPendingRoleClearsTheMarker(): void
{
$user = (new User())->setRoles([User::PENDING_ROLES['ROLE_ADMIN']]);
$form = $this->createForm($user);
// the marker is not an assignable choice and must not reach the field
$this->assertSame([], $form->createView()->children['roles']->vars['data']);
$form->submit([
'roles' => ['ROLE_ADMIN'],
'superAdmin' => null,
'hotelCodes' => [],
]);
$this->assertTrue($form->isValid());
$this->assertSame(['ROLE_ADMIN'], $user->getAssignedRoles());
$this->assertSame([], $user->getPendingRoles());
}
public function testRendersHotelCodesNotCoveredByTheConfiguredMap(): void
{
$user = (new User())->setHotelCodes(['XYZ']);
$view = $this->createForm($user)->createView();
$this->assertSame(['XYZ'], $view->children['hotelCodes']->vars['data']);
}
public function testSubmitStoresAssignedRolesOnly(): void
{
$user = (new User())->setRoles(['ROLE_TEAMER']);
$form = $this->createForm($user);
$form->submit([
'roles' => ['ROLE_ADMIN'],
'superAdmin' => '1',
'hotelCodes' => [],
]);
$this->assertTrue($form->isValid());
$this->assertSame(['ROLE_ADMIN'], $user->getAssignedRoles());
$this->assertTrue($user->isSuperAdmin());
}
public function testSubmitRejectsSuperAdminWithoutRoleAdmin(): void
{
$user = new User();
$form = $this->createForm($user);
$form->submit([
'roles' => ['ROLE_MANAGER'],
'superAdmin' => '1',
'hotelCodes' => [],
]);
$this->assertFalse($form->isValid());
$this->assertCount(1, $form->get('superAdmin')->getErrors());
}
private function createForm(User $user): \Symfony\Component\Form\FormInterface
{
self::bootKernel();
/** @var FormFactoryInterface $formFactory */
$formFactory = self::getContainer()->get(FormFactoryInterface::class);
return $formFactory->create(UserType::class, $user, [
'csrf_protection' => false,
]);
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Tests\Security\Voter;
use App\Entity\User;
use App\Security\Voter\UserVoter;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\VoterInterface;
class UserVoterTest extends TestCase
{
private Security&MockObject $security;
protected function setUp(): void
{
$this->security = $this->createMock(Security::class);
}
public function testSuperAdminMayEditOtherUsers(): void
{
$this->assertSame(
VoterInterface::ACCESS_GRANTED,
$this->vote($this->createUser(true), $this->createUser(false)),
);
}
public function testPlainAdminMayNotEdit(): void
{
$this->assertSame(
VoterInterface::ACCESS_DENIED,
$this->vote($this->createUser(false), $this->createUser(false)),
);
}
public function testSuperAdminMayNotEditThemselves(): void
{
$currentUser = $this->createUser(true);
$this->assertSame(
VoterInterface::ACCESS_DENIED,
$this->vote($currentUser, $currentUser),
);
}
public function testImpersonatorMayNotEdit(): void
{
$this->assertSame(
VoterInterface::ACCESS_DENIED,
$this->vote($this->createUser(true), $this->createUser(false), true),
);
}
private function vote(User $currentUser, User $targetUser, bool $isImpersonator = false): int
{
$this->security
->method('isGranted')
->with('IS_IMPERSONATOR')
->willReturn($isImpersonator);
$token = $this->createMock(TokenInterface::class);
$token
->method('getUser')
->willReturn($currentUser);
$voter = new UserVoter($this->security);
return $voter->vote($token, $targetUser, [UserVoter::EDIT]);
}
private function createUser(bool $superAdmin): User
{
return (new User())
->setRoles(['ROLE_ADMIN'])
->setSuperAdmin($superAdmin)
;
}
}