Skip to main content

Access Control — How it works

Overview

The access-control engine is the platform's authorization core. It owns local identity (user accounts, local credentials, refresh tokens), the role/permission model, the access scopes that bound a user or provider to tenants/facilities/departments/modules, and the unified access decision that combines all of these — plus identity-access visibility/consent/grants and feature flags, with optional OPA policy — to answer "may this caller do this to this object?". It also supports break-glass access and audits patient-data access.

Data model & ownership

AreaTables
Local identityuser_account, user_refresh_token, password_reset_token
RBACrole, permission, role_permission, user_role
Scopesuser_access_scope (+ _tenant), provider_access_scope (+ _tenant), user_facility_access, user_department_access (home flag — M17-014), user_module_access
Policyauthorization_policy, opa_policy_bundle_reference, policy_decision_log
Break-glass & auditbreak_glass_grant, patient_access_audit_event

Key rules & invariants

  • Session geography is resolved through peer services (ARCH-001). AccessScopeResolver turns in-force grants into tenant/facility picker options via FacilityService / TenantService (and FacilityGeographyQuery for paged search) — it does not inject peer repositories or return facility/tenant entities.
  • The unified access decision gathers authoritative local facts (status/roles/permissions/scopes)
    • identity-access visibility/consent/grants + feature flags; OPA evaluates contextual policy and must not duplicate role/scope lookup. OPA is off by default — the local pipeline already enforces access.
  • Patient/person/clinical searches filter by access scope server-side.
  • How a refusal is reported depends on what the refusal would disclose (SEC-011). A decision taken against a record the caller addressed by id is reported as that path's own not-found, so a denied caller and a caller naming an absent record cannot be told apart; a decision taken against a patient the caller named itself keeps the plain 403. The decision is the same either way — only its rendering differs. See Denied record reads answer as not-found.
  • No credential the platform generates is ever written down (SEC-010). An admin password reset mints a one-time link and stores only its SHA-256 hash; the account's own password is untouched until its holder redeems it at the unauthenticated POST /api/v1/auth/password-reset/redeem. Unknown, expired and already-spent tokens all answer 401 with one identical body. See Password reset issues a one-time link.
  • A permission exists only because a module declared it (SEC-016). PermissionCatalogService builds the permission table from ModuleDescriptor.permissions() and from nothing else, so a code an endpoint requires via @RequiresAccess but no descriptor declares is never created — and an endpoint requiring a code that does not exist refuses every caller, super_admin included, since super admin holds everything that exists rather than skipping the check. PermissionDeclarationGateTest fails the build on the mismatch. See A permission exists only if a module declares it.
  • Break-glass requires a reason, elevated permission, a time box, an audit record, and a review signal.
  • Service-to-service calls carry service identity + delegated user/request context.
  • A scope may only be granted at or below the grantor's own. A division administrator may grant that division or anything beneath it, never its parent, a sibling, or GLOBAL. The same-level case is allowed on purpose, so an administrator can appoint a peer; privilege spreads sideways but never upward. Enforced on assignment, update and revocation — update and revoke check the stored scope, since level and entity are immutable there. Registration shares the rule, or it would be a way around it.
  • No account administers its own authority. It may not change its own roles, edit a role it holds, or change the roles of its own user type. The latter two matter most: comparing only the target account to the caller is bypassed by editing a role the caller holds. A role held through a user type counts as held.
  • Nothing may be granted beyond the grantor's own holdings — a role only if its permissions are a subset of the caller's, a permission only if the caller holds it. With the rule above this also closes the two-step version: grant a superset role to an accomplice, have them grant it back.
  • A super admin bypasses the subset rule (it holds everything) but not self-administration: no account may widen itself with no second party involved. The unified access decision likewise treats a platform super-admin as holding every required permission (scope bypass already applied); the patient gate on clinical WRITEs still applies.

What a Facility Admin may and may not do

Facility Admin is a deny-list: everything except write/delete/manage on tenant, metadata (national geography), platformconfig, module, and accesscontrol.permission / .role / .policy. Reads are never excluded — the division and tenant pickers cannot place anyone without listing geography and tenants.

It does hold accesscontrol.scope and user-role writes, because administering people is the job. That is safe only because the guards above bound what those writes can achieve at runtime.

Being a deny-list, a permission a future module declares is granted to this role by default. BaselineRoleTest pins the excluded families for that reason: adding an above-facility family without excluding it fails the build rather than leaking.

One role per module (PO 2026-08-23)

Fourteen module roles are seeded beside the general ones — Pharmacy, Laboratory, Imaging, Finance, Human Resource, Procurement, Scheduling, Programme, Reporting, Forms, Workflow, Interoperability, Terminology, Notifications. Each is built by BaselineRole.moduleWorker(prefixes…), which grants every permission in the module's own namespace and, outside it, reads only.

Three properties are pinned by BaselineRoleTest rather than left to the reader:

  • No module role can administer access control. accesscontrol.* is excluded outright, so a pharmacy role cannot grant itself anything else.
  • A module role reads outside its module but never writes. A pharmacist must see the patient and the order to dispense; they have no business editing the encounter.
  • It cannot write another module's namespace. Two module roles are not a superset of each other.

:::note An access role and a job role are different things, and both were asked for role decides what an account may do. workforce.job_role decides what a person is employed as — the vocabulary a staff engagement references, alongside job level, salary grade and band. A pharmacist needs both: the role so the system lets them dispense, the job role so the establishment records that dispensing is their job. workforce/037 seeds a matching job role per module.

Nothing joins the two, deliberately. A facility is free to employ a "Dispensing Technician" who holds the Pharmacy access role, and that is the normal case. :::

Home vs cover department access (M17-014)

user_department_access drives form org-visibility through UserOrgAccessResolver — see Form → visibility scoping for the form side. Grants are administered at /api/v1/access-control/user-accounts/{userAccountId}/department-access and gated on accesscontrol.department-access.read / .write, not the broad accesscontrol.legacy-access.* bucket its facility- and module-access siblings share: a department grant decides which clinical forms an account is offered, so it should not ride along with unrelated administration. ScopeDelegationGuard containment applies on both grant and home rotation — an administrator may only grant departments inside a facility their own scope already reaches. Rows marked home = true are the engagement/registration home: staff registration bootstraps them, and workforce StaffEngagement create/promote rotates them (PUT …/department-access/home voids prior home and writes the new unit→department). Resign/retire calls DELETE …/department-access/home. Cover / float / admin grants leave home = false and survive promote.

Live roster union (M17-015)

UserOrgAccessResolver still starts from standing user_department_access day windows, then — when present — unions an optional exchange SPI bean CurrentOrgAssignmentSource that answers active now (LocalDateTime shift windows). Workforce contributes WorkforceCurrentOrgAssignmentSource (ASSIGNED shifts with startsAt <= now < endsAt). Core stays Maven-independent: no SPI bean means grants-only visibility. The @Cacheable short TTL (45s, uhp.cache.caches.userOrgAccess.ttl) bounds roster lag, and the grant write paths evict the entry outright.

That TTL is load-bearing rather than a tuning knob, and it was inert until M22-017 closed: userOrgAccess was missing from CacheNames.ALL, so Caffeine built it on demand with no expiry and no size bound and the configured value was read by nobody. A grant whose end_date passed at midnight therefore stayed in force on that instance until some unrelated write evicted it, because the resolver evaluates LocalDate.now() once per cached answer. Any new cache name must be added to CacheNames.ALLCacheConfigTest now asserts the list against the declared constants.

Machine principals — service and daemon identities (SEC-018)

Some work has no person behind it: a scheduled report run, an inbound HL7 message arriving on an MLLP listener, an asynchronous bulk export. Those threads have no inbound HTTP request, so there is no Authorization header to forward, and every write they attempt is rejected as anonymous.

A service identity is the credential for that work.

Registered inservice_identity — a client id, a BCrypt secret, the module it speaks for, and the tenant/facility its writes are scoped to
Authenticates viaPOST /api/v1/auth/service-token — an OAuth2 client-credentials grant, so Keycloak or Cognito can issue these later without changing any caller
Acts asa backing user account, so authorization, tenant scoping and the audit actor behave exactly as they do for a person
Two kindsa daemon principal runs system-initiated work with no user behind it; every other principal must name the user it acts for

The secret is shown exactly once, when the principal is created and again when it is rotated. The server keeps only the digest, so a lost secret is rotated rather than looked up — and rotation takes effect immediately, with no grace period, because rotation exists to answer a suspected leak.

A service account cannot log in. Creating a principal marks its backing account service_account, which LocalAuthService refuses on login, refresh and session-scope selection. Before this, a module's configured password was also a working UI login, and the audit trail could not tell the two apart.

The identity is proven, not asserted. X-Service-Identity has been read since M4-019, but any caller can write a header — which is why the audit attribution deliberately refused to persist it. The identity now arrives as a signed claim, the filter overwrites whatever the header said, and audit_event.service_identity records it. Deactivating a principal takes effect on the next request, because the policy re-reads the row rather than trusting the token.

Configuring a module to use one — in <module>/src/main/resources/uhp-module-defaults.yml, never the module's application.yml (only app's is read in the single jar):

platform:
service-identity:
client-id: ${REPORTING_SERVICE_CLIENT_ID}
client-secret: ${REPORTING_SERVICE_CLIENT_SECRET}

Both values live in .env. There is deliberately no default: the app should fail on a missing secret rather than run with one from source control. With no principal configured, the module boots normally and simply has no background credential.

API

See the API Reference. Endpoint groups under /api/v1/access-control: auth (local login/refresh), users, roles, permissions, user/provider access scopes (+ facility/department/module access, including home rotate/end), access-decision and object-access-decision, break-glass, patient-access audit, and service identities (list/create/rotate/activate). The grant itself is POST /api/v1/auth/service-token, beside login and refresh — it is where a caller becomes authenticated, so it cannot require authentication.

Configuration & feature flags

  • security.access-control.enforce (declarative @RequiresAccess filter; on by default, M10-017 / M25-008). Set SECURITY_ACCESS_CONTROL_ENFORCE=false only to opt out deliberately. Handlers annotated with @RequiresAccess are checked through AuthorizationGate before the controller runs. When enforcement is on and the gate bean is missing, annotated requests are refused (fail-closed). Controllers across core and workforce declare @RequiresAccess (M25-008); AuthController login/refresh/logout and platform status stay unannotated by design. Endpoint permission does not replace the patient gate on clinical WRITEs (see VISIBILITY.md).
  • OPA enablement (OPA_ENABLED, fail-closed, base URL) — off by default.

Why it is this way

A permission must be declared by a module before it can be granted (SEC-016). An undeclared code is ungrantable rather than silently permissive, so a typo in a permission string fails closed at configuration time instead of opening a hole at runtime.

Refusals are indistinguishable from absence on patient-scoped reads. A distinguishable refusal tells an unauthorised caller that the record exists, and for a patient record that existence is itself the disclosure. The real reason is written to the access log, where it is answerable.

Service identity is separate from user identity (SEC-018). Background jobs and module-to-module calls have no user, and borrowing one would make the audit trail claim a person did something a scheduler did.

Scoping a search (REF-005)

Build the tenant predicate with TenantScopePredicates.tenantOnly or tenantOrGlobal (core/.../common/search/). ⚠ Do not hand-roll it, and in particular do not write if (tenantId != null) around it.

That conditional was in every search specification, and read for what it does when the condition is false, it drops the tenant predicate and returns every tenant's rows. Nothing throws, nothing logs, and the response is a well-formed page of results, which is what makes a leak of that shape invisible from the outside.

tenantOnly restricts to the session tenant. tenantOrGlobal also admits Global rows, which are the platform catalogue every tenant may read and are shared by design. Both throw when the tenant is null, at the point the query is built, so a caller with no tenant fails loudly instead of quietly widening its own result set.

SearchesCannotSkipTenantScopeTest enforces this for any file that builds a Specification. It deliberately does not police the same expression elsewhere: seven services use if (tenantId != null) to decide whether to validate a tenant id, which is a null check about an argument rather than a decision about what a query returns.

Who can find a patient (SEC-404)

A caller may READ a patient when any one of these holds. The first is the ordinary case; the rest are the deliberate routes.

routecondition
discoverable at the caller's facilitythe patient is not RESTRICTED and is registered at, or visible to, the facility on the caller's session. ⚠ Covers READ and WRITE
provider in scopethe request carries a provider id and a scope the provider holds
explicit grantan ACTIVE person_access_grant in date, naming the caller
break-glassan active emergency grant
submitting providerthe caller wrote the record (WRITE only within 24 hours)
programme visibilitythe patient is enrolled in a programme visible to the caller's tenant

"At their facility" means registration first

"Registration" here is patient_facility_relationship, a patient's care relationship with a facility. It has nothing to do with shift rosters or unit roster policy, which are Workforce concepts about scheduling staff. Both could be called "the facility's roster" in English and they are unrelated tables; prefer "registered at" for patients and "rostered to" for staff.

Two tables make facility statements and they disagree on live data:

  • patient_facility_relationship — where the patient is registered. Patient search's own SQL requires an active row here at the queried facility, so this is the set a caller can actually be handed.
  • person_visibility_scope — how widely the patient may be seen, at FACILITY, DEPARTMENT, TENANT, DIVISION, COUNTRY or GLOBAL level.

The decision reads the registration first and falls back to the scope. ⚠ A patient was found registered at one hospital while carrying a scope naming another, and consulting only the scope refused a row the search query had already judged to belong to the facility — the screen read "No matching patients" about somebody plainly registered there.

⚠⚠ It turns on the active facility, never the tenant claim alone. A tenant spans facilities, so accepting the tenant as a stand-in would make every patient in the tenant discoverable to a clinician signed in to one of its hospitals. With no active facility in the session, the answer is no.

⚠ WRITE too, and why that is not a privilege escalation

PO 2026-09-10: "whoever has patient read in the facility can see non-vip patients and whoever has patient write permission can write non-vip patients."

This decision answers "may this caller reach this patient at all", never "may they perform this operation". The operation is gated earlier by @RequiresAccess and AccessAuthorizationInterceptor, which refuse the request before the service is reached unless the caller holds the specific permission (clinical.visit.write, and so on). So a reader does not become a writer; a writer simply stops being blocked by the patient.

⚠ It does bypass the 24-hour submission window for these patients. That is intended: the window exists so a submitter may amend a record they could not otherwise reach, and a clinician at the patient's own facility now reaches it by right.

⚠⚠ RESTRICTED (VIP) patients get none of this. restricted is read first and this route requires !restricted, so facility-wide discoverability is exactly what a VIP gives up (SEC-015). A VIP is reachable only by explicit grant or break-glass — including for a provider, whose grant matches through their account id because under ID-001 account, person and provider share one UUID.

⚠ Two things hid this defect for a long time. PersonAccessGuard sends only a user account id, a person id and READ, so providerInScope is structurally unreachable from search; and a Global-tenant member (super_admin) skips decidePatientAccess entirely, so the account most likely to be used to check a screen was the one account the defect could not reach.

⚠⚠ The registration reader is injected @Lazy. Without it the context refuses to start on a five-bean cycle back through PatientAccessGuard — every guard resolves through the access decision, so anything the decision injects that reaches a guarded service closes a loop. Mock-based tests cannot see this.

Traps

Every client-credentials failure answers identicallyAuthController. Unknown client, wrong secret and deactivated principal are one response, because distinguishing them is an oracle for enumerating valid client ids.

There is deliberately no path from a secret hash to a DTOServiceIdentityMapper. Not an oversight to be helpfully "fixed": a secret is shown once at creation and never again.

The mapper ignores active, lastUsedAt and secretRotatedAt from a request — the same class. These are facts the server owns; accepting them from a caller would let a client backdate its own rotation.

ServiceIdentityRepository is deliberately not tenant-scoped, unlike nearly every other finder in the engine. Service principals authenticate before a tenant is known.

A password-reset link is enqueued, not sentUserAccountController. Nothing dispatches notifications yet (NOTIF-001), so a deployment that assumes delivery will wait for an email that is sitting in a queue.

The self-service reset request answers 204 whatever happensPasswordResetController (SEC-030). Unknown username, disabled account, no email, Keycloak: one identical response, so the endpoint cannot enumerate accounts. requestPasswordReset returns void specifically so no status can be derived from it. Adding a helpful error here re-opens the oracle; the tests will fail if you do.

That endpoint's rate limit does not come from failure counting. AuthRateLimitFilter counts only 401/403, and this path never produces either — so it is in security.rate-limit.always-counted-paths, where every request consumes budget. Without that entry the two designs cancel out and the endpoint is unthrottled. See Authentication rate limiting.

A security event is recorded only when the account exists. Recording the misses would put "does this username exist?" into a table an analyst can query — the enumeration oracle moved, not closed.