Skip to main content

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.

TopologyGateResult
BundledAccessDecisionGateAdapter (in-process)works
Distributed (the core service)AccessDecisionGateAdapterworks
Distributed (any other module)noneevery 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

FactStandalone answerWhy not remote
currentCallerRoleIds()emptyThe 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()falseThe 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