> For the complete documentation index, see [llms.txt](https://docs.opendatadiscovery.org/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.opendatadiscovery.org/developer-guides/architecture-decision-log/adr-0002-centralised-path-matcher-authorization.md).

# ADR-0002: Centralised path-matcher authorization

## 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:

1. a whitelist of public paths is permitted (`actuator`, static assets, the ingestion surface, and the Slack-events webhook);
2. every `SECURITY_RULES` row is registered as a matcher guarded by that permission's authorization manager;
3. 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_RULES` row 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_RULES` row; reaching for a `@PreAuthorize` annotation 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 of `SECURITY_RULES`: it permits `WHITELIST_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 (one `new 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 `@RolesAllowed` annotation exists anywhere in `odd-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](/developer-guides/architecture-decision-log/adr-0001-openapi-generated-controller-interfaces.md) — 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](/configuration-and-deployment/enable-security/authorization/policies.md) — how operators author the policies whose permissions these rules check.
* [Permissions](/configuration-and-deployment/enable-security/authorization/permissions.md) — the permission model the rule table references.
