17 KiB
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 rule, from which most of the rest follows:
The BusPro 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.
Role catalogue
Defined in User::ROLES (src/Entity/User.php), these four are the only roles a human can
assign:
| Role | Label | Granted by | Hierarchy |
|---|---|---|---|
ROLE_ADMIN |
Admin | super admin, manually | ⇒ ROLE_ADMINISTRATIVE |
ROLE_MANAGER |
Reisemanager | super admin, manually | ⇒ ROLE_ADMINISTRATIVE |
ROLE_HOUSE_MANAGER |
Hausleitung | super admin, manually | — |
ROLE_TEAMER |
Teamer | 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.
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 edit form binds to |
getPendingRoles() |
only the markers |
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 | label matching /^Hausleitung ([A-Z0-9]+)$/, selected |
isHouseManager + one hotel code per match |
UserDataHandler::collectPendingRoles() turns those into markers — isManager wins over
isHouseManager, they are never both claimed, though the hotel codes of a Hausleitung are
imported eitherway. collectRoles() is that set plus a real ROLE_TEAMER when isTeamer.
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_ADMINAdminAPP_BPN_CRM_ID_MANAGERManagerAPP_BPN_CRM_ID_TEAMERE&P Teamer - allg. MerkmalMatching is by id and never by label, so
Preisrechner Admindoes not trip the admin flag.
APP_BPN_DEFAULT_HOTEL_CODEis 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 — the only automatic grant of anything
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
UserDataHandler::updateLocalUser() refreshes name, email and the teamer record, and then:
refreshPendingRoles()recomputes the marker set from the current CRM claims. A marker whose real role is already granted is dropped — an approved role is never marked again. Granted roles are kept untouched.grantTeamerRole()addsROLE_TEAMERif the CRM reports a teamer and the user does not have it yet. This is deliberately grant-only: the role is never withdrawn here, because it may have been handed out manually and must survive a login. Losing the CRM teamer attribute while holding no other role blocks the account anyway (see 4).
Administrative roles and hotel codes are imported at creation and are managed by hand afterwards. Nothing on this path can raise a privilege.
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 roles field binds to assignedRoles, whose setter replaces the whole column.
Consequences to be aware of:
- Approving is "tick the real role and save". The marker disappears because the column is rewritten from the ticked choices.
- Saving the form drops every marker, including ones you did not act on.
- A denial is not recorded anywhere. As long as the CRM keeps claiming the role, the marker returns on that user's next login.
4. Revocation — demotion by the CRM
Evaluated on every login, in BpnAuthenticator::getOrCreateLocalUser(), before the local
user is even loaded. If collectRoles() 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":
- the response must be a
CrmAttributesResponse— BusPro answers with a notification record on its own errors; - 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 meansgetRoles()contains strings that are not roles in any meaningful sense. Always intersect againstUser::ROLESwhen asking "may this user do anything at all" —UserCheckerdoes exactly that. ROLE_TEAMERis 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 |