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
+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',