Skip to main content

A permission exists only if a module declares it (SEC-016)

The permission table is not written by hand and not seeded by Liquibase. PermissionCatalogService builds it at startup from exactly one source: the permissions() list of every ModuleDescriptor bean on the classpath. Nothing else creates a permission row.

So a permission code has two independent halves, and they are written in different files:

  • an endpoint requires it — @RequiresAccess(permission = "metadata.currency.write")
  • a module declares it — MetadataModuleDescriptor.permissions()

Write only the first and the code never becomes a row. No role can be granted what does not exist, so the endpoint refuses every caller — super_admin included, because super admin holds every permission that exists rather than bypassing the check. The failure is total, silent at build time, and looks in production exactly like a misconfigured role.

That is not hypothetical. demographic.person-merge.write shipped this way in M13-008 and left patient merge unusable until M13-008B. A sweep then found sixteen more:

CodeUngrantable sinceEffect
document.document.read / .writeM13-006the document engine had no descriptor at all — every upload and download
metadata.currency.read / .write / .deleteM10-005currency management
workforce.job-level.*, workforce.job-role-level-grade.*, workforce.salary-band-pay-range.* (3 verbs each), workforce.staff-portal.writeM17-016grade/band administration and the staff portal's writes
billing.insurance-claim.readM10-006one claim endpoint — a typo for billing.claim.read, which its five siblings use
hl7.integration-message.readHL7-001the message log — the descriptor calls the resource hl7.message

The last two were corrected at the annotation rather than declared, because a second code for a resource that already has one splits its grants in two.

The gate

PermissionDeclarationGateTest (app/src/test/java/.../app/) fails the build when a required code is undeclared, naming every offending site.

It lives in app because app is the only module that depends on all six. A copy per module would be blind to the others, and a code borrowed across a module boundary would pass every one of them. It reads class metadata with ASM instead of reflection, so it sees @RequiresAccess on interfaces and services too — PatientConfidentialityService declares it on the interface — and initialises no application class to run.

A blank permission is left alone. It means "authenticated, no RBAC check", and whether a handler may opt into that is RequiresAccessArchitectureTest's question, not this one.

It also covers write endpoints outside core

RequiresAccessArchitectureTest requires @RequiresAccess on every write-mapped controller method — but it analyses com.zhenus.uhp.api.core and can see no further, so workforce, imaging, lab, pharmacy and hl7 were never covered. The same gate now enforces the rule for that remainder.

It needs no exemption list of its own: the controllers legitimately reachable without a permission — AuthController, PlatformStatusController, PasswordResetController — all live in core and are already excused there. A second copy of that allow-list would only be a second thing to drift.

One controller is exempt by name. Hl7OutboundController maps two POSTs with no @RequiresAccess, so any authenticated caller can queue an outbound ORM or ADT message. It is left as it is on purpose: it answers Hl7OutboundClient, a module-to-module call, and the control it wants is service identity — requiring a user permission would refuse the ordering clinician unless every such role were also granted hl7.message.write, breaking imaging order placement to close a lesser hole. Service identity is unbuilt (Tickets.md GAP-003). Naming the exemption keeps it from quietly widening to the next controller.

The reverse direction is deliberately not enforced: a declared code with no endpoint yet is a normal state. BillingModuleDescriptor declares claim and subscription codes ahead of their APIs so roles can be composed before the endpoints land.

Adding a permission

  1. Declare it in the owning engine's ModuleDescriptor.permissions(). The prefix must be that descriptor's moduleKey() — the gate checks this too, so ownership stays unambiguous.
  2. Require it with @RequiresAccess on the handler.
  3. Grant it: the code is created on the next startup, but an existing role does not pick it up automatically — super_admin is re-granted everything by SuperAdminBootstrapService, other roles need the grant made explicitly.

Step 3 is what makes this a deploy-time concern as well as a build-time one: on an already-running database the new row appears at restart, and any role that should hold it must be updated.