300 lines
10 KiB
PHP
300 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Security;
|
|
|
|
/**
|
|
* The roles this application knows about, and the policy that assigns them.
|
|
*
|
|
* Roles are stored as plain strings in the User::$roles JSON column and consumed as strings
|
|
* by #[IsGranted], the voters and security.yaml's role_hierarchy, so they are constants
|
|
* rather than an enum.
|
|
*
|
|
* The governing rule: the BusPro CRM is the source of truth. It may nominate, but never grant,
|
|
* an administrative role — a nomination is stored as a marker (ROLE_X_PENDING) that grants
|
|
* 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
|
|
{
|
|
/**
|
|
* The role every account holds implicitly. User::getRoles() prepends it and it is never
|
|
* stored, so it has to be filtered out wherever a role set is written back.
|
|
*/
|
|
public const USER = 'ROLE_USER';
|
|
|
|
public const ADMIN = 'ROLE_ADMIN';
|
|
public const MANAGER = 'ROLE_MANAGER';
|
|
public const TEAMER = 'ROLE_TEAMER';
|
|
public const CUSTOMER = 'ROLE_CUSTOMER';
|
|
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.
|
|
* Markers live in the same column as the real roles and are handed to Symfony along with
|
|
* them, but nothing references them: no access_control rule, no role_hierarchy entry and no
|
|
* voter. Always ask effectiveOnly() when the question is what an account may actually do.
|
|
*/
|
|
public const PENDING_SUFFIX = '_PENDING';
|
|
|
|
/**
|
|
* The roles that are actually assigned to accounts. ROLE_USER is left out because every
|
|
* account has it implicitly (see User::getRoles()) and it is never stored.
|
|
*
|
|
* @var string[]
|
|
*/
|
|
public const ALL = [
|
|
self::ADMIN,
|
|
self::MANAGER,
|
|
self::TEAMER,
|
|
self::CUSTOMER,
|
|
self::HOUSE_MANAGER,
|
|
self::GROUPS_ADMIN,
|
|
self::GROUPS_MANAGER,
|
|
self::EMPLOYEE,
|
|
];
|
|
|
|
/**
|
|
* 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,
|
|
];
|
|
|
|
/**
|
|
* Roles the CRM only nominates for. Many people can edit CRM selections in the BusPro
|
|
* backend, so honouring these directly would let anybody make themselves an administrator.
|
|
*
|
|
* @var string[]
|
|
*/
|
|
public const ADMINISTRATIVE = [
|
|
self::ADMIN,
|
|
self::MANAGER,
|
|
self::HOUSE_MANAGER,
|
|
self::GROUPS_ADMIN,
|
|
self::GROUPS_MANAGER,
|
|
];
|
|
|
|
/**
|
|
* 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
|
|
* administrator. The remaining administrative roles grant less than what an approver
|
|
* already holds, so requiring a second pair of eyes for them would only lock out the
|
|
* single-administrator case for no gain.
|
|
*
|
|
* @var string[]
|
|
*/
|
|
public const SELF_APPROVAL_FORBIDDEN = [
|
|
self::ADMIN,
|
|
];
|
|
|
|
/**
|
|
* The marker standing for a role that the CRM claims but nobody has approved.
|
|
*/
|
|
public static function pending(string $role): string
|
|
{
|
|
return $role.self::PENDING_SUFFIX;
|
|
}
|
|
|
|
/**
|
|
* The roles actually stored on an account — everything except the implicit ROLE_USER,
|
|
* which User::getRoles() prepends and which is never stored. Markers included.
|
|
*
|
|
* @param string[] $roles
|
|
*
|
|
* @return string[]
|
|
*/
|
|
public static function assignedOnly(array $roles): array
|
|
{
|
|
return array_values(array_diff($roles, [self::USER]));
|
|
}
|
|
|
|
/**
|
|
* The roles that actually grant something: no ROLE_USER, no markers.
|
|
*
|
|
* @param string[] $roles
|
|
*
|
|
* @return string[]
|
|
*/
|
|
public static function effectiveOnly(array $roles): array
|
|
{
|
|
return array_values(array_filter(
|
|
self::assignedOnly($roles),
|
|
static fn (string $role): bool => false === self::isPending($role),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* The markers on an account: administrative roles the CRM claims, awaiting approval.
|
|
*
|
|
* @param string[] $roles
|
|
*
|
|
* @return string[]
|
|
*/
|
|
public static function pendingOnly(array $roles): array
|
|
{
|
|
return array_values(array_filter($roles, static fn (string $role): bool => self::isPending($role)));
|
|
}
|
|
|
|
/**
|
|
* The roles behind those markers, labelled — what an approver acts on.
|
|
*
|
|
* @param string[] $roles
|
|
*
|
|
* @return array<string, string> role => label
|
|
*/
|
|
public static function nominatedFrom(array $roles): array
|
|
{
|
|
$labels = self::labels();
|
|
$nominated = [];
|
|
|
|
foreach (self::pendingOnly($roles) as $marker) {
|
|
$role = self::realRole($marker);
|
|
|
|
if (\in_array($role, self::ADMINISTRATIVE, true)) {
|
|
$nominated[$role] = $labels[$role];
|
|
}
|
|
}
|
|
|
|
return $nominated;
|
|
}
|
|
|
|
/**
|
|
* The whole policy, applied on every login.
|
|
*
|
|
* 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;
|
|
* 3. mark every administrative role it claims that is not granted already. This runs after
|
|
* the revocation, so a role just revoked is not immediately marked again, and an approved
|
|
* role is never marked a second time;
|
|
* 4. fall back to ROLE_CUSTOMER when nothing effective is left.
|
|
*
|
|
* Nothing here can raise a privilege: step 3 only ever produces markers.
|
|
*
|
|
* @param string[] $storedRoles
|
|
* @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[]
|
|
*/
|
|
public static function sync(array $storedRoles, array $claimedRoles): array
|
|
{
|
|
$claimed = array_values(array_intersect(self::ALL, array_unique($claimedRoles)));
|
|
|
|
$roles = array_values(array_filter(
|
|
self::assignedOnly($storedRoles),
|
|
static fn (string $role): bool => \in_array(self::realRole($role), $claimed, true),
|
|
));
|
|
|
|
foreach (array_intersect($claimed, self::UNCONDITIONAL) as $role) {
|
|
$roles[] = $role;
|
|
}
|
|
|
|
foreach (array_intersect($claimed, self::ADMINISTRATIVE) as $role) {
|
|
if (false === \in_array($role, $roles, true)) {
|
|
$roles[] = self::pending($role);
|
|
}
|
|
}
|
|
|
|
return self::withCustomerFallback(array_values(array_unique($roles)));
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @param string[] $storedRoles
|
|
*
|
|
* @return string[]
|
|
*
|
|
* @throws \InvalidArgumentException when $role is not pending on this account
|
|
*/
|
|
public static function approve(array $storedRoles, string $role): array
|
|
{
|
|
$roles = self::assignedOnly($storedRoles);
|
|
|
|
if (false === \in_array(self::pending($role), $roles, true)) {
|
|
throw new \InvalidArgumentException(sprintf('The role "%s" is not pending approval.', $role));
|
|
}
|
|
|
|
$roles = array_diff($roles, [self::pending($role)]);
|
|
$roles[] = $role;
|
|
|
|
return self::withCustomerFallback(array_values(array_unique($roles)));
|
|
}
|
|
|
|
/**
|
|
* Everybody without an effective role is a customer, and nobody with one is. The fallback is
|
|
* a fallback, not a baseline — ROLE_CUSTOMER and ROLE_TEAMER are mutually exclusive on
|
|
* purpose.
|
|
*
|
|
* @param string[] $roles
|
|
*
|
|
* @return string[]
|
|
*/
|
|
private static function withCustomerFallback(array $roles): array
|
|
{
|
|
if ([] === array_diff(self::effectiveOnly($roles), [self::CUSTOMER])) {
|
|
return array_values(array_unique([...$roles, self::CUSTOMER]));
|
|
}
|
|
|
|
return array_values(array_diff($roles, [self::CUSTOMER]));
|
|
}
|
|
|
|
private static function isPending(string $role): bool
|
|
{
|
|
return str_ends_with($role, self::PENDING_SUFFIX);
|
|
}
|
|
|
|
private static function realRole(string $role): string
|
|
{
|
|
return self::isPending($role)
|
|
? substr($role, 0, -\strlen(self::PENDING_SUFFIX))
|
|
: $role;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string> role => label
|
|
*/
|
|
public static function labels(): array
|
|
{
|
|
$labels = [
|
|
self::ADMIN => 'Administration',
|
|
self::MANAGER => 'Manager:in',
|
|
self::TEAMER => 'Teamer:in',
|
|
self::CUSTOMER => 'Kund:in',
|
|
self::HOUSE_MANAGER => 'Hausleitung',
|
|
self::GROUPS_ADMIN => 'Preisrechner Admin',
|
|
self::GROUPS_MANAGER => 'Preisrechner',
|
|
self::EMPLOYEE => 'Mitarbeiter:in',
|
|
];
|
|
|
|
foreach (self::ADMINISTRATIVE as $role) {
|
|
$labels[self::pending($role)] = $labels[$role].' (nicht freigeschaltet)';
|
|
}
|
|
|
|
return $labels;
|
|
}
|
|
}
|