Status: DEPLOYED AND VERIFIED 2026-09-15 — accepted as delivered; nothing outstanding. Hermes deployed
4426e20 to the app engine, applied partner.0007 (central) and pharmacy.0015 (apex + client tenant),
provisioned the realm service accounts for both realms, and verified all ten acceptance criteria below live
(the full evidence is in the session-18 verification block on agents/handoff/from-opencode). A view for
adding pharmacy staff, modelled on the
staff-user screens the GP and dental apps already have. MFA is out of scope (explicitly deferred). Gating role decided:
Business Manager only. Related: client-realm (the realm
model this sits on), index,
module-map.
/pharmacy/staff/ (list + inline add-user with role selector), /pharmacy/staff/<id>/,toggle/, reset-password/ — in the pharmacy app. One mixin,PharmacyStaffUserManageRequiredMixin (Business Manager only). No MFA control and no MFA route.role attribute, joined to its role group, org_id on client realmsCustomUser is created.RealmServiceAccountClient — client-credentials against the realmrealm-management: manage-users + view-users, stored in theKeycloakRealmServiceAccount (migration partner.0007). Provisioned byprovision_pharmacy_staff_client --realm <realm>. The master credential is used only to provision.PharmacyAuditEvent STAFF_USER_CREATED / _ENABLED / _DISABLED / _PASSWORD_RESETpharmacy.0015), identifying the target by username + role + realm in detail.pharmacy/tests_staff_users.py (18). The live check that was outstanding is now PROVENGET /users?briefRepresentation=false returns attributes on the deployedpharmacy.Adding a member of staff is one of the everyday activities, and today it cannot be done in the app.
The pharmacy consequence has already been felt: an identity created on the Django side only
(demo.tech.one) exists in the app and cannot log in, because there is no Keycloak user behind it.
The half that must not be skipped is the Keycloak half.
In the GP and dental apps — the screens to model on. The shared partner app provides:
| route | purpose |
|---|---|
/partner/users/ |
list + inline "add user" form (username, email, first name, last name, password) |
/partner/users/<id>/ |
detail; enable/disable, reset password, toggle MFA |
/partner/users/<id>/edit/ |
edit profile and attributes |
/partner/users/create/ |
POST handler for the form above |
Gated by PartnerUserManageMixin → ORG_ADMIN, PARTNER_ADMIN, PARTNER_REGISTERED_MANAGER, ADMIN,
and a request.partner_org (the row the middleware sets from the host). Creation goes through
PartnerKeycloakClient(realm): it creates the Keycloak user with a temporary password, tags
org_id, and also creates a local CustomUser. PartnerUserToggleMFAView is the MFA button.
In the pharmacy app. The same code is present (same fork) but unreachable: pharmacy users carry
PHARMACY_* roles, which are not in those role lists, and the apex tenant has no PartnerOrg row to
supply a partner context. There is also staff_management at /staff/create/, but it creates only the
Django user — no Keycloak call — and is gated to platform ADMIN. Neither is a pharmacy staff screen.
What is missing from a straight copy: the partner create form has no role selector and hardcodes
the app role. Pharmacy needs the role chosen, because the app maps the role claim and the matching
Keycloak role group is what carries the client roles on the app and ERPNext clients.
One screen for the pharmacy sector, at /pharmacy/staff/, with:
role, enabled/disabled, and last login. Search by username orPharmacyAuditEvent.What "add user" must do, in order (this is the whole point — one action, both sides):
role user attribute = PHARMACY_… for the chosen role (the app maps this first, inusers/auth.py::_pharmacy_role_from_claims),RESPONSIBLE_PHARMACIST, DISPENSING_TECHNICIAN,COUNTER_ASSISTANT, BUSINESS_MANAGER) — the group carries the client roles on both the app andorg_id = the client's slug when the realm is a client realm (the app reads it from theorg_id.CustomUser is created. OIDC_CREATE_USER = True means the app makes its own userRealm resolution — no hardcoding. request.partner_org.keycloak_realm when the request is a client
host, else the sector realm (PHARMACY_REALM, pharmacy). This is the same rule the OIDC backend
already uses, so one screen serves the apex tenant and every client.
Scoping. The realm is the scope: a client realm holds only that pharmacy's staff, so the list needs
no org_id filter (it is displayed, and set on creation for client realms). The apex realm holds only
apex staff.
PartnerKeycloakClient authenticates to therealm-management: manage-users and view-users.| # | decision | state |
|---|---|---|
| 1 | MFA control | out of scope (Matthew, 2026-09-15) — button absent |
| 2 | Which role may use the screen | DECIDED — BUSINESS_MANAGER only (Matthew, 2026-09-15) |
| 3 | Password handling | recommend a manager-entered temporary password (matches the partner screen, no email dependency). Email invitation is a later fast-follow; onboarding already has an Invitation flow to build on. |
| 4 | Does it manage the apex tenant too | recommend yes — same screen, realm from the request |
| 5 | Leaver | disable, never delete — in the realm and app-side (the DSPT starter/mover/leaver story) |
role attribute,org_id; and cannot authenticate at the apex host (isolation preserved).PharmacyAuditEvent.MFA; editing another user's role after creation (a "mover" flow — decide later); email invitations;
self-service registration; bulk import; the shared staff_management leave/team screens.
role-mapper, org-id-mapper) —DISPENSING_TECHNICIAN role has no identity anywhere yet; the first thing this screen is