feat: bpn as single source of truth for role and hotel code assignments

This commit is contained in:
Björn Fromme
2026-08-18 11:33:42 +02:00
parent 1fd0fbc21e
commit f0e850978b
17 changed files with 818 additions and 198 deletions
+192 -27
View File
@@ -14,9 +14,17 @@ use Psr\Log\LoggerInterface;
/**
* Owns the role policy for local user accounts, whatever identity source reports them.
* The importing of BusPro profile data is specific to the CRM, but the role rules -
* toPendingRoles(), refreshPendingRoles() and grantTeamerRole() - are deliberately
* shared with the MyE&P SSO login (see App\Security\MyEpAuthenticator), so that no
* identity source can grant an administrative role this application would not.
* toPendingRoles() and everything syncRoles() composes - are deliberately shared with
* the MyE&P SSO login (see App\Security\MyEpAuthenticator), so that no identity source
* can grant an administrative role this application would not.
*
* The policy in one paragraph: the identity source leads. Every role it no longer claims
* is revoked on the next login, and the hotel codes are re-imported with them. What it
* claims is not granted, though: an administrative role arrives as a privilege-free
* marker and only a super admin turns it into the real role. ROLE_TEAMER is the exception
* that needs no approval - it carries no privileges of its own - and is granted outright.
* Nobody can therefore escalate through the CRM, and nobody keeps an access the CRM has
* taken away.
*/
class UserDataHandler
{
@@ -27,7 +35,7 @@ class UserDataHandler
}
/**
* Collects the roles to grant on initial user creation.
* Collects the roles to store 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
@@ -48,9 +56,15 @@ class UserDataHandler
}
/**
* Collects the pending markers for the administrative roles claimed in the CRM.
* The roles the CRM claims for this person, as plain role names.
*
* This is what the CRM says, not what the application grants for it - see syncRoles()
* for that. Named after MyEpAuthenticator::collectClaimedRoles(), which produces the
* same shape from the SSO claims, so that one policy can serve both identity sources.
*
* @return string[]
*/
public function collectPendingRoles(CrmAttributesResponse $crmAttributes): array
public function collectClaimedRoles(CrmAttributesResponse $crmAttributes): array
{
$claimedRoles = [];
@@ -66,7 +80,19 @@ class UserDataHandler
$claimedRoles[] = 'ROLE_HOUSE_MANAGER';
}
return $this->toPendingRoles($claimedRoles);
if ($crmAttributes->isTeamer()) {
$claimedRoles[] = 'ROLE_TEAMER';
}
return $claimedRoles;
}
/**
* Collects the pending markers for the administrative roles claimed in the CRM.
*/
public function collectPendingRoles(CrmAttributesResponse $crmAttributes): array
{
return $this->toPendingRoles($this->collectClaimedRoles($crmAttributes));
}
/**
@@ -75,6 +101,10 @@ class UserDataHandler
* attributes, expressed over plain role names so that any identity source can use
* it. ROLE_TEAMER has no marker and is dropped here: it needs no approval.
*
* Every claimed role gets its own marker. The roles stand on their own - a manager is
* not a super-set of a house manager - so somebody claiming both is approved for both,
* one decision at a time.
*
* @param string[] $claimedRoles
*
* @return string[]
@@ -83,15 +113,10 @@ class UserDataHandler
{
$roles = [];
if (true === in_array('ROLE_ADMIN', $claimedRoles, true)) {
$roles[] = User::PENDING_ROLES['ROLE_ADMIN'];
}
// a manager outranks a house manager, so only the higher marker is kept
if (true === in_array('ROLE_MANAGER', $claimedRoles, true)) {
$roles[] = User::PENDING_ROLES['ROLE_MANAGER'];
} elseif (true === in_array('ROLE_HOUSE_MANAGER', $claimedRoles, true)) {
$roles[] = User::PENDING_ROLES['ROLE_HOUSE_MANAGER'];
foreach (User::PENDING_ROLES as $role => $pendingRole) {
if (true === in_array($role, $claimedRoles, true)) {
$roles[] = $pendingRole;
}
}
return $roles;
@@ -209,13 +234,11 @@ class UserDataHandler
/**
* Updates an existing user from BusPro data.
*
* Administrative roles and hotel codes are imported once on user creation only and are
* managed manually afterwards, so they are intentionally left untouched here. The
* exceptions are the privilege-free pending markers, which keep tracking the
* administrative roles claimed in the CRM, and ROLE_TEAMER, which needs no approval
* and is granted to whoever the CRM reports as a teamer.
* The CRM leads: roles and hotel codes are re-synced on every login, so a role or a
* house it no longer reports is gone. Granting still needs approval - see syncRoles().
*
* @param string[] $claimedRoles pending markers as returned by collectPendingRoles()
* @param string[] $claimedRoles plain role names as returned by collectClaimedRoles()
* @param string[] $hotelCodes the houses the CRM reports for this person
*/
public function updateLocalUser(
User $user,
@@ -223,6 +246,7 @@ class UserDataHandler
bool $isTeamer = false,
array $crmSelections = [],
array $claimedRoles = [],
array $hotelCodes = [],
): void {
$user
->setFirstName($profileResponse->getFirstName())
@@ -230,11 +254,10 @@ class UserDataHandler
->setEmail($profileResponse->getCommunication()->getEmail())
;
$this->refreshPendingRoles($user, $claimedRoles);
$this->syncRoles($user, $claimedRoles);
$this->syncHotelCodes($user, $hotelCodes);
if (true === $isTeamer) {
$this->grantTeamerRole($user);
$address = Address::fromApiResponse($profileResponse);
$communication = Communication::fromApiResponse($profileResponse);
if (null === $user->getTeamer()) {
@@ -306,6 +329,148 @@ class UserDataHandler
]);
}
/**
* Turns a nomination into the real role - the one role decision left to a human.
*
* Only a role the identity source currently claims can be approved, and the marker is
* what says so: no marker, nothing to approve, whoever asked for it. Everything else
* the user holds is left exactly as it is, markers for other roles included.
*
* Returns false when there is nothing to approve, so the caller can refuse the request
* rather than silently granting a role off a hand-crafted URL.
*/
public function approveRole(User $user, string $role): bool
{
$pendingRole = User::PENDING_ROLES[$role] ?? null;
if (null === $pendingRole || false === in_array($pendingRole, $user->getPendingRoles(), true)) {
return false;
}
$user->setRoles([
...$user->getAssignedRoles(),
$role,
...array_values(array_diff($user->getPendingRoles(), [$pendingRole])),
]);
$this->entityManager->flush();
$this->logger->info('Approve role', [
'user_id' => $user->getId(),
'user_email' => $user->getEmail(),
'role' => $role,
]);
return true;
}
/**
* Brings a user's roles in line with what the identity source claims for them.
*
* The whole policy, in the order it has to run:
*
* 1. revoke what is no longer claimed - the identity source leads;
* 2. drop the super admin flag along with ROLE_ADMIN, or the highest privilege in the
* application would outlive the role it depends on;
* 3. refresh the pending markers, after the revocation so that a role just revoked is
* not immediately marked again - it is unclaimed in both steps;
* 4. grant ROLE_TEAMER, the one role that needs no approval.
*
* Nothing here grants an administrative role: step 3 only ever produces markers, and
* turning one into the real role is a super admin's decision.
*
* Does not flush; the caller decides when to.
*
* @param string[] $claimedRoles plain role names, e.g. from collectClaimedRoles()
*/
public function syncRoles(User $user, array $claimedRoles): void
{
$this->revokeUnclaimedRoles($user, $claimedRoles);
$this->refreshPendingRoles($user, $this->toPendingRoles($claimedRoles));
if (true === in_array('ROLE_TEAMER', $claimedRoles, true)) {
$this->grantTeamerRole($user);
}
}
/**
* Replaces the houses a user is responsible for with the ones the identity source
* reports. They are led by the CRM just like the roles are, so a house manager who
* moves house is not left seeing their old one.
*
* Does not flush; the caller decides when to.
*
* @param string[] $hotelCodes
*/
public function syncHotelCodes(User $user, array $hotelCodes): void
{
$hotelCodes = array_values($hotelCodes);
if ($hotelCodes === $user->getHotelCodes()) {
return;
}
$user->setHotelCodes($hotelCodes);
$this->logger->info('Sync hotel codes', [
'user_id' => $user->getId(),
'user_email' => $user->getEmail(),
'hotel_codes' => $hotelCodes,
]);
}
/**
* Withdraws every granted role the identity source no longer claims.
*
* Only the roles of User::ROLES are touched: getAssignedRoles() excludes the pending
* markers as well as the implicit ROLE_USER, and the markers are dealt with by
* refreshPendingRoles(). ROLE_SUPER_ADMIN is not a stored role at all but a flag, so
* it is handled separately below.
*
* @param string[] $claimedRoles
*/
private function revokeUnclaimedRoles(User $user, array $claimedRoles): void
{
$grantedRoles = $user->getAssignedRoles();
$keptRoles = array_values(array_intersect($grantedRoles, $claimedRoles));
if ($keptRoles === $grantedRoles) {
return;
}
$user->setRoles([...$keptRoles, ...$user->getPendingRoles()]);
$this->logger->info('Revoke roles no longer claimed', [
'user_id' => $user->getId(),
'user_email' => $user->getEmail(),
'revoked_roles' => array_values(array_diff($grantedRoles, $keptRoles)),
]);
$this->revokeSuperAdminWithoutRoleAdmin($user);
}
/**
* Takes the super admin flag down with ROLE_ADMIN.
*
* The flag is stored on its own and getRoles() turns it into ROLE_SUPER_ADMIN whatever
* else the user holds, so without this a person the CRM no longer calls an admin would
* keep the one role that outranks every check in the application. User::validateSuperAdmin()
* enforces the same rule on the edit form, but only there.
*/
private function revokeSuperAdminWithoutRoleAdmin(User $user): void
{
if (false === $user->isSuperAdmin() || true === $user->hasRole('ROLE_ADMIN')) {
return;
}
$user->setSuperAdmin(false);
$this->logger->info('Revoke super admin flag along with ROLE_ADMIN', [
'user_id' => $user->getId(),
'user_email' => $user->getEmail(),
]);
}
/**
* Grants ROLE_TEAMER to a user the CRM reports as a teamer.
*
@@ -314,8 +479,8 @@ class UserDataHandler
* area, and a teamer without it would be left with a teamer record they cannot reach,
* or locked out entirely for holding no assignable role at all.
*
* It is never withdrawn here. Losing the CRM attribute while holding no other role
* blocks the account anyway, and a role handed out manually must survive a login.
* Grant-only in itself - withdrawing it is revokeUnclaimedRoles()' business, which
* syncRoles() runs first.
*
* Does not flush; the caller decides when to.
*/
@@ -0,0 +1,55 @@
<?php
namespace App\Controller\Admin\System\User;
use App\BusProNet\UserDataHandler;
use App\Entity\User;
use App\Htmx\HxRedirectResponse;
use App\Security\Voter\UserVoter;
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;
/**
* Granting a privilege is its own act, deliberately not a checkbox on the user edit form:
* it is confirmed on its own, logged on its own, and cannot happen as a side effect of
* saving an unrelated setting. It is also the only way a role is ever granted at all -
* everything else about roles is synced from BusPro (see docs/user-roles.md).
*/
class ApproveRoleController extends AbstractController
{
public function __construct(private readonly UserDataHandler $userDataHandler)
{
}
#[Route('/admin/system/user/approve-role/{id}/{role}', name: 'app_admin_system_user_approve_role')]
#[IsGranted(UserVoter::EDIT, subject: 'user')]
public function index(User $user, string $role, Request $request): Response
{
// nothing but a role this user is actually nominated for, so a hand-crafted URL
// cannot grant one BusPro never claimed
$pendingRole = User::PENDING_ROLES[$role] ?? null;
if (null === $pendingRole || false === in_array($pendingRole, $user->getPendingRoles(), true)) {
throw $this->createNotFoundException('Für diese Rolle liegt keine Freischaltung vor');
}
if (true === $request->isMethod(Request::METHOD_POST)) {
// checked again by the handler, which is what catches a sync revoking the claim
// between opening the dialog and confirming it
if (false === $this->userDataHandler->approveRole($user, $role)) {
throw $this->createNotFoundException('Für diese Rolle liegt keine Freischaltung vor');
}
$this->addFlash('success', sprintf('Die Rolle %s wurde freigeschaltet', User::ROLES[$role]));
return new HxRedirectResponse($this->generateUrl('app_admin_system_user_edit', ['id' => $user->getId()]));
}
return $this->render('admin/system/user/modal_approve_role.html.twig', [
'user' => $user,
'roleLabel' => User::ROLES[$role],
]);
}
}
@@ -2,6 +2,7 @@
namespace App\Controller\Admin\System\User;
use App\Config\HouseCatalog;
use App\Entity\User;
use App\Form\UserType;
use App\Security\Voter\UserVoter;
@@ -18,6 +19,7 @@ class EditController extends AbstractController
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly HouseCatalog $houseCatalog,
) {
}
@@ -44,9 +46,21 @@ class EditController extends AbstractController
return $this->redirectToRoute('app_admin_system_user_index');
}
// a code with no house behind it stays visible under its own name rather than
// vanishing from the page - it is what the user is actually restricted to
$houses = [];
foreach ($user->getHotelCodes() as $code) {
$houses[$code] = $this->houseCatalog->getName($code) ?? $code;
}
return $this->render('admin/system/user/edit.html.twig', [
'form' => $form,
'user' => $user,
'grantedRoles' => array_map(
static fn (string $role): string => User::ROLES[$role],
$user->getAssignedRoles(),
),
'houses' => $houses,
]);
}
}
+21
View File
@@ -231,6 +231,27 @@ class User implements UserInterface, TimestampableEntityInterface, SoftDeletable
return array_values(array_intersect($this->roles, array_keys(self::ROLES)));
}
/**
* The roles this user is nominated for but does not hold, as role => label.
*
* The markers are storage; this is what an approver acts on, so it is keyed by the real
* role rather than by the marker standing in for it.
*
* @return array<string, string>
*/
public function getNominatedRoles(): array
{
$roles = [];
foreach (self::PENDING_ROLES as $role => $pendingRole) {
if (true === in_array($pendingRole, $this->roles, true)) {
$roles[$role] = self::ROLES[$role];
}
}
return $roles;
}
/**
* The pending markers currently held by the user.
*/
+24 -29
View File
@@ -2,7 +2,6 @@
namespace App\Form;
use App\Config\HouseCatalog;
use App\Entity\User;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
@@ -12,27 +11,20 @@ use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* The account settings of an administrative user - and nothing else.
*
* Roles and hotel codes are synced from BusPro on every login and are not editable here or
* anywhere else, so they are not fields: the edit page renders them as information. The one
* role decision left to a human, approving a nomination, is its own action with its own
* confirmation (see App\Controller\Admin\System\User\ApproveRoleController). Keeping all
* three apart is deliberate - a form field that cannot be submitted only looks broken.
*/
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.',
])
->add('disabled', CheckboxType::class, [
'label' => 'Account gesperrt',
'required' => false,
@@ -74,23 +66,26 @@ class UserType extends AbstractType
;
});
// 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 {
// Super admin is an elevation of an existing role, never a grant of its own, so the
// field exists only for somebody who already holds ROLE_ADMIN - offering it to
// anyone else would only produce the violation from User::validateSuperAdmin().
// A flag already set without the role is the exception: it has to stay editable, or
// that user could not be saved at all until BusPro claims them an admin again.
$builder->addEventListener(FormEvents::PRE_SET_DATA, static 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;
}
if (false === $user instanceof User) {
return;
}
$event->getForm()->add('hotelCodes', MultiselectType::class, [
'label' => 'Häuser',
if (false === in_array('ROLE_ADMIN', $user->getAssignedRoles(), true) && false === $user->isSuperAdmin()) {
return;
}
$event->getForm()->add('superAdmin', CheckboxType::class, [
'label' => 'Superadmin',
'required' => false,
'choices' => $choices,
'empty_label' => 'keine Häuser',
'help' => 'Darf Rollen freischalten und Accounts verwalten.',
]);
});
}
+13 -5
View File
@@ -128,8 +128,15 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
// Determine teamer status from CRM attributes
$isTeamer = $crmAttributes->isTeamer();
// Everything the CRM grants this person here: pending markers plus ROLE_TEAMER
// What the CRM claims, as plain role names, and what this application makes of it:
// pending markers plus ROLE_TEAMER. The first drives the sync, the second is what
// a new account starts with.
$claimedRoles = $this
->userDataHandler
->collectClaimedRoles($crmAttributes)
;
$grantedRoles = $this
->userDataHandler
->collectRoles($crmAttributes)
;
@@ -180,8 +187,8 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
return $user;
}
// 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
// Update the existing user, re-syncing roles and hotel codes from the CRM: it
// leads, so a role or a house it no longer reports is withdrawn here
if (null !== $user) {
$this
->userDataHandler
@@ -190,7 +197,8 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
$profileResponse,
$isTeamer,
$crmSelections,
$this->userDataHandler->collectPendingRoles($crmAttributes),
$claimedRoles,
$crmAttributes->getHotelCodes(),
)
;
@@ -202,7 +210,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
->userDataHandler
->createLocalUser(
$profileResponse,
$claimedRoles,
$grantedRoles,
$isTeamer,
$crmSelections,
$crmAttributes->getHotelCodes(),
+22 -11
View File
@@ -161,6 +161,7 @@ class MyEpAuthenticator extends AbstractAuthenticator
$isTeamer = in_array('ROLE_TEAMER', $claimedRoles, true);
$pendingRoles = $this->userDataHandler->toPendingRoles($claimedRoles);
$hotelCodes = $this->collectHotelCodes($userinfo);
$user = $this->findLocalUser($userinfo);
@@ -177,23 +178,20 @@ class MyEpAuthenticator extends AbstractAuthenticator
return $user;
}
// Update existing user, leaving granted roles and hotel codes alone: they are
// imported once on creation and managed manually afterwards. Only the
// privilege-free markers and ROLE_TEAMER track MyE&P on every login.
// Update existing user. MyE&P leads here exactly as BusPro does on the other login
// path - the shared policy in UserDataHandler withdraws what is no longer claimed
// and marks administrative claims for approval rather than granting them.
if (null !== $user) {
$this->refreshBusProIds($user, $userinfo);
$this->userDataHandler->refreshPendingRoles($user, $pendingRoles);
if (true === $isTeamer) {
$this->userDataHandler->grantTeamerRole($user);
}
$this->userDataHandler->syncRoles($user, $claimedRoles);
$this->userDataHandler->syncHotelCodes($user, $hotelCodes);
$this->entityManager->flush();
return $user;
}
return $this->createLocalUser($userinfo, $pendingRoles, $isTeamer);
return $this->createLocalUser($userinfo, $pendingRoles, $isTeamer, $hotelCodes);
}
/**
@@ -233,6 +231,19 @@ class MyEpAuthenticator extends AbstractAuthenticator
return $claimedRoles;
}
/**
* The houses MyE&P reports for this person. Read on both the create and the update
* path, since they are led by the identity provider just like the roles are.
*
* @return string[]
*/
private function collectHotelCodes(array $userinfo): array
{
$profile = is_array($userinfo['profile'] ?? null) ? $userinfo['profile'] : [];
return is_array($profile['hotel_codes'] ?? null) ? array_values($profile['hotel_codes']) : [];
}
/**
* Matches the local account on the BusPro ids first and falls back to the email, the
* same precedence UserDataHandler::findLocalUser() applies to a BusPro login, so that
@@ -300,8 +311,9 @@ class MyEpAuthenticator extends AbstractAuthenticator
/**
* @param string[] $pendingRoles
* @param string[] $hotelCodes
*/
private function createLocalUser(array $userinfo, array $pendingRoles, bool $isTeamer): ?User
private function createLocalUser(array $userinfo, array $pendingRoles, bool $isTeamer, array $hotelCodes): ?User
{
$addressId = $userinfo['address_id'] ?? null;
$personId = $userinfo['person_id'] ?? null;
@@ -318,7 +330,6 @@ class MyEpAuthenticator extends AbstractAuthenticator
}
$profile = is_array($userinfo['profile'] ?? null) ? $userinfo['profile'] : [];
$hotelCodes = is_array($profile['hotel_codes'] ?? null) ? $profile['hotel_codes'] : [];
$user = new User();
$user