From c9e3abe7be921144a86fe243d9e2a6fee33ca717 Mon Sep 17 00:00:00 2001 From: Adrien Langou Date: Thu, 11 Jun 2026 15:16:15 +0200 Subject: [PATCH] fix(gateway): gate unsafe auth deployment modes Require explicit opt-in for OIDC authentication-only mode on shared gateway deployments and fail closed when gRPC user requests have no auth path. Align Helm validation, tests, and docs so weak auth modes are intentional and visible. Signed-off-by: Adrien Langou --- architecture/gateway.md | 28 ++ crates/openshell-core/src/config.rs | 276 +++++++++++++++- crates/openshell-core/src/lib.rs | 9 +- crates/openshell-server/src/auth/authz.rs | 8 +- crates/openshell-server/src/cli.rs | 3 +- crates/openshell-server/src/config_file.rs | 2 + crates/openshell-server/src/lib.rs | 297 +++++++++++++++--- crates/openshell-server/src/multiplex.rs | 32 +- deploy/helm/openshell/README.md | 5 +- deploy/helm/openshell/ci/values-keycloak.yaml | 3 +- deploy/helm/openshell/templates/_helpers.tpl | 17 + .../openshell/templates/gateway-config.yaml | 11 +- .../openshell/tests/gateway_config_test.yaml | 63 ++++ deploy/helm/openshell/values.yaml | 12 +- docs/kubernetes/access-control.mdx | 28 +- docs/kubernetes/setup.mdx | 1 + docs/reference/gateway-auth.mdx | 4 +- docs/reference/gateway-config.mdx | 3 + docs/security/best-practices.mdx | 6 +- 19 files changed, 719 insertions(+), 89 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index c7569c3d5b..a6485c1027 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -29,6 +29,34 @@ gateway maps the verified certificate subject to a user principal. Kubernetes deployments use mTLS for transport only and require OIDC or a trusted access proxy for user authentication unless the explicit unsafe local-development `allow_unauthenticated_users` switch is enabled. +OIDC deployments normally enforce RBAC roles for user and admin APIs, and +authenticated gRPC methods fail closed when no user, sandbox, mTLS, or explicit +local-dev principal can be derived. + +Auth validation runs in two stages so an unsafe gateway fails to start before +any listener accepts traffic: + +1. **Configuration invariants** (`validate_gateway_auth_config`), checked before + the compute runtime is initialized. `allow_oidc_auth_only` requires OIDC, and + OIDC `admin_role` and `user_role` must both be set (RBAC) or both be empty + (authentication-only). +2. **Resolved deployment posture** (`validate_gateway_auth_posture`), checked + after the compute runtime is selected or created and before listeners bind. + +The posture is derived from resolved facts — the selected compute driver kind, +the configured gateway bind address, and any driver-provided gateway listener +requirements — and never from raw driver-configuration strings. It encodes an +exposure (`Shared` for Kubernetes, an exact non-loopback listener, or a default +route interface; otherwise `Local`) and an mTLS usage (`UserAuthentication` or +`TransportOnly`). Kubernetes is always `Shared` and `TransportOnly`. + +`Shared` exposure requires an explicit user auth path — OIDC, mTLS user auth, or +`allow_unauthenticated_users` — and rejects OIDC authentication-only mode unless +`allow_oidc_auth_only` is set explicitly. `TransportOnly` rejects enabled mTLS +user auth outright, so a Kubernetes gateway cannot promote a client certificate +to a user principal, including when the configured driver name differs in case +or the driver was auto-detected from an empty driver list. + When that service port is bound to loopback, the listener can also accept plaintext HTTP on the same port for sandbox service subdomains only. That local browser path is enabled by default and disabled with diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index daa867f16f..9281dd4f3f 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -612,6 +612,43 @@ pub struct GatewayAuthConfig { /// gateway-minted sandbox JWTs. #[serde(default)] pub allow_unauthenticated_users: bool, + + /// When true, an OIDC issuer may authenticate users without requiring + /// configured RBAC roles. Configured scope checks still apply, so shared + /// deployments must opt in explicitly. + #[serde(default)] + pub allow_oidc_auth_only: bool, +} + +/// Effective network exposure of the gateway API. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GatewayExposure { + /// The gateway API is reachable only through local loopback listeners. + Local, + /// The gateway API is shared or reachable beyond local loopback. + Shared, +} + +/// How verified mTLS client certificates may be used by this deployment. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MtlsIdentityMode { + /// A verified client certificate may represent a gateway user. + UserAuthentication, + /// Client certificates secure transport but must not represent users. + TransportOnly, +} + +/// Resolved deployment facts required to validate gateway authentication. +/// +/// The gateway server constructs this after selecting its compute driver and +/// discovering any driver-provided listeners. It deliberately contains no raw +/// driver configuration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct GatewayAuthPosture { + /// Effective reachability of the gateway API. + pub exposure: GatewayExposure, + /// Whether mTLS identities may authenticate gateway users. + pub mtls_identity: MtlsIdentityMode, } /// One configured gateway interceptor service. @@ -936,6 +973,89 @@ impl Config { self.service_routing.enable_loopback_service_http = enabled; self } + + /// Validate authentication settings that depend only on configuration. + pub fn validate_gateway_auth_config(&self) -> Result<(), String> { + if self.auth.allow_oidc_auth_only && self.oidc.is_none() { + return Err( + "auth.allow_oidc_auth_only=true requires OIDC to be configured".to_string(), + ); + } + + if let Some(oidc) = &self.oidc { + let admin_set = !oidc.admin_role.is_empty(); + let user_set = !oidc.user_role.is_empty(); + + if admin_set != user_set { + return Err(format!( + "OIDC RBAC misconfiguration: admin_role={:?}, user_role={:?}. \ + Either set both roles (RBAC mode) or leave both empty (authentication-only mode).", + oidc.admin_role, oidc.user_role, + )); + } + + if admin_set && self.auth.allow_oidc_auth_only { + return Err( + "auth.allow_oidc_auth_only=true is only valid when OIDC admin_role and user_role are both empty" + .to_string(), + ); + } + } + + Ok(()) + } + + /// Validate authentication against the resolved deployment posture. + /// + /// This composes the configuration-only checks with facts supplied by the + /// gateway after compute-driver selection and listener discovery. + pub fn validate_gateway_auth_posture(&self, posture: GatewayAuthPosture) -> Result<(), String> { + self.validate_gateway_auth_config()?; + + let shared = posture.exposure == GatewayExposure::Shared; + if let Some(oidc) = &self.oidc { + let rbac_enabled = !oidc.admin_role.is_empty(); + + if shared && !rbac_enabled && !self.auth.allow_oidc_auth_only { + return Err( + "OIDC authentication-only mode is disabled for shared gateway deployments; \ + configure admin_role and user_role for RBAC, or set \ + auth.allow_oidc_auth_only=true to skip role checks while retaining configured scope checks" + .to_string(), + ); + } + } + + if posture.mtls_identity == MtlsIdentityMode::TransportOnly && self.mtls_auth.enabled { + return Err( + "mTLS user authentication is not supported when client certificates are transport-only; \ + configure OIDC or a trusted fronting proxy for user authentication" + .to_string(), + ); + } + + let has_user_auth_path = self.oidc.is_some() + || (self.mtls_auth.enabled + && posture.mtls_identity == MtlsIdentityMode::UserAuthentication) + || self.auth.allow_unauthenticated_users; + if shared && !has_user_auth_path { + let message = match posture.mtls_identity { + MtlsIdentityMode::UserAuthentication => { + "shared gateway deployments require an explicit auth path; configure OIDC, \ + mTLS user auth, or set auth.allow_unauthenticated_users=true \ + only behind a trusted local-dev/fronting-proxy boundary" + } + MtlsIdentityMode::TransportOnly => { + "shared gateway deployments require an explicit auth path; configure OIDC, \ + or set auth.allow_unauthenticated_users=true only behind a trusted \ + local-dev/fronting-proxy boundary" + } + }; + return Err(message.to_string()); + } + + Ok(()) + } } impl Default for ServiceRoutingConfig { @@ -1020,9 +1140,10 @@ mod tests { #[cfg(unix)] use super::is_reachable_unix_socket; use super::{ - ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayInterceptorBindingPolicy, - GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, GatewayJwtConfig, - GatewayProviderProfileSourceConfig, PolicyValidationFailureMode, + ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayAuthPosture, + GatewayExposure, GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, + GatewayInterceptorFailurePolicy, GatewayJwtConfig, GatewayProviderProfileSourceConfig, + MtlsIdentityMode, OidcConfig, PolicyValidationFailureMode, detect_docker_socket_from_candidates, detect_driver, detect_podman_socket_from_candidates, docker_host_unix_socket_path, docker_socket_responds, is_unix_socket, normalize_compute_driver_name, podman_socket_candidates_from_env, podman_socket_responds, @@ -1106,6 +1227,155 @@ mod tests { assert!(!cfg.auth.allow_unauthenticated_users); } + fn oidc_config(admin_role: &str, user_role: &str) -> OidcConfig { + OidcConfig { + issuer: "https://issuer.example.com".to_string(), + audience: "openshell-cli".to_string(), + jwks_ttl_secs: 3600, + roles_claim: "realm_access.roles".to_string(), + admin_role: admin_role.to_string(), + user_role: user_role.to_string(), + scopes_claim: String::new(), + } + } + + const fn local_auth_posture() -> GatewayAuthPosture { + GatewayAuthPosture { + exposure: GatewayExposure::Local, + mtls_identity: MtlsIdentityMode::UserAuthentication, + } + } + + const fn shared_auth_posture() -> GatewayAuthPosture { + GatewayAuthPosture { + exposure: GatewayExposure::Shared, + mtls_identity: MtlsIdentityMode::UserAuthentication, + } + } + + const fn transport_only_shared_auth_posture() -> GatewayAuthPosture { + GatewayAuthPosture { + exposure: GatewayExposure::Shared, + mtls_identity: MtlsIdentityMode::TransportOnly, + } + } + + #[test] + fn gateway_auth_posture_allows_loopback_oidc_auth_only_without_override() { + let cfg = Config::new(None).with_oidc(oidc_config("", "")); + + assert!( + cfg.validate_gateway_auth_posture(local_auth_posture()) + .is_ok() + ); + } + + #[test] + fn gateway_auth_posture_rejects_shared_oidc_auth_only_without_override() { + let cfg = Config::new(None).with_oidc(oidc_config("", "")); + + let err = cfg + .validate_gateway_auth_posture(shared_auth_posture()) + .unwrap_err(); + assert!(err.contains("OIDC authentication-only mode")); + } + + #[test] + fn gateway_auth_posture_allows_shared_oidc_auth_only_with_override() { + let mut cfg = Config::new(None).with_oidc(oidc_config("", "")); + cfg.auth.allow_oidc_auth_only = true; + + assert!( + cfg.validate_gateway_auth_posture(shared_auth_posture()) + .is_ok() + ); + } + + #[test] + fn gateway_auth_config_rejects_oidc_auth_only_override_without_oidc() { + let mut cfg = Config::new(None); + cfg.auth.allow_oidc_auth_only = true; + + let err = cfg.validate_gateway_auth_config().unwrap_err(); + assert!(err.contains("requires OIDC to be configured")); + } + + #[test] + fn gateway_auth_config_rejects_oidc_auth_only_override_with_rbac() { + let mut cfg = Config::new(None).with_oidc(oidc_config("openshell-admin", "openshell-user")); + cfg.auth.allow_oidc_auth_only = true; + + let err = cfg.validate_gateway_auth_config().unwrap_err(); + assert!(err.contains("only valid when OIDC admin_role and user_role are both empty")); + } + + #[test] + fn gateway_auth_posture_allows_shared_oidc_rbac() { + let cfg = Config::new(None).with_oidc(oidc_config("openshell-admin", "openshell-user")); + + assert!( + cfg.validate_gateway_auth_posture(shared_auth_posture()) + .is_ok() + ); + } + + #[test] + fn gateway_auth_config_rejects_partial_oidc_roles() { + let cfg = Config::new(None).with_oidc(oidc_config("openshell-admin", "")); + + let err = cfg.validate_gateway_auth_config().unwrap_err(); + assert!(err.contains("OIDC RBAC misconfiguration")); + } + + #[test] + fn gateway_auth_posture_rejects_shared_gateway_without_auth_path() { + let cfg = Config::new(None); + + let err = cfg + .validate_gateway_auth_posture(shared_auth_posture()) + .unwrap_err(); + assert!(err.contains("require an explicit auth path")); + } + + #[test] + fn gateway_auth_posture_rejects_shared_gateway_with_only_sandbox_jwt() { + let mut cfg = Config::new(None); + cfg.gateway_jwt = Some(GatewayJwtConfig { + signing_key_path: "/tmp/signing.pem".into(), + public_key_path: "/tmp/public.pem".into(), + kid_path: "/tmp/kid".into(), + gateway_id: "openshell".to_string(), + ttl_secs: 3600, + }); + + let err = cfg + .validate_gateway_auth_posture(shared_auth_posture()) + .unwrap_err(); + assert!(err.contains("require an explicit auth path")); + } + + #[test] + fn gateway_auth_posture_rejects_transport_only_mtls_user_auth() { + let mut cfg = Config::new(None).with_oidc(oidc_config("openshell-admin", "openshell-user")); + cfg.mtls_auth.enabled = true; + + let err = cfg + .validate_gateway_auth_posture(transport_only_shared_auth_posture()) + .unwrap_err(); + assert!(err.contains("mTLS user authentication is not supported")); + } + + #[test] + fn gateway_auth_posture_allows_local_mtls_user_auth() { + let mut cfg = Config::new(None); + cfg.mtls_auth.enabled = true; + + assert!( + cfg.validate_gateway_auth_posture(local_auth_posture()) + .is_ok() + ); + } + #[test] fn config_defaults_to_builtin_and_user_provider_profile_sources() { let cfg = Config::new(None); diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 56ffda38c4..2de28bf6ec 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -42,10 +42,11 @@ pub mod time; pub mod transport_errors; pub use config::{ - ComputeDriverKind, Config, GatewayAuthConfig, GatewayInterceptorBindingOverride, - GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, GatewayInterceptorFailurePolicy, - GatewayInterceptorPhaseConfig, GatewayJwtConfig, GatewayProviderProfileSourceConfig, - MtlsAuthConfig, OidcConfig, PolicyValidationFailureMode, TlsConfig, + ComputeDriverKind, Config, GatewayAuthConfig, GatewayAuthPosture, GatewayExposure, + GatewayInterceptorBindingOverride, GatewayInterceptorBindingPolicy, GatewayInterceptorConfig, + GatewayInterceptorFailurePolicy, GatewayInterceptorPhaseConfig, GatewayJwtConfig, + GatewayProviderProfileSourceConfig, MtlsAuthConfig, MtlsIdentityMode, OidcConfig, + PolicyValidationFailureMode, TlsConfig, }; pub use error::{ComputeDriverError, Error, Result}; pub use metadata::{ diff --git a/crates/openshell-server/src/auth/authz.rs b/crates/openshell-server/src/auth/authz.rs index 8d2e0eca48..c95c79f0a5 100644 --- a/crates/openshell-server/src/auth/authz.rs +++ b/crates/openshell-server/src/auth/authz.rs @@ -23,7 +23,9 @@ const SCOPE_ALL: &str = "openshell:all"; /// /// Supports two modes: /// - **RBAC mode**: both `admin_role` and `user_role` are non-empty. -/// - **Authentication-only mode**: both are empty (any valid token is authorized). +/// - **Authentication-only mode**: both are empty (role checks are skipped). +/// +/// Configured scope checks apply in both modes. /// /// Partial configuration (one empty, one set) is rejected at construction /// to prevent accidentally leaving admin endpoints unprotected. @@ -60,8 +62,8 @@ impl AuthzPolicy { /// Check whether the identity is authorized to call the given method. /// /// Returns `Ok(())` if authorized, `Err(PERMISSION_DENIED)` if not. - /// When both role names are empty, all authenticated callers are authorized - /// (authentication-only mode for providers like GitHub). + /// When both role names are empty, role checks are skipped. Configured + /// scope checks still apply. /// /// Methods annotated with `global_role` (e.g. `"platform_admin"`) require /// the `admin_role` OIDC claim. Methods annotated with `workspace_role` diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 8b18034947..50c7865a44 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -505,8 +505,7 @@ async fn run_from_args(mut args: RunArgs, matches: ArgMatches) -> Result<()> { && prepared.config.gateway_jwt.is_none() { warn!( - "Neither mTLS user auth nor OIDC nor sandbox JWT auth is configured — \ - the gateway has no authentication mechanism" + "No gateway authentication path is configured; non-loopback or shared deployments will fail startup" ); } diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 1adad2b6b0..01b24912fd 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -538,11 +538,13 @@ policy_validation_failure_mode = "keep_old" let toml = r" [openshell.gateway.auth] allow_unauthenticated_users = true +allow_oidc_auth_only = true "; let tmp = write_tmp(toml); let file = load(tmp.path()).expect("valid auth config parses"); let auth = file.openshell.gateway.auth.expect("auth config"); assert!(auth.allow_unauthenticated_users); + assert!(auth.allow_oidc_auth_only); } #[test] diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 1ab9e1ada1..0c1d9475c8 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -57,7 +57,10 @@ mod tracing_setup; mod ws_tunnel; use metrics_exporter_prometheus::PrometheusBuilder; -use openshell_core::{ComputeDriverKind, Config, Error, ObjectLabels, Result}; +use openshell_core::{ + ComputeDriverKind, Config, Error, GatewayAuthPosture, GatewayExposure, MtlsIdentityMode, + ObjectLabels, Result, +}; use openshell_supervisor_middleware::MiddlewareRegistry; use std::collections::HashMap; use std::io::ErrorKind; @@ -77,7 +80,7 @@ pub(crate) static TEST_ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::n #[cfg(test)] pub(crate) static TEST_TRACING_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); -use compute::ComputeRuntime; +use compute::{ComputeRuntime, GatewayListenerRequirement}; use gateway_listener::{BoundGatewayListener, GatewayListenerScope, bind_gateway_listeners}; pub use grpc::OpenShellService; pub use http::{health_router, http_router, metrics_router, service_http_router}; @@ -256,6 +259,9 @@ pub(crate) async fn run_server( if database_url.is_empty() { return Err(Error::config("database_url is required")); } + config + .validate_gateway_auth_config() + .map_err(Error::config)?; let middleware_registrations = config_file .as_ref() @@ -279,24 +285,6 @@ pub(crate) async fn run_server( let store = Arc::new(Store::connect(database_url).await?); - let oidc_cache = if let Some(ref oidc) = config.oidc { - // Validate RBAC configuration before starting. - let policy = auth::authz::AuthzPolicy { - admin_role: oidc.admin_role.clone(), - user_role: oidc.user_role.clone(), - scopes_enabled: !oidc.scopes_claim.is_empty(), - }; - policy.validate().map_err(Error::config)?; - - let cache = auth::oidc::JwksCache::new(oidc) - .await - .map_err(|e| Error::config(format!("OIDC initialization failed: {e}")))?; - info!("OIDC JWT validation enabled (issuer: {})", oidc.issuer); - Some(Arc::new(cache)) - } else { - None - }; - let sandbox_index = SandboxIndex::new(); let sandbox_watch_bus = SandboxWatchBus::new(); let supervisor_sessions = Arc::new(supervisor_session::SupervisorSessionRegistry::new()); @@ -317,6 +305,34 @@ pub(crate) async fn run_server( supervisor_sessions.clone(), ) .await?; + let auth_posture = resolved_gateway_auth_posture( + &config, + compute.driver_kind(), + compute.gateway_listener_requirements(), + ); + config + .validate_gateway_auth_posture(auth_posture) + .map_err(Error::config)?; + + let oidc_cache = if let Some(ref oidc) = config.oidc { + // Validate RBAC configuration before starting. + let policy = auth::authz::AuthzPolicy { + admin_role: oidc.admin_role.clone(), + user_role: oidc.user_role.clone(), + scopes_enabled: !oidc.scopes_claim.is_empty(), + }; + policy.validate().map_err(Error::config)?; + + let cache = auth::oidc::JwksCache::new(oidc) + .await + .map_err(|e| Error::config(format!("OIDC initialization failed: {e}")))?; + info!("OIDC JWT validation enabled (issuer: {})", oidc.issuer); + warn_if_oidc_auth_only_enabled(&config); + Some(Arc::new(cache)) + } else { + None + }; + let gateway_interceptors = openshell_gateway_interceptors::initialize(config.gateway_interceptors.clone()) .await @@ -948,6 +964,52 @@ fn builtin_compute_driver(name: &str) -> Option { name.parse().ok() } +fn resolved_gateway_auth_posture( + config: &Config, + driver_kind: Option, + driver_listener_requirements: &[GatewayListenerRequirement], +) -> GatewayAuthPosture { + let kubernetes = driver_kind == Some(ComputeDriverKind::Kubernetes); + let has_non_loopback_listener = + driver_listener_requirements + .iter() + .any(|requirement| match requirement { + GatewayListenerRequirement::Exact { address, .. } => !address.ip().is_loopback(), + GatewayListenerRequirement::DefaultRouteInterface { .. } => true, + GatewayListenerRequirement::LoopbackInterface { .. } => false, + }); + let shared = kubernetes || !config.bind_address.ip().is_loopback() || has_non_loopback_listener; + + GatewayAuthPosture { + exposure: if shared { + GatewayExposure::Shared + } else { + GatewayExposure::Local + }, + mtls_identity: if kubernetes { + MtlsIdentityMode::TransportOnly + } else { + MtlsIdentityMode::UserAuthentication + }, + } +} + +fn oidc_auth_only_enabled(config: &Config) -> bool { + config.auth.allow_oidc_auth_only + && config + .oidc + .as_ref() + .is_some_and(|oidc| oidc.admin_role.is_empty() && oidc.user_role.is_empty()) +} + +fn warn_if_oidc_auth_only_enabled(config: &Config) { + if oidc_auth_only_enabled(config) { + warn!( + "OIDC authentication-only mode enabled; RBAC role checks are disabled and configured scope checks still apply" + ); + } +} + fn kubernetes_sandbox_jwt_expiry_disabled(config: &Config) -> bool { config .gateway_jwt @@ -1029,10 +1091,10 @@ mod tests { MultiplexService, ServerState, TlsAcceptor, allow_plaintext_service_http, bind_gateway_listeners, classify_initial_bytes, configured_compute_driver, is_benign_tls_handshake_failure, kubernetes_sandbox_jwt_expiry_disabled, - serve_gateway_listener, + oidc_auth_only_enabled, resolved_gateway_auth_posture, serve_gateway_listener, }; use openshell_core::{ - ComputeDriverKind, Config, + ComputeDriverKind, Config, GatewayExposure, MtlsIdentityMode, proto::{HealthRequest, open_shell_client::OpenShellClient}, }; use std::io::{Error, ErrorKind}; @@ -1053,6 +1115,33 @@ mod tests { tls_test_utils::{generate_test_certs_with_ca, install_rustls_provider}, }; + struct EnvVarGuard { + key: &'static str, + original: Option, + } + + impl EnvVarGuard { + #[allow(unsafe_code)] + fn set(key: &'static str, value: &str) -> Self { + let original = std::env::var(key).ok(); + // SAFETY: tests serialize environment mutation with TEST_ENV_LOCK. + unsafe { std::env::set_var(key, value) }; + Self { key, original } + } + } + + impl Drop for EnvVarGuard { + #[allow(unsafe_code)] + fn drop(&mut self) { + match self.original.as_deref() { + // SAFETY: tests serialize environment mutation with TEST_ENV_LOCK. + Some(value) => unsafe { std::env::set_var(self.key, value) }, + // SAFETY: tests serialize environment mutation with TEST_ENV_LOCK. + None => unsafe { std::env::remove_var(self.key) }, + } + } + } + fn test_driver_startup<'a>( config: &'a Config, file: Option<&'a super::config_file::ConfigFile>, @@ -1370,38 +1459,123 @@ mod tests { } #[test] - fn configured_compute_driver_triggers_auto_detection_when_empty() { + fn configured_compute_driver_autodetects_kubernetes_when_empty_in_cluster() { + let _lock = super::TEST_ENV_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _env = EnvVarGuard::set("KUBERNETES_SERVICE_HOST", "127.0.0.1"); let config = Config::new(None).with_compute_drivers(std::iter::empty::()); - // Empty drivers triggers auto-detection, which may return Some or None - // depending on the environment. This test verifies the auto-detection path - // is taken rather than immediately returning an error. - let result = configured_compute_driver(&config, test_driver_startup(&config, None)); - // Either we get a detected driver or an error about none being detected. - match result { - Ok(ConfiguredComputeDriver::Builtin(driver)) => { - assert!( - matches!( - driver, - ComputeDriverKind::Kubernetes - | ComputeDriverKind::Docker - | ComputeDriverKind::Podman - ), - "auto-detected unexpected driver: {driver:?}" - ); - } - Ok(ConfiguredComputeDriver::Remote { name }) => { - panic!("auto-detection returned remote driver: {name}"); - } - Err(e) => { - assert!( - e.to_string() - .contains("auto-detection found no suitable driver"), - "unexpected error: {e}" - ); - } + let driver = + configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap(); + + assert!(matches!( + driver, + ConfiguredComputeDriver::Builtin(ComputeDriverKind::Kubernetes) + )); + + let posture = + resolved_gateway_auth_posture(&config, Some(ComputeDriverKind::Kubernetes), &[]); + assert_eq!(posture.exposure, GatewayExposure::Shared); + assert_eq!(posture.mtls_identity, MtlsIdentityMode::TransportOnly); + } + + #[test] + fn configured_compute_driver_normalizes_mixed_case_kubernetes() { + let config = Config::new(None).with_compute_drivers(["Kubernetes"]); + let driver = + configured_compute_driver(&config, test_driver_startup(&config, None)).unwrap(); + + assert!(matches!( + driver, + ConfiguredComputeDriver::Builtin(ComputeDriverKind::Kubernetes) + )); + } + + #[test] + fn mixed_case_kubernetes_toml_with_mtls_is_rejected_after_driver_resolution() { + let file: super::config_file::ConfigFile = toml::from_str( + r#" +[openshell.gateway] +compute_drivers = ["Kubernetes"] + +[openshell.gateway.mtls_auth] +enabled = true +"#, + ) + .expect("valid gateway TOML"); + let gateway = &file.openshell.gateway; + let mut config = Config::new(None).with_compute_drivers( + gateway + .compute_drivers + .clone() + .expect("compute drivers configured"), + ); + config.mtls_auth = gateway.mtls_auth.clone().expect("mTLS auth configured"); + + let driver = + configured_compute_driver(&config, test_driver_startup(&config, Some(&file))).unwrap(); + let driver_kind = driver.name().parse().ok(); + let posture = resolved_gateway_auth_posture(&config, driver_kind, &[]); + + assert_eq!(posture.exposure, GatewayExposure::Shared); + assert_eq!(posture.mtls_identity, MtlsIdentityMode::TransportOnly); + let err = config.validate_gateway_auth_posture(posture).unwrap_err(); + assert!(err.contains("mTLS user authentication is not supported")); + } + + #[test] + fn resolved_kubernetes_posture_rejects_mtls_regardless_of_raw_driver_config() { + for raw_drivers in [Vec::::new(), vec!["Kubernetes".to_string()]] { + let mut config = Config::new(None).with_compute_drivers(raw_drivers); + config.mtls_auth.enabled = true; + let posture = + resolved_gateway_auth_posture(&config, Some(ComputeDriverKind::Kubernetes), &[]); + + let err = config.validate_gateway_auth_posture(posture).unwrap_err(); + assert!(err.contains("mTLS user authentication is not supported")); } } + #[test] + fn resolved_posture_marks_driver_non_loopback_listener_shared() { + let config = Config::new(None); + let driver_bind: SocketAddr = "172.18.0.1:17670".parse().expect("valid address"); + let posture = resolved_gateway_auth_posture( + &config, + None, + &[docker_listener_requirement(driver_bind)], + ); + + assert_eq!(posture.exposure, GatewayExposure::Shared); + assert_eq!(posture.mtls_identity, MtlsIdentityMode::UserAuthentication); + let err = config.validate_gateway_auth_posture(posture).unwrap_err(); + assert!(err.contains("require an explicit auth path")); + } + + #[test] + fn resolved_posture_marks_default_route_listener_shared() { + let config = Config::new(None); + let requirement = GatewayListenerRequirement::DefaultRouteInterface { + driver_name: "docker".to_string(), + reason: "managed bridge".to_string(), + }; + let posture = resolved_gateway_auth_posture(&config, None, &[requirement]); + + assert_eq!(posture.exposure, GatewayExposure::Shared); + } + + #[test] + fn resolved_posture_keeps_loopback_listener_local() { + let config = Config::new(None); + let requirement = GatewayListenerRequirement::LoopbackInterface { + driver_name: "docker".to_string(), + reason: "host loopback".to_string(), + }; + let posture = resolved_gateway_auth_posture(&config, None, &[requirement]); + + assert_eq!(posture.exposure, GatewayExposure::Local); + } + #[test] fn configured_compute_driver_rejects_multiple_entries() { let config = Config::new(None) @@ -1520,6 +1694,29 @@ mod tests { assert!(!kubernetes_sandbox_jwt_expiry_disabled(&Config::new(None))); } + #[test] + fn oidc_auth_only_enabled_requires_explicit_active_mode() { + let mut config = Config::new(None).with_oidc(openshell_core::OidcConfig { + issuer: "https://issuer.example.com".to_string(), + audience: "openshell-cli".to_string(), + jwks_ttl_secs: 3600, + roles_claim: "realm_access.roles".to_string(), + admin_role: String::new(), + user_role: String::new(), + scopes_claim: String::new(), + }); + + assert!(!oidc_auth_only_enabled(&config)); + + config.auth.allow_oidc_auth_only = true; + assert!(oidc_auth_only_enabled(&config)); + + let oidc = config.oidc.as_mut().expect("OIDC configured"); + oidc.admin_role = "openshell-admin".to_string(); + oidc.user_role = "openshell-user".to_string(); + assert!(!oidc_auth_only_enabled(&config)); + } + #[tokio::test] async fn failed_gateway_listener_bind_does_not_attempt_persisted_sandbox_resume() { let occupied_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index bf06d2c537..591c4df7b3 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -880,9 +880,9 @@ where /// for local single-user gateways, or to an unsafe local developer user when /// `auth.allow_unauthenticated_users` is explicitly enabled. /// -/// When neither OIDC nor sandbox credentials are configured (a barebones -/// dev gateway), the chain is left as `None` so the router short-circuits -/// to pass-through unless mTLS or local unauthenticated users are enabled. +/// When neither OIDC nor sandbox credentials are configured, the chain is left +/// as `None`; authenticated methods still fail closed unless mTLS or local +/// unauthenticated users are enabled explicitly. fn build_authenticator_chain(state: &ServerState) -> Option { let mut authenticators: Vec> = Vec::new(); if let Some(k8s) = state.k8s_sa_authenticator.clone() { @@ -907,8 +907,8 @@ fn build_authenticator_chain(state: &ServerState) -> Option /// - Strip any external `x-openshell-auth-source` marker first (so callers /// cannot spoof a sandbox identity). /// - Health probes / reflection bypass the chain entirely. -/// - When no chain is configured (OIDC not configured), forward without -/// authentication — preserves today's pass-through behavior. +/// - When no chain is configured, authenticated methods fail closed unless +/// mTLS user auth or the explicit local unauthenticated user mode applies. /// - Otherwise, run the chain. The first match produces a `Principal`. /// `Principal::User` is gated by the RBAC `AuthzPolicy`. /// `Principal::Sandbox` is gated by a supervisor-method allowlist, then @@ -1026,10 +1026,9 @@ where } else if allow_unauthenticated_users { unauthenticated_dev_user_principal() } else { - // No auth configured — dev / fronting-proxy deployments. - // Inject a local-dev principal so downstream handlers that - // call extract_principal() always find one. - unauthenticated_dev_user_principal() + return Ok(status_response(tonic::Status::unauthenticated( + "gateway authentication is not configured", + ))); }; match principal { @@ -2681,6 +2680,21 @@ mod tests { )); } + #[tokio::test] + async fn missing_chain_without_explicit_auth_fails_closed() { + let (recorder, seen) = PrincipalRecorder::new(); + let mut router = + AuthGrpcRouter::with_peer_identity(recorder, None, None, None, false, false); + + let res = router + .call(empty_request("/openshell.v1.OpenShell/ListSandboxes")) + .await + .unwrap(); + + assert!(seen.lock().unwrap().is_none()); + assert_eq!(grpc_status(&res).as_deref(), Some("16")); + } + #[tokio::test] async fn user_principal_lands_in_request_extensions() { let mock = Arc::new(MockAuthenticator::returning(Ok(Some(user_principal( diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index d4310cb9a7..3df8c26811 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -194,6 +194,7 @@ add `ci/values-spire.yaml` to the OpenShell release values files. | securityContext.runAsNonRoot | bool | `true` | Require the gateway container to run as a non-root user. | | securityContext.runAsUser | int | `1000` | UID assigned to the gateway container. | | server.appArmorProfile | string | `"Unconfined"` | Kubernetes AppArmor profile requested for sandbox agent containers. Default Unconfined avoids runtime/default AppArmor blocking the supervisor's network namespace mount setup on AppArmor-enabled nodes. Set to "" to omit the field, "RuntimeDefault" to force the runtime default profile, or "Localhost/profile-name" for an operator-managed localhost profile. | +| server.auth.allowOidcAuthOnly | bool | `false` | UNSAFE: allow OIDC authentication-only mode when adminRole and userRole are both empty. This skips role checks while retaining configured scope checks. This setting requires OIDC with both roles empty. Leave false for shared or production clusters. | | server.auth.allowUnauthenticatedUsers | bool | `false` | UNSAFE: accept unauthenticated CLI/user requests as a local developer principal. Intended only for trusted local Skaffold/k3d development or a fully trusted fronting proxy. Leave false for shared or production clusters. | | server.dbUrl | string | `"sqlite:/var/openshell/openshell.db"` | Gateway database URL (used for the default SQLite backend). | | server.defaultRuntimeClassName | string | `""` | Default Kubernetes runtimeClassName for sandbox pods. Applied when a CreateSandbox request does not specify one. Empty (default) = omit the field, using the cluster's default RuntimeClass. Set to a RuntimeClass name (e.g. "kata-containers", "nvidia") to apply it to all sandboxes that don't explicitly override it. | @@ -206,14 +207,14 @@ add `ci/values-spire.yaml` to the OpenShell release values files. | server.grpcRateLimit.windowSeconds | int | `0` | gRPC rate-limit window length in seconds. Must be positive (alongside requests) to enable rate limiting; 0 (default) disables it. | | server.hostGatewayIP | string | `""` | Host gateway IP for sandbox pod hostAliases. When set, sandbox pods get hostAliases entries mapping host.docker.internal and host.openshell.internal to this IP, allowing them to reach services running on the Docker host. Auto-detected by the cluster entrypoint script. | | server.logLevel | string | `"info"` | Gateway log level. | -| server.oidc.adminRole | string | `""` | Role name for admin access. Leave empty (with userRole also empty) for authentication-only mode. Both must be set or both empty. | +| server.oidc.adminRole | string | `""` | Role name for admin access. Set with userRole for RBAC mode. Leaving both empty enables authentication-only mode only when server.auth.allowOidcAuthOnly=true. | | server.oidc.audience | string | `"openshell-cli"` | Expected audience claim for the API resource server. This should match the server's --oidc-audience, NOT the CLI client ID. | | server.oidc.caConfigMapName | string | `""` | Name of a ConfigMap containing a CA certificate bundle (key: ca.crt) for verifying the OIDC issuer's TLS certificate. Required when the issuer uses a non-public CA (e.g. OpenShift ingress, private PKI). | | server.oidc.issuer | string | `""` | OIDC issuer URL (e.g. https://keycloak.example.com/realms/openshell). | | server.oidc.jwksTtl | int | `3600` | JWKS key cache TTL in seconds. | | server.oidc.rolesClaim | string | `""` | Dot-separated path to the roles array in the JWT claims. Keycloak: "realm_access.roles", Entra ID: "roles", Okta: "groups". | | server.oidc.scopesClaim | string | `""` | Dot-separated path to the scopes array in the JWT claims. | -| server.oidc.userRole | string | `""` | Role name for standard user access. | +| server.oidc.userRole | string | `""` | Role name for standard user access. Set with adminRole for RBAC mode. | | server.policyValidationFailureMode | string | `"fail_closed"` | Posture when a candidate sandbox policy fails validation. `fail_closed` deactivates the previous policy; `retain_last_valid` keeps it active. | | server.providerTokenGrants.spiffe.enabled | bool | `false` | Mount the SPIFFE Workload API socket into sandbox pods for dynamic provider token grants. | | server.providerTokenGrants.spiffe.workloadApiSocketPath | string | `"/spiffe-workload-api/spire-agent.sock"` | Path to the SPIFFE Workload API socket mounted into sandbox pods. | diff --git a/deploy/helm/openshell/ci/values-keycloak.yaml b/deploy/helm/openshell/ci/values-keycloak.yaml index cc6ca658bf..7afd87e88f 100644 --- a/deploy/helm/openshell/ci/values-keycloak.yaml +++ b/deploy/helm/openshell/ci/values-keycloak.yaml @@ -31,6 +31,7 @@ server: jwksTtl: 60 # Keycloak puts realm roles at realm_access.roles in the JWT. rolesClaim: "realm_access.roles" - # Leave both empty for authentication-only mode (any valid token is accepted). + # RBAC mode: both roles must be set. Leave both empty only with + # server.auth.allowOidcAuthOnly=true for authentication-only mode. adminRole: "openshell-admin" userRole: "openshell-user" diff --git a/deploy/helm/openshell/templates/_helpers.tpl b/deploy/helm/openshell/templates/_helpers.tpl index 1b4598088f..e671991a7e 100644 --- a/deploy/helm/openshell/templates/_helpers.tpl +++ b/deploy/helm/openshell/templates/_helpers.tpl @@ -198,9 +198,26 @@ Validate chart values that Helm would otherwise accept silently. {{- $workloadKind := include "openshell.workloadKind" . -}} {{- $workload := .Values.workload | default dict -}} {{- $replicaCount := int (default 1 .Values.replicaCount) -}} +{{- $oidcIssuer := default "" .Values.server.oidc.issuer -}} +{{- $oidcAdminRole := default "" .Values.server.oidc.adminRole -}} +{{- $oidcUserRole := default "" .Values.server.oidc.userRole -}} +{{- $oidcAdminRoleSet := ne $oidcAdminRole "" -}} +{{- $oidcUserRoleSet := ne $oidcUserRole "" -}} {{- if and (hasKey .Values "postgres") (kindIs "map" .Values.postgres) (hasKey .Values.postgres "enabled") -}} {{- fail "postgres.enabled was removed; the OpenShell chart no longer deploys PostgreSQL. Provision PostgreSQL separately and set server.externalDbSecret to a Secret containing a PostgreSQL URI." -}} {{- end -}} +{{- if and .Values.server.auth.allowOidcAuthOnly (not $oidcIssuer) -}} +{{- fail "server.auth.allowOidcAuthOnly=true requires server.oidc.issuer to be configured." -}} +{{- end -}} +{{- if and $oidcIssuer (ne $oidcAdminRoleSet $oidcUserRoleSet) -}} +{{- fail "server.oidc.adminRole and server.oidc.userRole must either both be set for OIDC RBAC or both be empty for authentication-only mode." -}} +{{- end -}} +{{- if and .Values.server.auth.allowOidcAuthOnly $oidcAdminRoleSet $oidcUserRoleSet -}} +{{- fail "server.auth.allowOidcAuthOnly=true is only valid when server.oidc.adminRole and server.oidc.userRole are both empty." -}} +{{- end -}} +{{- if and $oidcIssuer (not $oidcAdminRoleSet) (not .Values.server.auth.allowOidcAuthOnly) -}} +{{- fail "OIDC authentication-only mode skips role checks while retaining configured scope checks. Set server.oidc.adminRole and server.oidc.userRole for RBAC, or set server.auth.allowOidcAuthOnly=true to opt in explicitly." -}} +{{- end -}} {{- if not (or (eq $workloadKind "statefulset") (eq $workloadKind "deployment")) -}} {{- fail "workload.kind must be one of: statefulset, deployment." -}} {{- end -}} diff --git a/deploy/helm/openshell/templates/gateway-config.yaml b/deploy/helm/openshell/templates/gateway-config.yaml index 0c2fc3bbd4..3524039fe3 100644 --- a/deploy/helm/openshell/templates/gateway-config.yaml +++ b/deploy/helm/openshell/templates/gateway-config.yaml @@ -83,11 +83,16 @@ data: client_ca_path = "/etc/openshell-tls/client-ca/ca.crt" {{- end }} - {{- if .Values.server.auth.allowUnauthenticatedUsers }} + {{- if or .Values.server.auth.allowUnauthenticatedUsers .Values.server.auth.allowOidcAuthOnly }} [openshell.gateway.auth] + {{- if .Values.server.auth.allowUnauthenticatedUsers }} allow_unauthenticated_users = true {{- end }} + {{- if .Values.server.auth.allowOidcAuthOnly }} + allow_oidc_auth_only = true + {{- end }} + {{- end }} [openshell.gateway.gateway_jwt] signing_key_path = "/etc/openshell-jwt/signing.pem" @@ -105,10 +110,10 @@ data: {{- if .Values.server.oidc.rolesClaim }} roles_claim = {{ .Values.server.oidc.rolesClaim | quote }} {{- end }} - {{- if .Values.server.oidc.adminRole }} + {{- if or .Values.server.oidc.adminRole .Values.server.auth.allowOidcAuthOnly }} admin_role = {{ .Values.server.oidc.adminRole | quote }} {{- end }} - {{- if .Values.server.oidc.userRole }} + {{- if or .Values.server.oidc.userRole .Values.server.auth.allowOidcAuthOnly }} user_role = {{ .Values.server.oidc.userRole | quote }} {{- end }} {{- if .Values.server.oidc.scopesClaim }} diff --git a/deploy/helm/openshell/tests/gateway_config_test.yaml b/deploy/helm/openshell/tests/gateway_config_test.yaml index 90e4f9cef0..6a6e835b37 100644 --- a/deploy/helm/openshell/tests/gateway_config_test.yaml +++ b/deploy/helm/openshell/tests/gateway_config_test.yaml @@ -47,6 +47,8 @@ tests: set: server.disableTls: true server.oidc.issuer: https://issuer.example.com + server.oidc.adminRole: openshell-admin + server.oidc.userRole: openshell-user server.oidc.caConfigMapName: openshell-oidc-ca asserts: - equal: @@ -293,6 +295,67 @@ tests: - failedTemplate: errorMessage: "server.grpcRateLimit.requests and server.grpcRateLimit.windowSeconds must not be negative; they map to unsigned gateway settings" + - it: fails OIDC authentication-only mode without explicit opt-in + template: templates/statefulset.yaml + set: + server.oidc.issuer: https://issuer.example.com + asserts: + - failedTemplate: + errorPattern: "OIDC authentication-only mode skips role checks while retaining configured scope checks" + + - it: fails OIDC authentication-only opt-in without OIDC + template: templates/statefulset.yaml + set: + server.auth.allowOidcAuthOnly: true + asserts: + - failedTemplate: + errorPattern: "server.auth.allowOidcAuthOnly=true requires server.oidc.issuer" + + - it: fails OIDC authentication-only opt-in with RBAC roles + template: templates/statefulset.yaml + set: + server.auth.allowOidcAuthOnly: true + server.oidc.issuer: https://issuer.example.com + server.oidc.adminRole: openshell-admin + server.oidc.userRole: openshell-user + asserts: + - failedTemplate: + errorPattern: "server.auth.allowOidcAuthOnly=true is only valid when server.oidc.adminRole and server.oidc.userRole are both empty" + + - it: fails OIDC configuration with only one role set + template: templates/statefulset.yaml + set: + server.oidc.issuer: https://issuer.example.com + server.oidc.adminRole: openshell-admin + asserts: + - failedTemplate: + errorPattern: "server.oidc.adminRole and server.oidc.userRole must either both be set" + + - it: renders OIDC RBAC roles when configured + template: templates/gateway-config.yaml + set: + server.oidc.issuer: https://issuer.example.com + server.oidc.rolesClaim: realm_access.roles + server.oidc.adminRole: openshell-admin + server.oidc.userRole: openshell-user + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?ms)\[openshell\.gateway\.oidc\].*?roles_claim\s*=\s*"realm_access\.roles".*?admin_role\s*=\s*"openshell-admin".*?user_role\s*=\s*"openshell-user"' + + - it: renders explicit OIDC authentication-only mode when opted in + template: templates/gateway-config.yaml + set: + server.auth.allowOidcAuthOnly: true + server.oidc.issuer: https://issuer.example.com + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?ms)\[openshell\.gateway\.auth\].*?allow_oidc_auth_only\s*=\s*true' + - matchRegex: + path: data["gateway.toml"] + pattern: '(?ms)\[openshell\.gateway\.oidc\].*?admin_role\s*=\s*"".*?user_role\s*=\s*""' + - it: uses the configured existing sandbox service account name template: templates/gateway-config.yaml set: diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 0525ed475d..6988e845e6 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -248,6 +248,11 @@ server: # principal. Intended only for trusted local Skaffold/k3d development or a # fully trusted fronting proxy. Leave false for shared or production clusters. allowUnauthenticatedUsers: false + # -- UNSAFE: allow OIDC authentication-only mode when adminRole and userRole + # are both empty. This skips role checks while retaining configured scope + # checks. This setting requires OIDC with both roles empty. Leave false for + # shared or production clusters. + allowOidcAuthOnly: false tls: # -- K8s secret (type kubernetes.io/tls) with tls.crt and tls.key for the server. certSecretName: openshell-server-tls @@ -303,10 +308,11 @@ server: # -- Dot-separated path to the roles array in the JWT claims. # Keycloak: "realm_access.roles", Entra ID: "roles", Okta: "groups". rolesClaim: "" - # -- Role name for admin access. Leave empty (with userRole also empty) for - # authentication-only mode. Both must be set or both empty. + # -- Role name for admin access. Set with userRole for RBAC mode. Leaving + # both empty enables authentication-only mode only when + # server.auth.allowOidcAuthOnly=true. adminRole: "" - # -- Role name for standard user access. + # -- Role name for standard user access. Set with adminRole for RBAC mode. userRole: "" # -- Dot-separated path to the scopes array in the JWT claims. scopesClaim: "" diff --git a/docs/kubernetes/access-control.mdx b/docs/kubernetes/access-control.mdx index 5409a4b11d..3e0eac8775 100644 --- a/docs/kubernetes/access-control.mdx +++ b/docs/kubernetes/access-control.mdx @@ -37,7 +37,10 @@ helm upgrade openshell \ --version \ --namespace openshell \ --set server.oidc.issuer=https://your-idp.example.com/realms/openshell \ - --set server.oidc.audience=openshell-cli + --set server.oidc.audience=openshell-cli \ + --set server.oidc.rolesClaim=realm_access.roles \ + --set server.oidc.adminRole=openshell-admin \ + --set server.oidc.userRole=openshell-user ``` The `audience` value must match the client ID configured in your identity provider for the OpenShell resource server. @@ -53,12 +56,11 @@ The `audience` value must match the client ID configured in your identity provid | `server.oidc.adminRole` | `""` | Role name that grants admin access. | | `server.oidc.userRole` | `""` | Role name that grants standard user access. | | `server.oidc.scopesClaim` | `""` | Dot-separated path to the scopes array in JWT claims. | +| `server.auth.allowOidcAuthOnly` | `false` | Explicit opt-in for OIDC authentication-only mode when both role values are empty. | ### Auth-only mode vs. RBAC mode -Leave both `adminRole` and `userRole` empty to use auth-only mode: any request with a valid JWT from the configured issuer is accepted, but no role distinction is enforced. - -Set both values to enable RBAC mode, where the gateway checks the role claim and enforces access based on the assigned role: +Set both role values to enable RBAC mode, where the gateway checks the role claim and enforces access based on the assigned role. This is the recommended mode for shared Kubernetes deployments: ```shell helm upgrade openshell \ @@ -72,7 +74,23 @@ helm upgrade openshell \ --set server.oidc.userRole=openshell-user ``` -Both `adminRole` and `userRole` must be set, or both must be empty. Setting only one is not supported. +Authentication-only mode skips the admin/user role distinction for tokens validated by the configured issuer. Configured scope checks still apply. To use it, leave both `adminRole` and `userRole` empty and explicitly opt in: + +```shell +helm upgrade openshell \ + oci://ghcr.io/nvidia/openshell/helm-chart \ + --version \ + --namespace openshell \ + --set server.oidc.issuer=https://your-idp.example.com/realms/openshell \ + --set server.oidc.audience=openshell-cli \ + --set server.auth.allowOidcAuthOnly=true +``` + +Both `adminRole` and `userRole` must be set, or both must be empty. Setting only one is not supported. Helm rejects authentication-only mode unless `server.auth.allowOidcAuthOnly=true` is set. It also rejects this opt-in when OIDC is disabled or RBAC roles are configured. + + + Before upgrading an existing OIDC deployment with both role values empty, configure both roles or set `server.auth.allowOidcAuthOnly=true`. Otherwise, Helm rejects the upgrade or the shared gateway fails to start. + ### Provider-specific rolesClaim paths diff --git a/docs/kubernetes/setup.mdx b/docs/kubernetes/setup.mdx index c2fca827f1..134d0b088b 100644 --- a/docs/kubernetes/setup.mdx +++ b/docs/kubernetes/setup.mdx @@ -157,6 +157,7 @@ The most commonly changed values are: | `server.appArmorProfile` | AppArmor profile requested for sandbox agent containers. Defaults to `Unconfined`. | | `server.disableTls` | Run the gateway over plaintext HTTP. Use only behind a trusted transport. | | `server.auth.allowUnauthenticatedUsers` | Accept user-facing calls without OIDC or mTLS credentials. Use only for trusted local development or a fully trusted access proxy. | +| `server.auth.allowOidcAuthOnly` | Allow OIDC authentication without RBAC role checks. Configured scope checks still apply. | | `server.enableLoopbackServiceHttp` | Enable local plaintext HTTP for loopback sandbox service URLs. Defaults to `true`. | | `pkiInitJob.serverDnsNames` / `certManager.serverDnsNames` | Additional gateway server DNS SANs. Wildcard SANs also enable sandbox service URLs under that domain. | | `supervisor.sideloadMethod` | How the supervisor binary is delivered into sandbox pods. Leave empty to auto-detect based on cluster version: clusters running Kubernetes 1.35 or later use `image-volume` (ImageVolume GA in 1.36); older clusters use `init-container`. Set explicitly to `image-volume` on Kubernetes 1.33 or 1.34 with the ImageVolume feature gate enabled, or to `init-container` to force the legacy path on any version. | diff --git a/docs/reference/gateway-auth.mdx b/docs/reference/gateway-auth.mdx index 2ef54de70c..7742e01099 100644 --- a/docs/reference/gateway-auth.mdx +++ b/docs/reference/gateway-auth.mdx @@ -110,6 +110,8 @@ server: scopesClaim: "" ``` +For Kubernetes and other shared gateways, set both `adminRole` and `userRole` for RBAC. Authentication-only mode leaves both roles empty and skips role checks for tokens validated by the issuer. Configured scope checks still apply. This mode requires an explicit opt-in with `server.auth.allowOidcAuthOnly=true` in Helm or `allow_oidc_auth_only = true` under `[openshell.gateway.auth]` in TOML. + Register an OIDC gateway with the CLI: ```shell @@ -129,7 +131,7 @@ The connection flow: 3. The CLI connects to the gateway and attaches `authorization: Bearer ` metadata to each gRPC request. 4. The gateway validates the JWT signature, issuer, audience, expiration, and key ID against the issuer's JWKS. 5. The gateway extracts roles and optional scopes from the configured claim paths. -6. The gateway authorizes the gRPC method. Platform-scoped methods require the configured admin role. Workspace-scoped methods require the configured user role and a sufficient membership in the target workspace. Admin role holders satisfy user-role checks and bypass workspace membership checks. +6. The gateway authorizes the gRPC method. In RBAC mode, platform-scoped methods require the configured admin role. Workspace-scoped methods require the configured user role and a sufficient membership in the target workspace. Admin role holders satisfy user-role checks and bypass workspace membership checks. In authentication-only mode, role checks are skipped only after the deployment explicitly opts in; configured scope and workspace membership checks still apply. For the Platform Admin, Workspace Admin, and Workspace User permissions, refer to [Manage Workspaces and Access](/sandboxes/manage-workspaces). diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 2fd5717aec..0c22b4ecb7 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -144,6 +144,7 @@ ttl_secs = 3600 [openshell.gateway.auth] allow_unauthenticated_users = false +allow_oidc_auth_only = false [openshell.gateway.mtls_auth] enabled = false @@ -190,6 +191,8 @@ Local Docker, Podman, and VM gateways can also set `[openshell.gateway.mtls_auth `[openshell.gateway.auth] allow_unauthenticated_users = true` is an unsafe local-development and trusted-proxy escape hatch. It accepts user-facing CLI/API calls without OIDC or mTLS credentials while sandbox supervisors still authenticate with gateway-minted sandbox JWTs. Leave it false for shared and production gateways. +`[openshell.gateway.auth] allow_oidc_auth_only = true` permits OIDC authentication-only mode when `admin_role` and `user_role` are both empty. This skips role checks for tokens validated by the configured issuer while retaining configured scope checks. The gateway rejects this setting when OIDC is absent or RBAC roles are configured, and logs a startup warning when the mode is active. Shared gateways fail startup unless OIDC RBAC roles are configured or this flag is set explicitly. + ## OTLP Export `[openshell.gateway.otlp]` enables OpenTelemetry export over OTLP/gRPC. Omit the table to disable export; there is no separate `enabled` flag. diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 0ac0d5528f..c307ab30a8 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -261,9 +261,9 @@ Gateway transport uses TLS, with client certificate checks available where the d | Aspect | Detail | |---|---| | Default | Local TLS bundles enable mTLS user authentication for single-user local gateways. Helm deployments generate mTLS certificates for transport, while sandbox supervisors authenticate API calls with gateway-minted sandbox JWTs. TLS-enabled loopback gateways also accept plaintext HTTP for sandbox service hostnames by default. | -| What you can change | Configure OIDC or a trusted access proxy for multi-user gateways, set `OPENSHELL_ENABLE_MTLS_AUTH=true` for local single-user gateways, enable `server.auth.allowUnauthenticatedUsers=true` only for trusted local Kubernetes development or a fully trusted proxy, disable TLS only for trusted reverse-proxy setups, or disable loopback service HTTP with `--enable-loopback-service-http=false`. | -| Risk if relaxed | Disabling TLS removes transport-level protection entirely. Allowing unauthenticated users removes the gateway user-auth boundary and must not be exposed to shared or public networks. Treating transport certificates as shared user identity in Kubernetes would collapse user and sandbox trust boundaries. Loopback service HTTP is local-only and rejects cross-origin browser requests, but any local process can still reach exposed service URLs directly. | -| Recommendation | Use local mTLS user authentication only for single-user Docker, Podman, and VM gateways. Use OIDC or a trusted access proxy for Kubernetes and shared deployments. | +| What you can change | Configure OIDC or a trusted access proxy for multi-user gateways, set `OPENSHELL_ENABLE_MTLS_AUTH=true` for local single-user gateways, enable `server.auth.allowUnauthenticatedUsers=true` only for trusted local Kubernetes development or a fully trusted proxy, set `server.auth.allowOidcAuthOnly=true` only when issuer-validated tokens should bypass RBAC role checks, disable TLS only for trusted reverse-proxy setups, or disable loopback service HTTP with `--enable-loopback-service-http=false`. | +| Risk if relaxed | Disabling TLS removes transport-level protection entirely. Allowing unauthenticated users removes the gateway user-auth boundary and must not be exposed to shared or public networks. OIDC authentication-only mode validates tokens but skips RBAC role checks; configured scope checks still apply. Treating transport certificates as shared user identity in Kubernetes would collapse user and sandbox trust boundaries. Loopback service HTTP is local-only and rejects cross-origin browser requests, but any local process can still reach exposed service URLs directly. | +| Recommendation | Use local mTLS user authentication only for single-user Docker, Podman, and VM gateways. Use OIDC RBAC or a trusted access proxy for Kubernetes and shared deployments. | ### SSH Tunnel Authentication