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

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.

ExplanationLast updated

On this page

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 covers the ladder, and the tenancy 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.

{
  "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.

The governance tenancy tree and its limitsA tenancy tree descends org to team to user to key. A virtual key (ttv_) binds to a tenancy node. Limits attach to a node or to a level: a budget_usd caps the org, a quota_tokens caps the team, and rate and concurrency limits attach at the user level or at a node pinned by name. A key is counted against them through the node it binds to, because no key-level node is created for a template to attach to. Resolution walks the chain deepest-first as a conjunction, so a parent cap binds every child. The engine then evaluates each selected instance in a fixed order — deepest node first, then limit id, then node id — and stops at the first one that is over its cap. It does not compare amounts to find the tightest, so a shallower and tighter cap that also breaches is not the one the caller is told about.org: acmeteam: mluser: dev1keyttv_… (bound to node)budget_usdlevel: org · $1000 / monthquota_tokens · quota_requestslevel: team · per periodrpm · tpm · concurrencylevel: user · rate and in-flight capsDeepest-first conjunction — a parent cap binds every child; the first instance over its cap stops the request

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.

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

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. What an override is, and how a limit resolves through the tree, is the tenancy hierarchy. What a minted ttv_ key carries, how it is scoped, and how it rotates is virtual keys. Every endpoint with its role floor and its shape is the management API.