ADR-0028 — Guarded Transactional Actions: Compose, Don't Add a Primitive¶
Status: Accepted (v0.80.62). #1311 + #1312 shipped (v0.80.63 / v0.80.64); #1313 pending, now scoped by ADR-0029.
Issue: #1310 (proposal). Decomposes into #1311 (FK-path create-scope, the #1124 v2 unlock — shipped), #1312 (update-destination revalidation — shipped), and #1313 (extend atomic #1228 — pending).
Relates: ADR-0009 (predicate algebra), ADR-0010 (permit/scope separation), ADR-0025 (entity-level authorization), ADR-0029 (atomic flows = the schema-local transactional-intent substrate — supersedes this ADR's #1313 framing and absorbs rule 3's scope-parent clause as its invariant 4)
Decision¶
There is a real class of operation Dazzle's permit:/scope: model can't secure today: one whose authorization boundary is a multi-hop FK path on the create/write path, and/or which is a multi-step transaction that must be atomic and auditable (the canonical case: a department-scoped "reassign" that end-dates one row and creates another, guarded on the department of both the source and the destination).
Dazzle will not address this with a new top-level action: primitive, and will not introduce guards "evaluated in the handler" outside the predicate algebra. Instead, a guarded transactional action is the composition of three pieces, each on an existing trajectory, with the guard expressed as algebra predicates (ADR-0009), not imperative handler code:
- FK-path authorization on
create— #1311 (the #1124 v2 unlock). Lift the depth-1 limit onscope: create:via the already-roadmapped payload-time SQL probe, soscope: create: teaching_group.department = current_user.departmentworks. This closes the spoofing hole inside the algebra — statically validated at link time, visible to the RBAC matrix and conformance. - Re-validate the destination on
update— #1312. The update path today checks only the pre-read of the existing row; it never validates the new FK value the payload moves the row to. Add destination revalidation so an in-scope row cannot be moved into a foreign scope. - Extend
atomic(#1228), don't fork it — #1313.atomicalready owns the single-transaction, rollback-all, multi-step executor and route emission. Add (a) non-create step kinds (update / end-date), (b) optional entity binding (self), (c) routing of every step through scope enforcement so the FK-path guard applies to each touched entity, (d) an audit record, and (e) wiringatomic_flowsintorbac/,testing/, andspecs/(it is invisible to all three today). As of ADR-0029 this is no longer a tidy-up but the first consumer of a unified model —atomicas the schema-local transactional-intent substrate, with n=1 CRUD as its degenerate case. ADR-0029 owns the model definition and the normative invariants; #1313 implements the minimal first slice (see ADR-0029 Implementation status for the re-decomposition).
The security rules below are normative regardless of how the operation is implemented (DSL atomic, a # dazzle:implements route override, or hand-written until the above lands).
The gap (verified against the code, #1310)¶
Scope enforcement splits by operation:
read/list/update/deletecompile the predicate to SQL and follow FK paths (predicate_compiler.py::_compile_path_check→ nestedIN (SELECT …)subqueries).createscope is payload-only and depth-1.scope_create_eval.py:113raises onPathCheckdepth > 1; the linker rejects FK-path/junction create-scope at link time (RenderValidationError). This is the documented #1124 v1 boundary; #1124 v2 (the "payload-time SQL probe") is the deferred unlock.
Two concrete failures follow, both confirmed in the runtime:
- Denormalize-for-create-scope is spoofable. Adding a settable
departmentcolumn +scope: create: department = current_user.departmentdoes not secure the boundary:check_create_predicatereadspayload.get("department")verbatim with no FK-consistency derivation, so a client sendsdepartment=minealongsideteaching_group=<foreign-dept class>and the row lands in the wrong department. (The TOCTOU variant — a trigger/generated-columndepartmentpopulated after the app-layer check — is real in ordering but hypothetical for Dazzle-native apps: the DSL exposes no author-declarable DB-side scope-key derivation; it would require a hand-rolled trigger, itself an ADR-0017 violation. The spoofing argument alone condemns the workaround.) update-scope never guards the destination. There is no_enforce_update_scope; the update path pre-reads the existing row under the scope predicate, then passes the new payload to the service unchecked. A Head of Department whose existing row is in-scope can repoint its FK at another department's class and nothing re-validates the destination.
So neither half of a guarded "move" — the create of the destination, nor the repoint of the source — can be secured by generic CRUD scope today.
Security rules for a guarded transactional action (normative)¶
A guarded transactional action MUST:
- Derive every scope key (role, tenant/owner/department ids) from the authenticated principal, never from the request body. Client-supplied scope keys are spoofable.
- Validate the authorization boundary for every entity the operation touches — source and destination — not only the row being mutated. (Guarding the source transition but not the destination create is the #1310 hole.)
- Execute in a single transaction with an optimistic-concurrency guard (e.g.
is_current = TRUE+ rowcount), so a concurrent change can't interleave. (The sharper sub-clause — that the operation must also stay consistent across the scope-determining parent rows its guards read, not only the rows it writes — is lifted into ADR-0029 invariant 4, where it belongs: it is a property of a transactional intent, not of a single guarded mutation.) - Fail closed and never leak internal error detail (no stack/SQL in the response; a denied boundary is indistinguishable from a missing row, per the IDOR-avoidance contract in
rbac-scope.md).
A guarded action MUST NOT rely on a denormalized, client-settable scope key to satisfy depth-1 create-scope (spoofable, see above), and SHOULD NOT express its guard as imperative handler logic when the predicate algebra (extended per #1124 v2) can express it — keeping the guard in the algebra is what makes it visible to the RBAC matrix, conformance, and the API-surface audit.
Why not a new action: primitive / in-handler guards¶
- It duplicates
atomic(#1228), whose runtime already shipped.atomicowns the single-transaction, rollback-all, multi-step executor + route emission. A parallel primitive fragments "multi-step transactional operation" into two overlapping constructs. actionis already a loaded keyword (service,integration,surface). An entity-boundactionblock overloads it — parser and agent-instruction cost for no semantic gain (cf. ADR-0027's keyword-overload objection).- In-handler guards regress the predicate algebra (ADR-0009). Dazzle's authorization is a statically-validated, SQL-compiled, matrix-visible predicate algebra on purpose. A guard "evaluated in the handler" re-introduces exactly the ad-hoc, unanalyzable authz the algebra replaced — invisible to the matrix/conformance/api-audit unless hand-wired. The fix for "the algebra can't express FK-path create-scope" is to extend the algebra (#1124 v2), not to escape it.
- It bundles two distinct, independently-solvable gaps (FK-path create authz; multi-step atomic mutation) into one feature, obscuring that each has a cleaner, narrower answer.
Consequences¶
- Positive: the capability lands within the existing formal model — guards stay statically validated and matrix-visible; no keyword overload; no second "transactional op" construct; the incidental update-destination security gap gets fixed on its own merits.
- Negative: the capability is gated on #1124 v2 (FK-path create-scope), which was deferred "until adoption signal" — #1310 is that signal, but it is non-trivial (a payload-time SQL probe). Until it lands, projects either denormalize and validate FK-consistency server-side, or hand-roll a route that follows the rules above (a
dazzle.http.runtime.guarded_actionhelper for the auth + transaction + optimistic-concurrency + fail-closed boilerplate is a reasonable bridge, framed as interim — not the destination). - Neutral:
atomicbecomes the home for guarded multi-entity mutations; its current invisibility torbac//testing//specs/is a pre-existing gap that this work closes.
Alternatives Considered¶
- A declarative
action:primitive with in-handler guards (the #1310 proposal, Option A). Rejected — see "Why not" above: duplicatesatomic, overloadsaction, escapes the algebra. - Re-parent everything onto the state machine (model the move as a guarded transition). Rejected —
TransitionGuard.guard_expronly sees the in-memory row (no DB lookup, only a scalarcurrent_user, so it can reach neither the destination's FK path norcurrent_user.department), and transition-effect creates bypass scope enforcement. The state machine is the right model for the source transition but is structurally single-row. - Bless denormalization for create-scope. Rejected — spoofable (verified); documented here as an anti-pattern.
Implementation status¶
- #1311 (FK-path + EXISTS create-scope) — shipped.
scope: create:now acceptsPathCheckdepth > 1 andExistsCheck/NotExistsCheck, resolved by a payload-time SQL probe (scope_create_evalhybrid walker +predicate_compiler.compile_path_check_probe/compile_exists_check_probe, run viaroute_generator.build_create_scope_probe). The link-time check now only rejects a pathologically deep FK path (> 4 hops). The override path (policy.check_entity_op) builds the same probe from the entity's service. The guard stays in the algebra (ADR-0009). - #1312 (update-destination revalidation) — shipped. The UPDATE path now
re-validates the row's would-be-final state (
existing ⊕ changed fields) againstscope: update:after the source pre-read and before the write (route_generator._enforce_update_scope, also wired into the override pathpolicy.check_entity_op). An in-scope row can no longer be moved into a foreign scope by repointing an FK; FK-path / EXISTS destination guards reuse the #1311 probe. Denial is an IDOR-shaped 404. - #1313 (extend
atomic) — pending, and re-scoped by ADR-0029. Its per-step FK-path guard composes with the #1311 probe and lands consistently with #1312's source+destination semantics. ADR-0029 promotes the target from "wire the executor into the analysis surfaces" to "the first consumer of the schema-local transactional-intent model"; see ADR-0029 Implementation status for the minimal-first-slice decomposition (per-step scope routing + in-transaction audit + analysis-surface wiring first; derived ordering, the scope-parent share-lock, and flow-level invariants are deferred follow-ups).
Cross-reference¶
docs/reference/rbac-scope.mddocuments the create-scope hybrid walker (in-Python simple leaves + payload-time probe for FK-path / EXISTS) and points the denormalization anti-pattern at this ADR as the sanctioned alternative.- ADR-0029 generalises #1313's target:
atomicis the schema-local transactional-intent substrate. This ADR's rule 3 scope-parent sub-clause is absorbed there as invariant 4. Read ADR-0029 before implementing #1313.