feat: share the role policy across identity sources

This commit is contained in:
Björn Fromme
2026-08-12 17:55:16 +02:00
parent 8b09845d96
commit 450f5b0515
2 changed files with 100 additions and 6 deletions
+47 -6
View File
@@ -11,6 +11,13 @@ use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface; 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.
*/
class UserDataHandler class UserDataHandler
{ {
public function __construct( public function __construct(
@@ -45,15 +52,45 @@ class UserDataHandler
*/ */
public function collectPendingRoles(CrmAttributesResponse $crmAttributes): array public function collectPendingRoles(CrmAttributesResponse $crmAttributes): array
{ {
$roles = []; $claimedRoles = [];
if ($crmAttributes->isAdmin()) { if ($crmAttributes->isAdmin()) {
$roles[] = User::PENDING_ROLES['ROLE_ADMIN']; $claimedRoles[] = 'ROLE_ADMIN';
} }
if ($crmAttributes->isManager()) { if ($crmAttributes->isManager()) {
$claimedRoles[] = 'ROLE_MANAGER';
}
if ($crmAttributes->isHouseManager()) {
$claimedRoles[] = 'ROLE_HOUSE_MANAGER';
}
return $this->toPendingRoles($claimedRoles);
}
/**
* Maps a list of claimed roles to the pending markers for the administrative ones
* among them. This is the same policy collectPendingRoles() applies to the CRM
* 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.
*
* @param string[] $claimedRoles
*
* @return string[]
*/
public function toPendingRoles(array $claimedRoles): array
{
$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']; $roles[] = User::PENDING_ROLES['ROLE_MANAGER'];
} elseif ($crmAttributes->isHouseManager()) { } elseif (true === in_array('ROLE_HOUSE_MANAGER', $claimedRoles, true)) {
$roles[] = User::PENDING_ROLES['ROLE_HOUSE_MANAGER']; $roles[] = User::PENDING_ROLES['ROLE_HOUSE_MANAGER'];
} }
@@ -279,8 +316,10 @@ class UserDataHandler
* *
* It is never withdrawn here. Losing the CRM attribute while holding no other role * 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. * blocks the account anyway, and a role handed out manually must survive a login.
*
* Does not flush; the caller decides when to.
*/ */
private function grantTeamerRole(User $user): void public function grantTeamerRole(User $user): void
{ {
$grantedRoles = $user->getAssignedRoles(); $grantedRoles = $user->getAssignedRoles();
@@ -302,9 +341,11 @@ class UserDataHandler
* super admin can turn one into an actual role, and an already granted role is never * super admin can turn one into an actual role, and an already granted role is never
* marked as pending again. * marked as pending again.
* *
* @param string[] $claimedRoles * Does not flush; the caller decides when to.
*
* @param string[] $claimedRoles pending markers as returned by toPendingRoles()
*/ */
private function refreshPendingRoles(User $user, array $claimedRoles): void public function refreshPendingRoles(User $user, array $claimedRoles): void
{ {
// an approved role needs no marker anymore // an approved role needs no marker anymore
$grantedRoles = $user->getAssignedRoles(); $grantedRoles = $user->getAssignedRoles();
+53
View File
@@ -83,6 +83,59 @@ class UserDataHandlerTest extends TestCase
]; ];
} }
/**
* The role policy has to be identical whichever identity source claims the role, so
* toPendingRoles() must agree with collectPendingRoles() on equivalent input.
*
* @dataProvider toPendingRolesProvider
*/
public function testToPendingRolesMatchesTheCrmPolicy(array $claimedRoles, CrmAttributesResponse $crmAttributes, array $expectedRoles): void
{
$handler = new UserDataHandler($this->entityManager, $this->logger);
$this->assertSame($expectedRoles, $handler->toPendingRoles($claimedRoles));
$this->assertSame($expectedRoles, $handler->collectPendingRoles($crmAttributes));
}
public static function toPendingRolesProvider(): iterable
{
yield 'admin' => [
['ROLE_ADMIN'],
(new CrmAttributesResponse())->setAdmin(true),
[User::PENDING_ROLES['ROLE_ADMIN']],
];
yield 'manager takes precedence over house manager' => [
['ROLE_HOUSE_MANAGER', 'ROLE_MANAGER'],
(new CrmAttributesResponse())->setManager(true)->setHouseManager(true),
[User::PENDING_ROLES['ROLE_MANAGER']],
];
yield 'house manager' => [
['ROLE_HOUSE_MANAGER'],
(new CrmAttributesResponse())->setHouseManager(true),
[User::PENDING_ROLES['ROLE_HOUSE_MANAGER']],
];
yield 'admin and house manager' => [
['ROLE_ADMIN', 'ROLE_HOUSE_MANAGER'],
(new CrmAttributesResponse())->setAdmin(true)->setHouseManager(true),
[User::PENDING_ROLES['ROLE_ADMIN'], User::PENDING_ROLES['ROLE_HOUSE_MANAGER']],
];
yield 'teamer has no marker' => [
['ROLE_TEAMER'],
(new CrmAttributesResponse())->setTeamer(true),
[],
];
yield 'nothing claimed' => [
[],
new CrmAttributesResponse(),
[],
];
}
public function testUpdateLocalUserSyncsUserAndTeamerDataFromBusPro(): void public function testUpdateLocalUserSyncsUserAndTeamerDataFromBusPro(): void
{ {
$user = (new User()) $user = (new User())