feat: reserve administrative roles for staff email addresses

This commit is contained in:
2026-09-17 18:13:52 +02:00
parent 725e32daab
commit 540cd8eeb7
11 changed files with 230 additions and 70 deletions
+1 -1
View File
@@ -381,7 +381,7 @@ OIDC-style claims for the authenticated user. The response contains only the cla
- always: `email`
- scope `id`: `person_id`, `address_id`
- scope `roles`: `roles` (array; only the roles that actually grant something are exported — the implicit baseline role is stripped, and so are the `*_PENDING` markers of roles the BusPro CRM claims but nobody has approved yet, see `docs/user-roles.md`)
- scope `roles`: `roles` (array; only the roles that actually grant something are exported — the implicit baseline role is stripped, and so are the `*_PENDING` markers of roles the BusPro CRM claims but nobody has approved yet, see `docs/buspronet-schema/crm-selection-queries.md#from-claim-to-role`)
- scope `profile`: `profile` object:
```json
+4
View File
@@ -577,6 +577,10 @@ Known role mapping from the XML parser:
- admin users also receive default hotel code `SSL`
- no matched role means `ROLE_CUSTOMER`
These are the CRM's claims. The portal filters them before storing anything (staff-only
roles, approval markers), so a proxy must return the claims unfiltered — see
`docs/buspronet-schema/crm-selection-queries.md#from-claim-to-role`.
Candidate SQL for selected customer selections:
```sql
+34 -5
View File
@@ -29,6 +29,35 @@ Symfony roles:
Only selections with `auswahl="True"` count. Every `Admin/*` controller is gated on
one of these roles.
These are *claims*, not grants. What an account ends up holding is decided by
`Role::sync()` (`src/Security/Role.php`) on every login — see
[From claim to role](#from-claim-to-role).
---
## From claim to role
BusPro backend users can edit their own CRM selections, so the claims above are
filtered before anything is stored:
| Role | Claimed without a staff address | Claimed with a staff address |
| --- | --- | --- |
| `ROLE_TEAMER` | granted | granted |
| `ROLE_HOUSE_MANAGER` | nominated | nominated |
| `ROLE_ADMIN`, `ROLE_TEAM_ADMIN`, `ROLE_MANAGER`, `ROLE_GROUPS_ADMIN`, `ROLE_GROUPS_MANAGER`, `ROLE_CUSTOMER_EXPERT` (`Role::EMPLOYEE_ONLY`) | **ignored** | nominated |
- **Staff address**: the login email's domain is listed in `employee_email_domains`
(`config/services.yaml`, exact match, never a suffix). Such an account also gets
`ROLE_EMPLOYEE`, which is derived from the address and not from the CRM.
- **Nominated**: stored as the marker `ROLE_X_PENDING`, which grants nothing until an
administrator approves it in `/admin/user`. Nominations are announced to
`role_nomination_notification_emails`.
- **Ignored**: treated exactly like a selection that is not set. No marker is stored,
and a role approved earlier is **revoked** on the next login. A leftover marker from
before the rule cannot be approved and disappears on that login too.
- Anything the CRM stops claiming is revoked, approved or not.
- An account left without any effective role gets `ROLE_CUSTOMER`.
---
## The four tables
@@ -398,11 +427,11 @@ BusProNet host is the fix, and no schema change is needed.
## Portal side: a correct XML is not enough
`BpnAuthenticator::createOrUpdateLocalUser()` snapshots the roles onto the local `User`
entity at login (`setRoles($crmAttributes->roles)` /
`setHotelCodes($crmAttributes->hotelCodes)`). Roles are **not** re-read per request. So
after fixing BusPro the affected user must **log out and log in again** before
`ROLE_GROUPS_ADMIN` takes effect — an active session keeps the stale role set.
`BpnAuthenticator::syncFromCrm()` writes the roles onto the local `User` entity at login,
through `Role::sync()` (see [From claim to role](#from-claim-to-role)), and the hotel codes
verbatim. Roles are **not** re-read per request. So after fixing BusPro the affected user
must **log out and log in again**, and even then `ROLE_GROUPS_ADMIN` only takes effect once
the account has a staff address and an administrator has approved the nomination.
---
+7 -5
View File
@@ -182,9 +182,11 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes)))
;
// Compared on the full role sets rather than on the markers alone: nominatedFrom() needs
// to see ROLE_EMPLOYEE to know whether an EMPLOYEE_ONLY marker counts.
$nominated = array_values(array_diff(
Role::pendingOnly($user->getRoles()),
Role::pendingOnly($previousRoles),
array_keys(Role::nominatedFrom($user->getRoles())),
array_keys(Role::nominatedFrom($previousRoles)),
));
if ([] === $nominated) {
@@ -198,9 +200,9 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
'roles' => $nominated,
]);
// Only the newly appeared markers reach this point, so a repeat login with a nomination
// still standing announces nothing. That difference is the whole de-duplication.
return array_keys(Role::nominatedFrom($nominated));
// Only the newly appeared nominations reach this point, so a repeat login with a
// nomination still standing announces nothing. That difference is the whole de-duplication.
return $nominated;
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
+45 -4
View File
@@ -23,6 +23,10 @@ namespace App\Security;
* That widens a claim from "what the CRM reports" to "what the CRM reports plus what the account
* itself implies", and nothing more: ROLE_EMPLOYEE is not administrative, so it cannot reach the
* nomination path, and the CRM remains the only source for every role that grants privileges.
*
* ROLE_EMPLOYEE does gate a few administrative roles, though (see EMPLOYEE_ONLY): for those, a
* CRM claim only counts when the account is staff. It narrows what the CRM can nominate and
* never widens it — the approval step still applies on top.
*/
final class Role
{
@@ -33,6 +37,7 @@ final class Role
public const USER = 'ROLE_USER';
public const ADMIN = 'ROLE_ADMIN';
public const TEAM_ADMIN = 'ROLE_TEAM_ADMIN';
public const MANAGER = 'ROLE_MANAGER';
public const TEAMER = 'ROLE_TEAMER';
public const CUSTOMER = 'ROLE_CUSTOMER';
@@ -58,6 +63,7 @@ final class Role
*/
public const ALL = [
self::ADMIN,
self::TEAM_ADMIN,
self::MANAGER,
self::TEAMER,
self::CUSTOMER,
@@ -90,6 +96,7 @@ final class Role
*/
public const ADMINISTRATIVE = [
self::ADMIN,
self::TEAM_ADMIN,
self::MANAGER,
self::HOUSE_MANAGER,
self::GROUPS_ADMIN,
@@ -97,6 +104,25 @@ final class Role
self::CUSTOMER_EXPERT,
];
/**
* Administrative roles reserved for staff accounts. A CRM claim for one of these is ignored
* unless the account is also claimed as ROLE_EMPLOYEE, so it neither nominates nor keeps a
* role approved earlier. BusPro backend users outside the company can edit their own CRM
* selections too, and these roles reach far enough that the email domain has to agree.
* ROLE_HOUSE_MANAGER is the one administrative role left out: a Hausleitung signs in with
* the hotel's own address.
*
* @var string[]
*/
public const EMPLOYEE_ONLY = [
self::ADMIN,
self::TEAM_ADMIN,
self::MANAGER,
self::GROUPS_ADMIN,
self::GROUPS_MANAGER,
self::CUSTOMER_EXPERT,
];
/**
* Roles nobody may approve for their own account. ROLE_ADMIN outranks every check in this
* application, including the approval surface itself, so it always takes a second
@@ -159,7 +185,9 @@ final class Role
}
/**
* The roles behind those markers, labelled — what an approver acts on.
* The roles behind those markers, labelled — what an approver acts on. A marker for an
* EMPLOYEE_ONLY role on an account that is not staff is left out: it predates the rule and
* goes on that account's next login, and until then it must not be approvable.
*
* @param string[] $roles
*
@@ -170,9 +198,15 @@ final class Role
$labels = self::labels();
$nominated = [];
$isEmployee = \in_array(self::EMPLOYEE, $roles, true);
foreach (self::pendingOnly($roles) as $marker) {
$role = self::realRole($marker);
if (false === $isEmployee && \in_array($role, self::EMPLOYEE_ONLY, true)) {
continue;
}
if (\in_array($role, self::ADMINISTRATIVE, true)) {
$nominated[$role] = $labels[$role];
}
@@ -184,6 +218,8 @@ final class Role
/**
* The whole policy, applied on every login.
*
* 0. drop the claims for EMPLOYEE_ONLY roles unless ROLE_EMPLOYEE is claimed as well — they
* then count as not made, so the steps below revoke and never mark them;
* 1. revoke everything the CRM no longer claims — granted roles and markers alike, which is
* what makes BusPro the source of truth;
* 2. grant the unconditional roles it claims;
@@ -204,6 +240,10 @@ final class Role
{
$claimed = array_values(array_intersect(self::ALL, array_unique($claimedRoles)));
if (false === \in_array(self::EMPLOYEE, $claimed, true)) {
$claimed = array_values(array_diff($claimed, self::EMPLOYEE_ONLY));
}
$roles = array_values(array_filter(
self::assignedOnly($storedRoles),
static fn (string $role): bool => \in_array(self::realRole($role), $claimed, true),
@@ -224,8 +264,8 @@ final class Role
/**
* Turns a marker into the role it stands for. Refuses anything the account is not nominated
* for, so neither a hand-crafted request nor a claim revoked while the confirmation dialog
* was open can grant a role the CRM never reported.
* for (see nominatedFrom()), so neither a hand-crafted request nor a claim revoked while the
* confirmation dialog was open can grant a role the CRM never reported.
*
* @param string[] $storedRoles
*
@@ -237,7 +277,7 @@ final class Role
{
$roles = self::assignedOnly($storedRoles);
if (false === \in_array(self::pending($role), $roles, true)) {
if (false === \array_key_exists($role, self::nominatedFrom($roles))) {
throw new \InvalidArgumentException(sprintf('The role "%s" is not pending approval.', $role));
}
@@ -284,6 +324,7 @@ final class Role
{
$labels = [
self::ADMIN => 'Administration',
self::TEAM_ADMIN => 'Team Administration',
self::MANAGER => 'Manager:in',
self::TEAMER => 'Teamer:in',
self::CUSTOMER => 'Kund:in',
@@ -33,11 +33,11 @@ class ApproveRoleControllerTest extends TestCase
$controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class));
$response = $controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN'));
$response = $controller->index($user, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('admin/user/modal_approve_role.html.twig', $controller->renderedView);
self::assertSame([Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)], Role::assignedOnly($user->getRoles()));
self::assertSame([Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)], Role::assignedOnly($user->getRoles()));
}
public function testPostGrantsTheRoleAndRedirectsTheBrowser(): void
@@ -49,9 +49,9 @@ class ApproveRoleControllerTest extends TestCase
$controller = new TestableApproveRoleController($entityManager, $this->createStub(LoggerInterface::class));
$response = $controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
$response = $controller->index($user, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], Role::assignedOnly($user->getRoles()));
self::assertSame([Role::TEAMER, Role::HOUSE_MANAGER], Role::assignedOnly($user->getRoles()));
self::assertTrue($response->headers->has('HX-Redirect'));
self::assertSame(['success'], array_column($controller->flashes, 'type'));
}
@@ -94,9 +94,9 @@ class ApproveRoleControllerTest extends TestCase
// Only ROLE_ADMIN needs a second pair of eyes — an approver already holds it, so the
// rest grant less than they could grant themselves anyway.
$controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
$controller->index($user, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], Role::assignedOnly($user->getRoles()));
self::assertSame([Role::TEAMER, Role::HOUSE_MANAGER], Role::assignedOnly($user->getRoles()));
}
public function testPostWithAnInvalidTokenIsDenied(): void
@@ -108,7 +108,7 @@ class ApproveRoleControllerTest extends TestCase
$this->expectException(AccessDeniedException::class);
$controller->index($this->nominatedUser(), Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
$controller->index($this->nominatedUser(), Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
}
public function testApprovingForYourselfReissuesTheSecurityToken(): void
@@ -125,12 +125,12 @@ class ApproveRoleControllerTest extends TestCase
tokenStorage: $tokenStorage,
);
$controller->index($user, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
$controller->index($user, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
// Without this the next request would find the stored roles out of step with the token
// and end the session, logging the approver out mid-action.
self::assertContains(Role::GROUPS_ADMIN, $tokenStorage->getToken()?->getRoleNames() ?? []);
self::assertNotContains(Role::pending(Role::GROUPS_ADMIN), $tokenStorage->getToken()?->getRoleNames() ?? []);
self::assertContains(Role::HOUSE_MANAGER, $tokenStorage->getToken()?->getRoleNames() ?? []);
self::assertNotContains(Role::pending(Role::HOUSE_MANAGER), $tokenStorage->getToken()?->getRoleNames() ?? []);
}
public function testApprovingForSomebodyElseLeavesYourOwnTokenAlone(): void
@@ -148,14 +148,14 @@ class ApproveRoleControllerTest extends TestCase
tokenStorage: $tokenStorage,
);
$controller->index($other, Role::GROUPS_ADMIN, Request::create('/admin/user/1/approve/ROLE_GROUPS_ADMIN', 'POST'));
$controller->index($other, Role::HOUSE_MANAGER, Request::create('/admin/user/1/approve/ROLE_HOUSE_MANAGER', 'POST'));
self::assertSame($originalToken, $tokenStorage->getToken());
}
private function nominatedUser(): User
{
return (new User('[email protected]'))->setRoles([Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)]);
return (new User('[email protected]'))->setRoles([Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)]);
}
}
@@ -21,7 +21,7 @@ class ShowControllerTest extends TestCase
$controller->index($this->nominatedUser(), $this->permissionsRequest());
self::assertSame(
[Role::MANAGER => 'Manager:in', Role::GROUPS_ADMIN => 'Preisrechner Admin'],
[Role::HOUSE_MANAGER => 'Hausleitung', Role::GROUPS_ADMIN => 'Preisrechner Admin'],
$controller->parameters['approvableRoles'],
);
self::assertSame([], $controller->parameters['selfRefusedRoles']);
@@ -29,7 +29,7 @@ class ShowControllerTest extends TestCase
public function testWhatCannotBeSelfApprovedIsNotOffered(): void
{
$user = (new User('admin@example.org'))->setRoles([Role::ADMIN, Role::pending(Role::ADMIN), Role::pending(Role::MANAGER)]);
$user = (new User('admin@ep-reisen.de'))->setRoles([Role::EMPLOYEE, Role::ADMIN, Role::pending(Role::ADMIN), Role::pending(Role::MANAGER)]);
$controller = new TestableShowController(currentUser: $user);
@@ -62,8 +62,8 @@ class ShowControllerTest extends TestCase
private function nominatedUser(): User
{
return (new User('teamer@example.org'))
->setRoles([Role::TEAMER, Role::pending(Role::MANAGER), Role::pending(Role::GROUPS_ADMIN)]);
return (new User('teamer@ep-reisen.de'))
->setRoles([Role::TEAMER, Role::EMPLOYEE, Role::pending(Role::HOUSE_MANAGER), Role::pending(Role::GROUPS_ADMIN)]);
}
/**
@@ -19,7 +19,7 @@ class PendingRoleApprovalsWidgetProviderTest extends TestCase
$user = (new User('[email protected]'))
->setFirstName('Rita')
->setLastName('Vorschlag')
->setRoles([Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)])
->setRoles([Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)])
;
$widget = $this->provider([$user])->build();
@@ -27,7 +27,7 @@ class PendingRoleApprovalsWidgetProviderTest extends TestCase
$this->assertNotNull($widget);
$this->assertCount(1, $widget->entries);
$this->assertStringContainsString('Rita Vorschlag', $widget->entries[0]->label);
$this->assertStringContainsString(Role::labels()[Role::GROUPS_ADMIN], $widget->entries[0]->label);
$this->assertStringContainsString(Role::labels()[Role::HOUSE_MANAGER], $widget->entries[0]->label);
}
/**
+43 -15
View File
@@ -43,7 +43,7 @@ class BpnAuthenticatorTest extends TestCase
{
$persisted = null;
$authenticator = $this->authenticator(
$this->crmAttributes([Role::ADMIN, Role::TEAMER], ['SSL', 'SSL']),
$this->crmAttributes([Role::HOUSE_MANAGER, Role::TEAMER], ['SSL', 'SSL']),
null,
$persisted,
);
@@ -51,7 +51,7 @@ class BpnAuthenticatorTest extends TestCase
$user = $this->loadUser($authenticator);
self::assertSame($persisted, $user);
self::assertSame(['ROLE_USER', Role::TEAMER, Role::pending(Role::ADMIN)], $user->getRoles());
self::assertSame(['ROLE_USER', Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)], $user->getRoles());
self::assertSame(['SSL'], $user->getHotelCodes());
}
@@ -61,7 +61,7 @@ class BpnAuthenticatorTest extends TestCase
$persisted = null;
$authenticator = $this->authenticator(
$this->crmAttributes([Role::TEAMER, Role::GROUPS_ADMIN], []),
$this->crmAttributes([Role::TEAMER, Role::HOUSE_MANAGER], []),
$existing,
$persisted,
);
@@ -70,7 +70,7 @@ class BpnAuthenticatorTest extends TestCase
self::assertSame($existing, $persisted, 'a login must not create a second account');
self::assertSame(
['ROLE_USER', Role::TEAMER, Role::pending(Role::GROUPS_ADMIN)],
['ROLE_USER', Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)],
$user->getRoles(),
);
self::assertNotNull($user->getLastLoginAt(), 'the rest of the profile is still synced');
@@ -78,24 +78,24 @@ class BpnAuthenticatorTest extends TestCase
public function testApprovedRoleSurvivesTheNextLogin(): void
{
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]);
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::HOUSE_MANAGER]);
$persisted = null;
$authenticator = $this->authenticator(
$this->crmAttributes([Role::TEAMER, Role::GROUPS_MANAGER], []),
$this->crmAttributes([Role::TEAMER, Role::HOUSE_MANAGER], []),
$existing,
$persisted,
);
self::assertSame(
['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER],
['ROLE_USER', Role::TEAMER, Role::HOUSE_MANAGER],
$this->loadUser($authenticator)->getRoles(),
);
}
public function testRoleRevokedInBusProIsWithdrawnOnLogin(): void
{
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::GROUPS_MANAGER]);
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::HOUSE_MANAGER]);
$persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([], []), $existing, $persisted);
@@ -140,7 +140,7 @@ class BpnAuthenticatorTest extends TestCase
public function testDegradedCrmResponseLeavesAnExistingAccountUntouched(): void
{
$existing = (new User('[email protected]'))
->setRoles([Role::TEAMER, Role::GROUPS_MANAGER])
->setRoles([Role::TEAMER, Role::HOUSE_MANAGER])
->setHotelCodes(['DKS'])
;
@@ -155,7 +155,7 @@ class BpnAuthenticatorTest extends TestCase
$user = $this->loadUser($authenticator);
self::assertSame(['ROLE_USER', Role::TEAMER, Role::GROUPS_MANAGER], $user->getRoles());
self::assertSame(['ROLE_USER', Role::TEAMER, Role::HOUSE_MANAGER], $user->getRoles());
self::assertSame(['DKS'], $user->getHotelCodes());
}
@@ -240,12 +240,40 @@ class BpnAuthenticatorTest extends TestCase
self::assertSame(['ROLE_USER', Role::CUSTOMER], $user->getRoles());
}
public function testEmployeeOnlyClaimFromAnotherDomainIsIgnored(): void
{
$persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN, Role::TEAMER], []), null, $persisted);
$user = $this->loadUser($authenticator, '[email protected]');
// Not even a nomination, so there is nothing for an administrator to be told about.
self::assertSame(['ROLE_USER', Role::TEAMER], $user->getRoles());
self::assertSame([], $this->dispatched);
}
public function testEmployeeOnlyRoleIsRevokedWhenTheAddressIsNotStaff(): void
{
$existing = (new User('[email protected]'))->setRoles([Role::TEAMER, Role::ADMIN, Role::HOUSE_MANAGER]);
$persisted = null;
$authenticator = $this->authenticator(
$this->crmAttributes([Role::TEAMER, Role::ADMIN, Role::HOUSE_MANAGER], []),
$existing,
$persisted,
);
$user = $this->loadUser($authenticator, '[email protected]');
// Only the EMPLOYEE_ONLY role goes; a Hausleitung does not need a staff address.
self::assertSame(['ROLE_USER', Role::TEAMER, Role::HOUSE_MANAGER], $user->getRoles());
}
public function testANewNominationIsAnnouncedOnce(): void
{
$persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), null, $persisted);
$user = $this->loadUser($authenticator);
$user = $this->loadUser($authenticator, '[email protected]');
self::assertCount(1, $this->dispatched);
$message = $this->dispatched[0];
@@ -257,11 +285,11 @@ class BpnAuthenticatorTest extends TestCase
public function testAStandingNominationIsNotAnnouncedAgain(): void
{
$existing = (new User('[email protected]'))->setRoles([Role::pending(Role::ADMIN)]);
$existing = (new User('[email protected]'))->setRoles([Role::EMPLOYEE, Role::pending(Role::ADMIN)]);
$persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted);
$this->loadUser($authenticator);
$this->loadUser($authenticator, '[email protected]');
// The nomination has not changed, so there is nothing new to tell an administrator about.
self::assertSame([], $this->dispatched);
@@ -269,11 +297,11 @@ class BpnAuthenticatorTest extends TestCase
public function testAnApprovedRoleIsNotAnnouncedAsANomination(): void
{
$existing = (new User('[email protected]'))->setRoles([Role::ADMIN]);
$existing = (new User('[email protected]'))->setRoles([Role::EMPLOYEE, Role::ADMIN]);
$persisted = null;
$authenticator = $this->authenticator($this->crmAttributes([Role::ADMIN], []), $existing, $persisted);
$this->loadUser($authenticator);
$this->loadUser($authenticator, '[email protected]');
self::assertSame([], $this->dispatched);
}
+76 -20
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Security;
use App\Security\Role;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
@@ -15,46 +16,46 @@ class RoleTest extends TestCase
{
public function testAdministrativeClaimOnlyProducesANomination(): void
{
$roles = Role::sync([], [Role::ADMIN, Role::GROUPS_ADMIN, Role::TEAMER]);
$roles = Role::sync([], [Role::HOUSE_MANAGER, Role::GROUPS_ADMIN, Role::TEAMER, Role::EMPLOYEE]);
self::assertSame(
[Role::TEAMER, Role::pending(Role::ADMIN), Role::pending(Role::GROUPS_ADMIN)],
[Role::TEAMER, Role::EMPLOYEE, Role::pending(Role::HOUSE_MANAGER), Role::pending(Role::GROUPS_ADMIN)],
$roles,
);
self::assertSame([Role::TEAMER], Role::effectiveOnly($roles));
self::assertSame([Role::TEAMER, Role::EMPLOYEE], Role::effectiveOnly($roles));
}
public function testCustomerExpertClaimOnlyProducesANomination(): void
{
$roles = Role::sync([], [Role::CUSTOMER_EXPERT]);
$roles = Role::sync([], [Role::EMPLOYEE, Role::CUSTOMER_EXPERT]);
self::assertSame([Role::pending(Role::CUSTOMER_EXPERT), Role::CUSTOMER], $roles);
self::assertSame([Role::CUSTOMER], Role::effectiveOnly($roles));
self::assertSame([Role::EMPLOYEE, Role::pending(Role::CUSTOMER_EXPERT)], $roles);
self::assertSame([Role::EMPLOYEE], Role::effectiveOnly($roles));
self::assertSame(
[Role::CUSTOMER_EXPERT => 'KO-Experte'],
[Role::CUSTOMER_EXPERT => 'KO-Expert:in'],
Role::nominatedFrom($roles),
);
}
public function testApprovedCustomerExpertDisplacesTheCustomerFallback(): void
public function testCustomerExpertCanBeApprovedForStaff(): void
{
$roles = Role::approve([Role::pending(Role::CUSTOMER_EXPERT), Role::CUSTOMER], Role::CUSTOMER_EXPERT);
$roles = Role::approve([Role::EMPLOYEE, Role::pending(Role::CUSTOMER_EXPERT)], Role::CUSTOMER_EXPERT);
self::assertSame([Role::CUSTOMER_EXPERT], $roles);
self::assertSame([Role::EMPLOYEE, Role::CUSTOMER_EXPERT], $roles);
}
public function testApprovedRoleSurvivesTheNextSyncAndIsNotMarkedAgain(): void
{
$roles = Role::sync([Role::TEAMER, Role::GROUPS_ADMIN], [Role::GROUPS_ADMIN, Role::TEAMER]);
$roles = Role::sync([Role::TEAMER, Role::HOUSE_MANAGER], [Role::HOUSE_MANAGER, Role::TEAMER]);
self::assertSame([Role::TEAMER, Role::GROUPS_ADMIN], $roles);
self::assertSame([Role::TEAMER, Role::HOUSE_MANAGER], $roles);
}
public function testRoleTheCrmNoLongerClaimsIsRevoked(): void
{
// Both halves go: BusPro is the source of truth for the granted role as much as for
// the nomination.
$roles = Role::sync([Role::TEAMER, Role::ADMIN, Role::pending(Role::MANAGER)], [Role::TEAMER]);
$roles = Role::sync([Role::TEAMER, Role::GROUPS_ADMIN, Role::pending(Role::HOUSE_MANAGER)], [Role::TEAMER]);
self::assertSame([Role::TEAMER], $roles);
}
@@ -69,8 +70,8 @@ class RoleTest extends TestCase
// The nomination stays visible — it is what an approver acts on — but grants nothing,
// so the account is a customer in the meantime.
self::assertSame(
[Role::pending(Role::ADMIN), Role::CUSTOMER],
Role::sync([], [Role::ADMIN]),
[Role::pending(Role::HOUSE_MANAGER), Role::CUSTOMER],
Role::sync([], [Role::HOUSE_MANAGER]),
);
self::assertSame([Role::CUSTOMER], Role::sync([], []));
}
@@ -90,10 +91,10 @@ class RoleTest extends TestCase
public function testApprovalTurnsTheNominationIntoTheRole(): void
{
$roles = Role::approve([Role::pending(Role::ADMIN), Role::CUSTOMER], Role::ADMIN);
$roles = Role::approve([Role::pending(Role::HOUSE_MANAGER), Role::CUSTOMER], Role::HOUSE_MANAGER);
// The customer fallback goes with it: the account now holds an effective role.
self::assertSame([Role::ADMIN], $roles);
self::assertSame([Role::HOUSE_MANAGER], $roles);
}
public function testApprovingARoleWithoutANominationIsRefused(): void
@@ -105,11 +106,11 @@ class RoleTest extends TestCase
public function testEffectiveRolesExcludeNominationsAndTheImplicitRoleUser(): void
{
$roles = [Role::USER, Role::TEAMER, Role::pending(Role::ADMIN)];
$roles = [Role::USER, Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)];
self::assertSame([Role::TEAMER], Role::effectiveOnly($roles));
self::assertSame([Role::pending(Role::ADMIN)], Role::pendingOnly($roles));
self::assertSame([Role::ADMIN => 'Administration'], Role::nominatedFrom($roles));
self::assertSame([Role::pending(Role::HOUSE_MANAGER)], Role::pendingOnly($roles));
self::assertSame([Role::HOUSE_MANAGER => 'Hausleitung'], Role::nominatedFrom($roles));
}
public function testEmployeeIsGrantedOutrightAndDisplacesTheCustomerFallback(): void
@@ -133,6 +134,61 @@ class RoleTest extends TestCase
);
}
/**
* @return iterable<string, array{string}>
*/
public static function employeeOnlyRoles(): iterable
{
foreach (Role::EMPLOYEE_ONLY as $role) {
yield $role => [$role];
}
}
#[DataProvider('employeeOnlyRoles')]
public function testEmployeeOnlyClaimWithoutEmployeeIsNotNominated(string $role): void
{
self::assertSame([Role::TEAMER], Role::sync([], [Role::TEAMER, $role]));
}
#[DataProvider('employeeOnlyRoles')]
public function testEmployeeOnlyRoleIsRevokedWithoutEmployee(string $role): void
{
// The CRM still claims it, but the account is no longer staff: the claim counts as not
// made, and a role approved back when it was staff goes with it.
self::assertSame(
[Role::TEAMER],
Role::sync([Role::TEAMER, Role::EMPLOYEE, $role], [Role::TEAMER, $role]),
);
}
#[DataProvider('employeeOnlyRoles')]
public function testEmployeeOnlyRoleSurvivesTheNextSyncForStaff(string $role): void
{
self::assertSame(
[Role::EMPLOYEE, $role],
Role::sync([Role::EMPLOYEE, $role], [Role::EMPLOYEE, $role]),
);
}
public function testHouseManagerDoesNotNeedEmployee(): void
{
// A Hausleitung signs in with the hotel's own address.
self::assertSame([Role::pending(Role::HOUSE_MANAGER), Role::CUSTOMER], Role::sync([], [Role::HOUSE_MANAGER]));
self::assertSame([Role::HOUSE_MANAGER], Role::sync([Role::HOUSE_MANAGER], [Role::HOUSE_MANAGER]));
}
public function testStaleEmployeeOnlyNominationWithoutEmployeeCannotBeApproved(): void
{
// A marker from before the rule, still stored until the account's next login.
$roles = [Role::TEAMER, Role::pending(Role::ADMIN)];
self::assertSame([], Role::nominatedFrom($roles));
$this->expectException(\InvalidArgumentException::class);
Role::approve($roles, Role::ADMIN);
}
public function testEmployeeAndTeamerCoexist(): void
{
self::assertSame(
+2 -2
View File
@@ -26,10 +26,10 @@ class AppRuntimeRoleLabelsTest extends TestCase
public function testNominationsAreListedApartFromTheEffectiveRoles(): void
{
$roles = [Role::TEAMER, Role::pending(Role::ADMIN)];
$roles = [Role::TEAMER, Role::pending(Role::HOUSE_MANAGER)];
self::assertSame(['Teamer:in'], $this->runtime()->effectiveRoles($roles));
self::assertSame(['Administration'], $this->runtime()->nominatedRoles($roles));
self::assertSame(['Hausleitung'], $this->runtime()->nominatedRoles($roles));
}
public function testUnknownRoleStaysVisible(): void