Skip to content

Authentication

Every governed cell route accepts identity from exactly one place: a verified Authorization: Bearer <jwt>.

Authorization: Bearer <jwt>

There is no trusted identity header and no header-trust switch — X-Actor-Roles and X-OSL-Subject are always ignored, even if a proxy sends them. A missing token denies by default; a present-but-invalid token (bad signature, expired, wrong shape) is a hard 401 E3001. The engine never downgrades a broken token to “anonymous”.

Cell bearer tokens are ActorToken v2 JWTs:

Field Requirement
Header typ must be opendome-actor+jwt
Header alg HS256 (default) or RS256
version 2
tenant_id the tenant this token is scoped to
chain[] non-empty delegation chain, root → leaf
sub must equal chain[-1] (the acting subject)
roles[] control permissions (e.g. TENANT_ADMIN) — admin operations only
acl_roles[] data-read permissions — the only input for Lance ACLs
iat / exp / jti required; max TTL 300 s, validated with 30 s clock skew
iss / aud required when the verifier is configured with them (chart default issuer: opendome)

Access is evaluated over a chain of subjects (root → leaf), not a single identity — see Access & capabilities. The chain is inside the signed payload, so a caller cannot shorten or forge it without breaking the signature:

{
"typ": "opendome-actor+jwt",
"version": 2,
"tenant_id": "demo",
"iss": "opendome",
"chain": ["ana@example.com", "agent://copilot-finance"],
"sub": "agent://copilot-finance",
"roles": [],
"acl_roles": ["analyst"],
"iat": 1750000000,
"exp": 1750000300,
"jti": "01HV…"
}

The signing Secret (standalone default: HS256)

Section titled “The signing Secret (standalone default: HS256)”

The tenant-semantic-api chart ships with auth.jwt.enabled: true: it creates (or reuses on upgrade) an HS256 signing Secret named tenant-semantic-api-jwt in the tenant namespace, and the engine verifies bearer tokens against it. The other cell charts verify against the same Secret by default, so one Secret per tenant covers the whole cell.

For GitOps / render-only pipelines, set auth.jwt.existingSecret to a Secret you manage — a pure render cannot look up the live Secret and would otherwise rotate it on every render.

scripts/osl-token.sh (in the platform repository) reads the chart-generated Secret via kubectl and signs an HS256 ActorToken v2 with the Python 3 stdlib only. Defaults: sub=local-admin, acl_roles=["analyst"], TTL 300 s.

Terminal window
TOKEN=$(scripts/osl-token.sh demo)
curl -fsS "http://$HOST/osl/query" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"osql":"SELECT customer_id, name FROM osl.entities.customer LIMIT 5"}'

Useful flags:

Terminal window
scripts/osl-token.sh demo --acl-roles analyst,finance # data-read roles (Lance ACL input)
scripts/osl-token.sh demo --control-roles TENANT_ADMIN # control roles (admin routes)
scripts/osl-token.sh demo --sub agent-1 --chain root,agent-1 # delegation chain (sub = leaf)
scripts/osl-token.sh demo --ttl 60 --audience osl # TTL ≤ 300 s; optional audience

BYO OIDC — the production upgrade path Apache-2.0

Section titled “BYO OIDC — the production upgrade path ”

A production self-hoster can replace the chart-minted HS256 Secret with its own issuer — this is part of the open-source distribution, not an Enterprise gate:

  • Set auth.jwt.algorithm: RS256 and point auth.jwt.existingSecret at a Secret carrying your issuer’s public verifying key. The verifier never needs the private key.
  • Set auth.jwt.issuer / auth.jwt.audience — once configured they become required claims.
  • Your issuer must emit the ActorToken v2 shape above (the opendome-actor+jwt header typ and the required claims, typically via protocol/claim mappers). Tokens without that shape are rejected — the contract is deliberately hard-cut.

The repository’s docs/auth.md walks through configuring a local Keycloak as a test OIDC issuer.

The reference engine does not yet enforce in-process rate limits. The intended defaults (per subject) are 60 rps for POST /osl/query and 5 rps for the cached GET /osl/schema; exceeding them will return 429 with Retry-After.

Every request — 200 or not — produces an audit entry with request_id, subject, action, the object FQNs touched, the decision and a timestamp.