Identity and access
PartialIdentity and access controls who may call the platform and which slice of the fleet each caller sees and acts on, enforced entirely in the app so “forgot to filter” cannot happen: the capability check (<resource>:<action>) runs as API route middleware before the handler, and the ABAC scope filter is injected by the Storage Gateway, the only path to the database. Scope is built on the cascade’s groups (cascade).
The model in one breath
Section titled “The model in one breath”A principal is the polymorphic subject of authN/authZ; identity is its opaque uuid, never an email or name. Each principal has one or more credentials and zero or more grants, each a (role x scope) pair: the role contributes the verbs, the scope the entities, additive across grants.
Principal kinds
Section titled “Principal kinds”A principal carries a kind: authN methods and per-kind domain attributes differ, the role machinery does not.
| kind | what it represents | authN |
|---|---|---|
| human | a person | local password + session, OIDC, SAML |
| service | scripts, integrations, SDKs, bots | bearer token |
| node | the edge daemon running in the field | NATS JWT/nkey credential |
AI acts as a user (a first-class agent principal is deferred): an AI tool authenticates via OAuth as a human or service principal and acts with exactly that principal’s grants, a scoped, audited user (AI).
Each kind gets a 1:1 per-kind table linked by principal_id (human, service, node); the base principal table holds identity + kind only, the per-kind tables the rest, including the kind’s own identifier: a human’s username, a service account’s name, a node’s name. Each of the three is unique, because each is the only handle its principal has and each is denormalized as bare text into the audit trail (ADR-0111).
Credentials
Section titled “Credentials”One credential row per authN method per principal; a principal can hold many. (method, identifier) is the lookup key.
| method | identifier | secret_hash | who uses it |
|---|---|---|---|
| password | principal.id (uuid) | argon2id of the password | humans |
| oidc | iss\|sub (issuer + subject) | null (IdP verifies) | humans |
| token | sha256(token) | null (identifier IS the verifier) | service |
| nats | nkey public key | null (NATS verifies the signed nonce) | nodes |
The password identifier is the principal.id (not the username), so a username change does not invalidate the credential. Service bearer tokens are ogp_<locator>_<secret>: a prefix for secret-scanners, a 4-byte non-secret locator (stored, so a token can be named without its secret), and a 24-byte (192-bit) crypto/rand secret; the server stores only sha256(token), cleartext returned once at mint. A node enrolls with a per-tenant NATS JWT/nkey, the JWT carrying the node’s placement-derived visible_set as subject permissions (The node path).
Subjects
Section titled “Subjects”human, service, node, and principal_groups. Roles attach regardless of kind; the same principal_grant rows mean the same thing for each.
Group kinds
Section titled “Group kinds”The group membership mechanism (static list or dynamic filter) is shared across kinds, but the kinds are kept distinct (not one polymorphic primitive yet):
component/system/locationgroups are entity-groups: they carry config bindings (the cascade) and serve as ABAC scopes.principal_groupis a collection of principals (SCIM-synced or local): a grant subject, carrying no config. Members can be any principal kind, in practice humans synced from the IdP. A grant attaches through the sameprincipal_granttable and every member inherits it (the grant loader unions group grants with direct grants, so an inherited grant resolves identically). Membership is static and flat (no nesting) in the first cut; a group grant is bounded by the same escalation cover-check as a direct one.
So group appears on both sides of authZ: principal_groups as subjects, entity-groups as object scopes.
Roles and the role hierarchy
Section titled “Roles and the role hierarchy”A role is a capability set: permissions per (resource, action), in a role table with a uuid id, a globally unique renameable name, and an official boolean: official: true ships with the binary, seeded via the boot phase (ON CONFLICT DO UPDATE can patch a default permission on release); official: false is operator-created via the IAM API. No overrides: the create paths refuse a collision with an official name and the seed phase fails-safe against an existing operator role, diverging from the shadowable registries (role override risks lockout with no compensating use case), so a role name resolves to exactly one row.
The five official roles
Section titled “The five official roles”Each role carries a label and a description (surfaced in the Roles view and grant-builder tooltips). Inheritance is transitive and composes by union (below). The set, its inheritance and its effective permissions are rendered here from internal/seed/roles.yaml itself, with inheritance, wildcards and the read floor resolved by the same rbac path the authorizer runs, so what this page says a role holds is what the authorizer will let it do (#722):
Viewer (viewer): Read-only access to everything. The floor every other role inherits.
Effective permissions (1):
*:read
Operator (operator inherits viewer): Day-to-day operations: create or edit components, interfaces, secrets, variables, and the property, metric, event, and command catalogs, and issue commands in scope. Sees and reveals the device secrets in its scope (never the admin-sensitive platform credentials). Inherits Viewer.
Effective permissions (26):
component:createcomponent:updatecomponent:renamecomponent:moveinterface:createinterface:updatesecret:readsecret:revealsecret:createsecret:updatevariable:createvariable:updateproperty_type:createproperty_type:updatemetric_type:createmetric_type:updateevent_type:createevent_type:updatecommand_type:createcommand_type:updatecommand:issuealarm:acknowledgetelemetry:pushfile:createfile:delete*:read
Deploy (deploy inherits viewer): Integrator or field tech: create, update, rename, and move on locations, systems, and components, and no delete. A grant of it reaches ONE tier, the tier it is scoped at. Granted at a location with the "under only" operator, it builds out and edits the rooms under that location, never the location itself, plus the secrets those locations own; it reaches no system and no component there, not even to read one. Building those out inside a building takes a system-scoped and a component-scoped grant of this role beside the location one, until a grant's scope can span tiers. Inherits Viewer, whose read floor is scoped the same way.
Effective permissions (18):
location:createlocation:updatelocation:renamelocation:movesystem:createsystem:updatesystem:renamesystem:movecomponent:createcomponent:updatecomponent:renamecomponent:movesecret:readsecret:revealsecret:createsecret:updatefile:create*:read
Administrator (admin inherits operator): Manages the fleet, users, roles, and grants: full create, update, and delete on locations, systems, and components, and on principals, credentials, grants, and roles. Deliberately NOT the superuser: it cannot grant a role above its own tier, so it cannot make itself owner. Inherits Operator.
Effective permissions (81):
component:deleteinterface:*task:*system:createsystem:updatesystem:deletesystem:renamesystem:movelocation:createlocation:updatelocation:deletelocation:renamelocation:moveprincipal:*principal:read:adminprincipal:purge:adminsecret:>file:>variable:*platform:*settings:readsettings:updateprincipal_grant:*principal_group:*principal_group:read:adminnode:*role:read:adminaudit:read:adminproperty_type:*metric_type:*event_type:*command_type:*command:*location_type:createlocation_type:updatelocation_type:deletecomponent_type:createcomponent_type:updatecomponent_type:deletesystem_type:createsystem_type:updatesystem_type:deletevendor:createvendor:updatevendor:deletedriver:createdriver:updatedriver:deleteproduct:createproduct:updateproduct:deletestandard:createstandard:updatestandard:deletetag:*component:createcomponent:updatecomponent:renamecomponent:moveinterface:createinterface:updatesecret:readsecret:revealsecret:createsecret:updatevariable:createvariable:updateproperty_type:createproperty_type:updatemetric_type:createmetric_type:updateevent_type:createevent_type:updatecommand_type:createcommand_type:updatecommand:issuealarm:acknowledgetelemetry:pushfile:createfile:delete*:read
Owner (owner): Full control of everything, the break-glass superuser (the tail wildcard, so it covers every capability at every tier, including admin-sensitive ones and future resources). At least one active owner always exists, and an owner account cannot be impersonated.
Effective permissions (1):
>
owner sits at the top of that list without inheriting from admin, and the omission is not one: its permission is >, the tail wildcard, which already covers every capability at every tier including ones no release has shipped yet, so there is nothing an inheritance edge could add.
What the permission strings above do not say, per role:
viewerreads every operator-facing resource in scope, and two families are deliberately not operator-facing. The IAM directories (principal,role,principal_group) read at the admin tier (<resource>:read:admin), which a two-token*:readstructurally cannot reach, so the Users, Roles and Groups pages stay closed to it (ADR-0023).secretis a sensitive resource a bare*does not reach at all, soviewerreads no secret directory (ADR-0025).operatorholds the operational secrets in its scope and never the platform’s: secret delete stays admin-only, and an admin-sensitive secret (a platform credential,admin_sensitive = true) is absent from the directory and a non-disclosing 404 on reveal regardless of scope, so an operator sees device secrets but never a platform key at the same scope (ADR-0025). Creating an admin-sensitive secret needs the admin tier, so an operator mints only operational ones.deployis the integrator / field-tech role, typically granted with thesubtree_excl_rootoperator to build out a subtree without editing its root, and a grant of it reaches one tier, the tier it is scoped at. Its permission set names three tiers, but a location-kind grant fills no system-tier or component-tier scope at all, not even to read one, because a scope kind contains only its own tier until the cross-tier expansion lands (#10). Building out a building therefore takes a location-scoped, a system-scoped and a component-scoped grant of this role together, the shapeinternal/api/placement_scope_e2e_test.gouses; the reach is pinned byinternal/seed/role_reach_test.goand the role’s own description says so.adminaddsplatform:*, the install-wide authority a write at the cascade’splatformtier needs (below);operatoranddeployhold noplatformcapability, so an all-scoped operator runs every site without being able to change the install-wide value under them. It holdssecret:>andfile:>(the tail wildcard, not the two-token*), which is what reaches the admin-sensitive tiers a two-token wildcard cannot. IAM management is meaningful only from an@ allgrant: a scopedadmin @ subtreekeeps the operator powers within its subtree and gets no IAM. Registry curation is a plain capability, so a custom role can carry<registry>:createalone for a non-admin curator. It is deliberately not the superuser: it cannot grant a role above its own tier (ADR-0013), so it cannot make itself owner, and it cannot deleteofficialroles.owneris break-glass and unkillable: at least one activeowner@allgrant must exist at all times (enforced by DB trigger), an owner account cannot be impersonated, and the bootstrap creates the first one.
The console Roles view (GET /roles, gated role:read:admin) lists these read-only with label, description, inheritance, and effective permissions. The role blade renders a net view: the permission universe (below) plus, per role, the held subset (resolved server-side by the same rbac.Set.Allows matcher), one-per-line, lexicographically, with a Held / Missing / All toggle. Custom-role editing is a later slice.
Permission format
Section titled “Permission format”Permissions are topic patterns, matched like NATS subjects (the whole stack shares one wildcard convention): a colon-delimited token path where a literal matches itself, * matches exactly one token, and > matches one or more tokens and must be last (ADR-0015). One entry per resource per role; the action segment may be comma-separated:
component:read <- one permissioncomponent:create,update <- expands to component:create and component:updatealarm:acknowledge <- a domain verb alongside CRUDproperty_type:create <- a registry curator capability (tag/unit/event_type/severity_level/source likewise)principal:* <- any single action on this resource (a two-token pattern)audit:read:admin <- an admin-sensitive permission (three tokens)> <- everything, at every tier (the owner superuser)A normal permission is resource:action (two tokens); an admin-sensitive one carries a third admin token (audit:read:admin). Because * matches exactly one token, a two-token pattern like *:read or principal:* structurally cannot match a three-token :admin permission: admin-sensitivity is a deeper token, not a matcher special case. The IAM directory reads (principal:read:admin, role:read:admin, principal_group:read:admin) use this to stay off the viewer floor, alongside audit:read:admin and principal:purge:admin; admin carries each explicitly. > is the whole-fleet superuser (owner); <resource>:> grants everything under one resource including its admin tier. Actions are HTTP-aligned (read, create, update, delete) plus resource-specific verbs (acknowledge, reveal, issue); there is no aggregate write.
One further carve-out: a sensitive-resource set, {secret, settings, platform}, that a bare single-token * does not reach in either place * can grant (the direct topic match and the :read floor), for resources not :admin-tier wholesale but that a bare wildcard must not sweep up: an operator holds a literal secret:read while a *:read-only viewer reads no secrets; settings is install configuration off the viewer floor (an ordinary user sees only the client-visible namespaces, through the authn-only /settings/me); and platform is install-wide authority (below), which full-fleet reach must not confer. A literal, a resource wildcard (secret:*), and owner’s > still name a sensitive resource; only the bare * is turned away (ADR-0025). This composes with the :admin tier: secret uses both, a per-secret admin_sensitive flag flipping an individual platform credential to the :admin tier (config and credentials). The IAM directories are not in the set (no legitimate sub-admin reader, so the :admin tier alone suffices).
Inheritance composes permissions by union:
parent: component:create,updatechild: component:deletechild effective: component:create, component:update, component:deleteThere are no negative permissions; to narrow, define a fresh role. The escalation guard (rbac.Set.Covers) uses pattern subsumption: a broader pattern covers a narrower one, > covers everything, and no partial wildcard covers > (so an admin can neither impersonate nor grant an owner).
The permission universe, published per route
Section titled “The permission universe, published per route”Every capability-gated route registers through one helper, gated(op, tokens...), which sets the authn + require middleware, stamps the operation with the x-omniglass-permission OpenAPI extension, and records the permission in an in-process registry, so the authz contract is published in the generated api/openapi.json, machine-readable, not buried in Go middleware. The permission universe is the sorted, deduped set of every stamp, derived from the routes with no hand-kept catalog to drift; the Roles view reports it as the denominator for the net held-vs-missing view.
A permission granted but absent from the universe would enforce nothing; since #463 reversed the ship-ahead policy there are none. A grant lands in roles.yaml in the same slice as the first route that enforces it, the drift test’s aheadOfRoutes allow-list is empty, and the docs lint closes the loop from the other side (a permission named in prose must gate a route or sit inside a design fence).
Authorization: grants = role x scope
Section titled “Authorization: grants = role x scope”A principal holds grants in principal_grant. Each grant is a (role, scope_kind, scope_id) triple; a principal can hold many, and they are additive:
canDo(P, action, E) iff exists grant g in grants(P) such that action in perms(g.role) AND E in expand(g.scope_kind, g.scope_id)Action and scope bind per grant, not globally. The action and the E-membership test are satisfied by the same grant g; flattening permissions into one global set and entities into one global visible set over-permits, and the enforcement layers below preserve the per-grant binding (the worked example traces it). The same role at different scopes and mixed roles (operator @ HQ + viewer @ all for a site lead) are the intended pattern; grants from principal_group memberships compose the same way.
Scopes
Section titled “Scopes”Today each fleet entity is scoped by its own tier (a location scope contains locations only, and
so on; only secret / variable / interface / task resolve through their owning entity’s
tier), and a group scope is refused with a 422.
expand realizes a scope to a bound id set the gateway injects as a parameterized owner IN (...) predicate (or a closure-table join for deep trees), never string-built; the tree walk carries a cycle guard, and the set is fleet-size-bounded (entities), an indexed membership filter. scope_kind is enumerated; adding a kind is a schema change (CHECK constraint) plus a new expand case. scope_id is operator data.
Install-wide authority is not fleet scope
Section titled “Install-wide authority is not fleet scope”The cascade’s least-specific tier is platform: the value an admin set for the
whole install. A write there needs two permissions, the resource’s own and platform:<action>,
checked together (ADR-0057):
secret:create + platform:create <- seal a secret at the platform tiervariable:update + platform:update <- change the install-wide value of a variabletag:update + platform:update <- POST /tags/{name}:setPlatformsettings:update + platform:update <- every settings write, which is install-wide by definitionA scope says how much of the fleet a grant reaches; platform:<action> says whether the principal may
change the value under all of it. platform:* is seeded to admin (and reaches owner through >);
operator and deploy hold no platform write, and nothing implies one, mechanically: platform is in
the sensitive-resource set, so a bare single-token * never names it, which is why a
custom role carrying *:update holds every fleet write and still no install-wide authority. The console
mirrors the same set, offering the tier controls only to a caller holding both halves and naming the missing
capability. The tier gate is published per route: an x-omniglass-platform-permission extension beside
the x-omniglass-permission stamp, both in the route-derived
permission universe. Where the request names the tier the
handler refuses up front (403); where only the stored row knows its tier the resolved capability rides into
the Storage Gateway beside the ABAC scope, so the 404-versus-403 split stays non-disclosing, as on every
other write.
Visibility cascades down the structural tree
Section titled “Visibility cascades down the structural tree”A scope of entity E includes E and everything structurally beneath it (a location -> its systems -> their components -> their properties and alarms). The visible set is parameterized by action: visible_set(P, action) = the union, over only the grants whose role carries action, of each scope entity plus its descendants. :read is an implicit floor on every grant: visible_set(P, read) is always the widest set, every other per-action set a subset. The floor lives in the matcher, evaluated per call (rbac.Set.Allows): a two-token <resource>:read check passes when the set holds any permission on that resource, so the floor holds in the fast-reject and in canDo without pre-expansion, and a verb-only role (secret:reveal without secret:read) is not hard-403’d on the read. The asymmetry runs one way: a principal can read an entity it cannot act on, never the reverse, so the status split below stays three-way. Dynamic-group scopes recompute as membership changes; each per-action set is bounded by fleet size (entities), not data volume.
Scope operators (how a grant’s root matches the tree)
Section titled “Scope operators (how a grant’s root matches the tree)”A grant carries a scope_op saying how its root matches the tree, a small operator on the grant rather
than a new scope kind or boolean modifiers, so it composes with the additive-grant model. It is moot for the
all scope.
| operator | glyph | in scope | for |
| --- | --- | --- | --- |
| subtree (default) | ≥ | the root and everything beneath it | every action |
| subtree_excl_root | > | the root’s descendants; not the root itself | update / delete (read and create keep the root) |
| self | = | exactly the root row, no descendants | read / update / delete (not create: no children) |
subtree is the ordinary case. subtree_excl_root is the integrator / deploy grant: deploy @ location:room-42 (>) lets a field tech add and edit the LOCATIONS under room-42 without renaming or
deleting room-42 itself (the tier is the grant’s own kind: the systems and components sitting in
room-42 need their own grants, see the role table above); it narrows only the modify actions, so a PATCH on the root is the
readable-but-out-of-write-scope 403 while a POST under it and a PATCH on a descendant succeed.
self is a leaf-lock on one node: exactly its own row for read, update, and delete, and not
create-placement, so operator @ location:room-42 (=) sees and edits only room-42 and a POST under it is
a 403. Operators combine by union across grants, resolved per action: an inclusive subtree grant wins
over an excluding one, and a self grant re-admits a root that a subtree_excl_root grant stripped. The
operator is part of a grant’s identity, so changing one is a revoke plus a grant.
The owner invariant
Section titled “The owner invariant”At least one active owner @ all grant must exist at all times. Enforced as a deferrable constraint trigger in Postgres (fires at COMMIT, so the swap-owners pattern works in one transaction):
BEGIN; INSERT INTO principal_grant (... role='owner', scope_kind='all' ...); -- new owner DELETE FROM principal_grant WHERE principal_id=<old> AND role='owner'; -- oldCOMMIT; -- trigger fires here, sees the new grant, passes.Removing the last owner (by grant delete, principal delete, principal disable, or role change) raises a check-violation, which the Gateway translates into a 409 with a remediation message (“cannot revoke the last owner grant”).
Grants cannot exceed the granter
Section titled “Grants cannot exceed the granter”Creating a grant is refused (403) when the granted role’s capabilities are not covered by the granter’s own all-scope capabilities (rbac.Set.Covers, the same primitive as the impersonation escalation guard): no caller can promote anyone, itself included, above its own tier (an admin cannot grant owner, since admin’s enumerated patterns do not subsume the superuser tail), and a capability held only through a narrower grant cannot be conferred fleet-wide. The same rule will apply to role editing when it lands.
Impersonation (view-as and act-as)
Section titled “Impersonation (view-as and act-as)”An owner or all-scope admin holding principal:impersonate can temporarily see and act through another
principal, for troubleshooting. View-as resolves reads under the target’s visible_set and refuses
every write; act-as is full, its mutations attributed to both principals.
POST /principals/{id}:impersonate mints a bounded (default 30 minutes, revocable) bearer token stored as
an impersonation_session, distinct from credential (a credential authenticates a principal as
itself, a session one principal as another, with its own expiry, revoke, and “who is impersonating
whom” listing). authn resolves the token on a bearer miss to the target principal, tagging the
request with the real actor and mode; POST /auth/me:stopImpersonation revokes it.
Two guarantees, over a hard floor. Owner protection: a principal holding owner @ all is
un-impersonatable by anyone, in either mode; impersonating the highest-trust account is a full-takeover
vector, removed rather than left to the cover arithmetic. The escalation guard: a caller may
impersonate a (non-owner) target only when its capabilities cover the target’s (rbac.Set.Covers), so
impersonation never confers a capability the caller lacks. View-as is cross-scope (read-only grants no write
authority), but act-as also requires the caller’s all-scope grants alone to cover the target, since
an impersonated request resolves its scope from the target; without it a split-grant admin (all-scope user
management, campus-scoped infra) could act-as a different campus’s admin and gain write there. The rule is
resource-agnostic, closing escalation through non-tree writes (principal_grant, role) whose scoped
grants resolve to an empty effective scope. And accountability: every audited mutation while
impersonating records real_actor_principal_id alongside the impersonated actor_principal_id.
Self-impersonation and nesting are refused, and disabling either party kills the session on its next
request (the same per-request active re-read that makes disable hard revocation).
Enforcement: where each check lives
Section titled “Enforcement: where each check lives”There is no RLS and no direct database access (no PostgREST): the Storage Gateway is the only door to the database, the API its only caller, so authz lives entirely in the app. A targeted mutation passes three checkpoints, each one code seam: the capability fast-reject at the route, the canDo decision in the handler, and the per-action scope plus audit injected by the gateway:
The capability check is necessary not sufficient (it only rejects), the canDo check is the authoritative decision, and the gateway predicate is the enforce-by-construction backstop: handler and gateway return the same status for the same input, so a forgotten handler check cannot leak a write. The detail:
- Capability (RBAC) in the API middleware is a FAST-REJECT, never an authorization. Does the action appear in any grant? If not, 403 before the gateway is touched, answered from an in-process cache. Passing only means “not categorically forbidden”; scope still decides. Routes declare their permission through
gated(op, "component", "create")(above). - Scope (ABAC) in the Storage Gateway is per-action. Every query carries
visible_set(P, action)for the specific action, filtering rows by their exclusive-arc owner: a read usesvisible_set(P, read), a write the write-action’s set, never a global union. An:ackoutsidevisible_set(P, ack)matches 0 rows even if the handler forgot its check, and a 0-row write is never a silent success (a silent 200/no-op is forbidden): the gateway reports the miss and the handler returns 404 or 403 matching the up-front decision. The set is fleet-size-bounded; as an owner filter in app code it works identically on Postgres, the columnar tier, or object storage. - The gateway has three query modes: scoped (an API request carrying a principal’s visible set), node (a node-driven write confined to the node’s placement-derived
visible_set, so a compromised node cannot write arbitrary owners intra-tenant), and system (trusted internal work: the CDC publisher, the sample persistence sink, reconcile / migrate / seed, all-visibility; an explicit, audited choice, never the default). There is no fourth path. - Targeted mutation on a known id resolves the target twice: within the read scope, then within the action’s own scope. A custom method against a specific id (
POST /components/{name}/alarms/{id}:acknowledgeis one of eighteen) resolves that target through the gateway carryingvisible_set(P, read)to decide whether the caller may know it exists, andvisible_set(P, action)for its own action, never a neighbouring one, to decide whether it may act. The read set decides only which refusal is owed and grants nothing, so a wide read still cannot widen a narrow write. The status split is three-way (below): (a) action in no grant -> 403 at the fast-reject; (b) target invisible_set(P, read)but outsidevisible_set(P, action)-> 403; (c) target outsidevisible_set(P, read)-> 404, non-disclosing. - Scope is structural, not per-handler: the principal’s scope is a required input to the gateway’s query layer, so no code path can query unscoped by accident. With no RLS backstop, the gateway is the sole guarantor.
- Coverage scales with the surface, by test. An authz conformance matrix runs the full assertion set (capability 403, the over-permit scope 403, the non-disclosing 404, in-scope success, the read/act asymmetry) against every scoped entity from a registry: a new scoped entity is one registry line. Each entity also declares the mutating routes that hang off it and resolve it as their target (its property writes, and for a system its membership and role writes, for a component its alarm writes and its command issue), and the matrix drives the whole three-way split through each one, so the truthful 403 is proved per route and not merely per entity: the routes are not uniform across the registry, and a per-entity assertion would have left thirteen of them unproved. A route-gating guard drives every generated-OpenAPI operation with a zero-permission principal, asserting 403 outside a short allow-list:
GET /healthz,GET /auth/status,POST /auth/login,POST /auth/logout, the authn-only/auth/mefamily andGET /settings/me, andPOST /nodes:claim, the API’s one public write (safe unauthenticated because the enrollment token in its body is the credential, minted once by anode:enroll-gated route, only its hash stored; an invalid token is a 401 that discloses nothing). A published-gate guard is the spec-side companion: every route outside that allow-list carries anx-omniglass-permissionstamp and every allow-listed route carries none, so “gated” and “published” are the same set.
Worked example (per-grant binding denies fleet-wide acknowledgement). This one is built (#728, refusal status corrected by #736), so it is the real route rather than a stand-in. Principal P holds operator @ component-A (the role carries alarm:acknowledge) and viewer @ all (read-only). Alarm X hangs off component B. P calls POST /components/{name}/alarms/{id}:acknowledge naming B and X:
- Middleware fast-reject:
alarm:acknowledgeappears in a grant (theoperator @ component-Aone), so it passes; fast-reject cannot see that the acknowledging grant does not cover X. - Scope resolved from the acknowledgement’s own permission:
visible_set(P, alarm, acknowledge)unions only the grants whose role carries it, so it is{component-A}; the fleet-wideviewergrant carries no acknowledgement and widens nothing. This is the load-bearing part: resolving the scope fromcomponent:read(which P holds everywhere) or fromcomponent:update(which a pure responder role need not hold at all) would answer the wrong question in opposite directions. - Status: B is outside the acknowledgement’s set, but P holds
viewer @ alland so can READ B: the target resolves invisible_set(P, read)and fails only the action half, and P gets a 403. Naming the refusal discloses nothing, because P canGETthat component and that alarm already; answering 404 instead would tell a caller that a row it is looking straight at does not exist. Had P held no read over B at all, the same call would be the non-disclosing 404 of case (c), and P would learn nothing about whether B exists (the three-way split). - Backstop: the gateway is where the scope is applied, not the handler, so a handler that forgot to pass it could not query unscoped: the scope is a required argument.
The three-way status split
Section titled “The three-way status split”A refusal picks its status from the difference between two scopes, not from one:
| the target is | status | why |
| --- | --- | --- |
| in no grant carrying the action at all | 403 | the fast-reject, before the gateway is touched |
| in visible_set(P, read), outside visible_set(P, action) | 403 | the caller can already see this row, so naming the missing authority discloses nothing a GET would not, and it is the truth |
| outside visible_set(P, read) | 404 | non-disclosing: the caller must not learn the row exists, not even through a different status |
One primitive makes the split for every route that has one, resolveScoped in the Storage
Gateway, so the ordering (read first, then action) exists in one place rather than once per
route. Routes whose write takes no scope of its own, the alarm verbs, the system-role
declarations, and the command issue, reach the same primitive through ResolveActionTarget.
Two of the eighteen resolve their action half from a permission that is not the target
entity’s own write, and both are the same shape: alarm:acknowledge on
POST /components/{name}/alarms/{id}:acknowledge, and command:issue on
POST /components/{name}/commands:issue. Each is granted on its own resource while the target is a
component, so each resolves on the component tier from its own permission
(ADR-0117):
recording that a human saw an alarm is not editing the component, and neither is telling a device to
do something. The command one is the sharper case, because what a wide read used to widen there was
not what a caller could learn but what it could physically actuate.
The read set is the caller’s own <resource>:read at every one of them, and that is a
condition rather than a convention: the truthful 403 names a row, so it may only ever be
reached by a caller who could have read that row anyway. A route that checked a wider set here
(the action’s own, a neighbouring tier’s) would hand the existence of a row to somebody with no
grant to see it, which is exactly the disclosure the 404 exists to prevent.
- Non-entity resources have no entity
E, socanDocannot scope by owner; the authorization is the grant-class check, the one place the decision is capability-shaped. Three governance classes:- IAM subjects (
principal,role,principal_grant, and a principal’s login credential create/delete): the action must appear in a grant whosescope_kindisall; a scoped grant confers no IAM capability. Typicallyowner @ all/admin @ all. (A credential variable is different: entity-scoped, so itssecret:readdecrypt and rotation are ordinary scoped actions, config and credentials.) - Data registries (
property_type,tag,unit,event_type,severity_level, source): a distinct<registry>:createcurator capability (property_type:create,tag:create,event_type:create; the rest take the same shape when they land). No owner entity, soscope_kindis irrelevant, and a curator role mints entries without IAM admin. A minted entry carries its ownscope(an org-scoped entry shadows an official one, the namespace-shadow pattern);official-scoped entries are reserved toownerand the boot seed. - Type registries (
location_type,secret_type): each speaks its own permission word, never a shared generic one. Thelocation_typeregistry carries its own resource with the full verb set (location_type:read,:create,:update,:delete); the read ridesviewer’s*:readfloor, the writes go toadmin, and the property contract (/location-types/{id}/properties) hangs off the same resource (location_type:updatedeclares,location_type:deletewithdraws). Thesecret_typeregistry is read-only reference data behindsecret:read(a sensitive resource off the viewer floor, so whoever may read secrets may read their shapes, and nobody else); it has no write routes. A type row carries no namespace-shadowscope: anofficialrow is read-only (create/update/delete 422), an operator row is editable, and deleting alocation_typestill referenced by a location is refused (409, a Gateway pre-count with the parent foreign key as backstop). The shipped location types seed asofficial: false(the seed model); the shipped secret types are seed-owned andofficial: true. - Catalog entities (
vendor,driver,component_type,product,standard): each its own resource with the full verb set (product:read/:create/:update/:delete,standard:read/ …, and so on), because each declares more than a label. A component carries no type (its shape comes from itsproduct, delete-guarded by thecomponent.product_idrestrict FK); a system conforms to astandard, which is whystandardgraduated out of thetyperegistry (ADR-0048). Each catalog’s property contract rides its owner’s permission (product:update,standard:update).
- IAM subjects (
A reference resolves within a scope
Section titled “A reference resolves within a scope”A caller may address a location, system, or component by uuid, bare name, or dotted address
(ADR-0089),
and every form goes through one primitive, resolveRef, before the ordinary canDo decision above
ever runs: scope decides before ambiguity does. A bare name is narrowed to the caller’s scope
first, then judged ambiguous only within that narrowed set, which is what lets an operator scoped to
one room use display-1 without being refused because an unrelated room they cannot even read holds a
same-named row; an ErrAmbiguousName’s candidate list can therefore never name a uuid the caller could
not otherwise read, and so it is listed: the refusal names the rows that collided, each of them a
spelling of the same reference that resolves, because an ambiguity that names nothing tells an operator
their input is ambiguous and hands them nothing to disambiguate with
(#697). The exception is a resolve with no
caller scope to filter by at all, where the list stays empty by design: the three *NameTaken
availability advisories (deliberately scope-blind, so availability answers about the placement bucket
asked about rather than the caller’s own grant), the component end of a membership or role write and of
ResolveTags’ for_system (whose only scope in hand is resolved for the other tier, which can never
narrow it). A dotted address skips this step entirely on its own: the placement-scoped unique
indexes it walks admit at most one row per hop, so there is no ambiguity to decide, only the same scope
check every other reference gets once it resolves to a uuid.
The primitive carries one policy axis, not one behaviour, and the two policies do not make the same promise:
-
The read path (
scopedGet/scopedByNameInScope) folds every failure into one non-disclosing 404: absent, out of scope, and ambiguous-only-outside-scope are indistinguishable from outside the caller’s grant, matching the read/act asymmetry above. -
The write path (
resolveScopedRef, used to resolve a create’s or an action’s same-tier parent or owner reference) keeps the notFound-versus-forbidden split a write already makes elsewhere: absent anywhere is 404, present only outside the given scope is 403, because the caller supplied this reference itself in the same request, not the new disclosure a read’s own uuid would be. Its guarantee is narrower than it can sound: a resolved reference is one the caller may write a binding against, not one the caller may read, and a bare-name 403 is a name-existence oracle by construction (a caller learns a name exists somewhere in the fleet from the status code alone, with no candidates disclosed), predating this epic and tested (interfaces_scope_test.go:82), now asymmetric with the read path’s single 404 by design, not by omission. -
The cross-tier placement bind (
resolvePlacementRef, used byCreateComponent,CreateSystem,MoveComponent, andMoveSystemfor the location and system a row is placed against) takes the read path’s policy on the referenced tier, and a scope resolved for that tier: out of scope is the same non-disclosing 422 an absent reference gives. Neither of the two policies above fits it. The write policy’s set is resolved for the entity being written, and a location’s scope tree is its own unrelated ancestor chain, so checking a component-tier scope against the location table can never match and would deny every non-all caller; and the question a location asks is a read question, because since ADR-0100 the label these writes stamp is rendered from the location’s own label and the primary system’s type label and is handed straight back in the response. Existence-only resolution therefore made a create a read of a row the caller holds no grant to read. The:renderLabeldraft route (ADR-0104) already refused exactly this, so the create it previews now shares its refusal rather than being the lenient one (#700). A caller holding no grant on a tier reads nothing there and so can bind nothing there, which is the same sentence in both directions. Because it resolves inside a caller scope, its ambiguity refusal lists its candidates: it was the one bind that redacted them while resolving fleet-wide, and it stopped needing to the moment the scope narrowed (#697).The action that scope is resolved for is not the same on both references, because the two references do not do the same thing. A location is read and rendered into the label, so it resolves in
location:read. A system on a component create is a membership: it inserts the rowPUT /systems/{name}/members/{component}writes, so it resolves insystem:updateand the route requires that permission when the reference is present (ADR-0107). That is a live narrowing of who may create a component into a system:operatorholdscomponent:createand nosystem:*at all, so it maintains components and adeploytech builds out systems and their membership. The console does not offer the picker to a principal that cannot use it, and the API’s refusal names the permission, so the narrowing is met before the form is filled in rather than as a 403 after it.The
:renderLabeldraft resolves that same reference in that same set (#713). A preview and the create it previews agree on every refusal, and the agreement is the platform’s rather than the console’s: a draft that resolved the system insystem:readalone served a rendered label, assembled from that system’s own type, for a create the platform then refused. The draft carries the create’s conditional permission as well as its scope, so both halves of the gate rehearse.
A guard inside the same primitive panics if the caller’s scope was resolved for a resource that does
not cover the config being checked against, catching a tier mismatch (a component-tier scope compared
against a system’s own table, say) as a caller bug rather than a silently wrong answer. It is forward
insurance, not proof: every call site today derives its resource label from the same config it is
checked against, so the guard cannot fire on any input the current code produces, and it inherits a
blind spot from the scope-covers check itself, unable to tell a right tier from a wrong one within
the secret / variable / field / telemetry family, only a right family from a wrong one. The full
accounting of what this primitive does and does not guarantee is
ADR-0089’s
own list, not repeated here.
Both layers operate within one database. Tenant isolation is per-deployment: a tenant is one database plus one NATS account plus one deployment, so per-database isolation (storage) and per-account isolation (messaging) are the same boundary; there is no tenant_id column anywhere and no RLS backstop.
The /auth/me contract
Section titled “The /auth/me contract”The web app (and any CLI client) gets the principal + their effective permissions in one call:
GET /api/v1/auth/me{ "principal": { "id": "...", "kind": "human" }, "permissions": [ "component:read", "component:create", "component:update", "alarm:acknowledge", ... ], "grants": [ { "role": "operator", "scope_kind": "location", "scope_id": "HQ" }, { "role": "viewer", "scope_kind": "all", "scope_id": null } ]}The /auth/me family also manages the caller’s own identity: PATCH /api/v1/auth/me edits the caller’s label (email is administrator-set), and POST /api/v1/auth/me:changePassword verifies the current password and installs a new one. Both are authn-only and self-scoped: they resolve the target from the session, never a path id, so they need no capability and join the route-gating allow-list; acting on another principal is the admin surface and does carry capabilities. The same family manages its own sessions: GET /api/v1/auth/me/sessions lists the caller’s live bearer credentials (told apart by credential.purpose; the request’s own credential flagged current, expired rows omitted by the same filter AuthenticateBearer uses), returning only non-secret metadata (the sha256(token) is compared in-query and never leaves the database). POST /api/v1/auth/me/sessions/{id}:revoke deletes one, bounded to the caller’s own principal (another principal’s credential id is a non-disclosing 404); revoking the current credential signs that session out. The bulk POST /api/v1/auth/me/sessions:revokeAll with a { purpose } body (session or token) ends all of one kind, always keeping the credential that made the request, and returns the count (reusing RevokeBearersByPurposeExcept, the change-password force-logout primitive). POST /api/v1/auth/me/tokens mints the caller’s own API token (a required description, an optional ttl_days, default 90, capped at 365), returning the secret once. Every bearer credential carries identifying metadata: the token’s description (empty on an auto-created session), the user-agent and client-ip that created it (captured by middleware before Huma), and a last_used_at bumped on authentication (throttled to once a minute). GeoIP is deferred. The console splits the list into Sessions and API tokens sections, each with a Revoke all, plus a Create token action.
The admin counterpart ends another principal’s sessions, so a lost laptop or a leaked API token can be cut off without resetting the account. GET /api/v1/principals/{id}/sessions lists the target’s active bearer credentials in the same non-secret shape, with current always false (a nil currentHash); POST /api/v1/principals/{id}/sessions/{sid}:revoke ends one (204). Both gate on principal:revoke-session, a normal two-token permission held by admin and owner through principal:* / >, kept separable for a future help-desk role. The revoke reuses the same principal-scoped delete as the self-service one (a sid that is not the target’s is a non-disclosing 404) and sits behind the same takeover guard as impersonation and the password reset: an owner’s sessions cannot be revoked by anyone, nor can a caller revoke a principal whose capabilities exceed its own. It is audited with the acting admin as the actor (verb = revoke_session; the real actor rides context when impersonating). The list is read-only and carries no takeover guard, so an admin can see an owner’s sessions even where it cannot end them. The bulk POST /api/v1/principals/{id}/sessions:revokeAll ({ purpose }) ends all of one kind (a purpose-filtered RevokeBearersByPurpose, so revoking sessions never touches tokens) and returns the count, with the same gate, guard, and audit.
permissions is the flat union of the caller’s roles’ raw patterns (wildcards like principal:* and > ride through unexpanded, the :read floor not materialized), so a useCan(...) check in the web app applies the same matching rules the server does. It is a fast-reject / UI hint only: “could this principal ever do X anywhere”, never “can it do X to this entity”. Per-row action affordances (the acknowledge button on a specific alarm) must be computed against visible_set(P, action) for that target, which the grants array drives (scope chips, per-row actionability, explaining why a button is hidden). The server is the only authority regardless.
Profile pictures
Section titled “Profile pictures”A human principal can carry a profile picture, on the identity surface’s two lanes.
Self: POST /auth/me:setAvatar / :removeAvatar, authn-only and self-scoped, no capability.
Admin: POST /principals/{id}:setAvatar / :removeAvatar, gated principal:set-avatar (an all-scope
capability, held by admin through principal:* and owner through >), audited with the administrator
as the actor; an avatar is not a capability, so the admin lane carries no takeover guard (unlike a password
reset).
The upload is normalized server-side by the pure avatar.Normalize primitive, so the client cannot
bypass it: JPEG, PNG, or WebP in (anything else rejected), a payload over 8 MiB or any source dimension
over 8000px refused (two decompression-bomb guards), center-cropped to the largest centered square, resized
to 256x256, re-encoded as JPEG at quality 82; a bad or oversize image is a 422. The one normalized size
is stored base64 on the human row (avatar, with avatar_updated_at); the bytes are never loaded
on the loadPrincipal hot path, which selects only avatar is not null, so the read models carry a cheap
has_avatar flag and the console falls back to initials without paying for the payload per row.
The read side is a JSON endpoint (GET /principals/{id}/avatar gated principal:read:admin,
GET /auth/me/avatar on the self lane) returning { image_base64 }, rendered as a data: URL; a principal
without a picture is a 404. JSON, not raw image/jpeg
(ADR-0018): a
raw-bytes chi handler would sit outside the Huma authz middleware, breaking the permission-on-every-route
invariant, and a bare <img src> cannot carry a bearer.
One model, never duplicated
Section titled “One model, never duplicated”Each layer is enforced in one place and re-derived nowhere else: capability in the route middleware, scope in the Storage Gateway (which also writes the in-transaction audit_log, owning scope and audit but not capability). No third surface re-implements either:
- The live UI relay calls these, it does not copy them. Operators never connect to NATS. The SSE subscribe is a normal route, capability fast-rejected at open (not authorized there); the server-side SSE relay runs each candidate message through the same gateway scope a read uses (
visible_set(P, read)against the message’s exclusive-arc owner), and the session re-checks on every grant-cache invalidation, so a mid-stream scope shrink tears the stream down rather than leaking. - Node subject permissions gate the subject; the admission consumer gates the owner. Subject permissions constrain the subject string, while a sample’s owner lives in the payload: subject perms keep a node off subjects it has no business on; the admission consumer (above) keeps a forged owner label out of the trusted stream. The bus carries no operator (
kind=human) clients at all.
Encryption in transit
Section titled “Encryption in transit”TLS on the HTTP API (terminated at the binary when given a cert + key, or at the operator’s reverse proxy) and on the NATS connection that carries node telemetry and commands. BYO PKI. “TLS off” is a deliberate dev-mode flag, never a silent default.
Every API operation records the resolved actor (the principal id) in audit_log; secret decrypts are always audited, never filterable. Node-mode writes record the node principal; a system-mode write records a null actor (audit_log.actor_principal_id is a nullable foreign key), distinguishing operator action from platform internals; an impersonated request also records real_actor_principal_id. Fleet mutations write their audit_log row in the same transaction as the change (via the Storage Gateway); auth events (login, logout) fire on read/no-tx paths through a separate non-transactional seam under resource = 'auth'. The read side (GET /audit-log) requires the admin-sensitive audit:read:admin, out of a two-token wildcard’s reach (ADR-0015). An attempt on an unknown username is not written, so scanning random usernames cannot flood the log; endpoint rate limiting (a later slice) bounds a targeted brute force. The full model, including the auth-event verbs, is audit.
Bootstrap
Section titled “Bootstrap”The first install runs omniglass bootstrap <username> with optional --password <pw>, --email <email>, and --label <name> flags: one transaction creating the first operator as a human principal with an owner @ all grant and a bearer credential; --password also installs a password credential for the web console. There is no implicit default principal; the bootstrap is the only path to the first owner.
Worked example
Section titled “Worked example”Sam, an AV support tech, is SCIM-synced into the AV-Support principal_group, which holds operator @ "AV-devices" (component-group), viewer @ "HQ" (location). Sam operates on AV devices fleet-wide and reads everything at HQ; the gateway hides every row outside those scopes, and the middleware blocks principal:create (not in operator). The day a device joins the AV-devices dynamic group it enters Sam’s scope; the day Sam leaves AV-Support in the IdP, SCIM removes the grant.
Storage
Section titled “Storage”The IAM subjects and their grants; the physical layout lives on storage. Each
kind carries its own identifier and, where it has one, a label: human.username plus email and the
optional label, service.name, and node.name with its own label (whose full
table renders on core entities). A service account has an identifier
and no label at all: the console shows its name, and there is nowhere else to put words.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK, default uuidv7() | |
kind | text | not null | human, service, or node; the per-kind table holds the rest |
created_at | timestamp with time zone | not null, default now() | |
active | boolean | not null, default true | |
archived_at | timestamp with time zone | ||
seq | bigint | not null |
CHECK constraints and unique indexes on principal
-
principal_kind_check:CHECK ((kind = ANY (ARRAY['human'::text, 'service'::text, 'node'::text, 'agent'::text])))
| Column | Type | Constraints | Notes |
|---|---|---|---|
principal_id | uuid | PK, FK → principal.id | |
username | text | not null | |
email | text | ||
label | text | The person's real name, the operator-facing label | |
failed_login_count | integer | not null, default 0 | |
locked_until | timestamp with time zone | ||
must_change_password | boolean | not null, default false | |
avatar | text | ||
avatar_updated_at | timestamp with time zone |
CHECK constraints and unique indexes on human
-
human_username_key:CREATE UNIQUE INDEX human_username_key ON public.human USING btree (username)
| Column | Type | Constraints |
|---|---|---|
principal_id | uuid | PK, FK → principal.id |
name | text | not null |
CHECK constraints and unique indexes on service
-
service_name_key:CREATE UNIQUE INDEX service_name_key ON public.service USING btree (name)
role is the RBAC capability set; viewer/operator/admin/owner ship, custom roles are org-local.
| Column | Type | Constraints | Notes |
|---|---|---|---|
name | text | not null | |
official | boolean | not null, default false | Shipped-canonical versus custom; official rows are read-only |
permissions | ARRAY | not null, default '{}'::text[] | The resource:action set the role grants |
inherits | ARRAY | not null, default '{}'::text[] | |
created_at | timestamp with time zone | not null, default now() | |
label | text | ||
description | text | ||
id | uuid | PK, default uuidv7() |
CHECK constraints and unique indexes on role
-
role_name_key:CREATE UNIQUE INDEX role_name_key ON public.role USING btree (name)
principal_grant is role times scope, additive: the scope is a structural entity or all.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK, default uuidv7() | |
principal_id | uuid | FK → principal.id | |
scope_kind | text | not null | Which arm carries the scope; group is admitted by the CHECK but refused by every code path today |
scope_id | text | ||
created_at | timestamp with time zone | not null, default now() | |
scope_op | text | not null, default 'subtree'::text | How the scope composes |
group_id | uuid | FK → principal_group.id | |
role_id | uuid | FK → role.id, not null |
CHECK constraints and unique indexes on principal_grant
-
principal_grant_scope_kind_check:CHECK ((scope_kind = ANY (ARRAY['all'::text, 'location'::text, 'system'::text, 'component'::text]))) -
principal_grant_scope_op_check:CHECK ((scope_op = ANY (ARRAY['subtree'::text, 'subtree_excl_root'::text, 'self'::text]))) -
principal_grant_target_ck:CHECK ((num_nonnulls(principal_id, group_id) = 1)) -
principal_grant_unique:CREATE UNIQUE INDEX principal_grant_unique ON public.principal_grant USING btree (principal_id, role_id, scope_kind, COALESCE(scope_id, ''::text), scope_op) -
principal_group_grant_unique:CREATE UNIQUE INDEX principal_group_grant_unique ON public.principal_grant USING btree (group_id, role_id, scope_kind, COALESCE(scope_id, ''::text), scope_op) WHERE (group_id IS NOT NULL)