Skip to content

Commit 39654df

Browse files
committed
feat(gateway): broker sandbox SPIFFE provider access tokens
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
1 parent 2f0dde3 commit 39654df

51 files changed

Lines changed: 1896 additions & 2253 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.agents/skills/helm-dev-environment/SKILL.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,11 @@ e2e:kubernetes:shared-supervisor` runs the focused e2e
8484
through `OPENSHELL_E2E_KUBE_EXTRA_VALUES` to cover managed namespaces.
8585
`mise run e2e:kubernetes:shared-supervisor-spiffe` installs pinned SPIRE charts
8686
(`ci/values-spire-delegation-stack.yaml`, removed again on exit) and the
87-
`experimental_supervisor_spiffe_delegation` overlay
87+
`openshell.gateway.experimental_spiffe_delegation` overlay
8888
(`ci/values-supervisor-spiffe.yaml`), then registers sandbox
8989
identities with `ClusterStaticEntry` resources. `mise run test:spire-delegation`
90-
covers the same authorization rules against a local SPIRE server and agent
91-
without a cluster.
90+
checks delegated issuance and identity selection against a local SPIRE server
91+
and agent without a cluster; the Kubernetes test covers the gateway broker.
9292

9393
### 2. Deploy OpenShell
9494

‎Cargo.lock‎

Lines changed: 1 addition & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/openshell-cli/tests/ensure_providers_integration.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,14 @@ impl TestOpenShell {
8080

8181
#[tonic::async_trait]
8282
impl OpenShell for TestOpenShell {
83+
async fn get_sandbox_provider_access_token(
84+
&self,
85+
_request: tonic::Request<openshell_core::proto::GetSandboxProviderAccessTokenRequest>,
86+
) -> Result<Response<openshell_core::proto::GetSandboxProviderAccessTokenResponse>, Status>
87+
{
88+
Err(Status::unimplemented("not used by this test server"))
89+
}
90+
8391
async fn peer_notify_config_update(
8492
&self,
8593
_request: tonic::Request<openshell_core::proto::PeerNotifyConfigUpdateRequest>,

‎crates/openshell-cli/tests/mtls_integration.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@ struct TestOpenShell;
3232

3333
#[tonic::async_trait]
3434
impl OpenShell for TestOpenShell {
35+
async fn get_sandbox_provider_access_token(
36+
&self,
37+
_request: tonic::Request<openshell_core::proto::GetSandboxProviderAccessTokenRequest>,
38+
) -> Result<Response<openshell_core::proto::GetSandboxProviderAccessTokenResponse>, Status>
39+
{
40+
Err(Status::unimplemented("not used by this test server"))
41+
}
42+
3543
async fn peer_notify_config_update(
3644
&self,
3745
_request: tonic::Request<openshell_core::proto::PeerNotifyConfigUpdateRequest>,

‎crates/openshell-cli/tests/provider_commands_integration.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,14 @@ impl TestOpenShell {
235235

236236
#[tonic::async_trait]
237237
impl OpenShell for TestOpenShell {
238+
async fn get_sandbox_provider_access_token(
239+
&self,
240+
_request: tonic::Request<openshell_core::proto::GetSandboxProviderAccessTokenRequest>,
241+
) -> Result<Response<openshell_core::proto::GetSandboxProviderAccessTokenResponse>, Status>
242+
{
243+
Err(Status::unimplemented("not used by this test server"))
244+
}
245+
238246
async fn peer_notify_config_update(
239247
&self,
240248
_request: tonic::Request<openshell_core::proto::PeerNotifyConfigUpdateRequest>,

‎crates/openshell-cli/tests/sandbox_create_lifecycle_integration.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ struct TestOpenShell {
101101

102102
#[tonic::async_trait]
103103
impl OpenShell for TestOpenShell {
104+
async fn get_sandbox_provider_access_token(
105+
&self,
106+
_request: tonic::Request<openshell_core::proto::GetSandboxProviderAccessTokenRequest>,
107+
) -> Result<Response<openshell_core::proto::GetSandboxProviderAccessTokenResponse>, Status>
108+
{
109+
Err(Status::unimplemented("not used by this test server"))
110+
}
111+
104112
async fn peer_notify_config_update(
105113
&self,
106114
_request: tonic::Request<openshell_core::proto::PeerNotifyConfigUpdateRequest>,

‎crates/openshell-cli/tests/sandbox_exec_streaming_integration.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,13 @@ macro_rules! mock_gateway {
163163
) => {
164164
#[tonic::async_trait]
165165
impl OpenShell for MockGateway {
166+
async fn get_sandbox_provider_access_token(
167+
&self,
168+
_request: Request<proto::GetSandboxProviderAccessTokenRequest>,
169+
) -> Result<Response<proto::GetSandboxProviderAccessTokenResponse>, Status> {
170+
Err(Status::unimplemented("not used by this test server"))
171+
}
172+
166173
$(async fn $method(&self, _: Request<$request>) -> Result<Response<$response>, Status> {
167174
Err(Status::unimplemented("unused test RPC"))
168175
})*

‎crates/openshell-cli/tests/sandbox_name_fallback_integration.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ struct TestOpenShell {
4747

4848
#[tonic::async_trait]
4949
impl OpenShell for TestOpenShell {
50+
async fn get_sandbox_provider_access_token(
51+
&self,
52+
_request: tonic::Request<openshell_core::proto::GetSandboxProviderAccessTokenRequest>,
53+
) -> Result<Response<openshell_core::proto::GetSandboxProviderAccessTokenResponse>, Status>
54+
{
55+
Err(Status::unimplemented("not used by this test server"))
56+
}
57+
5058
async fn peer_notify_config_update(
5159
&self,
5260
_request: tonic::Request<openshell_core::proto::PeerNotifyConfigUpdateRequest>,

‎crates/openshell-core/src/config.rs‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,39 @@ pub fn normalize_compute_driver_name(value: &str) -> Result<String, String> {
205205
Ok(value.to_ascii_lowercase())
206206
}
207207

208+
/// Experimental gateway-owned SPIRE delegation for sandbox provider grants.
209+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
210+
#[serde(deny_unknown_fields)]
211+
pub struct ExperimentalSpiffeDelegationConfig {
212+
pub admin_socket_path: String,
213+
pub spiffe_id_prefix: String,
214+
#[serde(default, skip_serializing_if = "Option::is_none")]
215+
pub admin_socket_group: Option<u32>,
216+
}
217+
218+
impl ExperimentalSpiffeDelegationConfig {
219+
/// Reject ambiguous host paths and identities before opening the SPIRE socket.
220+
pub fn validate(&self, gateway_id: &str) -> Result<(), String> {
221+
let path = std::path::Path::new(&self.admin_socket_path);
222+
if !path.is_absolute()
223+
|| path
224+
.parent()
225+
.is_none_or(|parent| parent == std::path::Path::new("/"))
226+
|| self
227+
.admin_socket_path
228+
.split('/')
229+
.skip(1)
230+
.any(|part| part.is_empty() || part == "." || part == "..")
231+
|| self.admin_socket_group == Some(0)
232+
{
233+
return Err("experimental_spiffe_delegation requires a canonical absolute admin socket path in a dedicated directory and a non-root socket group".into());
234+
}
235+
crate::spiffe::SandboxSpiffeIdPrefix::parse(&self.spiffe_id_prefix)?
236+
.sandbox_spiffe_id(gateway_id, "sandbox")?;
237+
Ok(())
238+
}
239+
}
240+
208241
/// Server configuration.
209242
///
210243
/// Built programmatically in [`crate::Config::new`] and the gateway CLI from
@@ -269,6 +302,8 @@ pub struct Config {
269302
/// JWTs as `Principal::Sandbox`. Required for the per-sandbox identity
270303
/// flow (issue #1354).
271304
pub gateway_jwt: Option<GatewayJwtConfig>,
305+
/// Gateway-only access to SPIRE; shared supervisors receive access tokens.
306+
pub experimental_spiffe_delegation: Option<ExperimentalSpiffeDelegationConfig>,
272307

273308
/// Database URL for persistence.
274309
pub database_url: String,
@@ -940,6 +975,7 @@ impl Config {
940975
provider_profile_sources: vec![GatewayProviderProfileSourceConfig::User],
941976
mtls_auth: MtlsAuthConfig::default(),
942977
gateway_jwt: None,
978+
experimental_spiffe_delegation: None,
943979
database_url: String::new(),
944980
database_max_connections: None,
945981
database_lock_max_connections: None,

‎crates/openshell-core/src/grpc_client.rs‎

Lines changed: 32 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -176,22 +176,6 @@ impl GatewayCredentials {
176176
self.inner.sandbox_bearer.clone()
177177
}
178178

179-
/// The gateway session JWT these credentials currently present, as last
180-
/// renewed, or `None` once revoked. The value is unverified; callers that
181-
/// act on its claims must verify it first.
182-
#[cfg(feature = "jwt")]
183-
#[must_use]
184-
pub fn current_gateway_token(&self) -> Option<crate::jwt::SecretJwt> {
185-
let bearer = self
186-
.inner
187-
.bearer
188-
.read()
189-
.unwrap_or_else(std::sync::PoisonError::into_inner)
190-
.clone()?;
191-
let token = crate::auth::strip_bearer_scheme(bearer.to_str().ok()?)?;
192-
crate::jwt::SecretJwt::parse(token).ok()
193-
}
194-
195179
/// Clear both credentials and stop renewal. Existing clients fail their
196180
/// next request locally instead of presenting a revoked identity.
197181
pub fn revoke(&self) {
@@ -1577,50 +1561,43 @@ pub async fn exchange_provider_subject_token(
15771561
))
15781562
}
15791563

1580-
/// Confirm that the gateway still accepts this session's current credentials.
1581-
///
1582-
/// The gateway authenticates every sandbox RPC against the persisted runtime
1583-
/// generation, credential epoch, gateway-token lineage, and an active sandbox
1584-
/// phase, so a successful authenticated read proves the session is current
1585-
/// right now, not only when its JWT was signed. The response is discarded.
1586-
#[tracing::instrument(
1587-
name = "supervisor.gateway.confirm_session_authorization",
1588-
skip_all,
1589-
fields(otel.kind = "client", otel.status_code = tracing::field::Empty)
1590-
)]
1591-
pub async fn confirm_session_authorization(
1564+
/// Obtain only the final access token; the gateway selects and verifies the
1565+
/// sandbox identity and resolves all grant parameters from its configuration.
1566+
#[tracing::instrument(name = "supervisor.gateway.get_provider_access_token", skip_all)]
1567+
pub async fn get_sandbox_provider_access_token(
15921568
gateway: &GatewayEndpoint,
1593-
sandbox_name: &str,
1594-
) -> std::result::Result<(), TokenGrantFailure> {
1595-
let status = ClientSpanStatus::current();
1569+
credential_key: &str,
1570+
request_id: Option<&str>,
1571+
) -> std::result::Result<ProviderSubjectTokenExchangeResult, TokenGrantFailure> {
15961572
let endpoint = token_grant_gateway_endpoint(gateway.url())?;
1597-
if sandbox_name.is_empty() {
1598-
return Err(gateway_grant_failure(TokenGrantFailureKind::Configuration));
1599-
}
16001573
let mut client = connect(&gateway.with_url(endpoint.as_str()))
16011574
.await
16021575
.map_err(|_| gateway_grant_failure(TokenGrantFailureKind::Unavailable))?;
1603-
let result = client
1604-
.get_sandbox_config(GetSandboxConfigRequest {
1605-
workspace_scope: None,
1606-
name: sandbox_name.to_string(),
1607-
})
1608-
.await
1609-
.map(drop)
1610-
.map_err(|status| {
1611-
gateway_grant_failure(match status.code() {
1612-
tonic::Code::Unauthenticated
1613-
| tonic::Code::PermissionDenied
1614-
| tonic::Code::NotFound
1615-
| tonic::Code::FailedPrecondition => {
1616-
TokenGrantFailureKind::WorkloadIdentityRejected
1617-
}
1618-
tonic::Code::DeadlineExceeded => TokenGrantFailureKind::Timeout,
1619-
tonic::Code::Unavailable => TokenGrantFailureKind::Unavailable,
1620-
_ => TokenGrantFailureKind::Unclassified,
1621-
})
1622-
});
1623-
status.finish(result)
1576+
let mut request = tonic::Request::new(crate::proto::GetSandboxProviderAccessTokenRequest {
1577+
credential_key: credential_key.to_string(),
1578+
});
1579+
if let Some(value) = request_id.and_then(|value| value.parse().ok()) {
1580+
request.metadata_mut().insert("x-request-id", value);
1581+
}
1582+
let response = tokio::time::timeout(
1583+
Duration::from_mins(1),
1584+
client.get_sandbox_provider_access_token(request),
1585+
)
1586+
.await
1587+
.map_err(|_| gateway_grant_failure(TokenGrantFailureKind::Timeout))?
1588+
.map_err(|status| provider_subject_token_exchange_status(&status, &endpoint))?
1589+
.into_inner();
1590+
// connect() and the response boundary both reject a locally revoked session.
1591+
if gateway.credentials().is_revoked() {
1592+
return Err(gateway_grant_failure(
1593+
TokenGrantFailureKind::WorkloadIdentityRejected,
1594+
));
1595+
}
1596+
provider_subject_token_exchange_result(crate::proto::ExchangeProviderSubjectTokenResponse {
1597+
access_token: response.access_token,
1598+
expires_after: response.expires_after,
1599+
token_type: response.token_type,
1600+
})
16241601
}
16251602

16261603
/// Attach the exchange's request ID as `x-request-id` gRPC metadata.

0 commit comments

Comments
 (0)