feat: bpn as single source of truth for role and hotel code assignments
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user