Skip to content
Browse documentation

Concepts

Security model

Authorization is computed server-side on every request. There are two layers — scope clamping (where you can look) and ACL filtering (which individual results you can see) — plus a tamper-evident audit log.

Two enforcement layers#

  • Scope clamp (RBAC + grants) — the query is confined to the scopes your grants allow, in SQL and via vector-index pushdown, before any results are read.
  • ACL filter (ABAC) — each surviving Unit is checked against your identity/groups; denied Units are dropped and counted.

Grant resolution#

A principal’s permissions and scopes are the union of direct grants and role grants. Wildcard (*) scopes expand to the registry scopes in the namespace. If a principal is read_only, write and admin are stripped regardless of granted roles.

Scope intersection#

The effective scope is the requested scope intersected with the allowed scopes — it never widens. Path-like scopes match by equality or by parent/child prefix.

intersect(requested, allowed)
requested = "*"                         -> all allowed scopes
requested = "acme/alpha/backend"        -> ["acme/alpha/backend"]        (exact)
requested = "acme/alpha/backend/x"      -> ["acme/alpha/backend"]        (child of an allowed parent)
requested = "acme/alpha"                -> allowed children of acme/alpha
requested = "acme/alpha/frontend"       -> []   (bob, allowed only backend => empty => 0 results)

When the effective set is empty, the SQL clamp becomes namespace = ? AND 1 = 0 — a guaranteed empty result set rather than an error.

Per-hit ACL filter#

The rule is intentionally simple and applied to every candidate:

visible(hit)
visible  <=>  hit.acl_owner == principal
          OR  (hit.acl_groups ∩ principal.groups) ≠ ∅

This is why bob@acme (group eng-backend) cannot see finance-only Units owned by others, while alice@acme (also in finance) can. On list and search endpoints the removed count is returned as hidden_by_acl; a single hidden Unit fetched by id returns 404 (indistinguishable from not-existing).

Action as bobResult
Search (finance terms)hidden_by_acl > 0, finance Units absent
GET a finance Unit by id404 not_found
Search a scope he lackseffective_scopes: [], 0 hits
POST /v1/ingest403 forbidden

Hash-chained audit log#

Every authorization decision (allow or deny) appends a row to an append-only audit log. Each row embeds the previous row’s hash, so the log is a hash chain: the head hash is kept in the key-value store, and any reader can recompute the chain to detect tampering, truncation, or reordering.

chain linkage
row.hash = sha256(canonicalJson({ seq, ts, namespace, principal,
                                  action, resource, surface, decision,
                                  prev_hash }))
row[n].prev_hash == row[n-1].hash        (row[0].prev_hash = 0×64 genesis)

Verifiable by construction

Because the hash uses a canonical (key-sorted) JSON encoding, the chain is deterministic: recomputing it from the stored rows reproduces the same hashes. Altering, dropping, or reordering any row breaks the linkage. This property is covered by the automated test suite.

One core, many surfaces

The same enforcement core runs for every access surface, so REST, the console, and future SDK/CLI/MCP clients cannot diverge in what they permit. See the architecture overview.