Status: the client-tier realm drift is CORRECTED and verified, 2026-09-15. test-client-pharmacy
now authenticates through its own Keycloak realm, and the apex tenant and the client can no longer
reach each other's identities. The repeatable procedure is below. Related:
test-client (the tenant itself),
client-vps (its database move),
index.
This is the same shape as the GP and dental apps, and it is shared code — the pharmacy app carries all
of it:
tenancy.middleware.TenantMiddleware takes the first label of the request hosttest-client-pharmacy.pharmacy.veripath.co.uk → test-client-pharmacy) and looks for aPartnerOrg row with that slug.request.partner_org.users/auth.py::_is_partner_request() is bool(request.partner_org), and_get_partner_realm() returns partner_org.keycloak_realm.TenantAwareOIDCRequestView then point the entire login flow at that realm,partner_org.client_secret.So the realm a host authenticates against is a database row, not configuration. The apex tenant
pharmacy.veripath.co.uk has no such row and falls through to the sector constant
PHARMACY_REALM = 'pharmacy'; each client gets a row naming its own realm.
| Tenant | Host | Realm |
|---|---|---|
pharmacy (apex) |
pharmacy.veripath.co.uk | pharmacy (shared-sector realm, apex only) |
test-client-pharmacy |
test-client-pharmacy.pharmacy.veripath.co.uk | test-client-pharmacy |
| GP client | test-client.gp.veripath.co.uk | test-client |
| Dental client | test-client-dental.dental.veripath.co.uk | test-client-dental |
The pharmacy client was onboarded with a tenant row, its own database on its own box, its host, TLS and
an nginx vhost — but no PartnerOrg row. Without it the host fell through to the sector constant,
so the client's staff authenticated into the shared pharmacy realm, alongside the apex tenant's
demo.* identities, and nothing on a Keycloak user said which pharmacy they belonged to. It was an
onboarding gap, not a design difference — but it had a real consequence:
Before the correction, tc.rp.one (a client identity) could authenticate at the APEX host and sign on
duty at the apex premises. Verified with a real login, not inferred. demo.rp.one at the client host
was also accepted in principle for the same reason (both sets were in one realm).
manage.py provision_pharmacy_realm --realm test-client-pharmacy --name "Test Client Pharmacy" --host test-client-pharmacy.pharmacy.veripath.co.uk --erpnext-source-realm pharmacypharmacy-booking-app (confidential) with that host's redirect URIs, an erpnext clientpharmacy, the four operational roles, and one role group per role (group ids aretc.rp.one, tc.rp.two (RP), tc.bm.one (BM), tc.ca.one (CA) — each with the rolePartnerOrg row was created — slug test-client-pharmacy, realm test-client-pharmacy,client_secret fetched from the new realm's client and stored (never printed).pharmacy were disabled (tc.*, including thetc.test.user): set enabled=false, not deleted, so the audit trail survives and it istc.test.user has no identity in the newis_active=False) — the leaver rule ispharmacy--realm and resolve group ids by name.Redirect on the live hosts: pharmacy.veripath.co.uk → realm pharmacy (unchanged);
test-client-pharmacy.pharmacy.veripath.co.uk → realm test-client-pharmacy.
Full login + RP cycle: verify-pharmacy-client-e2e.py PASS on the client host — the login now
posts to realms/test-client-pharmacy/login-actions/authenticate, and sign-on, the one-on-duty
refusal, history, sign-off and the second pharmacist all behave. PASS on the apex host too, so the
apex tenant is unaffected.
Isolation, real browser logins:
| identity | host | before | after |
|---|---|---|---|
tc.rp.one (client) |
apex | ACCEPTED | rejected |
tc.rp.one (client) |
client | ACCEPTED | ACCEPTED |
demo.rp.one (apex) |
client | — | rejected |
demo.rp.one (apex) |
apex | ACCEPTED | ACCEPTED |
No impact on the client's data or accounting: the move is identity-only. The client's
PharmacyERPNextConfig still names test-client.accounts.pharmacy.veripath.co.uk; logged in as the
client's Business Manager, /pharmacy/ and /accounting/ render no realm string and no
*accounts* host, so the pharmacy accounting surfaces emit no realm-bound SSO link.
No patient data involved: realms hold staff identities only.
Do these in this order; only the last step changes behaviour. Creating the org row before the realm
is complete breaks that client's login (there would be no pharmacy-booking-app client to authenticate
against), so never do step 3 first.
manage.py provision_pharmacy_realm --realm <host-prefix> --name "<Client Name>" --host <client-host> --erpnext-source-realm pharmacyveripath-ops/scripts/create-pharmacy-test-user.py, now realm-aware):printf '%s\n' "$PW" | create-pharmacy-test-user.py --username <prefix>.rp.one --first-name … \ --last-name … --role PHARMACY_RESPONSIBLE_PHARMACIST --realm <host-prefix>provision-pharmacy-client-keycloak.py --host <client-host> --realm <host-prefix> does steps 1–2'smanage.py provision_pharmacy_client_org --slug <host-prefix> --realm <realm> --name "<Client Name>"pharmacy-booking-app client, fetches its secret with GET (so a re-run reusesPartnerOrg row. --dry-run reportsverify-pharmacy-client-e2e.py --host <client-host> --prefix <prefix> must PASS, thenverify-pharmacy-realm-isolation.py --client-host <client-host> must report PASS (it asserts thekcadmin.sh update users/<id> -r pharmacy -s enabled=false) — never delete: disabling is reversible and keeps the trail.Rules that keep it correct: a client's staff exist only in that client's realm; the shared
pharmacy realm is for the apex tenant only; never copy group ids between realms; a leaver must be
disabled in the realm and checked app-side.
Checked dimension by dimension against the live admin API and the central database. Eight of
eight hold for all three client sectors (gp, dental, pharmacy):
| dimension | gp | dental | pharmacy |
|---|---|---|---|
PartnerOrg row pointing at this realm |
yes | yes | yes |
| that row carries a client secret | yes | yes | yes |
| app client present in the realm | yes | yes | yes |
| app client has a redirect URI for that host | yes | yes | yes |
erpnext client present |
yes | yes | yes |
| identities present | yes | yes | yes |
identities carry that realm's role attribute |
yes | yes | yes |
identities carry the org_id attribute |
yes | yes | yes |
The app client also carries both attribute mappers in every client realm — role-mapper and
org-id-mapper (oidc-usermodel-attribute-mapper, delivered to userinfo, id token and access
token) — which is what turns those attributes into the claims the app reads.
Also verified: the client host redirects to its own realm, the apex host still redirects to the
sector realm, and the client's isolation matrix passes (see above).
veripathpharmacy. That is Pharmacy Phase 0's design, notrole attribute alone. Pharmacy is the stricter of the two.<host>/* entry; dental and pharmacy carry/*, /oidc/*, /oidc/callback trio. All three work.The GP and dental client identities' org_id is consumed by the app: on those paths
users/auth.py::update_user() writes user.org_id = claims['org_id'], and
users/views.py filters querysets by it "to prevent data leakage".
On the pharmacy path it is not consumed. update_user() returns early for any pharmacy-sector
request (users/auth.py:233-239, which sets the role, marks staff and returns) before the org_id
block at line 240; create_user() likewise takes the pharmacy branch and never sets org_id. So
CustomUser.org_id stays null for pharmacy users — apex and client alike — even though the realm
now delivers the claim.
Resolved 2026-09-15 (OpenCode session 17). The decision was to consume the claim, matching GP and
dental: users/auth.py now writes user.org_id = org_id in the pharmacy branch of both create_user
and update_user. Apex identities carry no claim and stay null. Tests in users/tests.py.
Consequence of the old behaviour: none, because pharmacy tenant isolation is by host routing and the
tenant's own database, not by org_id filtering. It mattered if the pharmacy user-management view reused
the partner screens (which match local users by org_id) or if any pharmacy queryset later relied on
org_id scoping.
Not tested: realm isolation for the GP and dental clients. No credentials for those identities
were available, so their isolation is inferred from the same code path and separate realms rather
than proven. Pharmacy's is proven live, 4/4.
false on every realm platform-wide (veripath, pharmacy,test-client, test-client-dental) — a platform decision, not a pharmacy defect.Resolved 2026-09-15 (OpenCode session 17):
provision_pharmacy_client_org (step 3 above). The twoPharmacyERPNextConfig.keycloak_realm now exists (migration pharmacy.0014), mirroring dental;provision_pharmacy_erpnext --realm sets it from the tenant's PartnerOrg realm, else the sector realm.org_id (see "One asymmetry" above), so pharmacy matches GP/dental.Verified 2026-09-15 against the live hosts, the Keycloak admin API (/opt/kcadmin.sh), the central
database (tenancy_tenant, partner_partnerorg), and the app itself. The repeatable checks live in
veripath-ops/scripts: verify-pharmacy-client-e2e.py (login + RP cycle),
verify-pharmacy-realm-isolation.py (the four-cell matrix above), create-pharmacy-test-user.py
(realm-aware identity creation) and provision-pharmacy-client-keycloak.py (redirect URIs + synthetic
RPs, realm-aware). Read-only against patient data — realms hold no patient data at any tier.