Skip to content

Choreography 1 — Auth & identity

How a Protocol client authenticates, which scopes exist, and how the multi-isolation triple (tenant, user, session) flows through every request.

Methods demonstrated: start, sessions.list, auth.rotate_token

The two-part model: the token says WHO, the header says WHICH conversation

The connection token is a per-backend credential, like an API key. It authenticates (tenant, user) plus the connection's scopes. It does not pin a session. The session is dynamic — chosen per-request via the X-Harbor-Session header, always scoped under the token's verified (tenant, user):

text
Authorization: Bearer <connection-token>
X-Harbor-Session: <conversation-session-id>

Rules the Runtime enforces at the auth middleware, fail-closed:

  • X-Harbor-Session present and non-empty → it replaces the token's session claim. Tenant and user stay token-verified.
  • Header absent → the token's session claim is used as a back-compat default (the dev token carries session: dev for exactly this).
  • Neither a header nor a default claim → 401 identity_required. The session is mandatory; identity fails closed — there is no silent default.
  • The header can only ever set the session. A client can never widen its tenant or user — there is no header override for those on the authenticated path.

New conversation = new session id = create-on-first-use. Pick a fresh id (a UUID is recommended; any non-empty string works) and issue a start with it — the Runtime materialises the session row on the first turn. There is no explicit "open session" call. A start on an already-open session is the normal second-and-later turn (not an error); a start on a closed session (explicitly closed or GC-reaped) reopens it — the conversation resumes with its history intact. The one exception is a session that was permanently deleted by sessions.delete (right-to-erasure): a start on an erased session id is rejected session_erased — the conversation is gone; start a new one with a fresh session id.

Many sessions coexist under one token, fully isolated: every storage read and event delivery filters by the complete (tenant, user, session) triple. One user driving five concurrent conversations gets five disjoint worlds.

The JWT

Asymmetric algorithms only: RS256 / RS384 / RS512 / ES256 / ES384 / ES512. HS* and none are rejected at the algorithm allowlist before the keyfunc is ever consulted. The verified claims:

ClaimRole
tenantverified — the connection's tenant
userverified — the connection's user
sessiondefault only — used iff X-Harbor-Session is absent
scopesthe connection's elevated scopes (may be empty)
iss / aud / exp / nbf / kidstandard JWT validation against the Runtime's identity: config

The body of a request may also carry an identity object (IdentityScope). The body scope is INPUT, not authority: the authority is the identity the Runtime verified for the request, and one shared gate reconciles the two before any handler acts.

The simplest correct client sends "identity": {} and relies on the header — the Runtime backfills the body from the verified request identity. When the body IS populated, the reconciliation is:

Body componentRule
user, sessionMust equal the verified identity on every surface but one; a contradiction is 401 identity_required. The exception is state.history, which reads another identity's whole timeline as a single unit — see the tenant row.
tenantMust equal the verified identity, EXCEPT on the surfaces that offer a cross-tenant read (below). Elsewhere a differing tenant is 401 identity_required whatever your claims.

The surfaces that offer a cross-tenant read, the claim each takes, and what a refusal looks like:

SurfaceBody components it will crossClaimRefused without it
artifacts.listtenant, useradmin or console:fleet403 scope_mismatch (tenant) / 403 identity_scope_required (user)
artifacts.put / artifacts.deletetenantadmin only403 scope_mismatch
artifacts.get_ref— (crosses for nobody)403 scope_mismatch
the seven posture reads (runtime.*, metrics.snapshot, governance.posture, llm.posture)tenantadmin or console:fleet403 scope_mismatch
topology.snapshottenantadmin only403 scope_mismatch
state.historytenant, user and session — a whole cross-identity readadmin only404 not_found
the five search.* methodsfilter.tenant_ids, filter.user_ids (and a MULTI-value filter.session_ids)admin or console:fleet403 scope_mismatch

Two of those rows are easy to get wrong, so branch on them deliberately. state.history is the only surface where a body naming another user and session is granted rather than refused — reading another identity's timeline is the point of the method. It is also the only one that answers a missing claim with 404 not_found rather than 403 scope_mismatch: the refusal is deliberately indistinguishable from "that session does not exist", so a caller without the claim learns nothing about which sessions are real. Do not treat a 404 from state.history as proof the session is gone.

Writes take admin alone: artifacts.put, artifacts.delete and topology.snapshot refuse a read-only console:fleet token, which reaches artifacts.list and the posture reads but neither deposits nor destroys. artifacts.get_ref crosses for no claim at all — a presigned reference is a time-bounded bearer capability to the CONTENT, materially broader than the metadata a listing returns.

An artifact listing is scoped to your own user

artifacts.list treats its scope's user and session differently, and the difference is worth sending deliberately:

  • user is an isolation principal. Omit it and the listing folds to YOUR user — not to every user in the tenant. Name somebody else's and you are refused 403 identity_scope_required unless you hold admin or console:fleet; with either claim, naming a user reads that user and omitting it fans across the whole tenant.
  • session is a filter, not a boundary. Omit it and you get artifacts from every session of your own; name one of your own sessions and it narrows. No claim is involved either way, because the user fold above already decides whose artifacts you can see.

So the ordinary "show me my artifacts" call is {"scope": {"tenant": "..."}} and it needs no claim. The tenant-wide catalog is the same call with a claim. The events.list filter draws the same line on the same two axes.

Every granted crossing publishes audit.admin_scope_used naming the verified caller.

A search is scoped to your own user too

The five search.* methods draw the same line on their filter, and it is worth sending deliberately because the everyday call omits the axis entirely:

  • tenant_ids and user_ids are isolation principals. Omit either and the search folds to YOUR tenant / YOUR user — not to every user in the tenant. Name somebody else's, or name more than one, and you are refused 403 scope_mismatch unless you hold admin or console:fleet; with either claim, naming a user reads that user and omitting it fans across the tenant.
  • session_ids is a filter, not a boundary. A single value — including one of your OWN other sessions — needs no claim, because the user fold above it already decides whose rows are in play. More than one value is a fan-in and takes the same claim as the other axes.

So the ordinary "search my stuff" call is {"query": "..."} with no filter at all, and it needs no claim. artifacts.list and the events.list filter draw the same line on the same axes.

The refusal code differs from those two surfaces — search.* answers 403 scope_mismatch where they answer 403 identity_scope_required. Both are 403 and the class of refusal is identical; the divergence is search.* keeping the code its tenant axis has published since the surface shipped. Branch on the status, or on either code, but do not assume one code covers all three surfaces.

The body's run and scope fields are independent of the token and survive the backfill (they parameterise steering — see task control).

The scope vocabulary (closed set)

Two elevated scopes exist. There is no third; per-surface scopes (tools.admin, events.crosstenant, …) are deliberately not minted:

ScopeGrants
adminCross-tenant fan-in on every read surface that has one; the admin-gated mutations (artifacts.delete, memory.put / memory.delete, the mcp.servers.* and tools.* admin verbs, the agents.* fleet-control verbs, flows.run, auth.rotate_token); admin impersonation.
console:fleetFleet observation: cross-tenant fan-in on events.subscribe / events.aggregate, the five search.* methods, sessions.list, artifacts.list, memory.list, and the seven posture reads (runtime.*, metrics.snapshot, governance.posture, llm.posture). It is observation-only — it satisfies no mutation gate, and the admin-only fan-ins (tasks.list, pause.list, topology.snapshot, flows.list / flows.runs.list) do not consult it. The per-method Auth column in the methods reference is the authoritative row-level map.

Distinct from both: the steering scope (session_user / owner_user / admin) — the privilege tier each control is checked against per its RFC §6.3 minimum. It is derived from your verified token, never read from a request body: the Runtime compares your token's (tenant, user) and admin claim against the target run (admin → cross-tenant + every control; you own the run → owner_user, which covers inject_context / user_message / cancel / pause / resume / redirect / approve / reject; otherwise no authority). A prioritize or any cross-tenant control needs admin. The body's scope field is ignored. See task control.

Dev bootstrap vs production posture

Devharbor dev mints an ephemeral ES256 keypair at boot and serves POST /v1/dev/bootstrap.json (loopback-only — non-loopback peers get a flat 403 regardless of headers; the request Host must also name a local authority such as localhost or 127.0.0.1, with an optional port). The minted token carries tenant=dev / user=dev / session=dev (default) and the full scope set ["admin", "console:fleet"]. It is printed at boot as HARBOR_DEV_TOKEN= and returned by the bootstrap envelope. The quickstart uses it.

To mint a lesser-privileged token for testing the steering authorization contract — a token for a chosen identity with no admin scope — POST an optional override body to the same loopback endpoint:

bash
curl -sS -X POST http://127.0.0.1:18080/v1/dev/bootstrap.json \
  -d '{"tenant":"dev","user":"dev","session":"dev","scopes":[]}'

An explicit empty scopes array mints a token with no scopes (a non-admin token); a full (tenant, user, session) triple overrides the identity. An empty {} body keeps the default admin token, so the one-click attach flow is unchanged. This override is dev-only — harbor serve does not mint tokens.

The four members above — tenant, user, session, scopes — are the only ones this body accepts, and anything else is refused with a 400 naming it rather than discarded. That matters because the endpoint mints a credential: a misspelled scope would otherwise be dropped and answer 200 with the default admin token, and a snake-cased tenant_id would answer 200 with a token for a different identity. The identity members are spelled the way the Protocol spells an identity scope everywhere else — tenant / user / session, not tenant_id / user_id / session_id, which are record-type field names.

Production — the Runtime validates tokens against your OIDC provider via the identity: config block (issuer, audience, jwks_url, jwt_algorithms). Your identity provider mints the tokens; the bootstrap endpoint does not exist on harbor serve. An admin-scoped operator can rotate their own token via auth.rotate_token (one-time reveal; every rotation emits a redacted audit event). The end-to-end setup — registering an OIDC app, mapping the (tenant, user, session) + scopes claims, the iss/aud exact-match contract, the mint-and-test loop, and the no-IdP harbor token self-issuing on-ramp — is the production identity setup guide.

What 401 / 403 mean (branch on code, not on the status alone)

Four distinct rejections, one error envelope each (errors reference):

HTTPcodeMeaningFix
401identity_requiredNo identity resolved: missing bearer, missing session, or a body identity contradicting the token on a component no claim widens.Attach the token / the X-Harbor-Session header; send "identity": {}.
401auth_rejectedA token was present but failed verification (bad alg / signature / expiry / kid / audience / issuer).Obtain a fresh, correctly-issued token.
403identity_scope_requiredAuthenticated and identified, but the requested cross-tenant fan-in or admin verb needs admin / console:fleet.Re-authenticate with a scope-bearing token.
403scope_mismatchA steering control's body scope claim is below the control's RFC §6.3 minimum, cross-tenant steering without admin, or a body tenant naming another tenant on a surface that offers a cross-tenant read, without the claim it needs. state.history is the exception — it answers 404 not_found instead (see above).Use a sufficient steering scope, or re-authenticate with admin / console:fleet.

A useful diagnostic habit: 401 means "the wire doesn't know who you are"; 403 means "it knows exactly who you are, and that's the problem."

Listing what a connection can see

sessions.list returns every session under the connection's (tenant, user) — all conversations, open and closed, surviving Runtime restarts (the session catalog persists in the StateStore):

bash
curl -sS -X POST "$HARBOR_BASE_URL/v1/sessions/list" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Harbor-Session: any" \
  -H "Content-Type: application/json" \
  -d '{"identity": {}, "filter": {}, "limit": 50}'

A cross-tenant filter.tenant_ids requires admin; without it the call is rejected with scope_mismatch. Existence is never revealed across tenants — a foreign session id behaves exactly like a missing one (not_found).

Apache-2.0 licensed — see LICENSE.