mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-27 07:03:17 +08:00
* feat(auth): add OIDC/Keycloak authentication with RBAC Add OAuth2/OIDC authentication to the gateway server with role-based access control, CLI login flows, and full deployment plumbing. Server: JWT validation against configurable OIDC issuer (oidc.rs), JWKS key caching with TTL and rotation handling, method classification (unauthenticated/sandbox-secret/dual-auth/bearer), identity extraction with provider-agnostic Identity type, and RBAC enforcement via AuthzPolicy with configurable admin/user roles and auth-only mode. CLI: browser-based Authorization Code + PKCE flow, Client Credentials flow for CI/automation, token storage with refresh, gateway add/login/ logout commands, OIDC bearer token injection over mTLS transport, discovery endpoint for auto-configuration. Security: sandbox-secret scope restriction on UpdateConfig (policy sync only), anti-spoofing header stripping, dual-auth fallthrough from sandbox-secret to Bearer token. Deployment: OIDC config wired through DeployOptions, Docker env vars, Helm values/templates, HelmChart manifest, cluster-entrypoint.sh, and bootstrap scripts. Keycloak dev server script with pre-configured realm (test users, roles, PKCE client, CI client). Tested with Keycloak. The roles claim path and role names are configurable to support other OIDC providers. * feat(auth): add OAuth2 scope-based fine-grained permissions Add opt-in scope enforcement on top of existing OIDC role-based access control. When --oidc-scopes-claim is set, the server extracts scopes from the JWT and checks them per-method against an exhaustive scope map. Scopes: sandbox:read, sandbox:write, provider:read, provider:write, config:read, config:write, inference:read, inference:write, and openshell:all (wildcard). Methods not in the scope map require openshell:all. Scopes layer on top of roles and cannot escalate privilege. Auth-only mode (empty role names) still enforces scopes when enabled. Server: scopes_claim in OidcConfig, scope extraction from JWT (space-delimited and JSON array formats), standard OIDC scope filtering, scope check in AuthzPolicy after role check. CLI: --oidc-scopes on gateway add/start stored in metadata and consumed by gateway login, --oidc-scopes-claim on gateway start forwarded to server, scopes parameter in browser and client credentials OAuth2 flows with openid deduplication. Deployment: oidc_scopes_claim wired through DeployOptions, docker.rs, Helm, bootstrap scripts, and cluster entrypoint. Keycloak: realm config updated with built-in OIDC scopes and 9 OpenShell client scopes as optional on openshell-cli and openshell:all as default on openshell-ci. * fix(auth): address branch review findings Add GetInferenceBundle to sandbox-secret methods so sandbox inference route refresh works under OIDC. Make GetSandboxConfig dual-auth so CLI users can read sandbox settings with Bearer tokens. Preserve OIDC gateway metadata on restart — a bare gateway start without --oidc-* flags no longer erases the stored OIDC registration. Document CI client ID requirement (openshell-ci vs openshell-cli) in the testing guide. Add security note about auth-only mode blast radius for GitHub Actions. * fix(auth): complete review findings for OIDC auth boundary Move OpenShell/GetSandboxConfig from sandbox-secret-only to dual-auth so CLI users can read sandbox settings with Bearer tokens while sandbox supervisors continue using the shared secret. Add sandbox secret interceptor to the inference bundle fetch path so GetInferenceBundle works under OIDC-enabled gateways. Extract shared interceptor constructor to avoid duplication. Add GetSandboxConfig to the config:read scope map so scope enforcement applies consistently when scopes are enabled. Refactor OIDC metadata preservation into apply_oidc_gateway_metadata() with explicit resume semantics — only preserve existing OIDC metadata on real resume paths, not on fresh deployments. Update architecture docs and testing guide to reflect the corrected method classifications and add new test coverage for interceptor injection, scope requirements, metadata preservation, and dual-auth classification. * refactor(auth): use oauth2 crate for CLI OIDC flows Replace hand-written PKCE generation, authorization URL construction, token exchange, client credentials, and token refresh with the oauth2 crate's typed API. Eliminates sha2, hex, and getrandom dependencies from the CLI. The custom urlencoded() helper and manual form POST logic are replaced by BasicClient methods with proper type-state safety. Discovery and the callback server remain custom since the oauth2 crate does not provide OIDC discovery or a localhost redirect listener. * refactor(auth): move server auth modules into auth/ directory Group oidc.rs, authz.rs, identity.rs, and the auth HTTP endpoints under src/auth/ module directory. No behavioral changes. auth/mod.rs — module root, re-exports HTTP router auth/oidc.rs — JWT validation, JWKS caching, method classification auth/authz.rs — role and scope authorization policy auth/identity.rs — provider-agnostic Identity type auth/http.rs — /auth/connect and /auth/oidc-config endpoints * fix(auth): use RequestBody auth type for client credentials flow The oauth2 crate defaults to BasicAuth (HTTP Basic header) but Keycloak and most OIDC providers expect client_secret_post (credentials in the request body). Set AuthType::RequestBody explicitly to match the pre-refactor behavior. Also re-export Identity, IdentityProvider, and JwksCache from the auth module so ServerState's public API remains nameable by external consumers. * fix(auth): forward OPENSHELL_OIDC_SCOPES through cluster bootstrap Pass --oidc-scopes to gateway start so the metadata includes requested scopes after cluster bootstrap. Without this, users had to manually edit metadata.json to set scopes for gateway login. Usage: OPENSHELL_OIDC_SCOPES="openshell:all" mise run cluster * test(auth): add OIDC e2e tests for RBAC, scopes, and client credentials Add 10 end-to-end tests covering OIDC authentication against a live K3s cluster with Keycloak: RBAC (5 tests): admin can create providers, user cannot, user can list sandboxes, unauthenticated requests rejected, health probe works without auth. Scopes (4 tests): sandbox-scoped token can list sandboxes but not providers, openshell:all grants full access, no-scopes token denied. Client credentials (1 test): CI token via client_credentials grant. Tests are opt-in via OPENSHELL_E2E_OIDC=1 and OPENSHELL_E2E_OIDC_SCOPES=1 env vars. They derive the Keycloak URL from gateway metadata to match the server's configured issuer. Run with: OPENSHELL_E2E_OIDC=1 OPENSHELL_E2E_OIDC_SCOPES=1 \ PYTHONPATH=python uv run pytest e2e/python/oidc/ -v * fix(docs): fix markdown lint errors in OIDC architecture docs Add blank lines before lists and fenced code blocks to satisfy markdownlint MD031 and MD032 rules.
368 lines
13 KiB
Rust
368 lines
13 KiB
Rust
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||
// SPDX-License-Identifier: Apache-2.0
|
||
|
||
//! Auto-bootstrap helpers for sandbox creation.
|
||
//!
|
||
//! When `sandbox create` cannot reach a gateway, these helpers determine whether
|
||
//! to attempt gateway bootstrap and execute the local or remote bootstrap flow.
|
||
//! Bootstrap proceeds automatically unless the user opts out with `--no-bootstrap`.
|
||
|
||
use std::time::Duration;
|
||
|
||
use crate::tls::{TlsOptions, grpc_client};
|
||
use miette::Result;
|
||
use owo_colors::OwoColorize;
|
||
|
||
use crate::run::{deploy_gateway_with_panel, print_deploy_summary};
|
||
|
||
/// Default gateway name used during auto-bootstrap.
|
||
const DEFAULT_GATEWAY_NAME: &str = "openshell";
|
||
|
||
/// Determines if a gRPC connection error indicates the gateway is unreachable
|
||
/// and bootstrap should be offered.
|
||
///
|
||
/// Returns `true` for connectivity errors (connection refused, timeout, DNS failure)
|
||
/// and for missing default TLS materials (which implies no gateway has been deployed).
|
||
///
|
||
/// Returns `false` for explicit TLS configuration errors, auth failures, and other
|
||
/// non-connectivity issues.
|
||
pub fn should_attempt_bootstrap(error: &miette::Report, tls: &TlsOptions) -> bool {
|
||
// If TLS paths were explicitly provided (e.g. in tests) and they failed,
|
||
// that's a configuration error, not a missing-gateway situation.
|
||
if tls.has_any() {
|
||
return is_connectivity_error(error);
|
||
}
|
||
|
||
// With no explicit TLS options, missing default cert files strongly implies
|
||
// no gateway has been bootstrapped yet.
|
||
let msg = format!("{error:?}");
|
||
if is_missing_tls_material(&msg) {
|
||
return true;
|
||
}
|
||
|
||
is_connectivity_error(error)
|
||
}
|
||
|
||
/// Check if the error message indicates missing TLS material files at default paths.
|
||
fn is_missing_tls_material(msg: &str) -> bool {
|
||
let lower = msg.to_lowercase();
|
||
// require_tls_materials fails with "failed to read TLS ..." when cert files are absent
|
||
(lower.contains("failed to read tls") || lower.contains("tls ca is required"))
|
||
&& (lower.contains("no such file")
|
||
|| lower.contains("not found")
|
||
|| lower.contains("is required"))
|
||
}
|
||
|
||
/// Check if the error represents a network connectivity failure.
|
||
fn is_connectivity_error(error: &miette::Report) -> bool {
|
||
let msg = format!("{error:?}");
|
||
let lower = msg.to_lowercase();
|
||
|
||
// Connection-level failures
|
||
let connectivity_patterns = [
|
||
"connection refused",
|
||
"connect error",
|
||
"tcp connect",
|
||
"dns error",
|
||
"name resolution",
|
||
"no route to host",
|
||
"network unreachable",
|
||
"connection reset",
|
||
"broken pipe",
|
||
"connection timed out",
|
||
"operation timed out",
|
||
];
|
||
|
||
// TLS/auth errors that should NOT trigger bootstrap
|
||
let non_connectivity_patterns = [
|
||
"certificate",
|
||
"handshake",
|
||
"ssl",
|
||
"tls error",
|
||
"authorization",
|
||
"authentication",
|
||
"permission denied",
|
||
"forbidden",
|
||
"unauthorized",
|
||
];
|
||
|
||
// If any non-connectivity pattern matches, don't offer bootstrap
|
||
if non_connectivity_patterns.iter().any(|p| lower.contains(p)) {
|
||
return false;
|
||
}
|
||
|
||
// Check for connectivity patterns
|
||
connectivity_patterns.iter().any(|p| lower.contains(p))
|
||
}
|
||
|
||
/// Decide whether gateway bootstrap should proceed.
|
||
///
|
||
/// When `override_value` is `Some(false)` (from `--no-bootstrap`), returns
|
||
/// `false` to skip bootstrap. Otherwise returns `true` — a gateway is created
|
||
/// automatically without prompting the user.
|
||
pub fn confirm_bootstrap(override_value: Option<bool>) -> Result<bool> {
|
||
if override_value == Some(false) {
|
||
return Ok(false);
|
||
}
|
||
Ok(true)
|
||
}
|
||
|
||
/// Resolve the gateway name for bootstrap.
|
||
///
|
||
/// Respects `$OPENSHELL_GATEWAY` if set, otherwise falls back to the default.
|
||
fn resolve_bootstrap_name() -> String {
|
||
std::env::var("OPENSHELL_GATEWAY")
|
||
.ok()
|
||
.filter(|v| !v.trim().is_empty())
|
||
.unwrap_or_else(|| DEFAULT_GATEWAY_NAME.to_string())
|
||
}
|
||
|
||
/// Bootstrap a local gateway and return refreshed TLS options that pick up the
|
||
/// newly-written mTLS certificates, along with the gateway name used.
|
||
pub async fn run_bootstrap(
|
||
remote: Option<&str>,
|
||
ssh_key: Option<&str>,
|
||
gpu: bool,
|
||
) -> Result<(TlsOptions, String, String)> {
|
||
let gateway_name = resolve_bootstrap_name();
|
||
let location = if remote.is_some() { "remote" } else { "local" };
|
||
|
||
eprintln!();
|
||
eprintln!(
|
||
"{} No gateway found — starting one automatically.",
|
||
"ℹ".cyan().bold()
|
||
);
|
||
eprintln!();
|
||
eprintln!(" The Gateway provides a secure control plane for OpenShell. It streamlines");
|
||
eprintln!(" access for humans and agents alike — handles sandbox orchestration, and");
|
||
eprintln!(" enables secure, concurrent agent workflows.");
|
||
eprintln!();
|
||
eprintln!(
|
||
" Manage it later with: {} or {}",
|
||
"openshell status".bold(),
|
||
"openshell gateway stop".bold(),
|
||
);
|
||
eprintln!();
|
||
|
||
// Build deploy options. The deploy flow auto-resumes from existing state
|
||
// (preserving sandboxes and secrets) when it finds an existing gateway.
|
||
// If the initial attempt fails, fall back to a full recreate.
|
||
let build_options = |recreate: bool| {
|
||
let mut opts = openshell_bootstrap::DeployOptions::new(&gateway_name)
|
||
.with_recreate(recreate)
|
||
.with_gpu(if gpu {
|
||
vec!["auto".to_string()]
|
||
} else {
|
||
vec![]
|
||
});
|
||
if let Some(dest) = remote {
|
||
let mut remote_opts = openshell_bootstrap::RemoteOptions::new(dest);
|
||
if let Some(key) = ssh_key {
|
||
remote_opts = remote_opts.with_ssh_key(key);
|
||
}
|
||
opts = opts.with_remote(remote_opts);
|
||
}
|
||
// Read registry credentials from environment for the auto-bootstrap path.
|
||
// The explicit `--registry-username` / `--registry-token` flags are only
|
||
// on `gateway start`; when bootstrapping via `sandbox create`, the env
|
||
// vars are the mechanism.
|
||
if let Ok(username) = std::env::var("OPENSHELL_REGISTRY_USERNAME")
|
||
&& !username.trim().is_empty()
|
||
{
|
||
opts = opts.with_registry_username(username);
|
||
}
|
||
if let Ok(token) = std::env::var("OPENSHELL_REGISTRY_TOKEN")
|
||
&& !token.trim().is_empty()
|
||
{
|
||
opts = opts.with_registry_token(token);
|
||
}
|
||
// Read gateway host override from environment. Needed whenever the
|
||
// client cannot reach the Docker host at 127.0.0.1 — CI containers,
|
||
// WSL, remote Docker hosts, etc. The explicit `--gateway-host` flag
|
||
// is only on `gateway start`; this env var covers the auto-bootstrap
|
||
// path triggered by `sandbox create`.
|
||
if let Ok(host) = std::env::var("OPENSHELL_GATEWAY_HOST")
|
||
&& !host.trim().is_empty()
|
||
{
|
||
opts = opts.with_gateway_host(host);
|
||
}
|
||
opts
|
||
};
|
||
|
||
// Deploy the gateway. The deploy flow auto-resumes from existing state
|
||
// when it finds one. If that fails, fall back to a full recreate.
|
||
let handle = match Box::pin(deploy_gateway_with_panel(
|
||
build_options(false),
|
||
&gateway_name,
|
||
location,
|
||
))
|
||
.await
|
||
{
|
||
Ok(handle) => handle,
|
||
Err(resume_err) => {
|
||
tracing::warn!("auto-bootstrap resume failed, falling back to recreate: {resume_err}");
|
||
Box::pin(deploy_gateway_with_panel(
|
||
build_options(true),
|
||
&gateway_name,
|
||
location,
|
||
))
|
||
.await?
|
||
}
|
||
};
|
||
let server = handle.gateway_endpoint().to_string();
|
||
|
||
print_deploy_summary(&gateway_name, &handle);
|
||
|
||
// Auto-activate the bootstrapped gateway.
|
||
if let Err(err) = openshell_bootstrap::save_active_gateway(&gateway_name) {
|
||
tracing::debug!("failed to set active gateway after bootstrap: {err}");
|
||
}
|
||
|
||
// Build fresh TLS options that resolve the newly-written mTLS certs from
|
||
// the default XDG path for this gateway, using the gateway name directly.
|
||
let tls = TlsOptions::default()
|
||
.with_gateway_name(&gateway_name)
|
||
.with_default_paths(&server);
|
||
|
||
// Wait for the gateway gRPC endpoint to accept connections before
|
||
// handing back to the caller. The Docker health check may pass before
|
||
// the gRPC listener is fully ready, so retry with backoff.
|
||
wait_for_grpc_ready(&server, &tls).await?;
|
||
|
||
Ok((tls, server, gateway_name))
|
||
}
|
||
|
||
/// Retry connecting to the gateway gRPC endpoint until it succeeds or a
|
||
/// timeout is reached. Uses exponential backoff starting at 500 ms, doubling
|
||
/// up to 4 s, with a total deadline of 90 s.
|
||
///
|
||
/// The generous timeout accounts for gateway resume scenarios where stale k3s
|
||
/// nodes must be cleaned up and workload pods rescheduled before the gRPC
|
||
/// endpoint becomes available.
|
||
pub(crate) async fn wait_for_grpc_ready(server: &str, tls: &TlsOptions) -> Result<()> {
|
||
const MAX_WAIT: Duration = Duration::from_secs(90);
|
||
const INITIAL_BACKOFF: Duration = Duration::from_millis(500);
|
||
|
||
let start = std::time::Instant::now();
|
||
let mut backoff = INITIAL_BACKOFF;
|
||
let mut last_err = None;
|
||
|
||
while start.elapsed() < MAX_WAIT {
|
||
match grpc_client(server, tls).await {
|
||
Ok(_client) => return Ok(()),
|
||
Err(err) => {
|
||
tracing::debug!(
|
||
elapsed = ?start.elapsed(),
|
||
"gateway not yet accepting connections: {err:#}"
|
||
);
|
||
last_err = Some(err);
|
||
}
|
||
}
|
||
tokio::time::sleep(backoff).await;
|
||
backoff = (backoff * 2).min(Duration::from_secs(4));
|
||
}
|
||
|
||
Err(last_err
|
||
.unwrap_or_else(|| miette::miette!("timed out waiting for gateway"))
|
||
.wrap_err("gateway deployed but not accepting connections after 90 s"))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
// -- should_attempt_bootstrap / is_connectivity_error tests --
|
||
|
||
fn report(msg: &str) -> miette::Report {
|
||
miette::miette!("{}", msg)
|
||
}
|
||
|
||
#[test]
|
||
fn connection_refused_triggers_bootstrap() {
|
||
let err = report("tcp connect error: Connection refused (os error 111)");
|
||
assert!(should_attempt_bootstrap(&err, &TlsOptions::default()));
|
||
}
|
||
|
||
#[test]
|
||
fn dns_error_triggers_bootstrap() {
|
||
let err = report("dns error: failed to lookup address information");
|
||
assert!(should_attempt_bootstrap(&err, &TlsOptions::default()));
|
||
}
|
||
|
||
#[test]
|
||
fn timeout_triggers_bootstrap() {
|
||
let err = report("operation timed out");
|
||
assert!(should_attempt_bootstrap(&err, &TlsOptions::default()));
|
||
}
|
||
|
||
#[test]
|
||
fn no_route_triggers_bootstrap() {
|
||
let err = report("connect error: No route to host");
|
||
assert!(should_attempt_bootstrap(&err, &TlsOptions::default()));
|
||
}
|
||
|
||
#[test]
|
||
fn network_unreachable_triggers_bootstrap() {
|
||
let err = report("connect error: Network unreachable");
|
||
assert!(should_attempt_bootstrap(&err, &TlsOptions::default()));
|
||
}
|
||
|
||
#[test]
|
||
fn missing_default_tls_files_triggers_bootstrap() {
|
||
let err = report(
|
||
"failed to read TLS CA from /home/user/.config/openshell/clusters/openshell/mtls/ca.crt: No such file or directory",
|
||
);
|
||
assert!(should_attempt_bootstrap(&err, &TlsOptions::default()));
|
||
}
|
||
|
||
#[test]
|
||
fn tls_ca_required_triggers_bootstrap() {
|
||
let err = report("TLS CA is required for https endpoints");
|
||
assert!(should_attempt_bootstrap(&err, &TlsOptions::default()));
|
||
}
|
||
|
||
#[test]
|
||
fn certificate_error_does_not_trigger() {
|
||
let err = report("tls handshake error: certificate verify failed");
|
||
assert!(!should_attempt_bootstrap(&err, &TlsOptions::default()));
|
||
}
|
||
|
||
#[test]
|
||
fn auth_error_does_not_trigger() {
|
||
let err = report("authorization failed: permission denied");
|
||
assert!(!should_attempt_bootstrap(&err, &TlsOptions::default()));
|
||
}
|
||
|
||
#[test]
|
||
fn generic_error_does_not_trigger() {
|
||
let err = report("sandbox missing from response");
|
||
assert!(!should_attempt_bootstrap(&err, &TlsOptions::default()));
|
||
}
|
||
|
||
#[test]
|
||
fn explicit_tls_with_missing_files_does_not_trigger() {
|
||
// When the user explicitly provided TLS paths and they failed to read,
|
||
// that's a config error, not a missing cluster.
|
||
let tls = TlsOptions::new(
|
||
Some("/explicit/path/ca.crt".into()),
|
||
Some("/explicit/path/tls.crt".into()),
|
||
Some("/explicit/path/tls.key".into()),
|
||
);
|
||
let err =
|
||
report("failed to read TLS CA from /explicit/path/ca.crt: No such file or directory");
|
||
assert!(!should_attempt_bootstrap(&err, &tls));
|
||
}
|
||
|
||
#[test]
|
||
fn explicit_tls_with_connection_refused_triggers() {
|
||
// Even with explicit TLS, a connectivity error should still trigger bootstrap.
|
||
let tls = TlsOptions::new(
|
||
Some("/path/ca.crt".into()),
|
||
Some("/path/tls.crt".into()),
|
||
Some("/path/tls.key".into()),
|
||
);
|
||
let err = report("tcp connect error: Connection refused");
|
||
assert!(should_attempt_bootstrap(&err, &tls));
|
||
}
|
||
}
|