# Delegated self-serve

Let a team's own admin mint keys and raise caps inside one subtree, and know what their account can still read.

Source: https://tokentriage.com/docs/build/governance-self-serve

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

You run the gateway and twelve teams send traffic through it. One wants a key for a new service
on a Tuesday afternoon; two want their monthly cap moved before the quarter closes. All of it
lands on you, and most of it needs no judgement from you.

Delegation hands a bounded piece of that work to the person who was going to ask you anyway.
You grant a named dashboard account authority over one node of your tenancy tree. From then on
that person can mint keys and raise caps beneath that node, and nothing above it or beside it.

The grant is called a role binding, and it sits on a different axis from the dashboard role
ladder: it adds authority inside one subtree without moving anyone up the ladder.
[Who can administer governance](/docs/build/governance-access-control) covers the ladder, and
[the tenancy hierarchy](/docs/build/governance-hierarchy) covers the override ceilings a
delegated raise rides on.

Settle one thing before you delegate to a tenant you would not otherwise trust. A binding
bounds what an account can write. It does not bound what it can read, and the section on read
scope is the one to finish.

## What a delegated admin can do

Handing over authority is only safe if you can say what you handed over without reading the
code. The delegated vocabulary is closed at three actions, and each has one endpoint.

| action | endpoint | what it takes |
|---|---|---|
| mint a key | `POST /tt/api/v1/governance/selfserve/keys` | an owner node inside the subtree |
| set a bounded override | `POST /tt/api/v1/governance/selfserve/overrides` | a target node inside the subtree, and an amount at or under the template's ceiling |
| read the subtree | `GET /tt/api/v1/governance/selfserve` | the binding alone |

Nothing else has an expression on this path. There is no action for creating a tenancy node,
adding a user, or changing a role, so a delegated admin cannot attempt one of those and be
turned down; the vocabulary has no member for it. Plausible-looking sub-paths are absent from
the route table too, and a route under `/tt/api/v1` with no row is turned away for every
caller, a full admin included.

Two delegated roles exist, `org-admin` and `team-admin`, and they carry the same three actions.
What differs is the depth of node a binding may root at: an org-admin at a single `org:` node,
a team-admin at an `org:…/team:…` pair. One is broader than the other by the size of its
subtree, not by what it may do inside it.

There is no screen for any of this. The dashboard that ships with TokenTriage routes
`/governance`, `/governance/v2` and `/governance/health`, and no path to a self-serve page, so
a delegated admin calls the three endpoints directly or through a console you write against
them.

## A binding is a line in a JSON file

You will want to create one, and nothing in the product will do it for you. No route on the
management API and no CLI verb creates, lists, or removes a role binding. Granting one means
editing a file on the host.

The file is `role-bindings.json` under the dashboard state directory `dashboard.state_dir`,
which defaults to `~/.tokentriage/dash`. It does not exist until something writes it, and
nothing in the shipped binary writes it for you.

```json
{
  "v": 1,
  "bindings": [
    { "user": "priya", "root": "org:acme/team:ml", "role": "team-admin" },
    { "user": "sam", "root": "org:acme", "role": "org-admin" }
  ]
}
```

A version, then one object per grant: a dashboard username, a subtree root, and one of the two
delegated roles. Write it `0600`; the directory around it is created `0700` at startup.
Usernames match case-insensitively.

A restart is not required. The store re-reads the file on every authorization, so a binding you
add while the server is running is in force on the next request, and one you delete stops
working on the next request too.

A binding whose root depth disagrees with its role is skipped rather than reported. A
hand-written `{"user": "sneaky", "root": "org:acme", "role": "team-admin"}` grants nothing,
because a team-admin root has to be two segments, so an edit that widens a team admin to a whole
org produces an account that administers nothing.

The absence of a write route is also what stops a delegated admin from widening their own
binding. Authority arrives only from someone with access to the state directory.

Two ways delegation goes quiet, and they signal differently. If the state directory cannot be
created at startup, the server logs
`open role-binding store; tenant self-serve delegation disabled` and runs with no delegation
plane at all; every delegated call then answers 403.

The second way is silent. If the file is there but unreadable, or does not parse as JSON, the
lookup returns nothing on each request, no line is logged, and the caller gets the same 403
they would get for holding no binding. Parse the file yourself before you debug a binding.

## Delegation works in one auth mode

The authorization check starts from a username, and that decides which deployments can use
delegation at all.

`dashboard.auth.mode` takes `off`, `token` or `users`, and defaults to `off`. A bearer
principal in `token` mode carries a role and no username, so a delegated call from a `ttk_` or
`tta_` bearer is turned down whatever the bindings file says. Delegation is a `users`-mode
feature: the caller signs in as a named dashboard account, and the session carries the name a
binding matches. Mode `off` resolves every request to a single account named `local` holding the
admin role, which leaves nothing to delegate.

A delegated admin needs no high ladder role. The three routes carry a `viewer` floor, and that
floor is a pre-filter rather than the decision: a viewer with no binding clears it, reaches the
handler, and is turned down there with an audit row. A session-authenticated write also carries
the `x-tt-csrf` header.

## What inside the subtree means

Containment is where a check like this usually goes wrong, so the arithmetic is worth knowing.
A target node is inside a root when it equals the root, or when it starts with the root plus a
`/`. That trailing separator does the work: without it, a binding on `org:acme` would contain
`org:acme-evil`, since one string does start with the other.

Before containment is tested at all, the target has to parse as a node id: `/`-joined segments
of the form `kind:id`, kind in lowercase letters, id in lowercase letters, digits, `-` and `_`.
A `..` segment carries no colon, so `org:acme/../org:evil` fails to parse and is turned away
before any prefix comparison runs. The order matters, because prefix arithmetic on that string
alone would say yes.

A binding names one box in the tenancy tree, and its subtree is that box with everything under
it. The figure that follows draws the tree.

<GovernanceHierarchyDiagram />

The left column of that figure is the tree a binding roots in. A `team-admin` binding rooted at
`org:acme/team:ml` covers that box and everything beneath it: `user:dev1`, and the key bound to
it. It does not cover `org:acme` above or a sibling team beside it. The right column is the
limits attached at each level, and only the spend and quota boxes there take a delegated
override; the rate and concurrency box does not.

A read of a node outside every binding answers 403, not a 404 that would hide the attempt from
whoever reads the audit trail. The body names the rule rather than the boundary, so a probe
learns nothing about the tree, and every branch of all three actions writes an audit row
carrying the acting user's name.

With no `?node`, the read covers the caller's first bound subtree and lists every binding they
hold, so a console you write can switch between them. Its embedded feed of recent refusals
defaults to 100 rows and is clamped at 1,000 without saying so.

## Your delegated admin can read every tenant

The binding bounds two things: what a delegated admin may write, and what the self-serve view
shows them. It does not bound what their account can read elsewhere, and the reason is the
floor that lets them in.

To reach the three routes at all, the account clears a `viewer` floor, so a delegated admin
holds at least `viewer` on the ladder. On the ladder, `viewer` is global read parity across the
dashboard, exports included. The other governance reads take `?node` as a filter supplied by
the caller, and none of those handlers consults the bindings file. An absent `?node` matches
every node in the tree.

So an account holding one `team-admin` binding on one team can call
`GET /tt/api/v1/governance/keys` with no `?node` and read key metadata for every tenant you
have. Node status and spend, the recent-refusals feed, the armed limit definitions, and both
CSV exports answer the same way. That account still cannot write outside its subtree and still
cannot see another tenant through `/governance/selfserve`; it reads the rest of the dashboard
as any viewer does.

<Callout type="warning">

A delegated admin account can read every tenant's governance data: key metadata, node status
and spend, refusals, limit definitions, and both exports. Delegation is safe among colleagues.
It is not tenant isolation.

Where you need per-tenant reads, issue a `tts_` tenant token instead.
`POST /tt/api/v1/governance/tenant-tokens` with `{"node": "org:acme/team:ml"}` returns a
read-only credential frozen to that subtree, and a drill-down above or beside it answers 403.

</Callout>

A tenant token is the read half only: its holder sees the nodes, status, keys and refusals a
delegated admin sees for that subtree, and can mint nothing. Two cautions. The token travels in
the URL path, `GET /tt/api/v1/governance/tenant/{token}`, so any proxy or browser history that
logs request lines captures a live credential; TokenTriage redacts it in its own audit trail,
and the logs in front of it are yours to scrub. And the frozen subtree is checked for being
blank, not for existing, so a token minted against a typo verifies fine and returns an empty
view forever.

One gap sits in that widen-403. A drill-down carrying a `..` segment is not parsed on the
tenant-token path the way the delegated path parses it, so it reaches the prefix test and comes
back as an empty 200. No data crosses a boundary, since the reporter re-scopes with the same
rule and no node id carries a `..` segment.

## Bounded overrides

A cap bump is the next thing teams ask for, and this path keeps you out of the loop for the
ones already inside a bound you set.

A delegated override is the same runtime-plane write an operator makes, with four differences.
The target node has to be inside the subtree. The amount has to be at or under the ceiling the
template declared. A `limit_id` that names no compiled definition is turned down 400 here,
where the operator path takes it, answers 200, and drops it at the reload.

The fourth is attribution. Two audit rows carry the acting user's name rather than the generic
dashboard actor: the dashboard row, which also records the caller's ladder role, and the key
store's own runtime-plane row, which records the role marker `delegated`.

Everything else about overrides is unchanged. They move spend and quota limits only, and a
write is durable and audited at once but does not enforce until the next governance reload,
disclosed as `pending_reload`.
[Raising one node's cap](/docs/build/governance-hierarchy#raising-one-nodes-cap) has the
mechanism.

The ceiling has one case worth naming on its own.
`override: { allow: true }` with no ceiling leaves the operator path unbounded, and any positive
amount is accepted. The delegated path turns that case down: an undeclared ceiling is not read
as permission to raise without one.

Several distinct things can stop a delegated raise, and they do not all announce themselves
alike.

| status | what happened |
|---|---|
| 400 | `limit_id` or `node` is missing, or `amount` is zero or negative |
| 400 | `limit_id` names no compiled definition to bound the amount against |
| 403 | the target node is outside every binding the caller holds |
| 403 | the definition's `override_allowed` is `false` |
| 403 | the definition declares no `override_max` |
| 403 | `amount` is above `override_max` |
| 422 | the limit type cannot hold a per-node override |
| 404 | the delegated write path is not wired on this instance |

Four of those share both a status and an error type of `forbidden`, so a client keying on
either cannot tell them apart. The `detail` string names the case, and the settling answer is
`GET /tt/api/v1/governance/limits`, which publishes `override_allowed` and `override_max` for
every armed definition. The 422 is a different question: `rpm` and `tpm` freeze their cap into
a per-window store when governance compiles, and `concurrency` into a live gauge, so a reload
would drop an override on any of them. The write is stopped at the boundary instead of being
accepted and dropped later.

## What can_write does not tell you

The self-serve read reports a `can_write` field. It is set to `true` for every caller holding a
binding, and is computed from nothing else.

The write path goes missing with the key store behind it, which happens two ways: on an
instance where governance is not armed, and on an armed instance whose `governance.db` failed
to open. The read still answers 200 with `can_write: true`; the mint answers 404
`feature_absent` with a hint naming what to switch on, and that hint covers the first case
only.

<Callout type="warning">

Read `can_write` as "this account holds a binding", not as "this instance can mint". A console
that draws its buttons from the field will draw them on a deployment where both writes answer
404.

Confirm governance is armed on the instance the console talks to, and when it is, check the
startup log for a key-store open failure. The 404 is a deployment answer, not a client bug.

</Callout>

The order of the two checks is worth a note. Authorization runs before the feature-absent
check, so an unbound caller gets a 403 rather than learning which features the deployment runs.
Only a caller who would have been allowed finds out the write path is missing.

## What this page does not cover

The role ladder, dashboard accounts, sessions, and the limiter in front of the login route are
[who can administer governance](/docs/build/governance-access-control). What an override is,
and how a limit resolves through the tree, is
[the tenancy hierarchy](/docs/build/governance-hierarchy). What a minted `ttv_` key carries,
how it is scoped, and how it rotates is [virtual keys](/docs/build/governance-keys). Every
endpoint with its role floor and its shape is
[the management API](/docs/build/governance-api).

<NextSteps items={[
  { title: 'Who can administer governance', href: '/docs/build/governance-access-control', description: 'The role ladder, dashboard users and tokens, and what each role can read.' },
  { title: 'The tenancy hierarchy', href: '/docs/build/governance-hierarchy', description: 'How a limit resolves through org, team, user and key, and what an override can raise.' },
  { title: 'Virtual keys', href: '/docs/build/governance-keys', description: 'The ttv_ lifecycle a delegated mint produces: scope, rotation with grace, revocation.' },
  { title: 'The management API', href: '/docs/build/governance-api', description: 'Every governance endpoint grouped by job, with the role each one needs.' }
]} />
