ACL & quota granularity

Updated · View the entry on sijie.xyz ↗

Core model

Notation:

  • X = a capability or tool being checked for access

Exposure is pure AND narrowing across three layers:

exposed(X) = global_enabled(X) ∧ role_grants(X) ∧ ¬code_denies(X) ∧ connector_deps_met ∧ quota_ok

No layer can open what an upper layer didn't grant. Each must pass for access to succeed.

Freeze/live asymmetry

Access control enforcement depends on when each layer is evaluated (confusables):

  • Global layer: live. Stored in capability_settings, evaluated at request time. Flipping a global capability instantly kills all running sessions using it.
  • Role layer: frozen at issue. Snapshot into role-snapshot-frozen when the code is created. A role change doesn't retroactively affect existing codes.
  • Code layer: frozen at issue. Deny tables code_capability_denials / code_skill_denials / code_corpus_denials are locked at code creation. Past codes never see future code-side denials.

The tri-state elimination

The earlier tri-state code_capability_overrides (allow/deny/inherit) has been killed. It is now replaced by sparse deny tables:

  • code_capability_denials: presence of an entry means the capability is denied; no state column.
  • code_skill_denials: presence of an entry means the skill is denied; no state column.
  • code_corpus_denials: (code_id, uri_pattern) — a glob this code takes back from the role's positive corpus list (the third kind, landed 2026-07-16, 6395374b0; backend/db/schema.sql). Frozen into the snapshot as deniedCorpusURIs and checked at match time, not subtracted from the grant list (a glob cannot subtract from a list entry).

A code can only subtract, never re-allow. This eliminates the "code resurrects a capability" privilege escalation corner cases by construction.

V1 scope

The management-plane phase landed the global layer. V1 coverage:

  • Capability/tool ACL: fully implemented.
  • Skill ACL: fully implemented.
  • Corpus-glob ACL: LANDED — allowsCorpusEntry(scope, genre, path, published) → access.AllowsCorpusEntry(scope, CorpusEntryRef) enforced on every corpus read (backend/internal/corpus/usecase/corpus_lister_pg.go:53, backend/internal/access/entity/path_acl.go:143); the frozen scope (grants ∧ ¬code corpus denials ∧ published-only for the public identity) rides the RoleSnapshot (AllowsCorpus(uri, published) / CorpusScope()), raw://** hard-denied; forwarded to the externalized retrieval plugin as one opaque corpus_scope blob via _meta.
  • Owner-MCP-server ACL: LANDED — ext-mcp servers attach per role via role_mcp_servers (SetMCPServers + ownership validation); frozen into the snapshot as mcpServerIDs.

Implementation detail: ACL=always capabilities

Some capabilities are marked ACL=always (retrieval, ask_visitor, summarize) and do not appear in allowedTools. Their denial is gated at the exposure door via RoleSnapshot.deniedCapabilities, not table lookup.

Verification

The heaviest e2e coverage in the repo:

  • acl-capability-matrix: tests all combinations of global/role/code layers.
  • acl-skill-matrix: tests skill ACL isolation.
  • acl-freeze-isolation: verifies freeze/live asymmetry under concurrent changes.

Related notes