feat: bpn as single source of truth for role and hotel code assignments
This commit is contained in:
1 parent
1fd0fbc21e
commit
f0e850978b
17 files changed
+818
-198
No files matched your search
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user