Access Control¶
Auto-generated from knowledge base TOML files by
docs_gen.py. Do not edit manually; rundazzle docs generateto regenerate.
DAZZLE uses Cedar-style access rules with three layers: entity-level permit/forbid blocks, surface-level access restrictions, and workspace-level persona allow/deny lists. Default policy is deny. This page covers access rule syntax, authentication integration, and RBAC patterns.
Access Rules¶
Inline access control rules on entities. Legacy syntax uses access: blocks with read/write permissions. Cedar-style syntax (v0.21+) uses permit:/forbid:/audit: blocks for fine-grained RBAC with NIST SP 800-162 alignment. Evaluation order: FORBID > PERMIT > default-deny.
Syntax¶
# Legacy access: block (read/write permissions)
access:
read: <condition>
write: <condition>
# Cedar-style blocks (v0.21+)
permit:
<action>: <condition>
forbid:
<action>: <condition>
audit:
<action>: <condition>
# Expressions:
# field = current_user - Field matches logged-in user
# role(<name>) - User has the specified role
# field = value - Field equals literal value
# Combine with: and, or
# Cedar-style actions: read, write, create, update, delete, approve, prescribe, etc.
# Cedar evaluation: FORBID rules override PERMIT; default is deny
Example¶
# Legacy style
entity Document "Document":
owner: ref User required
is_public: bool = false
access:
read: owner = current_user or is_public = true or role(admin)
write: owner = current_user or role(admin)
# Cedar-style (v0.21+) - fine-grained RBAC
entity Prescription "Prescription":
patient: ref Patient required
prescriber: ref Doctor required
status: enum[draft,active,dispensed,cancelled]=draft
permit:
read: role(doctor) or role(pharmacist) or role(nurse)
prescribe: role(doctor)
dispense: role(pharmacist)
forbid:
prescribe: role(pharmacist)
dispense: role(doctor)
audit:
read: role(admin)
prescribe: role(compliance_officer)
dispense: role(compliance_officer)
Best Practices¶
- Use = for equality (not ==)
- Start with restrictive rules, expand as needed
- Use role() for administrative access
- Combine with persona scopes for UI filtering
- Cedar evaluation order: FORBID > PERMIT > default-deny
- Use audit: blocks for compliance logging of sensitive actions
- Use forbid: for separation-of-duty constraints (e.g., prescriber cannot dispense)
Related: Entity, Persona, Invariant, Cedar Rbac
Scope Rules¶
Row-level filtering rules on entities. scope: blocks control what rows a permitted role sees — they are separate from permit: blocks, which control whether a role may access an endpoint at all.
The two-block pattern is mandatory:
permit:— authorization gate. Contains onlyrole()checks. Field conditions insidepermit:are a parser error.scope:— row filter. Contains field conditions withfor:clauses. Evaluated at query time, not at the gate.
Every role that passes a permit: gate must have a matching scope: rule, or scope: all for unrestricted row access.
Syntax¶
scope:
for role(<name>): <field_condition>
for role(<name>): all
*
# for role(<name>): <condition> — rows matching condition are visible to role
# for role(<name>): all — all rows are visible to role (unrestricted)
# * — all rows visible to every permitted role (wildcard)
# Field conditions use standard ConditionExpr:
# field = current_user
# field = value
# field != value
# Combine with: and, or
Example¶
entity Task "Task":
id: uuid pk
title: str(200) required
owner: ref User required
team: ref Team required
status: enum[open,closed]=open
# Authorization: who may access this entity
permit:
list: role(admin) or role(manager) or role(member)
read: role(admin) or role(manager) or role(member)
create: role(admin) or role(manager)
update: role(admin) or role(manager) or role(member)
delete: role(admin)
# Row filtering: what each permitted role sees
scope:
for role(admin): all
for role(manager): team = current_user.team
for role(member): owner = current_user
entity Shape "Shape":
id: uuid pk
colour: enum[red,blue,green]
realm: ref Realm required
permit:
list: role(oracle) or role(sovereign)
read: role(oracle) or role(sovereign)
# Wildcard: all permitted roles see all rows
scope:
*
The all keyword and * wildcard¶
for role(admin): all— the named role sees every row, no filter applied.*on its own line — every permitted role sees every row. Use when no per-role scoping is needed. Equivalent to writingallfor each permitted role individually.
Default-deny at both layers¶
- If a role has no matching
permit:rule, the endpoint gate rejects it with HTTP 403. - If a role passes the gate but has no
scope:rule (and no*), it sees zero rows. This is intentional default-deny at the row level.
Best Practices¶
- Never put field conditions inside
permit:— they are a parser error. - Every permitted role needs an explicit
scope:entry or a*wildcard. - Use
for role(admin): allto grant unrestricted access to administrative roles. - Prefer named
for:clauses over*when different roles need different row visibility.
Related: Access Rules, Entity, Runtime Evaluation Model
Visibility Rules¶
Row-level visibility rules on entities. Controls which records are visible based on authentication state (anonymous or authenticated). Evaluated before permission rules to determine which records a user can see in list/query results. Uses ConditionExpr for the filter condition.
Syntax¶
visible:
when anonymous: <condition>
when authenticated: <condition>
# Auth contexts:
# anonymous - User is not logged in
# authenticated - Any logged-in user
# Condition is a standard ConditionExpr:
# field = value, field != value, role(name), field = current_user
# Combine with: and, or
Example¶
entity Document "Document":
id: uuid pk
title: str(200) required
is_public: bool = false
created_by: ref User
visible:
when anonymous: is_public = true
when authenticated: is_public = true or created_by = current_user
entity Post "Blog Post":
id: uuid pk
published: bool = false
author: ref User required
visible:
when anonymous: published = true
when authenticated: published = true or author = current_user
Best Practices¶
- Use anonymous visibility for public-facing content
- Authenticated visibility should include the owner check (created_by = current_user)
- Combine with permission rules for full access control
- Visibility filters apply at query time - no data leaks in list views
Related: Access Rules, Entity, Persona, Surface Access
Surface Access¶
Access control on surfaces (UI screens). Controls who can view and interact with a surface. Three levels: public (no auth), authenticated (any logged-in user), or persona-restricted (named personas only). Used by E2E test generators to create protected-route tests.
Syntax¶
surface <name> "<Title>":
...
access: public
access: authenticated
access: persona(<name1>, <name2>, ...)
# access: public → require_auth=false, anyone can access
# access: authenticated → require_auth=true, any logged-in user
# access: persona(Admin, Manager) → require_auth=true, only listed personas
Example¶
# Public surface - no login required
surface landing_page "Welcome":
uses entity Page
mode: view
access: public
# Authenticated - any logged-in user
surface my_tasks "My Tasks":
uses entity Task
mode: list
access: authenticated
# Persona-restricted - only these roles
surface admin_panel "Admin Panel":
uses entity User
mode: list
access: persona(Admin, SuperAdmin)
Best Practices¶
- Default to authenticated when auth is enabled globally
- Use persona() for admin-only or role-specific screens
- Public surfaces should only show non-sensitive data
- Pair with entity-level access rules for defense in depth
Related: Access Rules, Surface, Persona, Workspace Access, Visibility Rules
Workspace Access¶
Access control on workspaces. Defines who can access a workspace dashboard. Three levels: public, authenticated, or persona-restricted. Default is authenticated when auth is enabled globally. Syntax is the same as surface access.
Syntax¶
workspace <name> "<Title>":
...
access: public
access: authenticated
access: persona(<name1>, <name2>, ...)
# access: public → level=public, no login required
# access: authenticated → level=authenticated, any logged-in user (default)
# access: persona(Admin) → level=persona, only listed personas can access
Example¶
# Public dashboard - visible without login
workspace public_metrics "Public Metrics":
purpose: "Public-facing KPI dashboard"
access: public
metrics:
aggregate:
total_users: count(User)
display: metrics
# Admin-only workspace
workspace admin_dashboard "Admin Dashboard":
purpose: "System administration"
access: persona(Admin, SuperAdmin)
users:
source: User
display: list
action: user_edit
# Default: authenticated (explicit for clarity)
workspace team_board "Team Board":
purpose: "Team task overview"
access: authenticated
tasks:
source: Task
filter: status != closed
sort: priority desc
display: list
Best Practices¶
- Use persona() for role-specific dashboards
- Public workspaces should aggregate non-sensitive data only
- Default is authenticated - omit access: if that is sufficient
Related: Access Rules, Workspace, Persona, Surface Access
Runtime Evaluation Model¶
Access rules evaluate in two tiers at runtime. The permit: and scope: blocks map directly onto these two tiers.
Tier 1: Entity-Level Gate (permit: blocks)¶
Before any database query runs, the route handler performs a gate check: "Does this user have permission to access this endpoint at all?" This check calls evaluate_permission(operation, record=None, context) — note record=None, meaning no row data is available.
permit: blocks are evaluated here. They contain only role() checks, which can be resolved with just the user's roles. Field conditions cannot appear in permit: (they are a parser error), so the gate is always unambiguous.
If a role has no matching permit: rule, the gate returns HTTP 403 immediately.
Tier 2: Row-Level Filters (scope: blocks)¶
After the gate, the handler builds SQL filters from two sources:
- Visibility rules (
visible:blocks) — converted to SQL WHERE clauses based on auth state - Scope rules (
scope:blocks) — thefor role(<name>): <condition>clause matching the authenticated user's role is extracted and merged into the query
These filters ensure only authorized rows are returned. They run at query time, when record data is available. A role with no matching scope: entry (and no * wildcard) sees zero rows by default.
Evaluation Flow¶
Request arrives
│
├─ Tier 1: Gate check (permit: blocks only — no record available)
│ ├─ Does any permit: rule match the user's roles?
│ │ ├─ FORBID match → 403
│ │ ├─ PERMIT match → continue to Tier 2
│ │ └─ No match → 403 (default-deny)
│ └─ Note: field conditions inside permit: are a parser error, never reached here
│
├─ Build SQL filters
│ ├─ Visibility filters (visible: blocks)
│ └─ Scope filters (scope: blocks — for role(<name>): <condition>)
│ ├─ Matching for: clause found → apply field condition as WHERE clause
│ ├─ for role(<name>): all → no WHERE clause added (all rows)
│ └─ * wildcard → no WHERE clause for any role (all rows)
│
├─ Execute query with merged filters
│
└─ Tier 2: Post-fetch check (per record, for detail/update/delete)
└─ evaluate_permission(op, record, ctx) — full condition evaluation
Why Two Separate Blocks?¶
The gate (Tier 1) runs before any database query, so it cannot evaluate field conditions — there is no record yet. Putting field conditions inside permit: would force the gate to fail them (field lookup returns None), causing legitimate users to receive HTTP 403 even when they should see a filtered result set.
scope: blocks exist precisely to express "this role may access the endpoint, but only sees rows matching this condition." They are evaluated at query time (Tier 2), where record data is available.
This was the lesson of PR #503: a LIST gate that evaluated all permit: rules against record=None broke field-condition rules. The fix separated the concern — permit: for who, scope: for what.
Rule Type Summary¶
| Block | Pattern | Enforcement Point | Notes |
|---|---|---|---|
permit: |
list: role(admin) |
Tier 1 gate | Fast — no DB touch |
permit: |
list: role(teacher) or role(admin) |
Tier 1 gate | Multiple roles in one rule |
scope: |
for role(teacher): school = current_user.school |
Tier 2 row filter | Applied as SQL WHERE |
scope: |
for role(admin): all |
Tier 2 (no-op) | No filter added |
scope: |
* |
Tier 2 (no-op) | All permitted roles see all rows |
visible: |
when authenticated: owner = current_user |
Tier 2 row filter | Auth-state filter |
Best Practices¶
permit:is for who,scope:is for what. Never mix them. Field conditions inpermit:are a parser error.- Every permitted role needs a scope entry. Either a named
for role(X):clause or a*wildcard. A role with no scope entry sees zero rows. - Use
for role(admin): allto grant unrestricted row access to administrative roles. - Pure role gates are fast — the gate rejects unauthorized users before touching the DB.
*wildcard simplifies entities where all permitted roles see all rows with no per-role distinction.
Related: Access Rules, Scope Rules, Cedar Rbac, Visibility Rules
Grant-Based RBAC¶
Runtime-configurable delegation permissions that layer over Cedar-style static access rules (v0.42.0). Static permit: rules define the ceiling of what is possible; grant schemas define which of those permissions can be delegated at runtime, by whom, and under what constraints.
grant_schema — declaring a delegation domain¶
grant_schema department_delegation "Department Delegation":
description: "Delegation of department-level responsibilities"
scope: Department
relation acting_hod "Assign covering HoD":
granted_by: role(senior_leadership)
approval: required
expiry: required
max_duration: 90d
A grant_schema ties a set of delegation rules to a scope entity — the object instance the grant is attached to (here, a Department row). Every relation within the schema becomes a named grant type that can be created, approved, and revoked at runtime.
grant_relation fields¶
| Field | Values | Description |
|---|---|---|
granted_by |
role(name) or condition |
Who may create a grant of this type |
approved_by |
role(name) or condition |
Who must approve (if approval: required) |
approval |
required | immediate | none |
Approval workflow for new grants |
expiry |
required | optional | none |
Whether an expiry date must be set |
max_duration |
e.g. 90d, or param("key") |
Upper bound on expiry duration |
principal_label |
string | UI label for the grantee field |
confirmation |
string | Confirmation prompt shown before granting |
revoke_verb |
string | Label for the revoke action |
granted_by is mandatory. All other fields are optional.
has_grant() in state machine guards¶
Grant status is available inside state machine transition guards via has_grant():
The function signature is has_grant(relation_name, scope_id). The runtime checks whether the current user holds an active (non-expired, approved) grant of that relation type for the given scope instance. has_grant() is evaluated at transition time; if the grant has expired or was revoked, the guard fails and the transition is blocked.
Four-eyes approval workflows¶
Set approval: required and approved_by to a different role than granted_by to enforce separation of duties:
relation payment_approver "Payment Approver":
granted_by: role(finance_manager)
approved_by: role(cfo)
approval: required
expiry: required
max_duration: 30d
The grant is created by a finance_manager but only becomes active once a cfo approves it. This prevents unilateral escalation of privileges and satisfies four-eyes controls for regulated workflows.
DSL example¶
grant_schema school_cover "School Cover Arrangements":
description: "Temporary cover assignments within a school"
scope: School
relation acting_head "Acting Headteacher":
principal_label: "Cover headteacher"
granted_by: role(trust_admin)
approved_by: role(ceo)
approval: required
expiry: required
max_duration: param("max_acting_head_days")
confirmation: "This grants full headteacher access. Confirm?"
revoke_verb: "End cover arrangement"
relation cover_senco "Cover SENCO":
granted_by: role(headteacher) or role(acting_head)
approval: immediate
expiry: optional
max_duration: 14d
Related: Access Rules, Runtime Evaluation Model, Cedar Rbac
Authentication¶
Session-based authentication system in Dazzle. Uses cookie-based sessions with SQLite storage. Auth is optional and can be enabled/disabled per project.
Example¶
# In DSL, use personas to define role-based access:
ux:
for admin:
scope: all
for member:
scope: owner = current_user
# API login:
POST /auth/login
Content-Type: application/json
{"username": "admin", "password": "secret"}
# Response sets session cookie automatically