feat(server): support more than one sandbox ServiceAccount per gateway - #2835
Draft
bjw123 wants to merge 3 commits into
Draft
feat(server): support more than one sandbox ServiceAccount per gateway#2835bjw123 wants to merge 3 commits into
bjw123 wants to merge 3 commits into
Conversation
|
All contributors have signed the DCO ✍️ ✅ |
Author
|
I have read the DCO document and I hereby sign the DCO. |
bjw123
force-pushed
the
bwilkinson/sandbox-sa-allowlist
branch
4 times, most recently
from
August 20, 2026 13:50
f1d6880 to
4f21da6
Compare
Author
|
recheck |
bjw123
force-pushed
the
bwilkinson/sandbox-sa-allowlist
branch
from
August 20, 2026 18:33
69c56b3 to
e3d3d6c
Compare
Bootstrap auth compared the TokenReview principal against a single value, `[openshell.drivers.kubernetes] service_account_name`, which is also the name the driver stamps onto every sandbox pod. One field served both purposes, so every sandbox on a gateway necessarily shared one Kubernetes identity — and where something other than the driver assigns the pod's ServiceAccount, bootstrap failed outright. The ServiceAccount is the unit of cloud IAM federation: an EKS, GCP or Azure workload-identity trust policy conditions on system:serviceaccount:<namespace>:<name>. One accepted account per gateway therefore means one cloud identity per gateway. Accept a set instead. additional_bootstrap_service_account_names enrols further identities for bootstrap only; service_account_name remains the one name the driver puts on pods. The two are deliberately separate settings rather than one widened field, because a pod carries exactly one ServiceAccount and a list there would render invalid pods. The list is empty by default, so a gateway that does not set it behaves exactly as before. Rejections now name the presented principal and the enrolled set, and the success path logs the account that actually authenticated rather than the configured default. The enrolled set is also logged once at startup. Verification is unchanged: still TokenReview, still required to be pod-bound, still validated against the pod's owning Sandbox CR. This widens which identities an operator may enrol, not how they are verified. Refs: NVIDIA#2806 Signed-off-by: Bryce Wilkinson <22760097+bjw123@users.noreply.github.com>
bjw123
force-pushed
the
bwilkinson/sandbox-sa-allowlist
branch
3 times, most recently
from
August 20, 2026 19:54
119efc9 to
666c6b8
Compare
Part 1 made bootstrap auth accept a set, but the driver still stamps one name onto every pod it creates, so a deployment where the gateway owns the pods still gets one Kubernetes identity for every sandbox — and therefore one cloud identity, since the ServiceAccount is what EKS, GKE and Azure workload identity federate on. Granting the union of permissions to that shared account gives every sandbox the most-privileged set. Add SandboxTemplate.service_account_name, resolved by the Kubernetes driver against selectable_service_account_names plus the driver default. Resolution runs in validate_sandbox_create, so a request naming an account that is not selectable is refused before the gateway persists the sandbox or mints its JWT, and before the driver creates a namespace or copies a secret. A blank value is refused too: defaulting it would run the sandbox as an identity the caller did not ask for. The selectable list is deliberately separate from additional_bootstrap_service_account_names. That setting means "accept this identity when something outside the driver assigned it"; this one means "any caller who can create a sandbox may run as this identity". Enrolling a privileged account so an externally-owned pod can bootstrap should not silently make it requestable, so the two are opted into independently. Both feed the accepted-for-auth set, since a selectable account has to authenticate. Configured names are validated at driver startup and a requested name at the gateway, so a malformed account fails early rather than reaching the apiserver or being echoed back in an error. The rejection names the account the caller asked for; the selectable set goes to the gateway log instead. Only the Kubernetes driver supports the field, so the gateway rejects a request that sets it under another driver rather than ignoring it (Podman) or failing with a message about platform_config the caller never set (Docker, VM). Refs: NVIDIA#2806 Signed-off-by: Bryce Wilkinson <22760097+bjw123@users.noreply.github.com>
create_sandbox forwarded AlreadyExists and FailedPrecondition from the driver and collapsed everything else into Internal, so a request the driver rejected as malformed reached the caller as a server fault. Clients retry Internal; no retry gets them out of an invalid argument. InvalidArgument is now preserved with the driver's message intact. The four arms only differed in the status they returned, so they are one arm now. The compensating store delete they each ran was also discarding its error, which leaves a sandbox row with no compute object behind and no way to notice; that failure is logged. This is not required by the ServiceAccount selector, which the Kubernetes driver refuses in validate_sandbox_create before anything is persisted. It is the same class of problem one layer down, and the driver's own sandbox-name and GPU validation reach this path when a caller bypasses the pre-create hook. Signed-off-by: Bryce Wilkinson <22760097+bjw123@users.noreply.github.com>
bjw123
force-pushed
the
bwilkinson/sandbox-sa-allowlist
branch
from
August 21, 2026 10:30
666c6b8 to
a970d22
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Bootstrap auth compared the
TokenReviewprincipal against a single value —[openshell.drivers.kubernetes] service_account_name— which is also the name the driver stamps onto every sandbox pod. One field served both purposes, so every sandbox on a gateway necessarily shared one Kubernetes identity, and where something other than the driver assigns the pod'sServiceAccount, bootstrap failed outright.This implements both parts of the issue.
Part 1 makes bootstrap auth accept a configured set.
additional_bootstrap_service_account_namesadds identities for bootstrap only;service_account_namestays the one name the driver puts on pods.Part 2 lets a caller choose the account for a single sandbox through
SandboxTemplate.service_account_name, constrained toselectable_service_account_namesplus the driver default. Without part 2 the driver still stamps one name on every pod it creates, so a gateway that owns its pods still has one Kubernetes identity — and therefore one cloud identity — for every sandbox.Both lists are empty by default, so a gateway that sets neither behaves exactly as before.
Related Issue
Refs #2806, which I filed (
state:accepted,area:gateway,area:compute,test:e2e-kubernetes). @elezar gave the go-ahead to pick it up after vouching me.What the issue asks for
#2806 proposes two complementary parts, and is explicit that they are separable:
ServiceAccountbecomes selectable per sandbox — e.g.platform_config.service_account_name— constrained to the set from (1) and defaulting to the existing single value.This PR implements both, and covers all six of the issue's Definition-of-Done items (checked off at the bottom of this description) plus the part 2 capability.
Part 1 is the prerequisite: a per-sandbox selector without an accepted set cannot authenticate, because bootstrap would still compare the presented principal against the one pod-default name. It is also independently useful on its own, and that is the case that motivated the issue — where something other than the gateway assigns the pod's
ServiceAccount(a mutating admission policy, or an external controller that owns the sandbox pods), bootstrap fails outright today. The issue quotes the failure aswhich this PR replaces with
... is not an accepted sandbox service account, since "the configured account" is no longer a single thing. Worth noting for anyone searching logs or docs for the old string.Part 1 also follows the design constraint the issue is emphatic about: additive, not a widening of
service_account_name. That field feedsspec.serviceAccountNameon the pod, so a list or delimited string there would render invalid pods. The pod default and the accepted-for-auth set are kept as separate settings, which is the shape #2656 established for namespace validation and which this mirrors —ServiceAccountValidatorsits next to the existingNamespaceValidatorand works the same way.How part 2 works
SandboxTemplate.service_account_name(new field 12 on the public message) is forwarded intoplatform_config, and the Kubernetes driver resolves the pod's account invalidate_sandbox_create:service_account_name, exactly as beforeselectable_service_account_names(or the driver default) → that nameInvalidArgumentIt fails rather than falling back deliberately. A sandbox silently running as a different identity than the caller asked for does not surface at create time; it surfaces later as a confusing cloud IAM denial.
Resolving in
validate_sandbox_createrather than increate_sandboxmatters: that hook runs before the gateway persists the sandbox record and before it mints the sandbox JWT, and before the driver creates a workspace namespace or copies image-pull and TLS secrets. A rejected request leaves nothing behind.Validation is layered. The gateway rejects a requested name that Kubernetes could not issue (DNS-1123 subdomain, ≤253 chars) and caps its length with the other template strings, so an over-long value cannot be echoed back into a gRPC status. The driver validates its own configured lists at startup, so a typo fails the gateway instead of surfacing per-request. And only the Kubernetes driver supports the field: the gateway rejects a request that sets it under another driver, because Podman would ignore it — acknowledging a request to confine a sandbox to an identity and then not doing it — while Docker and VM would fail with a message about
platform_config, which the caller never set.The rejection names the account the caller asked for and nothing else. The selectable set goes to the gateway log, matching how the bootstrap rejection already behaves.
Selectability is a second, separate opt-in, and that is the crux of the design.
additional_bootstrap_service_account_namesmeans "accept this identity when something outside the driver assigned it to the pod".selectable_service_account_namesmeans "any caller who can create a sandbox may run as this identity". Those are different decisions, so enrolling a privileged account so an externally-owned pod can bootstrap does not put that account on the menu for everyone:service_account_nameadditional_bootstrap_service_account_namesselectable_service_account_namesBoth lists feed the accepted-for-auth set, since an account a sandbox can run as has to be able to bootstrap.
KubernetesComputeConfigowns both set builders so the authenticator and the driver cannot drift.One limitation worth being explicit about: there is no per-caller restriction within the selectable set. Everything in
selectable_service_account_namesis available to every caller who can create a sandbox, so its members should be scoped accordingly. If you would rather that were policy-driven per user or per workspace, that is a larger change and I would rather agree the shape first.On the triage recommendations
The triage assessment on the issue asked for unit coverage of legacy single-account behaviour, multiple allowed identities, non-member rejection, and selector validation, plus Kubernetes E2E coverage for bootstrap with a non-default allowed ServiceAccount.
The triage also asked to "keep namespace binding explicit for any multi-namespace deployment". Namespace validation is untouched by this PR and still runs separately per
workspace_mode, but the accepted set is keyed on the bareServiceAccountname, so a name is accepted in every namespace that validator accepts. That is called out in the config docs, the chart values and the reference docs — and if you would rather the setting took qualifiednamespace:nameprincipals, I am happy to change it; see the design notes below.Changes
auth/k8s_sa.rs— newServiceAccountValidator, a closedBTreeSetwith anaccepts()check, built either directly or from the driver config viafrom_kubernetes_config.token_review_identityconsults it instead of comparing one string, and now carries the presented account out of validation so the success log names the identity that actually authenticated rather than the configured default.config.rs(kubernetes driver) —additional_bootstrap_service_account_namesandselectable_service_account_names, both#[serde(default, skip_serializing_if = "Vec::is_empty")]and empty by default, plus the three functions that give both consumers one source of truth:accepted_bootstrap_service_account_names(),selectable_pod_service_account_names()andresolve_pod_service_account().proto/openshell.proto—SandboxTemplate.service_account_name(field 12). Go SDK bindings regenerated;sdk/goproto:checkpasses.compute/mod.rs—build_platform_configforwards the requested account to the driver, which is the only layer that knows what is selectable.driver.rs—create_sandboxresolves the effective account before buildingSandboxPodParams, so the rendered pod and the bootstrap authenticator agree on one name, and a rejected request never reaches the apiserver.lib.rs— builds the validator from the bootstrap config and logs the accepted set once when the authenticator is enabled.server.drivers.kubernetes.additionalBootstrapServiceAccountNamesandselectableServiceAccountNames, rendered into the[openshell.drivers.kubernetes]table directly beneathservice_account_nameand only when non-empty. A blank or non-string entry fails at render time rather than rendering away silently (sprigquotedrops a nil, so[null]would otherwise produce= []). Chart README regenerated.docs/reference/sandbox-compute-drivers.mdx, the driver-key table row, and thegateway-config.mdxexample.grpc/validation.rs— the requested name joins the other template strings underMAX_TEMPLATE_STRING_LENand must be a name Kubernetes could issue.compute/mod.rs(third commit) —create_sandboxforwardedAlreadyExistsandFailedPreconditionfrom the driver and collapsed everything else intoInternal, so a driver-rejected request reached the caller as a server fault that clients retry.InvalidArgumentis now preserved, the four near-identical arms are one, and the compensating store delete no longer discards its error — that failure orphans a sandbox row, so it is logged. This is not required by the selector, which is refused invalidate_sandbox_createbefore anything is persisted; it is the same class of problem one layer down.architecture/gateway.mdand.agents/skills/debug-openshell-cluster/SKILL.md— both described the gateway as accepting the configured service account, which is no longer accurate. Updated per theAGENTS.mdrequirements for architecture docs and for changes to Helm values/templates.One incidental line: adding
use std::collections::BTreeSetmakes a pre-existing fully-qualifiedstd::collections::BTreeSet::fromin an unrelated test redundant, whichunused_qualificationsrejects under-D warnings, so that call is now unqualified.Design notes
service_account_nameis untouched, as the issue asks: a pod spec has a singleserviceAccountNamefield, so a list there has no valid rendering. The pod default and the accepted-for-auth set are separate settings.ServiceAccountname never carries surrounding whitespace, so a padded config entry would otherwise sit in the set as a member noTokenReviewusername can ever match — an enrolment that looks correct in the values file and silently does nothing.NamespaceValidator, so under managed and operator workspace modes a name is accepted in every namespace that validator accepts. Adding a name does not widen which namespaces may bootstrap, but it does mean a generic name (defaultbeing the obvious one) is a much larger surface than it looks. This is documented in the config field, the chart values and the docs section. If you would rather the setting took qualifiednamespace:nameprincipals, say so — that is a bigger change than part 1 and I did not want to pre-empt it.namespaceandservice_account_namefor bootstrap (kubernetes_config_for_k8s_sa_bootstrap). feat(server): support more than one sandbox ServiceAccount per gateway #2806 notes the overlap with refactor(server): decouple K8s ServiceAccount bootstrap from selected compute driver config #2023 on bootstrap-config ownership; this follows the existing placement rather than pre-empting that decision, and it moves withservice_account_nameif refactor(server): decouple K8s ServiceAccount bootstrap from selected compute driver config #2023 relocates it.TokenReview, still pod-bound extras required, still the live pod UID matched against the token, still validated against the pod's owningSandboxCR. The set is operator-configured and closed.service_account_nameis not format-validated either, and a well-formed but wrong name fails exactly as silently as a malformed one, so the check would buy little. The rejection log names the presented principal alongside the accepted set, which is what actually makes a typo diagnosable. Happy to addis_dns_1123_labelenforcement at startup if you want the stricter posture.There is a natural bound on this worth stating: Kubernetes refuses to mint a pod-bound token whose
ServiceAccountdiffers from the pod's own —cannot bind token for serviceaccount "X" to pod running with different serviceaccount name. An accepted identity can therefore only ever be presented by a pod actually running thatServiceAccount.Testing
cargo test -p openshell-server -p openshell-driver-kubernetes— green (1,409 and 225). Part 1 unit tests: pod default accepted alone (legacy behaviour), every configured name accepted, non-member rejected, blank entries dropped and padded entries trimmed, duplicates collapsed, an additional account authenticating throughtoken_review_identity, and the presented account reported (with presented ≠ pod default, so the assertion distinguishes the token from the config). The pre-existing single-account accept/reject tests still pass unchanged.k8s_sa_bootstrap_reads_additional_service_accounts_from_driver_tableparses the exact TOML the chart renders throughkubernetes_config_for_k8s_sa_bootstrapand builds the validator from it.KubernetesComputeConfigdenies unknown fields, so this is what pins the Helm key against the Rust field; a mismatch would refuse to start every gateway in a fleet.build_platform_configforwards a requested account and omits an unset one; the validator accepts both lists.requested_pod_service_accountreads theplatform_configkey (and ignores a non-string value), the read and the config resolve together, and the TOML-ingest test now pins both keys — a rename of either would otherwise refuse to start every gateway in a fleet with nothing red in CI.create_sandbox_preserves_invalid_argument_from_the_driverasserts the code, the unwrapped message, and that no sandbox record survives the rejection.driver_template_rejects_a_service_account_request_for_other_driverscovers Podman, Docker and VM.helm unittest deploy/helm/openshell— 8 new cases (each key omitted by default; each rendered; both rendered together; blank and non-string entries fail the render for both lists). Passing goes 97 → 105. The assertions are line-anchored, because a containment regex silently stops matching once a rendered array literal sits between the table header and the second key. The 6 failures incredential_drivers_test.yamlandgateway_config_test.yamlare pre-existing — I get the identical 6 against an unmodifiedorigin/mainworktree.mise run pre-commitpasses, includingclippy -D warnings,helm:lint,helm:docs:checkandlicense:check.Verified on a kind cluster
kind v1.34.0, agent-sandbox v0.5.0, Kubernetes compute driver, sandbox namespace
os-sa, gateway built from this branch. Both parts are covered. The driver-assigned sandboxServiceAccountissa-openshell-sandbox;sa-openshell-sandbox-2stands in for an identity assigned out-of-band.To reproduce the issue's scenario I created a pod running
sa-openshell-sandbox-2, annotated with a real sandbox id and owner-referenced to that sandbox'sSandboxCR — a sandbox pod whoseServiceAccountthe driver did not choose — then calledIssueSandboxTokenwith a pod-bound token for that account.{sa-openshell-sandbox}(default)sa-openshell-sandbox-2PermissionDenied: SA token is not from an accepted sandbox service account{sa-openshell-sandbox}(default)sa-openshell-sandbox+ sa-openshell-sandbox-2sa-openshell-sandbox-2+ sa-openshell-sandbox-2sa-openshell-sandbox+ " sa-openshell-sandbox-2 "(padded in values)sa-openshell-sandbox-2The rejection record names both sides, as the Definition of Done asks:
Startup, with a name added:
The pod default is genuinely a separate setting — sandboxes created after the additional name was configured still get exactly one
ServiceAccount, the driver's:And a blank list entry fails at render instead of silently vanishing:
Part 2: selection and the escalation boundary
Gateway configured with
additional_bootstrap_service_account_names = ["sa-openshell-sandbox-external"]andselectable_service_account_names = ["sa-openshell-sandbox-2"], so the two lists are distinguishable. Startup:CreateSandboxwithSandboxTemplate.service_account_name:sa-openshell-sandboxsa-openshell-sandbox-2(selectable)sa-openshell-sandbox-2sa-openshell-sandbox-external(bootstrap-only)InvalidArgument: service_account_name 'sa-openshell-sandbox-external' is not selectable on this gateway; selectable accounts are {"sa-openshell-sandbox", "sa-openshell-sandbox-2"}nope-not-a-saInvalidArgument, same shapeThe third row is the escalation this design prevents: an account enrolled so an externally-owned pod can bootstrap is not on the menu for callers. And bootstrap still works independently of selectability:
sa-openshell-sandbox-2(selectable, driver-assigned via the request)sa-openshell-sandbox-external(bootstrap-only, assigned out-of-band)sa-openshell-sandbox-nope(in neither list)PermissionDenied: SA token is not from an accepted sandbox service accountThe first run of this returned
Internalrather thanInvalidArgumentfor the two rejections, which is what prompted the third commit; re-verified after the fix.Part 2
Verified against the exact commit on this branch: the deployed binary's SHA-256 matches the local build from
666c6b8bwith a clean tree. Gateway configured withadditional_bootstrap_service_account_names = ["sa-openshell-sandbox-external"]andselectable_service_account_names = ["sa-openshell-sandbox-2"], so the two lists are distinguishable.CreateSandboxwithSandboxTemplate.service_account_name:sa-openshell-sandboxsa-openshell-sandbox-2(selectable)sa-openshell-sandbox-2sa-openshell-sandbox-external(bootstrap-only)InvalidArgument: service_account_name '...' is not selectable on this gatewaysa-openshell-sandbox-nope(unknown)InvalidArgument, same shapeOpenshell_Sandbox(malformed)InvalidArgument: template.service_account_name must be a valid Kubernetes ServiceAccount name (DNS-1123 subdomain, at most 253 characters)" "(present but blank)InvalidArgument: template.service_account_name exceeds maximum length (2000 > 1024)The third row is the escalation the two-list split exists to prevent. Only the two successful creates produced a
SandboxCR — the five rejections left nothing behind, which is what moving the check intovalidate_sandbox_createbuys. The caller gets its own requested name and no more; the selectable set goes to the gateway log:Bootstrap still works independently of selectability:
sa-openshell-sandbox-2(selectable, assigned because the request asked for it)sa-openshell-sandbox-external(bootstrap-only, assigned out-of-band)sa-openshell-sandbox-nope(in neither list)PermissionDenied: SA token is not from an accepted sandbox service accountStartup validation is a new way for a gateway to refuse to boot, so it was worth confirming:
selectableServiceAccountNames: [Bad_Name]leaves the pod inCrashLoopBackOffwithselectable_service_account_names entry 'Bad_Name' is not a valid Kubernetes ServiceAccount name.Two limitations the cluster run confirmed rather than fixed, both now documented: the driver provisions only its own account, so a selected account must already exist in every namespace the gateway uses — which rules out
workspace_mode = "managed", where namespaces are created on demand — and a separately deployed driver process has no flag for the selectable list, so it refuses every non-default request.The issue carries
test:e2e-kubernetes, and the triage suggested E2E coverage for bootstrap with a non-default acceptedServiceAccount. I have not added ane2e/rusttest. The fixture needs a pod whoseServiceAccountthe driver does not assign, which is a new shape for that harness —workspace_namespace_operator.rsis the closest template, since it exercises the namespace half of the same function and already hand-creates aServiceAccount. Happy to add it in this PR rather than as a follow-up if you'd prefer.Definition of Done
ServiceAccountis selectable per sandbox, constrained to an operator-configured setChecklist