Skip to main content
Version: 2.0-rc2

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 OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization. at the API gateway level. OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization. decides before the request reaches the backend and has no knowledge of entity state. Introducing state-dependent permission checks would either compromise OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization.'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-PDPPolicy Decision PointThe component that decides whether a request is authorized. CIVITAS/CORE uses Open Policy Agent (OPA) as its 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 (OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization.) 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 (datasetsDatasetA data-related element that contains processed data and makes it available for consumption. A Dataset is populated via Pipelines and carries Metadata and access permissions. already use transition endpoints)
  • [n/a] Multi-tenancy
  • [full] Security by design — preserves single-PDPPolicy Decision PointThe component that decides whether a request is authorized. CIVITAS/CORE uses Open Policy Agent (OPA) as its PDP. principle, no authorization logic outside OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization.

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:

  1. 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.

  2. 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:

  • OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization. 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 (OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization.). Business state logic stays in one place (backend).

Example: DataStructures​

Endpoint mappings (OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization. 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 OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization. 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. OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization. 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. OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization. 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 OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization.'s decision log, so that all access-relevant denials — whether from OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization. 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.
  • OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization./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​

  • OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization. queries entity state from the database: Rejected. OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization. currently depends on exactly two generic data sourcesData sourceA data-related element that represents the origin of data. It defines how data is connected, accessed, and ingested into the Platform, such as an external database or sensor network.: a static endpoint-to-permission mapping and the AuthZ Repository for user context. Both are domain-agnostic. Fetching entity state would couple OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization. to the portal backend's domain model — entity types, state enums, query interfaces, schema changes. RegoRegoOPA's declarative policy language, used to express authorization rules such as checking role assignments at the most specific scope first. 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 OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization. check: OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization. checks the base permission at the gateway. When the backend detects state=AVAILABLE, it calls OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization. again to check the elevated permission. Preserves single-PDPPolicy Decision PointThe component that decides whether a request is authorized. CIVITAS/CORE uses Open Policy Agent (OPA) as its PDP. principle but introduces a runtime dependency from backend to OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization. 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-PDPPolicy Decision PointThe component that decides whether a request is authorized. CIVITAS/CORE uses Open Policy Agent (OPA) as its PDP. architecture. Creates a second code path for permission evaluation that must be kept in sync with OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization.'s logic.

  • Different permissions per state in OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization. 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 OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization. to know entity state, which brings us back to the fundamental problem: OPAOpen Policy AgentThe platform's authorization service: it evaluates 'who may do what' against the CIVITAS/CORE authorization model. OPA is the platform's central Policy Decision Point (PDP) for API authorization. evaluates before the backend, and has no access to entity state.