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.
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.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 bob | Result |
|---|---|
| Search (finance terms) | hidden_by_acl > 0, finance Units absent |
| GET a finance Unit by id | 404 not_found |
| Search a scope he lacks | effective_scopes: [], 0 hits |
| POST /v1/ingest | 403 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.
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
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.