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
12 changes: 9 additions & 3 deletions .agents/skills/debug-openshell-cluster/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,14 +180,20 @@ even when local Helm values disable TLS.

If `server.providerTokenGrants.spiffe.enabled=true`, the gateway should still
render `[openshell.gateway.gateway_jwt]` and mount the `sandbox-jwt` Secret.
SPIRE is used only by sandbox pods for dynamic provider token grants. Verify
that SPIRE is installed, the CSI driver is available, and the Kubernetes driver
config includes `provider_spiffe_workload_api_socket_path`:
SPIRE is used by both the gateway and sandbox supervisors for dynamic provider
token grants. The gateway pod must mount the `spiffe-workload-api` CSI volume
and set `OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET`; sandbox pods must
receive the matching Workload API socket from the Kubernetes driver config.
The gateway verifies supervisor JWT-SVIDs from JWT bundles fetched through this
Workload API socket, not from the SPIRE OIDC discovery endpoint.
Verify that SPIRE is installed, the CSI driver is available, and the Kubernetes
driver config includes `provider_spiffe_workload_api_socket_path`:

```bash
helm -n openshell get values openshell | grep -E 'providerTokenGrants|workloadApiSocketPath'
kubectl get pods -A | grep -E 'spire|spiffe'
kubectl -n openshell get configmap openshell-config -o yaml | grep provider_spiffe_workload_api_socket_path
kubectl -n openshell get pod -l app.kubernetes.io/name=helm-chart -o jsonpath="{.items[*].spec.containers[*].env[?(@.name==\"OPENSHELL_GATEWAY_SPIFFE_WORKLOAD_API_SOCKET\")].value}{\"\n\"}"
```

Sandbox pods using provider token grants should have an
Expand Down
10 changes: 10 additions & 0 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +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"] }
spiffe = { version = "0.15", default-features = false, features = ["workload-api-jwt", "jwt-verify-rust-crypto", "tracing"] }

# Filesystem embedding
include_dir = "0.7"
Expand Down
30 changes: 23 additions & 7 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,29 @@ when policy allows the target endpoint. Secrets must not be logged in OCSF or
plain tracing output.

Provider profiles can also declare dynamic token grants. For matching HTTP
endpoints, the supervisor obtains a SPIFFE JWT-SVID from the local Workload API,
exchanges it for an OAuth2 access token, caches the token, and injects it as an
`Authorization: Bearer` header before forwarding the request. Token grant
endpoints are HTTPS-only except for loopback and Kubernetes service DNS hosts,
and returned access tokens must be bearer-compatible before they are cached or
injected. Token response lifetimes are capped and cached with an expiry margin
unless a profile supplies an explicit cache TTL override.
endpoints, the supervisor obtains or exchanges OAuth2 access tokens, caches
them, and injects them before forwarding the request. `client_credentials`
grants use the supervisor SPIFFE JWT-SVID directly as the client assertion.
`token_exchange` grants ask the gateway to broker an intermediate token using a
stored provider subject credential and the gateway's own SPIFFE JWT-SVID; the
supervisor then exchanges that intermediate token for the final upstream token
using its own JWT-SVID. The gateway validates that its own JWT-SVID has the
requested audience, a SPIFFE subject, and a non-expired `exp` claim when
present. It also validates that the stored subject credential is declared by the
provider profile, and that the supervisor JWT-SVID is a well-formed
three-segment JWT with a SPIFFE subject in the same trust domain as the gateway
SVID. The gateway verifies the supervisor JWT-SVID signature with JWT bundles
fetched from its SPIFFE Workload API. Token grant endpoints are HTTPS-only
except for loopback and Kubernetes service DNS hosts, and returned access tokens
must be bearer-compatible before they are cached or injected. Token response
lifetimes are capped and cached with an expiry margin unless a profile supplies
an explicit cache TTL override. Cache entries are scoped by the sandbox provider
environment revision so provider credential updates miss the old token cache
without changing endpoint matching semantics. Gateway-brokered intermediate
tokens are cached separately by provider resource version, supervisor SPIFFE
subject, and gateway SPIFFE subject, and their cache lifetime is capped by the
intermediate token response, stored subject-token expiry, and supervisor SVID
expiry.

## Connect and Logs

Expand Down
184 changes: 165 additions & 19 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -716,7 +716,7 @@ impl From<CliEditor> for openshell_cli::ssh::Editor {
#[derive(Subcommand, Debug)]
enum ProviderCommands {
/// Create a provider config.
#[command(group = clap::ArgGroup::new("cred_source").required(true).args(["from_existing", "credentials", "from_gcloud_adc", "runtime_credentials"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
#[command(group = clap::ArgGroup::new("cred_source").required(true).multiple(true).args(["from_existing", "credentials", "from_gcloud_adc", "runtime_credentials", "from_oidc_token"]), help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Create {
/// Provider name.
#[arg(long)]
Expand Down Expand Up @@ -744,8 +744,12 @@ enum ProviderCommands {
#[arg(long, group = "cred_source", conflicts_with_all = ["from_existing", "credentials", "runtime_credentials"])]
from_gcloud_adc: bool,

/// Store the active gateway OIDC access token as the named provider credential.
#[arg(long, group = "cred_source", conflicts_with_all = ["from_existing", "from_gcloud_adc", "runtime_credentials"])]
from_oidc_token: bool,

/// Create a provider whose required credentials are resolved at runtime by the gateway/sandbox.
#[arg(long, conflicts_with_all = ["from_existing", "credentials", "from_gcloud_adc"])]
#[arg(long, conflicts_with_all = ["from_existing", "credentials", "from_gcloud_adc", "from_oidc_token"])]
runtime_credentials: bool,

/// Provider config key/value pair.
Expand Down Expand Up @@ -805,9 +809,13 @@ enum ProviderCommands {
name: String,

/// Re-discover credentials from existing local state (e.g. env vars, config files).
#[arg(long, conflicts_with = "credentials")]
#[arg(long, conflicts_with_all = ["credentials", "from_oidc_token"])]
from_existing: bool,

/// Store the active gateway OIDC access token as the named provider credential.
#[arg(long, conflicts_with = "from_existing")]
from_oidc_token: bool,

/// Provider credential pair (`KEY=VALUE`) or env lookup key (`KEY`).
#[arg(
long = "credential",
Expand Down Expand Up @@ -2834,20 +2842,44 @@ async fn main() -> Result<()> {
from_existing,
credentials,
from_gcloud_adc,
from_oidc_token,
runtime_credentials,
config,
} => {
run::provider_create_with_options(
endpoint,
&name,
provider_type.as_str(),
let selected_sources = [
from_existing,
&credentials,
from_gcloud_adc,
from_oidc_token,
runtime_credentials,
&config,
&tls,
)
]
.into_iter()
.filter(|selected| *selected)
.count();
if selected_sources > 1 {
return Err(miette::miette!(
"--from-existing, --from-gcloud-adc, --from-oidc-token, and --runtime-credentials are mutually exclusive"
));
}
let credential_source = if from_existing {
run::ProviderCreateCredentialSource::Existing
} else if from_gcloud_adc {
run::ProviderCreateCredentialSource::GcloudAdc
} else if from_oidc_token {
run::ProviderCreateCredentialSource::OidcToken
} else if runtime_credentials {
run::ProviderCreateCredentialSource::Runtime
} else {
run::ProviderCreateCredentialSource::ExplicitCredentials
};
run::provider_create_with_options(run::ProviderCreateOptions {
server: endpoint,
name: &name,
provider_type: provider_type.as_str(),
credentials: &credentials,
credential_source,
config: &config,
tls: &tls,
})
.await?;
}
ProviderCommands::Refresh(command) => match command {
Expand Down Expand Up @@ -2943,19 +2975,21 @@ async fn main() -> Result<()> {
ProviderCommands::Update {
name,
from_existing,
from_oidc_token,
credentials,
config,
credential_expires_at,
} => {
run::provider_update(
endpoint,
&name,
run::provider_update(run::ProviderUpdateOptions {
server: endpoint,
name: &name,
from_existing,
&credentials,
&config,
&credential_expires_at,
&tls,
)
from_oidc_token,
credentials: &credentials,
config: &config,
credential_expires_at: &credential_expires_at,
tls: &tls,
})
.await?;
}
ProviderCommands::Delete { names } => {
Expand Down Expand Up @@ -4016,6 +4050,68 @@ mod tests {
}
}

#[test]
fn provider_create_accepts_from_oidc_token_destination_credential() {
let cli = Cli::try_parse_from([
"openshell",
"provider",
"create",
"--name",
"custom-api",
"--type",
"custom-api",
"--from-oidc-token",
"--credential",
"user_oidc_token",
])
.expect("provider create should parse from oidc token");

match cli.command {
Some(Commands::Provider {
command:
Some(ProviderCommands::Create {
name,
provider_type,
from_oidc_token,
credentials,
..
}),
}) => {
assert_eq!(name, "custom-api");
assert_eq!(provider_type, "custom-api");
assert!(from_oidc_token);
assert_eq!(credentials, vec!["user_oidc_token"]);
}
other => panic!("expected provider create command, got: {other:?}"),
}
}

#[test]
fn provider_create_accepts_from_oidc_token_without_credential() {
let cli = Cli::try_parse_from([
"openshell",
"provider",
"create",
"--name",
"custom-api",
"--type",
"custom-api",
"--from-oidc-token",
])
.expect("provider create should parse inferred oidc token destination");

assert!(matches!(
cli.command,
Some(Commands::Provider {
command: Some(ProviderCommands::Create {
from_oidc_token: true,
credentials,
..
})
}) if credentials.is_empty()
));
}

#[test]
fn provider_create_rejects_from_gcloud_adc_with_from_existing() {
let err = Cli::try_parse_from([
Expand Down Expand Up @@ -4176,6 +4272,56 @@ mod tests {
));
}

#[test]
fn provider_update_accepts_from_oidc_token_destination_credential() {
let cli = Cli::try_parse_from([
"openshell",
"provider",
"update",
"custom-api",
"--from-oidc-token",
"--credential",
"user_oidc_token",
])
.expect("provider update should parse from oidc token");

assert!(matches!(
cli.command,
Some(Commands::Provider {
command: Some(ProviderCommands::Update {
name,
from_oidc_token: true,
credentials,
..
})
}) if name == "custom-api" && credentials == vec!["user_oidc_token"]
));
}

#[test]
fn provider_update_accepts_from_oidc_token_without_credential() {
let cli = Cli::try_parse_from([
"openshell",
"provider",
"update",
"custom-api",
"--from-oidc-token",
])
.expect("provider update should parse inferred oidc token destination");

assert!(matches!(
cli.command,
Some(Commands::Provider {
command: Some(ProviderCommands::Update {
name,
from_oidc_token: true,
credentials,
..
})
}) if name == "custom-api" && credentials.is_empty()
));
}

#[test]
fn provider_refresh_config_accepts_rfc3339_credential_expiry() {
let cli = Cli::try_parse_from([
Expand Down
Loading
Loading