feat: align role assignment logic with myep-team

This commit is contained in:
Björn Fromme
2026-08-19 12:14:09 +02:00
parent 5a3957e143
commit 0c667d6b69
23 changed files with 1109 additions and 527 deletions
@@ -7,8 +7,21 @@ use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\CrmSelection;
use App\BusProNet\Model\CrmSelectionGroup;
use App\BusProNet\Traits\TypeConversionTrait;
use App\Security\Role;
use Symfony\Component\DomCrawler\Crawler;
/**
* Turns the SelektionCRM response into the roles and hotel codes the CRM claims for an account.
*
* This reports what BusPro says and nothing more: no fallback role, no default hotel code. What
* is made of the claims — which are granted outright and which only nominate — is Role::sync()'s
* business.
*
* The selection ids below are deployment-critical. BusPro always returns the full attribute tree
* and expresses membership through the `auswahl` flag, so a wrong or unset id yields a perfectly
* well-formed response in which nobody holds anything, and every user logging in is demoted one
* at a time. There is no signal inside the response that tells that apart from a real revocation.
*/
class CrmAttributesResponseParser
{
use TypeConversionTrait;
@@ -18,7 +31,13 @@ class CrmAttributesResponseParser
private const BPN_CRM_ID_TEAMER = 1070;
private const BPN_CRM_ID_GROUPS_MANAGER = 1477;
private const BPN_CRM_ID_GROUPS_ADMIN = 1478;
private const BPN_DEFAULT_HOTEL_CODE = 'SSL';
/**
* @param array<int|string, string> $houseManagerIds "Hausleitung" selection id => hotel code
*/
public function __construct(private readonly array $houseManagerIds = [])
{
}
public function parse(Crawler $result): CrmAttributes
{
@@ -45,24 +64,28 @@ class CrmAttributesResponseParser
$attribute->mutable = $this->stringToBool($node->attr('aenderbar'));
$attribute->selected = $this->stringToBool($node->attr('auswahl'));
if (1 === preg_match('/^Hausleitung ([A-Z0-9]+)$/', $attribute->label, $matches) && true === $attribute->selected) {
$roles[] = 'ROLE_HOUSE_MANAGER';
$hotelCodes[] = $matches[1];
}
if (self::BPN_CRM_ID_ADMIN === $attribute->id && true === $attribute->selected) {
$roles[] = 'ROLE_ADMIN';
}
if (self::BPN_CRM_ID_MANAGER === $attribute->id && true === $attribute->selected) {
$roles[] = 'ROLE_MANAGER';
}
if (self::BPN_CRM_ID_TEAMER === $attribute->id && true === $attribute->selected) {
$roles[] = 'ROLE_TEAMER';
}
if (self::BPN_CRM_ID_GROUPS_MANAGER === $attribute->id && true === $attribute->selected) {
$roles[] = 'ROLE_GROUPS_MANAGER';
}
if (self::BPN_CRM_ID_GROUPS_ADMIN === $attribute->id && true === $attribute->selected) {
$roles[] = 'ROLE_GROUPS_ADMIN';
if (true === $attribute->selected) {
$hotelCode = $this->houseManagerIds[$attribute->id] ?? null;
if (null !== $hotelCode) {
$roles[] = Role::HOUSE_MANAGER;
$hotelCodes[] = $hotelCode;
}
if (self::BPN_CRM_ID_ADMIN === $attribute->id) {
$roles[] = Role::ADMIN;
}
if (self::BPN_CRM_ID_MANAGER === $attribute->id) {
$roles[] = Role::MANAGER;
}
if (self::BPN_CRM_ID_TEAMER === $attribute->id) {
$roles[] = Role::TEAMER;
}
if (self::BPN_CRM_ID_GROUPS_MANAGER === $attribute->id) {
$roles[] = Role::GROUPS_MANAGER;
}
if (self::BPN_CRM_ID_GROUPS_ADMIN === $attribute->id) {
$roles[] = Role::GROUPS_ADMIN;
}
}
$attributes[] = $attribute;
@@ -74,13 +97,6 @@ class CrmAttributesResponseParser
})
;
$roles = array_unique($roles);
// Assign default role if none could be resolved
if (0 === count($roles)) {
$roles = ['ROLE_CUSTOMER'];
}
$result
->filterXPath('//crmaktionen/crmaktion')
->each(function (Crawler $node) use (&$actions) {
@@ -96,15 +112,11 @@ class CrmAttributesResponseParser
})
;
if (true === in_array('ROLE_ADMIN', $roles, true)) {
$hotelCodes[] = self::BPN_DEFAULT_HOTEL_CODE;
}
$response = new CrmAttributes();
$response->selectionGroups = $groups;
$response->crmActions = $actions;
$response->roles = $roles;
$response->hotelCodes = $hotelCodes;
$response->roles = array_values(array_unique($roles));
$response->hotelCodes = array_values(array_unique($hotelCodes));
return $response;
}
@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\User;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\User;
use App\Htmx\HxRedirectResponse;
use App\Security\Role;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken;
/**
* Turns a nomination from the BusPro CRM into an actual role.
*
* This is deliberately its own action rather than a field on a form: it grants a privilege, so
* it is confirmed on its own, logged on its own, and cannot happen as a side effect of saving
* something unrelated. The nomination is checked again on submit, so a sync that revoked the
* claim while the dialog was open cannot be approved through. Role::SELF_APPROVAL_FORBIDDEN is
* refused for your own account.
*/
#[IsGranted('ROLE_ADMIN')]
class ApproveRoleController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
private readonly TokenStorageInterface $tokenStorage,
) {
}
#[Route('/admin/user/{id}/approve/{role}', name: 'app_admin_user_approve_role', requirements: ['role' => 'ROLE_[A-Z_]+'])]
public function index(User $user, string $role, Request $request): Response
{
if ($user === $this->getUser() && \in_array($role, Role::SELF_APPROVAL_FORBIDDEN, true)) {
throw $this->createAccessDeniedException(sprintf('The role "%s" cannot be approved for your own account.', $role));
}
$nominated = Role::nominatedFrom($user->getRoles());
if (false === \array_key_exists($role, $nominated)) {
throw $this->createNotFoundException(sprintf('The account is not nominated for "%s".', $role));
}
$csrfTokenId = 'approve_user_role_'.$user->getId().'_'.$role;
if ($request->isMethod(Request::METHOD_POST)) {
if (false === $this->isCsrfTokenValid($csrfTokenId, $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$user->setRoles(Role::approve($user->getRoles(), $role));
$this->entityManager->flush();
$this->reissueOwnSecurityToken($user);
$this->addFlash('success', sprintf('Die Rolle %s wurde freigeschaltet', $nominated[$role]));
$this->logger->info('Approved user role', [
'email' => $user->getEmail(),
'role' => $role,
'roles' => $user->getRoles(),
]);
return new HxRedirectResponse($this->getReturnUrl($request, 'app_admin_user'));
}
return $this->render('admin/user/modal_approve_role.html.twig', [
'user' => $user,
'role_label' => $nominated[$role],
'csrf_token_id' => $csrfTokenId,
]);
}
/**
* Keeps the approver signed in when they just approved a role for themselves.
*
* A token carries the role names it was issued with, and ContextListener ends the session
* as soon as the stored user no longer matches them — the safeguard that makes a revocation
* take effect at once. Here the grant is deliberate and just happened under ROLE_ADMIN, so
* the token is re-issued with the new roles instead of the session being dropped.
*/
private function reissueOwnSecurityToken(User $user): void
{
$token = $this->tokenStorage->getToken();
if (false === $token instanceof PostAuthenticationToken || $token->getUser() !== $user) {
return;
}
$this->tokenStorage->setToken(
new PostAuthenticationToken($user, $token->getFirewallName(), $user->getRoles()),
);
}
}
@@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\User;
use App\Controller\Traits\ReturnUrlTrait;
use App\Entity\User;
use App\Form\Admin\UserType;
use App\Htmx\HxRedirectResponse;
use App\Security\Role;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_ADMIN')]
class EditController extends AbstractController
{
use ReturnUrlTrait;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/user/{id}/edit', name: 'app_admin_user_edit')]
public function index(User $user, Request $request): Response
{
$previousRoles = $user->getRoles();
$previousHotelCodes = $user->getHotelCodes();
$form = $this->createForm(UserType::class, $user, ['hx_post' => $request->getRequestUri()]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if ($this->locksOutSelf($user)) {
$form->get('roles')->addError(new FormError('Du kannst dir die Rolle Administration nicht selbst entziehen.'));
return $this->render('admin/user/modal_edit.html.twig', [
'user' => $user,
'form' => $form,
'syncedRoles' => Role::syncedOnly($user->getRoles()),
]);
}
$this->entityManager->flush();
$this->addFlash('success', 'Der Benutzeraccount wurde aktualisiert');
$this->logger->info('Updated user permissions', [
'email' => $user->getEmail(),
'previousRoles' => $previousRoles,
'roles' => $user->getRoles(),
'previousHotelCodes' => $previousHotelCodes,
'hotelCodes' => $user->getHotelCodes(),
]);
return new HxRedirectResponse($this->getReturnUrl($request, 'app_admin_user'));
}
return $this->render('admin/user/modal_edit.html.twig', [
'user' => $user,
'form' => $form,
'syncedRoles' => Role::syncedOnly($user->getRoles()),
]);
}
private function locksOutSelf(User $user): bool
{
return $user === $this->getUser() && false === \in_array(Role::ADMIN, $user->getRoles(), true);
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Controller\Admin\User;
use App\Entity\User;
use App\Security\Role;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Shows what an account holds and what it is nominated for.
*
* Nothing here is editable: roles and hotel codes come from the BusPro CRM and are synced on
* every login, so the only administrative act is approving a nomination — which is its own
* route, its own confirmation and its own log entry (see ApproveRoleController).
*/
#[IsGranted('ROLE_ADMIN')]
class ShowController extends AbstractController
{
#[Route('/admin/user/{id}/permissions', name: 'app_admin_user_permissions')]
public function index(User $user, Request $request): Response
{
$nominated = Role::nominatedFrom($user->getRoles());
// What ApproveRoleController would refuse for this account is not offered either, so
// nobody is sent into an access denied page.
$refused = $user === $this->getUser()
? array_intersect_key($nominated, array_flip(Role::SELF_APPROVAL_FORBIDDEN))
: [];
return $this->render('admin/user/modal_permissions.html.twig', [
'user' => $user,
'approvableRoles' => array_diff_key($nominated, $refused),
'selfRefusedRoles' => $refused,
'returnUrl' => $this->forwardedReturnUrl($request),
]);
}
/**
* Where an approval started from, still encoded the way return_url() handed it over.
*
* The approval links must forward what this modal was given rather than call return_url()
* themselves: that function answers with the *current* request URI, which here is the modal
* itself — and redirecting to it after the approval would render a bare modal as a page.
*/
private function forwardedReturnUrl(Request $request): string
{
$returnUrl = $request->query->getString('r');
return '' !== $returnUrl ? $returnUrl : rawurlencode($this->generateUrl('app_admin_user'));
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ class UserinfoController extends AbstractController
// Patch current user's roles. The implicit ROLE_USER says nothing about the
// account — every authenticated user holds it — and is not exported.
$data->roles = Role::assignedOnly($user->getRoles());
$data->roles = Role::effectiveOnly($user->getRoles());
// Patch current user's hotel codes
$data->hotelCodes = $user->getHotelCodes();
-98
View File
@@ -1,98 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Form\Admin;
use App\Entity\User;
use App\Security\Role;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Lets an administrator assign the privileged roles and the hotel codes of an existing account.
*
* Only Role::PRIVILEGED is offered: the remaining roles are synced from the BusPro CRM on every
* login (see BpnAuthenticator), so editing them here would be undone at the user's next login.
* Hotel codes are seeded once at account creation, which makes this form the only way to change
* them afterwards.
*
* @extends AbstractType<User>
*/
class UserType extends AbstractType
{
/**
* @param array<string, string> $hotelCodes code => label
*/
public function __construct(private readonly array $hotelCodes = [])
{
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$user = $builder->getData();
$builder
->add('roles', ChoiceType::class, [
'label' => 'Rollen',
'choices' => $this->privilegedChoices(),
'multiple' => true,
'expanded' => true,
'required' => false,
'help' => 'Alle übrigen Rollen kommen bei jeder Anmeldung aus BusPro und lassen sich hier nicht ändern.',
// Only the administrator-granted half is editable; the synced half is preserved,
// as is the implicit ROLE_USER, which must never be written back.
'getter' => static fn (User $user): array => Role::privilegedOnly($user->getRoles()),
'setter' => static function (User $user, array $roles): void {
$user->setRoles(Role::combine($user->getRoles(), $roles));
},
])
->add('hotelCodes', ChoiceType::class, [
'label' => 'Häuser',
'choices' => $this->hotelCodeChoices($user),
'multiple' => true,
'expanded' => true,
'required' => false,
'setter' => static function (User $user, array $hotelCodes): void {
$user->setHotelCodes(array_values(array_unique($hotelCodes)));
},
])
;
}
/**
* @return array<string, string> label => role
*/
private function privilegedChoices(): array
{
return array_flip(array_intersect_key(Role::labels(), array_flip(Role::PRIVILEGED)));
}
/**
* Codes already stored on the account are always offered, even when they are missing from
* the configured catalog — otherwise saving the form would silently drop them.
*
* @return array<string, string> label => code
*/
private function hotelCodeChoices(?User $user): array
{
$codes = $this->hotelCodes;
foreach ($user?->getHotelCodes() ?? [] as $code) {
$codes[$code] ??= $code;
}
ksort($codes);
return array_flip($codes);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}
+39 -37
View File
@@ -8,6 +8,7 @@ use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ImmediateConnectionCloseException;
use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\CrmAttributes;
use App\BusProNet\Model\PersonalData;
use App\Entity\User;
use App\Htmx\HxRedirectResponse;
@@ -34,13 +35,11 @@ use Symfony\Component\Security\Http\Util\TargetPathTrait;
* Validates credentials via BPN's getPersonalData endpoint and creates or updates
* local User entities. Passwords are stored encrypted with RSA for subsequent API calls.
*
* Roles have two owners. The non-privileged ones mirror the CRM selections on every login, in
* both directions, so somebody who becomes (or stops being) a Teamer in BusPro is granted (or
* loses) ROLE_TEAMER here — the sibling app myep-team gates on it. Role::PRIVILEGED is never
* imported: BusPro backend users can edit their own CRM selections, so honouring those would
* let anybody make themselves an administrator; they are granted in /admin/user only.
*
* Hotel codes still seed a *new* account only and are managed in /admin/user afterwards.
* 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
* 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.
*/
class BpnAuthenticator extends AbstractLoginFormAuthenticator implements AuthenticationEntryPointInterface
{
@@ -109,13 +108,13 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
if (null === $user = $userRepository->findOneBy(['email' => $email])) {
$user = new User($email);
$user->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes)));
$this->entityManager->persist($user);
}
$this->syncFromCrm($user, $crmAttributes);
$user
->setRoles($this->syncedRoles($email, $user->getRoles(), $crmAttributes->roles))
->setPassword($encryptedPassword)
->setPersonId($personalData->personId)
->setAddressId($personalData->addressId)
@@ -131,40 +130,43 @@ class BpnAuthenticator extends AbstractLoginFormAuthenticator implements Authent
}
/**
* Merges the two halves of the role set: the non-privileged roles the BusPro CRM currently
* reports, and the privileged ones an administrator granted here. Anything the CRM no
* longer reports is dropped, so revoking a selection there revokes it here too.
*
* @param string[] $storedRoles
* @param string[] $crmRoles
*
* @return string[]
* Writes back what the CRM currently claims: the roles per Role::sync() and the hotel codes
* verbatim. Both replace what is stored, which is what makes BusPro the source of truth.
*/
private function syncedRoles(string $email, array $storedRoles, array $crmRoles): array
private function syncFromCrm(User $user, CrmAttributes $crmAttributes): void
{
return Role::combine($this->importableRoles($email, $crmRoles), $storedRoles);
}
$previousRoles = $user->getRoles();
/**
* @param string[] $crmRoles
*
* @return string[]
*/
private function importableRoles(string $email, array $crmRoles): array
{
$roles = Role::filterImportable($crmRoles);
$dropped = array_values(array_intersect($crmRoles, Role::PRIVILEGED));
if ([] !== $dropped) {
// Somebody holds a privileged CRM selection in BusPro. We do not honour it, but it
// should stay visible: it either needs to be revoked there or granted in /admin/user.
$this->authLogger->warning('Ignored privileged roles from BPN CRM attributes', [
'email' => $email,
'roles' => $dropped,
if ([] === $crmAttributes->selectionGroups) {
// BusPro always answers with the full attribute tree and expresses membership through
// the `auswahl` flag, so an empty one is a degraded payload rather than a revocation.
// Syncing it would strip the roles of every user who logs in.
$this->authLogger->warning('Skipped the role sync: the BPN CRM response carries no selection groups', [
'email' => $user->getEmail(),
]);
// An existing account keeps everything it has. A brand new one still needs a role,
// and an empty claim set is exactly what Role::sync() answers with the fallback.
if ([] !== Role::assignedOnly($previousRoles)) {
return;
}
}
return $roles;
$user
->setRoles(Role::sync($previousRoles, $crmAttributes->roles))
->setHotelCodes(array_values(array_unique($crmAttributes->hotelCodes)))
;
$nominated = array_diff(Role::pendingOnly($user->getRoles()), Role::pendingOnly($previousRoles));
if ([] !== $nominated) {
// The CRM claims an administrative role for somebody who does not hold it. It grants
// nothing until an administrator approves it in /admin/user.
$this->authLogger->info('Nominated for administrative roles by the BPN CRM', [
'email' => $user->getEmail(),
'roles' => array_values($nominated),
]);
}
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
+174 -30
View File
@@ -5,11 +5,17 @@ declare(strict_types=1);
namespace App\Security;
/**
* The roles this application knows about.
* 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.
*/
final class Role
{
@@ -27,6 +33,14 @@ final class Role
public const GROUPS_ADMIN = 'ROLE_GROUPS_ADMIN';
public const GROUPS_MANAGER = 'ROLE_GROUPS_MANAGER';
/**
* 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.
@@ -44,37 +58,54 @@ final class Role
];
/**
* Roles that are never taken over from BusProNet. Many people can edit CRM selections in
* the BusPro backend, so these are granted by an administrator in /admin/user only.
* 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.
*
* @var string[]
*/
public const PRIVILEGED = [
public const UNCONDITIONAL = [
self::TEAMER,
self::CUSTOMER,
];
/**
* 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,
];
/**
* Reduces the roles derived from BPN CRM attributes to the ones we accept from there.
* 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.
*
* Falls back to ROLE_CUSTOMER the way CrmAttributesResponseParser does, so an account
* whose only selection was a privileged one does not end up without any role.
*
* @param string[] $roles
*
* @return string[]
* @var string[]
*/
public static function filterImportable(array $roles): array
{
$importable = array_values(array_unique(array_diff($roles, self::PRIVILEGED)));
public const SELF_APPROVAL_FORBIDDEN = [
self::ADMIN,
];
return [] === $importable ? [self::CUSTOMER] : $importable;
/**
* 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 assigned to an account — everything except the implicit ROLE_USER,
* which User::getRoles() prepends and which is never stored.
* 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
*
@@ -86,41 +117,148 @@ final class Role
}
/**
* The non-privileged half of a role set: what BpnAuthenticator syncs from the BusPro CRM.
* The roles that actually grant something: no ROLE_USER, no markers.
*
* @param string[] $roles
*
* @return string[]
*/
public static function syncedOnly(array $roles): array
public static function effectiveOnly(array $roles): array
{
return array_values(array_diff(self::assignedOnly($roles), self::PRIVILEGED));
return array_values(array_filter(
self::assignedOnly($roles),
static fn (string $role): bool => false === self::isPending($role),
));
}
/**
* The privileged half: what an administrator granted in /admin/user.
* The markers on an account: administrative roles the CRM claims, awaiting approval.
*
* @param string[] $roles
*
* @return string[]
*/
public static function privilegedOnly(array $roles): array
public static function pendingOnly(array $roles): array
{
return array_values(array_intersect($roles, self::PRIVILEGED));
return array_values(array_filter($roles, static fn (string $role): bool => self::isPending($role)));
}
/**
* Reassembles a complete role set from its two owners. Both sides are filtered, so a
* privileged role can never arrive through the synced half and vice versa.
* The roles behind those markers, labelled — what an approver acts on.
*
* @param string[] $synced
* @param string[] $privileged
* @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
*
* @return string[]
*/
public static function combine(array $synced, array $privileged): array
public static function sync(array $storedRoles, array $claimedRoles): array
{
return array_values(array_unique([...self::syncedOnly($synced), ...self::privilegedOnly($privileged)]));
$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;
}
/**
@@ -128,7 +266,7 @@ final class Role
*/
public static function labels(): array
{
return [
$labels = [
self::ADMIN => 'Administration',
self::MANAGER => 'Manager:in',
self::TEAMER => 'Teamer:in',
@@ -137,5 +275,11 @@ final class Role
self::GROUPS_ADMIN => 'Preisrechner Admin',
self::GROUPS_MANAGER => 'Preisrechner',
];
foreach (self::ADMINISTRATIVE as $role) {
$labels[self::pending($role)] = $labels[$role].' (nicht freigeschaltet)';
}
return $labels;
}
}
+2 -1
View File
@@ -27,7 +27,8 @@ class AppExtension extends AbstractExtension
new TwigFilter('map_status', [AppRuntime::class, 'mapStatus']),
new TwigFilter('map_country', [AppRuntime::class, 'mapCountry']),
new TwigFilter('map_nationality', [AppRuntime::class, 'mapNationality']),
new TwigFilter('map_roles', [AppRuntime::class, 'mapRoles']),
new TwigFilter('effective_roles', [AppRuntime::class, 'effectiveRoles']),
new TwigFilter('nominated_roles', [AppRuntime::class, 'nominatedRoles']),
];
}
+28 -6
View File
@@ -126,22 +126,44 @@ class AppRuntime implements RuntimeExtensionInterface
}
/**
* Turns the roles stored on a User into the labels the edit form uses.
*
* ROLE_USER is dropped because every account holds it implicitly; a role without a label
* is passed through unchanged so it stays visible instead of silently disappearing.
* The labels of the roles an account actually holds — no ROLE_USER, no nominations.
*
* @param string[] $roles
*
* @return string[]
*/
public function mapRoles(array $roles): array
public function effectiveRoles(array $roles): array
{
return $this->roleLabels(Role::effectiveOnly($roles));
}
/**
* The labels of the roles the BusPro CRM claims for an account but nobody has approved.
*
* @param string[] $roles
*
* @return string[]
*/
public function nominatedRoles(array $roles): array
{
return array_values(Role::nominatedFrom($roles));
}
/**
* A role without a label is passed through unchanged so it stays visible instead of
* silently disappearing.
*
* @param string[] $roles
*
* @return string[]
*/
private function roleLabels(array $roles): array
{
$labels = Role::labels();
$mapped = [];
foreach (Role::assignedOnly($roles) as $role) {
foreach ($roles as $role) {
$mapped[] = $labels[$role] ?? $role;
}