Skip to content

RBAC: list: scope does not cover read/update/delete — design decision needed (every app produces ~20 no_scope_rule warnings) #1071

@manwithacat

Description

@manwithacat

Symptom

Every example app (simple_task, contact_manager, support_tickets, ops_dashboard, fieldtest_hub) and every downstream user app that follows the documented pattern produces ~10-20 lint warnings of this shape on every dazzle validate:

WARNING: [RBAC no_scope_rule] Role 'admin' passes permit for User.read but has no matching scope rule — will see 0 records
WARNING: [RBAC no_scope_rule] Role 'admin' passes permit for User.create but has no matching scope rule — will see 0 records
WARNING: [RBAC no_scope_rule] Role 'admin' passes permit for User.update but has no matching scope rule — will see 0 records
WARNING: [RBAC no_scope_rule] Role 'admin' passes permit for User.delete but has no matching scope rule — will see 0 records
... × 5+ roles × N entities

simple_task alone surfaces ~24 such warnings; support_tickets, ops_dashboard, fieldtest_hub each surface ~15-30.

Root cause

The DSL convention every example app follows is:

entity User "Team Member":
  permit:
    list: role(admin) or role(manager)
    read: role(admin) or role(manager)
    create: role(admin)
    update: role(admin)
    delete: role(admin)

  scope:
    list: all
      as: admin, manager

— i.e. one list: scope rule is written, and the same predicate is implicitly understood to govern reads, updates, deletes.

But the runtime resolver in src/dazzle/rbac/matrix.py:188 does strict per-operation matching:

def _find_scope_for_role(scopes, operation, role) -> ScopeRule | None:
    for scope in scopes:
        if scope.operation != operation:
            continue
        ...

A list: scope rule does NOT match an incoming read decision. The result is PERMIT_NO_SCOPE — and per the matrix-builder's comment, that means the runtime returns 0 records for permitted-but-unscoped operations. Every example app silently has broken read/update/delete paths for admin/manager roles.

The lint warning is correct. The DSL pattern is incomplete. But every example app has the same omission, and no docs section tells authors they need per-op scope rules — the impression from docs/reference/predicate-algebra.md is that scope: is a per-entity row-filter, not a per-operation one.

The design question

Three options. They are not equivalent — each is a real product decision the framework owes its users.

Option A — Implicit inheritance: list:read:

The narrowest fix. When read: is missing on an entity, fall back to the list: scope predicate. Justification: a row that's visible in a list is, by every existing example app's expectation, visible to read. Inheritance only goes list: → read: because the asymmetry is intuitive (you can list things you can also read; you can read things you can't list).

update: / delete: / create: continue to require explicit scope rules — because those are actions, not visibility predicates.

Implementation: _find_scope_for_role accepts a read query, falls through to list: if no read: matches.

Pros: Removes ~80% of the lint noise on every app. Matches the implicit mental model. No CHANGELOG breakage — existing per-op DSL still works (declared read: wins over inherited).

Cons: New implicit semantic. Has to be documented. Slight risk that someone intended read: to be more permissive than list: (admin can list nothing, but can read by direct id) — though that pattern is rare.

Option B — Strict per-op stays; mass-update every example app

Every example app gets scope: read: …, update: …, delete: …, create: … rules added explicitly. The warning becomes load-bearing — anyone who sees it has actually under-specified RBAC.

Pros: Most explicit. Forces authors to think about each operation.

Cons: 5 example apps × ~5 entities × 5 ops = ~125 new DSL lines for example apps. Every downstream app also has to do this. The framework demands more boilerplate than the average user needs. The "make sense by default" property is lost.

Option C — Default-deny is too aggressive; relax to PERMIT_FILTERED via permit's own condition

If the user wrote permit: read: role(admin) with no scope, the matrix could treat that as PERMIT (full access) for the role, not PERMIT_NO_SCOPE → 0 records. Permit alone is enough; scope is opt-in row filtering. The lint warning becomes a suggestion ("consider adding a scope rule") not a defect indicator ("will see 0 records").

Pros: Aligns with the v0.46.4-era convention where permit: … alone was the standard. Restores the lint warning to being informational.

Cons: Loses default-deny ergonomics — admin gets full unfiltered access if read: scope is missing, which may surprise authors expecting strict gating.

Recommendation

Option A is the smallest credible change. Cycle 117 (this cycle) is filing this issue to defer the decision to a human review — the design question is too consequential to bake in autonomously.

Once a decision lands:

  • If A → linker change + tests + CHANGELOG under Changed + add a Guidance note to predicate-algebra.md
  • If B → ~125-line DSL update across all example apps + CHANGELOG under Changed (DSL semantics tightened) + lint message clarification
  • If C → matrix.py change + CHANGELOG under Changed (lint demoted from warning → info) + add an explicit "default-deny via missing-scope" opt-in pragma

Discovered by

/improve cycle 115 example-apps Tier 1 sweep (filed as row 100 in dev_docs/improve-backlog.md). Pattern verified against simple_task and support_tickets; identical shape in the other three example apps confirmed by Tier 1 lint output.

This issue is a design proposal, not a bug. Auto-classifying as discussion or needs-design would be appropriate.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions