How a Claim Works: the whole path, in diagrams
This page describes how the claim path is wired: who talks to whom, in
order, from a developer typing kubectl to a sandbox VM destroyed by its own
deadline. For the task-level view — which fields to set, and what each one
means — read the VirtualMachine Claims
guide instead; the two are companions.
Every diagram here is drawn from the shipped code, not from intent. Where
something is designed but not built, it says so in the diagram itself.
The cast
Claims involve more actors than most banlieue resources, because identity is
part of the design: a claim records who a sandbox was handed to, so the
thing that establishes "who" is inside the picture.
| Actor |
What it is |
Where it lives |
| Developer |
A human who wants a sandbox VM |
a laptop |
| kubectl |
The CLI |
the same laptop |
| kubelogin |
kubectl oidc-login, an exec credential plugin. Runs the browser flow, caches the ID token |
the same laptop |
| Browser |
Where the OAuth2 consent happens |
the same laptop |
| GitHub |
The identity provider. OAuth2, not OIDC — it mints no ID tokens |
the internet |
| Dex |
The OIDC bridge in front of GitHub. Mints the ID token the API server will accept |
in-cluster |
| API server |
Authenticates the token, then runs admission |
control plane |
| Admission policy |
ValidatingAdmissionPolicy — checks spec.subject against the caller (ADR-0047 D10) |
control plane |
| Claim controller |
Binds a member, holds it, releases it |
banlieue-controller |
| Pool controller |
Keeps warmReplicas Ready members |
banlieue-controller |
| Provider controller |
Creates and destroys the real VM |
banlieue-provider-libvirt, -vsphere |
| Hypervisor |
libvirtd / vCenter |
a host somewhere |
| Broker |
Hands sandboxes out on behalf of other people (roadmap phase C) |
not built |
| In-guest agent |
Receives the subject's credential and verifies it (ADR-0049) |
not built, separate repo |
Dex is a development convenience, not a requirement
Any OIDC issuer the API server trusts works the same way — Google, Auth0,
your corporate IdP. Dex appears here because GitHub is OAuth2 and has
no ID tokens, so it cannot be an API server issuer on its own. See
OAuth client setup and
Testing claim authorization.
1. Identity: how a developer becomes a username
Nothing about a claim makes sense until this has happened. The API server
can only vouch for request.userInfo, and this is how that gets populated.
sequenceDiagram
autonumber
actor Dev as Developer
participant K as kubectl
participant KL as kubelogin
participant B as Browser
participant GH as GitHub, OAuth2 only
participant Dex as Dex, the OIDC bridge
participant API as API server
Dev->>K: kubectl get vmclaim
K->>KL: exec credential plugin
Note over KL: kubeconfig carries issuer URL,<br/>client id and secret, plus the<br/>extra scopes profile, email, groups
alt cached token still valid by exp
KL-->>K: cached ID token
else no usable cached token
KL->>B: open the authorize URL, with PKCE
B->>Dex: GET /auth
Dex->>GH: OAuth2 authorization code flow
GH->>B: consent screen
Dev->>B: approve
B->>GH: grant
GH-->>Dex: access token + user profile
Note over Dex: Dex is the bridge: it turns an<br/>OAuth2 profile into a signed ID token
Dex-->>B: redirect with code
B-->>KL: code, via the localhost callback
KL->>Dex: POST /token with the code and PKCE verifier
Dex-->>KL: signed ID token, a JWT
KL->>KL: cache under ~/.kube/cache/oidc-login
KL-->>K: ID token
end
K->>API: request + Authorization: Bearer <ID token>
API->>Dex: fetch JWKS once, then cache it
API->>API: verify signature, iss, aud, exp
API->>API: username = oidc-username-prefix + the configured claim
Note over API: a sub of octocat becomes<br/>a userInfo.username of github:octocat
API-->>K: response
Two failure modes worth recognising, both learned the hard way:
kubelogin decides freshness from exp alone
If the issuer rotates its signing keys, the cached token is still
unexpired and kubelogin keeps serving it — while the API server
rejects it, forever. No amount of re-running the login escapes it. The
only cure is rm -rf ~/.kube/cache/oidc-login. Dex with
storage: type: memory rotates keys on every restart, so this is not
hypothetical.
iss must match byte-for-byte across three consumers
The browser, kubectl and the API server must all reach the issuer at the
same URL, because iss inside the token is compared literally against
--oidc-issuer-url. In kind this forces a NodePort plus a host-port
mapping, and therefore an IP SAN rather than a DNS name.
Where the prefix goes, and where it does not
This distinction is the reason ADR-0047 Decision 9 was amended:
flowchart LR
subgraph JWT["ID token, as the issuer minted it"]
S["sub / preferred_username<br/><b>octocat</b>"]
end
subgraph K8S["Kubernetes, after --oidc-username-prefix"]
U["userInfo.username<br/><b>github:octocat</b>"]
end
subgraph CR["VirtualMachineClaim"]
C["spec.subject.id<br/><b>octocat</b>"]
end
S -->|"API server prepends the prefix"| U
S ==>|"stored raw — this is the value<br/>a JWT actually carries"| C
U -.->|"policy re-applies the prefix<br/>only to compare"| C
style C fill:#e8f5e9,stroke:#2e7d32
style U fill:#fff3e0,stroke:#ef6c00
spec.subject.id stores the raw subject. The in-guest agent compares it
against a JWT, and no token anywhere carries a github: prefix — so storing
the prefixed Kubernetes username would make the field uncomparable to the
only thing it exists to be compared against. The admission policy adds the
prefix at check time instead, from its params ConfigMap: the claim's
issuer's own line in issuerPrefixes when there is one, else
usernamePrefix. With a distinct prefix per issuer, that also proves the
claim's issuer is the one that authenticated its author.
2. Creating a claim: what admission checks
spec.subject is a free-text assertion by whoever created the claim, and
without this policy anyone with create virtualmachineclaims can attribute
a VM to any identity they like — with the audit log recording it as genuine.
The check has to happen at admission, while the requesting principal still
exists; by reconcile time the controller sees an object, never a caller.
sequenceDiagram
autonumber
actor Dev as Developer
participant K as kubectl
participant API as API server
participant VAP as ValidatingAdmissionPolicy
participant CM as ConfigMap<br/>banlieue-claim-subject-policy
participant ETCD as etcd
Dev->>K: kubectl create -f claim.yaml
K->>API: CREATE virtualmachineclaims
API->>API: authenticate → userInfo.username
API->>API: authorize (RBAC)
API->>VAP: admission review
VAP->>CM: paramRef, with parameterNotFoundAction Deny
CM-->>VAP: issuers, brokers, usernamePrefix, issuerPrefixes
Note over VAP: variables: split on newlines,<br/>drop blanks, trim
alt caller is in brokers
VAP->>VAP: skip the id check — handing<br/>sandboxes to others is its job
else ordinary caller
VAP->>VAP: prefix(subject.issuer) + subject.id == userInfo.username ?<br/>(issuerPrefixes entry, else usernamePrefix)
end
VAP->>VAP: subject.issuer in issuers ?
alt all validations pass
VAP-->>API: allow
API->>ETCD: persist
API-->>K: created
K-->>Dev: virtualmachineclaim/foo created
else any validation fails
VAP-->>API: deny, reason Forbidden
API-->>K: 403 + the policy's messageExpression
K-->>Dev: error explaining which field and why
end
The five validations, and why each is scoped as it is
flowchart TD
OP{"request.operation"}
OP -->|CREATE| C1["1. subject.id binds to the caller<br/>unless the caller is a broker"]
C1 --> C2["2. subject.issuer is in the allowlist"]
C2 --> OK1([allow])
OP -->|UPDATE| U3["3. spec.subject is immutable"]
U3 --> U4["4. spec.poolRef is immutable"]
U4 --> U5["5. spec.ttlSeconds is immutable"]
U5 --> OK2([allow])
style C1 fill:#e3f2fd,stroke:#1565c0
style C2 fill:#e3f2fd,stroke:#1565c0
style U3 fill:#fce4ec,stroke:#ad1457
style U4 fill:#f3e5f5,stroke:#6a1b9a
style U5 fill:#f3e5f5,stroke:#6a1b9a
Checks 1 and 2 are CREATE-only, and that is load-bearing
On UPDATE the requester is whoever is touching the object now — which
is normally the claim controller adding its finalizer, not the person
the claim is for. Applying the identity check to updates denies the
controller, so claims are created and then never reconciled: Bound is
never reached and no VM is ever bound. The visible symptom is
indistinguishable from "banlieue is not running".
Nothing is lost by the scoping, because check 3 freezes subject: an
attribution authorized at creation cannot be edited afterwards by
anyone, controller included. Checks 4 and 5 exist for a plainer reason —
mutating those fields would silently do nothing, which is worse than
being refused.
3. Binding: how a claim gets a VM
The claim controller never locks anything. The API server is already the
serialisation point, so a resourceVersion precondition on the member patch
is the entire concurrency story.
sequenceDiagram
autonumber
participant API as API server
participant CC as Claim controller
participant PC as Pool controller
participant PROV as Provider controller
participant HV as Hypervisor
API-->>CC: watch event: claim created
CC->>API: ensure finalizer banlieue.io/claim-protection
Note over CC: next_step sees not deleting,<br/>not expired, not bound, so Wait,<br/>and now it looks for a member
CC->>API: GET pool named by spec.poolRef
CC->>API: LIST vms labelled banlieue.io/pool=<pool>
CC->>CC: pick_member: Ready only, current image<br/>revision first, then Ready longest, then name
alt a Ready member exists
CC->>API: PATCH member, merge patch carrying a resourceVersion precondition
Note over CC,API: label banlieue.io/claim=<claim><br/>annotate claim-subject-{issuer,id}<br/>ownerReferences → the claim
alt precondition holds
API-->>CC: 200
CC->>API: PATCH claim status
Note over CC,API: phase Bound, virtualMachineRef, boundAt,<br/>expiresAt = boundAt + ttlSeconds
API-->>PC: watch event: member is now Claimed
PC->>PC: Claimed members are invisible to sizing —<br/>they only count against maxReplicas
PC->>API: CREATE replacement member
PROV->>HV: provision the replacement
else somebody else wrote the member first
API-->>CC: 409 Conflict
CC->>CC: lost the bind race, so requeue in 1s and pick again
end
else nothing Ready
CC->>API: PATCH claim status: phase Pending + why
Note over CC: requeue in 3s — the shortest in the<br/>controller, because a human is blocked on it
end
Three consequences of that shape:
- Re-parenting is what makes a claim safe. The member's owner becomes the
claim, not the pool, so deleting the pool cannot destroy a sandbox somebody
is using (ADR-0047 D3).
- The subject is stamped onto the VM as
banlieue.io/claim-subject-issuer / -id annotations. That is the whole
of banlieue's interest in subject: it records the attribution and reads
it no further.
- No nonce is minted. Until v0.4.13 a bind published a 128-bit
status.nonce for a handshake nobody built; it is deprecated and never
set (ADR-0049, amended 2026-11-05). The guest's attestation carries its
own challenge (section 6).
Two consumers, one member
sequenceDiagram
autonumber
participant CA as claim-a reconcile
participant API as API server
participant CB as claim-b reconcile
CA->>API: LIST members
CB->>API: LIST members
API-->>CA: vm-7 is Ready (resourceVersion 100)
API-->>CB: vm-7 is Ready (resourceVersion 100)
Note over CA,CB: both picked the same member from<br/>the same snapshot
CA->>API: PATCH vm-7 if resourceVersion == 100
API-->>CA: 200 — now at 101
CB->>API: PATCH vm-7 if resourceVersion == 100
API-->>CB: 409 Conflict
CB->>CB: requeue 1s, re-list, pick a different member
No lease, no lock, no leader coordination for the hand-out itself. A
Claimed member is never offered by pick_member again, which is the
invariant the whole design rests on.
4. The claim state machine
stateDiagram-v2
direction LR
[*] --> Pending : created
Pending --> Bound : a Ready member was taken
Pending --> Pending : nothing Ready — retry every 3s,<br/>unboundedly on purpose
Pending --> Releasing : deleted before it ever bound
Bound --> Bound : hold — mirror the member's addresses
Bound --> Releasing : TTL reached, or deleted
Bound --> Failed : the bound member disappeared
Releasing --> [*] : member gone, finalizer removed
Failed --> Releasing : deleted
Failed --> [*] : terminal otherwise
note right of Pending
Pending never times out.
A pool may simply be filling,
and a deadline here would
fail claims that were about
to succeed. (ADR-0047 D11)
end note
note right of Failed
Terminal, never rebound: a
consumer holding this claim
believes it is talking to one
specific VM. (ADR-0047 D7)
end note
Precedence is the interesting part
next_step is a pure function of a snapshot, and the order it tests things
in is a design decision rather than an implementation detail:
flowchart TD
START(["one reconcile pass"]) --> D1{"deletionTimestamp set<br/>OR expiresAt passed?"}
D1 -->|yes| REL["<b>Release</b><br/>destroy the member, then let go"]
D1 -->|no| D2{"status.virtualMachineRef set?"}
D2 -->|yes| D3{"does that member still exist?"}
D3 -->|no| FAIL["<b>Fail</b><br/>terminal — never a rebind"]
D3 -->|yes| HOLD["<b>Hold</b><br/>mirror addresses, sleep"]
D2 -->|no| D4{"pick_member found a candidate?"}
D4 -->|yes| BIND["<b>Bind</b><br/>take it"]
D4 -->|no| WAIT["<b>Wait</b><br/>phase Pending, retry in 3s"]
style REL fill:#ffebee,stroke:#c62828
style FAIL fill:#fce4ec,stroke:#ad1457
style HOLD fill:#e8f5e9,stroke:#2e7d32
style BIND fill:#e3f2fd,stroke:#1565c0
style WAIT fill:#fff3e0,stroke:#ef6c00
Release outranks everything, including a healthy binding: once a claim is
going away, or its deadline has passed, the only remaining job is to destroy
the member. And a vanished member fails the claim even when a replacement is
sitting Ready, because rebinding would hand the consumer a different VM than
the one it believes it holds.
5. Release: the deadline, and the two finalizers
A claim's TTL is a deadline, not an idle timer. It is counted from the
bind instant — so a claim that waited ten minutes for capacity still gets its
full TTL — and nothing extends it.
sequenceDiagram
autonumber
actor Dev as Developer
participant API as API server
participant CC as Claim controller
participant PROV as Provider controller
participant HV as Hypervisor
participant PC as Pool controller
alt the developer is done early
Dev->>API: kubectl delete vmclaim foo
API->>API: set deletionTimestamp —<br/>the claim finalizer blocks actual removal
else the TTL simply runs out
Note over CC: is_expired(status.expiresAt, now)
end
API-->>CC: watch / requeue
CC->>API: DELETE member, background propagation
CC->>API: PATCH claim status: phase Releasing<br/>reason Released or Expired
API->>API: member gets deletionTimestamp —<br/>the provider's finalizer blocks removal
API-->>PROV: watch event: member deleting
PROV->>HV: destroy the domain / VM, free volumes
HV-->>PROV: gone
PROV->>API: remove the provider finalizer
API->>API: member object actually disappears
API-->>CC: requeue: member is 404 now
alt the developer deleted the claim
CC->>API: remove banlieue.io/claim-protection
Note over API: claim object disappears
else it expired on its own
CC->>API: DELETE the claim itself
Note over CC: an expired claim must not look like<br/>a live hold on a VM that is gone
end
API-->>PC: the pool is short a member
PC->>API: CREATE a replacement
\"Claim gone\" means \"sandbox gone\", transitively
The ordering is the guarantee. The claim keeps its finalizer until the
member is gone from the API server, and the member's own finalizer keeps
it until the backend VM is actually destroyed. Neither layer trusts
the other to have finished — each one waits and re-checks.
6. Delivering the subject's credential
Built outside banlieue
This is the consumer's side: mediatore builds it (ADR-0097).
banlieue's share is what it publishes: the subject fields, the bound
member's EK certificate (status.tpmEndorsementCertificates,
ADR-0045), and the trust anchors on the Provider
(spec.attestation.ekTrustBundle).
ADR-0049
as amended 2026-11-05 records the contract.
A claim deliberately carries no credential: a custom resource is readable by
every reader of the namespace and lands in the audit log, so a token in it is
a token disclosed. And a warm pool member exists before any claim does,
so nothing baked in at build or boot can be subject-specific. The credential
therefore has to arrive later, over a channel banlieue is not part of.
sequenceDiagram
autonumber
actor Dev as Developer
participant BR as Broker<br/>(mediatore)
participant API as API server
participant SP as SPIRE server
participant AG as Guest agents<br/>(SPIRE agent + mediatore's)
participant TPM as per-VM vTPM
AG->>SP: TPM node attestation: EK certificate + AK
SP->>SP: EK certificate chains to a hypervisor EK CA?
SP->>AG: credential-activation challenge, sealed to the EK
AG->>TPM: ActivateCredential
TPM-->>AG: the secret (only this vTPM can recover it)
AG-->>SP: secret
SP-->>AG: SVID for this node
Dev->>BR: asks for a sandbox, presenting a token
BR->>API: CREATE claim, setting subject on their behalf
API-->>BR: claim bound, carrying virtualMachineRef,<br/>addresses, tpmEndorsementCertificates
BR->>AG: mTLS, peer SVID bound to the claim's EK
BR->>AG: the subject's credential
alt attestation and binding hold
Note over AG: the agent checks iss == subject.issuer,<br/>the subject claim == subject.id,<br/>aud == its OWN configured audience,<br/>plus exp, nbf and the signature
AG->>AG: start the workload
else no SVID, or the EK does not match the claim
BR->>AG: nothing
Note over AG,TPM: no token, no workload. The claim's TTL<br/>reaps the VM as usual: there is no<br/>partial-trust mode
end
The challenge is what makes this unreplayable: SPIRE issues it fresh, sealed
to the vTPM's EK, so an answer captured from one sandbox proves nothing about
another. That is why banlieue publishes no nonce.
Three points that are decisions rather than mechanics:
- banlieue neither carries nor validates the token. It records the
attribution and mirrors the EK certificate.
Verification belongs to the agent, the only party that can legitimately
hold the credential — which is what keeps subject credentials out of the
controller's compromise radius.
aud must be the agent's own configured audience, never read from the
claim. Taking it from the claim is the obvious implementation, and it
silently removes the guarantee: whoever wrote the claim would choose the
audience, so a token minted for any other service would satisfy the check.
- The issuer allowlist turns out to be load-bearing twice. The agent
learns which issuer to trust from
subject.issuer — a field on a CR — so
without the admission allowlist, anyone who could create a claim would
point the agent at their own JWKS and every token they minted would
validate. It was added for audit honesty; it is also what makes signature
verification meaningful.
GuestReady is a different signal, and must not be conflated
A guest that announced itself has booted from its installed disk
(ADR-0043). Only a verified quote says which guest it is.
7. Everything at once
flowchart TB
subgraph LAPTOP["Developer's laptop"]
DEV(["Developer"])
KUBECTL["kubectl"]
KLOGIN["kubelogin<br/>token cache"]
BROWSER["Browser"]
end
subgraph EXTERNAL["Outside the cluster"]
GITHUB["GitHub<br/>OAuth2"]
end
subgraph CLUSTER["Kubernetes cluster"]
DEX["Dex<br/>OIDC bridge"]
subgraph CP["Control plane"]
APISERVER["API server<br/>authn + authz"]
POLICY["Admission policy<br/>subject authorization"]
end
subgraph BANLIEUE["banlieue-system"]
CLAIMC["Claim controller"]
POOLC["Pool controller"]
PROVC["Provider controller"]
end
subgraph OBJ["Objects"]
POOL["VirtualMachinePool"]
CLAIM["VirtualMachineClaim"]
VM["VirtualMachine<br/>+ infra CR"]
end
end
subgraph HOST["Hypervisor host"]
LIBVIRT["libvirtd / vCenter"]
SANDBOX["Sandbox VM<br/>+ per-VM vTPM"]
end
DEV --> KUBECTL
KUBECTL <--> KLOGIN
KLOGIN <--> BROWSER
BROWSER <--> DEX
DEX <--> GITHUB
KUBECTL -->|"Bearer ID token"| APISERVER
APISERVER -->|"verify signature via JWKS"| DEX
APISERVER --> POLICY
POLICY -->|"allow / deny"| APISERVER
APISERVER --- CLAIM
APISERVER --- POOL
APISERVER --- VM
POOLC -->|"keeps warmReplicas Ready"| POOL
POOL -->|"owns, until claimed"| VM
CLAIMC -->|"binds, re-parents, expires"| CLAIM
CLAIM -.->|"ownerRef after bind"| VM
PROVC --> VM
PROVC --> LIBVIRT
LIBVIRT --> SANDBOX
style POLICY fill:#fff3e0,stroke:#ef6c00
style CLAIM fill:#e3f2fd,stroke:#1565c0
style SANDBOX fill:#e8f5e9,stroke:#2e7d32
style GITHUB fill:#f5f5f5,stroke:#616161
The trust boundaries worth naming on that picture:
- Laptop → cluster. Crossed by a bearer token. The API server is the
only thing that decides what the developer is called.
- API server → admission policy. The last point at which the caller
still exists. Everything after this sees objects.
- Controller → hypervisor. Crossed by provider credentials, never by
consumer credentials.
- Cluster → sandbox guest. Crossed only by the broker's attested
channel, in a design that is not built yet. banlieue itself never reaches
into the guest to deliver anything.
Where to go next