Who can administer governance
Work out which teammates can mint keys, change limits, or only read them, and how each one signs in
On this page
Governance is running, and the questions turn into questions about people. Someone wants a
team’s cap raised before a demo. Someone else needs a key for a service that ships on
Thursday. A third person only wants to see whether last week’s 429s were theirs. Those are
three different amounts of authority, and out of the box everyone who reaches the admin port
holds the same one: dashboard.auth.mode defaults to off, which resolves every request to a
local admin.
TokenTriage answers with three roles in a line, a table of routes with a minimum role on each row, and two ways to present a credential. What follows is what each role can do to governance, how a person and a program each sign in, and four places where the behaviour differs from what the config suggests. Read Governance first if limits and virtual keys are new; giving one team’s own admin the run of their subtree is delegated self-serve.
Three roles, and a table that lists every route
A role model you cannot enumerate is one you cannot audit, so this one is a list you can read end to end.
The ladder is viewer < operator < admin, compared by rank, so a higher role covers
everything a lower one can do. There is no fourth role, no per-resource grant, and no way to
hand somebody one extra endpoint. A fourth value covers routes that take no credential at all
(login, the problem registry, a token-in-the-URL read) and is not assignable to a person.
The split follows what an action does. Reads sit at viewer, exports included, because a CSV
export is a read; writes that change running configuration sit at operator; anything that
creates or reveals a credential sits at admin.
Every admin-API route under /tt/api/v1 carries a row of method, path pattern and floor, and
matching is a first-match walk. Patterns compare segment counts, so a collection row does not
cover its children and each child route carries its own. A pattern ending in * is the one
exception, and it exists for parameters that themselves contain /, such as a tenancy node id:
those rows match every remaining segment rather than one.
A path with no row that also sits outside that prefix is not this table’s to gate and passes
through, which is how /health, /metrics and the UI stay reachable. A handful of rows sit
outside the prefix anyway: the older /tt/api/status and /tt/api/decisions reads at viewer,
and the problem registry and rendered spec under /tt/api/ with no credential at all.
What each role can do to governance
Each rung adds to the one below it, so an admin holds every cell in this table and a viewer holds the first.
| role | what it adds |
|---|---|
viewer |
every governance read, for any node — status, armed limit definitions, key metadata, the runtime node list, the refusals feed, health, shadow counts, reconcile drift — plus both CSV exports and POST /governance/preview, which is write-shaped and changes nothing |
operator |
revoke a virtual key; create and delete tenancy nodes; set and clear a limit override |
admin |
mint a virtual key; rotate one; mint a tts_ tenant read token; decommission a replica; create users and dashboard tokens |
The line between operator and admin is drawn at revealing a secret. Revoking a key is an operator act; minting one is not, and neither is rotating one, because a rotation mints a successor and prints it. One governance route asks for no role at all, the tenant read, because the token in its path is the credential.
The three /governance/selfserve routes carry a viewer floor, which reads as though a viewer
can mint a key. They cannot. That floor is the outer door, and the handler behind it runs a
subtree check that turns away any caller without an admin-authored binding.
Delegated self-serve has that mechanism.
The ladder gates the admin API and stops there. keys, user and token write into the
dashboard state directory directly, with no role check, so a shell account on the host sits
above every row. File permissions are the other half of this model, and they are not uniform.
users.yaml, tokens.json, share-tokens.json and role-bindings.json are written 0600,
into a directory those stores create at 0700. governance.db, which is what keys writes,
is not: its parent directory is created 0755 and the database file takes the process umask.
Two ways to hold a credential, and they do not stack
A person types a password. A program cannot. dashboard.auth.mode picks which of those the
dashboard reads, and it takes off, token or users, defaulting to off.
off is valid only when server.admin binds loopback, and a config error otherwise, so it
cannot be paired with an admin bind a colleague dials directly. Every request then resolves to
one admin principal named local. Anyone with a shell on the host, or a port forward to it,
holds that principal.
users is local accounts. tokentriage user add writes users.yaml under the state
directory, carrying a name, a role and an argon2id hash at the OWASP parameters (64 MiB, one
pass, four lanes); the password itself does not land on disk. Signing in mints a server-side
session, the cookie is HttpOnly and SameSite=Strict, and mutating requests carry an
x-tt-csrf header bound to that session. dashboard.auth.session_ttl defaults to 12h, and
sessions live in memory, so a restart signs everybody out.
token is bearer tokens. tokentriage token new prints the secret once and keeps its
SHA-256 in tokens.json.
$ tokentriage token new --role admin --label docs-capture
token id: 39d61563c5ab79d8 role: admin
This token is shown ONCE and cannot be recovered:
ttk_tQIzM0aMxJtSY9gcFn-T3k5Ne3Br…
That capture is from a source build against a throwaway state directory, and the secret’s tail
is trimmed; a live one carries 43 characters after the prefix. Watch the defaults: --role is
operator and --ttl is 0, no expiry. A token minted without thinking about either is a
non-expiring operator.
The mode selects one reader rather than a chain. Under users, an Authorization: Bearer
header is not read at all; under token, there are no sessions and no CSRF check. A CI job
holding a ttk_ gets a 401 from a dashboard running in users mode, and nothing about the
token is wrong.
Three credential prefixes turn up in this product, and they are not interchangeable.
| prefix | what it is | where it is presented | who mints it |
|---|---|---|---|
ttv_ |
virtual key, a caller’s data-plane credential bound to a tenancy node | Authorization on proxy traffic |
keys mint, or POST /governance/keys at admin |
ttk_ |
dashboard bearer, a role on the admin API with no scope list and no subtree binding | Authorization on /tt/api/v1/… |
token new on the host |
tts_ |
tenant or share token, a frozen read of one subtree, or of one saved view or query | in the URL path of its own read route | POST /governance/tenant-tokens, or POST /share-tokens for a view, both at admin |
Virtual keys owns the ttv_ lifecycle.
The login limiter, and the knob it does not have
The password path is the expensive one. A single argon2id verify allocates 64 MiB, and the user store hashes a dummy password even for a name it does not hold, so timing gives away no account list. Left alone, an unknown-username spray buys an attacker that work per request at no cost to itself.
A fixed-window limiter sits in front of POST /tt/api/v1/auth/login, ahead of the verify, and
fronts that one route only. The defaults are 15 attempts per source per one-minute window, over
at most 8,192 tracked sources.
It keys on the connection’s remote address host, not on X-Forwarded-For and not on
X-Real-Ip. Those headers travel inside the request, so a caller who could choose the key
could rotate it per attempt and clear its own counter. At 8,192 sources a new source takes a
free idle cell and is otherwise turned away; a live cell is not evicted, because evicting one
would reset a counter that is doing its job. A throttled attempt gets a 429 with Retry-After,
and the audit log carries one row per source per cell lifetime, so it tells you a source was
throttled without telling you for how long.
Two costs come with that shape, and the code implementing it names both.
- One shared egress is one budget. Everybody behind a single NAT address shares 15 attempts a minute, so a bad morning of typos in a 40-person office can 429 somebody who mistyped nothing.
- A fixed window is per window, not rolling. Fifteen at the end of one minute and fifteen at the start of the next is 30 verifies across the boundary. Bounded, and twice the number on the label.
Neither has a configuration key today. dashboard.auth carries two fields, mode and
session_ttl. The rate, the window and the source cap are constants compiled into the binary
and passed unconditionally at startup, so the advice the product’s own operator guide gives for
shared-NAT throttling, raise the rate, is a rebuild rather than an edit.
(dashboard.api_limits.read_per_min and write_per_min are a second, outer limiter over
everything under /tt/api/, login included. Its write budget defaults to 120 per source per
minute, well above the login throttle, so moving it moves nothing here.) The levers you do have
are network-shaped: a second egress for the office, or an identity proxy in front with the port
kept off the public internet.
Four traps worth knowing before you hand out an account
A shell role change does not sign anyone out
user set-role, user passwd and user remove write users.yaml and touch nothing else.
The store holding live sessions is in the running process, and only the HTTP handlers under
/users drop a user’s sessions when their role, password or existence changes.
The password you type is on the screen
user add and user passwd prompt on stderr and read one line from stdin. They do not switch
the terminal out of echo, so an interactively typed password is displayed as you type and stays
in the scrollback. Pipe it in instead, as the worked setup at the end of this page does.
token list shows revocation, not usability
The REVOKED column prints the stored revoked flag rather than the token’s lifecycle state.
Here is the listing against the same throwaway store the preceding mint wrote to.
$ tokentriage token list
ID ROLE REVOKED LABEL
39d61563c5ab79d8 admin false docs-capture
Four columns, and none of them is time. A token whose --ttl lapsed an hour ago still prints
false there and reads as live, because the column is the flag an operator sets, not the state
the authenticator computes. The store does compute active | revoked | expired for the admin
API’s token read; this table does not use it. The expires: line at mint time is the only
place the CLI tells you, and it prints only when you passed a --ttl.
The CLI mints one kind of token
token new mints the legacy role-only bearer, ttk_: a role, a label and an optional --ttl,
with no scope list and no subtree binding. The scoped, subtree-bindable, rotatable management
credential (tta_) is reachable only over HTTP, which is why there is no token rotate on the
CLI and no --scopes or --node flag. The HTTP rotate route is no help either, because it
turns down any token that is not a tta_. Rotating a ttk_ means minting a second one, moving
callers to it, and revoking the first.
A tenant token rides in the URL
One credential family is presented differently from the rest, and that changes where it can
leak. tts_ tenant and share tokens are read on routes that carry the token in the path,
GET /tt/api/v1/governance/tenant/{token} and GET /tt/api/v1/share/{token}. Those rows carry
no role floor, because the token is the whole credential, verified against its stored digest in
constant time.
TokenTriage keeps it out of its own records: before a denial reaches the audit log, any path
segment beginning with a known credential prefix is replaced with <redacted>, as is any
segment a registered route marks as credential-bearing. That covers one log. A reverse proxy’s
access log, a load balancer, an APM agent and a browser’s history all record request lines, and
each captures a working read credential for as long as it retains them.
A setup you can copy
Dana runs the ML team’s spend work and needs to move caps. A CI job posts a weekly summary and should be able to change nothing. That is one account and one token.
Adding an account looks like the capture below, taken against a throwaway state directory. It
adds an admin called alice; --role operator is the value Dana’s account wants.
$ tokentriage user add alice --role admin
Password:
added user "alice" with role admin
user add defaults --role to viewer, so the flag is doing work. Pipe the password in
rather than typing it, for the reason the preceding trap gives:
printf '%s\n' "$TT_PW" | tokentriage user add dana --role operator. The CI job’s credential
comes from tokentriage token new --role viewer --label ci-cost-report, which prints in the
shape the mint capture earlier on this page shows. Both commands take --state-dir, defaulting
to ~/.tokentriage/dash.
Dana, operator. Revokes a virtual key, creates and deletes tenancy nodes, and sets or clears a limit override inside the ceiling the limit’s template declares, on top of every read. A template that permits an override without naming a ceiling bounds her at nothing. Cannot mint or rotate a key, mint a tenant token, or add another user or token.
The CI token, viewer. Reads node status, the armed limit definitions, key metadata, the
refusals feed and the health panel, downloads both CSV exports, and dry-runs a proposed limit
set with POST /tt/api/v1/governance/preview. Changes nothing.
One of them is admitted at a time. Under mode: users Dana signs in and the CI token gets
a 401. Under mode: token the CI token works, and Dana’s password login still answers 200
while the cookie it sets authenticates nothing on a later call. Pick the mode for the caller
you care about most and give the other a different door: a tts_ tenant token reads on a route
with no role floor, so it works under either mode. That is also the answer when the program
should see one tenant and no other, because viewer is read parity across the whole tree. The
node filter on a governance read is a query parameter the caller supplies, and an absent one
means every tenant.
What this page does not cover
Bindings that let a team’s own admin mint keys and raise caps inside their subtree, and the
read-scope caveat that comes with them, are
delegated self-serve. The endpoints themselves, grouped by
job with the role each needs, are the management API. Where
secrets live and how TLS terminates is security. The lifecycle a
keys mint starts is virtual keys, and the flags for every
command named above are the CLI reference.