feat: bpn as single source of truth for role and hotel code assignments
This commit is contained in:
+95
-38
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user