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:
| Code | Ungrantable since | Effect |
|---|---|---|
document.document.read / .write | M13-006 | the document engine had no descriptor at all — every upload and download |
metadata.currency.read / .write / .delete | M10-005 | currency management |
workforce.job-level.*, workforce.job-role-level-grade.*, workforce.salary-band-pay-range.* (3 verbs each), workforce.staff-portal.write | M17-016 | grade/band administration and the staff portal's writes |
billing.insurance-claim.read | M10-006 | one claim endpoint — a typo for billing.claim.read, which its five siblings use |
hl7.integration-message.read | HL7-001 | the 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
- Declare it in the owning engine's
ModuleDescriptor.permissions(). The prefix must be that descriptor'smoduleKey()— the gate checks this too, so ownership stays unambiguous. - Require it with
@RequiresAccesson the handler. - Grant it: the code is created on the next startup, but an existing role does not pick it up
automatically —
super_adminis re-granted everything bySuperAdminBootstrapService, 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.