ADR 032: State-Dependent Authorization via State Machine Enforcement
Date: 2026-02-17
Status: Proposed
Decision Makers: Architecture Board
Context
The DataStructures API (#889) introduces entities with lifecycle states (e.g. DRAFT, AVAILABLE). Team 2 identified that PUT operations on AVAILABLE entities should require elevated permissions (RELEASE_DATASTRUCTURE) in addition to the base permission (UPDATE_DATASTRUCTURE).
This raises an architectural question: the current authorization model uses static endpoint-to-permission mappings evaluated by OPA at the API gateway level. OPA decides before the request reaches the backend and has no knowledge of entity state. Introducing state-dependent permission checks would either compromise OPA's stateless design or split authorization logic across multiple components.
The question is how to handle operations that are semantically different depending on entity state, without breaking the single-PDP principle or coupling the policy engine to business domain state.
Checked Architecture Principles
- [n/a] Model-centric data flow
- [n/a] Distributed architecture with unified user experience
- [full] Modular design — clean separation between authorization (OPA) and business logic (backend)
- [full] Integration capability through defined interfaces — transition endpoints are explicit, well-defined API contracts
- [n/a] Open source as the default
- [n/a] Cloud-native architecture
- [n/a] Prefer standard solutions over custom development
- [n/a] Self-contained deployment
- [full] Technological consistency to ensure maintainability — follows existing pattern (datasets already use transition endpoints)
- [n/a] Multi-tenancy
- [full] Security by design — preserves single-PDP principle, no authorization logic outside OPA
Decision
State transitions are modeled as explicit endpoints with their own permission mappings. CRUD endpoints are restricted to states where the base permission is sufficient. The backend enforces valid state transitions.
The authorization model distinguishes two categories of operations:
-
CRUD operations (GET, PUT, DELETE) — mapped to base permissions (e.g. UPDATE_DATASTRUCTURE). The backend enforces which states allow these operations. A PUT on an AVAILABLE entity returns 409 Conflict, not 403 Forbidden — the user is authorized, but the operation is invalid for the current state.
-
State transitions — modeled as dedicated endpoints (e.g.
POST /v2/datastructures/{id}/release), each mapped to its own permission. These are the permission-relevant operations for lifecycle changes.
This means:
- OPA remains stateless with static endpoint-to-permission mappings. It answers: "Is this user allowed to perform this type of operation?"
- The backend enforces the state machine. It answers: "Is this operation valid for this entity's current state?"
- Authorization logic stays in one place (OPA). Business state logic stays in one place (backend).
Example: DataStructures
Endpoint mappings (OPA data.json):
"/v2/datastructures/{id}": { "PUT": "UPDATE_DATASTRUCTURE", "DELETE": "DELETE_DATASTRUCTURE" },
"/v2/datastructures/{id}/release": { "POST": "RELEASE_DATASTRUCTURE" },
"/v2/datastructures/{id}/deprecate": { "POST": "DEPRECATE_DATASTRUCTURE" }
Backend enforcement:
PUT /v2/datastructures/{id}— only allowed in DRAFT state. Returns 409 if AVAILABLE.POST /v2/datastructures/{id}/release— only allowed in DRAFT (or APPROVED) state. Transitions to AVAILABLE.
To modify a released (AVAILABLE) datastructure, the user must go through the appropriate transition workflow (e.g. revise → edit → re-release), each step gated by its own permission.
Scaling to complex state graphs
The pattern scales linearly: each state transition is one endpoint, one permission, one line in the OPA data file. Adding states does not increase combinatorial complexity because permissions are modeled around transitions, not around resources x verbs x states.
DRAFT ──submit──→ IN_REVIEW ──approve──→ APPROVED ──release──→ AVAILABLE ──deprecate──→ DEPRECATED
↑ | |
└───── reject ──────┘ └──── revise ────→ DRAFT (new version)
Each arrow is an endpoint. Each endpoint has one permission. OPA stays flat.
A note on REST purity
The transition endpoints (POST /v2/datastructures/{id}/release) are not strictly RESTful — they encode actions in URLs rather than operating on resources with HTTP verbs. However, REST does not model state transitions well. The alternatives are worse:
- PATCH the state field (
PATCH /datastructures/{id}with{"state": "AVAILABLE"}): Hides operation semantics. OPA sees a PATCH, maps it to UPDATE_DATASTRUCTURE. Back to the same problem. - PUT a state sub-resource (
PUT /datastructures/{id}/state): All transitions go through one endpoint with one permission. Same problem.
The POST /resource/{id}/action pattern is an acknowledged REST extension known as a "controller resource" or "process resource" (cf. REST API Design Rulebook, Rule 2.6). It is widely used in production APIs: GitHub (POST /pulls/{id}/merge), Stripe (POST /charges/{id}/refund), Kubernetes (POST /pods/{name}/eviction).
The portal backend already follows this convention — POST /v2/datasets/{id}/release is an existing endpoint in the current API.
Consequences
- Backend teams must enforce state constraints on CRUD endpoints (409 for invalid state) and expose explicit transition endpoints for lifecycle operations. State constraint rejections (409) must be logged in a structured format compatible with OPA's decision log, so that all access-relevant denials — whether from OPA at the gateway or from the backend's state machine — are auditable in a single trail. The log format and transport will be defined jointly between the authz and backend teams.
- OPA/AuthZ requires no structural changes. New transitions only need a new line in the endpoint mapping data file.
- Frontend must use the transition endpoints for state changes rather than overloading PUT with different semantics depending on state.
- API design follows a consistent pattern: CRUD for data manipulation, named POST endpoints for state transitions. This is more explicit and self-documenting than overloading CRUD verbs with state-dependent behavior.
Alternatives
-
OPA queries entity state from the database: Rejected. OPA currently depends on exactly two generic data sources: a static endpoint-to-permission mapping and the AuthZ Repository for user context. Both are domain-agnostic. Fetching entity state would couple OPA to the portal backend's domain model — entity types, state enums, query interfaces, schema changes. Rego policies would need entity-specific branching logic per resource type, and would break whenever the backend's domain model evolves. The added latency (extra HTTP call per request) is secondary to the coupling concern.
-
Backend performs secondary OPA check: OPA checks the base permission at the gateway. When the backend detects state=AVAILABLE, it calls OPA again to check the elevated permission. Preserves single-PDP principle but introduces a runtime dependency from backend to OPA and an implicit "sometimes we check twice" pattern that is hard to reason about and audit. Also sets a precedent for backend-initiated authorization checks that could proliferate.
-
Backend performs its own permission check: Backend queries the AuthZ Repository or permission tables directly when entity state requires elevated permissions. Rejected. Splits authorization logic across two components, violating the single-PDP architecture. Creates a second code path for permission evaluation that must be kept in sync with OPA's logic.
-
Different permissions per state in OPA data file: Extend the endpoint mapping format to include state-dependent permissions (e.g.
{"PUT": {"DRAFT": "UPDATE_X", "AVAILABLE": ["UPDATE_X", "RELEASE_X"]}}). Rejected. Requires OPA to know entity state, which brings us back to the fundamental problem: OPA evaluates before the backend, and has no access to entity state.