Skip to content

Commit 157478e

Browse files
committed
feat(gateway): broker stable sandbox SPIFFE provider tokens
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
1 parent 9a85667 commit 157478e

52 files changed

Lines changed: 3657 additions & 1080 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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ OPENSHELL_E2E_KUBE_IMAGE_PULL_SECRET=e2e-regcred \
9696
mise run e2e:kubernetes:shared-supervisor
9797
```
9898

99+
`mise run e2e:kubernetes:shared-supervisor-spiffe` installs pinned SPIRE charts
100+
(`ci/values-spire-delegation-stack.yaml`, removed again on exit) and the
101+
`openshell.gateway.experimental_spiffe_delegation` overlay
102+
(`ci/values-supervisor-spiffe.yaml`), then registers sandbox
103+
identities with `ClusterStaticEntry` resources. `mise run test:spire-delegation`
104+
checks delegated issuance and identity selection against a local SPIRE server
105+
and agent without a cluster; the Kubernetes test covers the gateway broker.
106+
99107
### 2. Deploy OpenShell
100108

101109
**Iterative dev** (rebuilds on file changes, recommended during active development):

‎Cargo.lock‎

Lines changed: 1 addition & 0 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: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1578,6 +1578,46 @@ pub async fn exchange_provider_subject_token(
15781578
))
15791579
}
15801580

1581+
/// Obtain only the final access token; the gateway selects and verifies the
1582+
/// sandbox identity and resolves all grant parameters from its configuration.
1583+
#[tracing::instrument(name = "supervisor.gateway.get_provider_access_token", skip_all)]
1584+
pub async fn get_sandbox_provider_access_token(
1585+
gateway: &GatewayEndpoint,
1586+
credential_key: &str,
1587+
request_id: Option<&str>,
1588+
) -> std::result::Result<ProviderSubjectTokenExchangeResult, TokenGrantFailure> {
1589+
let endpoint = token_grant_gateway_endpoint(gateway.url())?;
1590+
let mut client = connect(&gateway.with_url(endpoint.as_str()))
1591+
.await
1592+
.map_err(|_| gateway_grant_failure(TokenGrantFailureKind::Unavailable))?;
1593+
let mut request = tonic::Request::new(crate::proto::GetSandboxProviderAccessTokenRequest {
1594+
credential_key: credential_key.to_string(),
1595+
});
1596+
if let Some(value) = request_id.and_then(|value| value.parse().ok()) {
1597+
request.metadata_mut().insert("x-request-id", value);
1598+
}
1599+
let response = tokio::time::timeout(
1600+
Duration::from_mins(1),
1601+
client.get_sandbox_provider_access_token(request),
1602+
)
1603+
.await
1604+
.map_err(|_| gateway_grant_failure(TokenGrantFailureKind::Timeout))?
1605+
.map_err(|status| provider_subject_token_exchange_status(&status, &endpoint))?
1606+
.into_inner();
1607+
// connect() and the response boundary both reject locally revoked sandbox
1608+
// instance credentials.
1609+
if gateway.credentials().is_revoked() {
1610+
return Err(gateway_grant_failure(
1611+
TokenGrantFailureKind::WorkloadIdentityRejected,
1612+
));
1613+
}
1614+
provider_subject_token_exchange_result(crate::proto::ExchangeProviderSubjectTokenResponse {
1615+
access_token: response.access_token,
1616+
expires_after: response.expires_after,
1617+
token_type: response.token_type,
1618+
})
1619+
}
1620+
15811621
/// Attach the exchange's request ID as `x-request-id` gRPC metadata.
15821622
///
15831623
/// A value that is not valid metadata is omitted, never rewritten.

0 commit comments

Comments
 (0)