Search

↑↓ to move↵ to openesc to closetop 6 documentation results

Book a demoTry TokenTriage

In development · Explore the product preview. Join the launch list or book a walkthrough.

Browse docs

Who can administer governance

Work out which teammates can mint keys, change limits, or only read them, and how each one signs in

ExplanationLast updated

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.