Files

22 KiB
Raw Permalink Blame History

User Roles: Assignment, Approval and Revocation

Reference for how a user account comes into existence, how it gains and loses roles, and how it gets blocked. Describes the behaviour as implemented — not a plan.

The governing rules, from which the rest follows:

BusPro is the source of truth for roles and hotel codes. Both are synced on every login, so anything it no longer reports is withdrawn.

The CRM may nominate, but never grant, an administrative role. Only a super admin turns a nomination into a privilege. The CRM's word alone is enough to take away access, never to hand it out - and an administrator's word alone is enough for neither.

Two corollaries that surprise people:

  • A role cannot be handed out by hand. The edit form approves what BusPro claims; it cannot add a role BusPro is silent about, nor remove one it reports. Same for hotel codes, which are display-only there.
  • Every role stands on its own. ROLE_TEAMER is not a base role others build on, and a Reisemanager is not a superset of a Hausleitung - somebody claiming both is nominated for both, and approved for each separately.

Role catalogue

Defined in User::ROLES (src/Entity/User.php), these four are the only roles a human can assign:

Role Label Granted by Revoked by Hierarchy
ROLE_ADMIN Admin super admin, approving a CRM claim the CRM, automatically ROLE_ADMINISTRATIVE
ROLE_MANAGER Reisemanager super admin, approving a CRM claim the CRM, automatically ROLE_ADMINISTRATIVE
ROLE_HOUSE_MANAGER Hausleitung super admin, approving a CRM claim the CRM, automatically
ROLE_TEAMER Teamer the CRM, automatically the CRM, automatically

User::PENDING_ROLES holds a marker for each of the three administrative roles, keyed by the role it stands for:

Marker Meaning
ROLE_ADMIN_PENDING the CRM claims this person is an admin, nobody has confirmed it
ROLE_MANAGER_PENDING likewise for Reisemanager
ROLE_HOUSE_MANAGER_PENDING likewise for Hausleitung

Markers grant nothing. They appear in getRoles() and therefore in the security token, but no access_control rule, role_hierarchy entry or voter references them. Their only effects are cosmetic (rendered as "Admin (nicht freigeschaltet)") and organisational (they put the user on the approval list). ROLE_TEAMER has no marker: it needs no approval.

Two further roles are synthesized by User::getRoles() and never stored: ROLE_USER for everybody, and ROLE_SUPER_ADMIN when the separate superAdmin boolean column is set. A validation callback (User::validateSuperAdmin()) refuses superAdmin without ROLE_ADMIN alongside it — super admin is an elevation, never a standalone grant. The sync enforces the same rule from the other side: revoking ROLE_ADMIN clears the flag, or the one role that outranks every check in the application would outlive the role it depends on.

Storage and accessors

Everything except superAdmin lives in the single roles JSON column. Three accessors slice it, and picking the right one matters:

Accessor Returns
getRoles() the column plus synthesized ROLE_USER / ROLE_SUPER_ADMIN — what Symfony authorises against
getAssignedRoles() only the four real roles from the column — what the sync works on
getPendingRoles() only the markers
getNominatedRoles() the roles behind those markers, as role => label — what an approver acts on

Where roles come from: the CRM mapping

ResponseParser::createCrmAttributesResponse() walks the selektionsmerkmale of the SelektionCRM response and sets four booleans plus the hotel codes:

CRM attribute Recognised by Sets
admin attribute id %bpn_crm_id_admin%, selected isAdmin
Reisemanager attribute id %bpn_crm_id_manager%, selected isManager
teamer attribute id %bpn_crm_id_teamer%, selected isTeamer
Hausleitung attribute id listed in %bpn_crm_house_manager_ids%, selected isHouseManager + the hotel code that id maps to

bpn_crm_house_manager_ids (config/services.yaml) maps a BusPro selection id to a hotel code. Entries whose code is not a house in the houses parameter are kept commented out: such a house manager could log in but would see no assignments and no dispositions at all.

UserDataHandler::collectClaimedRoles() turns those four booleans into plain role names - what the CRM says. Two things are made of that list: toPendingRoles() produces one marker per claimed administrative role (the roles are independent, so a Reisemanager who is also a Hausleitung gets both), and collectRoles() produces the markers plus a real ROLE_TEAMER, which is what a brand new account starts with.

The shape of a BusPro response (important)

BusPro always returns the full attribute tree. Membership is expressed by the auswahl flag on each <selektion>, so a role somebody does not hold arrives as auswahl="False", never as a missing element, and a person who holds nothing at all still receives every group. Sample payloads for both cases live in tests/Resources/crm_attributes_granted.xml and crm_attributes_revoked.xml, and ResponseParserTest asserts against them.

This is what makes the demotion path below safe: an empty group set cannot occur in a real response, so it is a reliable signal that the response is degraded rather than that the roles were revoked.

Note what that check does not cover. The three ids are compared with ===, and a wrong or unset one produces a perfectly well-formed response in which nobody holds anything — every user logging in would be demoted, one at a time. The attribute ids are therefore deployment-critical configuration, not a detail:

Parameter Attribute
APP_BPN_CRM_ID_ADMIN Admin
APP_BPN_CRM_ID_MANAGER Manager
APP_BPN_CRM_ID_TEAMER E&P Teamer - allg. Merkmal

Matching is by id and never by label, so Preisrechner Admin does not trip the admin flag.

bpn_crm_house_manager_ids (config/services.yaml) is deployment-critical for the same reason, and more sharply so: since roles are synced, an id missing from that map does not merely fail to nominate a Hausleitung, it revokes the role from everyone holding it, one login at a time, with re-approval manual per user. Entries whose hotel code is not a house in the houses parameter are deliberately commented out there — a person holding only such a Hausleitung claims nothing at all and is blocked (see 4) rather than left with a role that shows them no data.

APP_BPN_DEFAULT_HOTEL_CODE is a testing affordance: when set, every admin also becomes a house manager for that hotel. It must stay empty outside local development.


Lifecycle

1. First login

BpnAuthenticator::getOrCreateLocalUser()UserDataHandler::createLocalUser() writes collectRoles() verbatim, together with the hotel codes from the Hausleitung attributes.

A CRM admin who is not also a teamer therefore starts with ['ROLE_ADMIN_PENDING'] and no privileges at all: they can authenticate, but UserChecker refuses the session until a super admin approves them.

2. Every subsequent login — the sync

UserDataHandler::updateLocalUser() refreshes name, email and the teamer record, and hands the roles to syncRoles(), which is the whole policy in four steps:

  1. revoke every granted role the CRM no longer claims. This is what makes BusPro the source of truth, and it applies to ROLE_TEAMER as much as to the administrative roles.
  2. clear the super admin flag when ROLE_ADMIN was among them — ROLE_SUPER_ADMIN is synthesized from a separate column and would otherwise survive its own precondition.
  3. refreshPendingRoles() recomputes the marker set from the current claims. A marker whose real role is already granted is dropped — an approved role is never marked again. It runs after the revocation, so a role just revoked is not immediately marked again.
  4. grantTeamerRole() adds ROLE_TEAMER when the CRM claims it. Grant-only in itself; withdrawing it is step 1's business.

syncHotelCodes() then replaces the hotel codes with the ones the CRM reports, so a Hausleitung who moves house is not left seeing the old one.

Nothing on this path can raise a privilege: step 3 only ever produces markers.

The same two methods run on the MyE&P SSO path (MyEpAuthenticator), against the eligible roles it reports — one policy, two identity sources.

3. Approval — turning a marker into a role

/admin/system/user/edit/{id}, UserType, gated by UserVoter::EDIT: super admin only, never yourself, never while impersonating.

The page keeps three things apart, because they follow three different rules:

Block What it is
Aus BusPro information, not a form: granted roles, houses, last login. Synced on every login and editable nowhere in this application
Freischaltung one action per nomination — a button, a confirmation dialog, its own route (ApproveRoleController). Only shown when the user carries a marker
Account the actual form (UserType): super admin, block, block reasons

None of this is a disabled form field. Roles and hotel codes are simply not fields, so there is nothing to submit and nothing that looks editable but is not.

Approval is its own act, deliberately not a checkbox on the 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 an unrelated setting. UserDataHandler::approveRole() refuses any role the user has no marker for, so a hand-crafted URL cannot grant one the CRM never claimed, and the check runs again on submit to catch a sync that revoked the claim while the dialog was open.

A denial is not recorded anywhere: as long as the CRM keeps claiming the role, the nomination is back on the next login.

Super admin is only offered to somebody who already holds ROLE_ADMIN — approve first, elevate afterwards. The one exception is a flag that outlived its role, which stays editable so the account can be saved at all while User::validateSuperAdmin() is violated; the sync clears it (see 2), so it should never occur in practice.

The user list marks nominations with their own badge — it is the only place an approver would look for them.

4. Losing everything — the block

Revocation of an individual role is step 1 of the sync above. This section is the stronger case: the CRM claims nothing at all, which is not a demotion but an exit.

Evaluated on every login, in BpnAuthenticator::getOrCreateLocalUser(), before the local user is even loaded. If collectClaimedRoles() is empty, the CRM grants this person nothing in this application, so they are not a user of it:

Situation Outcome
no local account login refused, no account created
existing account disableForRevokedCrmRoles() blocks it, and the user is still returned so UserChecker can state the reason
account already blocked left completely alone — an existing block may be disciplinary and must never be overwritten

disableForRevokedCrmRoles() sets disabledAt, a public disabledReason ("Für deinen Account liegt in BusPro keine Berechtigung mehr vor.") and an internal one, and drops all pending markers — they no longer reflect the CRM. Granted roles are kept, so the account stays reviewable.

Two preconditions guard this branch, because "no roles" is otherwise indistinguishable from "the CRM told us nothing". They now protect the sync as well: a degraded response that got past them would not merely block one account, it would strip the roles of every user logging in.

  1. the response must be a CrmAttributesResponse — BusPro answers with a notification record on its own errors;
  2. it must carry at least one attribute group. Since a real response always carries the full tree (see above), an empty group set means an empty payload or a changed schema — which would otherwise block every user who logs in. That case refuses the single login, logs a warning, and leaves the account untouched.

A misconfigured attribute id defeats both checks, as noted above. There is no signal inside the response that distinguishes it from a genuine revocation.

Regaining a CRM role does not unblock anything. The next login skips the demotion branch, refreshes markers as usual, and UserChecker still refuses on disabledAt. Unblocking is always a human decision.

5. Blocking and unblocking by hand

Two surfaces, with deliberately different authority — the super admin rule is about administrative users, teamers are an admin's business:

Surface Who Notes
/admin/teamer/disable-user/{uuid} and /administrative/teamer/enable-user/{uuid} ROLE_ADMIN teamers; public reason mandatory, internal optional
"Account gesperrt" checkbox on the user edit form super admin (UserVoter) everyone else; both reasons optional

Both go through User::setDisabled(), which is a no-op when the state is unchanged — saving an unrelated edit never resets the block timestamp — and clears both reasons on unblocking. UserType additionally clears them in a POST_SUBMIT listener, so unticking the box without emptying the prefilled textareas cannot leave a stale reason behind.


The login gate

UserChecker::checkPreAuth() runs after BusPro has accepted the credentials, in this order:

Condition Message
disabledAt set Dein Account wurde gesperrt: «disabledReason»
no role from User::ROLES, but markers present Dein Account wurde noch nicht freigeschaltet.
no role from User::ROLES at all Keine gültige Rolle zugewiesen.

Only then does onAuthenticationSuccess() stamp lastLoginAt and redirect to User::getDefaultRoute() — admin, manager, house manager or teamer area, in that order of precedence.

Note that a refused login never updates lastLoginAt, which is what the teamer list's includeInactive filter keys on.


Who is listed where

List Contains
/admin/system/user (UserRepository::getAdministrativeUsers()) holders of an administrative role, holders of a marker, and blocked accounts without a teamer record
teamer list (TeamerRepository) teamers; blocked ones are badged, and stale ones need the includeInactive filter

The blocked-accounts clause exists because a CRM demotion can leave a user with no role that would list them — a candidate awaiting approval loses their markers and would otherwise become invisible with no page to be unblocked from. Blocked teamers are excluded because they already have one.


Accountzustände

An account is in exactly one of three states. Block and deletion are independent flags, so both can be set at once — a disciplinary block has to survive a deletion and the restore that follows it.

Zustand Spalte Anmeldung Listen, Formulare, Mailings Gesetzt von
aktiv ja enthalten
gesperrt user.disabled_at abgelehnt, Grund wird angezeigt weiterhin enthalten Admin (DisableUserController), CRM (disableForRevokedCrmRoles())
gelöscht teamer.deleted_at + user.deleted_at abgelehnt ausgeschlossen ausschließlich AccountDeletionHandler

A blocked teamer stays in the list on purpose: that list is the only place the block can be lifted from. A deleted one is removed from every forward-looking process instead, which is what makes the deletion legally meaningful.

Why two columns

Both Teamer and User carry their own deleted_at. Teamers without a user exist, users without a teamer exist, and there is no Doctrine SQL filter — every query filters by hand on the alias it already has (teamer on the teamer side, user on the login and mail side). A one-sided flag would force a join into roughly ten queries, each an opportunity to drop rows through the wrong join type.

src/Service/Teamer/AccountDeletionHandler.php is the only place that writes either flag, which is what keeps the two from drifting apart. Do not call setDeleted() on a Teamer or User anywhere else.

What a deletion excludes

TeamerRepository::getListQuery() (behind includeDeleted) and getAutocompletionData(), UserRepository::getAdministrativeUsers() / getUsersByRoleAndHotelCode() / getAutocompletionData(), AvailabilityRepository::getListQuery() (custom branch) and getSelectableForTeamer(), every teamer-facing handler in EmailNotificationSubscriber, the cron reminders in UploadReminderService and DispositionReminderService, new applications (ApplicationValidator) and impersonation (ImpersonationVoter).

What stays visible

Everything already recorded: dispositions, applications, uploads, feedback, and the contracts and invoices rendered from them. AssignmentRepository, DispositionRepository, ApplicationRepository, UploadRepository, FeedbackRepository, the dashboards and all of src/Service/Pdf/ deliberately do not filter. Deleted teamers are marked in the UI with templates/_partials/_teamer_deleted_badge.html.twig; PDFs are left untouched so that a document regenerated after a deletion is identical to the one issued before it.

Login and CRM

UserChecker refuses a deleted account before it checks the block, so the deletion message wins. BpnAuthenticator::getOrCreateLocalUser() returns a deleted user untouched before any sync branch runs: nothing is written back, no role is granted or revoked, lastLoginAt is not bumped. UserDataHandler::findLocalUser() still matches a deleted account by email — excluding it there would make the caller take the person for unknown and create a second account, resurrecting them under a new row.

Symfony's ContextListener does not re-run the user checker when restoring a session from its cookie, so DeletedUserSubscriber ends the session of anyone deleted while logged in.


Deliberate decisions and known edges

  • Markers grant nothing but are stored in roles. Convenient (one column, one query), but it means getRoles() contains strings that are not roles in any meaningful sense. Always intersect against User::ROLES when asking "may this user do anything at all" — UserChecker does exactly that.
  • ROLE_TEAMER is granted but never withdrawn automatically. See 2. A strict mirror would strip manual grants on the next login.
  • A denied nomination reappears. See 3. There is no "rejected" state.
  • Sole-super-admin lockout. A super admin who is not a teamer and loses their CRM admin attribute is auto-blocked, and only another super admin can unblock them. With no second super admin there is no route back through the UI.
  • getUsersByRoleAndHotelCode() matches granted roles only, so notification recipients (e.g. feedback reminders) never include people who are merely nominated. That is intended.

File map

Concern File
role constants, accessors, setDisabled() src/Entity/User.php
CRM → roles mapping src/BusProNet/ResponseParser.php, src/BusProNet/Model/CrmAttributesResponse.php
create / update / demote src/BusProNet/UserDataHandler.php
login flow and the demotion branch src/Security/BpnAuthenticator.php
login gate src/Security/UserChecker.php
who may edit a user src/Security/Voter/UserVoter.php
who may impersonate src/Security/Voter/ImpersonationVoter.php
approval / block form src/Form/UserType.php, templates/admin/system/user/_form.html.twig
teamer block / unblock src/Controller/Admin/Teamer/DisableUserController.php, src/Form/DisableUserType.php
soft delete / restore src/Service/Teamer/AccountDeletionHandler.php, src/Entity/Traits/SoftDeletableEntity.php
deletion by an admin src/Controller/Admin/Teamer/DeleteAccountController.php
deletion by the teamer src/Controller/Teamer/Profile/DeleteAccountController.php, src/Form/DeleteAccountType.php
session of a deleted user src/EventListener/DeletedUserSubscriber.php
listing src/Repository/UserRepository.php
hierarchy, firewall, impersonation config/packages/security.yaml
CRM attribute ids config/services.yaml

Tests

File Covers
tests/BusProNet/UserDataHandlerTest.php CRM → marker mapping, marker refresh, teamer role grant, demotion incl. the untouched disciplinary block
tests/Security/BpnAuthenticatorTest.php the three demotion outcomes, including the empty-payload safeguard
tests/Entity/UserTest.php setDisabled() block / unblock / no-op, block and deletion as independent states, OneToOne inverse-side sync
tests/Service/Teamer/AccountDeletionHandlerTest.php deletion from either side, missing counterpart, idempotence, roles and block left untouched, restore
tests/Security/UserCheckerTest.php deleted refused, deletion message wins over the block message
tests/EventListener/EmailNotificationSubscriberTest.php no mail to a deleted teamer, per-teamer exclusion on an assignment call-off
tests/Entity/Traits/SoftDeletableEntityTest.php nullable deletedAt, setDeleted() / setRestored()
tests/Form/UserTypeTest.php approval clears the marker, block with reasons, unblock clears them even when the fields are still filled