For local development against tools that need no auth, turn the gate off with
ACCESS_CONTROL_ENABLE=false — the quickstart
does exactly this.anonymous when the request carried no identity
— a public route, or the gate turned off), the method, the route, the response
status, how long it took, and a timestamp. It is on by default and turns off with
TAI_AUDIT_LOG_ENABLE=false. The route is the only request text on the line, and it is
the path template: a path parameter is written as its {name}, so a door that
carries its credential in the path (a trigger link’s /trigger/{token}) records the
shape and never the secret. A stray path matches too on a deployment that serves the
Studio, where the SPA catch-all takes it: the line reads /{spa_path}, again never
the tail. A path nothing matched at all has no template to record, so the line reads
<unmatched> and carries no part of that path — as does a path handed on to a
mounted sub-server (/app/{slug}), which records the mount’s own prefix and then
that same constant, /app/<unmatched>, because what the sub-server made of the rest
is not visible from here. Nothing else reaches the
line — never a request or response body, a query string, a header, or a token — so
the trail is safe to ship wherever your logs already go.
A refusal is on the same trail, so failed access is visible, not just granted
access. A request the gate turns away writes its own line: a 401 when it carried
no usable identity, a 403 when a known identity is not permitted, or a 429 when
the flood limiter sheds it. A denial line adds a reason= naming the cause it can
state (a permission miss, an unconfigured route), and its principal is the real
caller when the request was authenticated-but-denied, otherwise the marker
unauthenticated. A refusal records the same path template (or <unmatched>), so
it never leaks a path-borne secret either.
Identity, policy, route
The gate has three moving parts, resolved on every request:- Identity — an inbound key is hashed and looked up; a stored key resolves to
a
user_id. A raw key is generated with a CSPRNG, only its SHA-256 is ever stored, and the plaintext is returned to the caller exactly once. - Policy — the identity’s policy carries its scopes plus an optional jq condition and policy data. The policy is cached per worker and keyed on a version, so a management edit bumps the version and the next read bypasses the stale cache without waiting out the TTL.
- Route — a request path maps to a scope id; the guard admits the request
only if the identity’s scopes cover it. A route pinned to the public marker
(via
tai scopes public-pin) is public;*is a superuser scope on a key, not a route value. A route with NO mapping fails closed for every ordinary identity — except a condition-free*super-admin key, which is admitted (a root can map the route anyway, so blocking it is a footgun, not security). So an access-control-enabled deployment’s admin reaches the whole surface out of the box; the route table only ever scopes non-admin identities.
Resolving a credential across providers
A deployment can enable more than one identity provider at once — an API-key store, an accounts provider’s sessions, an issuer-JWT verifier.ACCESS_CONTROL_AUTH_PROVIDERS is an ordered list, and the gate
resolves an inbound token by trying each provider in order, first match
wins: the first provider that recognises the token answers, and resolution
stops there.
The distinction that keeps this safe is error is not miss. A provider that
does not recognise a token returns “not mine” and the gate falls through to the
next provider. A provider that recognises the token but hits a backend failure
raises — it does not silently return “not mine”, which would let a
misconfigured store fall through to a weaker provider. A miss falls through; an
error fails closed.
Attenuation is enforced at the gate
When an accounts provider is in play, a resolved API key carries its owning account, and the gate applies owner attenuation on every request: the key’s effective authority is intersected with its owner’s live policy right here, at resolution time, before the policy decision. Because it is re-applied per request rather than frozen at mint, a change to the owner’s role — or disabling the owner — takes effect on the key’s very next call. Only the mint path may set the owner claim; the gate strips any owner claim a non-minting provider returns, so an external issuer can never assert ownership it did not earn.A key that carries an owner claim is an owned key — a capped, isolated
sub-identity you can hand to a teammate, a device, or a machine. The delegation
model, the
GET /api/auth/me capability projection, per-identity data isolation,
the claim-link channels, and which scopes are safe to grant live on
Owned keys.The login namespace is public; /api/auth stays reserved
Human login flows live under the /api/login namespace — the
methods listing, the credential-submit routes, the bootstrap and invite flows.
That namespace is public by default: a browser must reach it before it has
any credential, so its public-ness is a code-side default, not a route mapping an
operator has to remember to add. The /api/auth provisioning and policy routes
stay reserved behind the gate — minting keys, editing policies, and managing
principals all require an authenticated, authorized caller. Sign-in is open;
administration is not.
Two reserved routes carry a carve-in so a scoped identity can still onboard and
introspect: GET /api/auth/me (the capability projection)
is authenticated-implies-allowed — any authenticated caller reaches it regardless
of the route table — and POST /api/auth/claim-links is carved into the seeded
editor/viewer roles so a role-holder may share its own key. Both stay behind
the gate; neither is public.
The Studio SPA shell is public; its data is not
An unmatched non-/api GET is served the dataless Studio SPA shell —
index.html and its static assets — so a refresh or bookmark of an inner Studio
route (/agents, /settings) lands the app, not a 404. This is on by default
(the spa_shell_public setting) and safe: the shell carries no data — everything
it renders sits behind an authenticated /api/* call — so an unauthenticated
visitor sees only the login page. Deep links therefore work with no manual
public pin for / or any SPA path. Because the guard resolves deny-wins, an
over-broad protected mapping that also covers SPA paths suppresses this fallback, so
the refresh meets a 401 rather than the shell — scope a protected pattern to a real
surface, never the whole site. The fallback is GET-only and never touches
/api or /mcp, so it can never open the control plane; a headless deployment
turns it off by setting spa_shell_public to false. See
deploying the Studio.
Routes declare their own public-ness
A route declares whether it answers unauthenticated at registration — a core native route registeredauthed=False (the interactions callback and served-media
capability doors), or a plugin route carrying its own public flag in the plugin’s
descriptor. The route registry records
that declaration against the route’s single owner, and the gate reads it as a tier of
its own: after the always-public prefixes and patterns resolve, and before the
Postgres route-table lookup, the gate matches the request (path, method) against
the registry — a match on a route declared public is served unauthenticated,
whatever its owner. So a core capability door or a plugin’s inbound webhook is open
the moment it registers, with no route row to add and no pin to remember.
The tier is per-method. It resolves only the exact methods a route declares
public: a POST declared public opens, while a GET on the same path that is not
declared public falls straight through to the normal gate. And because every route
has one owner — two owners can never
both claim one path shape and method — the declaration is the whole source of truth.
Nothing is ever written to the access_control_routes store for a declared-public
route, so there is no stored row to drift out of step with the code.
The declaration is authoritative: because the tier short-circuits above the
route-table lookup, an operator cannot re-protect a declared-public route in place
with a later route-table pin — the pin never gets consulted. A core capability
door’s declaration is fixed in the platform code; to close a plugin door, decline it
at the install-time public-acceptance gate or uninstall the plugin. Remapping only
relocates the public route, it does not protect it. The operator’s own hand-pin door
stays for the operator’s own routes.
The reserved namespace still wins. A declared public route resolving under a
reserved prefix — /api/auth by default — is refused at boot, not quietly opened,
so no owner can declare its way past the reserved control plane described above. The
operator’s own public-pin door (tai scopes public-pin) is unchanged and
independent — it maps a route row public in the store, which is a separate tier from
a route’s own declaration.
Flood control on public doors
Every public door is rate-limited, and the coverage is derived, never listed: a route declares whether it is public, the route registry records that, and the limiter throttles every route registered public. A new public door — a plugin’s inbound webhook, another login door — is flood-limited the moment it registers. Authed routes pass straight through; the credential is the gate there. A request is answered by the most specific registered route that covers it, so a concrete door always outranks the SPA catch-all, and a path no registered route matches is not the limiter’s business.Door families
Public doors are grouped into families whose counters are disjoint, so a flood on one door can never exhaust another’s budget. A family is the door’s path stem — its leading static segments, at most two, stopping at the first{parameter}, with the
shared /api segment dropped:
Two segments is what keeps a deployment’s families disjoint without splitting one
door’s own surface: the six
/api/channels/web/* web-chat doors all fold into
channels_web, while every other channel keeps a family of its own. A path
parameterised from its first segment — the SPA catch-all /{spa_path} — has no stem
and folds into root.
Budgets
Every family is charged 600 requests a minute with a 120-request burst in any 10 seconds unless a tighter budget applies. The runtime holds the three single-request doors —trigger, universal_webhook, and interactions_callback —
at 60 a minute and 10 in 10 seconds, because one request there buys real work: a
webhook fan-out, a ticket redemption, a tool run. It holds the web-chat family —
channels_web — at 120 a minute and 30 in 10 seconds, wide enough for one first page
load, which is the chat page plus every bundle file it links in a single burst.
Over either window the door answers 429 with a Retry-After naming the seconds
left in that window, and the refusal writes its own audit line as an unauthenticated
request against the route template.
Tuning a family
TAI_RATE_LIMIT_FAMILIES overrides one family’s budget, as a JSON object or per
field:
TAI_RATE_LIMIT_DEFAULT_LIMIT, TAI_RATE_LIMIT_DEFAULT_BURST, and
TAI_RATE_LIMIT_DEFAULT_ENABLED move the floor every family that states nothing
starts from.
An override tunes or disables the door it names; it never widens coverage.
{"enabled": false} passes that family through, and an entry for a family no route
declares is inert.
Which client a request is from
The counter is keyed per client address, andX-Forwarded-For is caller-writable
text — any client can claim any address in it — so it is believed only under a
declared trust statement:
TAI_RATE_LIMIT_TRUSTED_PROXIES— the addresses your proxies call from, single hosts or CIDR blocks. The socket peer must itself be trusted before a header byte is read; then the right-most entry that is not itself a trusted proxy is the client.TAI_RATE_LIMIT_TRUSTED_HOPS— the number of proxies sitting in front of the deployment. Use it when the proxy’s own address moves (a managed load balancer renumbers): the limiter skips exactly that many right-most hops instead of matching addresses. A positive value wins, and the roster is then not consulted.
/64, since one
host routinely holds a whole /64.
With neither setting, X-Forwarded-For is ignored and the socket peer is the client:
correct for a directly exposed deployment, and behind a proxy it collapses every
client into one bucket. Boot emits a WARNING when no trust is declared, and a
forwarded header arriving under that posture is reported once more.
No Redis, no limiter
The counters live in Redis —TAI_RATE_LIMIT_REDIS_URL, or the shared
TAI_DEFAULT_REDIS_URL. With neither, rate limiting is OFF: every public door
passes through unthrottled and boot emits one WARNING naming the variable, never a
boot refusal. See what OFF looks
like and the
RateLimitSettings table.
Admin-only mutations
Beyond the editable per-tag role map, some routes are never grantable — no per-tag level opens them for any non-admin. The fence is declared on the route, not maintained as a hand-written list: every authed route declares an action-class, and two of the classes are the admin-only fence:fenced— the admin-only mutations: the pre-existing infrastructure changes and the role and policy control plane. Running a tool directly (POST /api/run-tool); reloading or removing a tool; theconfigenv write and reload (POST /api/config/env,/api/config/reload); replacing the manifest; reloading failed MCPs and deregistering an MCP; reloading the fleet; marketplace install / uninstall / update; backup import / export; and the role and policy control plane (creating, editing, deleting, and rolling back roles, and rolling back a user’s policy). Editing scopes, the route table, and the public-route pins is notfenced— those routes are ordinaryread/writeclass, made admin-only by the base-tier/api/authcontrol-plane ceiling that default-denies the area to every non-admin role.secret— the admin-only reads, over two admin-owned stores. The bulk role / policy reads: the roles listing (GET /api/auth/roles), the role version histories, and a user’s policy version history, whose payload carries raw jq policy bodies. And the deployment config store: the stored environment (GET /api/config/env) and the per-settings-class values (GET /api/config/settings-schema). Environment and settings are one admin-owned store — including deployment secrets such as the connector encryption key — so only an admin reads or writes it.
admin reaches all of them; an editor or viewer is denied. Everyday
feature configuration is not on this fence: starting an async tool-run
(POST /api/tool-runs), scheduling a tool, setting the MCP config, reloading a
single MCP server, mounting a sub-MCP, setting tool extensions, registering hooks,
and reading the hooks listing are ordinary grantable read/write routes an
editor reaches — an editor configures every feature through the feature’s own
routes. The deployment env/settings store is the admin-owned exception. A route
that declares no action-class fails boot, so the fence can never be silently
forgotten. See
user types and permissions for the full surface and the
per-tag role model.
Editors configure every feature; they do not own the deployment store. An
editor reaches the async tool-run surface, schedules, the MCP config, sub-MCP
mounts, tool extensions, and hooks through each feature’s own grantable routes —
the platform trusts a granted role to operate the features it holds. The
deployment env/settings store stays admin-only: reading the env and settings
(GET /api/config/env, GET /api/config/settings-schema) and the env write are
admin-only, alongside the infrastructure mutations and access-control
administration above.The jq policy
A policy is scopes plus rules. The rule is a jq condition evaluated against the request’s credentials and context — expressive enough to gate on identity, scope, and request attributes without any custom code. The condition is compiled and evaluated by the policy enforcer; a backend error while reading the policy version fails closed by raising, which surfaces as a clean deny rather than a silent default. A denied request never leaks internal detail: an authentication failure returns a fixed401 and an authenticated-but-forbidden request a fixed 403, with the full
failure logged only server-side.
Validate a jq condition offline before you commit it, and mint keys with the CLI:
Route mapping is two-tier
Mapping a route to public or protected is a configuration decision, not a property of the route code. It is two-tier: a path pattern maps a URL to a template key, and a Postgres record in the access-control policy store maps that template key to a scope id (or the public marker). This is how the interactions callback doors are mapped public while the stream and answer doors stay protected. Every management mutation that touches more than one key commits through a single WATCH/MULTI/EXEC transaction, so a crash never leaves the store half-written and a concurrent edit aborts and retries against fresh state. See the access-control guide for the provisioning workflow, the CLI reference fortai keys and
tai scopes, and the HTTP API reference for the routes.
