The lab bundles its own Dex so the platform has an identity provider that exists nowhere else: static users, a fixed group vocabulary bound to RBAC, and one issuer URL that the apiserver, muster and Backstage all trust. This page is the reference for that layer — who the users are, why the plumbing is shaped the way it is, and how to hang another client off the same Dex.
The default configuration ships three users (password: password):
| User | Groups in the token | Effective access |
|---|---|---|
admin@lab.local |
platform-admins, developers |
cluster-admin |
dev@lab.local |
developers |
edit inside ns/demo only |
viewer@lab.local |
viewers |
view cluster-wide |
agentlab configure --tenants adds two more, one per organization of
agentlab tenants-test, when no user carries their group:
| User | Groups in the token | Effective access |
|---|---|---|
alice@lab.local |
tenant-a |
the platform tenant; with tenants-test, agent-platform:user in its first organization |
bob@lab.local |
tenant-b |
the platform tenant; with tenants-test, agent-platform:user in its second organization |
Users are fully configurable: agentlab configure lets you keep, edit, remove
and add users, with per-user passwords and group membership. The groups are
a fixed vocabulary — RBAC binds exactly platform-admins → cluster-admin,
developers → edit-in-demo, viewers → view, and nothing to tenant-a and
tenant-b, which tenants-test binds per organization — and the form only
offers those five. Passwords are bcrypt-hashed automatically; the hash is cached in
agentlab.yaml so renders stay deterministic (no spurious Dex pod rolls). After
editing users, run agentlab reload.
What agentlab tenants-test asserts about who sees and runs which agent
(#166; the plan is giantswarm/giantswarm#36698):
- A tenant is a namespace. The platform tenant is
kagent; an organization's tenant is a namespace labelledgiantswarm.io/organization. - Kubernetes RBAC decides, at the kagent controller. The chart's three
ClusterRoles are bound per tenant:
agent-platform:user(see the agents, start conversations),agent-platform:editor(also write agents),agent-platform:admin. The chart binds the platform tenant's users tosystem:authenticated, so every signed-in person sees its agents. The controller (kagent.controller.authorization.mode: kubernetes, kagent 1.19) asks the API server with a SubjectAccessReview for the caller's email and thegroupsclaim of the token the edge verified, each with theoidc:prefix the lab's API server puts before them (kagent.controller.authorization.usernamePrefixandgroupsPrefixin the lab's values; the fleet's API servers use none). - What a person sees:
ListAgentswith no namespace lists the agents of every tenant the person maylistagents in — the platform tenant and the organizations bound to their groups — and nothing else; a person bound to no organization lists the platform tenant only. - What a person runs:
CreateSession, and a first A2A message with no Session, on an agent of a tenant the person is not bound to arePermissionDenied, and no turn reaches the model. - What a person writes: an editor creates, changes and deletes agents in their organization and nowhere else; a user writes nothing.
- Everyone's sessions:
ListSessionswithall_creatorsneeds a cluster-widelistonsessions.api.kagent.dev, which no tenant role grants: refused to a person, answered to a cluster-admin (and to workspace-manager's ServiceAccount, which the chart grants it with workspaces on). - Slack asks as the person: klaus-gateway (4.10.0 and later) fetches each
person's roster as that person, across tenants, each row naming its tenant
(
platform, ororganization <name>fororg-<name>), and refuses an agent of another tenant — a pick, or a turn as a thread's collaborator — with a note naming the tenant, sending nothing to it.
A person with a cluster-wide read (viewers, platform-admins) sees every
tenant, as on an installation.
With platform.tenants on, muster serves the MCPServers of the
organization namespaces (org-…) next to its own namespace's
(muster.muster.mcpServersInAllNamespaces, muster 5.48.0 or later, where the
chart's range starts from agent-platform 4.157.1 on). What tenants-test asserts (the
agent-permissions plan's D9):
- Kubernetes RBAC decides what a person sees. muster lists the
MCPServers of each namespace with the person's own Dex token: a person sees the servers of every namespace where they maylistmcpservers.muster.giantswarm.io— muster's namespace through the chart'sagent-platform:mcpserver-viewerbinding to every signed-in person, an organization throughagent-platform:useror:editorthere. A person in no organization sees muster's namespace only. The token must carry the audience muster checks before it calls the API server (writesAsCaller.kubernetesAudience): the lab setskubernetes, the client id the lab's API server trusts, which every lab token for muster carries (the fleet's API servers trustdex-k8s-authenticator, muster's default). - A server a person may not see does not exist for them: not in
core_mcpserver_listorlist_tools, and a call of its tools,core_service_startandcore_auth_loginfor it are answered as for a server that does not exist. - A tenant server gets no identity from muster: an
MCPServerof an organization namespace that setsauth.forwardToken,auth.requiredAudiences,auth.tokenExchange,familyorauth.type: sigv4is not started and readsFailedwith the reason. - A tenant server's sign-in stays in its namespace. It is keyed
<namespace>/<name>, its tools arex_<namespace>_<name>_<tool>, it signs in through<muster>/oauth/ns/<namespace>/callbackwith a client of its own namespace, and its token is filed under its namespace and issuer: a sign-in to it gives no token to a server of the same issuer in muster's namespace or in another organization. The lab Dex lists the callbacks oftenants-test's two namespaces on the platform client. - A tenant namespace's sign-ins go with its servers. muster forgets them
when the namespace's last server, or the namespace, goes (a server deleted
next to others takes the issuers only it named): a namespace created again
with the same name starts with no sign-in, and
core_auth_loginthere answers a sign-in link, not a connect. Everytenants-testrun creates its namespaces again, so a second run right after the first signs in again. - Through an agent, as the person: an organization's agent bound to muster lists, in a turn as the person, the servers that person sees.
What an organization sets up for a server of its own that signs in with a
client of its namespace (authorizationServer.clientCredentialsSecretRef):
muster reads that Secret with its own ServiceAccount, and no chart grants it a
read outside muster's namespace (least privilege). So whoever declares the
MCPServer also declares, in its namespace, a Role with get on that Secret
alone (resourceNames) and a RoleBinding of it to muster's ServiceAccount
(muster in agent-platform), as muster's how-to "Servers outside muster's
namespace"
describes. tenants-test's fixture does this for
agentlab-tenants-mcp (the Role and RoleBinding
agentlab-tenants-mcp-secret-reader). Without them, muster answers the
sign-in with Cannot sign in to '<namespace>/<name>': the server's sign-in is not ready: <reason>, and the reason names the Secret and the Role.
The Dev Portal (Backstage with giantswarm/backstage#3161) reads the same
model as the person, through its own backend routes. What tenants-test
asserts (steps P, The Agent Platform proof):
- The roster per tenant. The portal asks the controller which namespaces
hold an agent the person may list (
GET /api/agent-platform/kagent/agent-namespaces,AgentService/ListAgentswith no namespace, as the person, through the agentgateway edge's GRPCRoute) and reads the Agents of each through the Kubernetes API as the person: a tenant user's roster is their organization's agents and the platform tenant's, labelledorganization <name>andplatform. - Where a person may create: the create flow's tenants are agent-manager's
get_infonamespaces.managedas the person — an editor of one organization gets that one — and a create elsewhere isforbidden. - What a person may change: the agent page asks the API server
(
SelfSubjectAccessReviewforupdateanddeleteon the agent's HelmRelease): a user gets no, an editor yes in their organization only. - What a person may run: a session start on another tenant's agent is the
controller's refusal (
forbidden: kagent: …fromstart_session), and nothing reaches the agent. - Who created an agent: agent-manager records the caller's email as
agent-platform.giantswarm.io/created-by, and the portal shows it. It is never an authorization input.GET /kagent/meanswers the token's claims on kagent 1.19: itsemailis the person, itssubis Dex's subject. The portal's "Created by: Me" compares the creator with thatsub, so it keeps nothing (giantswarm/backstage#3161; the browser suite'stenants.spec.tsshows it). - No tenant, no agent: a person bound in no organization, with no platform
agent, reads an empty roster, not an error. The chart binds
agent-platform:platform-userto every signed-in person inkagent, so the platform tenant is never refused: the portal shows "No agents yet" and its invitation to create one, not its "No agents for you yet" naming the roles, which needs every namespace refused.
issuer: https://localhost:32000/dex
|
Mac: localhost:32000 ---> kind port mapping ---.
+--> NodePort 32000 --> Dex :5556
apiserver static pod (hostNetwork) ------------'
localhost:32000
The single trick that makes this work is the shared issuer URL. The
apiserver has to validate tokens against the same URL the browser was
redirected to. Because the apiserver static pod runs with hostNetwork, its
localhost:32000 lands on the node's NodePort — and kind maps that same port
onto the Mac. One URL, valid from both sides. (32000 is the default; the Dex
port is a form question, constrained to the NodePort range 30000-32767.)
Everything else follows from that:
agentlab upmints the name-constrained lab CA and a Dex server cert (crypto/x509) whose SAN carries bothIP:127.0.0.1andDNS:localhost. The issuer uses the name, not the IP — see Whylocalhostand not127.0.0.1.- The rendered kind config bind-mounts
certs/into/etc/kubernetes/pki/dexon the node. kubeadm already mounts/etc/kubernetes/pkiinto the apiserver pod, so a subdirectory of it is the one place the CA is visible without extra plumbing. - The apiserver gets
--oidc-issuer-url,--oidc-client-id,--oidc-ca-file,--oidc-username-claim=email,--oidc-groups-claim=groupsandoidc:prefixes. - RBAC binds
Group: oidc:platform-admins(etc.) to ClusterRoles.
Every manifest the binary applies is also written to state/ (gitignored), so
kubectl diff -f state/dex.yaml and plain reading remain possible. The
templates live in internal/lab/templates/.
The issuer was originally https://127.0.0.1:32000/dex. muster refuses that:
mcp-oauth's ValidateIssuerURL rejects any issuer whose host parses as a
loopback or private IP, unconditionally — allowPrivateIPOIDC only relaxes the
dial-time SSRF guard, not this static check. A hostname that merely resolves
to 127.0.0.1 passes, because net.ParseIP("localhost") returns nil.
So the lab issuer is https://localhost:32000/dex. Nothing else changed: the
Dex cert already carried DNS:localhost in its SAN, and localhost resolves to
127.0.0.1 on the Mac, inside the kind node, and inside any hostNetwork pod — the
same one-URL trick, just spelled with a name.
groups on staticPasswords landed in Dex v2.45.0 (Feb 2026,
PR #4456, closing
issue #1080 after eight years).
Same release added name, preferredUsername and configurable emailVerified.
On v2.44.0 and earlier, staticPasswords returns only
UserID/Username/Email with EmailVerified hardcoded to true — no
groups, which is the historical reason people bolted LDAP or Keycloak onto Dex
just to get a lab going. That is no longer necessary.
Watch out:
- The upstream Helm chart
dexidp/dex0.24.1 still defaults to appVersion 2.44.0. You must overrideimage.tag. giantswarm/dex-appv2.2.3 is based on Dex v2.43.2, and its template hardcodesenablePasswordDB: falsewith nostaticPasswordssupport at all. This lab therefore uses plain manifests, notdex-app.
The Dex image is a form question (dexImage in agentlab.yaml) for when the
next version lands.
The rendered Dex config carries the shared agent-platform static client:
issuer: https://localhost:32000/dex
client id: agent-platform
client secret: agent-platform-lab-secret
redirect URIs: https://muster.127.0.0.1.nip.io/oauth/callback
https://backstage.127.0.0.1.nip.io/api/auth/oidc-agent-platform/handler/frame
The Backstage redirect path carries the provider name from the app-config,
not the literal word oidc — Backstage serves each provider at
/api/auth/<provider>/handler/frame, and the chart names it
oidc-agent-platform. More clients means editing the Dex template
(internal/lab/templates/dex.yaml.tmpl), rebuilding and agentlab reload.
To let client A mint a token whose audience client B accepts, A requests the
scope audience:server:client_id:B — and B must list A in its
trustedPeers. It is a grant published by the audience, not a capability
claimed by the caller.
So "Backstage may act on the Kubernetes API" is spelled:
- id: kubernetes
trustedPeers:
- backstage # <- the *caller* is listed on the *audience's* clientGetting this backwards fails closed and loudly, which is the one mercy:
$ curl -u backstage:... -d 'scope=...audience:server:client_id:kubernetes' .../token
{"error":"invalid_request",
"error_description":"Client can't request scope(s) [\"audience:server:client_id:kubernetes\"]"}
The lab uses one token for three audiences. gs.auth.extraScopes in the
Backstage template asks for both cross-client scopes, so a single Dex id_token
comes back with aud: ["kubernetes", "muster", "backstage"] and is accepted by
the apiserver (--oidc-client-id=kubernetes), by muster
(trustedAudiences: [muster]) and by Backstage itself.
The lab's Dex rotates its signing key every 30 days (signer.config.keysRotationPeriod: 720h; Dex's default is 6 hours), so a lab that lives for days does not meet
the apiserver's window below every 6 hours. A key that expired while the node
was down still rotates at Dex's first start after it. Dex checks every 30 seconds whether nextRotation of its stored
signing keys has passed, mints a new key, and keeps the retired one in its
JWKS as a verification key until the last token it signed has expired:
rotation time plus the id-token lifetime, 24 hours by default
(rotation.go).
It serves the JWKS with Cache-Control: max-age=600, must-revalidate, a
fixed 10 minutes
(handlers.go).
The apiserver discovers the key set from the issuer's discovery document
(Kubernetes: OpenID Connect tokens);
the documentation says nothing about when it fetches it again, which is the
authenticator's code. The lab's apiserver (Kubernetes 1.37, the embedded
kind's node image, configured by the --oidc-* flags) verifies tokens with
go-oidc v2.5.0's remote key set
(oidc.go at v1.37.0,
go-oidc jwks.go at v2.5.0):
- A token whose key id is in the cached key set verifies from the cache, with no fetch, however old the cache is.
- A token with an unknown key id fetches the JWKS again only when the cached
set is within 30 seconds of its
max-age. Before that it is refused at once withfailed to verify id token signature, and nothing is fetched. There is no schedule: only such a token, after the cache's expiry, makes the fetch.
So after a rotation:
| Token | In the lab |
|---|---|
| Signed by the new key | Refused until 9 min 30 s after the apiserver's last JWKS fetch, accepted from the first request after that. A rotation test waits up to 10 minutes; if the last fetch was more than 9 min 30 s before the rotation, the first such token is accepted at once. |
| Signed by the retired key | Accepted until the token's own exp. The cached set holds the retired key, and after a fetch Dex's JWKS still carries it for 24 hours. No wait makes it refused earlier; a test of the refusal uses an expired token. |
The wait is the apiserver's, so a rotation test reads it from there. Its last
fetch is the apiserver_authentication_jwt_authenticator_jwks_fetch_last_timestamp_seconds
metric (the StructuredAuthenticationConfigurationJWKSMetrics gate, on by
default since 1.35;
Kubernetes metrics):
kubectl --kubeconfig state/kubeconfig get --raw /metrics | grep jwks_fetch_last_timestampA new-key token is accepted from that timestamp plus 570 seconds. Until then every surface that authenticates to the apiserver with the person's token answers 401: the portal's inventory read among them, which hides the installation before the session list runs. That is the lab apiserver's cache, not a portal failure. The agent gateway's JWT policies keep their own copy of the JWKS; with its 5-minute refresh, fresh tokens were measured refused for 4 min 47 s after a rotation in this lab (agentgateway-upstream#46).
agentlab platform-test, test, backstage-test and agents-test start
with the stage apiserver OIDC: a fresh token of the first user at whoami. Refused while Dex publishes its key, it
is this window: the stage presents a new token every 15 seconds until the
apiserver fetches again, at most 10 min 30 s, and the proof goes on. Any other
refusal fails the stage at once. A rotation is forced for a test by setting
the SigningKey object's nextRotation to a past time (kubectl -n dex patch signingkeies.dex.coreos.com openid-connect-keys --type merge -p '{"nextRotation":"<past RFC 3339 time>"}'); Dex rotates within 30 seconds.
Nothing in the flags shortens the apiserver's wait: the --oidc-* flags and
the AuthenticationConfiguration file have no JWKS cache setting, and the
max-age is Dex's. Kubernetes after 1.37 moves to go-oidc v3, which fetches
again on every unknown key id, rate-limited to six fetches a minute; this
section is re-checked when the embedded kind's node image moves.
To sign in as a real account — yourself, for a demo or for using the portal as
you — turn on the GitHub sign-in: a github connector on
this same Dex, rendered from agentlab.yaml with the App's client secret
placed as a Secret, next to the local users.
staticPasswords means editing agentlab.yaml and reloading. If a demo needs
users created live, put a lightweight LDAP behind Dex's ldap connector
instead:
- lldap — has a web UI, ships an official Dex example config.
- glauth — config-file only, stateless, more GitOps-friendly.
Both give real groups on any Dex version. Keycloak is not needed for this.