Skip to main content
Turn on the request-auth gate and write a jq access-control policy. Access control explains the model this configures. The runtime gates its HTTP routes behind API keys and per-user policies. A request presents an sk-… key; the access-control middleware resolves the key’s user and evaluates that user’s jq policy against the request context. You provision keys, map routes to scopes, and attach jq conditions.

Prerequisites

The gate is on by default. Before it can admit requests in a real deployment, these backing pieces must be in place.
1

Access control is enabled

The gate runs unless you set ACCESS_CONTROL_ENABLE=false. Leave it unset (or true) in production — the quickstart disables it only for local, auth-free development.
2

A Redis for the live auth context

The per-user live counters live in a plain Redis — any standard redis-server. No RedisJSON module or redis-stack image is required. There is no baked-in default: point the connection with ACCESS_CONTROL_REDIS_URL, or let it resolve from the shared TAI_DEFAULT_REDIS_URL — with the gate on, one must be set, or every request that carries a key is denied. The Redis API-key provider keeps its key records in its own plain Redis, TAI_IDENTITY_REDIS_URL, which also falls back to TAI_DEFAULT_REDIS_URL.
3

A Postgres policy store

Scopes, route mappings, and per-user policy bodies live in Postgres — the only policy store. It lives in the named database the core skeleton component binds to — the default database unless TAI_DB_BINDING_SKELETON points it elsewhere. Configure that database, including its password via TAI_DATABASE_DEFAULT_PG_PASSWORD.
4

An identity provider in the manifest

The skeleton ships no concrete identity provider, so a deployment with the gate on must name one as a lifecycle module in the manifest. The default is the redis-backed api-key provider:
examples/access_control/identity_manifest.yaml
It registers itself as the redis provider at import. ACCESS_CONTROL_AUTH_PROVIDERS (an ordered JSON list) names the chain the verifier tries; unset, the chain is every registered identity provider, sorted by name. Swap the module and the provider name to use a different authn backend.
5

Probe routes are public by default

A registered, authenticated route the operator mapped to no scope is reached by every role-holder out of the box (it resolves to the universal scope *); only a path the app does not serve is denied to ordinary identities (a condition-free * super-admin key is still admitted — a root is never gated by a missing route). The liveness and readiness probes carry no key, but they are served public by the app’s own route-level declaration: /health and /ready ship in ACCESS_CONTROL_ACKNOWLEDGED_PUBLIC_ROUTES, so an orchestrator reaches them with no key and no manual pin.

Initialize the deployment

With the gate on and no principal yet, there is no authenticated door to mint a key — tai keys create itself needs a key. The one-shot tai setup door (POST /api/setup) closes that gap: it creates the owner principal — the first admin — and mints the owner’s first condition-free ["*"] key, returned once.
The door resolves its token in one of two ways:
  • TAI_SETUP_TOKEN — an operator-set token. When set, it is the token the door expects.
  • the auto-generated token — absent an operator token, each deployment fixes one random token once at startup and logs it a single time (setup token: …). Only someone who can read the server log can initialize the deployment.
Pass the token out of band, never on the command line — --token - reads it as one line from stdin (a value on argv leaks via ps and shell history). The door is one-shot and secure by default: it refuses with 409 Already initialized the moment any principal exists, a wrong or absent token is a generic 403 (with a per-IP backoff that escalates against a token flood), and with the gate off (ACCESS_CONTROL_ENABLE=false) it is disabled with a 501 — there is no deployment to initialize. For local, auth-free development, TAI_SETUP_OPEN=true opens the door without a token. Once the owner key is in hand, every further key is created through the authenticated surface below.

Initializing a deployment whose logins are OIDC

When your humans sign in through an external OIDC provider, that provider answers only who a caller is — it provisions nothing. An issuer-authenticated subject with no principal resolves to the empty policy, so every protected route denies it, and an OIDC identity provider mints no API keys. A fresh deployment still needs its first principal — the owner — and the setup door is the only way to create one. So the setup token matters even when every human logs in through OIDC: it guards the one door that mints the owner and the owner’s first key. Configure a key-minting identity provider beside the OIDC one in ACCESS_CONTROL_AUTH_PROVIDERS — the bundled redis API-key provider mints the owner’s first key. The auto-generated setup token is minted and logged once, at INFO, only when the door can serve. No token is logged under four conditions; under the first the door still works — the token is your own value — and under the other three the door itself is unavailable and answers 501:
  • TAI_SETUP_TOKEN is set — that value is the token, and the door uses it directly.
  • no configured identity provider can mint API keys — an OIDC-only chain mints none; add a key-minting provider.
  • the access-control Redis is unset — the door needs it for the setup token, the throttle, and the mint lock.
  • access control is off — there is no deployment to initialize.
The token is fixed once per deployment, so a restart finds it in place and never re-logs it. Initialize an OIDC deployment in this order:
1

Read the setup token

Take it from the server log, or pin it yourself with TAI_SETUP_TOKEN.
2

Create the owner and first key

Run tai setup — it mints the owner principal and the owner’s first ["*"] key, returned once.
3

Sign in with the key

The owner authenticates with that key while no interactive login exists yet.
4

Provision the OIDC subjects

The owner creates a principal for each OIDC subject through the admin principals door (POST /api/auth/principals) and grants it a role.
5

OIDC logins work

Each provisioned subject now signs in through OIDC; the setup door has closed for good.
On Kubernetes, pin the token ahead of boot: set TAI_SETUP_TOKEN from a Secret so the value is known before the pods start, rather than scraped from a replica’s log.

Provision an API key

Create a key for a user. The raw sk-… value is printed once — capture it then. Grant scopes with the repeatable --scope, and attach an inline jq authorization condition with --condition. A key needs at least one scope or a condition: a key with neither grants nothing, so the mint refuses it with a 400 and writes nothing — an api key needs at least one scope or a condition: a key with neither grants nothing, and every door treats it as no key.
The scopes are granted with the repeatable --scope flag — the command’s own help confirms it:
examples/cli/keys_create_help.sh
The CLI itself authenticates with a key. Pass it out of band — read one line from stdin with the global --api-key-stdin flag, or set TAI_API_KEY in the environment. There is no --api-key VALUE flag, because a value would leak via ps and shell history.

Keys for automation

A machine identity is a service principal — a non-human principal that holds keys but never logs in. An admin creates one, then mints keys owned by it with --for:
--for names the principal a key belongs to; omitted, a key is owned by the caller’s own principal. Manage service principals with tai principals list|disable|enable|delete. A principal and a key never share a user id. Creating a principal under the user id of a minted key is refused with a 409 and nothing is kept — user id 'ci' is an api key's id; a role is assigned to a principal, never to a key.

Map routes to scopes

This step is for scoped keys — a key minted with named scopes rather than *. Role-holders (admin/editor/viewer and any custom role, all carrying *) already reach every registered route, so a fresh deployment needs no route-mapping step to be usable. Map a route to a named scope only to let a scoped key reach it. A scope maps URLs to a named policy target; add a URL to a scope, optionally with a dynamic match pattern, and list the current mappings.
A route that declares itself public at registration answers everyone whatever scope it is mapped to, so it cannot be mapped. tai scopes add on a URL such a route serves, on any method, is refused with a 400 and nothing is written — mapping /health reads '/health' is public by its own declaration and stays open to everyone whatever scope it is mapped to, so it cannot be mapped into scope 'read'. tai scopes routes lists every route with its mapping, and its declared_public column is true for each route public by its own declaration. The route table stores every URL in its canonical form — each segment percent-decoded once, repeated slashes collapsed, . and .. resolved, no trailing slash — the same form every request is looked up by. tai scopes add read /api/tools/ stores /api/tools, and removing or unpinning either spelling addresses the same row. A URL with no canonical form (a NUL, control or backslash byte after decoding) is refused with a 400. An ACCESS_CONTROL_PATH_PATTERNS template must itself be canonical (/studio-authed, never studio-authed or /studio-authed/); a non-canonical template refuses the settings load, naming the form to supply. A route row that is not canonical — one written to the database directly, never through a door — stops the deployment from booting, and the refusal names every such row to correct or delete; see access-control routes are canonical.
Scope a pattern to a surface; never blanket the whole site. Every API route already declares its own protection — a scope row or pattern adds scoping, it is not what keeps /api private. Deny wins: a path that matches both a protected pattern and a public mapping resolves protected, so a whole-site pattern like ^/.*$ also matches browser page navigations and blocks the dataless Studio shell — a deep-link refresh then meets a 401 JSON body instead of the app shell and its login flow, and the shell exposes no data anyway. Tighten a real surface with a scoped pattern (^/api/tools/.*), never a blanket. The plugin studio-asset door stays public through its own route declaration, which the gate resolves public alone, ahead of any route row (a plugin bundle loads as a native ESM import that cannot send an auth header), on the methods the door serves (GET, and HEAD with it); a scope row does not protect it.

Write and validate a jq condition

A jq condition compiles against the request context and returns whether the request is allowed. Validate one before you save it — compile it, and optionally sample-evaluate it against a JqAuthContext-shaped sample.
Attach the validated condition when you create or edit a key with --condition. An edit that would leave a policy with neither a scope nor a condition is refused with a 400 and nothing is written — an edit (PUT /api/auth/api-keys/{user_id}) that sets "scopes": [] while no condition is set, tai keys edit --clear-condition on a key with no scope, or tai keys scopes --remove taking away the last scope of a key with no condition. The refusal names the repair for the row it refuses:
  • a key reads the edit leaves the policy with neither a scope nor a condition: a policy with neither grants nothing, and every door treats it as no key; revoke the key instead;
  • a principal’s own policy reads the edit leaves the principal's policy with neither a scope nor a condition: a policy with neither grants nothing, and every door treats it as no key; disable or delete the principal instead;
  • the last enabled admin principal’s own policy is refused with the last-admin 409 instead, ahead of the 400.
A condition that tests the path reads .request.path, the canonical request path: a mount prefix the app is served under is removed, segments are decoded once, slashes collapsed, dot segments resolved and a trailing slash dropped. Write path tests against that form — .request.path | startswith("/api/tools") — never against a decoded-but-unnormalized or mount-prefixed spelling such as "/mnt/api/tools" or "/api/tools/"; a condition written against such a spelling never matches and must be rewritten. The validator’s sample verdict is the enforcer’s own: validate-condition with a sample runs the same evaluation the gate runs.
1

Draft the condition

Write a jq expression over the request context and compile-check it with tai keys validate-condition.
2

Attach it to a key

Create the key with --condition — a templated-text JSON object carrying either inline jq (content) or a stored condition (id), plus optional render kwargs.
3

Track policy changes

Each user’s enforced policy is append-only and versioned. List the history with tai keys policy-versions, and roll back with tai keys policy-rollback.
A key is revoked immediately with tai keys delete — the next request with it fails to authenticate. Rotate a key by revoking and re-issuing; editing a key never rotates its material.

Verify enforcement

Prove the gate before you trust it: call a guarded route with two keys. A key whose policy grants the route’s scope reaches the resource with 200; a valid key that lacks the scope is denied with a fixed 403. Set TAI_BASE_URL to your server and the X-Api-Key header to each key. An allowed key reaches the resource:
examples/access_control/verify_allowed.sh
A valid but under-scoped key is denied — a fixed 403, with the full reason logged only server-side:
examples/access_control/verify_denied.sh
Both requests run against the real AuthAdapter → AccessControlAuthBackend → ResourceGuardMiddleware chain in docs CI, so a change that breaks allow/deny enforcement breaks this step.

Delegate with owned keys

A key minted by a non-admin is an owned key — capped at a subset of its owner’s scopes, isolated to its own data, and attenuated with the owner on every request. To mint a capped key for a teammate or device, inspect what it can do with tai auth whoami, and share it by one-time QR claim link, follow the owned-keys guide.

See also