ADR-0010: Permit/Scope Separation¶
Status: Accepted Date: 2026-03-18
Note added 2026-05-17: the persona-binding keyword on
scope:rules was renamed fromfor:toas:in #998 to remove the overloadedforkeyword from the grammar. Wherever this document refers to afor:clause, the current syntax isas:. The semantics are unchanged.Note added 2026-05-17 (v0.71.19):
scope:rules now enforce at runtime forupdateanddeleteoperations as well aslistandread(#1123). Pre-v0.71.19, write-op scope rules were parsed-but- not-enforced — a regression in expressive power from the pre-ADR-0010permissions:block style that this ADR's split introduced without anyone noticing. SOC2 CC6.1 / ISO 27001 A.9.4.1 require row-level authorization on write ops in multi-tenant systems; the v0.71.19 fix closes that capability gap.
scope: create:is parsed and stored in IR but not yet enforced at runtime — see #1124 for the deferred design work. Until that lands, express create-time row constraints viainvariant:blocks (predicate-only) or service-layer pre-create hooks (need auth context). The runtime semantics for each operation are documented indocs/reference/rbac-scope.md.
Context¶
Before this decision, Dazzle's access control used a single permit: block that mixed two distinct concerns:
- Role authorization — which personas may perform an action at all
- Row filtering — which rows a permitted user may see or modify
Issue #526 identified that placing field conditions inside permit: blocks produced a critical security flaw: a user matching the field condition was granted access regardless of their role. For example:
A user with no role but a matching school_id would pass the field condition branch and be authorized. The permit block evaluated field conditions as an alternative to role checks rather than as an additional constraint.
The fix requires architectural separation: role checks and row filters must be enforced in sequence, not as alternatives.
Decision¶
Access control is split into two strictly separated layers:
permit: blocks — role-only authorization (pure RBAC):
- Accept only persona/role names and boolean combinations thereof
- Field conditions are a parse error inside permit:
- Default-deny: if no permit: block matches, access is refused
scope: blocks — row filtering (ABAC field conditions):
- Mandatory for: clause naming the personas the scope applies to
- Field conditions compile to the predicate algebra (ADR-0009)
- Default-deny: rows not matching an active scope are hidden
- Applied only after permit: has already granted access
The two layers are enforced in order: permit gates on role first, scope filters rows second. A user must pass both layers to access a row.
Consequences¶
Positive¶
- Field conditions can never substitute for role checks — privilege escalation via field match is structurally impossible
- Each layer is independently auditable: RBAC matrix covers
permit:, predicate algebra coversscope: - RBAC static matrix verification (CI gate) operates cleanly on
permit:blocks alone - Scope predicates can be compiled to Postgres RLS policies without tangling with role logic
Negative¶
- Existing DSL files using field conditions in
permit:blocks require migration — this is a breaking change - Two separate blocks increase DSL verbosity for simple cases
for:clause onscope:is mandatory, not inferred — slightly more typing
Neutral¶
- Both layers default-deny, so omitting either is safe (restrictive) rather than permissive
- The RBAC verifier (
src/dazzle/rbac/) validatespermit:blocks; the scope validator validatesscope:blocks
Alternatives Considered¶
1. Field Conditions in Permit Blocks¶
Allow field conditions to continue appearing in permit: blocks, but require an explicit role check alongside them.
Rejected: The parser cannot statically enforce "role check required alongside field condition" without introducing complex validation rules that are easy to misread. Structural separation is unambiguous.
2. Single Unified Authorization Layer¶
Merge permit and scope into one block that handles both roles and field conditions.
Rejected: A unified layer cannot be independently compiled to RBAC matrix (role-only) and RLS policy (row filter). Static analysis of the RBAC matrix requires isolating role predicates.
3. Implicit Scope Defaults¶
Infer scope rules from entity ownership conventions (e.g., automatically scope to current_user for owned entities).
Rejected: Implicit defaults hide authorization logic from static analysis and DSL audits. Every access control decision must be explicit in the DSL.
Implementation¶
- DSL parser: field conditions in
permit:are rejected with a descriptive error referencing this ADR for:clause onscope:blocks is validated at parse time — missingfor:is a hard error- Runtime enforces permit check before scope filter in all query paths
dazzle lintreports anypermit:blocks that were valid before this change (migration aid)- See
src/dazzle/rbac/for static matrix verification andsrc/dazzle/core/ir/predicates.pyfor scope IR