Skip to content

Identity & authorization

Knowing a call belongs to a tenant is not enough. You also need to know who within that tenant is calling, and what they are allowed to do. This plane answers both, as two separate steps. Authentication resolves a principal — a caller identity plus what kind of caller it is. Authorization checks that principal’s granted scopes against the scope the requested operation requires. Both steps fail closed: an unrecognized credential authenticates nobody, and a caller without the required scope is denied.

apomesh does not introduce a parallel identity system to do this. It completes the plugin auth plane the substrate already shipped — the same SessionAuth backends that resolved a tenant now resolve who within the tenant and what they may do. There is no substrate-owned user table: human principals are derived from an identity provider’s token claims, service principals from a key binding or a certificate subject, and the only principals the platform persists are runtime tokens, which exist as rows by necessity (they are minted, hashed, and revoked).

  • Authentication — validating a caller’s credential through one configured backend and resolving the caller’s identity.
  • The resolved principal — an ActorRef (the subject label) plus a PrincipalKind classifying it (Human / Service / Dev), carried on the TenantContext.
  • Authorization — a closed vocabulary of Scopes at RPC-class grain, enforced only at the trust boundary.
  • Runtime tokens — platform-minted, scope-bound service credentials that authenticate under any backend.
  • What it does not govern: the tool/service secret plane (that is the credential plane — secrets, never principals); and worker-to-orchestrator identity, which is a distinct transport-layer boundary covered under Deployment & isolation.

A call authenticates through exactly one configured SessionAuth backend. Four ship with the platform — the no-auth dev fallback, a pre-shared API key, OAuth 2.0 / OIDC (a JWKS-validated JWT), and mutual TLS — and multiple can coexist in one deployment for different client classes. The backends themselves are documented in the auth-providers crate; what matters here is the shape they all resolve to.

Every backend returns the same typed result: an AuthenticatedCaller, which carries two threads that are deliberately kept apart downstream.

  • The TenantContext is the isolation concept. It carries the resolved ActorRef and is threaded through the whole substrate interior — state lookups, dispatch, the event tail — exactly as before this plane existed.
  • The AuthorizationContext is the entry-gate policy concept. It carries the caller’s granted scopes and is consumed only at the boundary (below), never in the interior.

The principal is the ActorRef plus its PrincipalKind. The kind says what sort of subject a backend resolved: Human for an interactive OAuth/OIDC operator (the subject is the token’s sub claim), Service for a non-interactive account (an API key’s bound principal, an mTLS certificate’s subject, or a runtime token’s principal label), and Dev for the no-auth fallback (NullSessionAuth resolves the fixed dev subject so that even the zero-config path carries an honest, non-null principal). PrincipalKind is a closed enum — a new kind lands additively and breaks the build at every match site rather than slipping through a wildcard.

Grant resolution is config-mapped per backend: an OAuth roles claim, a per-key grant list, a per-certificate grant list. A non-dev backend with no grants mapping is a boot error that names the missing config, never a silent grant of everything — the same secure-by-default posture the secret-forwarding policy takes. The dev Dev principal is the one deliberate exception: it resolves to tenant-admin (every scope) so a fresh clone runs without an identity provider. Mapping the grants lives in the config crate.

Authorization is a closed vocabulary of scopes at RPC-class grain, not a set of ad-hoc permission strings. Every driver-facing RPC and every protected REST route maps to exactly one Scope, and related operations share one: listing agents, skills, and tools all require CatalogRead; every mutating catalog operation requires CatalogAdmin. The vocabulary spans the platform’s planes — SessionStart / SessionRead / SessionControl, HitlRespond, CatalogRead / CatalogAdmin, RegistryRead / RegistryPublish, ConfigRead / ConfigAdmin, MemoryRead / MemoryWrite, and TokenAdmin.

Closed is the point. A finite, exhaustively-known scope set means the gate can never be handed a permission it does not understand, a new RPC cannot ship ungated (a compile-time coverage canary fails the build if a driver-facing RPC or protected route has no scope mapping), and no consumer can invent authority by concatenating a string. Finer-grained skill- and agent-level scopes are deliberately absent — that vocabulary belongs to the future A2A protocol increment, and the scope carrier is shaped to gain it without reshaping the enum.

Each scope is classified Read or Write (a ScopeClass), and that split selects what a denial looks like — see the enforcement section.

Every call authenticates to a principal, then the scope gate decides — permit into the tenant-isolated workflow, or fail closed. The gate fires before any lookup.

Enforcement lives only at the trust boundary

Section titled “Enforcement lives only at the trust boundary”

The scope check runs at exactly two places — the transport prologues — and nowhere else. On gRPC it is the daemon’s authenticate_grpc prologue; on REST/SSE it is the oauth_extract middleware. Both do the same thing: extract the credential, call the same SessionAuth plugin instance, split the resulting AuthenticatedCaller, consume the AuthorizationContext against the route’s required scope, and thread only the TenantContext inward. The substrate interior is never scope-checked; a deep path re-verifying a scope would be a second enforcement point that drifts from the first.

This concentration is deliberate, not incidental. Sprinkling authorization through the substrate would mean every new interior call site is a place to forget the check, and the two concepts — isolation and entry-gate policy — would blur. Keeping the gate at the boundary means one audited location owns “may this caller do this,” while the interior owns only “which tenant’s data is this.” An unmapped protected route fails closed rather than falling through.

Denial follows the read/write split, so that authorization can never become an existence oracle:

  • A denied write/act scope returns a fixed PermissionDenied — the same opaque body every time, with the reason recorded only in the server-side audit log.
  • A denied get-shaped read returns a not-found, and a denied list read returns an empty collection — the same shapes a genuinely absent resource or an empty tenant would produce.

Because the gate fires before any lookup, a denied caller receives one constant response for every resource behind that RPC and can never tell an existing resource from a missing one. The only thing a denial reveals is that the caller lacks the scope — which the WhoAmI self-introspection surface will tell that same caller on request, so it is not a disclosure. This reuses the audited tenant-isolation outcome discipline; the cross-tenant story is in Deployment & isolation.

A runtime token is a platform-minted, scope-bound service credential — the right shape for a CI job, an SDK client, or a future peer that has no interactive identity flow. It is an opaque amt_-prefixed value with 256 bits of entropy, shown once at mint and never retrievable again: the store keeps only its SHA-256 digest, so a full dump of the token table authenticates nobody.

Its lifecycle is a single arc:

  1. Mint requires TokenAdmin, and it enforces one domain invariant — the requested scopes must be a subset of the minter’s own grants. Privilege can be delegated, never manufactured; a caller holding TokenAdmin and SessionRead cannot mint itself a ConfigAdmin token. Delegation is therefore monotone: a token’s scopes can only narrow relative to its issuer.
  2. Authenticate works under any backend. A decorator installed unconditionally over the configured SessionAuth routes an inbound amt_-prefixed bearer value to the token store and everything else to the backend, so a deployment running OIDC for humans can still mint service tokens, and even the dev none backend validates a real token. This is format routing of an untyped inbound string — choosing which parser claims a wire value by its self-declared prefix — not the credential plane’s prohibited byte-sniffing of an already-typed secret.
  3. Bind to the tenant on the token’s own row, never to whoever presents it. At authentication no tenant is known yet — the credential is what establishes it — so the token resolves to a Service principal in exactly its row’s tenant.

Revocation is a hard delete: a revoked token and a token that never existed take the identical row-absent path and return the identical failure, so revocation cannot be used to probe whether a token ever existed. An expired token is the one distinct signal — its row is retained so authentication can answer Expired (which an SDK refreshes on) rather than the generic invalid-credential message (which it would escalate on). Runtime tokens never touch the credential plane, the event log, a checkpoint, or agent-readable content.

The resolved principal is an operator-facing identity, not a secret, and it surfaces exactly where an audit trail needs it — never on an agent-readable path. When a human answers a human-in-the-loop pause, the responder’s subject is stamped onto the event-log row server-side from the authenticated call, never taken from the wire payload, so no caller can impersonate another by forging a field. Minting and revoking a runtime token, and every scope denial, emit structured server-side audit lines keyed by tenant and subject. The principal’s subject label rides the TenantContext and reaches the event log through that one stamping path — never an agent’s prompt, tool arguments, or working memory. Because it is always resolved on a TenantContext, the principal is inseparable from the tenant it belongs to — the same context that gates every state lookup and dispatch.

  • Every authenticated call carries a resolved principal. All four backends and the runtime-token path produce an ActorRef + PrincipalKind; there is no authenticated call without a caller identity.
  • Authorization fails closed. A protected operation runs only if the caller holds the required scope; an absent grant, an unmapped route, and a missing-mapping boot all deny rather than permit.
  • Denials are not existence oracles. A denied read is shaped like absence and the gate fires before any lookup, so a caller cannot probe for resources it may not see.
  • Runtime-token delegation is monotone. A minted token’s scopes are always a subset of the minter’s; privilege narrows, never widens. Tokens are hashed at rest and shown once.
  • Identity is stamped server-side. The subject on a HITL decision or an audit line comes from the authenticated call, never the request body, and never reaches agent-readable content.

The scope vocabulary and principal kinds are closed enums under the substrate’s closed-enum discipline; tenant binding and the server-side stamping are Invariant-tier contracts. See Sessions, events & durability for how the audit trail is sourced from the one event log.

Locally, the default none profile runs NullSessionAuth: every call resolves the dev principal with tenant-admin, so you exercise the full surface with no identity provider. The oauth profile swaps in a Zitadel-backed OIDC backend, and the console signs in over a PKCE authorization-code flow (a system-browser round-trip with a loopback redirect, carrying a code_challenge and code_verifier, never a client secret) — see The local stack for the profile switch and the operator console for sign-in.

The daemon reads the IdP’s discovery document at <issuer_url>/.well-known/openid-configuration and takes the JWKS endpoint from it, rather than asking an operator to configure an endpoint that the IdP already publishes. One issuer URL is the whole configuration.

jwks_uri remains as an explicit override — named for the discovery metadata field it stands in for — for an air-gapped deployment or an IdP that publishes no discovery document. Setting it is the opt-out; there is no separate “use discovery” switch that could disagree with it. Where a backchannel base URL is configured, an overridden URI is rebased onto it exactly as a discovered one would be.

One direction is named but not yet built: a runtime grants store, moving per-caller grants from static config into a tenant-admin-managed table, the way runtime tokens already work. It is additive — the scope carrier is shaped to absorb it without reshaping the vocabulary.