Skip to content

Identity and access

Partial

Identity 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).

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.

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).

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).

human, service, node, and principal_groups. Roles attach regardless of kind; the same principal_grant rows mean the same thing for each.

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 / location groups are entity-groups: they carry config bindings (the cascade) and serve as ABAC scopes.
  • principal_group is 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 same principal_grant table 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.

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.

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:create
  • component:update
  • component:rename
  • component:move
  • interface:create
  • interface:update
  • secret:read
  • secret:reveal
  • secret:create
  • secret:update
  • variable:create
  • variable:update
  • property_type:create
  • property_type:update
  • metric_type:create
  • metric_type:update
  • event_type:create
  • event_type:update
  • command_type:create
  • command_type:update
  • command:issue
  • alarm:acknowledge
  • telemetry:push
  • file:create
  • file: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:create
  • location:update
  • location:rename
  • location:move
  • system:create
  • system:update
  • system:rename
  • system:move
  • component:create
  • component:update
  • component:rename
  • component:move
  • secret:read
  • secret:reveal
  • secret:create
  • secret:update
  • file: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:delete
  • interface:*
  • task:*
  • system:create
  • system:update
  • system:delete
  • system:rename
  • system:move
  • location:create
  • location:update
  • location:delete
  • location:rename
  • location:move
  • principal:*
  • principal:read:admin
  • principal:purge:admin
  • secret:>
  • file:>
  • variable:*
  • platform:*
  • settings:read
  • settings:update
  • principal_grant:*
  • principal_group:*
  • principal_group:read:admin
  • node:*
  • role:read:admin
  • audit:read:admin
  • property_type:*
  • metric_type:*
  • event_type:*
  • command_type:*
  • command:*
  • location_type:create
  • location_type:update
  • location_type:delete
  • component_type:create
  • component_type:update
  • component_type:delete
  • system_type:create
  • system_type:update
  • system_type:delete
  • vendor:create
  • vendor:update
  • vendor:delete
  • driver:create
  • driver:update
  • driver:delete
  • product:create
  • product:update
  • product:delete
  • standard:create
  • standard:update
  • standard:delete
  • tag:*
  • component:create
  • component:update
  • component:rename
  • component:move
  • interface:create
  • interface:update
  • secret:read
  • secret:reveal
  • secret:create
  • secret:update
  • variable:create
  • variable:update
  • property_type:create
  • property_type:update
  • metric_type:create
  • metric_type:update
  • event_type:create
  • event_type:update
  • command_type:create
  • command_type:update
  • command:issue
  • alarm:acknowledge
  • telemetry:push
  • file:create
  • file: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:

  • viewer reads 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 *:read structurally cannot reach, so the Users, Roles and Groups pages stay closed to it (ADR-0023). secret is a sensitive resource a bare * does not reach at all, so viewer reads no secret directory (ADR-0025).
  • operator holds 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.
  • deploy is the integrator / field-tech role, typically granted with the subtree_excl_root operator 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 shape internal/api/placement_scope_e2e_test.go uses; the reach is pinned by internal/seed/role_reach_test.go and the role’s own description says so.
  • admin adds platform:*, the install-wide authority a write at the cascade’s platform tier needs (below); operator and deploy hold no platform capability, so an all-scoped operator runs every site without being able to change the install-wide value under them. It holds secret:> and file:> (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 @ all grant: a scoped admin @ subtree keeps the operator powers within its subtree and gets no IAM. Registry curation is a plain capability, so a custom role can carry <registry>:create alone 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 delete official roles.
  • owner is break-glass and unkillable: at least one active owner@all grant 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.

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 permission
component:create,update <- expands to component:create and component:update
alarm:acknowledge <- a domain verb alongside CRUD
property_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,update
child: component:delete
child effective: component:create, component:update, component:delete

There 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).

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.

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.

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 tier
variable:update + platform:update <- change the install-wide value of a variable
tag:update + platform:update <- POST /tags/{name}:setPlatform
settings:update + platform:update <- every settings write, which is install-wide by definition

A 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.

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'; -- old
COMMIT; -- 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”).

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.

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).

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:

Client: SPA / CLI / MCPAPI process (one binary)Storage Gateway: the only DB doorNATS KV cachegrants plus role indexCDC-invalidatedRoute middlewaregated(op, 'alarm', 'ack')action inANY grant?403 capability missingHandlercanDo(P, ack, X) ?403 cannot act on target404 non-disclosinginject visible_set(P, ack)plus audit_log in one txnPostgres200 plus action row noyes: fast-reject passedreadable, not ack-scopeout of read-scopeparameterized predicate1 row changedPOST /alarms/X:ackyes0 rows: backstop firescomposed per request

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 uses visible_set(P, read), a write the write-action’s set, never a global union. An :ack outside visible_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}:acknowledge is one of eighteen) resolves that target through the gateway carrying visible_set(P, read) to decide whether the caller may know it exists, and visible_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 in visible_set(P, read) but outside visible_set(P, action) -> 403; (c) target outside visible_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/me family and GET /settings/me, and POST /nodes:claim, the API’s one public write (safe unauthenticated because the enrollment token in its body is the credential, minted once by a node: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 an x-omniglass-permission stamp 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:

  1. Middleware fast-reject: alarm:acknowledge appears in a grant (the operator @ component-A one), so it passes; fast-reject cannot see that the acknowledging grant does not cover X.
  2. 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-wide viewer grant carries no acknowledgement and widens nothing. This is the load-bearing part: resolving the scope from component:read (which P holds everywhere) or from component:update (which a pure responder role need not hold at all) would answer the wrong question in opposite directions.
  3. Status: B is outside the acknowledgement’s set, but P holds viewer @ all and so can READ B: the target resolves in visible_set(P, read) and fails only the action half, and P gets a 403. Naming the refusal discloses nothing, because P can GET that 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).
  4. 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.

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, so canDo cannot 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 whose scope_kind is all; a scoped grant confers no IAM capability. Typically owner @ all / admin @ all. (A credential variable is different: entity-scoped, so its secret:read decrypt and rotation are ordinary scoped actions, config and credentials.)
    • Data registries (property_type, tag, unit, event_type, severity_level, source): a distinct <registry>:create curator capability (property_type:create, tag:create, event_type:create; the rest take the same shape when they land). No owner entity, so scope_kind is irrelevant, and a curator role mints entries without IAM admin. A minted entry carries its own scope (an org-scoped entry shadows an official one, the namespace-shadow pattern); official-scoped entries are reserved to owner and the boot seed.
    • Type registries (location_type, secret_type): each speaks its own permission word, never a shared generic one. The location_type registry carries its own resource with the full verb set (location_type:read, :create, :update, :delete); the read rides viewer’s *:read floor, the writes go to admin, and the property contract (/location-types/{id}/properties) hangs off the same resource (location_type:update declares, location_type:delete withdraws). The secret_type registry is read-only reference data behind secret: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-shadow scope: an official row is read-only (create/update/delete 422), an operator row is editable, and deleting a location_type still referenced by a location is refused (409, a Gateway pre-count with the parent foreign key as backstop). The shipped location types seed as official: false (the seed model); the shipped secret types are seed-owned and official: 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 its product, delete-guarded by the component.product_id restrict FK); a system conforms to a standard, which is why standard graduated out of the type registry (ADR-0048). Each catalog’s property contract rides its owner’s permission (product:update, standard:update).

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 ResolveTagsfor_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 by CreateComponent, CreateSystem, MoveComponent, and MoveSystem for 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 :renderLabel draft 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 row PUT /systems/{name}/members/{component} writes, so it resolves in system:update and 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: operator holds component:create and no system:* at all, so it maintains components and a deploy tech 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 :renderLabel draft 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 in system:read alone 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 web app (and any CLI client) gets the principal + their effective permissions in one call:

GET /api/v1/auth/me
{
"principal": { "id": "...", "kind": "human" },
"human": { "username": "jordan", "email": "[email protected]", "label": "Jordan Rivera", ... },
"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.

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.

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.

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.

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.

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.

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.

principal (identity): generated from the migrated schema by make gen
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])))
human (identity): generated from the migrated schema by make gen
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)
service (identity): generated from the migrated schema by make gen
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.

role (identity): generated from the migrated schema by make gen
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.

principal_grant (identity): generated from the migrated schema by make gen
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)