Operating the broker
The broker is a stateless HTTP process, and a deployment may run more than one of them behind one address (Running more than one pod). Everything it knows lives in Postgres; it reads who may have which secret, and the secrets’ values, from its secret store, AWS Secrets Manager or Google Cloud Secret Manager, and it starts nothing else.
What to run
Section titled “What to run”The broker ships as envoy-broker in the Envoy image, ghcr.io/cybermonklabs/legion/envoy:<commit>,
which .github/workflows/release-envoy-listener.yaml publishes on a push to main that touches
the image’s sources (the image’s default
entrypoint is the Envoy listener, so run it with --entrypoint envoy-broker). Of the builds
waiting at once only the newest runs, and the build that replaces another holds that commit’s
change, so a commit may reach main without an image tag of its own. Pin the image by digest
and verify it as Running Dispatch shows. To build it yourself:
cd packages/envoygo build -o envoy-broker ./cmd/brokerIt takes no flags (it refuses any flag it is given) and reads all of its configuration from the environment. The configuration reference, generated from the loader, lists every variable with its default and accepted values. A minimal production configuration is:
BROKER_LISTEN_ADDR=0.0.0.0:13380BROKER_PUBLIC_URL=https://secrets.internal.exampleBROKER_MACHINE_LOGIN_URL=https://dispatch.internal.example/credentials/machineBROKER_DATABASE_URL='postgres://broker:<password>@db.internal.example:5432/broker'BROKER_K8S_OIDC_ISSUER=https://<cluster issuer>BROKER_K8S_OIDC_AUDIENCE=legion-brokerBROKER_API_ACCOUNTS=dispatch=system:serviceaccount:example-ns:example-dispatchBROKER_SIGNIN_ISSUER=https://accounts.google.comBROKER_SIGNIN_CLIENT_ID=<client id>.apps.googleusercontent.comBROKER_SIGNIN_CLIENT_SECRET_FILE=/var/run/secrets/broker-signin/client-secretBROKER_SIGNIN_DOMAIN=example.comBROKER_SECRETS_PREFIX=production/agent-secrets/BROKER_SECRETS_KMS_KEY_ARN=arn:aws:kms:<region>:<account>:key/<key id>AWS_REGION=<region>BROKER_MACHINE_LOGIN_URL is the page where a person types a machine login’s code, Dispatch’s
/credentials/machine. It is optional. With it, the device authorization grant’s
verification_uri names that page, so a machine shows its person where to go. Without it there is
no device grant at all: POST /v1/oauth/device_authorization is not mounted and answers 404,
and the broker’s authorization server metadata leaves the device code out of
grant_types_supported, which is how Dispatch knows to serve no machine-code page. Everything
else the OAuth provider serves — the token endpoint, the RFC 8693 token exchange, introspection
and revocation — is served either way. Leave it unset in a deployment where every machine holds a
cloud identity and nobody ever types a code. The boot line says which of the two this broker is:
machine login device grant state=on verification_uri=…, or state=off naming the unset
variable.
GET /.well-known/oauth-authorization-server is that metadata, RFC 8414. It needs no credential,
since it is what a client reads before it holds one, and nothing in it is a fact a caller could
not learn by calling the endpoints it names. It names this broker’s issuer
(BROKER_PUBLIC_URL), its token, introspection and revocation endpoints, the grants it serves,
the DPoP signing algorithms it takes, and the subject token types its exchange is configured
with, so a client reads off one document what this deployment turned on rather than being
configured with it twice.
BROKER_API_ACCOUNTS lists the callers of the broker’s named-caller routes, here Dispatch’s
server as the caller dispatch, each by its own account. An account is written in one of two
forms, and the entry’s own text says which: a Kubernetes ServiceAccount subject, as above, or
google: and a Google service account’s address
(listener=google:example-listener@example-project.iam.gserviceaccount.com). A Kubernetes caller
presents its projected token for BROKER_K8S_OIDC_AUDIENCE, verified against
BROKER_K8S_OIDC_ISSUER, and is looked up by the token’s sub, so such an entry needs that pair.
A Google caller presents its machine’s own Google identity token for BROKER_GOOGLE_OIDC_AUDIENCE
— the audience every machine of the deployment already holds a token for, so the Envoy listener on
a machine presents what its metadata server already hands it and mints no second token — verified
against BROKER_GOOGLE_OIDC_ISSUER, and is looked up by the token’s email, so such an entry
needs that audience and no cluster at all. A project’s default Google accounts and Google-managed
service agents are refused here as they are in BROKER_VM_SERVICES. No secret is shared between
the broker and its callers. A token that lives longer than an hour from its issue is refused, and
an account the list does not name is answered 403 ACCOUNT_NOT_LISTED. Through those routes
Dispatch lists what waits on a person and relays a person’s machine-login decisions, their
machine-login revokes and their sign-in revokes; it decides no secret request
(Network and trust).
The four BROKER_SIGNIN_* settings are required, and a broker started without one of them exits
naming the missing ones. They are the page where a person approves a secret request, signed in to
the broker itself (Approval sign-in).
A caller’s name decides which of those routes its account reaches, so listing an account is
not one authority but a choice between them. The broker knows three names: dispatch, which
reaches every route Dispatch’s server calls — the approval, denial, revocation and listing
routes, POST /v1/oauth/revoke, and POST /v1/oauth/introspect; listener, which reaches
POST /v1/oauth/introspect alone, for a resource server such as the Envoy listener that reads
one of the broker’s access tokens back and relays nobody’s decision; and access-review, which
reaches GET /v1/grant-log alone, for the quarterly review that reads the broker’s record of
who asked for what and who said yes, and changes nothing in it. The grant log is no part of what
dispatch and listener carry, and neither of them reaches it. A listed caller on a route its
name does not carry is answered 403 ACCOUNT_LACKS_POWER, naming the caller and the route, and an
entry naming a caller the broker does not know refuses the start, naming that name and the names
it knows, so a typo cannot quietly produce a caller that reaches nothing.
Five settings turn the model judge on, which decides one narrow class of the requests that would
otherwise wait for a person (Concepts): the endpoint
and the id of the model it asks, the audience of the Google identity token the broker presents to
that endpoint, and the Dispatch it reads an issue from with the file holding the broker’s own
projected ServiceAccount token for it. They are all-or-none: with none of them set there is no
judge, and every human-tier request waits for the person who decides it today. A sixth setting
names the protocol that endpoint speaks and has a default. The
configuration reference names each and gives its accepted
values. BROKER_JUDGE_TEST_SKIP_CHECKS turns the judge’s checks before the model off, as the
local stack’s negative control of them, and a production start — one with
BROKER_FAKE_SECRETS_FILE unset — is refused naming it.
BROKER_SECRETS_STORE picks the secret store: aws-secrets-manager, the default when it is unset,
as above, or gcp-secret-manager, which takes the Google Cloud forms of the namespace and the key
and reads nothing from AWS:
BROKER_SECRETS_STORE=gcp-secret-managerBROKER_SECRETS_PREFIX=projects/<project number>/secrets/agent-secrets-BROKER_SECRETS_KMS_KEY_NAME=projects/<project>/locations/global/keyRings/<ring>/cryptoKeys/<key>The prefix names the project by its number, not its id, because Secret Manager answers every
resource name with the number and the broker matches what the API answers (gcloud projects describe <id> --format='value(projectNumber)' finds it); the id after secrets/ ends in -, so
the secret a session asks for as DEEL_API_KEY is projects/<number>/secrets/agent-secrets-deel-api-key.
The key is in the global location, the only one a secret with automatic replication can be
encrypted with. Each store refuses the other’s key variable, so a deployment moved between stores
cannot keep the old key set and silently mean nothing by it.
AWS_REGION is the AWS SDK’s own setting, not the broker’s, read by the AWS store alone: the SDK
takes the region of Secrets Manager and KMS only from AWS_REGION, AWS_DEFAULT_REGION or the
shared AWS config file, never from the instance it runs on. Without one, the broker’s first read of
the namespace fails and it exits at startup with broker: fatal error="list secrets under production/agent-secrets/: operation error Secrets Manager: ListSecrets, failed to resolve service endpoint, endpoint rule error, Invalid Configuration: Missing Region".
BROKER_SERVICE_ACCOUNT_SECRETS binds each google-service-account secret to the one Google
service account the broker may mint a token for, whitespace-separated
<SECRET NAME>=<account email> entries:
BROKER_SERVICE_ACCOUNT_SECRETS="GCP_ADMIN=gcp-admin@example-project.iam.gserviceaccount.com DEV_ADA=dev-ada@example-project.iam.gserviceaccount.com"The name is the uppercase name a session asks for, not the secret’s store id or its slug; the
account is a user-managed service account, <account id>@<project id>.iam.gserviceaccount.com in
lowercase. The broker refuses to start, naming the entry, on a name no agent secret can carry, an
address that is not a user-managed account, a Google-managed service agent (Google’s own services
run as those accounts, and the broker must not be able to impersonate one), or a secret named
twice. One account may back two secrets, since only the secret’s name is the key.
This setting, and not a secret’s stored value, chooses the account: the broker holds
roles/iam.serviceAccountTokenCreator on every account bound here, so a mint happens only where
the binding and the stored value name the same account. A secret the setting does not name, and a
stored value naming any other account, release nothing: the session is answered
502 MINT_FAILED and the broker logs class=binding, before any call reaches Google. Unset, no
secret is bound and every google-service-account release is refused, which is where a
deployment stands between this build and the apply that writes its bindings; the broker still
boots and still serves every other kind. The policy loader reads the setting as well, for one
rule: a secret bound to a dev-<name> account is one person’s own, so it may list no registered
service among its requesters.
The broker refuses to start, naming the variable, when one is missing, malformed or out of range,
and also while a variable it no longer reads is still set. BROKER_DATABASE_URL is the only
database variable: a password goes in the URL itself, URL-escaped, and on Amazon RDS the broker
needs none (Signing in to RDS by IAM token).
A secret owned by a service rather than a person needs that service registered.
BROKER_SERVICES lists each service with the Kubernetes service accounts its pods run as,
whitespace-separated name=<service account> entries, for example
BROKER_SERVICES=legion-daemon=system:serviceaccount:legion:legion-worker for the Legion daemon’s
worker pods. A service whose pods run as several accounts gets one entry for each, for example
legion-daemon=system:serviceaccount:legion:legion-worker legion-daemon=system:serviceaccount:legion:legion-controller.
A pod gets the service’s agent-tier secrets at once when two things hold: a machine login for the
listed service enrolled it, and its projected token proved one of that service’s accounts. Every
other session is denied them. For the same reason each account is bound to one service: the broker
refuses to start, naming both services, if BROKER_SERVICES binds one account to two of them. It
also refuses, naming the account, an account given twice for the same service.
The account must be one the service’s pods name. The broker refuses to start, naming the entry,
if BROKER_SERVICES binds a namespace’s default account
(system:serviceaccount:<namespace>:default). Every pod in a namespace whose spec names no account
runs as default, and any of those pods can get a token for the broker’s audience. A default
binding would therefore prove nothing about which pods are the service’s. Create a dedicated
account, such as legion-worker, set it as the pods’ serviceAccountName, and bind that account.
While a secret’s owner tag names a service the list leaves out, the broker refuses the secret as
owner-tag-malformed (Concepts).
A service’s launcher, such as the Legion daemon, logs in with its own projected ServiceAccount
token, and no person approves it. BROKER_SERVICE_LOGINS lists which ServiceAccounts may log in
as which service, whitespace-separated <service>=<service account> entries, for example
BROKER_SERVICE_LOGINS=legion-daemon=system:serviceaccount:legion-system:legion-daemon-legion,
one entry per daemon. Each account is named exactly, with no wildcard, and logs in as the one
service its entry names.
The broker refuses to start on an entry naming a namespace’s default account, which every pod in
that namespace whose spec names no account runs as, so any pod there could log in as the daemon.
An account BROKER_SERVICES binds for a service’s pods may be listed here as well, which is a pod
of that account logging the service in to enrol itself (agent-secrets enroll --pod).
Every pod of such an account holds a token for it and so logs the service in, and each login
enrols the one pod whose token made it: any other pod UID is refused 403 POD_SELF_ENROLL_ONLY.
A pod enrolling itself states no session name either, 403 POD_SELF_ENROLL_NO_SESSION: the pods
of one account enrol under one principal, so a name such a pod picked would be a sibling pod’s,
and the enrolment would take that sibling’s grants. agent-secrets enroll --pod sends none.
The broker verifies the token as it verifies a pod’s, against
BROKER_K8S_OIDC_ISSUER and BROKER_K8S_OIDC_AUDIENCE, so the setting needs both, and refuses a
token that lives longer than an hour from its issue (exp minus iat, beside a minute of skew) as
TOKEN_TOO_LONG_LIVED: give the daemon’s projected token an expirationSeconds of at most 3600,
and kubectl create token a --duration of at most 1h. Each account holds one launcher
credential: every login moves it to the login’s key and to the token’s expiry, at most an hour
away. A running daemon proves the same key at each login, so nothing about it changes but its
expiry; a restart’s login brings a new key, so the daemon’s earlier key stops working at once and
the pods it enrolled before the restart stay its own. A device-code login that names a service is
refused SERVICE_LOGS_IN_WITH_TOKEN. Dispatch’s machine-login page lists a daemon’s token login
for everyone signed in, naming its account and no approver, and anyone may revoke it, which ends
every pod it enrolled at once; the daemon then logs in again with its token under a new credential,
which enrolls the pods it launches next, while a pod already running stays without secrets until it
is replaced. To stop a daemon logging in, remove its entry and redeploy the broker: its credential
stops authenticating at its next call, and its next login is refused.
An account on that list also registers which pod may write under which session name
(POST /v1/oauth/exchange/pod-sessions), which is how a deployment’s daemon tells the broker that
a pod it has just started owns an issue’s session. That is a second authority the list carries, so
weigh it before listing an account. The request carries the pod’s own projected ServiceAccount
token, which the broker verifies against the cluster’s issuer as it verifies an enrolling pod’s,
bar the one check it does not make here, that the token names a pod: such a token verifies and
the exchange then refuses the registration 400 POD_NOT_BOUND. So a listed account registers a
pod whose token it holds and no other. A caller whose own token names a pod may not register
that pod, 403 POD_CALLER_IS_THE_POD — a pod that wrote its own row would pick the session
name it writes under, including a name a sibling pod holds. The refusal reads the caller’s own
token, so it never holds a caller whose token names no pod, such as one
kubectl create token mints: whoever mints one for a listed account registers every pod of it
whose token they hold, the pod they run in included. So listing a session account, which the
paragraph above describes, does not in itself give its pods that authority over one another:
the authority follows the token, so a pod reaches another only while it holds that pod’s own
token, and a pod holding its own alone reaches nothing here, since its own is the one the
refusal takes away. A pod that does hold a sibling’s registers it, which is how one generation
of a rollout registers the next. The authority goes no wider than the cluster’s own rights to
mint or read a pod’s token — the same kind of access over that pod’s account as the paragraph
below names over a listed one — and it stops short of them: the caller needs a
BROKER_SERVICE_LOGINS token of its own, and the pod’s
account must be one BROKER_EXCHANGE_ACCOUNTS lists, 403 POD_ACCOUNT_NOT_LISTED. A
deployment that does not want that lists no such account.
A daemon that stops registering costs its pods their writes within the registration’s own retention
(24 hours past the last write). Do not list such an account in BROKER_API_ACCOUNTS as well: that
is the separate list of the callers that relay a person’s decisions and read tokens back.
Whoever can get a token for a listed account logs in as its service. Kubernetes access control on
the daemon’s namespace is that boundary: anyone who may create pods there naming the account,
create serviceaccounts/token for it, or exec into the daemon’s pod and read its token file can
get one. Deleting the daemon’s ServiceAccount stops new tokens, but a token already issued keeps
verifying until it expires, at most an hour after it was minted, because the broker checks tokens
against the cluster’s published keys rather than asking the API server. A token login spends its
source address’s rate-limit bucket before its token is verified and its account’s own after, so a
daemon behind an address other callers share is held to that address’s bucket. A login the broker
could not complete (a 5xx, such as while its database is away) spends neither bucket, so the
daemon’s retries through a database outage leave it free to log in once the database is back. Set
BROKER_TRUSTED_PROXY_HEADER behind a proxy, so each caller is keyed on its own address, and
keep the daemon’s and its pods’ traffic to the broker on their own pod addresses rather than
translated to a node’s or a gateway’s. The daemon renews halfway through its credential’s life,
which carries it through brief contention for its address’s bucket; a caller that keeps that
bucket drained for the rest of the credential’s life stops it logging in.
A service login a person approved through a device code before this broker keeps working until
it expires, at most BROKER_LAUNCHER_CREDENTIAL_SECONDS (30 days by default) after its approval:
nothing refreshes it.
A machine holding a Google identity token — a Compute Engine VM, a pod on Google Kubernetes Engine, or anything a workload identity pool mints one for — trades that token at the broker’s token exchange for a Dispatch access token and the machine login that trade mints. It holds no stored credential and no person approves it. Five settings describe its fleet, and the broker refuses to start with only some of them:
BROKER_GOOGLE_OIDC_AUDIENCE: the audience the machines ask their metadata server for. The issuer is Google’s,https://accounts.google.com, unlessBROKER_GOOGLE_OIDC_ISSUERnames a stand-in for a dev stack; the broker reads its keys at startup only when the audience is set, and then logs onegoogle machine identityline naming the issuer, the audience and the machine services. Unset, the broker reads and logs nothing about Google and takes no such token.BROKER_VM_SERVICES: whitespace-separated<service>=<service account email>entries. Use a dedicated user-managed account for each fleet: the broker refuses a project’s default Compute Engine or App Engine account, which every workload in the project runs as by default.BROKER_VM_PROJECTSandBROKER_VM_INSTANCES:<service account email>=<project id>and<service account email>=<instance-name glob>, in Go’spath.Matchsyntax. They pin a fleet to one Compute Engine project and the instance names its machines have. The two go together: set both for an account, or neither. An account pinned in neither takes its machines wherever they run, a token carrying nogoogle.compute_engineclaims included, which is what a workload identity pool and a pod’s metadata server mint.BROKER_VM_EXCHANGE_ACCOUNTS:<service account email>=<project>=<person>[=<session>], the person and the Dispatch project a fleet writes for. An accountBROKER_VM_SERVICESlists and this setting does not gets a token that names its service and no person, which Dispatch and the Envoy listener read. Either way it reaches the fleet’s Dispatch token alone and never its secrets: the machine login the exchange mints names no person, so a machine reads what its service owns and the shared agent-tier secrets, and a person reaches their own on that machine only by signing in on it.
One fleet’s entry, example-fleet, whose machines run as one account in example-project, are
named example-vm-..., and write for one person:
BROKER_GOOGLE_OIDC_AUDIENCE=example-vm-audienceBROKER_VM_SERVICES=example-fleet=example-fleet@example-project.iam.gserviceaccount.comBROKER_VM_PROJECTS=example-fleet@example-project.iam.gserviceaccount.com=example-projectBROKER_VM_INSTANCES=example-fleet@example-project.iam.gserviceaccount.com=example-vm-*BROKER_VM_EXCHANGE_ACCOUNTS=example-fleet@example-project.iam.gserviceaccount.com=EXAMPLE=ada@example.comA machine of that fleet then reads the secrets example-fleet owns and the shared agent-tier
ones, and asks for the rest. ada@example.com is on the Dispatch token it trades for, not on its
machine login: her own secrets stay hers until she signs in on that machine.
When she does — agent-secrets login, which needs BROKER_SIGNIN_DEVICE_AUDIENCE set — that
sign-in’s trade mints a machine login of its own on that box, carrying her rather than the fleet,
and a session enrolled under it reads her secrets and the shared agent-tier ones and nobody else’s.
It is bound to the box key the command makes there, so her sign-in on one machine is not her
sign-in on another. Revoking that sign-in (POST /v1/person-signins/{id}/revoke, which Dispatch
relays from her page) ends the access tokens it minted and the machine login with them, so every
session it enrolled ends at once; the fleet’s own machine login, and her sign-ins on other
machines, are untouched.
That revoke ends the box key, not her grant at Google: agent-secrets login keeps Google’s
refresh token on the machine beside the key, and whoever can read that machine’s files can copy
both and sign in again from elsewhere, as a sign-in of its own that this revoke does not reach.
To cut off a box you believe is compromised, have her revoke the grant at Google — her Google
Account’s third-party connections page, the entry for the deployment’s agent-secrets login
client — which ends every copy of that refresh token at once; the broker’s own revoke is for a
box she has finished with.
A sign-in belongs to the machine it was made on: the same box key presented with another
machine’s identity token is refused invalid_grant naming agent-secrets login, since the
machine login it holds is keyed by the person, the machine and the key together.
The broker refuses an exchange whose token is for another audience or does not verify, whose email
Google does not hold verified, whose account no entry lists, or whose Compute Engine project or
instance name the entry’s pins do not allow, each 400 invalid_request naming what did not match.
Two settings turn a person’s sign-in on. BROKER_SIGNIN_DEVICE_AUDIENCE is the OAuth client id of
the device-flow client agent-secrets login runs Google’s device authorization grant as, which is
the audience a person’s own Google identity token must carry, since Google puts a client’s id in
the aud of the ID tokens it mints for it; set it to the same client id the machines are given as
AGENT_SECRETS_SIGNIN_CLIENT_ID. BROKER_SIGNIN_DOMAIN, which the approval sign-in already
needs, is the Workspace domain the person’s hd claim must name, so a person outside it signs in
nowhere. The issuer of both the person’s token and the machine’s is BROKER_GOOGLE_OIDC_ISSUER,
https://accounts.google.com unless a dev stack names a stand-in, and the audience setting needs
BROKER_GOOGLE_OIDC_AUDIENCE, whose machines are the boxes a person may sign in on. Unset, the
broker accepts no person’s sign-in and refuses either token type by name.
Each machine holds a launcher credential of its own, keyed by the machine and the key its proof
presented: <account>@<project>/<zone>/<instance id>#<key thumbprint> for a token minted on a
Compute Engine instance, and <account>#<key thumbprint> for one minted anywhere else. The key is
part of the name because the identity token is a bearer: a copy of it presented with a key of the
copier’s asks for a credential of its own and moves nothing of the machine’s. So one machine’s
trade never moves another’s credential, and ending one machine’s enrollment leaves the others.
agent-secrets-helper keeps that key in ~/.config/agent-secrets/identity-key.pem, mode 0600,
beside the machine login a machine that proves no identity keeps, and makes one only at this
machine’s first trade. A restart therefore trades the same subject and moves the one credential
the machine already holds, so the sessions enrolled under it keep renewing. A machine that makes
a new key — one whose key file was removed, or a machine rebuilt — leaves its old credential
behind until that credential’s own expiry, and starts a new one at its next trade; the sessions
enrolled under the old one stop renewing when it lapses, and a box enrolled with
agent-secrets enroll --helper is enrolled again by running that command again.
Only a trade that asks for a credential holds one, and a trade asks unless it says otherwise, so
agent-secrets and the helper hold theirs by doing nothing. A person’s sign-in cannot do
otherwise: a sign-in holds its credential, so a trade of a person’s that declined one is refused
400 invalid_request. The clients that
want a Dispatch token and read no secret — the dispatch command, the Oh My Pi and Claude Code
plugins, envoy-send — decline it, since each of them makes a key per process and per session
and a machine-logins list with a row per command run is a list nobody can read. So the machines
on that list are the boxes that enrol sessions, not every process that ever traded.
The credential expires with the token it was minted from, an hour after that token was minted, and
a machine’s session renews only while that credential is live: not revoked, not past its expiry,
and its account still listed for its service. A machine that stops being able to prove its
identity therefore stops renewing within the hour, and its sessions lapse one
BROKER_LEASE_SECONDS later; a copy of a session’s key and enrollment id taken off the box renews
nothing once the box’s login has ended. The broker answers such a renewal 401 ENROLLMENT_NOT_LIVE and logs broker: machine session renewal refused: its machine login has ended. Only a machine’s own session is held this way: a person’s session on that box renews until
they revoke the sign-in, which ends it at once, and a pod’s renewal proves the pod’s own projected
token.
Dispatch’s machine-login page lists each machine’s login, its host and no approver, and anyone signed in may revoke it, which ends that machine’s enrollment; the machine trades again at its next call. A revoke ends the enrollment, not the Google identity token: someone who copied that token off the machine trades it again within its hour, under a machine login of their own key, and reads what that fleet’s service may read. To end a copied token for certain, take the machine out of its fleet and wait out the hour, as a copied machine token says. To stop a fleet trading, remove its entries and redeploy the broker: each machine’s credential stops authenticating at its next call, its next trade is refused, and its sessions stop renewing.
A revoke alone does not cut off a machine whose key may have been copied. The credential is
keyed by the machine and the key it proved, and agent-secrets-helper keeps that key at
~/.config/agent-secrets/identity-key.pem across restarts, so the box’s next trade — within the
hour, since the credential lives only as long as the identity token it was minted from — mints a
new live credential bound to that same key, and whoever holds a copy of the file signs launcher
proofs under the new credential’s id. Three steps, in this order, end that:
- remove
~/.config/agent-secrets/identity-key.pemon the machine; - restart the helper (
systemctl --user restart agent-secrets-helper.service, oragent-secrets-helper install --restart-with-sessions, which also ends the grants of the sessions registered with it), so it makes a new key and its next trade holds a credential of a subject of its own; - then revoke the old login on the machine-login page, by the credential id the old key held, which ends it and every session it enrolled.
In the other order the revoke is undone: the helper still holds the key, and its next trade inserts a live credential on it again, since a revoked row is no longer the one a trade of that subject moves. None of this reaches the machine’s Google identity token, which is what the trade itself proves: a box you believe is compromised comes out of its fleet, as a copied machine token says.
Whoever can get an identity token for the fleet’s account logs in as its machines. Google Cloud IAM
is that boundary: who may run a workload as the account (iam.serviceAccounts.actAs on it), and
who may run code on a machine of the fleet, which can fetch that machine’s token.
On the machine itself there is no enrollment command to run and no key to place: agent-secrets,
agent-secrets-helper and the Legion controller trade the identity the machine already holds
whenever they find no key directory, no sign-in and no helper. What they read is
AGENT_SECRETS_URL, the broker’s public URL, and AGENT_SECRETS_MACHINE_IDENTITY, which says
which identity this machine proves: metadata with AGENT_SECRETS_MACHINE_AUDIENCE, a Compute
Engine machine’s own token from its metadata server, which is the fleet above; kubernetes with
AGENT_SECRETS_MACHINE_TOKEN_FILE, a pod’s projected service-account token; or pool, a token
minted through a workload identity pool, which takes all four of
AGENT_SECRETS_MACHINE_TOKEN_FILE, the projected service-account token the pool federates as the
external credential; AGENT_SECRETS_POOL_AUDIENCE, the pool provider that credential is
presented to, Google’s full
//iam.googleapis.com/projects/<number>/locations/global/workloadIdentityPools/<pool>/providers/<provider>
resource name; AGENT_SECRETS_POOL_ACCOUNT, the service account the pool lets that credential
act as, by email address or unique id, which generateIdToken is called on; and
AGENT_SECRETS_MACHINE_AUDIENCE, here the aud of the token that mint returns, the client id the
broker checks against BROKER_GOOGLE_OIDC_AUDIENCE. A pool machine missing any of the four
trades nothing and its helper restart-loops, as Azure dev boxes did for want of the token file
(theorem-labs/agent-infra#2200). AGENT_SECRETS_SESSION names the session a trade writes under
for a caller that acts as no session of its own — agent-secrets, the helper, a daemon — and is
the only name such a caller has. A caller that is a session writes under its own session id and
reads that variable for nothing: an agent session’s id is minted when the session starts, and its
token must name it, since the Envoy listener refuses a subscribe, an unsubscribe, a role claim or a
send from a session whose token names another. So a fleet whose entry fixes a session, or whose
pods write under registered names, is a fleet no agent session can trade at: the broker refuses
the trade, naming what the entry writes as. For a person to sign in there, give the machine the
device-flow client as AGENT_SECRETS_SIGNIN_CLIENT_ID and
AGENT_SECRETS_SIGNIN_CLIENT_SECRET_FILE, the client whose
id is BROKER_SIGNIN_DEVICE_AUDIENCE. With AGENT_SECRETS_MACHINE_IDENTITY unset nothing trades
and every machine works as it did, through a machine login a person approves.
Approval sign-in
Section titled “Approval sign-in”A person approves or denies a secret request on the broker’s own page,
<BROKER_PUBLIC_URL>/approve/<record>, signed in to the broker with OpenID Connect through an
OAuth client of the broker’s own; Dispatch’s Inbox only links there, and no route a service calls
decides a secret request (Concepts). Four settings are the
sign-in, and every one of them is required:
| Setting | What it is |
|---|---|
BROKER_SIGNIN_ISSUER |
The provider’s issuer, whose discovery document the broker reads at startup and every hour after, in a check of its own (What the sign-in is doing), and for the page at the first sign-in each process serves, which it keeps once a read succeeds; until then a sign-in reads it again at most every 5 seconds. A read that fails stops neither the boot nor the machines the broker serves, so a provider outage never stops it serving them. https://accounts.google.com for Google. An absolute https URL, or http only to a loopback host, for a run on one machine. |
BROKER_SIGNIN_CLIENT_ID |
The broker’s own OAuth client at that provider. Not Dispatch’s: a token or code made for Dispatch’s client signs no one in to the broker. |
BROKER_SIGNIN_CLIENT_SECRET_FILE |
A file holding that client’s secret, read at startup and trimmed; mount it from a Kubernetes Secret. The broker refuses to start while BROKER_SIGNIN_CLIENT_SECRET is set, so the secret is never in its environment. |
BROKER_SIGNIN_DOMAIN |
The Google Workspace domain a person must belong to: the ID token’s hd claim must name it, and its email must be verified. A person’s login is their lowercased email, the form a secret’s owner tag names. |
BROKER_SIGNIN_SESSION_SECONDS |
Optional. How long a sign-in lasts, counted from the moment it is made and never extended: 86400 (24 hours), the default, at most. Signing out ends it at once. A person whose Workspace account is suspended keeps approving until their session’s expiry, which broker end-sessions ends sooner (Ending a person’s sessions). |
A broker missing any of the four refuses to start, naming the ones it is missing: broker: fatal error="the broker's approval page needs BROKER_SIGNIN_ISSUER, BROKER_SIGNIN_CLIENT_ID, BROKER_SIGNIN_CLIENT_SECRET_FILE, BROKER_SIGNIN_DOMAIN; missing BROKER_SIGNIN_DOMAIN". There is
no broker that serves machines but approves nothing: a deployment either signs people in or does
not run.
On Google, create the client once, in the Google Cloud project whose OAuth consent screen is the
Workspace’s Internal one (APIs & Services → Credentials → Create credentials → OAuth client ID,
application type Web application), with one authorized redirect URI:
<BROKER_PUBLIC_URL>/signin/callback, for example https://secrets.internal.example/signin/callback.
Its client id is BROKER_SIGNIN_CLIENT_ID, and its secret goes in the file
BROKER_SIGNIN_CLIENT_SECRET_FILE names. The broker asks for openid email profile with PKCE,
asks for no offline access and keeps no refresh token, and sends the request’s approver as
login_hint, so a person already signed in to Google is asked nothing.
Deploy the broker with these settings before the Dispatch release whose Inbox links the page. In
between, an open Dispatch tab’s Approve on a secret request gets the broker’s
403 DECIDE_ON_BROKER_PAGE naming the page, and nothing is approved on Dispatch’s word.
What the sign-in is doing
Section titled “What the sign-in is doing”GET /signin/status answers what this deployment’s sign-in has done, read from Postgres on every
call, so every pod answers the same thing. It takes no credential, and every word in it is one of
two sets the broker itself chose: no provider’s message, no login and no secret reaches it.
{ "client_check": "ready", "client_checked_at": "2026-03-04T09:00:11Z", "last_sign_in_at": "2026-03-04T08:41:02Z", "last_failed_sign_in_at": null, "last_failed_sign_in_reason": null, "last_decision_at": "2026-03-04T08:41:40Z"}A fact that has never happened is null, so a deployment nobody has signed in to yet is not a
deployment whose sign-in broke. What an operator reads from each field:
client_checkandclient_checked_at: what the broker’s own client was found to be at the provider, and when. Each pod checks it at startup and every hour after, by asking the token endpoint to redeem an invalid code with the broker’s client credentials.readyis the provider accepting the client,client_rejectedis the provider refusing it (nobody can sign in: the client id, its secret or its registration is wrong), andissuer_unreachableis a provider that could not be asked, which says nothing about the client. A check neither answers a person nor stops the broker: it writes this row, and it logs only a change. When it findsclient_rejectedorissuer_unreachableand its pod did not log that state last, it logsbroker: the sign-in client check. When it findsreadyafter such a line, it logsbroker: the sign-in client check finds the client ready again(Health and logs). So a provider that stays away for a day is one line, not one each hour.last_sign_in_at: when a person last finished signing in. Long silence on a deployment people use is the signal, read besidelast_decision_at.last_failed_sign_in_atandlast_failed_sign_in_reason: when a sign-in last failed at the code exchange or at the ID-token check, and why, as one ofinvalid_client,unauthorized_client,redirect_uri_mismatch,invalid_grant,issuer_unreachable,bad_issuer,bad_audience,bad_signature,bad_nonce,not_in_domainandemail_not_verified. The first three are the client’s registration,not_in_domainandemail_not_verifiedare the person rather than the deployment, and a failure none of the eleven names leaves the row alone and is logged instead.last_decision_at: when a person last approved or denied a request on the page.
Ending a person’s sessions
Section titled “Ending a person’s sessions”A broker session lasts BROKER_SIGNIN_SESSION_SECONDS from the moment it was made and is never
extended, so a person whose Workspace account is suspended can still approve until it expires. To
end every browser session one login holds, wherever and whenever it was made, run the subcommand
in a broker pod:
kubectl exec deploy/broker -- envoy-broker end-sessions ada@example.comend-sessions: ended 2 sessions of ada@example.comIt reads the same configuration the serving process does, so it reaches the same database and
signs in to it the same way, binds no listener and applies no migration: an image older than the
servers cannot migrate the database behind them on its way to ending a session. The person’s next
visit to a request’s page signs them in again, if the provider still lets them; to stop that, take
them out of the Workspace or out of BROKER_SIGNIN_DOMAIN first. It is the only subcommand the
broker takes: anything else is refused with a usage line, and envoy-broker with no argument
serves.
Signing in to RDS by IAM token
Section titled “Signing in to RDS by IAM token”On Amazon RDS or Aurora the broker can sign in to its database with an
IAM auth token
instead of a password, so no database password exists for it to hold or for RDS to rotate under
it. It does so when BROKER_DATABASE_URL names a user and no password and its host is an RDS
endpoint, one ending in .rds.amazonaws.com:
BROKER_DATABASE_URL='postgres://broker@<cluster endpoint>:5432/broker?sslmode=verify-full&sslrootcert=/etc/ssl/rds/global-bundle.pem'AWS_REGION=<region>Each new connection signs in with a token the broker mints for that user and host, signed with
the AWS SDK’s default credentials in the region AWS_REGION, AWS_DEFAULT_REGION or the shared AWS
config names; with none, the broker refuses to start. A token is good for 15 minutes from its mint and
is checked only when a connection signs in, so a connection the broker holds longer keeps working,
and the next connection it opens brings a fresh token. The broker’s AWS identity needs
rds-db:connect on the database user,
arn:aws:rds-db:<region>:<account>:dbuser:<cluster resource id>/<user>, and the database user
must be a member of rds_iam. The cluster needs IAM database authentication turned on. Give the
broker a database user of its own, and never grant rds_iam to the RDS master user, directly or
through a role it belongs to: once a user holds it, IAM authentication takes precedence over that
user’s password
(AWS’s limitations of IAM database authentication),
so the master could no longer sign in with its password.
A token is a password to the database until it expires, so the broker sends one only to a server
it has verified. The URL must name that one host, with sslmode=verify-full and an sslrootcert
file (the Envoy image ships the RDS CA bundle at /etc/ssl/rds/global-bundle.pem); otherwise the
broker refuses to start, naming the host: sslmode=require encrypts but verifies nothing, and
sslrootcert=system (in the URL or PGSSLROOTCERT) names the system trust store, which holds no RDS
CA, so every sign-in would fail. The bundle sits outside the system trust store, so nothing else in
the image trusts it. It is the
global bundle AWS publishes,
vendored as packages/envoy/docker/rds-global-bundle.pem with the date it was fetched and its
checksum.
A URL with a password, a passwordless URL whose password libpq supplies (PGPASSWORD or a
passfile), or a host that is not an RDS endpoint (a local Postgres that trusts its clients, say)
connects as given and mints nothing. The broker substitutes no password into the URL: it refuses
to start while the removed BROKER_DATABASE_PASSWORD is set or the URL names its
${BROKER_DATABASE_PASSWORD} placeholder.
What it depends on
Section titled “What it depends on”| Dependency | What the broker needs from it |
|---|---|
| Postgres | A database the broker owns (BROKER_DATABASE_URL). The broker applies its own migrations at startup; they only move forward, so never run an older broker against a database a newer one has migrated. Each process also holds one connection outside its pool, for as long as it runs, to hear what the other processes reread (Running more than one pod). On Amazon RDS it can sign in by IAM token instead of a password (Signing in to RDS by IAM token). |
| Rolling back | Roll forward past a broker that serves kind secrets, never back. A broker before kinds reads no kind tag and would serve a kind secret’s stored GitHub App key or refresh token as its value. agent-secrets secret writes mint-service-account: kind-secret beside every kind, which every broker from legion-envoy-v13.19.0 to the release before kinds refuses as mint-target-malformed; a broker older than legion-envoy-v13.19.0 reads no such tag and serves the value. A kind secret written without the guard (by hand, or by a deployment that does not set it) is served by every earlier broker. To go back anyway, first disable every kind secret’s versions. |
| The secret store | The agent secrets: every secret whose name starts with BROKER_SECRETS_PREFIX, carrying owner and tier (AWS tags, or Secret Manager annotations), encrypted with the agent-secrets key, and holding a current value. A kind tag makes the broker mint a release from that value. Concepts describes the tags, the name each secret is asked for by, and how often the broker rereads them. BROKER_SECRETS_STORE names which store, and the two rows below say what each needs. |
AWS Secrets Manager (aws-secrets-manager) |
The broker calls AWS with the SDK’s default credential chain (environment, shared config, or the workload’s role), in a region (AWS_REGION, AWS_DEFAULT_REGION or the shared config). It needs secretsmanager:ListSecrets (which takes no resource, so on *), secretsmanager:DescribeSecret (to reread one secret) and secretsmanager:GetSecretValue on the namespace’s secrets, kms:Decrypt on the agent-secrets key (BROKER_SECRETS_KMS_KEY_ARN), kms:ListAliases (on *, called only when a secret names its key by an alias), and, when it signs in to RDS by IAM token, rds-db:connect on its database user; nothing else. |
Google Cloud Secret Manager (gcp-secret-manager) |
The broker calls Secret Manager under Application Default Credentials: on GKE, Workload Identity Federation for the pod’s Kubernetes ServiceAccount, with no key file and no Google service account; the boot log’s secret store … credentials=application-default line says so. Its principal needs secretmanager.secrets.list on the project (the listing takes the project as its parent) and secretmanager.secrets.get, secretmanager.versions.get and secretmanager.versions.access on the namespace’s secrets: roles/secretmanager.viewer on the project and roles/secretmanager.secretAccessor with an IAM condition resource.name.startsWith("projects/<number>/secrets/<id prefix>") cover them. The condition narrows only the accessor binding, so the broker reads values from the namespace alone; the viewer role reaches every secret’s metadata in the project (its annotations, its versions’ state and its IAM policy), never a value. A secret is served while its versions/latest is ENABLED, encrypted with the key BROKER_SECRETS_KMS_KEY_NAME names (replication.automatic.customerManagedEncryption; a secret on Google’s default encryption is refused not-on-agent-secrets-key); one with an expire_time is scheduled for deletion and left out, as a Secrets Manager secret in its recovery window is, and a grant made before it no longer reads its value, though Secret Manager still would until that time. Every Secret Manager call but reading a value counts against the project’s read quota, 600 a minute, shared by everything in the project (reading a value has its own 90,000). The broker’s worst case against it is the sum of: a reload every five minutes, one listing plus one call per secret (a 200-secret namespace, about 40 a minute); the public reread route, two each, bounded across all callers of each process at one reread every N² × 1 second, where N is the pods that process counted at its last beat and never fewer than two — one every 4 seconds at two pods, one every 9 at three, one every 16 at four — which holds the whole fleet at the same 120 metadata reads a minute whatever N is (see Running more than one pod); each session’s rereads for names it was not served, two each, at most one per session every ten seconds; and value releases, a kind secret’s among them, one per distinct secret per 30 seconds (two for a secret whose latest version is disabled), since the broker trusts its last check of a secret, readable or not, for that long (so a delete made outside agent-secrets secret stops releases at most 30 seconds after Secret Manager’s own metadata shows it, which Google documents as eventually consistent; agent-secrets secret delete rereads the name, and the broker then releases nothing more). A busy fleet of sessions requesting unknown names can still spend the quota, and other workloads in the same project draw on it too, so give the agent secrets a project of their own. On the metadata calls the broker retries an UNAVAILABLE answer and never a RESOURCE_EXHAUSTED one, which would only spend more of that quota; reading a value keeps the client library’s own retry of both, against its separate quota. On Secret Manager the agent-secrets key gates no caller: Secret Manager’s service agent makes the KMS calls, so writing a secret onto the key and reading it need no KMS permission, and being on the key is the namespace’s convention, not a second permission check as it is on AWS. Secret Manager makes the KMS calls itself, so the broker needs no KMS permission; the Secret Manager service agent needs roles/cloudkms.cryptoKeyEncrypterDecrypter on the key. |
| Kind secrets | A github-app secret needs outbound HTTPS access to api.github.com, and a github-owners tag naming the GitHub accounts it mints for, which the deployment sets when it creates the secret. A google-login secret needs outbound HTTPS access to oauth2.googleapis.com. A google-service-account secret needs outbound HTTPS access to iamcredentials.googleapis.com, an entry in BROKER_SERVICE_ACCOUNT_SECRETS binding it to one account, and the broker’s own Google identity holding roles/iam.serviceAccountTokenCreator on each bound account and on no other, since the broker mints the token by impersonating that account; without the role Google refuses the mint and the session is answered 502 MINT_FAILED. The deployment’s binding, not the secret’s stored value, chooses which account the broker impersonates. A secret bound to no account, and a stored value naming an account other than the bound one, release nothing and are logged class=binding, so a secret someone writes with another person’s account in it mints no token, and a secret the deployment never bound mints none either. What the secret’s own tags still decide is who may ask for it (requesters) and what the token can do (scopes), so the write access to a bound secret is an administrator’s (People who manage secrets). The broker reads the stored value for each release, as it reads any secret, and returns only the minted token. It also reads each kind secret’s value once for each version, kind and shape a pod lists, to check it, and refuses a secret whose value its kind’s minter could not mint from (the value read). It needs no IAM permission to mint a GitHub App or a Google login value, and BROKER_SERVICE_ACCOUNT_SECRETS is the one BROKER_* setting any of the three kinds needs. A mint GitHub or Google refuses answers the session 502 MINT_FAILED, naming their reason, and logs broker: mint failed (below). A call to GitHub or Google that answered a 5xx or a rate limit, or got no answer, is attempted up to three times before that (the mint retry). A narrowing the secret cannot serve, an account its tag does not list or one GitHub refuses, answers 403 NARROWING_OWNER_NOT_LISTED or 422 NARROWING_REFUSED instead and logs broker: narrowing refused. |
| A local stand-in (development only) | BROKER_FAKE_SECRETS_FILE names a JSON file the broker reads in place of the store, in Secrets Manager’s shape whatever BROKER_SECRETS_STORE says, each secret’s kms_key_id naming the agent-secrets key as the selected store configures it (BROKER_SECRETS_KMS_KEY_ARN, or BROKER_SECRETS_KMS_KEY_NAME with the GCP store); the configuration reference gives its format. |
| Dispatch | Dispatch’s server calls the broker’s account routes as its own ServiceAccount: it lists the requests and machine logins waiting on the signed-in person, each secret request with its page on the broker (approve_url), which its Inbox links to, and relays a person’s machine-login decisions and revokes. Set Dispatch’s DISPATCH_AGENT_SECRETS_URL to the broker’s URL and DISPATCH_AGENT_SECRETS_ACCOUNT_TOKEN_FILE to the path of its projected token for BROKER_K8S_OIDC_AUDIENCE, and list that account in the broker’s BROKER_API_ACCOUNTS. Without the URL, Dispatch hides its credential pages. Deploy a broker with the approval page before a Dispatch whose Inbox links it (Approval sign-in). |
| The sign-in provider | A person’s browser signs in to the broker at the provider (Google, under the Workspace’s internal consent screen), and the broker reaches its token endpoint and keys over HTTPS at a sign-in, and its discovery document at the first sign-in each process serves (kept once a read succeeds) and at the client check it takes at startup and every hour after. A discovery that could not be read answers the page 503 naming the issuer and logs approval sign-in: reach the provider; machines are served as before, and GET /signin/status reports issuer_unreachable (What the sign-in is doing). |
| Kubernetes (optional) | To enroll pods, and to let a service log in with its token (BROKER_SERVICE_LOGINS), BROKER_K8S_OIDC_ISSUER and BROKER_K8S_OIDC_AUDIENCE name the cluster’s service-account token issuer and the audience the pods’ and the services’ projected tokens carry. The broker fetches the issuer’s discovery document at startup and refuses to start if it cannot. |
| Google Cloud (optional) | To let machines trade their Google identity tokens, BROKER_GOOGLE_OIDC_AUDIENCE names the audience of those tokens, and BROKER_VM_SERVICES, BROKER_VM_PROJECTS, BROKER_VM_INSTANCES and BROKER_VM_EXCHANGE_ACCOUNTS describe each fleet. The broker fetches Google’s discovery document at startup and refuses to start if it cannot. |
| Envoy (optional) | With BROKER_ENVOY_URL, the broker tells a waiting agent session, best effort, when its pending request expires. |
| The model judge (optional) | Outbound HTTPS access to the model endpoint BROKER_JUDGE_MODEL_URL names, which the broker reaches with its machine’s own Google identity token for BROKER_JUDGE_MODEL_AUDIENCE, and to the Dispatch BROKER_JUDGE_DISPATCH_URL names, which it reads with the projected ServiceAccount token in the file BROKER_JUDGE_DISPATCH_TOKEN_FILE names, read at call time and never at boot, so a rotation needs no restart. It reads one issue and that issue’s spec document there and writes nothing. With those settings unset the broker makes neither call and runs no judge (Concepts). |
Network and trust
Section titled “Network and trust”- Agents, their helpers and launchers, and Dispatch’s server reach the broker at
BROKER_PUBLIC_URL. Every signed proof names that URL plus the request’s path, so clients’AGENT_SECRETS_URLmust be exactly it, and a proxy in front of the broker must not change the path. The broker checks each proof againstBROKER_PUBLIC_URL, never against theHost,X-Forwarded-ProtoorX-Forwarded-Hosta request arrives with: a proxy may rewriteHostor terminate TLS without settingX-Forwarded-Proto, and a client that sends those headers itself changes nothing. That holds for the machine login’s token endpoint (POST /v1/oauth/token) too, whose DPoP proofs nameBROKER_PUBLIC_URL/v1/oauth/token. - The model judge adds two outbound calls. The broker reads one Dispatch issue and that issue’s
spec document at
BROKER_JUDGE_DISPATCH_URL, with its own projected ServiceAccount token, and writes nothing there: no ask, no comment, no status. It calls the model atBROKER_JUDGE_MODEL_URL, presenting its machine’s own Google identity token forBROKER_JUDGE_MODEL_AUDIENCE. Each call carries the broker’s own identity and no session’s, and neither carries a secret value: what reaches the model is the secret’s name and rules, the request’s reason and the issue, as data to judge. - A person approves a secret request in a browser at
BROKER_PUBLIC_URL, which must therefore be anhttpsaddress people’s browsers reach: the page’s two cookies,__Host-broker-sessionand__Host-broker-signin, areSecureand host-only. A decision is accepted only from a form on the broker’s own origin (Origin, orSec-Fetch-Site: same-origin), bound to that browser’s sign-in and to the requests its page printed, so a page on another origin, Dispatch’s included, cannot make one even when both are under one parent domain. The page runs no script and cannot be framed. - A caller listed as
dispatchholds an approval credential: its ServiceAccount token can approve a machine login and revoke grants and machine logins in any person’s name, the broker recording whichever approver that caller names, so whoever holds that account’s token can decide those as anyone. It cannot decide a secret request. List Dispatch’s own account under that name and nothing else, never one an agent’s pod runs as. An account listed aslistenerreads tokens back and decides nothing, which is the whole of what that name carries. - Behind a reverse proxy, set
BROKER_TRUSTED_PROXY_HEADERso the machine-login, token-endpoint and secret-reread rate limiters see each caller’s address rather than the proxy’s. The token endpoint (POST /v1/oauth/token) has its own per-address bucket in each process, a burst of 60 refilled at two a second, far above a machine’s poll every five seconds and refresh about once a day; a refused request is429invalid_request(RATE_LIMITED: …) withRetry-After. - The settings and reread routes take no credential.
GET /v1/settingsanswers the store, the namespace prefix and, per store, the agent-secrets key’s ARN with its AWS account and region, or the Cloud KMS key’s name with the project number.POST /v1/secrets/{name}/rereadmakes the broker read one secret from the store at once, and answers whether it now serves it, as which kind (kind:github-apporgoogle-login, absent for a stored secret) and, if not, why. Neither releases a value. What a reread does tell any caller who can reach the broker is whether a name under the namespace exists, the kind it is served as, and, when the broker leaves it out, which of its reasons applies —absent, or a refusal such asowner-tag-missingornot-on-agent-secrets-key. That is more thanUNKNOWN_SECRET, which says only that no agent secret has the name, says it only to an enrolled session, and never separates a secret that does not exist from one the broker refuses; it is what anyone allowed to list the namespace reads from the secrets’ own names and tags (or annotations). Rereads are limited twice, by limits each pod derives rather than by settings (RereadOverallForandRereadLimitForinpackages/envoy/internal/broker/api/limits.go): the broker as a whole takes one reread everyN² × 250 mson the default store, burst 10, however many addresses the rereads come from — each one takes the policy’s writer lock while it reads the secret, so that lock, not the caller, is what this limit bounds — and each source address gets a burst of 30, refilled one every twice that.Nis how many broker pods the pod serving the call counted at its last beat, never fewer than two, so a two-pod fleet allows one reread a second and an address one every 2 seconds, a three-pod fleet one every 2.25 seconds and an address one every 4.5, and a four-pod fleet one every 4 seconds and an address one every 8. On Google Secret Manager, where a reread spends two reads of the project’s shared metadata quota, the same arithmetic runs over a budget four times smaller: one reread everyN² × 1 secondbroker-wide, burst 10, and an address’s own one every twice that — 4 and 8 seconds at two pods, 16 and 32 at four. A reread counts against either limit only when both let it run, so one address flooding past its own limit leaves the broker-wide one to everyone else. Past either, the broker answers429 RATE_LIMITEDwith aRetry-Afterheader naming the limit that refused, at the rate that limit runs at now: at two pods, 1 second for the broker-wide limit, 4 seconds for Secret Manager’s, and for an address’s own twice whichever of those this process mounted — 2 seconds on the default store, 8 under Secret Manager. Each of these buckets is one process’s, and the broker-wide pair is derived from what the whole fleet spends of the secret store, which is not the same as what it spends in HTTP rereads: one reread the broker accepts costs the store one read per pod, because the pod that served it rereads the secret and then tells every other pod to reread it too. So the fleet reads the storereplicas × this rate × replicastimes a second, and each bucket is the fleet’s whole budget divided by the replica count squared. At two pods:2 pods × 1 reread/s × 2 store reads = 4 store reads a second, which is what one pod alone was allowed, and on Secret Manager2 pods × 0.25 reread/s × 2 store reads × 2 metadata reads = 2 a second, the same 120 metadata reads a minute as before. Read those as the arithmetic rather than as the number two: a third replica divides the fleet’s budget by nine, not by four, and the pod does that division itself. Their burst is not divided at all: it stays at the budget’s ten. A burst is a depth and what it has to admit is one person’s write burst, a hard floor that the replica count does not change, while what it guards — each pod’s own transient, its own burst plus every peer’s — is a soft cost, latency rather than quota, spent once and re-bounded immediately by the rate. Dividing it by the replica count put the depth at 3 for a three-pod fleet, under the floor of four thatagent-secrets’ own end-to-end test measures, so ten now carries six tokens of headroom at any fleet size. The per-address bucket is derived from the bucket this process mounts rather than chosen: it refills at half that bucket’s rate, so it always takes at least two addresses to hold the fleet’s budget at zero, on either store and without tightening a deployment for a bucket it does not mount. That ordering matters because the route takes no credential and a refusal costs a caller nothing, so a caller that merely keeps succeeding at the shared refill rate would otherwise starve the fan-out for everyone. Each pod re-derives all three whenever the number of pods it counts changes, and logs the rate it moved to (broker reread rate, Health and logs). A reread one pod’s notification drives on another spends nothing from either bucket, since the pod that served the original call already paid for it — which is what makes the figures above the whole of the fleet’s store traffic rather than a floor under it (Running more than one pod). Every other limiter is per process and unchanged, so a fleet of N pods allows N times each: this per-address reread bucket, the two machine-login buckets, the token endpoint’s, and each enrollment’s budget of rereads for names it was not served.agent-secrets secretsends one reread after each write it makes, from the person’s own address, so writing more than ten secrets at once meets the broker-wide burst. When a limit refuses it, the write stands in the secret store and the CLI exits 1 saying so (manage a secret); only the narrowing of who may read that secret waits for the next token. - Agents never see the broker’s database or the secret store; whoever can write the database can forge a record, so its access control is part of the broker’s. Whoever can tag (or annotate) a secret under the namespace decides who gets it, so that write access is part of the broker’s too.
People who manage secrets
Section titled “People who manage secrets”The broker writes no secret. A person or an agent creates, changes and deletes agent secrets with
agent-secrets secret (manage a secret), which calls
the store itself under whatever cloud credential its environment gives it - AWS, the sign-in
--profile, AWS_PROFILE or the SDK’s default chain names; GCP, an access token in
CLOUDSDK_AUTH_ACCESS_TOKEN, else the Application Default Credentials, of any type, else the
gcloud login - so that cloud’s own IAM is what decides who may change which secret, and with the
tags (AWS) or annotations (GCP), who gets it. The CLI asks the broker only for its settings
(GET /v1/settings) and, after each write, for a reread. Before anything else it refuses, on AWS,
a sign-in in another account than the agent-secrets key’s (GCP needs no equivalent: every GCP call
already names the project explicitly, so the API itself refuses one in another project). It
refuses no credential for the kind of identity it is, a person’s or a machine’s: the cloud’s IAM
is the boundary, so grant the namespace’s write permissions to no machine identity that should
not write the agent secrets. Whoever holds a credential needs each form’s permissions on the
namespace’s secrets, and the tables below say what each request carries that a policy’s
conditions can match.
One kind is not a person’s to write. A google-service-account secret the deployment binds to an
account (BROKER_SERVICE_ACCOUNT_SECRETS) releases a token that acts as that account, and the
secret’s own tags say who may ask for it and which scopes its token carries, so whoever may write
those tags decides who can get a token as that account. Give every write of a bound secret, its
value’s as much as its tags’, to an administrator: name those secrets in the access you grant,
since the setting names them, rather than testing the kind tag. On AWS that is a condition on the
secret’s own name under the namespace (its ARN, and secretsmanager:Name) for create,
PutSecretValue, TagResource and UntagResource; on GCP, where an IAM condition cannot test
an annotation’s value, keep secretmanager.secrets.update and secretmanager.versions.add on
those secrets for administrators alone.
The binding names a secret by its name alone, so a bound name with no secret behind it is open to
whoever may create a secret in the namespace: they create it under that name, own it, list
themselves, store the bound account as its value, and the broker mints that account’s token for
them. So keep every bound name held by the administrator’s own secret. Create the secret before
its binding is written, and remove the binding before the secret is deleted, since a deleted
secret’s name is free again once its recovery window ends. On GCP, keep
secretmanager.secrets.create on the namespace for administrators alone unless each bound name
always holds its secret.
A google-service-account secret under a name the deployment binds to nothing mints nothing, so
a person who creates one of their own under such a name, with any account in its value, is
answered MINT_FAILED rather than a token. The requesters tag bounds who may ask for a
secret; the binding is what bounds which account it mints for.
AWS Secrets Manager
Section titled “AWS Secrets Manager”| Form | Permissions | What a condition can match |
|---|---|---|
| every form | sts:GetCallerIdentity, which needs no permission |
|
list |
secretsmanager:ListSecrets (on *: it takes no resource) |
|
show |
secretsmanager:DescribeSecret |
The secret’s own tags (aws:ResourceTag/owner, aws:ResourceTag/tier) |
create |
secretsmanager:CreateSecret and secretsmanager:TagResource, since it tags the secret as it creates it; kms:GenerateDataKey and kms:Decrypt on the agent-secrets key |
The new secret’s name under the namespace (its ARN, and secretsmanager:Name); the request tags: aws:RequestTag/owner (a person’s email, from --owner me or --owner <email>, or shared) and aws:RequestTag/tier always; aws:RequestTag/kind and aws:RequestTag/mint-service-account (the rollback guard, kind-secret, written beside every kind) with --kind; aws:RequestTag/github-owners (GitHub logins separated by spaces) with --kind github-app, which needs it; aws:RequestTag/requesters (emails and service names separated by spaces) with --requesters; aws:RequestTag/scopes (OAuth scope names separated by spaces) with --scopes; aws:RequestTag/admin (true) with --admin; aws:RequestTag/shape (token or document) with --shape; aws:RequestTag/max-grant with --max-grant; and aws:RequestTag/default-grant with --default-grant (aws:TagKeys is the tags sent, so a policy that limits it must allow owner, tier, kind, mint-service-account, github-owners, requesters, scopes, admin, shape, max-grant and default-grant); the key, named by its ARN (secretsmanager:KmsKeyArn); the TagResource check made on the new secret sees the requested tags as its own (aws:ResourceTag/owner, aws:ResourceTag/tier), so a policy that reserves a shared secret’s tags for administrators still needs a statement that admits a shared create (request and resource owner both shared, the same tier). A secret created with --owner shared and --tier agent is served to every agent session without approval, so only a person whose access allows a shared create can create one. |
set |
secretsmanager:DescribeSecret, since a set reads the secret’s tags for the kind it checks the value as and the shape it stores the value under before it writes; secretsmanager:PutSecretValue; kms:GenerateDataKey on the agent-secrets key |
The secret’s own tags (aws:ResourceTag/owner) |
retag |
secretsmanager:DescribeSecret, and secretsmanager:TagResource, or secretsmanager:UntagResource for --kind none, --github-owners none, --requesters none, --scopes none, --admin none, --shape none, --max-grant none or --default-grant none. A retag that finds another writer raced it needs both secretsmanager:TagResource and secretsmanager:UntagResource, whichever it sent, since the restore re-sends the tags it read and removes any the race added. A retag that changes the secret’s kind, or the shape of a secret that keeps a kind, also reads the secret’s value to check it as that kind’s minter reads it, before it writes: secretsmanager:GetSecretValue on the secret and kms:Decrypt on the agent-secrets key. The check needs that read and the retag does not: where Secrets Manager refuses the read with AccessDeniedException, the retag writes its tags and prints one line on standard error that says the value was not checked, and the broker then reads the value itself and refuses the secret value-malformed when its minter could not mint from it. Any other failure of the read refuses the retag, which then writes nothing. A retag never prints the value |
A retag reads the secret’s tags, sends one request, then reads them back. A tag request carries owner and tier, the unchanged one re-sent as the secret holds it, plus kind and mint-service-account (the rollback guard) when the command sets a kind, github-owners when it names owners, requesters when it names who may ask, scopes when it names scopes, admin when it marks the secret an administrator’s, shape when it sets a shape, max-grant when it sets one, and default-grant when it sets that; a policy that limits aws:TagKeys must allow each of them. An untag request’s aws:TagKeys is the tags none removes: --kind none removes kind and mint-service-account together, and --github-owners none, --requesters none, --scopes none, --admin none, --shape none, --max-grant none and --default-grant none each remove the tag they name; a retag that removes a tag changes nothing else, so a refused call leaves the secret as it was. A retag that would leave a github-app secret without github-owners, or an admin or google-service-account secret without a max-grant of an hour or less, is refused before any write. When the read-back finds another writer’s change left a github-app secret without github-owners, the retag restores the tags it read: a tag request re-sending them, as the secret held them, and an untag request for any tag the race added; then it exits 1. The secret’s own tags let a policy allow a person to tag their own secret (aws:ResourceTag/owner their email) to themselves or shared, and reserve a shared secret’s tags for administrators |
delete |
secretsmanager:DeleteSecret |
The secret’s own tags; secretsmanager:RecoveryWindowInDays is 30, and secretsmanager:ForceDeleteWithoutRecovery is never set |
restore |
secretsmanager:RestoreSecret |
The secret’s own tags |
Secrets Manager makes the KMS calls itself, on the caller’s behalf, so the agent-secrets key’s
policy can allow them only through Secrets Manager (kms:ViaService of
secretsmanager.<region>.amazonaws.com, with kms:CallerAccount). The email in
aws:RequestTag/owner is lowercase: --owner me writes the Identity Center session name in
lowercase, and --owner <email> lowercases the email it names, so a condition that compares it
with a principal tag holding the caller’s email matches only where that tag is lowercase too.
No form prints a value. One form reads one: a retag that changes the kind, or the shape of a
secret that keeps a kind, calls GetSecretValue to check the value and then drops it (the row
above). delete and restore are permissions of their own: an access that grants a person every
other form on their own secret but not these refuses those two with AccessDeniedException.
GCP Secret Manager
Section titled “GCP Secret Manager”| Form | Permissions |
|---|---|
list |
secretmanager.secrets.list on the project and secretmanager.versions.get on each secret (to tell whether its versions/latest is ENABLED) |
show |
secretmanager.secrets.get and secretmanager.versions.list, secretmanager.versions.get (for its versions and whether versions/latest is ENABLED) |
create |
secretmanager.secrets.create and secretmanager.versions.add. Automatic replication encrypts with the CMEK key through Secret Manager’s own service agent, not the person’s identity: that service agent (service-<project number>@gcp-sa-secretmanager.iam.gserviceaccount.com) needs roles/cloudkms.cryptoKeyEncrypterDecrypter on the key, granted once at setup, not per person |
set |
secretmanager.secrets.get, since a set reads the secret’s annotations for the kind it checks the value as and the shape it stores the value under before it writes, and secretmanager.versions.add |
retag |
secretmanager.secrets.get and secretmanager.secrets.update: owner and tier annotations in one call, plus kind and mint-service-account (the rollback guard) when the command sets a kind, github-owners when it names owners, requesters when it names who may ask, scopes when it names scopes, admin when it marks the secret an administrator’s, shape when it sets a shape, max-grant when it sets one, and default-grant when it sets that; or, for none, the annotations it removes (--kind none removes kind and mint-service-account together), and nothing else. A retag that changes the secret’s kind, or the shape of a secret that keeps a kind, also reads the value of versions/latest to check it as that kind’s minter reads it, before it writes: secretmanager.versions.access on the secret. The check needs that read and the retag does not, and people who add versions and change annotations often hold no accessor role: where Secret Manager refuses the read with PermissionDenied, the retag writes its annotations and prints one line on standard error that says the value was not checked, and the broker then reads the value itself and refuses the secret value-malformed when its minter could not mint from it. Any other failure of the read refuses the retag, which then writes nothing. A retag never prints the value |
delete |
secretmanager.secrets.update (sets expire_time to 30 days out) |
restore |
secretmanager.secrets.update (clears expire_time) |
Unlike AWS’s tags, a GCP IAM condition cannot test an annotation’s value, so GCP has no per-secret
counterpart to AWS’s “a person may tag only their own secret” conditions above, and nothing stops
a write from reaching another person’s secret: whoever is granted secretmanager.secrets.update
on the namespace can retag any secret under it to any owner, tier or kind, shared secrets included,
and whoever is granted secretmanager.versions.add can replace any secret’s value under it. On
Secret Manager, grant write under the namespace - secrets.create, secrets.update and
versions.add alike - to administrators only, and let people ask an administrator to write on
their behalf.
Health and logs
Section titled “Health and logs”GET /healthz answers 200 {"status":"ok"} while the broker can reach Postgres, and
503 DATABASE_UNAVAILABLE when it cannot. From the signal that stops it, it answers
503 SHUTTING_DOWN instead, for the whole drain, while every other route goes on being served
exactly as before. Use it as a readiness probe and never as a liveness probe: a liveness probe
reads a draining pod as a dead one, so the kubelet would kill the container in the middle of the
drain and drop the requests the drain exists to finish
(Running more than one pod).
The broker writes its log to stderr as one JSON object per line, in the shape Cloud Logging reads
as a structured entry with a severity of its own. Each object carries time (RFC 3339 with
nanoseconds, in UTC), severity (INFO, WARNING or ERROR: the broker writes nothing below
INFO), message, and each field of the line as a member of its own. A duration is written as
Go writes one, such as "every":"1m30s". This is the line a broker writes once it serves, here on
the local stack:
{"time":"2026-11-14T02:26:30.105447105Z","severity":"INFO","message":"broker listening","addr":"127.0.0.1:46052"}A line records a change of state, not a pass of a loop. The five-minute policy reload, each of the request sweeper’s sweeps, the hourly sign-in client check and the read of a kind secret’s value each write one line when they start to fail and one when they succeed again. The policy writes a line when a secret’s state changes, not at each load. A standing state is what the metrics hold (Metrics).
The table names each line by its message, then its fields as field=<value>, and says the
severity it is written at (WARN is written WARNING). The lines worth alerting or searching on:
| Log line | Meaning |
|---|---|
broker stop budget drain=<drain> shutdown=60s total=<their sum> |
The first line of the run, before the database or the secret store is reached for: the whole budget a stop spends, BROKER_DRAIN_SECONDS and the 60 seconds in-flight requests get after it. total is what a deployment’s terminationGracePeriodSeconds has to cover, and with the defaults the line reads drain=5s shutdown=60s total=65s (Running more than one pod). |
database sign-in method=<method> |
At startup, before the broker reaches for AWS or its database: rds-iam when each connection signs in with an RDS IAM token the broker mints, password when it signs in as BROKER_DATABASE_URL and libpq’s defaults say (a password in the URL, PGPASSWORD, a passfile, or none for a server that trusts it). |
secret store store=<store> credentials=<source> |
At startup, once the store is built: aws-secrets-manager under the AWS SDK’s credential chain (aws-sdk), gcp-secret-manager under Application Default Credentials (application-default), or either name over BROKER_FAKE_SECRETS_FILE (fake secrets file). |
approval sign-in on issuer=<issuer> client_id=<client id> domain=<domain> |
At startup: the approval page signs people in through that client, holding each person to that Workspace domain (Approval sign-in). The client’s secret is never logged. |
broker: the sign-in client check state=<state> issuer=<issuer> error=… |
At WARN, from the check each pod takes at startup and every hour after, when it finds a state other than ready that the pod did not log last: the provider refused the broker’s client (state=client_rejected, so nobody can sign in) or could not be asked (state=issuer_unreachable). A check that finds the state the pod logged last, or finds the client ready with no such line before it, logs nothing, so a provider that stays away for a day is one line. Every check writes its answer to GET /signin/status. |
broker: the sign-in client check finds the client ready again issuer=<issuer> was=<state> |
At INFO: the first check that finds the client ready after the line above. was is the state that line named. |
broker: the sign-in client check was not written state=<state> error=… |
At WARN: a check could not write its answer to Postgres after the check before it wrote, so GET /signin/status answers the last answer written. The checks after it log nothing more until one writes. |
broker: the sign-in client check is written again state=<state> |
At INFO: the first check that writes its answer after the line above. |
approval sign-in login=<email> / approval sign-out login=<email> |
A person signed in to, or out of, the approval page. |
approval sign-in refused … |
At WARN: a sign-in made no session, the line saying why: the provider refused it, its state or return path was not the sign-in’s, the code exchange or ID token failed, or the person is outside BROKER_SIGNIN_DOMAIN or unverified. |
approval page decision login=<email> record=<record id> decision=<approve or deny> outcome=<outcome> |
A person decided a secret request on the page; outcome is what became of it (granted, for one). |
approval sign-in: reach the provider issuer=<issuer> error=… |
At ERROR: the provider’s discovery document could not be read at a sign-in, and the page answered 503 naming the issuer. |
broker listening addr=<host:port> |
Startup finished: configuration, migrations and the first read of the namespace all succeeded, and every port is bound: the API’s, at this address; the airlock’s proxy port, where the deployment has one (airlock proxy listening addr=<host:port>, just after); and the metrics port (the line below). |
broker metrics listening addr=<host:port> |
After broker listening, and after airlock proxy listening where the deployment has an airlock: the metrics port is bound at this address (Metrics). A pod that cannot bind it does not start, and logs broker: fatal naming the address. |
broker: fatal error=… |
Startup refused; the error names the variable or dependency. The process exits 1. |
agent secret policy refused name=<secret's name in the store> reason=<reason> owner=<owner tag> first_load=<true or false> |
At ERROR, when a secret becomes one the broker refuses to serve, and when it is refused for another reason than before, with was=<the reason before>: one line at the change, not one for each read of the store. A pod’s first load writes one for each secret it refuses, each with first_load=true. A load or a reread that finds a secret refused for the reason it had writes nothing, and broker_unservable_secrets (Metrics) holds every secret refused now. name is the secret’s whole name in the store, such as production/agent-secrets/deel-api-key. owner is its owner tag, and empty for owner-tag-missing and owner-tag-malformed. Reasons include owner-tag-missing, owner-tag-malformed, tier-tag-missing, tier-tag-malformed, name-malformed, service-owner-human-tier, not-on-agent-secrets-key, no-current-value, kind-unknown, github-owners-missing, github-owners-malformed, requesters-malformed (a requesters list holding a comma, naming nobody, carrying an email with a capital letter, or naming a service the deployment does not register), max-grant-required (an admin secret, or a google-service-account one, with no max-grant or one longer than an hour), requesters-not-owner (a google-service-account secret whose requesters are not its one person owner, or that lists a service it may not: any service at agent tier, and any service at all on a secret BROKER_SERVICE_ACCOUNT_SECRETS binds to a dev-<name> account), admin-malformed (an admin tag set to anything but true), admin-tier (an admin secret that is not human tier), admin-requesters (an admin secret with no requesters list), scopes-malformed (a scopes list holding a comma, naming no scope, naming one twice, or holding an entry that is no scope name, a whole https://www.googleapis.com/auth/… URL among them), scopes-kind (a scopes tag on a secret that is not a google-service-account one), shape-malformed (a shape tag set to anything but token or document), max-grant-malformed, default-grant-malformed and value-malformed (a github-app, google-login, google-service-account or azure-login secret whose stored value that kind’s minter could not mint from: the value read). |
agent secret policy no longer refused name=<secret's name in the store> reason=<the reason it had> owner=<owner tag> now=<servable or absent> |
At INFO, when a secret the broker refused is refused no longer: now=servable when the broker serves it, and now=absent when the store no longer holds it (deleted, or scheduled for deletion). |
agent secret policy load failed; previous policy kept error=… |
At ERROR, in two cases. The five-minute reload of the namespace failed (the secret store out of reach, or refusing the broker; a Secret Manager failure names its gRPC status code alone, never the status message), and the broker keeps serving the policy from its last good read: the first failed reload of a run writes the line, and the reloads after it write nothing until one succeeds, which writes agent secret policy load succeeded again. Or, with name=<NAME> (the name a request asked for), the request named a secret the broker did not serve and the broker’s reread of that one secret failed, so the request is refused UNKNOWN_SECRET: one line for each such request. A read in flight when the broker shuts down, and a request’s reread its own caller abandoned, are not one of these and log nothing. |
agent secret policy load succeeded again |
At INFO: the first reload that succeeds after the line above began a run of failed ones. |
agent secret value read failed; its state kept name=<secret's name in the store> version=<version> error=… |
At WARN: the broker could not read a kind secret’s stored value to check it (the value read), for a permission, a timeout or the store’s own failure, which error names by its call and its class and never by a byte of the value. A stored value that is not text is no failed read: it is refused value-malformed. The secret keeps the state it had, but for a value-malformed refusal of an older version: a failed read of a version the store listed since ends that refusal, which writes agent secret policy no longer refused. Each load after it reads the value again and writes nothing more until a read succeeds. |
agent secret value read succeeded again name=<secret's name in the store> version=<version> |
At INFO: the first read of that secret’s value that succeeds after the line above. |
broker: answer refused answer_id=<id> status=<status> code=<code> method=<method> route=<route> caller_class=<class> caller=<caller> secrets=[<names>] secrets_count=<n> |
At WARN, once for each refusal of the API port and of the airlock’s proxy port: every answer from 400 to 499 but a 429 and the two answers a machine gets while it polls a device login, slow_down and authorization_pending, which are counted and not logged. It is written before the answer is sent, and it names the answer id the refused caller was given (Refusals and the answer id). It lists the first secret names the request named, up to 32 KiB of names as the line writes them (JSON-escaped, each with its two quotes), counts them all in secrets_count, and adds secrets_omitted=<n> when it left names out, since Cloud Logging refuses an entry over 256 KiB. An answer Go’s HTTP server writes itself before any of the broker’s code runs, such as a 400 for a malformed request line, has no answer id and writes no line. |
broker: <operation> failed error=… |
A request failed with a 500 or 503; the line carries the cause the response does not. A reread whose own request ended before it finished - its caller went away, or it reached the 45-second bound - is not one of these: it answers 503 REQUEST_ENDED and logs nothing, since anyone can end a request. One whose request ended only while it read the value of a github-app, google-login, google-service-account or azure-login secret has already put the secret’s new tags in force on that pod, so it still tells the other pods to reread the secret, as a 200 does. |
broker: mint failed name=<NAME> kind=<kind> class=<class> attempts=<attempts> error=… |
At ERROR, for each release of a kind secret its minter could not make. class says whose fix it is: stored-value, the secret’s value is not a key or login the kind takes, so its owner stores a new one; binding, a google-service-account secret the deployment binds to no account, or to an account its stored value does not name, so the operator fixes BROKER_SERVICE_ACCOUNT_SECRETS — this one is not the secret’s owner to fix by storing a new value, and the mint is refused before any call reaches Google; refused, GitHub or Google answered and refused, such as a Google login that needs a new sign-in or a GitHub App with no installation on a listed account, so its owner signs in again or fixes the App; upstream, GitHub or Google could not be asked, answered no usable reply or rate limited the call, or answered a narrowed GitHub token holding more than the narrowing asked, which the broker revokes at GitHub, so check the broker’s egress and the upstream’s status. attempts is how many times the broker sent the call the mint failed at, 1 to 3 (the mint retry), and 0 for a failure that was no call’s, such as a stored value the minter could not read. A stored-value failure also refuses the secret as value-malformed, so its grants release nothing until a value that decodes is stored. A narrowing the caller got wrong is never this line: see broker: narrowing refused. The error never holds the stored value, a token or a value from GitHub’s answer. The session is answered 502 MINT_FAILED with the same error. |
broker: a stored value's trailing line ending was removed name=<NAME> |
At INFO, once for each value of a release the shape rule changed, so twice for one release that changed two values: both the value a session is handed and the input a minter reads pass that rule. The line carries the secret’s name and no part of any value. The secret’s shape tag says how the broker reads its value, and a secret carrying no tag is read by the value itself. An owner whose value the rule reads wrongly answers the line with one retag, agent-secrets secret retag <NAME> --shape document. |
broker: GitHub did not revoke a narrowed token it minted wider than asked; it lapses at its expiry app=… installation=… expires_at=… error=… |
At WARN, beside a mint failed … class=upstream line: GitHub answered a narrowed token holding more than asked, the broker refused it, and GitHub did not confirm the revoke the broker then sent. A 401 or 404 to the revoke’s first attempt is GitHub refusing it and writes this line, since nothing in the revoke can have revoked a token minted moments before. A 401 or 404 to a retried attempt counts as confirmed and writes no line, since an earlier attempt may have revoked the token and lost its answer; an earlier attempt GitHub rate-limited, which revoked nothing, cannot be told apart from that. No caller got the token; GitHub honours it until expires_at, at most an hour. Check the broker’s route to GitHub, as for the line it follows. |
broker: narrowing refused grant=… enrollment=… name=<NAME> error=… |
At WARN, for each release whose narrowing of its GitHub App tokens names what the secret cannot serve: an account its github-owners tag does not list, refused before any minter runs (403 NARROWING_OWNER_NOT_LISTED), or a narrowing GitHub answered 422, such as a repository the installation does not reach (422 NARROWING_REFUSED); a 422 that says it is a rate limit is an upstream mint failed instead. Nothing was released. The caller fixes its narrowing; the App and the secret are not at fault, so this line needs no action unless one session repeats it. |
broker: grant log … |
At INFO, once for each request decision, each request.judged row and each grant end. Every line carries the event’s kind, its actor, whichever of the enrollment, request and grant ids the row names, and raw, the audit row’s own detail; it carries no audit id and no timestamp, since the line is written once the row has committed and the log keeps its own time. The rest is what the writer of that line had in hand, which differs by line: a request.created line adds the session and its person, the service the broker verified, the service the session’s credential claims (claimed_service), the secrets and how each was decided, the state, the approver, the lifetime, the reason and the detail; a request.granted or request.denied line adds the session and its person, the claimed service, the state, the approver, who decided, the lifetime, the reason and the detail, and neither the verified service nor the secrets; a request.cancelled or request.expired line adds the state, the detail and, for a cancel, who decided; a request.judged line, written for every judge run that reached a verdict, its referrals and its failed checks included, adds the session and its person, the claimed service, and the grant where the verdict granted one; a grant.revoked or grant.withheld line adds nothing more, except the session’s person when a person’s revoke ended it. A reader joins a line to its request’s own request.created line by request_id for the rest, or reads that row from GET /v1/grant-log, which joins the tables and holds every field. The line is the copy a deployment’s log pipeline keeps of the rows that route answers from the audit table, so a closed period’s decisions can be read without the database (the audit record). A release (grant.used) and an enrollment row write no such line. |
broker sweeper: ended an enrollment whose lease lapsed enrollment_id=… kind=… runtime_id=… slot=… lease_expired_at=… grants_revoked=… requests_cancelled=… left_dormant=… |
A session stopped renewing (a pod that is gone, a box whose agent-secrets renew stopped) while a broker was serving; the sweep ended it, revoking its grants and cancelling its pending requests. A lease that ran out while no broker was serving is not ended (when no broker is serving). |
broker sweeper: end lapsed enrollments sweep=lapsed-enrollments error=…, and the failure lines of the six other sweeps |
At WARN, when one of the seven sweeps the sweeper runs every BROKER_SWEEP_SECONDS fails after its run before did not. That sweep logs nothing more until it succeeds, which writes broker sweeper: a sweep that failed succeeded again, so a database that stays away for an hour is two lines for each sweep and not one for each run. Each sweep’s line has its own message and sweep: broker sweeper: end lapsed enrollments (lapsed-enrollments), broker sweeper: end dormant enrollments (dormant-enrollments), broker sweeper: expire pending requests (pending-requests), broker sweeper: expire pending machine logins (pending-machine-logins), broker sweeper: delete spent OAuth state (spent-oauth-state), broker sweeper: delete expired browser sessions (expired-browser-sessions) and broker sweeper: delete the placeholders of grants that have ended (ended-grant-placeholders). A sweep the broker’s shutdown cuts short logs nothing. |
broker sweeper: a sweep that failed succeeded again sweep=<sweep> |
At INFO: the first run of that sweep that succeeds after its failure line. sweep names it as that line did. |
broker: gave back the time no broker was serving to every lease still running when the last broker served gap=… last_serving_at=… enrollments=… |
At INFO, on the first beat after more than three sweeps in which no broker beat: every broker process was down, or none could reach the database. Each enrollment not revoked whose lease was still running at last_serving_at had its lease moved on by gap, but no further than BROKER_LEASE_SECONDS from now; enrollments counts them. Their sessions renew as before. |
broker: the serving clock could not beat; it retries every interval and logs again once it beats error=… timed_out=… |
At WARN: this process could not record its beat, usually because the database is out of reach. A beat may take at most one BROKER_SWEEP_SECONDS; timed_out=true says it ran out of that (its deadline, or a lock or statement past it), as when another transaction holds this process’s row, while the other brokers go on beating. It retries every BROKER_SWEEP_SECONDS and logs broker: the serving clock beats again once it does. |
broker reread rate replicas=<n> every=<interval> burst=10 |
At INFO, at this pod’s first beat of the serving clock and then each time the number of pods it counts changes: the limits it now serves POST /v1/secrets/{name}/reread at. every is how often the broker-wide bucket takes one reread back, and an address’s own bucket takes one back every twice that. A pod that has not beaten yet serves at the two-pod rate (Running more than one pod). |
broker reread rate above the replica count this sizing supports replicas=<n> every=<interval> store_reads_at_burst=<reads> |
At WARN, beside the line above when a pod counts more than four: the rate is derived the same way, but the burst of 10 is not divided, so a burst at that count reads the secret store store_reads_at_burst times at once, which this sizing was not chosen for. A pod logs it once, when its own count first goes above four, and again only if that count changes while it stays above four. A fleet that stays larger has outgrown the budget limits.go divides, which is where to change it. |
broker: pod enrollment renewed without its projected token enrollment=… pod_uid=… slot=… |
At WARN: a pod enrollment renewed without its token, and no enrollment naming its pod UID has ever renewed with one, so the broker accepted it; for that pod a token copied out of it still keeps an enrollment alive. It is expected from a pod on a worker image whose agent-secrets renew predates --pod-token-file. A pod on the current image sends its token from its first renewal, so a line naming such a pod means a holder of an enrollment key for it is renewing outside the cluster before the pod’s own first token renewal. List pod images below and replace an older-image pod. If its pod UID is absent or every image is current, end the login that made the line’s enrollment, which ends every enrollment it made, once its subject shows whether it is a Legion daemon’s (the renew token check gives the query and both ways to end it); never pick a machine-login row by its machine name. |
broker: pod enrollment renewal refused: projected token required enrollment=… |
At WARN: a pod enrollment renewed without its token after an enrollment naming the same pod UID, this one or another, had renewed with one, and was refused. One cause is someone holding its key, possibly enrolled under another machine login with a token copied out of the pod, renewing outside the cluster. The other is an old-image pod refused because someone else marked its UID first: whoever holds an unexpired copy of an old-image pod’s token and a launcher credential that enrolls pods can enroll a key of their own on that UID and renew it with the copy, which marks the UID, and from then on the real old-image pod, whose renewer sends no token, is refused here until its lease lapses. That is weaker than what the copy already allowed, which was to enroll and renew as the pod. The refused enrollment’s pod UID names the pod: list pod images (below). If that pod runs an older image, the copy’s rows are the marked rows of that pod UID: end each one’s login once its subject shows whether it is a Legion daemon’s (the renew token check), never picking a machine-login row by its machine name, and replace the pod with a current image. Otherwise the copy is the line’s own enrollment, as that section says. A current-image renewer that cannot read its token file sends nothing at all, so the broker logs nothing for it; agent-secrets renew prints ERROR: enrollment <id> cannot read its pod token instead, which the worker shim relays into the pod’s log. Its lease and grants end one BROKER_LEASE_SECONDS after its last valid renewal. |
broker: machine session renewal refused: its machine login has ended enrollment=… |
At WARN: a machine’s own session asked to renew while the launcher credential its Google identity token minted was no longer live — revoked, past the hour’s expiry that token gave it, or its account no longer listed for its service in BROKER_VM_SERVICES. The renewal is answered 401 ENROLLMENT_NOT_LIVE and the session lapses within one BROKER_LEASE_SECONDS. It is expected after a fleet is taken out of the settings and after a machine stops trading. A line for a machine that is still running and still listed means something holding that session’s key is renewing after its box’s login has ended, which is what this refusal is for: see a copied machine token. |
broker draining drain=<drain> |
SIGTERM or SIGINT: from here on GET /healthz answers 503 SHUTTING_DOWN while every other route is served and the listener stays open, for BROKER_DRAIN_SECONDS. A second signal ends the wait at once, logged as broker: a second signal ended the drain early. |
broker shutting down |
The drain is over: the broker stops accepting, and the requests still in flight get 60 seconds to finish. That is the server’s own WriteTimeout — the 45-second request deadline plus the 15 seconds a handler needs to write its answer — because it is the longest exchange the broker allows a request past its headers: one admitted in the drain’s last instant still gets its whole deadline. Shutdown closes idle connections at once, and one that has gone five seconds without sending its headers counts as idle, so the budget covers the requests in flight rather than every connection open. |
broker: listening for the other pods' secret rereads channel=… |
The reread fan-out’s receiving half has its LISTEN connection. The boot waits up to five seconds for this line before it binds, so on a healthy pod it comes just before broker listening; after that it reappears on every redial. |
broker: a Postgres listener has no connection; redialing channels=… error=… attempt=… wait_ms=… |
At WARN, from pglisten: this pod has lost its LISTEN connection and is redialing with a backoff that reaches thirty seconds. A pod logging this is outside the fan-out for every name — losing the LISTEN does not by itself fail readiness, so the pod can go on passing /healthz and serving the whole time while a retag another pod rereads does not reach it until its own five-minute reload. It often does fail readiness at the same time, since /healthz pings Postgres and a database the pod cannot reach is the commonest reason the LISTEN went: a 200 beside this line is the case to worry about, not the normal one. It is not the only way a pod falls outside: the three rows below leave one behind for at least one name, and of the four only the other pods were not told to reread a secret and a notification's reread of a secret did not run can be matched to a secret. Repeating lines mean the database is not carrying LISTEN sessions for this pod; one or two around a failover are expected. |
broker: binding before the reread listener has had an answer waited=5s |
At WARN: the boot’s five-second wait for that LISTEN ran out, so this pod began serving before it could hear its peers. It serves normally and the listener keeps redialing; what it loses is the notifications sent in the gap, with its own five-minute reload as the backstop. A pod that logs this takes no catch-up load either, there being no subscription to order a read after. |
broker: serving without the boot's catch-up policy load, so a retag another pod served while this one was starting may not be in force here until its five-minute reload error=… |
At WARN: once its LISTEN is in place a booting pod reads the namespace a second time, so that a retag a peer served while it was still loading is in force before it binds. That read failed. The pod serves what its first load gave it and catches up at its five-minute reload; the first load succeeded, so this is never a pod with no policy at all. |
broker: the other pods were not told to reread a secret secret=… error=… |
At WARN, not a failed request: the reread this pod was asked for read the secret’s tags and answered 200, or 503 REQUEST_ENDED when its request ended during the value read alone, and only the Postgres NOTIFY that tells the other pods failed. The retag is in force here and reaches the others at their own next reload. |
broker: a notification's reread of a secret did not run secret=… sender=… error=… |
At WARN, on no request at all: another pod told this one to reread a secret and the reread failed. This pod goes on serving the tags it last read until its own five-minute reload. |
broker: a second signal arrived while the broker was still starting up, so it is stopping without a drain: it had not begun serving when that signal was read |
At ERROR: two signals reached a process that had not finished booting, so it exited non-zero without draining. The line says what was true when the second signal was read, which is what the process can observe; a boot that bound its socket in the two statements after that read logs it too. The lines above it say how far the boot got. Expected only when someone or something stopped a pod twice during startup. |
broker: signalled while starting up, before it had bound anything stage=… |
One signal reached the process before it bound its socket, so it finished the stage it was in and stopped cleanly, serving nothing and running no drain. stage is the first policy load or the catch-up policy load. |
Each request is bounded to 45 seconds. Request bodies are JSON of at most 1 MiB.
Refusals and the answer id
Section titled “Refusals and the answer id”Every answer of the API port and of the airlock’s proxy port carries an answer id, 32 hexadecimal
characters, in its X-Broker-Answer-Id header. An answer from 400 to 499 carries it in its body
too: a JSON one as its answer_id member, a refusal of the airlock’s proxy port at the end of its
text as (answer <id>), and a refusal page of the approval page as an id to quote. A 429 is no
refusal, so two bodies carry no id: the text of a 429 that the Azure token endpoint answered and
the airlock’s proxy port forwards, and the approval page’s 429 page. The header of each still
carries it. The exception on both ports is the answers Go’s HTTP server writes itself before any
of the broker’s code runs, such as a 400 for a malformed request line, an invalid header name
or value, or a missing, repeated or malformed Host header, a 431 for headers over its limit,
a 417 for an Expect header it does not know, the 400
Client sent an HTTP request to an HTTPS server on the airlock’s bound connections, and the
200 to OPTIONS *. They carry no answer id and write no refusal line. agent-secrets ends
each error the broker answers with the id, a refusal or a failure, so whoever was refused has a
thing to quote to whoever runs the broker:
$ agent-secrets NO_SUCH_SECRET -- trueagent-secrets: no agent secret has this name (UNKNOWN_SECRET) (answer ed22ee417e5933b629d7182fc52ce9e6); asked the broker AGENT_SECRETS_URL names (AWS Secrets Manager)For a refusal, the broker writes its line, broker: answer refused, before it sends the answer,
so that id finds the line:
{"time":"2026-11-14T02:28:40.768728789Z","severity":"WARNING","message":"broker: answer refused","answer_id":"ed22ee417e5933b629d7182fc52ce9e6","status":400,"code":"UNKNOWN_SECRET","method":"POST","route":"POST /v1/requests","caller_class":"session","caller":"be3624bc-7b88-4e9c-8657-ebbf4b2651d7","secrets":["NO_SUCH_SECRET"],"secrets_count":1}Its fields:
answer_id: the id the answer carries.status: the answer’s HTTP status, from 400 to 499.code: the code the answer names. The broker reads it from the answer: its JSON body’scode, else the body’s OAutherror, else the code the approval page or the airlock’s proxy port names for that refusal, elseHTTP_<status>, which is what a path no route serves (404) and a method a route does not take (405) are written as. A code is a letter and then at most 63 letters, digits and underscores, and anything else is written asHTTP_<status>.methodandroute: the request’s method, at most its first 256 bytes, and the API route that took it, such asPOST /v1/requests. The route is empty where no route took the request, and on the airlock’s proxy port.caller_classandcaller: how the broker learnt who sent the request, and who that is.public: a route that takes no credential, or a path no route serves.calleris empty, but on the OAuth token route, where it is the OAuth client the token request named.unauthenticated: a route that takes a credential, refused before the broker knew who sent the request: a missing or bad credential, a listed caller on a route its name does not carry, a refusal that the two routes that decide a credential request answer before they read the credential, such asDECIDE_ON_BROKER_PAGE, and a refusal of the approval page or its sign-in to a browser with no live sign-in.calleris empty.launcher: a machine’s launcher, by the id of its launcher credential.session: an agent session or a pod, by its enrollment id.account: a callerBROKER_API_ACCOUNTSlists, by the name it gives that caller.service: a service logged in with its own token, as<service>@<namespace>/<name>, the ServiceAccount it presented.sign-in: a person signed in to the approval page, by their login.airlock: a call through the airlock’s proxy port, as<grant>@<enrollment>once its proxy password proved a grant, and empty before.
secrets: the secret names the request named, as a list, in the request’s order. It is empty when the broker refused the request before it read a name. It lists the first of them up to 32 KiB of names as the line writes them, JSON-escaped and each with its two quotes, each name cut to at most its first 256 bytes, and leaves the rest out, since Cloud Logging refuses an entry over 256 KiB.secrets_count: how many secret names the request named, the onessecretsleft out included.secrets_omitted: how many namessecretsleft out, and only when it left any out.correlation_id: the request’sX-Correlation-Id, at most 256 bytes of its printable ASCII, and only when the request sent one.
A refusal of the airlock’s proxy port has more fields:
host: the host the call was for, at most its first 256 bytes.reason: the proxy’s own words for the refusal, such asnot allowed by rule, at most its first 256 bytes, since some of them name the host the caller asked for.path: the request’s path, without its query, at most its first 256 bytes.issue: the issue key the grant’s reason named, once a grant is proved.slot: which of the secret’s values the call used, where the line names one.
A refused call to a bound host writes this line in place of its call line,
airlock proxy: a call to a bound host, so each such call still writes one line
(Run the broker locally
shows both). A 429 that the Azure token endpoint answers and the proxy forwards is no refusal:
it keeps its call line at INFO, which names its answer id and its status, and writes no refusal
line.
Each field whose text the caller chose is cut to its first 256 bytes, so whatever a caller sends, each line stays within one Cloud Logging entry. On the refusal line those are the method, the host, a reason that names the host, the path and the correlation id; on the call line, the method, the host and the path, which is all of the caller’s text it names.
No refusal line holds a secret’s value or a placeholder, and the API port’s line holds no message
text either: it names the code. Each answer, a refusal or not, also counts in
broker_answers_total (Metrics), which is the one record of the API port’s 429 and
of a device grant’s poll answers.
On the API port, an answer that no refusal line records names its id in no line: a 429, the two
answers of a device login’s poll, and every answer of 500 and above. agent-secrets prints the id
of a 429 and of a failure as well. For a 502 MINT_FAILED, read the broker: mint failed line
instead (Health and logs). It names the secret as name and holds no answer
id, so find it by that name and the time of the answer.
The value read
Section titled “The value read”The broker reads the stored value of each github-app, google-login, google-service-account
and azure-login secret once for each version, kind and shape of it that a pod lists and has not
checked, at the pod’s first load too, and decodes the value as that kind’s minter does. It drops
the value at once: no value is written, logged, counted or kept. It reads no value of a secret
that has no kind this way. Each read counts as read="value" in
broker_policy_store_reads_total (Metrics). So a pod reads each such secret once
after it starts, and once more for each new version, kind or shape, and never at each reload.
A value the decoder refuses makes the secret unservable as value-malformed from that load,
before any mint: one agent secret policy refused line and one broker_unservable_secrets
series. So does a stored value that is not text, which no minter can read either: an empty value
or one stored as binary alone on Secrets Manager, and a payload that is not valid UTF-8 on Secret
Manager. That is a refusal of the value and no failed read, so it writes no
agent secret value read failed line and is not read again at each load. A live grant of the
secret releases nothing (GRANT_NOT_LIVE).
The refusal holds while the store lists that version, until a read under a new kind or shape
decodes the value. A new version, or a retag of the kind or the shape, is read and decoded again,
so when agent-secrets secret set stores a value that decodes, the reread that follows the write
serves the secret again and writes one agent secret policy no longer refused line. A mint whose
minter cannot read the value refuses the secret in the same way, so a pod whose load could not
read the value still refuses the secret at its first mint. That refusal is of the version, kind
and shape the release read the value under. When a load or a reread has listed another one since,
a retag’s among them, the mint refuses nothing, and the broker decodes the value under the new
one.
Any other read that fails, for a permission, a timeout or the store’s own failure, refuses
nothing, and each load reads the value again. The secret keeps its state, but for a
value-malformed refusal of an older version: a failed read of a version the store listed since
ends that refusal, so the broker serves the secret again, and a mint whose minter cannot read the
new value refuses it again. The first failure writes
agent secret value read failed; its state kept, and the read that succeeds after it writes
agent secret value read succeeded again. The read needs no permission the broker lacks, since
a release reads the same value (What it depends on). The version these
lines name is the store’s own: Secrets Manager’s version id, Secret Manager’s version number, and
local-<n> over BROKER_FAKE_SECRETS_FILE.
The mint retry
Section titled “The mint retry”A minter’s call to GitHub or Google is made again when another attempt can succeed: an answer of
500 or above, a rate limit (a 429, GitHub’s X-RateLimit-Remaining: 0, or a body naming a rate
limit), or a call or a read of its answer that failed while the caller’s request was still open.
The broker does not retry a 4xx that is no rate limit, a call whose request its caller ended, an
answer larger than the minter reads, or a 2xx or 3xx answer other than the one the call asked
for.
- Each call gets at most 3 attempts, each under a 5-second timeout.
- Before the second attempt the broker waits a time drawn at random from 0 to 2 seconds, and before the third, from 0 to 4 seconds.
- A wait the upstream names takes the place of that draw:
Retry-After, in seconds or as an HTTP date, or GitHub’sX-RateLimit-ResetwhenX-RateLimit-Remainingis0. The broker waits it in full when it fits in what is left of the mint’s 10 seconds (the next item) and of the request’s deadline, and otherwise ends the mint with no further attempt. A rate limit that names no wait counts as 60 seconds, so it ends the mint. A wait longer than an hour counts as an hour, the longest GitHub asks for, since GitHub counts its primary rate limits per hour; that ends the mint too. A negative wait counts as none. - One mint waits at most 10 seconds in all, over every call it makes. A GitHub mint makes several:
a page of installations, a token for each owner, perhaps one more without
actions, perhaps a revoke. - Every retry of the process takes one from a budget of 60, which gains one back each second. When the budget is spent, a call that fails ends its mint at once.
Each attempt counts in broker_mint_attempts_total (Metrics), and a mint that fails
logs broker: mint failed with the attempts its last call made. A caller that gives up closes its
request, and that stops the retry at once.
Metrics
Section titled “Metrics”The broker serves its metrics in the Prometheus text format at GET /metrics, on a port of its
own: BROKER_METRICS_LISTEN_ADDR, by default 127.0.0.1:13382. The API’s port serves no
/metrics, and the metrics port serves nothing else: every other path answers 404, and a method
other than GET or HEAD answers 405. The boot binds it after the API’s port and the airlock’s
proxy port and logs broker metrics listening addr=<host:port>, and a pod that cannot bind it
does not start. A draining pod goes on answering scrapes through its drain, and the port closes
with the others.
The default address answers on the pod itself alone, so a scraper in another pod needs an address
it can reach, such as 0.0.0.0:13382. Let that scraper alone reach the port, and never route it
through the address callers reach the broker at: broker_unservable_secrets names the store id
and the owner of each secret the broker refuses, which tells whoever reads it whose secrets the
store holds and which of them are broken. No metric holds a value, a token or a name a caller
typed: each code is one the broker chose, and a secret’s name reaches a label only from the store’s
own listing.
| Metric | Type | Labels | What it holds |
|---|---|---|---|
broker_answers_total |
counter | outcome, code, caller_class |
Each answer the broker’s code writes on the API port and on the airlock’s proxy port; the answers Go’s HTTP server writes itself before that code runs are not counted (Refusals and the answer id). outcome is served (below 400), refused (a refusal the broker: answer refused line records), rate_limited (a 429, and the device grant’s slow_down), pending (the device grant’s authorization_pending, a machine’s poll while its person has not decided) or failed (500 and above). code is a refusal’s code, as its line names it, or the code the airlock’s proxy port names for a failure of its own, such as UPSTREAM_UNREACHABLE. Every other answer’s code is HTTP_<status>. caller_class is as the refusal line names it. A call the proxy carried to a service counts as served, with the service’s own status as its HTTP_<status>. |
broker_mint_attempts_total |
counter | kind, outcome, attempt |
Each attempt of each call a minter makes to GitHub or Google (the mint retry). kind is the secret’s kind. outcome is ok, upstream (an answer of 500 or above, no answer, or an answer it could not use), rate_limited, refused (a 4xx that is no rate limit) or cancelled (the caller’s request ended). attempt is 1, 2 or 3. |
broker_unservable_secrets |
gauge | secret, reason, owner |
One series of value 1 for each secret the broker refuses to serve now. secret is the secret’s whole name in the store, as the agent secret policy refused line names it, reason is that line’s reason, and owner its owner tag, empty for owner-tag-missing and owner-tag-malformed. A secret that becomes servable, or leaves the store, is gone from the next scrape, and a reread of a name nobody created adds no series. |
broker_policy_store_reads_total |
counter | read |
Each call the policy makes to the secret store, a failed one too: list for a load of the whole namespace, describe for one secret’s reread, and value for one kind secret’s value (the value read). It is how to see that this pod read the store: the broker logs no line for a read whose secrets it serves. On Secret Manager each of them spends the project’s read quota. A list is one ListSecrets for each page and one GetSecretVersion for each secret, and a describe is one GetSecret and one GetSecretVersion. A value is one GetSecret, or none when this pod read that secret in the 30 seconds before, and then the AccessSecretVersion that reads the value, which counts against Secret Manager’s separate access quota. |
Each pod counts its own answers, attempts and reads, from zero at its start. Run the broker locally shows a scrape.
Running more than one pod
Section titled “Running more than one pod”Nothing in a broker process is private to it but its cache of the policy and its rate-limit buckets, so a deployment may run several identical pods behind one address. A sign-in to the approval page is held in the browser’s cookie until its callback and its session in Postgres, so any pod finishes any sign-in and honours any session. What it has to set, and what it gets:
The stop budget its grace period must cover. On SIGTERM the broker drains for
BROKER_DRAIN_SECONDS (5 by default, 0 to 120; a second signal ends the wait at once): it answers
GET /healthz with 503 SHUTTING_DOWN while serving every other route exactly as before, and
keeps its listener open, because a pod is removed from its Service’s EndpointSlice concurrently
with the kubelet beginning termination rather than before it, so for a while after the signal new
connections are still routed to it. Only then does it stop accepting and give the requests still
in flight 60 seconds. Those 60 are the server’s own WriteTimeout, the 45-second request deadline
plus the 15 seconds a handler needs to write its answer, because that is the longest exchange the
broker itself allows a request past its headers: one admitted in the drain’s last instant still
gets its whole deadline. A connection that has sent no headers for five seconds is closed as
idle rather than waited for; its header read has its own ten-second ReadHeaderTimeout. So terminationGracePeriodSeconds must be at least any preStop pause, plus
BROKER_DRAIN_SECONDS, plus 60 seconds — 65 seconds with the defaults and no pause. A preStop
pause and the drain do the same job and are both paid out of the one grace period, so size the
period for their sum, or set the pause to 0 and let the drain do it alone. Past the grace period
the kubelet kills the container whatever the broker still believes it is allowed, and the
in-flight requests the budget exists for are dropped anyway. The broker logs the whole figure at
boot, so nobody adds it up by hand: broker stop budget drain=5s shutdown=60s total=65s.
/healthz as the readiness probe, and as nothing else. A liveness probe on it has the kubelet
kill the container in the middle of the drain, which is what the drain exists to prevent
(Health and logs).
One more Postgres connection per pod, on a session-mode path. The fan-out below listens on a
connection of its own, held for as long as the process runs and outside whatever pool_max_conns
sizes, since a LISTEN ends when a pooled connection is recycled. Count one extra connection per
pod against the database’s connection limit, and give the broker a session-mode path to Postgres:
a transaction pooler (pgbouncer in transaction mode, RDS Proxy) hands each transaction whichever
backend is free and carries no long-lived LISTEN. A pod that cannot hold the connection still
boots and still serves; its listener redials for as long as it runs.
A reread that reaches every pod, draining ones included. POST /v1/secrets/{name}/reread
rereads the secret on the pod the load balancer picked and then, once that pod has put the tags it
read in force (its 200 answer, or a 503 REQUEST_ENDED whose request ended during the value
read alone), tells every other pod serving the same database to reread the same name, over
Postgres LISTEN/NOTIFY on a channel of its own. So one call puts a retag in force across the
fleet within seconds rather than at each pod’s own next five-minute reload, and it needs no
infrastructure beyond the database the broker already has. A pod that is draining is still one of
those pods: it goes on being sent requests for the whole drain, so its listener and its
five-minute reload both run until the shutdown has answered the last request or spent its 60
seconds, not until the signal. A pod waits for its first successful LISTEN
before it binds, up to five seconds, so an address the load balancer can reach is normally an
address a retag can reach; a pod whose LISTEN has not succeeded by then logs a warning and
serves anyway, its listener still redialing.
The exception, plainly: while a pod’s listener is redialing it hears nothing, and nothing is
caught up when it reconnects, so that pod goes on serving what it last read until its own
five-minute reload. The promise is therefore “within seconds, except while a pod’s listener is
redialing, where the next five-minute reload is the backstop”. A reread a notification drives
spends no rate-limit token: the pod that served the original call already paid for it.
The reread limits are three numbers each pod derives from one budget, not one number halved.
One reread the broker accepts costs the secret store one read on every pod — the pod that served
it, and each peer the fan-out tells — so the fleet’s store traffic is replicas² × the per-pod rate, and the two broker-wide buckets’ rate is the fleet’s whole budget divided by that: at
two pods one reread a second, or one every 4 seconds against Google Secret Manager, which leaves
the store exactly the 4 reads a second, and Secret Manager the 120 metadata reads a minute, that
one pod alone was allowed. At three pods it is one every 2.25 seconds, or one every 9; at four,
one every 4 seconds, or one every 16. Each pod does that division itself: its serving clock
counts the pods beating in broker_serving at each beat, a count below two counts as two, and a
pod whose count changed logs broker reread rate and moves both of its buckets without
emptying or refilling either (Health and logs).
Their burst is not divided at all and stays at ten, because a burst is a
depth and what it admits is one person’s write burst, which the replica count does not change,
while what it guards is a transient on each pod’s own writer lock — latency, spent once, and
re-bounded by the rate. A burst momentarily lets the fleet accept replicas × burst rereads and
each costs a read on every pod, so the store sees replicas² × burst: 40, 90 and 160 reads at
two, three and four pods, twice that in metadata reads under Secret Manager (80, 180, 320), which
is 8, 18 and 32 seconds of its 600 a minute — transients, re-bounded immediately by the rate.
That cost grows with the square of the replica count while the rate is already divided by it, so
four pods is the largest count this sizing was chosen for. Above it the rates are derived the
same way and every pod logs a warning naming the count and the store reads a burst then costs,
because a fleet that stays that large wants its budget re-derived against the quota rather than
carrying ten forward unexamined. The per-address bucket is derived from them in turn — it
refills at half the rate of the bucket this process mounts, 2 seconds on the default store and 8
under Secret Manager at two pods — so no single address can hold the
fleet’s budget at zero on a route that takes no credential. Every other limiter is per process, so
a fleet of N pods allows N times each (Network and trust).
A rolling deploy counts one pod more than the Deployment runs. The pod a surge adds beats beside the pod it replaces, and a pod that has stopped goes on being counted until its row is three sweeps old, so a roll of two pods holds the fleet at the three-pod rate for the length of the roll and can reach the four-pod rate while a stopped pod’s row is still live. Nothing has to be set for that: each pod follows the count it sees and goes back to the two-pod rate within a few sweeps of the roll finishing. A roll of a four-pod fleet counts five, which is past what this sizing was chosen for, and each pod logs the warning above once, when its count first goes above four.
A roll serves two generations of the broker on one database, so a schema change that one of them cannot read is staged over two releases. The per-pod machine login is the current one. This release’s migration adds the two indexes it needs and drops none, so the pods still running the previous image go on serving every token login and every machine token exchange through the index they resolve their own statement against; nothing has to be ordered or held, and a roll of this release is an ordinary roll. The release after it drops that index, and the per-pod login — a launcher credential for each pod of a ServiceAccount rather than one for the account — begins there. Each broker reads which of the two schemas it is on at every token login, so an image from either release serves either schema: a roll forward and a roll back both work, and a pod of this release goes on serving while the next release’s pod migrates the database beneath it.
Before you roll a broker, read the lease refusal. A broker whose BROKER_LEASE_SECONDS is
too short for its BROKER_SWEEP_SECONDS exits at boot instead of serving, and that rule is
stricter than it was: it walks each renewer’s own retry schedule rather than counting two thirds
of the lease, so the shortest lease it accepts is 91 s at a sweep of 1 or 5 seconds, 105 s at
10, 225 s at 30 and 360 s at 60.
A pair below that boots a broker of an earlier release and does not boot this one — a 60-second
lease at the default sweep, or anything from 271 s to 359 s at a sixty-second sweep — so raise
BROKER_LEASE_SECONDS to at least the figure for your sweep and roll that first.
The refusal prints the figure itself
(when no broker is serving).
The renew token check
Section titled “The renew token check”A pod enrollment is not required to renew with its projected token yet. The broker ratchets each
pod UID instead: every enrollment starts unmarked, its first renewal carrying the pod’s token marks
it, and once any enrollment naming a pod UID, live or ended, is marked, a renewal without the token
from every enrollment naming that pod, whichever machine login made it, is refused
POD_TOKEN_REQUIRED. A pod on a worker image whose agent-secrets renew sends the token
(--pod-token-file) marks its UID on its first renewal, so the copied-token gap closes for every
pod UID a current-image pod has renewed under. Until that first renewal a copied token is used
without refusal: seconds, since the Legion daemon logs in with its own token at every start and
enrolls a pod as soon as it is up. A pod that ends before it enrolls never renews with its token,
so its UID stays open as an old-image pod’s does. A
pod on an older image never marks its UID: it renews as before, each renewal logged as the first
WARN line above, and the gap stays open for it until the release that requires the token. A pod
UID names one pod for ever, so a mark refuses nothing of another pod, and it keeps refusing a copy
between the pod’s role generations and after the pod is gone.
Every deploy order keeps every pod renewing: an older broker ignores the token a newer renewer sends, and this broker accepts a renewal without one from a pod no enrollment of which has sent one. That is liveness, not exposure. A grant lasts until its session ends, so a copy renewing an old-image pod’s UID keeps the grant one approval gave it for as long as it renews. So, before or right after the broker deploys, replace every old-image issue pod: a new worker image does not replace a running issue pod, so list the pods’ images to find them.
kubectl get pods -n <namespace> -l legion.dev/issue \ -o custom-columns=POD:.metadata.name,UID:.metadata.uid,IMAGE:.spec.containers[*].imageOnce that list shows no old-image pod, a token-less WARN line no longer names a pod of yours but a
copy, and for either line the copy is the line’s own enrollment: a pod on the current image renews
with its token, so its own rows are the marked ones, and the line names the row that renewed
without it. (Marked rows are a copy’s only when the refused pod runs an older image, as the
projected token required row above says.) List every enrollment of the line’s pod UID with the
machine login that made it; own is the line’s enrollment, and subject is the ServiceAccount a
token login logged in as (none for a login a person approved through a code):
select o.id = e.id as own, o.id as enrollment, o.pod_token_renewed_at is not null as marked, o.revoked_at is null and enrollment_lease_live(o.lease_expires_at) as live, c.id as credential_id, c.service, c.subject, c.host, to_char(c.created_at at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS') || ' UTC' as issued_at, (select ev.login from credential_request_events ev where ev.record_id = r.id and ev.event = 'approved') as approved_byfrom enrollments ejoin enrollments o on o.kind = 'pod' and o.runtime_id = e.runtime_idjoin launcher_credentials c on c.id = o.launcher_credential_idleft join credential_requests r on r.id = c.record_idwhere e.id = '<the line''s enrollment>'order by o.created_at;Whose login the line’s enrollment came from is its subject. A Legion daemon logs in only with
its own ServiceAccount’s token, so its login’s subject is an account BROKER_SERVICE_LOGINS
names for its service; a login whose subject is none, or an account the setting does not name,
is never the daemon’s. A login with no subject is one a person approved through a code: a
service login from before token logins (it lasts at most BROKER_LAUNCHER_CREDENTIAL_SECONDS
after its approval), since a person’s own machine login enrolls no pod. Revoke it on Dispatch’s
machine-login page (/credentials/machine), signed in: find the row whose Credential is the
query’s credential_id, check that its Approved by is the query’s approved_by and that its
Issued time, in the tooltip, is the query’s issued_at (the tooltip shows that second in the
browser’s time zone), then click Revoke and confirm. That ends every enrollment the login made
(end a machine’s login), and the
broker records the signed-in person as who revoked it. Never pick the row by its machine name: a
login’s host is the name its machine reported.
A line whose own enrollment’s subject is a daemon’s account names an enrollment made under that
daemon’s one credential: by the daemon itself, renewed by someone holding the enrollment’s key in
that pod (the role whose namespace holds it), or by someone holding a token for the daemon’s ServiceAccount. Each
login under the account is in the audit table, with the pod the token was bound to; the daemon’s
own token is bound to the daemon’s own pod, so a login whose pod_uid is another pod’s, or none
(a token kubectl create token minted), did not use the daemon’s own token unless you ran the
daemon off the cluster with such a token:
select at, detail->>'pod_uid' as pod_uid, detail->>'key_thumbprint' as key, detail->>'expires_at' as expires_at, detail->>'token_expires_at' as token_expires_atfrom auditwhere kind = 'launcher_credential.token_login' and detail->>'credential_id' = '<the query''s credential_id>'order by at;and compare pod_uid with the daemon’s pod (kubectl get pods -n <daemon namespace> -l <the daemon's labels> -o custom-columns=POD:.metadata.name,UID:.metadata.uid). A login under the
daemon’s account moves the credential to its own key, so a copy’s login also turns the daemon’s
next call into a refusal and a fresh login of its own, logged by the daemon as agent-secrets: the token login failed; retrying or logged in with the daemon's ServiceAccount token; a run of those
while nothing restarted the daemon is a copy fighting it. Replacing the line’s pod makes the daemon
revoke its enrollment as it lets the process go. If the pod is gone and the enrollment is still
live (the daemon logs supervise: agent-secrets: enrollment not revoked after every attempt; the lease ends it, which a copy that keeps renewing never lets happen), or a copy holds the daemon’s
token, end the daemon’s login at once: revoke it on Dispatch’s machine-login page by its
Credential (the query’s credential_id, Approved by reading No one: and the daemon’s
account), which ends every pod it enrolled, and, while a copy holds the daemon’s token, remove its
entry from BROKER_SERVICE_LOGINS and redeploy the broker so neither can log in again. Find and
remove what copied the token before you restore the entry: the broker refuses a token that lives
longer than an hour from its issue, so the copy’s token ends at most an hour after it was minted,
and the latest token_expires_at above among the logins that are not the daemon’s says when.
Pods the revoke ended stay without secrets until they are replaced.
To end a login you know by its code instead, such as a person’s machine or a service login from before token logins, find its row by that code:
select c.id as credential_id, c.service, c.host, to_char(c.created_at at time zone 'UTC', 'YYYY-MM-DD HH24:MI:SS') || ' UTC' as issued_at, (select ev.login from credential_request_events ev where ev.record_id = r.id and ev.event = 'approved') as approved_by, c.revoked_at is not null as revokedfrom credential_requests rjoin launcher_credentials c on c.record_id = r.idwhere r.kind = 'launcher_credential' and r.code = '<the code>';and revoke the row whose Credential is that credential_id, as above.
The release that requires the token from every pod is safe to deploy only when all three of these hold:
-
this query answers 0. A pod’s row is unmarked until its first renewal, seconds after it enrolls, so the query leaves out rows made in the last minute, which a busy fleet always holds:
select count(*) from enrollmentswhere kind = 'pod' and revoked_at is null and pod_token_renewed_at is nulland created_at < now() - interval '1 minute'; -
the broker has logged no
broker: pod enrollment renewed without its projected tokenline for one fullBROKER_LEASE_SECONDS. -
the command above lists no issue pod on a worker image older than the one whose renewer sends the token. An old-image issue pod between role generations holds no enrollment, so it counts as 0 and logs nothing, yet its next phase enrolls an unmarked row.
That release refuses every pod renewal without a token, so it also removes what only the ratchet
needs: the pod-UID lookup in Renew (podTokenRatcheted) and its partial index
enrollments_pod_token_renewed.
A copied machine token
Section titled “A copied machine token”A machine’s Google identity token logs in as that machine for up to an hour after it was minted,
wherever it is presented. A copy’s trades show in the audit record as
launcher_credential.token_login rows for that machine’s subject from more than one key:
select at, detail->>'credential_id' as credential_id, detail->>'key_thumbprint' as key, detail->>'token_expires_at' as token_expires_atfrom auditwhere kind = 'launcher_credential.token_login' and detail->>'subject' like '<account>%'order by at;A copy’s access token is bound to the copier’s own key, so it cannot spend the machine’s token, and
its credential and enrollment are its own: a credential is keyed by the key its proof presented,
so a copy’s trade mints a credential beside the machine’s rather than moving the machine’s onto
the copier’s key, and ending one ends nothing of the machine’s. What it does get is whatever
the fleet’s service may read. Revoking the machine’s login on Dispatch’s machine-login page ends
every enrollment that login made, but not the copied token: within its hour the copy trades again
under a login of its own. To end it for certain, take the machine out of the fleet’s settings and
redeploy the broker: change BROKER_VM_INSTANCES so the glob no longer matches the machine’s name,
or, for the whole fleet, remove the account’s entries from BROKER_VM_SERVICES,
BROKER_VM_PROJECTS, BROKER_VM_INSTANCES and BROKER_VM_EXCHANGE_ACCOUNTS. From then every
trade and every call of that machine, the copy’s included, is refused. Find and remove what copied
the token before you put the machine back: the latest token_expires_at above says when the last
copied token ends, at most an hour after it was minted. Rotate any secret the copy may have read;
the broker cannot take back a value it released.
The audit record
Section titled “The audit record”Requests, records, decisions and the audit trail live in the broker’s Postgres database (Concepts explains them). To read the latest events:
select at, kind, actor, enrollment_id, request_id, grant_id, detailfrom auditorder by at desclimit 50;and the decisions on credential requests:
select cr.id, cr.kind, cr.approver, r.state as request_state, e.event, e.actor, e.atfrom credential_requests crleft join requests r on r.record_id = cr.idleft join credential_request_events e on e.record_id = cr.idorder by cr.created_at desclimit 50;A secret request’s state is its request’s (request_state), which is what the broker goes by: a
request an older broker cancelled when its session ended carries no event on its record. A machine
login has no request, and its events alone say how it was decided.
A decision the model judge made names judge:<model id> in the event’s actor and in the
request’s decided_by, with no login beside it, and writes one request.judged row of its own.
That row is there for every judge run that reached a verdict, the referrals and the failed checks
included, so it is where a request the judge did not take says why
(Concepts):
select at, actor, request_id, detailfrom auditwhere kind = 'request.judged'order by at desclimit 50;No row any of them answers holds a secret value.
Running it locally
Section titled “Running it locally”Run the broker locally starts a whole stack (Postgres, the broker on a fake secrets file, and its clients) with one script.