ADR 005 — Database-driven, project-scoped RBAC
Status
Accepted
Context
The registry is multi-tenant: users act inside projects, and a user's rights in one project say nothing about their rights in another. Authorization therefore has two planes — global (what a user may do platform-wide) and project-scoped (what a user may do within a given project).
Authentication is already delegated to an external provider (ADR 004); that provider proves identity but does not model this application's project-scoped roles. We also wanted roles and their permissions to be editable as data, so that adjusting who-can-do-what does not require a code change and redeploy, and we wanted per-request checks to be cheap.
Decision
Implement RBAC as data in the database, enforced by Spring method security with a custom PermissionEvaluator.
Roles and their permissions are rows in the database, seeded by Flyway migrations, and loaded into an in-memory map at startup.
A user's granted authorities are plain strings:
- global permission names (e.g.
SOME_GLOBAL_PERMISSION); - per-project authorities namespaced as
{projectId}_{PERMISSION}; - per-project option authorities
{projectId}_REGISTRY_PROJECT_OPTION_{X}.
- global permission names (e.g.
Enforcement uses
@PreAuthorize:hasAuthority('X')checks a global permission;hasPermission(#projectId, 'X')routes to the customPermissionEvaluator, which simply checks whether the user holds the string{projectId}_X.
Project-scoped authorization is therefore a namespaced-string membership test.
Visibility gating: for a disabled (invisible) project, non-admins receive no authorities at all; even admins keep only project read/update/delete.
Authorities are computed at token-conversion time — the same custom converter that maps the OIDC token to the local user (ADR 004) builds this authority set.
Consequences
Positive
- Roles and permissions are editable as data. Changing what a role grants is a database change (a migration), not a code change — no redeploy to adjust the permission model.
- Multi-tenant isolation comes for free. Because project authorities are namespaced by
projectId, a permission in project A (A_EDIT) can never satisfy a check in project B (B_EDIT). Cross-tenant leakage is structurally impossible in the string form. - Checks are simple and fast. Both planes reduce to an in-memory string-set lookup, so
@PreAuthorizeadds negligible per-request cost. - Declarative and co-located.
@PreAuthorizesits on the contract interfaces of ADR 001, so the required permission for an endpoint is visible right at the API surface. - Visibility gating is centralized. The rule that an invisible project collapses a user's authorities lives in one place (authority computation), not scattered across handlers.
Negative
- Authorities are recomputed only at token conversion. A role or permission change does not take effect for an already-authenticated user until they re-authenticate and a fresh authority set is built. There is no live revocation mid-session.
- String-namespacing is clever but non-obvious.
{projectId}_{PERMISSION}as an authority is compact and fast, but a reader must know the convention; a typo or a projectId containing an underscore-like value is a latent correctness risk, and the scheme is not self-documenting. - The permission set is loaded at startup. Because roles/permissions are read into memory once, changing the seed data requires a restart or an explicit refresh to take effect — the database being the source of truth does not make it live.
- Two moving parts to keep aligned. Seed migrations define the permissions;
@PreAuthorizestrings consume them. A permission renamed in one place but not the other fails silently as a denied check.
Why not the identity provider's roles/groups
The OIDC provider (ADR 004) can carry roles and groups in the token, which would avoid a local permission model entirely. We rejected leaning on it because the provider does not model this application's project-scoped plane — encoding per-project authorities for every project into provider groups does not scale, and it would couple the tenant permission model to IdP administration. Keeping authorization local and data-driven keeps it under application control and editable by a migration.
Why not a fine-grained ACL / policy engine
A full ACL table or an external policy engine (per-resource grants, attribute-based rules) is more expressive and supports live changes without re-auth. We judged it overkill for the current model: the namespaced-string scheme is dramatically simpler and faster, and the two planes (global + project-scoped) cover the requirements. If authorization ever needs per-record grants or runtime revocation, this is the natural place to revisit — the PermissionEvaluator is the single seam where a richer engine would slot in.