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):
Authorization: Bearer <connection-token>
X-Harbor-Session: <conversation-session-id>Rules the Runtime enforces at the auth middleware, fail-closed:
X-Harbor-Sessionpresent and non-empty → it replaces the token'ssessionclaim. Tenant and user stay token-verified.- Header absent → the token's
sessionclaim is used as a back-compat default (the dev token carriessession: devfor 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:
| Claim | Role |
|---|---|
tenant | verified — the connection's tenant |
user | verified — the connection's user |
session | default only — used iff X-Harbor-Session is absent |
scopes | the connection's elevated scopes (may be empty) |
iss / aud / exp / nbf / kid | standard 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 component | Rule |
|---|---|
user, session | Must 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. |
tenant | Must 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:
| Surface | Body components it will cross | Claim | Refused without it |
|---|---|---|---|
artifacts.list | tenant, user | admin or console:fleet | 403 scope_mismatch (tenant) / 403 identity_scope_required (user) |
artifacts.put / artifacts.delete | tenant | admin only | 403 scope_mismatch |
artifacts.get_ref | — (crosses for nobody) | — | 403 scope_mismatch |
the seven posture reads (runtime.*, metrics.snapshot, governance.posture, llm.posture) | tenant | admin or console:fleet | 403 scope_mismatch |
topology.snapshot | tenant | admin only | 403 scope_mismatch |
state.history | tenant, user and session — a whole cross-identity read | admin only | 404 not_found |
the five search.* methods | filter.tenant_ids, filter.user_ids (and a MULTI-value filter.session_ids) | admin or console:fleet | 403 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:
useris 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 refused403 identity_scope_requiredunless you holdadminorconsole:fleet; with either claim, naming a user reads that user and omitting it fans across the whole tenant.sessionis 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 theuserfold 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_idsanduser_idsare 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 refused403 scope_mismatchunless you holdadminorconsole:fleet; with either claim, naming a user reads that user and omitting it fans across the tenant.session_idsis 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:
| Scope | Grants |
|---|---|
admin | Cross-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:fleet | Fleet 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
Dev — harbor 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:
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):
| HTTP | code | Meaning | Fix |
|---|---|---|---|
| 401 | identity_required | No 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": {}. |
| 401 | auth_rejected | A token was present but failed verification (bad alg / signature / expiry / kid / audience / issuer). | Obtain a fresh, correctly-issued token. |
| 403 | identity_scope_required | Authenticated and identified, but the requested cross-tenant fan-in or admin verb needs admin / console:fleet. | Re-authenticate with a scope-bearing token. |
| 403 | scope_mismatch | A 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):
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).