Authorization when modules run apart (AUTH-006)
Every guarded endpoint on the platform carries @RequiresAccess, and AccessAuthorizationInterceptor
enforces it through an AuthorizationGate. Until AUTH-006 there was exactly one implementation of that
gate, and it lived in core.
| Topology | Gate | Result |
|---|---|---|
| Bundled | AccessDecisionGateAdapter (in-process) | works |
| Distributed (the core service) | AccessDecisionGateAdapter | works |
| Distributed (any other module) | none | every guarded endpoint refused |
⚠ The gap denied; it did not expose. AccessAuthorizationInterceptor fails closed, so a module with
no gate refused everything rather than serving anything unchecked. Safe, and completely unusable.
What now happens
RemoteAuthorizationGate decides remotely, over the AccessDecisionClient that already existed. It is
registered by auto-configuration with @ConditionalOnMissingBean(AuthorizationGate.class), so:
- bundled, and the distributed core service, keep the in-process adapter, which is faster and strictly more capable;
- any other standalone module gets the remote gate.
⚠⚠ It must be auto-configuration rather than a @Component. Every module application scans
com.zhenus.uhp.api.common, and so does the bundled jar, so a scanned component would land in the bundled
context alongside the local adapter, giving two beans where the interceptor takes one, and the platform
would stop booting.
⚠⚠ Core's uptime becomes every module's uptime
This is the operational consequence, and it belongs in the runbook rather than in an incident. While the access-control service is unreachable, a standalone module refuses guarded requests. For a health record that is the right direction: the alternative is serving records to a caller nobody could confirm was allowed to see them.
Plan for it:
- run core with more replicas than any single module;
- alert on the module-side decision error rate, not only on core's own health;
- a module that is degraded because core is down looks like mass
401/403, not like a module fault.
⚠⚠ No decision cache, deliberately
One decision call per guarded request is real load. Caching trades that against revocation latency: a cached allow keeps working after the permission behind it is withdrawn, which is exactly the moment the answer matters. If a cache is added later it must be bounded in seconds, and it must never outlive the token the decision was made for, or a signed-out session keeps its answers.
Measure before adding one. The call is a single POST to a service on the same network.
⚠ Two facts a standalone module answers conservatively
| Fact | Standalone answer | Why not remote |
|---|---|---|
currentCallerRoleIds() | empty | The only endpoint answering it takes an arbitrary user id and is gated by accesscontrol.user-role.read. Reaching it means letting every module's service identity read the whole role graph, a far larger grant than the feature needs. |
currentCallerIsPlatformSuperAdmin() | false | The nearest remote signal, EffectiveAuthorizationDto.scopeBypass, is a related but different fact. Mapping one onto the other is a guess, and the guess that fails quietly is the one that grants. |
Both are the safe reading (they hide, they do not reveal), but both are a behaviour difference from bundled: role-scoped visibility (M11-006 dashboards) resolves to "none", and no super-admin-only capability is offered. Closing this properly needs a caller-scoped endpoint that answers "what am I", which is raised separately.
⚠ currentCallerId() stays local, read from the JWT this service has already validated. Asking core
"who is this" would double every request to learn something the token states.
See also
- Topology: bundled and distributed
- Choosing the authentication server