hetchyy's picture
deploy: inspector prod @ 2ce61c0
86bc0aa verified
|
Raw
History Blame Contribute Delete
7.82 kB

Inspector β€” Capabilities (data-driven authorization)

Authorization is a data-driven capability matrix, not hardcoded role tiers. An owner toggles any capability on/off per tier (anonymous / contributor / maintainer) from the Admin β†’ Permissions tab; the change is durable, audited, and takes effect on the affected user's next request. The resolver services/auth/capabilities.py::can() is the single source of authz β€” every route, state handler, predicate, and edit-lock branch calls it. Roles still exist (contributor < maintainer < owner) but only as the default each capability ships with; an owner overrides per cell.

This doc is the what-is + the convention for adding a gate so it surfaces in the UI. For the predicate/edit-lock/redaction detail see auth-permissions.md; for the Permissions-tab UI see admin-dashboard.md.


Adding a capability β€” the convention

To add a new permission gate so it automatically surfaces in the Permissions tab, do exactly two things (three if it has a UI affordance):

  1. Register it β€” add one Capability(...) (via the _c(...) helper) to CAPABILITIES in qua_shared/schemas/config/capabilities.py:

    • id β€” dot-namespaced, stable (e.g. reciter.publish, claim.force_release).
    • group β€” an existing G_* label (or a new one; groups render in first-appearance order).
    • label + description β€” human text, shown verbatim in the UI. Write them for an owner skimming the matrix.
    • anon β€” pass a bool only for identity-free read caps (β†’ anon_eligible); omit for action caps (anonymous renders N/A).
    • contributor / maintainer β€” the current default for each tier, so the empty-override baseline reproduces today's behavior (parity test enforces this).
  2. Gate the code path through the resolver β€” never a hardcoded role check:

    Where Use
    Route (decorator) @require_capability("your.cap") (injects user; anon allowed iff anon_eligible)
    Route (inline) actions.py::_require_cap("your.cap") or _admin_helpers.require_capability_or_403(user, "your.cap")
    State transition _require_capability(actor, "your.cap") inside the _h_* handler and add the event β†’ cap row to state.py::_EVENT_CAPABILITY
    Service / predicate / edit-lock capabilities.can(actor_or_user, "your.cap")
  3. (FE β€” only if there's a UI affordance to show/hide) gate it on lib/stores/capabilities.ts: can('your.cap') (reactive store) or hasCapability(user, 'your.cap') (pure). The Permissions tab itself needs no change β€” its matrix is fully data-driven (see below).

That's the whole recipe. No endpoint, no Permissions-tab, no migration change β€” the capability appears in the matrix (label + description + per-tier toggles) on the next GET /api/admin/permissions, and the owner can toggle it immediately. /api/me picks it up in the caller's resolved capabilities[].

Don'ts

  • Don't hardcode permissions.is_maintainer() / is_owner() / @require_role(...) at a new gate. Those bypass the registry β†’ the gate won't surface in the Permissions tab and won't be owner-toggleable. The guard test (below) fails the build if @require_role reappears in routes/.
  • Ownership checks (is_claim_holder, is_claim_holder_or_maintainer) are not capabilities β€” they ask "is this your row," and stay as-is. A capability governs the tier dimension layered on top.
  • An unknown capability id passed to can() / @require_capability raises ValueError at call/import time β€” so a typo can't silently create an ungated path.

How a capability surfaces in the UI (automatic)

CAPABILITIES (registry)
  β†’ services/admin/permissions.py::build_matrix()   # iterates CAPABILITIES by GROUP_ORDER
  β†’ GET /api/admin/permissions                       # grouped matrix (default βŠ• overrides)
  β†’ PermissionsCompartment.svelte                    # renders every group/cap/tier in the response

The FE hardcodes nothing about which capabilities exist β€” it renders whatever the matrix returns. So a registered capability is rendered with no FE edit. /api/me exposes the caller's resolved capabilities[] for capability-aware affordance gating elsewhere.


Resolver + storage (the substrate)

Piece Truth
Registry (data) qua_shared/schemas/config/capabilities.py β€” Capability{id, group, label, description, anon_eligible, owner_only_fixed, default_grants} + CAPABILITIES, CAPABILITIES_BY_ID, GROUP_ORDER, TIERS. Backend-only; NOT codegen'd to the FE (the matrix response models in admin_permissions.py are).
Resolver services/auth/capabilities.py β€” tier_of(user_or_actor) (Noneβ†’anonymous), resolve_grants() -> {(cap,tier): bool} (baseline βŠ• overrides, cached on db_seq), can(user_or_actor, cap), capabilities_for(user).
Override store permission_overrides(capability_id, tier, allowed, set_by, set_at) β€” migration 0008, services/db/repo_permissions.py. Deviations only: absent row β†’ default; reset = DELETE.
Cache services/storage/cache.py::{get,set,invalidate}_capability_matrix_cache β€” single (db_seq, matrix) tuple. Any committed override write bumps db_seq β†’ transparent invalidation, no restart. (The autouse test fixture invalidates it too.)
Endpoints GET /api/admin/permissions + POST /api/admin/permissions/<cap>/<tier> ({allowed} or {reset:true}), both @require_capability("manage_permissions"); POST @require_same_origin, audited access.permission_changed (a HIDDEN_EVENTS event). Service services/admin/permissions.py.

Invariants (hold regardless of the override table)

  • Owner is a superuser β€” can(owner, anything) is True.
  • manage_permissions is owner_only_fixed β€” never grantable to a lower tier even if a row is forced in. Gates the Permissions tab + endpoints; the recovery anchor, so owners can't lock themselves out.
  • Anonymous only where anon_eligible β€” action caps are always False for anonymous; only the public-read caps (view.catalog, view.public_activity) and the public Timestamps-tab report cap (timestamps.report, default-on for everyone) are anonymous-toggleable. timestamps.see_reporter_identity (owner-default) gates revealing the reporter's login on a report + its notification β€” the owner-facing sibling of segments.see_flagger_identity. timestamps.resolve_report (owner-default) gates the resolve action; timestamps.view_stale_reports (owner-default) gates filtering to reports a re-stamp invalidated; timestamps.view_nonpublic_reports (maintainer-default) gates seeing tajweed + phoneme report flags (timing flags + verse-level comment reports stay public to everyone; a reporter always sees their own).
  • Structural integrity is NOT a capability β€” the last-active-owner guard + owner-on-owner revoke asymmetry (services/admin/users.py, services/auth/access.py) stay enforced even with roles.* toggled on.

Guards (keep the convention honest)

  • tests/services/test_capabilities.py β€” baseline parity (empty overrides == legacy), owner-superuser, manage_permissions immutability, anon-eligibility, override apply/clear, _EVENT_CAPABILITY completeness.
  • tests/test_capability_convention.py β€” (a) build_matrix() returns every registry capability (registryβ†’UI completeness β€” a new cap can't fail to surface); (b) no @require_role( decorator in inspector/routes/ (forces @require_capability).
  • tests/routes/test_route_permissions.py β€” endpoint owner-only, toggle reflected live, manage_permissions/anon-ineligible rejected, audit emitted.