feat(auth): add SPIFFE supervisor authentication

Signed-off-by: Taylor Mutch <taylormutch@gmail.com>
This commit is contained in:
Taylor Mutch
2026-05-21 18:29:38 -07:00
parent c5c3f03ce7
commit c40becc390
35 changed files with 1103 additions and 249 deletions
@@ -154,6 +154,22 @@ If the gateway exits with `failed to read sandbox JWT signing key from
`sandbox-jwt` secret at `/etc/openshell-jwt`. The sandbox JWT mount is required
even when local Helm values disable TLS.
If `server.spiffe.enabled=true`, the sandbox JWT ConfigMap block and
`sandbox-jwt` StatefulSet mount are intentionally omitted. Instead verify that
SPIRE is installed, the CSI driver is available, and the gateway pod mounts the
SPIFFE Workload API socket:
```bash
helm -n openshell get values openshell | grep -E 'spiffe|trustDomain|workloadApiSocketPath'
kubectl get pods -A | grep -E 'spire|spiffe'
kubectl -n openshell get statefulset openshell -o yaml | grep -E 'spiffe-workload-api|csi.spiffe.io'
```
Sandbox pods in SPIFFE mode should have `openshell.io/sandbox-id` and
`openshell.io/spiffe-id` annotations, an `openshell.ai/managed-by=openshell`
label, and supervisor env vars `OPENSHELL_SPIFFE_WORKLOAD_API_SOCKET`,
`OPENSHELL_SPIFFE_AUDIENCE`, and `OPENSHELL_SPIFFE_ID`.
Check the image references currently used by the gateway deployment:
```bash
@@ -172,6 +172,21 @@ To remove Keycloak:
mise run keycloak:k8s:teardown
```
### SPIRE / SPIFFE Sandbox Identity
Skaffold can install SPIRE with the SPIFFE hardened Helm charts. To activate
SPIFFE JWT-SVID supervisor authentication:
1. Uncomment the `spire-crds` and `spire` releases in `deploy/helm/openshell/skaffold.yaml`
2. Uncomment `#- ci/values-spire.yaml` in the OpenShell release values files
3. Redeploy: `mise run helm:skaffold:run`
`ci/values-spire-stack.yaml` configures the local SPIRE trust domain as
`openshell.local` and adds a `ClusterSPIFFEID` that maps sandbox pod
annotations to `spiffe://openshell.local/openshell/sandbox/<sandbox-id>`.
OpenShell mounts the SPIFFE CSI Workload API socket at
`/spiffe-workload-api/spire-agent.sock`.
---
## Cluster Lifecycle (suspend/resume)
@@ -199,6 +214,8 @@ mise run helm:k3s:status
| `deploy/helm/openshell/ci/values-cert-manager.yaml` | cert-manager PKI overlay (opt-in; disables pkiInitJob) |
| `deploy/helm/openshell/ci/values-gateway.yaml` | Envoy Gateway GRPCRoute + Gateway overlay |
| `deploy/helm/openshell/ci/values-keycloak.yaml` | Keycloak OIDC overlay |
| `deploy/helm/openshell/ci/values-spire.yaml` | SPIFFE/SPIRE sandbox supervisor auth overlay |
| `deploy/helm/openshell/ci/values-spire-stack.yaml` | SPIRE hardened chart values for local dev |
| `deploy/helm/openshell/ci/values-tls-disabled.yaml` | Lint-only: TLS + auth disabled (reverse-proxy edge termination) |
| `deploy/kube/manifests/envoy-gateway-openshell.yaml` | GatewayClass for Envoy Gateway (`mise run helm:gateway:apply`) |
| `tasks/scripts/helm-k3s-local.sh` | k3d cluster create/delete/start/stop/status |
Generated
+130 -146
View File
@@ -156,6 +156,15 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "arc-swap"
version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207"
dependencies = [
"rustversion",
]
[[package]]
name = "argon2"
version = "0.5.3"
@@ -217,28 +226,6 @@ dependencies = [
"serde_json",
]
[[package]]
name = "async-stream"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
dependencies = [
"async-stream-impl",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-stream-impl"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "async-trait"
version = "0.1.89"
@@ -303,40 +290,13 @@ dependencies = [
"fs_extra",
]
[[package]]
name = "axum"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core 0.4.5",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"itoa",
"matchit 0.7.3",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"rustversion",
"serde",
"sync_wrapper",
"tower 0.5.3",
"tower-layer",
"tower-service",
]
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core 0.5.6",
"axum-core",
"base64 0.22.1",
"bytes",
"form_urlencoded",
@@ -347,7 +307,7 @@ dependencies = [
"hyper",
"hyper-util",
"itoa",
"matchit 0.8.4",
"matchit",
"memchr",
"mime",
"percent-encoding",
@@ -366,26 +326,6 @@ dependencies = [
"tracing",
]
[[package]]
name = "axum-core"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
dependencies = [
"async-trait",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"rustversion",
"sync_wrapper",
"tower-layer",
"tower-service",
]
[[package]]
name = "axum-core"
version = "0.5.6"
@@ -1855,19 +1795,13 @@ dependencies = [
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.0",
"indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -2149,7 +2083,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.3",
"socket2",
"tokio",
"tower-service",
"tracing",
@@ -2328,16 +2262,6 @@ dependencies = [
"quote",
]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -2957,12 +2881,6 @@ dependencies = [
"regex-automata",
]
[[package]]
name = "matchit"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "matchit"
version = "0.8.4"
@@ -3011,7 +2929,7 @@ dependencies = [
"http-body-util",
"hyper",
"hyper-util",
"indexmap 2.14.0",
"indexmap",
"ipnet",
"metrics",
"metrics-util",
@@ -3467,7 +3385,8 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"tonic",
"tonic-build",
"tonic-prost",
"tonic-prost-build",
"url",
]
@@ -3668,6 +3587,7 @@ dependencies = [
"serde_yml",
"sha1 0.10.6",
"sha2 0.10.9",
"spiffe",
"temp-env",
"tempfile",
"thiserror 2.0.18",
@@ -3689,7 +3609,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"async-trait",
"axum 0.8.9",
"axum",
"bytes",
"clap",
"futures",
@@ -3732,6 +3652,7 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.10.9",
"spiffe",
"sqlx",
"tempfile",
"thiserror 2.0.18",
@@ -4016,12 +3937,13 @@ dependencies = [
[[package]]
name = "petgraph"
version = "0.7.1"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset",
"indexmap 2.14.0",
"hashbrown 0.15.5",
"indexmap",
]
[[package]]
@@ -4259,9 +4181,9 @@ dependencies = [
[[package]]
name = "prost"
version = "0.13.5"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5"
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
dependencies = [
"bytes",
"prost-derive",
@@ -4269,19 +4191,20 @@ dependencies = [
[[package]]
name = "prost-build"
version = "0.13.5"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf"
checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7"
dependencies = [
"heck",
"itertools 0.14.0",
"log",
"multimap",
"once_cell",
"petgraph",
"prettyplease",
"prost",
"prost-types",
"pulldown-cmark",
"pulldown-cmark-to-cmark",
"regex",
"syn 2.0.117",
"tempfile",
@@ -4289,9 +4212,9 @@ dependencies = [
[[package]]
name = "prost-derive"
version = "0.13.5"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -4302,9 +4225,9 @@ dependencies = [
[[package]]
name = "prost-types"
version = "0.13.5"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16"
checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7"
dependencies = [
"prost",
]
@@ -4318,6 +4241,26 @@ dependencies = [
"autotools",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad"
dependencies = [
"bitflags",
"memchr",
"unicase",
]
[[package]]
name = "pulldown-cmark-to-cmark"
version = "22.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "quanta"
version = "0.12.6"
@@ -4346,7 +4289,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.2",
"rustls",
"socket2 0.6.3",
"socket2",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -4384,7 +4327,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.3",
"socket2",
"tracing",
"windows-sys 0.60.2",
]
@@ -5240,7 +5183,7 @@ version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.14.0",
"indexmap",
"itoa",
"ryu",
"serde",
@@ -5253,7 +5196,7 @@ version = "0.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd"
dependencies = [
"indexmap 2.14.0",
"indexmap",
"itoa",
"libyml",
"memchr",
@@ -5434,16 +5377,6 @@ dependencies = [
"serde",
]
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.3"
@@ -5454,6 +5387,34 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "spiffe"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d3f9e45e9e53f03cb452fe0f050101a9280ff4f4214e326037bc8275284d906"
dependencies = [
"arc-swap",
"base64ct",
"fastrand",
"futures",
"hyper-util",
"log",
"prost",
"prost-types",
"serde",
"serde_json",
"thiserror 2.0.18",
"time",
"tokio",
"tokio-util",
"tonic",
"tonic-prost",
"tower 0.5.3",
"tracing",
"url",
"zeroize",
]
[[package]]
name = "spin"
version = "0.9.8"
@@ -5514,7 +5475,7 @@ dependencies = [
"futures-util",
"hashbrown 0.15.5",
"hashlink",
"indexmap 2.14.0",
"indexmap",
"log",
"memchr",
"once_cell",
@@ -6080,7 +6041,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.6.3",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -6186,7 +6147,7 @@ version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap 2.14.0",
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
@@ -6202,13 +6163,12 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tonic"
version = "0.12.3"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52"
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
dependencies = [
"async-stream",
"async-trait",
"axum 0.7.9",
"axum",
"base64 0.22.1",
"bytes",
"h2",
@@ -6220,14 +6180,13 @@ dependencies = [
"hyper-util",
"percent-encoding",
"pin-project",
"prost",
"rustls-native-certs",
"rustls-pemfile",
"socket2 0.5.10",
"socket2",
"sync_wrapper",
"tokio",
"tokio-rustls",
"tokio-stream",
"tower 0.4.13",
"tower 0.5.3",
"tower-layer",
"tower-service",
"tracing",
@@ -6235,9 +6194,32 @@ dependencies = [
[[package]]
name = "tonic-build"
version = "0.12.3"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11"
checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322"
dependencies = [
"prettyplease",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tonic-prost"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0"
dependencies = [
"bytes",
"prost",
"tonic",
]
[[package]]
name = "tonic-prost-build"
version = "0.14.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27"
dependencies = [
"prettyplease",
"proc-macro2",
@@ -6245,6 +6227,8 @@ dependencies = [
"prost-types",
"quote",
"syn 2.0.117",
"tempfile",
"tonic-build",
]
[[package]]
@@ -6255,11 +6239,8 @@ checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
dependencies = [
"futures-core",
"futures-util",
"indexmap 1.9.3",
"pin-project",
"pin-project-lite",
"rand 0.8.6",
"slab",
"tokio",
"tokio-util",
"tower-layer",
@@ -6275,9 +6256,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"indexmap",
"pin-project-lite",
"slab",
"sync_wrapper",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
"tracing",
@@ -6764,7 +6748,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap 2.14.0",
"indexmap",
"wasm-encoder",
"wasmparser",
]
@@ -6790,7 +6774,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap 2.14.0",
"indexmap",
"semver",
]
@@ -7355,7 +7339,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap 2.14.0",
"indexmap",
"prettyplease",
"syn 2.0.117",
"wasm-metadata",
@@ -7386,7 +7370,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap 2.14.0",
"indexmap",
"log",
"serde",
"serde_derive",
@@ -7405,7 +7389,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap 2.14.0",
"indexmap",
"log",
"semver",
"serde",
@@ -7619,7 +7603,7 @@ dependencies = [
"flate2",
"getrandom 0.4.2",
"hmac",
"indexmap 2.14.0",
"indexmap",
"lzma-rust2",
"memchr",
"pbkdf2",
+6 -4
View File
@@ -17,10 +17,11 @@ repository = "https://github.com/NVIDIA/OpenShell"
tokio = { version = "1.43", features = ["full"] }
# gRPC/Protobuf
tonic = "0.12"
tonic-build = "0.12"
prost = "0.13"
prost-types = "0.13"
tonic = "0.14"
tonic-prost = "0.14"
tonic-prost-build = "0.14"
prost = "0.14"
prost-types = "0.14"
# HTTP server
axum = { version = "0.8", features = ["ws"] }
@@ -87,6 +88,7 @@ sha2 = "0.10"
rand = "0.9"
jsonwebtoken = "9"
getrandom = "0.3"
spiffe = { version = "0.15", default-features = false, features = ["workload-api-jwt", "tracing"] }
# Filesystem embedding
include_dir = "0.7"
+9 -3
View File
@@ -46,10 +46,16 @@ Supported auth modes:
| Unauthenticated local users | Trusted Kubernetes dev or fully trusted proxy deployments only. |
| Cloudflare JWT | Edge-authenticated deployments where Cloudflare Access supplies identity. |
| OIDC | Bearer-token auth for users, with browser PKCE or client credentials login. |
| SPIFFE JWT-SVID | Sandbox supervisor authentication through a local SPIFFE Workload API implementation such as SPIRE. |
Sandbox supervisor RPCs authenticate with gateway-minted sandbox JWTs when that
authenticator is configured; mTLS does not grant sandbox identity. User-facing
mutations are authorized by role policy when OIDC or edge identity is enabled.
Sandbox supervisor RPCs authenticate with explicit sandbox credentials; mTLS
does not grant sandbox identity. Kubernetes deployments can use either the
gateway-minted JWT bootstrap path or SPIFFE JWT-SVIDs. In SPIFFE mode, the
supervisor fetches a JWT-SVID from the SPIFFE Workload API and the gateway
validates it through its own local Workload API socket, then maps
`spiffe://<trust-domain>/openshell/sandbox/<id>` to `Principal::Sandbox`.
User-facing mutations are authorized by role policy when OIDC or edge identity
is enabled.
Sandbox secrets are gateway-signed JWTs bound to a single sandbox ID. Docker,
Podman, and VM drivers deliver the initial token through supervisor-only
+1 -1
View File
@@ -30,7 +30,7 @@ prost-types = { workspace = true }
tokio = { workspace = true }
# gRPC client
tonic = { workspace = true, features = ["tls", "tls-native-roots"] }
tonic = { workspace = true, features = ["tls-native-roots"] }
# CLI
chrono = "0.4"
+2 -1
View File
@@ -14,6 +14,7 @@ repository.workspace = true
prost = { workspace = true }
prost-types = { workspace = true }
tonic = { workspace = true }
tonic-prost = { workspace = true }
thiserror = { workspace = true }
miette = { workspace = true }
serde = { workspace = true }
@@ -28,7 +29,7 @@ ipnet = "2"
dev-settings = []
[build-dependencies]
tonic-build = { workspace = true }
tonic-prost-build = { workspace = true }
protobuf-src = { workspace = true }
[dev-dependencies]
+4 -3
View File
@@ -40,11 +40,12 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
collect_proto_files(&proto_root, &mut proto_files)?;
proto_files.sort();
// Configure tonic-build
tonic_build::configure()
// Configure tonic/prost protobuf code generation.
let include_paths = [proto_root];
tonic_prost_build::configure()
.build_server(true)
.build_client(true)
.compile_protos(&proto_files, &[proto_root.as_path()])?;
.compile_protos(&proto_files, &include_paths)?;
Ok(())
}
+35
View File
@@ -223,6 +223,12 @@ pub struct Config {
#[serde(default)]
pub gateway_jwt: Option<GatewayJwtConfig>,
/// SPIFFE Workload API configuration. When `Some`, the gateway validates
/// sandbox JWT-SVIDs through the local SPIFFE implementation and maps
/// matching SPIFFE IDs to sandbox principals.
#[serde(default)]
pub spiffe: Option<SpiffeConfig>,
/// Database URL for persistence.
pub database_url: String,
@@ -379,6 +385,26 @@ pub struct GatewayJwtConfig {
pub ttl_secs: u64,
}
/// SPIFFE-based sandbox identity configuration.
///
/// The gateway uses the local SPIFFE Workload API to validate JWT-SVIDs
/// presented by sandbox supervisors. Supervisors request those JWT-SVIDs
/// for the configured audience and use SPIFFE IDs shaped as
/// `spiffe://<trust_domain>/<sandbox_id_prefix><sandbox-id>`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpiffeConfig {
/// Path to the SPIFFE Workload API UNIX socket.
pub workload_api_socket_path: PathBuf,
/// Trust domain accepted for sandbox SPIFFE IDs.
pub trust_domain: String,
/// Audience expected in sandbox JWT-SVIDs.
#[serde(default = "default_spiffe_audience")]
pub audience: String,
/// Path prefix, below the trust domain, that precedes the sandbox UUID.
#[serde(default = "default_spiffe_sandbox_id_prefix")]
pub sandbox_id_prefix: String,
}
fn default_gateway_id() -> String {
"openshell".to_string()
}
@@ -387,6 +413,14 @@ const fn default_sandbox_token_ttl_secs() -> u64 {
3_600
}
fn default_spiffe_audience() -> String {
"openshell-gateway".to_string()
}
fn default_spiffe_sandbox_id_prefix() -> String {
"/openshell/sandbox/".to_string()
}
fn default_roles_claim() -> String {
"realm_access.roles".to_string()
}
@@ -413,6 +447,7 @@ impl Config {
auth: GatewayAuthConfig::default(),
mtls_auth: MtlsAuthConfig::default(),
gateway_jwt: None,
spiffe: None,
database_url: String::new(),
compute_drivers: vec![],
ssh_session_ttl_secs: default_ssh_session_ttl_secs(),
+1 -1
View File
@@ -28,7 +28,7 @@ pub mod time;
pub use config::{
ComputeDriverKind, Config, GatewayAuthConfig, GatewayJwtConfig, MtlsAuthConfig, OidcConfig,
TlsConfig,
SpiffeConfig, TlsConfig,
};
pub use error::{ComputeDriverError, Error, Result};
pub use metadata::{GetResourceVersion, ObjectId, ObjectLabels, ObjectName, SetResourceVersion};
+12
View File
@@ -53,3 +53,15 @@ pub const SANDBOX_TOKEN_FILE: &str = "OPENSHELL_SANDBOX_TOKEN_FILE";
/// writes and rotates this file; the supervisor exchanges its contents
/// for a gateway JWT at startup and on refresh.
pub const K8S_SA_TOKEN_FILE: &str = "OPENSHELL_K8S_SA_TOKEN_FILE";
/// Filesystem path to the SPIFFE Workload API UNIX socket.
///
/// When set, the supervisor fetches a JWT-SVID from the local Workload API
/// and presents that token directly to the gateway.
pub const SPIFFE_WORKLOAD_API_SOCKET: &str = "OPENSHELL_SPIFFE_WORKLOAD_API_SOCKET";
/// Audience requested when fetching a SPIFFE JWT-SVID.
pub const SPIFFE_AUDIENCE: &str = "OPENSHELL_SPIFFE_AUDIENCE";
/// Optional exact SPIFFE ID requested from the Workload API.
pub const SPIFFE_ID: &str = "OPENSHELL_SPIFFE_ID";
@@ -83,6 +83,16 @@ pub struct KubernetesComputeConfig {
/// this token within a few seconds of pod start, so any value at
/// the floor is sufficient. Default 3600.
pub sa_token_ttl_secs: i64,
/// SPIFFE Workload API socket path mounted into sandbox pods. Empty
/// disables SPIFFE identity material and keeps the `ServiceAccount`
/// bootstrap path active.
pub spiffe_workload_api_socket_path: String,
/// SPIFFE trust domain used for sandbox identities.
pub spiffe_trust_domain: String,
/// Audience requested by sandbox supervisors for JWT-SVIDs.
pub spiffe_audience: String,
/// Path prefix under the trust domain before the sandbox UUID.
pub spiffe_sandbox_id_prefix: String,
}
/// Lower bound enforced by kubelet for projected SA tokens.
@@ -114,6 +124,10 @@ impl Default for KubernetesComputeConfig {
enable_user_namespaces: false,
workspace_default_storage_size: DEFAULT_WORKSPACE_STORAGE_SIZE.to_string(),
sa_token_ttl_secs: 3600,
spiffe_workload_api_socket_path: String::new(),
spiffe_trust_domain: String::new(),
spiffe_audience: "openshell-gateway".to_string(),
spiffe_sandbox_id_prefix: "/openshell/sandbox/".to_string(),
}
}
}
@@ -131,6 +145,13 @@ impl KubernetesComputeConfig {
.clamp(MIN_SA_TOKEN_TTL_SECS, MAX_SA_TOKEN_TTL_SECS)
}
}
#[must_use]
pub fn spiffe_enabled(&self) -> bool {
!self.spiffe_workload_api_socket_path.trim().is_empty()
&& !self.spiffe_trust_domain.trim().is_empty()
&& !self.spiffe_audience.trim().is_empty()
}
}
#[cfg(test)]
+222 -35
View File
@@ -78,6 +78,7 @@ pub const SANDBOX_KIND: &str = "Sandbox";
const GPU_RESOURCE_NAME: &str = "nvidia.com/gpu";
const GPU_RESOURCE_QUANTITY: &str = "1";
const SPIFFE_WORKLOAD_API_VOLUME_NAME: &str = "spiffe-workload-api";
// ---------------------------------------------------------------------------
// Default workspace persistence (temporary — will be replaced by snapshotting)
@@ -331,6 +332,11 @@ impl KubernetesComputeDriver {
enable_user_namespaces: self.config.enable_user_namespaces,
workspace_default_storage_size: &self.config.workspace_default_storage_size,
sa_token_ttl_secs: self.config.effective_sa_token_ttl_secs(),
spiffe_enabled: self.config.spiffe_enabled(),
spiffe_workload_api_socket_path: &self.config.spiffe_workload_api_socket_path,
spiffe_trust_domain: &self.config.spiffe_trust_domain,
spiffe_audience: &self.config.spiffe_audience,
spiffe_sandbox_id_prefix: &self.config.spiffe_sandbox_id_prefix,
};
obj.data = sandbox_to_k8s_spec(sandbox.spec.as_ref(), &params);
let api = self.api();
@@ -1062,6 +1068,11 @@ struct SandboxPodParams<'a> {
/// Lifetime (seconds) of the projected `ServiceAccount` token used
/// for the bootstrap `IssueSandboxToken` exchange.
sa_token_ttl_secs: i64,
spiffe_enabled: bool,
spiffe_workload_api_socket_path: &'a str,
spiffe_trust_domain: &'a str,
spiffe_audience: &'a str,
spiffe_sandbox_id_prefix: &'a str,
}
impl Default for SandboxPodParams<'_> {
@@ -1082,6 +1093,11 @@ impl Default for SandboxPodParams<'_> {
enable_user_namespaces: false,
workspace_default_storage_size: DEFAULT_WORKSPACE_STORAGE_SIZE,
sa_token_ttl_secs: 3600,
spiffe_enabled: false,
spiffe_workload_api_socket_path: "",
spiffe_trust_domain: "",
spiffe_audience: "openshell-gateway",
spiffe_sandbox_id_prefix: "/openshell/sandbox/",
}
}
}
@@ -1172,8 +1188,25 @@ fn sandbox_template_to_k8s(
params: &SandboxPodParams<'_>,
) -> serde_json::Value {
let mut metadata = serde_json::Map::new();
if !template.labels.is_empty() {
metadata.insert("labels".to_string(), serde_json::json!(template.labels));
let mut pod_labels = template
.labels
.iter()
.map(|(key, value)| (key.clone(), serde_json::Value::String(value.clone())))
.collect::<serde_json::Map<String, serde_json::Value>>();
if params.spiffe_enabled {
pod_labels.insert(
LABEL_MANAGED_BY.to_string(),
serde_json::Value::String(LABEL_MANAGED_BY_VALUE.to_string()),
);
if !params.sandbox_id.is_empty() {
pod_labels.insert(
LABEL_SANDBOX_ID.to_string(),
serde_json::Value::String(params.sandbox_id.to_string()),
);
}
}
if !pod_labels.is_empty() {
metadata.insert("labels".to_string(), serde_json::Value::Object(pod_labels));
}
// Carry the sandbox UUID as a pod annotation so the gateway can resolve
// a projected SA token claim (pod name + uid) back to a sandbox identity
@@ -1193,6 +1226,12 @@ fn sandbox_template_to_k8s(
serde_json::Value::String(params.sandbox_id.to_string()),
);
}
if params.spiffe_enabled {
pod_annotations.insert(
"openshell.io/spiffe-id".to_string(),
serde_json::Value::String(sandbox_spiffe_id(params)),
);
}
if !pod_annotations.is_empty() {
metadata.insert(
"annotations".to_string(),
@@ -1270,6 +1309,7 @@ fn sandbox_template_to_k8s(
params.grpc_endpoint,
params.ssh_socket_path,
!params.client_tls_secret_name.is_empty(),
spiffe_env(params),
);
container.insert("env".to_string(), serde_json::Value::Array(env));
@@ -1291,9 +1331,10 @@ fn sandbox_template_to_k8s(
}),
);
// Mount client TLS secret for mTLS to the server, plus the projected
// ServiceAccount token used to bootstrap the sandbox's gateway JWT
// via `IssueSandboxToken`.
// Mount client TLS secret for mTLS to the server, plus exactly one
// sandbox identity source: SPIFFE Workload API socket when configured,
// otherwise a projected ServiceAccount token for the gateway-JWT
// bootstrap path.
let mut volume_mounts: Vec<serde_json::Value> = Vec::new();
if !params.client_tls_secret_name.is_empty() {
volume_mounts.push(serde_json::json!({
@@ -1302,11 +1343,19 @@ fn sandbox_template_to_k8s(
"readOnly": true
}));
}
volume_mounts.push(serde_json::json!({
"name": "openshell-sa-token",
"mountPath": "/var/run/secrets/openshell",
"readOnly": true,
}));
if params.spiffe_enabled {
volume_mounts.push(serde_json::json!({
"name": SPIFFE_WORKLOAD_API_VOLUME_NAME,
"mountPath": spiffe_socket_mount_path(params.spiffe_workload_api_socket_path),
"readOnly": true,
}));
} else {
volume_mounts.push(serde_json::json!({
"name": "openshell-sa-token",
"mountPath": "/var/run/secrets/openshell",
"readOnly": true,
}));
}
container.insert(
"volumeMounts".to_string(),
serde_json::Value::Array(volume_mounts),
@@ -1329,23 +1378,33 @@ fn sandbox_template_to_k8s(
"secret": { "secretName": params.client_tls_secret_name, "defaultMode": 256 }
}));
}
// Projected ServiceAccountToken volume — kubelet writes a short-lived
// audience-bound JWT into /var/run/secrets/openshell/token and rotates
// it automatically. The supervisor exchanges this for a gateway-minted
// JWT via `IssueSandboxToken` once at startup.
volumes.push(serde_json::json!({
"name": "openshell-sa-token",
"projected": {
"sources": [{
"serviceAccountToken": {
"audience": "openshell-gateway",
"expirationSeconds": params.sa_token_ttl_secs,
"path": "token"
}
}],
"defaultMode": 256
}
}));
if params.spiffe_enabled {
volumes.push(serde_json::json!({
"name": SPIFFE_WORKLOAD_API_VOLUME_NAME,
"csi": {
"driver": "csi.spiffe.io",
"readOnly": true
}
}));
} else {
// Projected ServiceAccountToken volume — kubelet writes a short-lived
// audience-bound JWT into /var/run/secrets/openshell/token and rotates
// it automatically. The supervisor exchanges this for a gateway-minted
// JWT via `IssueSandboxToken` once at startup.
volumes.push(serde_json::json!({
"name": "openshell-sa-token",
"projected": {
"sources": [{
"serviceAccountToken": {
"audience": "openshell-gateway",
"expirationSeconds": params.sa_token_ttl_secs,
"path": "token"
}
}],
"defaultMode": 256
}
}));
}
spec.insert("volumes".to_string(), serde_json::Value::Array(volumes));
// Add hostAliases so sandbox pods can reach the Docker host.
@@ -1457,6 +1516,7 @@ fn build_env_list(
grpc_endpoint: &str,
ssh_socket_path: &str,
tls_enabled: bool,
spiffe_env: Option<SpiffeEnv>,
) -> Vec<serde_json::Value> {
let mut env = existing_env.cloned().unwrap_or_default();
apply_env_map(&mut env, template_environment);
@@ -1468,6 +1528,7 @@ fn build_env_list(
grpc_endpoint,
ssh_socket_path,
tls_enabled,
spiffe_env,
);
env
}
@@ -1490,6 +1551,7 @@ fn apply_required_env(
grpc_endpoint: &str,
ssh_socket_path: &str,
tls_enabled: bool,
spiffe_env: Option<SpiffeEnv>,
) {
upsert_env(env, openshell_core::sandbox_env::SANDBOX_ID, sandbox_id);
upsert_env(env, openshell_core::sandbox_env::SANDBOX, sandbox_name);
@@ -1525,14 +1587,79 @@ fn apply_required_env(
"/etc/openshell-tls/client/tls.key",
);
}
// Projected ServiceAccount token written by kubelet (see the volume
// definition in `sandbox_template_to_k8s`). The supervisor reads this
// and exchanges it for a gateway-minted JWT via `IssueSandboxToken`.
upsert_env(
env,
openshell_core::sandbox_env::K8S_SA_TOKEN_FILE,
"/var/run/secrets/openshell/token",
);
if let Some(spiffe) = spiffe_env {
upsert_env(
env,
openshell_core::sandbox_env::SPIFFE_WORKLOAD_API_SOCKET,
&spiffe.socket_path,
);
upsert_env(
env,
openshell_core::sandbox_env::SPIFFE_AUDIENCE,
&spiffe.audience,
);
upsert_env(env, openshell_core::sandbox_env::SPIFFE_ID, &spiffe.id);
} else {
// Projected ServiceAccount token written by kubelet (see the volume
// definition in `sandbox_template_to_k8s`). The supervisor reads this
// and exchanges it for a gateway-minted JWT via `IssueSandboxToken`.
upsert_env(
env,
openshell_core::sandbox_env::K8S_SA_TOKEN_FILE,
"/var/run/secrets/openshell/token",
);
}
}
#[derive(Clone)]
struct SpiffeEnv {
socket_path: String,
audience: String,
id: String,
}
fn spiffe_env(params: &SandboxPodParams<'_>) -> Option<SpiffeEnv> {
params.spiffe_enabled.then(|| SpiffeEnv {
socket_path: params.spiffe_workload_api_socket_path.to_string(),
audience: params.spiffe_audience.to_string(),
id: sandbox_spiffe_id(params),
})
}
fn sandbox_spiffe_id(params: &SandboxPodParams<'_>) -> String {
format!(
"spiffe://{}{}{}",
params
.spiffe_trust_domain
.trim()
.trim_start_matches("spiffe://")
.trim_end_matches('/'),
normalize_spiffe_path_prefix(params.spiffe_sandbox_id_prefix),
params.sandbox_id,
)
}
fn normalize_spiffe_path_prefix(prefix: &str) -> String {
let trimmed = prefix.trim();
let with_leading = if trimmed.starts_with('/') {
trimmed.to_string()
} else {
format!("/{trimmed}")
};
if with_leading.ends_with('/') {
with_leading
} else {
format!("{with_leading}/")
}
}
fn spiffe_socket_mount_path(socket_path: &str) -> String {
std::path::Path::new(socket_path)
.parent()
.and_then(std::path::Path::to_str)
.filter(|path| !path.is_empty())
.unwrap_or("/spiffe-workload-api")
.to_string()
}
fn upsert_env(env: &mut Vec<serde_json::Value>, name: &str, value: &str) {
@@ -1952,6 +2079,7 @@ mod tests {
"https://endpoint:8080",
"0.0.0.0:2222",
true, // tls_enabled
None,
);
// Extract the TLS-related env vars
@@ -2530,6 +2658,65 @@ mod tests {
);
}
#[test]
fn spiffe_mode_mounts_csi_socket_and_sets_identity_env() {
let params = SandboxPodParams {
sandbox_id: "sandbox-123",
sandbox_name: "sandbox",
spiffe_enabled: true,
spiffe_workload_api_socket_path: "/spiffe-workload-api/spire-agent.sock",
spiffe_trust_domain: "openshell.local",
spiffe_audience: "openshell-gateway",
spiffe_sandbox_id_prefix: "/openshell/sandbox/",
..SandboxPodParams::default()
};
let pod_template = sandbox_template_to_k8s(
&SandboxTemplate::default(),
false,
&std::collections::HashMap::new(),
true,
&params,
);
let env = pod_template["spec"]["containers"][0]["env"]
.as_array()
.expect("env");
assert!(env.iter().any(|e| {
e["name"] == openshell_core::sandbox_env::SPIFFE_WORKLOAD_API_SOCKET
&& e["value"] == "/spiffe-workload-api/spire-agent.sock"
}));
assert!(env.iter().any(|e| {
e["name"] == openshell_core::sandbox_env::SPIFFE_ID
&& e["value"] == "spiffe://openshell.local/openshell/sandbox/sandbox-123"
}));
assert!(
!env.iter()
.any(|e| e["name"] == openshell_core::sandbox_env::K8S_SA_TOKEN_FILE),
"SPIFFE mode must not expose the ServiceAccount bootstrap token"
);
let volumes = pod_template["spec"]["volumes"].as_array().expect("volumes");
assert!(volumes.iter().any(|volume| {
volume["name"] == SPIFFE_WORKLOAD_API_VOLUME_NAME
&& volume["csi"]["driver"] == "csi.spiffe.io"
}));
assert!(
!volumes
.iter()
.any(|volume| volume["name"] == "openshell-sa-token"),
"SPIFFE mode must not mount the ServiceAccount token volume"
);
assert_eq!(
pod_template["metadata"]["annotations"]["openshell.io/spiffe-id"],
serde_json::json!("spiffe://openshell.local/openshell/sandbox/sandbox-123")
);
assert_eq!(
pod_template["metadata"]["labels"][LABEL_MANAGED_BY],
serde_json::json!(LABEL_MANAGED_BY_VALUE)
);
}
#[test]
fn platform_config_bool_extracts_value() {
let template = SandboxTemplate {
@@ -82,6 +82,26 @@ struct Args {
/// gateway clamps values outside `[600, 86400]`. Default 3600.
#[arg(long, env = "OPENSHELL_K8S_SA_TOKEN_TTL_SECS", default_value_t = 3600)]
sa_token_ttl_secs: i64,
#[arg(long, env = "OPENSHELL_SPIFFE_WORKLOAD_API_SOCKET")]
spiffe_workload_api_socket_path: Option<String>,
#[arg(long, env = "OPENSHELL_SPIFFE_TRUST_DOMAIN")]
spiffe_trust_domain: Option<String>,
#[arg(
long,
env = "OPENSHELL_SPIFFE_AUDIENCE",
default_value = "openshell-gateway"
)]
spiffe_audience: String,
#[arg(
long,
env = "OPENSHELL_SPIFFE_SANDBOX_ID_PREFIX",
default_value = "/openshell/sandbox/"
)]
spiffe_sandbox_id_prefix: String,
}
#[tokio::main]
@@ -115,6 +135,10 @@ async fn main() -> Result<()> {
openshell_driver_kubernetes::DEFAULT_WORKSPACE_STORAGE_SIZE.to_string()
}),
sa_token_ttl_secs: args.sa_token_ttl_secs,
spiffe_workload_api_socket_path: args.spiffe_workload_api_socket_path.unwrap_or_default(),
spiffe_trust_domain: args.spiffe_trust_domain.unwrap_or_default(),
spiffe_audience: args.spiffe_audience,
spiffe_sandbox_id_prefix: args.spiffe_sandbox_id_prefix,
})
.await
.into_diagnostic()?;
+2 -1
View File
@@ -24,8 +24,9 @@ openshell-router = { path = "../openshell-router" }
tokio = { workspace = true }
# gRPC
tonic = { workspace = true, features = ["channel", "tls"] }
tonic = { workspace = true, features = ["channel", "tls-native-roots"] }
tokio-stream = { workspace = true }
spiffe = { workspace = true }
# CLI
clap = { workspace = true }
+159 -29
View File
@@ -4,21 +4,24 @@
//! gRPC client for fetching sandbox policy, provider environment, and inference
//! route bundles from `OpenShell` server.
//!
//! Every request carries a gateway-minted JWT in the `Authorization` header.
//! The token is resolved at startup from one of three sources:
//! Every request carries a sandbox bearer credential in the `Authorization`
//! header. The token is resolved at startup from one of four sources:
//!
//! 1. `OPENSHELL_SANDBOX_TOKEN` — raw JWT in the env (test harness path).
//! 2. `OPENSHELL_SANDBOX_TOKEN_FILE` — file containing the JWT (Docker /
//! Podman / VM drivers write this to a bundle file at sandbox-create
//! time).
//! 3. `OPENSHELL_K8S_SA_TOKEN_FILE` — projected `ServiceAccount` JWT; the
//! 3. `OPENSHELL_SPIFFE_WORKLOAD_API_SOCKET` — local SPIFFE Workload API
//! socket; the supervisor fetches a JWT-SVID and presents it directly.
//! 4. `OPENSHELL_K8S_SA_TOKEN_FILE` — projected `ServiceAccount` JWT; the
//! supervisor exchanges it for a gateway JWT via `IssueSandboxToken`
//! once at startup.
//!
//! The resolved gateway JWT is held in process memory thereafter and
//! The resolved bearer credential is held in process memory thereafter and
//! injected on every outbound call by [`AuthInterceptor`].
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock, RwLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -31,6 +34,7 @@ use openshell_core::proto::{
UpdateConfigRequest, inference_client::InferenceClient, open_shell_client::OpenShellClient,
};
use openshell_core::sandbox_env;
use spiffe::{SpiffeId, WorkloadApiClient};
use tonic::Status;
use tonic::metadata::AsciiMetadataValue;
use tonic::service::interceptor::InterceptedService;
@@ -53,18 +57,12 @@ enum TokenSource {
K8sServiceAccount,
}
#[derive(Debug)]
struct AcquiredToken {
token: String,
source: TokenSource,
}
/// Process-wide token slot. Initialized by the first [`connect_channel`]
/// call and shared with every subsequent client and the renewal loop.
static TOKEN_SLOT: OnceLock<TokenSlot> = OnceLock::new();
/// Source used to acquire the process-wide token slot.
static TOKEN_SOURCE: OnceLock<TokenSource> = OnceLock::new();
/// Refresh strategy used by the process-wide token slot.
static TOKEN_REFRESH_MODE: OnceLock<RefreshMode> = OnceLock::new();
/// Serializes the first token acquisition. Several supervisor subsystems
/// connect during startup; without this guard they can all observe an empty
@@ -74,6 +72,25 @@ static TOKEN_INIT_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new((
/// One-shot guard so the renewal loop spawns at most once per process.
static REFRESH_SPAWNED: OnceLock<()> = OnceLock::new();
#[derive(Clone, Debug)]
enum RefreshMode {
GatewayJwt(TokenSource),
Spiffe(SpiffeTokenSource),
}
#[derive(Clone, Debug)]
struct SpiffeTokenSource {
socket_path: PathBuf,
audience: String,
spiffe_id: Option<String>,
}
#[derive(Debug)]
struct AcquiredToken {
token: String,
refresh_mode: RefreshMode,
}
fn install_token_slot(token: &str) -> Result<TokenSlot> {
let bearer = AsciiMetadataValue::try_from(format!("Bearer {token}"))
.into_diagnostic()
@@ -188,36 +205,52 @@ async fn build_plain_channel(endpoint: &str) -> Result<Channel> {
/// spawned once per process via [`REFRESH_SPAWNED`].
async fn connect_channel(endpoint: &str) -> Result<AuthedChannel> {
let channel = build_plain_channel(endpoint).await?;
let (slot, source) = token_slot(endpoint, &channel).await?;
let (slot, refresh_mode) = token_slot(endpoint, &channel).await?;
let plain_channel = channel.clone();
let intercepted = InterceptedService::new(channel, AuthInterceptor::new(slot.clone()));
if REFRESH_SPAWNED.set(()).is_ok() {
let refresh_channel = intercepted.clone();
let endpoint = endpoint.to_string();
tokio::spawn(async move {
refresh_token_loop(refresh_channel, slot, source, endpoint, plain_channel).await;
});
match refresh_mode {
RefreshMode::GatewayJwt(source) => {
let refresh_channel = intercepted.clone();
let endpoint = endpoint.to_string();
tokio::spawn(async move {
refresh_token_loop(refresh_channel, slot, source, endpoint, plain_channel)
.await;
});
}
RefreshMode::Spiffe(source) => {
tokio::spawn(async move {
refresh_spiffe_token_loop(source, slot).await;
});
}
}
}
Ok(intercepted)
}
async fn token_slot(endpoint: &str, plain_channel: &Channel) -> Result<(TokenSlot, TokenSource)> {
async fn token_slot(endpoint: &str, plain_channel: &Channel) -> Result<(TokenSlot, RefreshMode)> {
if let Some(existing) = TOKEN_SLOT.get() {
let source = TOKEN_SOURCE.get().copied().unwrap_or(TokenSource::Env);
return Ok((existing.clone(), source));
let refresh_mode = TOKEN_REFRESH_MODE
.get()
.cloned()
.unwrap_or(RefreshMode::GatewayJwt(TokenSource::Env));
return Ok((existing.clone(), refresh_mode));
}
let _guard = TOKEN_INIT_LOCK.lock().await;
if let Some(existing) = TOKEN_SLOT.get() {
let source = TOKEN_SOURCE.get().copied().unwrap_or(TokenSource::Env);
return Ok((existing.clone(), source));
let refresh_mode = TOKEN_REFRESH_MODE
.get()
.cloned()
.unwrap_or(RefreshMode::GatewayJwt(TokenSource::Env));
return Ok((existing.clone(), refresh_mode));
}
let acquired = acquire_sandbox_token(endpoint, plain_channel).await?;
let slot = install_token_slot(&acquired.token)?;
let _ = TOKEN_SOURCE.set(acquired.source);
Ok((slot, acquired.source))
let _ = TOKEN_REFRESH_MODE.set(acquired.refresh_mode.clone());
Ok((slot, acquired.refresh_mode))
}
/// Resolve the sandbox JWT used to authenticate every outbound RPC.
@@ -233,7 +266,7 @@ async fn acquire_sandbox_token(endpoint: &str, plain_channel: &Channel) -> Resul
debug!(source = "env", "loaded sandbox token");
return Ok(AcquiredToken {
token: t,
source: TokenSource::Env,
refresh_mode: RefreshMode::GatewayJwt(TokenSource::Env),
});
}
@@ -246,7 +279,21 @@ async fn acquire_sandbox_token(endpoint: &str, plain_channel: &Channel) -> Resul
debug!(source = "file", path = %path, "loaded sandbox token");
return Ok(AcquiredToken {
token: contents.trim().to_string(),
source: TokenSource::File,
refresh_mode: RefreshMode::GatewayJwt(TokenSource::File),
});
}
if let Some(source) = spiffe_token_source_from_env()? {
info!(
socket = %source.socket_path.display(),
audience = %source.audience,
spiffe_id = source.spiffe_id.as_deref().unwrap_or(""),
"fetching SPIFFE JWT-SVID for sandbox gateway authentication"
);
let token = fetch_spiffe_jwt_svid(&source).await?;
return Ok(AcquiredToken {
token,
refresh_mode: RefreshMode::Spiffe(source),
});
}
@@ -255,14 +302,15 @@ async fn acquire_sandbox_token(endpoint: &str, plain_channel: &Channel) -> Resul
{
return Ok(AcquiredToken {
token: acquire_k8s_sandbox_token(endpoint, plain_channel, &sa_path).await?,
source: TokenSource::K8sServiceAccount,
refresh_mode: RefreshMode::GatewayJwt(TokenSource::K8sServiceAccount),
});
}
Err(miette::miette!(
"no sandbox token source available — set one of {}, {}, or {}",
"no sandbox token source available — set one of {}, {}, {}, or {}",
sandbox_env::SANDBOX_TOKEN,
sandbox_env::SANDBOX_TOKEN_FILE,
sandbox_env::SPIFFE_WORKLOAD_API_SOCKET,
sandbox_env::K8S_SA_TOKEN_FILE,
))
}
@@ -297,6 +345,64 @@ async fn acquire_k8s_sandbox_token(
Ok(resp.into_inner().token)
}
fn spiffe_token_source_from_env() -> Result<Option<SpiffeTokenSource>> {
let Ok(socket_path) = std::env::var(sandbox_env::SPIFFE_WORKLOAD_API_SOCKET) else {
return Ok(None);
};
if socket_path.trim().is_empty() {
return Ok(None);
}
let audience = std::env::var(sandbox_env::SPIFFE_AUDIENCE)
.unwrap_or_else(|_| "openshell-gateway".to_string());
if audience.trim().is_empty() {
return Err(miette::miette!(
"{} must not be empty when {} is set",
sandbox_env::SPIFFE_AUDIENCE,
sandbox_env::SPIFFE_WORKLOAD_API_SOCKET,
));
}
let spiffe_id = std::env::var(sandbox_env::SPIFFE_ID)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
Ok(Some(SpiffeTokenSource {
socket_path: PathBuf::from(socket_path),
audience,
spiffe_id,
}))
}
async fn fetch_spiffe_jwt_svid(source: &SpiffeTokenSource) -> Result<String> {
let endpoint = spiffe_workload_api_endpoint(&source.socket_path);
let client = WorkloadApiClient::connect_to(&endpoint)
.await
.into_diagnostic()
.wrap_err_with(|| {
format!("failed to connect to SPIFFE Workload API endpoint {endpoint}")
})?;
let requested_spiffe_id = source
.spiffe_id
.as_deref()
.map(SpiffeId::try_from)
.transpose()
.into_diagnostic()
.wrap_err("invalid SPIFFE ID requested for JWT-SVID")?;
client
.fetch_jwt_token([source.audience.as_str()], requested_spiffe_id.as_ref())
.await
.into_diagnostic()
.wrap_err("SPIFFE FetchJWTSVID failed")
}
fn spiffe_workload_api_endpoint(path: &std::path::Path) -> String {
let path = path.to_string_lossy();
if path.starts_with("unix:") || path.starts_with("tcp:") {
path.into_owned()
} else {
format!("unix:{path}")
}
}
/// Build an authenticated channel for direct external use (e.g. the
/// long-lived `supervisor_session` control stream).
pub async fn connect_channel_pub(endpoint: &str) -> Result<AuthedChannel> {
@@ -385,6 +491,30 @@ async fn refresh_token_loop(
}
}
async fn refresh_spiffe_token_loop(source: SpiffeTokenSource, slot: TokenSlot) {
loop {
let sleep = compute_refresh_delay(&slot);
tokio::time::sleep(sleep).await;
match fetch_spiffe_jwt_svid(&source).await {
Ok(new_token) => match AsciiMetadataValue::try_from(format!("Bearer {new_token}")) {
Ok(value) => {
if let Ok(mut guard) = slot.write() {
*guard = value;
info!("rotated SPIFFE JWT-SVID in-place");
}
}
Err(e) => {
warn!(error = %e, "refreshed SPIFFE JWT-SVID contained invalid header bytes");
}
},
Err(err) => {
warn!(error = %err, "SPIFFE FetchJWTSVID failed; will retry");
tokio::time::sleep(Duration::from_secs(60)).await;
}
}
}
}
/// Compute the next refresh delay: 80 % of the time remaining until the
/// current token's `exp`, plus up to 10 % jitter, with a small lower bound
/// for already-expired tokens and capped at 12 h. If the token can't be parsed
+2 -1
View File
@@ -33,7 +33,7 @@ k8s-openapi = { workspace = true }
tokio = { workspace = true }
# gRPC
tonic = { workspace = true, features = ["channel", "tls"] }
tonic = { workspace = true, features = ["channel", "tls-native-roots"] }
prost = { workspace = true }
prost-types = { workspace = true }
@@ -82,6 +82,7 @@ uuid = { workspace = true }
hmac = "0.12"
sha2 = { workspace = true }
jsonwebtoken = { workspace = true }
spiffe = { workspace = true }
async-trait = "0.1"
url = { workspace = true }
hex = "0.4"
+1
View File
@@ -18,5 +18,6 @@ pub mod oidc;
pub mod principal;
pub mod sandbox_jwt;
pub mod sandbox_methods;
pub mod spiffe;
pub use http::router;
+212
View File
@@ -0,0 +1,212 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//! SPIFFE JWT-SVID authentication for sandbox supervisors.
//!
//! The gateway does not validate SPIFFE JWT-SVID signatures itself. Instead it
//! delegates validation to the local SPIFFE Workload API, keeping algorithm and
//! bundle handling inside the configured SPIFFE implementation.
use super::authenticator::Authenticator;
use super::principal::{Principal, SandboxIdentitySource, SandboxPrincipal};
use async_trait::async_trait;
use openshell_core::SpiffeConfig;
use spiffe::{JwtSvid, WorkloadApiClient};
use std::path::Path;
use tonic::Status;
use tracing::{debug, info, warn};
/// Authenticator backed by the SPIFFE Workload API `ValidateJWTSVID` RPC.
pub struct SpiffeAuthenticator {
client: WorkloadApiClient,
config: SpiffeConfig,
}
impl std::fmt::Debug for SpiffeAuthenticator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SpiffeAuthenticator")
.field("socket", &self.config.workload_api_socket_path)
.field("trust_domain", &self.config.trust_domain)
.field("audience", &self.config.audience)
.field("sandbox_id_prefix", &self.config.sandbox_id_prefix)
.finish_non_exhaustive()
}
}
impl SpiffeAuthenticator {
pub async fn new(config: SpiffeConfig) -> Result<Self, String> {
let endpoint = workload_api_endpoint(&config.workload_api_socket_path);
let client = WorkloadApiClient::connect_to(&endpoint)
.await
.map_err(|e| {
format!("failed to connect to SPIFFE Workload API endpoint {endpoint}: {e}")
})?;
info!(
socket = %config.workload_api_socket_path.display(),
trust_domain = %config.trust_domain,
audience = %config.audience,
"SPIFFE JWT-SVID sandbox authenticator enabled"
);
Ok(Self { client, config })
}
#[allow(clippy::result_large_err)]
async fn validate_bearer(&self, token: &str) -> Result<Option<Principal>, Status> {
let Some(candidate_id) = candidate_spiffe_id(token) else {
return Ok(None);
};
if parse_sandbox_id_from_spiffe_id(
&candidate_id,
&self.config.trust_domain,
&self.config.sandbox_id_prefix,
)
.is_none()
{
return Ok(None);
}
let svid = self
.client
.validate_jwt_token(&self.config.audience, token)
.await
.map_err(|status| {
debug!(error = %status, "SPIFFE JWT-SVID validation failed");
Status::unauthenticated("invalid SPIFFE JWT-SVID")
})?;
self.principal_from_validated_svid(&svid)
}
#[allow(clippy::result_large_err)]
fn principal_from_validated_svid(&self, svid: &JwtSvid) -> Result<Option<Principal>, Status> {
let spiffe_id = svid.spiffe_id().to_string();
let Some(sandbox_id) = parse_sandbox_id_from_spiffe_id(
&spiffe_id,
&self.config.trust_domain,
&self.config.sandbox_id_prefix,
) else {
warn!(
spiffe_id = %spiffe_id,
trust_domain = %self.config.trust_domain,
prefix = %self.config.sandbox_id_prefix,
"validated SPIFFE ID is outside the configured sandbox identity namespace"
);
return Err(Status::permission_denied(
"SPIFFE ID is not authorized as an OpenShell sandbox",
));
};
Ok(Some(Principal::Sandbox(SandboxPrincipal {
sandbox_id,
source: SandboxIdentitySource::SpiffeSvid { spiffe_id },
trust_domain: Some(self.config.trust_domain.clone()),
})))
}
}
#[async_trait]
impl Authenticator for SpiffeAuthenticator {
async fn authenticate(
&self,
headers: &http::HeaderMap,
_path: &str,
) -> Result<Option<Principal>, Status> {
let Some(token) = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
else {
return Ok(None);
};
self.validate_bearer(token).await
}
}
fn parse_sandbox_id_from_spiffe_id(
spiffe_id: &str,
trust_domain: &str,
sandbox_id_prefix: &str,
) -> Option<String> {
let trust_domain = trust_domain.trim().trim_start_matches("spiffe://");
let prefix = format!(
"spiffe://{}{}",
trust_domain.trim_end_matches('/'),
normalize_spiffe_path_prefix(sandbox_id_prefix)
);
let sandbox_id = spiffe_id.strip_prefix(&prefix)?;
(!sandbox_id.is_empty() && !sandbox_id.contains('/')).then(|| sandbox_id.to_string())
}
fn normalize_spiffe_path_prefix(prefix: &str) -> String {
let trimmed = prefix.trim();
if trimmed.starts_with('/') {
trimmed.to_string()
} else {
format!("/{trimmed}")
}
}
fn candidate_spiffe_id(jwt: &str) -> Option<String> {
JwtSvid::parse_insecure(jwt)
.ok()
.map(|svid| svid.spiffe_id().to_string())
}
fn workload_api_endpoint(path: &Path) -> String {
let path = path.to_string_lossy();
if path.starts_with("unix:") || path.starts_with("tcp:") {
path.into_owned()
} else {
format!("unix:{path}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_sandbox_id_from_configured_spiffe_id() {
assert_eq!(
parse_sandbox_id_from_spiffe_id(
"spiffe://openshell.local/openshell/sandbox/abc",
"openshell.local",
"/openshell/sandbox/",
)
.as_deref(),
Some("abc")
);
}
#[test]
fn rejects_spiffe_id_outside_sandbox_namespace() {
assert!(
parse_sandbox_id_from_spiffe_id(
"spiffe://openshell.local/ns/openshell/sa/default",
"openshell.local",
"/openshell/sandbox/",
)
.is_none()
);
assert!(
parse_sandbox_id_from_spiffe_id(
"spiffe://other.local/openshell/sandbox/abc",
"openshell.local",
"/openshell/sandbox/",
)
.is_none()
);
}
#[test]
fn prefixes_plain_socket_paths_as_unix_endpoints() {
assert_eq!(
workload_api_endpoint(Path::new("/spiffe-workload-api/spire-agent.sock")),
"unix:/spiffe-workload-api/spire-agent.sock"
);
assert_eq!(
workload_api_endpoint(Path::new("unix:/tmp/spire.sock")),
"unix:/tmp/spire.sock"
);
}
}
+12 -1
View File
@@ -387,6 +387,12 @@ async fn run_from_args(mut args: RunArgs, matches: ArgMatches) -> Result<()> {
} else if let Some(jwt) = local_jwt {
config.gateway_jwt = Some(jwt);
}
if let Some(spiffe) = file
.as_ref()
.and_then(|f| f.openshell.gateway.spiffe.clone())
{
config.spiffe = Some(spiffe);
}
let vm_config = build_vm_config(
file.as_ref(),
@@ -411,6 +417,10 @@ async fn run_from_args(mut args: RunArgs, matches: ArgMatches) -> Result<()> {
if has_oidc {
info!("OIDC authentication enabled");
}
if config.spiffe.is_some() {
info!("SPIFFE sandbox authentication enabled");
}
if config.auth.allow_unauthenticated_users {
warn!(
"Unauthenticated user access enabled — only use this for trusted local development or a fully trusted fronting proxy"
@@ -421,9 +431,10 @@ async fn run_from_args(mut args: RunArgs, matches: ArgMatches) -> Result<()> {
&& !config.mtls_auth.enabled
&& !has_oidc
&& config.gateway_jwt.is_none()
&& config.spiffe.is_none()
{
warn!(
"Neither mTLS user auth nor OIDC nor sandbox JWT auth is configured — \
"Neither mTLS user auth nor OIDC nor sandbox JWT nor SPIFFE auth is configured — \
the gateway has no authentication mechanism"
);
}
+25 -1
View File
@@ -25,7 +25,9 @@ use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use openshell_core::config::ComputeDriverKind;
use openshell_core::{GatewayAuthConfig, GatewayJwtConfig, MtlsAuthConfig, OidcConfig, TlsConfig};
use openshell_core::{
GatewayAuthConfig, GatewayJwtConfig, MtlsAuthConfig, OidcConfig, SpiffeConfig, TlsConfig,
};
use serde::{Deserialize, Serialize};
/// Latest schema version this build understands.
@@ -146,6 +148,8 @@ pub struct GatewayFileSection {
pub mtls_auth: Option<MtlsAuthConfig>,
#[serde(default)]
pub gateway_jwt: Option<GatewayJwtConfig>,
#[serde(default)]
pub spiffe: Option<SpiffeConfig>,
// ── Disallowed-in-file fields ────────────────────────────────────────
//
@@ -262,6 +266,10 @@ fn inheritable_keys(driver: ComputeDriverKind) -> &'static [&'static str] {
"host_gateway_ip",
"enable_user_namespaces",
"sa_token_ttl_secs",
"spiffe_workload_api_socket_path",
"spiffe_trust_domain",
"spiffe_audience",
"spiffe_sandbox_id_prefix",
],
ComputeDriverKind::Docker => &[
"sandbox_namespace",
@@ -298,6 +306,22 @@ fn gateway_inherited_value(g: &GatewayFileSection, key: &str) -> Option<toml::Va
"host_gateway_ip" => g.host_gateway_ip.as_deref().map(string_value),
"enable_user_namespaces" => g.enable_user_namespaces.map(toml::Value::Boolean),
"sa_token_ttl_secs" => g.sa_token_ttl_secs.map(toml::Value::Integer),
"spiffe_workload_api_socket_path" => g
.spiffe
.as_ref()
.map(|spiffe| path_value(&spiffe.workload_api_socket_path)),
"spiffe_trust_domain" => g
.spiffe
.as_ref()
.map(|spiffe| string_value(&spiffe.trust_domain)),
"spiffe_audience" => g
.spiffe
.as_ref()
.map(|spiffe| string_value(&spiffe.audience)),
"spiffe_sandbox_id_prefix" => g
.spiffe
.as_ref()
.map(|spiffe| string_value(&spiffe.sandbox_id_prefix)),
"guest_tls_ca" => g.guest_tls_ca.as_deref().map(path_value),
"guest_tls_cert" => g.guest_tls_cert.as_deref().map(path_value),
"guest_tls_key" => g.guest_tls_key.as_deref().map(path_value),
+12
View File
@@ -123,6 +123,10 @@ pub struct ServerState {
/// presenting a freshly minted token are recognized.
pub sandbox_jwt_authenticator: Option<Arc<auth::sandbox_jwt::SandboxJwtAuthenticator>>,
/// Authenticator that validates SPIFFE JWT-SVIDs through the local SPIFFE
/// Workload API and maps authorized SPIFFE IDs to sandbox principals.
pub spiffe_authenticator: Option<Arc<auth::spiffe::SpiffeAuthenticator>>,
/// Optional K8s `ServiceAccount` authenticator that backs the
/// `IssueSandboxToken` bootstrap path. Only present when the gateway
/// runs in-cluster.
@@ -173,6 +177,7 @@ impl ServerState {
oidc_cache,
sandbox_jwt_issuer: None,
sandbox_jwt_authenticator: None,
spiffe_authenticator: None,
k8s_sa_authenticator: None,
}
}
@@ -293,6 +298,13 @@ pub async fn run_server(
state.sandbox_jwt_authenticator = Some(Arc::new(authenticator));
}
if let Some(ref spiffe) = config.spiffe {
let authenticator = auth::spiffe::SpiffeAuthenticator::new(spiffe.clone())
.await
.map_err(|e| Error::config(format!("SPIFFE initialization failed: {e}")))?;
state.spiffe_authenticator = Some(Arc::new(authenticator));
}
// K8s ServiceAccount bootstrap authenticator. Only constructed when
// the gateway is running in-cluster (kubelet provides the API host
// env var) and has a sandbox JWT issuer to mint replacements against;
+14 -21
View File
@@ -273,7 +273,10 @@ where
/// other path; only present when the gateway runs in-cluster.
/// 2. `SandboxJwtAuthenticator` — validates gateway-minted JWTs. Recognized
/// via a distinctive `kid` so non-matching Bearer tokens fall through.
/// 3. `OidcAuthenticator` — validates user Bearer tokens against the
/// 3. `SpiffeAuthenticator` — validates SPIFFE JWT-SVIDs through the
/// local SPIFFE Workload API and maps sandbox SPIFFE IDs to
/// `Principal::Sandbox`.
/// 4. `OidcAuthenticator` — validates user Bearer tokens against the
/// configured OIDC issuer. Returns `Unauthenticated` for missing
/// Bearer headers so non-OIDC clients can't sneak through.
///
@@ -283,9 +286,9 @@ where
/// for local single-user gateways, or to an unsafe local developer user when
/// `auth.allow_unauthenticated_users` is explicitly enabled.
///
/// When neither OIDC nor gateway-minted JWTs are configured (a barebones
/// When neither OIDC nor sandbox credentials are configured (a barebones
/// dev gateway), the chain is left as `None` so the router short-circuits
/// to pass-through.
/// to pass-through unless mTLS or local unauthenticated users are enabled.
fn build_authenticator_chain(state: &ServerState) -> Option<AuthenticatorChain> {
let mut authenticators: Vec<Arc<dyn crate::auth::authenticator::Authenticator>> = Vec::new();
if let Some(k8s) = state.k8s_sa_authenticator.clone() {
@@ -294,6 +297,9 @@ fn build_authenticator_chain(state: &ServerState) -> Option<AuthenticatorChain>
if let Some(jwt) = state.sandbox_jwt_authenticator.clone() {
authenticators.push(jwt);
}
if let Some(spiffe) = state.spiffe_authenticator.clone() {
authenticators.push(spiffe);
}
if let Some(cache) = state.oidc_cache.clone() {
authenticators.push(Arc::new(OidcAuthenticator::new(cache)));
}
@@ -368,19 +374,13 @@ fn unauthenticated_dev_user_principal() -> Principal {
})
}
fn status_response(status: tonic::Status) -> Response<tonic::body::BoxBody> {
let response = status.into_http();
let (parts, body) = response.into_parts();
let body = tonic::body::BoxBody::new(body);
Response::from_parts(parts, body)
fn status_response(status: tonic::Status) -> Response<tonic::body::Body> {
status.into_http()
}
impl<S, B> tower::Service<Request<B>> for AuthGrpcRouter<S>
where
S: tower::Service<Request<B>, Response = Response<tonic::body::BoxBody>>
+ Clone
+ Send
+ 'static,
S: tower::Service<Request<B>, Response = Response<tonic::body::Body>> + Clone + Send + 'static,
S::Future: Send,
S::Error: Send + Into<Box<dyn std::error::Error + Send + Sync>>,
B: Send + 'static,
@@ -938,7 +938,7 @@ mod tests {
}
impl<B: Send + 'static> Service<Request<B>> for PrincipalRecorder {
type Response = Response<tonic::body::BoxBody>;
type Response = Response<tonic::body::Body>;
type Error = std::convert::Infallible;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
@@ -949,14 +949,7 @@ mod tests {
fn call(&mut self, req: Request<B>) -> Self::Future {
let principal = req.extensions().get::<Principal>().cloned();
*self.recorded.lock().unwrap() = principal;
Box::pin(async move {
let body = tonic::body::BoxBody::new(
Full::new(Bytes::new())
.map_err(|never| match never {})
.boxed_unsync(),
);
Ok(Response::new(body))
})
Box::pin(async move { Ok(Response::new(tonic::body::Body::empty())) })
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ ratatui = { workspace = true }
crossterm = { workspace = true }
terminal-colorsaurus = { workspace = true }
tokio = { workspace = true }
tonic = { workspace = true, features = ["tls"] }
tonic = { workspace = true, features = ["tls-native-roots"] }
miette = { workspace = true }
owo-colors = { workspace = true }
serde = { workspace = true }
+17
View File
@@ -57,6 +57,8 @@ See [`values.yaml`](values.yaml) for source defaults. Selected overlays:
- [`ci/values-gateway.yaml`](ci/values-gateway.yaml) - gateway-only configuration
- [`ci/values-cert-manager.yaml`](ci/values-cert-manager.yaml) - cert-manager integration
- [`ci/values-keycloak.yaml`](ci/values-keycloak.yaml) - Keycloak OIDC integration
- [`ci/values-spire.yaml`](ci/values-spire.yaml) - SPIFFE/SPIRE sandbox supervisor authentication
- [`ci/values-spire-stack.yaml`](ci/values-spire-stack.yaml) - SPIRE hardened chart values for local development
## PKI bootstrap
@@ -75,6 +77,16 @@ Disable with `--set pkiInitJob.enabled=false` when bringing your own PKI (cert-m
external CA, or pre-created Secrets). See `certManager.*` in `values.yaml` for the
cert-manager alternative.
## SPIFFE/SPIRE sandbox identity
Set `server.spiffe.enabled=true` to use SPIFFE JWT-SVIDs for sandbox supervisor
authentication instead of gateway-minted sandbox JWTs. The chart mounts the
SPIFFE CSI Workload API socket into the gateway pod and configures sandbox pods
to request `spiffe://<trust-domain>/openshell/sandbox/<sandbox-id>` JWT-SVIDs.
For local development, uncomment the SPIRE Helm releases in `skaffold.yaml` and
add `ci/values-spire.yaml` to the OpenShell release values files.
## Values
| Key | Type | Default | Description |
@@ -154,6 +166,11 @@ cert-manager alternative.
| server.sandboxJwt.signingSecretName | string | `""` | Name of the Opaque Secret holding the signing key material. Empty falls back to the chart fullname with "-jwt-keys" appended. |
| server.sandboxJwt.ttlSecs | int | `3600` | Token TTL in seconds. Defaults to 3600 (1h). |
| server.sandboxNamespace | string | `""` | Namespace where sandbox pods are created. Defaults to the Helm release namespace (.Release.Namespace) when left empty. |
| server.spiffe.audience | string | `"openshell-gateway"` | |
| server.spiffe.enabled | bool | `false` | |
| server.spiffe.sandboxIdPrefix | string | `"/openshell/sandbox/"` | |
| server.spiffe.trustDomain | string | `"openshell.local"` | |
| server.spiffe.workloadApiSocketPath | string | `"/spiffe-workload-api/spire-agent.sock"` | |
| server.tls.certSecretName | string | `"openshell-server-tls"` | K8s secret (type kubernetes.io/tls) with tls.crt and tls.key for the server. |
| server.tls.clientCaSecretName | string | `"openshell-server-client-ca"` | K8s secret with ca.crt for client certificate verification (mTLS). Set to "" to disable mTLS and run HTTPS-only (use OIDC for auth instead). |
| server.tls.clientTlsSecretName | string | `"openshell-client-tls"` | K8s secret mounted into sandbox pods for mTLS to the server. |
+12
View File
@@ -57,6 +57,8 @@ See [`values.yaml`](values.yaml) for source defaults. Selected overlays:
- [`ci/values-gateway.yaml`](ci/values-gateway.yaml) - gateway-only configuration
- [`ci/values-cert-manager.yaml`](ci/values-cert-manager.yaml) - cert-manager integration
- [`ci/values-keycloak.yaml`](ci/values-keycloak.yaml) - Keycloak OIDC integration
- [`ci/values-spire.yaml`](ci/values-spire.yaml) - SPIFFE/SPIRE sandbox supervisor authentication
- [`ci/values-spire-stack.yaml`](ci/values-spire-stack.yaml) - SPIRE hardened chart values for local development
## PKI bootstrap
@@ -75,5 +77,15 @@ Disable with `--set pkiInitJob.enabled=false` when bringing your own PKI (cert-m
external CA, or pre-created Secrets). See `certManager.*` in `values.yaml` for the
cert-manager alternative.
## SPIFFE/SPIRE sandbox identity
Set `server.spiffe.enabled=true` to use SPIFFE JWT-SVIDs for sandbox supervisor
authentication instead of gateway-minted sandbox JWTs. The chart mounts the
SPIFFE CSI Workload API socket into the gateway pod and configures sandbox pods
to request `spiffe://<trust-domain>/openshell/sandbox/<sandbox-id>` JWT-SVIDs.
For local development, uncomment the SPIRE Helm releases in `skaffold.yaml` and
add `ci/values-spire.yaml` to the OpenShell release values files.
{{ template "chart.valuesSection" . }}
{{ template "helm-docs.versionFooter" . }}
@@ -0,0 +1,22 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# SPIRE hardened chart values for the local Helm dev environment.
global:
spire:
clusterName: openshell-dev
trustDomain: openshell.local
spire-server:
controllerManager:
identities:
clusterSPIFFEIDs:
openshell-sandboxes:
enabled: true
spiffeIDTemplate: 'spiffe://{{ .TrustDomain }}/openshell/sandbox/{{ index .PodMeta.Annotations "openshell.io/sandbox-id" }}'
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: openshell
podSelector:
matchLabels:
openshell.ai/managed-by: openshell
@@ -0,0 +1,11 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# OpenShell overlay for local SPIRE-backed supervisor authentication.
server:
spiffe:
enabled: true
trustDomain: openshell.local
audience: openshell-gateway
workloadApiSocketPath: /spiffe-workload-api/spire-agent.sock
sandboxIdPrefix: /openshell/sandbox/
+21
View File
@@ -79,6 +79,24 @@ deploy:
# # wait ensures Gateway API CRDs are registered before the openshell
# # release attempts to create Gateway and HTTPRoute resources.
# wait: true
# SPIRE — installs SPIRE Server, Agent, Controller Manager, CSI Driver,
# and OIDC Discovery Provider using the SPIFFE hardened charts.
# Uncomment both releases and ci/values-spire.yaml below to use
# SPIFFE JWT-SVIDs for sandbox supervisor authentication.
#- name: spire-crds
# repo: https://spiffe.github.io/helm-charts-hardened/
# remoteChart: spire-crds
# namespace: spire
# createNamespace: true
# wait: true
#- name: spire
# repo: https://spiffe.github.io/helm-charts-hardened/
# remoteChart: spire
# namespace: spire
# createNamespace: true
# valuesFiles:
# - ci/values-spire-stack.yaml
# wait: true
- name: openshell
chartPath: .
namespace: openshell
@@ -95,6 +113,9 @@ deploy:
#- ci/values-keycloak.yaml
# To enable the Gateway API HTTPRoute (requires Envoy Gateway above):
#- ci/values-gateway.yaml
# To enable SPIFFE/SPIRE sandbox supervisor authentication (requires
# the spire-crds and spire releases above):
#- ci/values-spire.yaml
setValueTemplates:
image.repository: '{{.IMAGE_REPO_openshell_gateway}}'
image.tag: '{{.IMAGE_TAG_openshell_gateway}}'
@@ -136,3 +136,11 @@ init-container
{{- printf "%s://%s.%s.svc.cluster.local:%d" $scheme (include "openshell.fullname" .) .Release.Namespace (int .Values.service.port) -}}
{{- end -}}
{{- end }}
{{/*
Directory mounted for the SPIFFE Workload API CSI volume. The socket itself
lives at server.spiffe.workloadApiSocketPath.
*/}}
{{- define "openshell.spiffeWorkloadApiMountPath" -}}
{{- dir .Values.server.spiffe.workloadApiSocketPath -}}
{{- end }}
@@ -67,12 +67,20 @@ data:
allow_unauthenticated_users = true
{{- end }}
{{- if .Values.server.spiffe.enabled }}
[openshell.gateway.spiffe]
workload_api_socket_path = {{ .Values.server.spiffe.workloadApiSocketPath | quote }}
trust_domain = {{ .Values.server.spiffe.trustDomain | quote }}
audience = {{ .Values.server.spiffe.audience | quote }}
sandbox_id_prefix = {{ .Values.server.spiffe.sandboxIdPrefix | quote }}
{{- else }}
[openshell.gateway.gateway_jwt]
signing_key_path = "/etc/openshell-jwt/signing.pem"
public_key_path = "/etc/openshell-jwt/public.pem"
kid_path = "/etc/openshell-jwt/kid"
gateway_id = {{ .Values.server.sandboxJwt.gatewayId | default (include "openshell.fullname" .) | quote }}
ttl_secs = {{ .Values.server.sandboxJwt.ttlSecs | default 3600 }}
{{- end }}
{{- if .Values.server.oidc.issuer }}
@@ -98,7 +106,14 @@ data:
grpc_endpoint = {{ include "openshell.grpcEndpoint" . | quote }}
service_account_name = {{ include "openshell.sandboxServiceAccountName" . | quote }}
supervisor_sideload_method = {{ include "openshell.supervisorSideloadMethod" . | quote }}
{{- if .Values.server.spiffe.enabled }}
spiffe_workload_api_socket_path = {{ .Values.server.spiffe.workloadApiSocketPath | quote }}
spiffe_trust_domain = {{ .Values.server.spiffe.trustDomain | quote }}
spiffe_audience = {{ .Values.server.spiffe.audience | quote }}
spiffe_sandbox_id_prefix = {{ .Values.server.spiffe.sandboxIdPrefix | quote }}
{{- else }}
sa_token_ttl_secs = {{ .Values.server.sandboxJwt.k8sSaTokenTtlSecs | default 3600 }}
{{- end }}
{{- if .Values.server.sandboxImagePullPolicy }}
image_pull_policy = {{ .Values.server.sandboxImagePullPolicy | quote }}
{{- end }}
@@ -75,9 +75,15 @@ spec:
- name: gateway-config
mountPath: /etc/openshell
readOnly: true
{{- if .Values.server.spiffe.enabled }}
- name: spiffe-workload-api
mountPath: {{ include "openshell.spiffeWorkloadApiMountPath" . | quote }}
readOnly: true
{{- else }}
- name: sandbox-jwt
mountPath: /etc/openshell-jwt
readOnly: true
{{- end }}
{{- if not .Values.server.disableTls }}
- name: tls-cert
mountPath: /etc/openshell-tls/server
@@ -134,10 +140,17 @@ spec:
- name: gateway-config
configMap:
name: {{ include "openshell.fullname" . }}-config
{{- if .Values.server.spiffe.enabled }}
- name: spiffe-workload-api
csi:
driver: csi.spiffe.io
readOnly: true
{{- else }}
- name: sandbox-jwt
secret:
secretName: {{ .Values.server.sandboxJwt.signingSecretName | default (printf "%s-jwt-keys" (include "openshell.fullname" .)) }}
defaultMode: 0400
{{- end }}
{{- if not .Values.server.disableTls }}
- name: tls-cert
secret:
@@ -125,3 +125,30 @@ tests:
- matchRegex:
path: data["gateway.toml"]
pattern: 'server_sans\s*=\s*\["openshell", "\*\.dev\.openshell\.localhost"\]'
- it: renders SPIFFE sandbox auth instead of gateway JWT when enabled
set:
server.spiffe.enabled: true
template: templates/gateway-config.yaml
asserts:
- matchRegex:
path: data["gateway.toml"]
pattern: '\[openshell\.gateway\.spiffe\]'
- matchRegex:
path: data["gateway.toml"]
pattern: 'spiffe_workload_api_socket_path\s*=\s*"/spiffe-workload-api/spire-agent\.sock"'
- notMatchRegex:
path: data["gateway.toml"]
pattern: '\[openshell\.gateway\.gateway_jwt\]'
- it: mounts the SPIFFE Workload API socket when SPIFFE is enabled
set:
server.spiffe.enabled: true
template: templates/statefulset.yaml
asserts:
- matchRegex:
path: spec.template.spec.volumes[1].name
pattern: '^spiffe-workload-api$'
- matchRegex:
path: spec.template.spec.volumes[1].csi.driver
pattern: '^csi\.spiffe\.io$'
+9
View File
@@ -206,6 +206,15 @@ server:
# values outside [600, 86400]. Default 3600 — generous, since the
# supervisor consumes the token within seconds of pod start.
k8sSaTokenTtlSecs: 3600
# SPIFFE/SPIRE sandbox identity. When enabled, sandbox supervisors fetch a
# JWT-SVID from the SPIFFE Workload API and present it directly to the
# gateway instead of bootstrapping a gateway-minted sandbox JWT.
spiffe:
enabled: false
trustDomain: openshell.local
audience: openshell-gateway
workloadApiSocketPath: /spiffe-workload-api/spire-agent.sock
sandboxIdPrefix: /openshell/sandbox/
# OIDC (OpenID Connect) configuration for JWT-based authentication.
# When issuer is set, the server validates Bearer tokens on gRPC requests.
oidc:
+8
View File
@@ -19,6 +19,14 @@ The Helm chart always generates mTLS certificates at install time. The gateway u
For how the CLI resolves gateways and stores credentials, refer to [Gateway Authentication](/reference/gateway-auth).
## Sandbox Supervisor Identity
Kubernetes sandbox supervisors authenticate back to the gateway as sandbox workloads. By default, the gateway mints its own sandbox JWTs and Kubernetes sandboxes bootstrap them with a projected ServiceAccount token.
Set `server.spiffe.enabled=true` to use SPIFFE JWT-SVIDs instead. In this mode, sandbox pods mount the SPIFFE CSI Workload API socket, request a JWT-SVID for `server.spiffe.audience`, and present that token directly to the gateway. The gateway validates the token through its local SPIFFE Workload API socket and accepts SPIFFE IDs under `spiffe://<trust-domain>/openshell/sandbox/`.
SPIFFE mode requires a SPIFFE implementation such as SPIRE and a `ClusterSPIFFEID` that assigns per-sandbox IDs from the pod's `openshell.io/sandbox-id` annotation.
## OIDC User Authentication
Set `server.oidc.issuer` to enable OIDC. The gateway validates the `Authorization: Bearer <token>` header on every request against the issuer's JWKS endpoint.