The Subject
ctx.subject — what's on it, the anonymous fallback, and how to pattern-match on the type discriminator.
Every server-side executor receives ctx.subject — the typed identity of whoever is calling. It's always present (anonymous requests get an explicit anonymous subject), so app code never has to null-check before reading basic fields.
Shape
type Subject =
| { type: 'user'; id: string; tenantId: string; scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
| { type: 'apiKey'; id: string; tenantId: string; scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
| { type: 'serviceAccount'; id: string; tenantId: string; auditActorId?: string | null; scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
| { type: 'system'; id: string; tenantId: null; scopes?: ReadonlyArray<string>; metadata?: Record<string, unknown> }
| { type: 'anonymous'; id: null; tenantId: string | null }scopes is the permission currency — the auth strategy that resolved the subject puts the computed scopes here, and @voltro/plugin-rbac compiles a caller's roles onto the same set. There is no roles field on the wire; roles are an authoring convenience that resolve to scopes.
The type discriminator narrows downstream fields:
if (ctx.subject.type === 'user') {
// ctx.subject.id, tenantId are strings
}
if (ctx.subject.type === 'anonymous') {
// ctx.subject.tenantId is string | null; id is null
}Audit identity after tenant narrowing
tenantScopedSubject preserves the application audit actor separately from the
machine identity as auditActorId. System work narrowed to a tenant carries
explicit null; it must not stamp a fictitious system row into the app's
actors foreign key. Users retain their actor ID, and API keys retain their
existing acting-user attribution. subjectAuditActorId is the shared resolver
used by the runtime's audit writer. The field survives SubjectIdentity
serialization but grants no permissions. Authentication code establishes it;
do not copy an untrusted request field into it. Ordinary service accounts without
an explicit attribution use their own ID. This does not by itself fix Cadence's
still-open scheduler tenant placement.
Tenant narrowing preserves only scopes already held by the caller. Missing
scopes mean an empty set, not admin:full; a persisted workflow identity must
pass through authority resolution before it can regain permissions. The helper
does not authorize switching tenants: the calling boundary must check that too.
App metadata — subjectFromUser(user, { metadata })
metadata is the free-form slot the framework itself never reads. It is where a provider credential captured at login belongs — a plugin's credentialsResolver reads it back per request (@voltro/plugin-atlassian looks for subject.metadata.jiraToken, say), so nothing has to be re-fetched or stored server-side per call.
Pass it when you build the Subject:
import { subjectFromUser } from '@voltro/plugin-auth'
const subject = subjectFromUser(user, {
memberships, // → metadata.memberships
metadata: { jiraToken: atlassianPat }, // → metadata.jiraToken
})The two merge — neither clobbers the other. When a memberships key appears in both, the dedicated memberships option wins: it is the typed input, and it is the one projected into the { tenantId, role } shape subjectMemberships() and the tenant switcher read. Without the option, a memberships key inside metadata passes through unchanged. A Subject built with neither option has no metadata key at all.
Keys set this way survive the login paths: the sign-in / sign-up / magic-link / passkey handlers merge sessionId onto the existing slot, and the password strategy merges provider — they add, they don't replace. The one exception is naming a key sessionId or provider yourself; those two are overwritten by design.
It survives a tenant switch too. A switch rebuilds the Subject from the user record, so the built-in /switch-tenant route passes the caller's current subject.metadata through to handleSwitchTenant — a credential parked here keeps working after the user changes tenant. Calling handleSwitchTenant yourself? Pass metadata or the credential is dropped, and the symptom is unpleasant to diagnose: the user stays signed in while every call to the provider starts failing. memberships is deliberately not carried — it is re-derived for the target tenant, and a carried copy would report a role the user does not hold there.
Two things not to put here. Anything the caller could benefit from changing — the slot rides the signed session cookie, so it is tamper-evident, but it is also stale by design: it reflects the moment of sign-in, not the current database. And anything large — it is re-serialised into every session cookie.
Resolution
AuthMiddleware resolves a Subject on every request by running the strategy chain — composeAuthStrategies evaluates each strategy in order, first matched wins, first failed rejects with Unauthenticated (unless an app-owned fallback explicitly decides otherwise). A typical chain resolves, in order:
- Built-in password cookie —
voltroPasswordStrategyreadsvoltro:sessionand HMAC-verifies it. →type: 'user'. - API key —
apiKeyStrategyfrom@voltro/protocol/apikeymapsAuthorization: Bearer <prefix>_<token>→ a scopedapiKeysubject. This ships today; add it to theauth.strategieschain inapp.config.ts. →type: 'apiKey'. - In-process system calls — code running under
runAsSystem(cron, workflows, backfills) carries asystemsubject; it's never produced from an inbound request. →type: 'system'. - Anonymous — nothing matched. What the caller is now is the app's decision,
auth.anonymousinapp.config.ts: refused, tenant-less, thex-tenantheader's tenant, or one fixed tenant (below). →type: 'anonymous'.
Only under 'header' does an anonymous request with x-tenant: <id> carry that tenant — and the header is unauthenticated: never trust it for writes, and never make a member-only procedure openAccess on such an app.
Common patterns
Require sign-in
import { Schema } from 'effect'
class Unauthorised extends Schema.TaggedError<Unauthorised>()('Unauthorised', {}) {}
export default async (input, ctx) => {
if (ctx.subject.type !== 'user') throw new Unauthorised()
// ctx.subject.id is narrowed to string
}Require sign-in + specific tenant
import { assertOwnTenant } from '@voltro/plugin-multitenancy'
export default async (input, ctx) => {
if (ctx.subject.type !== 'user') throw new Unauthorised()
assertOwnTenant(input.tenantId, ctx.subject)
// …
}Allow system OR user
const canTrigger = ctx.subject.type === 'user' || ctx.subject.type === 'system'
if (!canTrigger) throw new Unauthorised({})RBAC
@voltro/plugin-rbac builds on the subject's scopes. Roles compile to scopes — the plugin resolves the caller's roles, flattens them onto the resolved scope set, and the permission() handler guard checks it in one line:
import { permission } from '@voltro/plugin-rbac'
yield* permission(ctx, 'admin:full') // Effect<void, ScopeError>See the RBAC plugin for the full model.
Anonymous callers — the app's decision
A request that matches no strategy still resolves to a Subject, and that Subject's tenantId decides what every tenant()-scoped table and every openAccess procedure answers. Left to the request, the x-tenant header would choose it: any client could read any tenant's public-per-tenant rows, and a procedure that is openAccess for a tenant's members would answer for whichever tenant the header named. So app.config.ts decides once, and voltro serve refuses to boot until it has:
// app.config.ts
export default {
type: 'api' as const, name: 'api',
auth: { anonymous: 'no-tenant' },
}auth.anonymous |
An unauthenticated caller is… | Use it for |
|---|---|---|
'refuse' |
refused with Unauthenticated |
an api with no public surface at all |
'no-tenant' |
anonymous with the empty tenant '': every tenant-scoped read is empty (fails closed); tables without tenant() and procedures that need no tenant answer |
token-shaped public surfaces — an invite preview, a signed link, a sign-out |
'header' |
anonymous in the tenant the request's x-tenant header names; a request without the header is refused |
public-per-tenant data — a marketing site per tenant, public listings by slug. Every openAccess procedure and every table without tenant() is then public for a client-chosen tenant, so nothing member-only may be openAccess on such an app |
{ tenantId: 'public' } |
anonymous in that one tenant, chosen by the server; the header is not consulted | a public surface that belongs to one tenant |
Never a null tenant: to the store a null tenant is a system subject and reads unscoped, across every tenant. 'no-tenant' carries '' for that reason.
voltro dev runs an app that has not decided on the development convenience — the x-tenant header's tenant, else the dev tenant (TENANT, else acme) — and says so at boot. voltro serve refuses instead, naming the four choices. Once decided, both boot paths run the same policy.
The same field goes to composeAuthStrategies(strategies, { anonymous }) when you compose the chain yourself (its default is 'no-tenant') and to makeTestApp({ anonymous }) (its default is the dev convenience). An app-owned fallback replaces the policy entirely: that function owns the anonymous subject and the rejected-credential decision.
A rejected credential is never anonymous. "No credential" and "a bad credential" are different callers: the first may read what the public may read, the second is told to sign in again. When a strategy rejects the credential it was handed (an expired session, a malformed JWT, a revoked key), the resolver throws Unauthenticated with credential rejected: <reason> — under every auth.anonymous policy alike. It used to fall into the anonymous branch marked credentialRejected, which every surface then had to remember to check. Only an app-owned fallback still receives that mark, because that function owns the decision.
Tenant selection — switching tenants for any credential
A subject that belongs to several tenants acts in ONE at a time. The auth plugin's POST /auth/switch-tenant re-issues its session cookie with the new active tenant — which does nothing for a subject whose credential is minted elsewhere: an external IdP's JWT names the tenant it names, an API key names the tenant it was issued for. Tenant selection is the same switch as a primitive on the Subject, independent of the strategy that produced it: the selection is stored beside the subject (_voltro_tenant_selections, a framework table), verified against the app's memberships on every request it applies to, and applied after the strategy matched and before auth.resolveScopes — so a role resolver sees the subject in the tenant it will act in.
Configure the ONE source of memberships in app.config.ts; the auth plugin's is authMemberships(userStore), an IdP project supplies its own:
// app.config.ts
import { authMemberships } from '@voltro/plugin-auth'
export default {
type: 'api' as const, name: 'api',
auth: {
// Read only when a stored selection names a tenant other than the credential's.
memberships: authMemberships(userStore),
// or, from your own directory:
// memberships: async (subject, { store }) => listTenantsOf(store, subject.id),
},
}Switching is three lines in a mutation — the same rule the resolver applies, then one row:
// tenants.switch.mutation.server.ts
import { selectTenant } from '@voltro/runtime'
export default async (input: { tenantId: string }, ctx) => {
const memberships = await listTenantsOf(ctx.store, ctx.subject.id)
// Throws `TenantSelectionRefused` (`not-a-member` | `subject-has-no-tenant`) — nothing is written then.
return selectTenant(ctx.store, ctx.subject, input.tenantId, memberships)
}What holds, on both boot paths:
- The next request is in the selected tenant — whatever credential it presents. There is no staleness window: the selection is one point read per request, and the memberships are consulted only when a selection exists that differs from the credential's tenant.
- Revoking the membership revokes the selection. A stored tenant the source no longer lists is ignored and deleted; the subject proceeds in the tenant its credential names.
- A selection that cannot be read fails closed with
Unauthenticated(tenant selection unavailable: …) — serving the credential's tenant instead would be the wrong-tenant answer with no symptom. - The auth plugin's
/auth/switch-tenantwrites the same row beside its cookie, so a password user and an IdP user switch through one rule (selectSubjectTenantin@voltro/protocol) and one table. anonymousandsystemsubjects have nothing to select and are never looked up;membershipsand the selection store come as a pair, and the resolver refuses to compose with only one of them.
Custom resolvers
For exotic auth setups (mTLS, custom JWTs from an upstream gateway) write a custom AuthStrategy and add it to the auth.strategies chain in app.config.ts — there is no runtime.resolveSubject field:
// app.config.ts
import { anonymousSubject } from '@voltro/protocol'
export default {
type: 'api' as const,
name: 'myApi',
auth: {
strategies: [
{
id: 'mtls',
resolve: (input) => {
const cn = input.headers['x-client-cert-cn']
return cn
? { kind: 'matched', subject: { type: 'system', id: `mtls:${cn}`, tenantId: null } }
: { kind: 'skip' }
},
},
],
},
}The built-in password strategy still runs first; your strategy adds to the chain. Most apps never need this.
ctx.subject in subscriptions
ctx.subject is captured at subscribe-time. If the cookie expires mid-subscription:
- Subsequent mutations from the now-expired client fail with
Unauthorised. - The subscription itself continues to stream until the client reconnects or the server drops it.
- On reconnect, the new connection re-resolves the subject — anonymous if the cookie is gone.
The open stream isn't force-killed on expiry; the next write fails with Unauthenticated and the client can re-auth or reconnect. That gentler UX is the framework's behaviour.
Anti-patterns
- Reading
ctx.subject.idwithout narrowing. It'sstringonuser/apiKey/serviceAccount/system, butnullonanonymous. Narrow ontypefirst. - Trusting
subject.tenantIdfor writes. It's read-only context. AlwaysassertOwnTenant(input.tenantId, ctx.subject)for any write that takes a tenant. - Caching subjects across requests. The subject is request-scoped; sessions expire, role membership changes. Resolve fresh each time.