Skip to content
Open
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
22 changes: 16 additions & 6 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,13 +338,23 @@ Provider profiles can also declare dynamic token grants. For matching HTTP
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
`token_exchange` grants ask the gateway to broker an intermediate token using
either a stored provider subject credential or the sandbox creator's delegated
OIDC identity, plus the gateway's own SPIFFE JWT-SVID; the supervisor then
exchanges that intermediate token for the final upstream token using its own
JWT-SVID. Delegated identity is opt-in at sandbox creation, stores one
gateway-scoped credential per issuer/client/user subject, and stores only a
per-sandbox authorization window on the sandbox. Only the delegating user can
extend or withdraw that window; workspace admins and platform admins can still
delete the sandbox to recover workspace resources. The gateway rejects exchange
after expiry, withdrawal, missing credential state, or credential revocation.
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
present. For provider-credential subject tokens, it also validates that the
stored subject credential is declared by the provider profile. For delegated
identity subject tokens, it validates that the sandbox was created with active
delegation for the stored credential principal. The gateway also verifies 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
Expand Down
252 changes: 252 additions & 0 deletions crates/openshell-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,13 @@ enum Commands {
command: Option<ProviderCommands>,
},

/// Manage delegated identity credential records.
#[command(help_template = SUBCOMMAND_HELP_TEMPLATE)]
DelegatedCredential {
#[command(subcommand)]
command: Option<DelegatedCredentialCommands>,
},

/// Manage workspaces.
#[command(alias = "ws", after_help = WORKSPACE_EXAMPLES, help_template = SUBCOMMAND_HELP_TEMPLATE)]
Workspace {
Expand Down Expand Up @@ -1102,6 +1109,62 @@ enum ProviderProfileCommands {
},
}

#[derive(Subcommand, Debug)]
enum DelegatedCredentialCommands {
/// List delegated identity credentials.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
List {
/// Maximum number of credentials to return.
#[arg(long, default_value_t = 100)]
limit: u32,

/// Number of credentials to skip.
#[arg(long, default_value_t = 0)]
offset: u32,

/// Output only credential IDs.
#[arg(long, conflicts_with = "output")]
ids: bool,

/// Output format.
#[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table, conflicts_with = "ids")]
output: OutputFormat,
},

/// Show delegated identity credential status.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Status {
/// Delegated identity credential ID.
id: String,

/// Output format.
#[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)]
output: OutputFormat,
},

/// Revoke a delegated identity credential.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Revoke {
/// Delegated identity credential ID.
id: String,

/// Expected resource version for compare-and-swap updates.
#[arg(long = "resource-version", default_value_t = 0)]
resource_version: u64,
},

/// Delete a delegated identity credential record.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Delete {
/// Delegated identity credential ID.
id: String,

/// Expected resource version for compare-and-swap deletes.
#[arg(long = "resource-version", default_value_t = 0)]
resource_version: u64,
},
}

// -----------------------------------------------------------------------
// Gateway commands (replaces the old `cluster` / `cluster admin` groups)
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -1413,6 +1476,11 @@ enum SandboxCommands {
#[arg(long = "provider")]
providers: Vec<String>,

/// Delegate the current OIDC identity to the sandbox for a bounded duration.
/// Accepts positive durations with m, h, or d suffixes, for example 30m, 8h, or 7d.
#[arg(long = "delegate-identity-for", value_name = "DURATION")]
delegate_identity_for: Option<String>,

/// Path to a custom sandbox policy YAML file.
/// Overrides the built-in default and the `OPENSHELL_SANDBOX_POLICY` env var.
#[arg(long, value_hint = ValueHint::FilePath)]
Expand Down Expand Up @@ -1672,6 +1740,41 @@ enum SandboxCommands {
/// Manage providers attached to a sandbox.
#[command(subcommand)]
Provider(SandboxProviderCommands),

/// Manage sandbox delegated identity.
#[command(subcommand, name = "delegated-identity")]
DelegatedIdentity(SandboxDelegatedIdentityCommands),
}

#[derive(Subcommand, Debug)]
enum SandboxDelegatedIdentityCommands {
/// Show delegated identity status for a sandbox.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Status {
/// Sandbox name.
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
name: String,
},

/// Withdraw delegated identity from a sandbox.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Withdraw {
/// Sandbox name.
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
name: String,
},

/// Set delegated identity expiry to now plus the requested duration.
#[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")]
Extend {
/// Sandbox name.
#[arg(add = ArgValueCompleter::new(completers::complete_sandbox_names))]
name: String,

/// New authorization window, for example 24h.
#[arg(long = "for", value_name = "DURATION")]
duration: String,
},
}

#[derive(Subcommand, Debug)]
Expand Down Expand Up @@ -2994,6 +3097,7 @@ async fn run_async() -> Result<()> {
memory,
driver_config_json,
providers,
delegate_identity_for,
policy,
forward,
tty,
Expand Down Expand Up @@ -3085,6 +3189,7 @@ async fn run_async() -> Result<()> {
driver_config_json: driver_config_json.as_deref(),
editor,
providers: &providers,
delegate_identity_for: delegate_identity_for.as_deref(),
policy: policy.as_deref(),
forward,
command: &command,
Expand Down Expand Up @@ -3304,6 +3409,37 @@ async fn run_async() -> Result<()> {
.await?;
}
},
SandboxCommands::DelegatedIdentity(command) => match command {
SandboxDelegatedIdentityCommands::Status { name } => {
run::sandbox_delegated_identity_status(
endpoint,
&name,
&cli.workspace,
&tls,
)
.await?;
}
SandboxDelegatedIdentityCommands::Withdraw { name } => {
run::sandbox_delegated_identity_withdraw(
endpoint,
&name,
&cli.workspace,
&tls,
)
.await?;
}
SandboxDelegatedIdentityCommands::Extend { name, duration } => {
run::sandbox_delegated_identity_extend(
endpoint,
&ctx.name,
&name,
&duration,
&cli.workspace,
&tls,
)
.await?;
}
},
}
}
}
Expand Down Expand Up @@ -3588,6 +3724,61 @@ async fn run_async() -> Result<()> {
}
}
}
Some(Commands::DelegatedCredential {
command: Some(command),
}) => {
let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?;
let endpoint = &ctx.endpoint;
let mut tls = tls.with_gateway_name(&ctx.name);
apply_auth(&mut tls, &ctx.name);

match command {
DelegatedCredentialCommands::List {
limit,
offset,
ids,
output,
} => {
run::delegated_identity_credential_list(
endpoint,
limit,
offset,
ids,
output.as_str(),
&tls,
)
.await?;
}
DelegatedCredentialCommands::Status { id, output } => {
run::delegated_identity_credential_status(endpoint, &id, output.as_str(), &tls)
.await?;
}
DelegatedCredentialCommands::Revoke {
id,
resource_version,
} => {
run::delegated_identity_credential_revoke(
endpoint,
&id,
resource_version,
&tls,
)
.await?;
}
DelegatedCredentialCommands::Delete {
id,
resource_version,
} => {
run::delegated_identity_credential_delete(
endpoint,
&id,
resource_version,
&tls,
)
.await?;
}
}
}
Some(Commands::Term { theme }) => {
let ctx = resolve_gateway(&cli.gateway, &cli.gateway_endpoint)?;
let mut tls = tls.with_gateway_name(&ctx.name);
Expand Down Expand Up @@ -3719,6 +3910,13 @@ async fn run_async() -> Result<()> {
.print_help()
.expect("Failed to print help");
}
Some(Commands::DelegatedCredential { command: None }) => {
Cli::command()
.find_subcommand_mut("delegated-credential")
.expect("delegated-credential subcommand exists")
.print_help()
.expect("Failed to print help");
}
Some(Commands::Gateway { command: None }) => {
Cli::command()
.find_subcommand_mut("gateway")
Expand Down Expand Up @@ -4439,6 +4637,60 @@ mod tests {
));
}

#[test]
fn delegated_credential_commands_parse() {
let list = Cli::try_parse_from(["openshell", "delegated-credential", "list", "--ids"])
.expect("delegated credential list should parse");
assert!(matches!(
list.command,
Some(Commands::DelegatedCredential {
command: Some(DelegatedCredentialCommands::List {
ids: true,
output: OutputFormat::Table,
..
})
})
));

let status = Cli::try_parse_from([
"openshell",
"delegated-credential",
"status",
"delegated-identity-123",
"-o",
"json",
])
.expect("delegated credential status should parse");
assert!(matches!(
status.command,
Some(Commands::DelegatedCredential {
command: Some(DelegatedCredentialCommands::Status {
id,
output: OutputFormat::Json,
})
}) if id == "delegated-identity-123"
));

let revoke = Cli::try_parse_from([
"openshell",
"delegated-credential",
"revoke",
"delegated-identity-123",
"--resource-version",
"7",
])
.expect("delegated credential revoke should parse");
assert!(matches!(
revoke.command,
Some(Commands::DelegatedCredential {
command: Some(DelegatedCredentialCommands::Revoke {
id,
resource_version: 7,
})
}) if id == "delegated-identity-123"
));
}

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