Skip to main content
Version: V2-Next

ADR 044: Backend-Side Authorization Rule Enforcement

Status

Date: 2026-05-20 Accepted

Decision Makers: Architecture Board

Context

CIVITAS/CORE runs an OPA in front of the API gateway today (the Gateway-OPA) — it decides "may this caller invoke this endpoint?" from request metadata and the authenticated user.

A new kind of rule cannot be evaluated by this gateway-side flow. The driving example is Pipeline-Write Datapool-Scope enforcement: a save must be rejected if any DataSource referenced by the Pipeline is not allowed in the Dataset's Datapool. The decision needs the IDs the backend will actually persist — for partial updates that is the merge of the request with existing saved state — neither of which is available to the gateway. The check therefore runs at the backend, after entity resolution. Pipeline-Write Datapool-Scope is the first such rule; a small handful more are anticipated near-term. (Evaluating them at the gateway was rejected — see Alternatives.)

The question is where the check is performed at the backend:

  • Option A — One OPA, two callers. The existing Gateway-OPA is also queried by the backend for these rules.
  • Option B — Gateway-OPA + Backend-OPA. Gateway-OPA unchanged; a separate Backend-OPA runs alongside the backend and hosts these rules.
  • Option C — No OPA; enforcement in the backend's service layer. Gateway-OPA unchanged; the rules are checked directly in Java, in the same service code that performs the save.

Checked Architecture Principles

  • [partial] Model-centric data flow — all three options decide on the final to-be-saved state.
  • [full] Distributed architecture with unified user experience
  • [full] Modular design
  • [full] Integration capability through defined interfaces
  • [full] Open source as the default
  • [full] Cloud-native architecture
  • [full] Prefer standard solutions over custom development
  • [full] Self-contained deployment
  • [full] Technological consistency
  • [none] Multi-tenancy — out of scope.
  • [partial] Security by design — see per-option analysis.

Option A — One OPA, two callers

A single OPA instance is queried by both the gateway and the backend. Both calls are mechanically identical — same protocol the gateway uses today, just from a different caller, to a different policy path, with a different payload. The backend's call is a regular OPA call whose input is the final to-be-saved set instead of request metadata.

Implications

  • Shared failure domain. Endpoint-level rules and the new rules share one process. A regression in either affects both.
  • Backend → OPA crosses the cluster network. The channel needs encryption + network rules to stay private.
  • One additional caller, no additional deployment. The OPA instance is the one that exists today.
  • Shared resources. Saturation in one workload can starve the other.

Option B — Gateway-OPA + Backend-OPA

Gateway-OPA unchanged. A separate Backend-OPA runs alongside the backend and is consulted by the backend once it knows the final to-be-saved set. The new rules live only in the Backend-OPA.

Implications

  • Independent failure domains. A regression in the new rules cannot take down endpoint-level authz, and vice versa.
  • Backend → OPA channel stays inside the backend's deployment unit. Private by where the Backend-OPA runs, not by configuration.
  • Backend-OPA starts and stops with the backend. No version drift, atomic rollout.
  • Backend-OPA scales with the backend. Per-instance helper.
  • Two OPAs to operate. In practice deployed together with the backend.

Option C — No OPA; enforcement in the backend's service layer

No additional OPA call. The rule is checked directly in the backend's Java service layer, as part of the same save lifecycle that resolves the entities and validates their status. The Gateway-OPA continues to handle endpoint-level rules unchanged.

Implications

  • No additional moving parts. No OPA instance, no policy bundle, no Rego pipeline, no operational footprint beyond the backend itself.
  • Rule lives where the domain model lives. Datapool-Scope is a business invariant on the entities being touched; the check is part of the same service code that loads and saves those entities.
  • Rule changes follow the backend release cadence. No separate policy distribution.
  • No policy-as-code property. The rule is reviewed as Java service code, not as a separate Rego module that an auditor can read in isolation.
  • Reversible. The rule can be ported to Rego and Options A or B adopted later if the surface grows or audit / policy-as-code becomes a hard requirement. The entity boundary the rule reads from does not change.

Comparison

Decision factorA — One OPA, two callersB — Gateway-OPA + Backend-OPAC — Service-layer check
Check runs in…Existing Gateway-OPA processNew Backend-OPA processThe backend itself (in-process)
Failure isolation between rule setsNone — shared processYes — separate processesYes — the new rules do not depend on any OPA
Latency of the checkOne cluster-network hopLoopback inside the PodIn-process, no hop
Operational footprintOne additional caller on an existing instancePer-backend-instance helperNone beyond the backend
Policy-as-code (separate, centrally reviewable)Yes (Rego)Yes (Rego)No (Java service code)
Rule release cadenceIndependent of backend deployCo-deployed with backendCo-deployed with backend

Decision

Option C — enforcement in the backend's service layer, no OPA is adopted for the first such rule (Pipeline-Write Datapool-Scope) and the small handful of similar rules anticipated near-term.

Reasoning

  • Operational simplicity. No additional OPA instance to deploy, version, or monitor; no policy bundle distribution; no Rego pipeline in the build; no separate decision-log stream to integrate. The rule changes follow the backend release cadence like the rest of its business logic.
  • Rule count too small to justify policy-as-code infrastructure. One rule today, a small handful anticipated near-term. The fixed costs of policy-as-code (Rego authoring, bundle build + distribution, OPA operations, testing tooling) exceed their return at this scale. The rule is small enough to be reviewed in Java alongside the entities it constrains.

Migration option

The decision is reversible. If the surface of these rules grows substantially, or audit / policy-as-code becomes a hard requirement (e.g. compliance, central policy review), the rules can be ported to Rego and either Option A or Option B adopted later. The data the rules depend on (Dataset.datapool, DataSource.datapoolScope, …) is part of the domain model regardless of where the check runs, so the entity boundary does not change — only the call-site moves.

Alternatives considered

Beyond the three options above, these wider approaches were considered and rejected:

  • Evaluating the rule at the gateway (either by extending the Gateway-OPA with body parsing, or by configuring the gateway to forward the body to an external authorization helper). The gateway does not forward the request body today, and the rule needs the post-merge set, which depends on existing saved state the gateway has no access to. Custom plugin work or body-forwarding is non-standard — the gateway vendor's own documentation marks request-body forwarding as not recommended. Body buffering at the gateway would also affect every API request, not only writes.

See also

  • ADR 040 — Separation of Data APIs and Management APIs via Subdomains in CIVITAS/CORE