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).
What it governs
Section titled “What it governs”- 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 aPrincipalKindclassifying it (Human/Service/Dev), carried on theTenantContext. - 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.
Authentication — who is calling
Section titled “Authentication — who is calling”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
TenantContextis the isolation concept. It carries the resolvedActorRefand is threaded through the whole substrate interior — state lookups, dispatch, the event tail — exactly as before this plane existed. - The
AuthorizationContextis 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 — what they may do
Section titled “Authorization — what they may do”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.
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.
Runtime tokens, end to end
Section titled “Runtime tokens, end to end”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:
- 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 holdingTokenAdminandSessionReadcannot mint itself aConfigAdmintoken. Delegation is therefore monotone: a token’s scopes can only narrow relative to its issuer. - Authenticate works under any backend. A decorator installed
unconditionally over the configured
SessionAuthroutes an inboundamt_-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 devnonebackend 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. - 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
Serviceprincipal 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.
Where identity lands
Section titled “Where identity lands”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.
Guarantees
Section titled “Guarantees”- 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.
The dev posture and what comes next
Section titled “The dev posture and what comes next”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.
Endpoints are discovered, not configured
Section titled “Endpoints are discovered, not configured”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.
Where this shows up
Section titled “Where this shows up”- Operate: the local stack selects the auth profile; the console signs in and surfaces the caller’s identity and scopes.
- Build: SDK and REST callers present credentials per call — see the Python SDK and REST & SSE guides.
- Reference: the backends live in
apomesh-auth-providers; the principal + scope vocabulary inapomesh-substrate-types; the prologues + token workflows inapomesh-orchestratorandapomesh-rest-api; the wire shapes onapomesh-protocol; grant mapping inapomesh-config.