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

This commit is contained in:
Björn Fromme committed 2026-08-18 11:33:42 +02:00
1 parent 1fd0fbc21e
commit f0e850978b
17 files changed
+818 -198

No files matched your search

+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.
*/