Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .agents/skills/helm-dev-environment/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ OPENSHELL_E2E_KUBE_IMAGE_PULL_SECRET=e2e-regcred \
mise run e2e:kubernetes:shared-supervisor
```

`mise run e2e:kubernetes:shared-supervisor-spiffe` installs pinned SPIRE charts
(`ci/values-spire-delegation-stack.yaml`, removed again on exit) and the
`openshell.gateway.experimental_spiffe_delegation` overlay
(`ci/values-supervisor-spiffe.yaml`), then registers sandbox
identities with `ClusterStaticEntry` resources. `mise run test:spire-delegation`
checks delegated issuance and identity selection against a local SPIRE server
and agent without a cluster; the Kubernetes test covers the gateway broker.

### 2. Deploy OpenShell

**Iterative dev** (rebuilds on file changes, recommended during active development):
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions crates/openshell-cli/tests/ensure_providers_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ impl TestOpenShell {

#[tonic::async_trait]
impl OpenShell for TestOpenShell {
async fn get_sandbox_provider_access_token(
&self,
_request: tonic::Request<openshell_core::proto::GetSandboxProviderAccessTokenRequest>,
) -> Result<Response<openshell_core::proto::GetSandboxProviderAccessTokenResponse>, Status>
{
Err(Status::unimplemented("not used by this test server"))
}

async fn peer_notify_config_update(
&self,
_request: tonic::Request<openshell_core::proto::PeerNotifyConfigUpdateRequest>,
Expand Down
8 changes: 8 additions & 0 deletions crates/openshell-cli/tests/mtls_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ struct TestOpenShell;

#[tonic::async_trait]
impl OpenShell for TestOpenShell {
async fn get_sandbox_provider_access_token(
&self,
_request: tonic::Request<openshell_core::proto::GetSandboxProviderAccessTokenRequest>,
) -> Result<Response<openshell_core::proto::GetSandboxProviderAccessTokenResponse>, Status>
{
Err(Status::unimplemented("not used by this test server"))
}

async fn peer_notify_config_update(
&self,
_request: tonic::Request<openshell_core::proto::PeerNotifyConfigUpdateRequest>,
Expand Down
8 changes: 8 additions & 0 deletions crates/openshell-cli/tests/provider_commands_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,14 @@ impl TestOpenShell {

#[tonic::async_trait]
impl OpenShell for TestOpenShell {
async fn get_sandbox_provider_access_token(
&self,
_request: tonic::Request<openshell_core::proto::GetSandboxProviderAccessTokenRequest>,
) -> Result<Response<openshell_core::proto::GetSandboxProviderAccessTokenResponse>, Status>
{
Err(Status::unimplemented("not used by this test server"))
}

async fn peer_notify_config_update(
&self,
_request: tonic::Request<openshell_core::proto::PeerNotifyConfigUpdateRequest>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ struct TestOpenShell {

#[tonic::async_trait]
impl OpenShell for TestOpenShell {
async fn get_sandbox_provider_access_token(
&self,
_request: tonic::Request<openshell_core::proto::GetSandboxProviderAccessTokenRequest>,
) -> Result<Response<openshell_core::proto::GetSandboxProviderAccessTokenResponse>, Status>
{
Err(Status::unimplemented("not used by this test server"))
}

async fn peer_notify_config_update(
&self,
_request: tonic::Request<openshell_core::proto::PeerNotifyConfigUpdateRequest>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,13 @@ macro_rules! mock_gateway {
) => {
#[tonic::async_trait]
impl OpenShell for MockGateway {
async fn get_sandbox_provider_access_token(
&self,
_request: Request<proto::GetSandboxProviderAccessTokenRequest>,
) -> Result<Response<proto::GetSandboxProviderAccessTokenResponse>, Status> {
Err(Status::unimplemented("not used by this test server"))
}

$(async fn $method(&self, _: Request<$request>) -> Result<Response<$response>, Status> {
Err(Status::unimplemented("unused test RPC"))
})*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ struct TestOpenShell {

#[tonic::async_trait]
impl OpenShell for TestOpenShell {
async fn get_sandbox_provider_access_token(
&self,
_request: tonic::Request<openshell_core::proto::GetSandboxProviderAccessTokenRequest>,
) -> Result<Response<openshell_core::proto::GetSandboxProviderAccessTokenResponse>, Status>
{
Err(Status::unimplemented("not used by this test server"))
}

async fn peer_notify_config_update(
&self,
_request: tonic::Request<openshell_core::proto::PeerNotifyConfigUpdateRequest>,
Expand Down
36 changes: 36 additions & 0 deletions crates/openshell-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,39 @@ pub fn normalize_compute_driver_name(value: &str) -> Result<String, String> {
Ok(value.to_ascii_lowercase())
}

/// Experimental gateway-owned SPIRE delegation for sandbox provider grants.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExperimentalSpiffeDelegationConfig {
pub admin_socket_path: String,
pub spiffe_id_prefix: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub admin_socket_group: Option<u32>,
}

impl ExperimentalSpiffeDelegationConfig {
/// Reject ambiguous host paths and identities before opening the SPIRE socket.
pub fn validate(&self, gateway_id: &str) -> Result<(), String> {
let path = std::path::Path::new(&self.admin_socket_path);
if !path.is_absolute()
|| path
.parent()
.is_none_or(|parent| parent == std::path::Path::new("/"))
|| self
.admin_socket_path
.split('/')
.skip(1)
.any(|part| part.is_empty() || part == "." || part == "..")
|| self.admin_socket_group == Some(0)
{
return Err("experimental_spiffe_delegation requires a canonical absolute admin socket path in a dedicated directory and a non-root socket group".into());
}
crate::spiffe::SandboxSpiffeIdPrefix::parse(&self.spiffe_id_prefix)?
.sandbox_spiffe_id(gateway_id, "sandbox")?;
Ok(())
}
}

/// Server configuration.
///
/// Built programmatically in [`crate::Config::new`] and the gateway CLI from
Expand Down Expand Up @@ -269,6 +302,8 @@ pub struct Config {
/// JWTs as `Principal::Sandbox`. Required for the per-sandbox identity
/// flow (issue #1354).
pub gateway_jwt: Option<GatewayJwtConfig>,
/// Gateway-only access to SPIRE; shared supervisors receive access tokens.
pub experimental_spiffe_delegation: Option<ExperimentalSpiffeDelegationConfig>,

/// Database URL for persistence.
pub database_url: String,
Expand Down Expand Up @@ -940,6 +975,7 @@ impl Config {
provider_profile_sources: vec![GatewayProviderProfileSourceConfig::User],
mtls_auth: MtlsAuthConfig::default(),
gateway_jwt: None,
experimental_spiffe_delegation: None,
database_url: String::new(),
database_max_connections: None,
database_lock_max_connections: None,
Expand Down
40 changes: 40 additions & 0 deletions crates/openshell-core/src/grpc_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1569,6 +1569,46 @@ pub async fn exchange_provider_subject_token(
))
}

/// Obtain only the final access token; the gateway selects and verifies the
/// sandbox identity and resolves all grant parameters from its configuration.
#[tracing::instrument(name = "supervisor.gateway.get_provider_access_token", skip_all)]
pub async fn get_sandbox_provider_access_token(
gateway: &GatewayEndpoint,
credential_key: &str,
request_id: Option<&str>,
) -> std::result::Result<ProviderSubjectTokenExchangeResult, TokenGrantFailure> {
let endpoint = token_grant_gateway_endpoint(gateway.url())?;
let mut client = connect(&gateway.with_url(endpoint.as_str()))
.await
.map_err(|_| gateway_grant_failure(TokenGrantFailureKind::Unavailable))?;
let mut request = tonic::Request::new(crate::proto::GetSandboxProviderAccessTokenRequest {
credential_key: credential_key.to_string(),
});
if let Some(value) = request_id.and_then(|value| value.parse().ok()) {
request.metadata_mut().insert("x-request-id", value);
}
let response = tokio::time::timeout(
Duration::from_mins(1),
client.get_sandbox_provider_access_token(request),
)
.await
.map_err(|_| gateway_grant_failure(TokenGrantFailureKind::Timeout))?
.map_err(|status| provider_subject_token_exchange_status(&status, &endpoint))?
.into_inner();
// connect() and the response boundary both reject locally revoked sandbox
// instance credentials.
if gateway.credentials().is_revoked() {
return Err(gateway_grant_failure(
TokenGrantFailureKind::WorkloadIdentityRejected,
));
}
provider_subject_token_exchange_result(crate::proto::ExchangeProviderSubjectTokenResponse {
access_token: response.access_token,
expires_after: response.expires_after,
token_type: response.token_type,
})
}

/// Attach the exchange's request ID as `x-request-id` gRPC metadata.
///
/// A value that is not valid metadata is omitted, never rewritten.
Expand Down
25 changes: 25 additions & 0 deletions crates/openshell-core/src/oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ pub const ACCESS_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_tok
const TOKEN_EXCHANGE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
const ERROR_CONTRACT: &str = "openshell.token-grant.v1";
const MAX_TOKEN_RESPONSE_BYTES: usize = 64 * 1024;
pub const DEFAULT_TOKEN_CACHE_TTL_SECONDS: i64 = 300;
pub const TOKEN_CACHE_EXPIRY_SKEW_SECONDS: i64 = 30;
pub const MAX_TOKEN_EXPIRES_IN_SECONDS: i64 = 3600;

/// HTTP client that authenticates HTTPS endpoints and never redirects assertions.
///
Expand Down Expand Up @@ -263,6 +266,28 @@ pub fn effective_token_type(token_type: &str) -> &str {
}
}

/// Cache lifetime for a granted access token.
///
/// A configured override is used exactly, including zero to disable caching.
/// Otherwise the response lifetime (capped, or a default when omitted) is
/// shortened by a fixed expiry skew, with a one-second floor.
pub fn token_cache_ttl(cache_ttl_override: Option<Duration>, expires_in: i64) -> Duration {
if let Some(cache_ttl_override) = cache_ttl_override {
return cache_ttl_override;
}

let ttl = if expires_in > 0 {
expires_in.min(MAX_TOKEN_EXPIRES_IN_SECONDS)
} else {
DEFAULT_TOKEN_CACHE_TTL_SECONDS
};

Duration::from_secs(
u64::try_from(ttl.saturating_sub(TOKEN_CACHE_EXPIRY_SKEW_SECONDS).max(1))
.unwrap_or(u64::MAX),
)
}

/// Validate the configured endpoint before acquiring any workload credentials.
///
/// HTTPS is accepted; plain HTTP is limited to loopback and Kubernetes service
Expand Down
94 changes: 94 additions & 0 deletions crates/openshell-core/src/spiffe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,77 @@ pub fn trust_domain(subject: &str) -> Option<&str> {
(!trust_domain.is_empty()).then_some(trust_domain)
}

/// Longest SPIFFE ID the SPIFFE specification permits.
const MAX_SPIFFE_ID_BYTES: usize = 2048;

/// Operator-configured prefix of the stable SPIFFE IDs the gateway requests
/// for sandboxes using shared supervision.
///
/// A sandbox's ID is `<prefix>/gateway/<gateway-id>/sandbox/<sandbox-id>`.
/// Both IDs must already be valid SPIFFE path segments; nothing is encoded,
/// so distinct gateway and sandbox IDs always produce distinct SPIFFE IDs.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SandboxSpiffeIdPrefix(String);

impl SandboxSpiffeIdPrefix {
/// Accept `spiffe://<trust-domain>` optionally followed by path segments,
/// with no trailing slash, query, fragment, or percent-encoding.
pub fn parse(value: &str) -> Result<Self, String> {
let invalid = || {
format!(
"SPIFFE ID prefix must be spiffe://<trust-domain>[/<segment>...] with lowercase trust domain characters [a-z0-9.-_] and path segment characters [A-Za-z0-9.-_]: {value:?}"
)
};
let rest = value.strip_prefix("spiffe://").ok_or_else(invalid)?;
let (domain, path) = rest
.split_once('/')
.map_or((rest, None), |(domain, path)| (domain, Some(path)));
if domain.is_empty()
|| !domain.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || b".-_".contains(&byte)
})
{
return Err(invalid());
}
if let Some(path) = path
&& !path.split('/').all(is_spiffe_path_segment)
{
return Err(invalid());
}
Ok(Self(value.to_string()))
}

#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}

/// Stable SPIFFE ID of `sandbox_id` under `gateway_id`.
pub fn sandbox_spiffe_id(&self, gateway_id: &str, sandbox_id: &str) -> Result<String, String> {
if !is_spiffe_path_segment(gateway_id) || !is_spiffe_path_segment(sandbox_id) {
return Err(
"gateway and sandbox IDs must be SPIFFE path segments [A-Za-z0-9.-_]".to_string(),
);
}
let id = format!("{}/gateway/{gateway_id}/sandbox/{sandbox_id}", self.0);
if id.len() > MAX_SPIFFE_ID_BYTES {
return Err("sandbox SPIFFE ID exceeds 2048 bytes".to_string());
}
Ok(id)
}
}

/// A non-empty SPIFFE path segment other than `.` and `..`.
#[must_use]
pub fn is_spiffe_path_segment(segment: &str) -> bool {
!segment.is_empty()
&& segment != "."
&& segment != ".."
&& segment
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b".-_".contains(&byte))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -164,6 +235,29 @@ mod tests {
));
}

#[test]
fn sandbox_spiffe_ids_are_unambiguous_and_reject_unsafe_input() {
let prefix = SandboxSpiffeIdPrefix::parse("spiffe://example.org/openshell").unwrap();
assert_eq!(
prefix.sandbox_spiffe_id("gw", "4f1c2b3a-9d8e").unwrap(),
"spiffe://example.org/openshell/gateway/gw/sandbox/4f1c2b3a-9d8e"
);
for invalid in [
"spiffe://Example.org",
"spiffe://example.org/",
"spiffe://example.org//a",
"spiffe://example.org/a/../b",
"spiffe://example.org/a%2Fb",
"spiffe://example.org:8443/a",
"https://example.org/a",
] {
assert!(SandboxSpiffeIdPrefix::parse(invalid).is_err(), "{invalid}");
}
for (gateway, sandbox) in [("gw/x", "a"), ("gw", "a/b"), ("gw", ".."), ("", "a")] {
assert!(prefix.sandbox_spiffe_id(gateway, sandbox).is_err());
}
}

#[test]
fn trust_domain_extracts_domain_from_spiffe_id() {
assert_eq!(
Expand Down
11 changes: 9 additions & 2 deletions crates/openshell-driver-kubernetes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,15 @@ fails closed.
credentials. Sandboxes cannot reach session control, and the routing preface
only selects which generation-pinned TLS identity the supervisor demands.

The topology does not support upstream proxies or provider SPIFFE identity, and
the shared supervisor is a single point of failure for the sandboxes it hosts.
- **Workload identity.** The gateway owns SPIRE delegation and provider-token
exchange when `openshell.gateway.experimental_spiffe_delegation` is configured.
Shared supervisors receive final access tokens through their authenticated
sessions. No SPIFFE socket is mounted in a shared supervisor or workload Pod.
The existing Workload API option supplies the gateway's component identity
for the RFC 8693 intermediate exchange.

The topology does not support upstream proxies, and the shared supervisor is a
single point of failure for the sandboxes it hosts.
Shared sandbox instances export no supervisor OTLP traces or OCSF events; the driver
warns at startup when the gateway configures that export.
When the setting is removed, reconciliation suspends shared generations, and a
Expand Down
3 changes: 0 additions & 3 deletions crates/openshell-driver-kubernetes/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -544,9 +544,6 @@ impl KubernetesComputeConfig {
"supervisor_topology=gateway requires shared_supervisor".to_string()
})?;
shared.validate()?;
if self.provider_spiffe_enabled() {
return Err("supervisor_topology=gateway does not support provider_spiffe_workload_api_socket_path; a shared supervisor has no per-sandbox workload identity".into());
}
if self.https_proxy.is_some()
|| self.proxy_auth_secret_name.is_some()
|| self.proxy_ca_bundle.is_some()
Expand Down
Loading
Loading