# Who can administer governance

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

Source: https://tokentriage.com/docs/build/governance-access-control

---
import Callout from '../../../components/Callout.astro';
import NextSteps from '../../../components/NextSteps.astro';

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](/docs/build/governance) first if limits and virtual keys
are new; giving one team's own admin the run of their subtree is
[delegated self-serve](/docs/build/governance-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.

<Callout type="key">

A path under `/tt/api/v1` with no row in that table is turned away before any handler runs:
401 without a credential, 403 with one, an admin's included.

</Callout>

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](/docs/build/governance-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`.

```console
$ 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](/docs/build/governance-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.

<Callout type="warning">

Demote an operator from the shell and their signed-in session keeps operator authority until
it expires (`dashboard.auth.session_ttl`, 12h by default) or the process restarts. Make the
change on the admin API's `/users/{name}` route, which drops that user's sessions as part of
the write. If you have already made it from the shell, restart the process.

</Callout>

### 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.

```console
$ 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.

<Callout type="tip">

Mint tenant tokens with an `expires_in`, since a token minted without one does not expire. Turn
off request-line logging for `/tt/api/v1/governance/tenant/` and `/tt/api/v1/share/` wherever
TLS terminates, or scrub that segment. Revocation is checked on every read, so revoking a
leaked token by id takes hold on the next request.

</Callout>

## 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.

```console
$ 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](/docs/build/governance-self-serve). The endpoints themselves, grouped by
job with the role each needs, are [the management API](/docs/build/governance-api). Where
secrets live and how TLS terminates is [security](/docs/operate/security). The lifecycle a
`keys mint` starts is [virtual keys](/docs/build/governance-keys), and the flags for every
command named above are [the CLI reference](/docs/reference/cli).

<NextSteps items={[
  { title: 'Delegated self-serve', href: '/docs/build/governance-self-serve', description: 'Let a team admin mint their own keys and raise their own caps, inside a subtree.' },
  { title: 'The management API', href: '/docs/build/governance-api', description: 'Every governance endpoint grouped by job, with the role floor each one carries.' },
  { title: 'Security', href: '/docs/operate/security', description: 'Secret references rather than stored secrets, hashed credentials, and TLS in front.' },
  { title: 'Virtual keys', href: '/docs/build/governance-keys', description: 'The ttv_ lifecycle a mint starts: scope, rotate with grace, revoke, and what is stored.' }
]} />
