ADR-0002: Centralised path-matcher authorization
ODD Platform centralises endpoint authorization in one path-matcher rule table — controllers carry no @PreAuthorize, so the entire access matrix lives in one auditable place.
Status
Accepted. Reconstructed from the codebase on 2026-05-30; the decision is live in the source today.
Context
ODD Platform exposes hundreds of endpoints, many of which mutate state — creating data sources, editing descriptions, regenerating collector tokens, managing policies and roles. Each needs an authorization decision: which permission must the caller hold to perform this operation?
Spring Security offers two broad places to answer that. One is method-level annotations — @PreAuthorize / @Secured on each controller method, keeping the rule next to the code it guards. The other is a centralised declaration — every rule registered in one place against the security filter chain, so the whole access matrix can be read at once. The trade-off is locality versus auditability: annotations sit next to the method but scatter the security model across the codebase; a central table concentrates the model at the cost of the controller no longer advertising its own auth posture.
ODD chose the centralised path.
Decision
Authorization is declared in one rule table — SecurityConstants.SECURITY_RULES — and enforced by a single AuthorizationCustomizer. Each gated endpoint is one row mapping a request matcher to the permission it requires; controllers and the generated *Api interfaces carry no @PreAuthorize, @Secured, or programmatic permission checks.
Each row is a SecurityRule(type, matcher, permission): an AuthorizationManagerType (whether the decision needs a resource context — NO_CONTEXT for global operations, or a resource type such as DATA_ENTITY / TERM for per-resource policies), a path-and-method matcher, and the PolicyPermissionDto the caller must hold.
AuthorizationCustomizer wires the chain in a fixed shape:
a whitelist of public paths is permitted (
actuator, static assets, the ingestion surface, and the Slack-events webhook);every
SECURITY_RULESrow is registered as a matcher guarded by that permission's authorization manager;a final
pathMatchers("/**").authenticated()catch-all closes the surface.
The catch-all is the deliberate floor: any endpoint not named in SECURITY_RULES requires authentication but no specific permission — it is available to any authenticated user.
Consequences
The platform has one auditable security matrix. To answer "what does this endpoint require?", read
SECURITY_RULES; to change an endpoint's authorization, edit that one list.Because read (
GET) endpoints are generally not listed, they fall through the catch-all to "any authenticated user." This is the platform's read-collaborative posture — a separate decision, recorded in its own record.The model is path-string coupled: a rule matches a literal path pattern. If a controller's URL changes but its
SECURITY_RULESrow is not updated to match, the rule silently stops applying. The single table makes the rules auditable, but nothing at compile time ties a rule to the route it guards — keeping the two in step is a manual discipline.A contributor adding a mutating endpoint must add its
SECURITY_RULESrow; reaching for a@PreAuthorizeannotation instead would not follow the convention and would split the security model across two places.
Evidence
odd-platform-api/.../auth/authorization/AuthorizationCustomizer.java:20-31— the sole consumer ofSECURITY_RULES: it permitsWHITELIST_PATHS, loops the rules registering.matchers(rule.matcher()).access(manager(rule.type(), extractors, permissionService, rule.permission())), then closes with.pathMatchers("/**").authenticated().odd-platform-api/.../auth/util/SecurityConstants.java:95-96—WHITELIST_PATHS = {"/actuator/**", "/favicon.ico", "/ingestion/**", "/img/**", "/api/slack/events"};:98—public static final List<SecurityRule> SECURITY_RULES = List.of(...), the rule table (onenew SecurityRule(type, new PathPatternParserServerWebExchangeMatcher(path, METHOD), permission)per gated endpoint).odd-platform-api/.../auth/util/SecurityRule.java—record SecurityRule(AuthorizationManagerType type, ServerWebExchangeMatcher matcher, PolicyPermissionDto permission).No
@PreAuthorize,@Secured,@PostAuthorize, or@RolesAllowedannotation exists anywhere inodd-platform-api/src/main— the authorization model is entirely table-driven, confirming the annotation-free decision by exhaustive absence.
See also
ADR-0001 — Contract-first HTTP layer — controllers are thin delegates over generated interfaces, which is why they carry no authorization annotations either: both routing and authorization live outside the controller class.
Policies — how operators author the policies whose permissions these rules check.
Permissions — the permission model the rule table references.
Last updated