feat: derive ROLE_EMPLOYEE from the account's email domain

This commit is contained in:
2026-09-13 12:49:51 +02:00
parent 443a3ed248
commit fab89dead6
7 changed files with 221 additions and 8 deletions
+15 -3
View File
@@ -34,8 +34,10 @@ use Symfony\Component\Security\Http\Util\TargetPathTrait;
*
* The password is kept, RSA-encrypted, because every later BPN call needs it again.
*
* BusPro owns the whole role set and the hotel codes: both are synced on every login, in both
* directions, so anything the CRM no longer reports is withdrawn here. What the CRM claims is
* BusPro owns the whole role set bar one, and the hotel codes: both are synced on every login, in
* both directions, so anything the CRM no longer reports is withdrawn here. The exception is
* ROLE_EMPLOYEE, which BusPro has no selection for and which is derived from the account's email
* domain — passed to Role::sync() as a claim, so it is granted and revoked by the same machinery. What the CRM claims is
* not automatically granted, though — Role::sync() turns an administrative claim into a
* nomination that an administrator has to approve in /admin/user, because BusPro backend users
* can edit their own CRM selections and would otherwise make themselves administrators.
@@ -51,6 +53,7 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
private readonly Crypt $crypt,
private readonly ProfileCompletenessChecker $completenessChecker,
private readonly LoggerInterface $authLogger,
private readonly EmployeeDomainMatcher $employeeDomainMatcher,
) {
}
@@ -152,8 +155,17 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
}
}
$claimedRoles = $crmAttributes->roles;
// Not a CRM claim: BusPro has no selection for it, so the account's own address decides.
// Passing it in as a claim rather than setting it afterwards is what makes it revocable —
// Role::sync() strips every stored role the claim set does not contain.
if ($this->employeeDomainMatcher->isEmployee($user->getEmail())) {
$claimedRoles[] = Role::EMPLOYEE;
}
$user
->setRoles(Role::sync($previousRoles, $crmAttributes->roles))
->setRoles(Role::sync($previousRoles, $claimedRoles))
->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes)))
;
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Security;
/**
* Decides whether an account belongs to a member of staff, by its email domain.
*
* BusPro has no CRM selection expressing "works here", so the email address is the only signal
* available. The domains are configuration (%employee_email_domains%) rather than a constant
* because they are deployment-specific, in the same way the brand hosts and the CRM selection ids
* are.
*
* Matching is exact on the domain part and never on a suffix: "mail.ep-reisen.de" and
* "notep-reisen.de" are not "ep-reisen.de". A suffix match here would hand ROLE_EMPLOYEE to
* anybody able to register a domain ending in the configured one.
*/
final class EmployeeDomainMatcher
{
/**
* @var string[]
*/
private readonly array $domains;
/**
* @param string[] $domains
*/
public function __construct(array $domains)
{
$this->domains = array_values(array_filter(array_map(
static fn (string $domain): string => strtolower(trim($domain, " \t\n\r\0\x0B.@")),
$domains,
)));
}
/**
* A malformed or missing identifier is simply not an employee. It must not throw: this runs
* inside the authentication path, where an exception would turn a bad address into a failed
* login rather than a login without the role.
*/
public function isEmployee(?string $email): bool
{
if (null === $email || [] === $this->domains) {
return false;
}
$position = strrpos($email, '@');
if (false === $position) {
return false;
}
$domain = strtolower(substr($email, $position + 1));
return \in_array($domain, $this->domains, true);
}
}
+17 -3
View File
@@ -16,6 +16,13 @@ namespace App\Security;
* nothing until an administrator approves it in /admin/user. The CRM's word alone is enough to
* take a role away, never to hand it out, and an administrator's word alone is enough for
* neither.
*
* One role is not claimed by the CRM at all: ROLE_EMPLOYEE is derived from the account's own
* email domain, because BusPro has no selection expressing "works here". It is passed to sync()
* as a claim alongside the CRM's, so it is granted and revoked by exactly the same machinery.
* 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.
*/
final class Role
{
@@ -32,6 +39,7 @@ final class Role
public const HOUSE_MANAGER = 'ROLE_HOUSE_MANAGER';
public const GROUPS_ADMIN = 'ROLE_GROUPS_ADMIN';
public const GROUPS_MANAGER = 'ROLE_GROUPS_MANAGER';
public const EMPLOYEE = 'ROLE_EMPLOYEE';
/**
* Appended to an administrative role to mark it as claimed by the CRM but not yet approved.
@@ -55,17 +63,21 @@ final class Role
self::HOUSE_MANAGER,
self::GROUPS_ADMIN,
self::GROUPS_MANAGER,
self::EMPLOYEE,
];
/**
* Roles the CRM grants outright. ROLE_CUSTOMER is never claimed by BusPro — it is the
* fallback for an account left without any effective role, and exclusive with the others.
* Roles granted outright, without an approval step. ROLE_CUSTOMER is never claimed by BusPro —
* it is the fallback for an account left without any effective role, and exclusive with the
* others. ROLE_EMPLOYEE is not claimed by BusPro either: it is derived from the account's own
* email domain (see EmployeeDomainMatcher) and, being effective, displaces that fallback.
*
* @var string[]
*/
public const UNCONDITIONAL = [
self::TEAMER,
self::CUSTOMER,
self::EMPLOYEE,
];
/**
@@ -180,7 +192,8 @@ final class Role
* Nothing here can raise a privilege: step 3 only ever produces markers.
*
* @param string[] $storedRoles
* @param string[] $claimedRoles what the CRM reports
* @param string[] $claimedRoles what the CRM reports, plus the roles derived from the account
* itself (ROLE_EMPLOYEE); anything outside self::ALL is ignored
*
* @return string[]
*/
@@ -274,6 +287,7 @@ final class Role
self::HOUSE_MANAGER => 'Hausleitung',
self::GROUPS_ADMIN => 'Preisrechner Admin',
self::GROUPS_MANAGER => 'Preisrechner',
self::EMPLOYEE => 'Mitarbeiter:in',
];
foreach (self::ADMINISTRATIVE as $role) {