feat: bpn as single source of truth for role and hotel code assignments

This commit is contained in:
Björn Fromme
2026-08-18 11:33:42 +02:00
parent 1fd0fbc21e
commit f0e850978b
17 changed files with 818 additions and 198 deletions
+95 -38
View File
@@ -3,11 +3,23 @@
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 governing rules, from which 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.
> **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.
---
@@ -16,12 +28,12 @@ The governing rule, from which most of the rest follows:
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 | — |
| 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:
@@ -40,7 +52,9 @@ put the user on the approval list). `ROLE_TEAMER` has no marker: it needs no app
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.
`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
@@ -50,8 +64,9 @@ 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 |
| `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 |
---
@@ -71,9 +86,11 @@ slice it, and picking the right one matters:
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::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`.
`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)
@@ -100,6 +117,14 @@ roles were revoked.
>
> 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.
@@ -107,7 +132,7 @@ roles were revoked.
## Lifecycle
### 1. First login — the only automatic grant of anything
### 1. First login
`BpnAuthenticator::getOrCreateLocalUser()``UserDataHandler::createLocalUser()` writes
`collectRoles()` verbatim, together with the hotel codes from the Hausleitung attributes.
@@ -116,40 +141,70 @@ A CRM admin who is not also a teamer therefore starts with `['ROLE_ADMIN_PENDING
privileges at all: they can authenticate, but `UserChecker` refuses the session until a
super admin approves them.
### 2. Every subsequent login
### 2. Every subsequent login — the sync
`UserDataHandler::updateLocalUser()` refreshes name, email and the teamer record, and then:
`UserDataHandler::updateLocalUser()` refreshes name, email and the teamer record, and hands
the roles to **`syncRoles()`**, which is the whole policy in four steps:
- **`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()`** adds `ROLE_TEAMER` if 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).
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.
Administrative roles and hotel codes are imported at creation and are managed by hand
afterwards. Nothing on this path can raise a privilege.
**`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 `roles` field binds to `assignedRoles`, whose setter **replaces the whole column**.
Consequences to be aware of:
The page keeps three things apart, because they follow three different rules:
- 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.
| 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 |
### 4. Revocation — demotion by the CRM
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 `collectRoles()` is empty, the CRM grants this person nothing in
this application, so they are not a user of it:
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 |
|-----------|---------|
@@ -163,7 +218,9 @@ pending markers — they no longer reflect the CRM. **Granted roles are kept**,
stays reviewable.
Two preconditions guard this branch, because "no roles" is otherwise indistinguishable from
"the CRM told us nothing":
"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;