diff --git a/.agents/skills/helm-dev-environment/SKILL.md b/.agents/skills/helm-dev-environment/SKILL.md index cb3023f476..a897009786 100644 --- a/.agents/skills/helm-dev-environment/SKILL.md +++ b/.agents/skills/helm-dev-environment/SKILL.md @@ -339,7 +339,11 @@ development TLS certificate, and publishes its trust anchor as the `openshell-keycloak-ca` ConfigMap in the OpenShell namespace. The command prints a port-forward command for acquiring tokens from the CLI. Rerunning setup rotates the development certificate and trust anchor; redeploy the gateway afterward so it reloads -the mounted CA bundle. +the mounted CA bundle. The chart renders the mount path as +`[openshell.gateway.oidc] ca_bundle`; the gateway adds that issuer CA to native roots +for OIDC discovery and JWKS requests without changing trust for other HTTPS clients. +The mounted bundle must be a regular file no larger than 1 MiB; the gateway rejects +other file types and oversized bundles during startup. Then activate OIDC in the OpenShell Helm chart: 1. Uncomment `#- ci/values-keycloak.yaml` in `skaffold.yaml` diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index c85fc4308a..eacaaac807 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -338,6 +338,12 @@ pub struct OidcConfig { /// OIDC issuer URL (e.g., `https://idp.example.com/realms/openshell`). pub issuer: String, + /// Optional PEM CA bundle for an issuer signed by a private CA. It must be + /// a regular file no larger than 1 MiB. These certificates augment the + /// platform trust roots for OIDC discovery and JWKS requests only. + #[serde(default)] + pub ca_bundle: Option, + /// Permit cleartext OIDC metadata and JWKS requests to numeric loopback /// addresses. This is a development-only escape hatch and never permits /// cleartext requests to hostnames or non-loopback addresses. diff --git a/crates/openshell-core/src/driver_utils.rs b/crates/openshell-core/src/driver_utils.rs index a5a86fe908..151758dee6 100644 --- a/crates/openshell-core/src/driver_utils.rs +++ b/crates/openshell-core/src/driver_utils.rs @@ -389,7 +389,7 @@ pub const MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES: u64 = 4096; /// cannot be opened or stat'd, is not a regular file, or exceeds the size /// bound. pub fn read_upstream_proxy_credential_file(path: &str) -> Result { - read_regular_file_bounded(path, MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES).map_err(|err| match err { + read_regular_utf8_file_bounded(Path::new(path), MAX_UPSTREAM_PROXY_CREDENTIAL_BYTES).map_err(|err| match err { BoundedReadError::Open(e) => format!("failed to open proxy auth file '{path}': {e}"), BoundedReadError::Stat(e) => format!("failed to stat proxy auth file '{path}': {e}"), BoundedReadError::NotRegular => format!("proxy auth file '{path}' is not a regular file"), @@ -430,19 +430,18 @@ pub const MAX_UPSTREAM_PROXY_CA_BUNDLE_BYTES: u64 = 1024 * 1024; /// cannot be read, is not a regular file, exceeds the size bound, or holds no /// usable certificate. pub fn read_upstream_proxy_ca_bundle_file(path: &str, label: &str) -> Result { - let pem = read_regular_file_bounded(path, MAX_UPSTREAM_PROXY_CA_BUNDLE_BYTES).map_err( - |err| match err { - BoundedReadError::Open(e) | BoundedReadError::Stat(e) | BoundedReadError::Read(e) => { - format!("{label} '{path}' could not be read: {e}") - } - BoundedReadError::NotRegular => { - format!("{label} '{path}' is not a regular file") - } - BoundedReadError::TooLarge => format!( - "{label} '{path}' exceeds the {MAX_UPSTREAM_PROXY_CA_BUNDLE_BYTES}-byte limit" - ), - }, - )?; + let pem = read_regular_utf8_file_bounded(Path::new(path), MAX_UPSTREAM_PROXY_CA_BUNDLE_BYTES) + .map_err(|err| match err { + BoundedReadError::Open(e) | BoundedReadError::Stat(e) | BoundedReadError::Read(e) => { + format!("{label} '{path}' could not be read: {e}") + } + BoundedReadError::NotRegular => { + format!("{label} '{path}' is not a regular file") + } + BoundedReadError::TooLarge => { + format!("{label} '{path}' exceeds the {MAX_UPSTREAM_PROXY_CA_BUNDLE_BYTES}-byte limit") + } + })?; validate_upstream_proxy_ca_bundle_pem(&pem, path, label)?; Ok(pem) } @@ -491,7 +490,8 @@ pub fn validate_upstream_proxy_ca_bundle_pem( /// Failure modes of [`read_regular_file_bounded`], so each caller can phrase /// them in terms of the operator setting it is reading. -enum BoundedReadError { +#[derive(Debug)] +pub enum BoundedReadError { Open(std::io::Error), Stat(std::io::Error), NotRegular, @@ -499,13 +499,21 @@ enum BoundedReadError { Read(std::io::Error), } -/// Read a regular file into a `String`, rejecting anything larger than +/// Read a regular file into memory, rejecting anything larger than /// `max_bytes` and anything that is not a regular file. /// -/// Backs the operator-supplied proxy file readers, which must never let a -/// hostile or misconfigured path (`/dev/zero`, a FIFO, a directory, a huge -/// file) exhaust memory or block the caller. -fn read_regular_file_bounded(path: &str, max_bytes: u64) -> Result { +/// The file is opened nonblocking on Unix so a FIFO with no writer cannot hang +/// the caller. The size is checked both before and during the read so a file +/// that grows after it is opened cannot bypass the bound. +/// +/// This is a blocking read. Async callers should run it with +/// `tokio::task::spawn_blocking`. +/// +/// # Errors +/// +/// Returns the operation that failed, or a dedicated error when the path is +/// not a regular file or exceeds `max_bytes`. +pub fn read_regular_file_bounded(path: &Path, max_bytes: u64) -> Result, BoundedReadError> { use std::io::Read as _; // Windows rejects opening a directory before a file handle is available, @@ -544,9 +552,9 @@ fn read_regular_file_bounded(path: &str, max_bytes: u64) -> Result max_bytes { return Err(BoundedReadError::TooLarge); @@ -554,6 +562,13 @@ fn read_regular_file_bounded(path: &str, max_bytes: u64) -> Result Result { + let bytes = read_regular_file_bounded(path, max_bytes)?; + String::from_utf8(bytes).map_err(|error| { + BoundedReadError::Read(std::io::Error::new(std::io::ErrorKind::InvalidData, error)) + }) +} + /// Operator-supplied corporate upstream-proxy settings, as a borrowed view. /// /// Compute drivers store these keys under their own diff --git a/crates/openshell-server/src/auth/oidc.rs b/crates/openshell-server/src/auth/oidc.rs index 002385de95..83f6f45b49 100644 --- a/crates/openshell-server/src/auth/oidc.rs +++ b/crates/openshell-server/src/auth/oidc.rs @@ -16,6 +16,7 @@ use super::principal::{Principal, UserPrincipal}; use async_trait::async_trait; use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; use openshell_core::OidcConfig; +use openshell_core::driver_utils::{BoundedReadError, read_regular_file_bounded}; use reqwest::Client; use serde::Deserialize; use std::collections::{HashMap, HashSet}; @@ -58,6 +59,10 @@ const KID_MISS_REFRESH_COOLDOWN: Duration = Duration::from_secs(1); const OIDC_DISCOVERY_MAX_BYTES: usize = 64 * 1024; const JWKS_MAX_BYTES: usize = 1024 * 1024; +/// Hard upper bound for an operator-supplied OIDC CA bundle. This matches the +/// Kubernetes `ConfigMap` size limit used to supply the bundle in Helm installs. +const OIDC_CA_BUNDLE_MAX_BYTES: u64 = 1024 * 1024; + /// Cached JWKS key set fetched from the OIDC issuer. /// /// A `refresh_mutex` ensures that only one refresh runs at a time, @@ -576,9 +581,31 @@ impl JwksCache { /// initial key set. pub async fn new(config: &OidcConfig) -> Result { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); - let http = Client::builder() + let mut http_builder = Client::builder() + // A configured issuer CA augments these roots; it must not replace + // trust for unrelated public HTTPS endpoints used by the gateway. + .tls_built_in_root_certs(true) .timeout(Duration::from_secs(10)) - .redirect(reqwest::redirect::Policy::none()) + .redirect(reqwest::redirect::Policy::none()); + if let Some(ca_bundle) = config.ca_bundle.as_deref() { + let pem = read_oidc_ca_bundle(ca_bundle).await?; + let certificates = reqwest::Certificate::from_pem_bundle(&pem).map_err(|error| { + format!( + "failed to parse OIDC CA bundle '{}': {error}", + ca_bundle.display() + ) + })?; + if certificates.is_empty() { + return Err(format!( + "OIDC CA bundle '{}' contains no certificates", + ca_bundle.display() + )); + } + for certificate in certificates { + http_builder = http_builder.add_root_certificate(certificate); + } + } + let http = http_builder .build() .map_err(|e| format!("failed to create HTTP client: {e}"))?; @@ -868,6 +895,30 @@ impl JwksCache { } } +async fn read_oidc_ca_bundle(path: &std::path::Path) -> Result, String> { + let path = path.to_path_buf(); + let display_path = path.display().to_string(); + let task_display_path = display_path.clone(); + tokio::task::spawn_blocking(move || { + read_regular_file_bounded(&path, OIDC_CA_BUNDLE_MAX_BYTES).map_err(|error| match error { + BoundedReadError::Open(error) + | BoundedReadError::Stat(error) + | BoundedReadError::Read(error) => { + format!("failed to read OIDC CA bundle '{task_display_path}': {error}") + } + BoundedReadError::NotRegular => { + format!("OIDC CA bundle '{task_display_path}' is not a regular file") + } + BoundedReadError::TooLarge => format!( + "OIDC CA bundle '{task_display_path}' exceeds the \ + {OIDC_CA_BUNDLE_MAX_BYTES}-byte limit" + ), + }) + }) + .await + .map_err(|error| format!("failed to read OIDC CA bundle '{display_path}': {error}"))? +} + /// Authenticator that validates `Authorization: Bearer ` headers against /// the configured OIDC issuer. /// @@ -911,6 +962,7 @@ mod tests { fn transport_test_config(issuer: impl Into) -> OidcConfig { OidcConfig { issuer: issuer.into(), + ca_bundle: None, dangerously_allow_insecure_http: false, jwks_allowed_origins: Vec::new(), audience: "test-audience".to_string(), @@ -934,6 +986,99 @@ mod tests { ); } + #[tokio::test] + async fn oidc_rejects_an_empty_ca_bundle() { + let ca_bundle = tempfile::NamedTempFile::new().unwrap(); + let mut config = transport_test_config("https://issuer.example.com"); + config.ca_bundle = Some(ca_bundle.path().to_path_buf()); + + let error = JwksCache::new(&config) + .await + .expect_err("an empty CA bundle must fail before discovery"); + + assert!( + error.contains("contains no certificates"), + "unexpected error: {error}" + ); + } + + #[tokio::test] + async fn oidc_rejects_an_oversized_ca_bundle() { + let ca_bundle = tempfile::NamedTempFile::new().unwrap(); + std::fs::write( + ca_bundle.path(), + vec![b'x'; usize::try_from(OIDC_CA_BUNDLE_MAX_BYTES + 1).unwrap()], + ) + .unwrap(); + let mut config = transport_test_config("https://issuer.example.com"); + config.ca_bundle = Some(ca_bundle.path().to_path_buf()); + + let error = JwksCache::new(&config) + .await + .expect_err("an oversized CA bundle must fail before discovery"); + + assert!( + error.contains("OIDC CA bundle"), + "unexpected error: {error}" + ); + assert!(error.contains("exceeds"), "unexpected error: {error}"); + assert!( + error.contains(&OIDC_CA_BUNDLE_MAX_BYTES.to_string()), + "unexpected error: {error}" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn oidc_rejects_a_fifo_ca_bundle_without_blocking() { + let dir = tempfile::tempdir().unwrap(); + let fifo = dir.path().join("oidc-ca-fifo"); + nix::unistd::mkfifo(&fifo, nix::sys::stat::Mode::S_IRUSR).unwrap(); + let mut config = transport_test_config("https://issuer.example.com"); + config.ca_bundle = Some(fifo); + + let start = Instant::now(); + let error = JwksCache::new(&config) + .await + .expect_err("a FIFO CA bundle must fail before discovery"); + + assert!( + error.contains("OIDC CA bundle"), + "unexpected error: {error}" + ); + assert!( + error.contains("not a regular file"), + "unexpected error: {error}" + ); + assert!( + start.elapsed() < Duration::from_secs(5), + "reading a FIFO must not block" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn oidc_rejects_a_device_ca_bundle() { + if !std::path::Path::new("/dev/zero").exists() { + return; + } + let mut config = transport_test_config("https://issuer.example.com"); + config.ca_bundle = Some("/dev/zero".into()); + + let error = JwksCache::new(&config) + .await + .expect_err("a device CA bundle must fail before discovery"); + + assert!( + error.contains("OIDC CA bundle"), + "unexpected error: {error}" + ); + assert!( + error.contains("not a regular file"), + "unexpected error: {error}" + ); + } + #[tokio::test] async fn oidc_rejects_non_loopback_http_even_when_acknowledged() { let mut config = transport_test_config("http://192.0.2.1/issuer"); @@ -1099,13 +1244,11 @@ mod tests { // Initialization succeeds after the client receives the rotated trust // anchor, and both discovery and JWKS stay on the authenticated channel. - let client = Client::builder() - .add_root_certificate(reqwest::Certificate::from_pem(ca_cert.pem().as_bytes()).unwrap()) - .redirect(reqwest::redirect::Policy::none()) - .build() - .unwrap(); - let config = transport_test_config(issuer.clone()); - let cache = JwksCache::new_with_client(&config, client) + let mut ca_bundle = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut ca_bundle, ca_cert.pem().as_bytes()).unwrap(); + let mut config = transport_test_config(issuer.clone()); + config.ca_bundle = Some(ca_bundle.path().to_path_buf()); + let cache = JwksCache::new(&config) .await .expect("TLS discovery and JWKS should accept the rotated CA"); @@ -1424,6 +1567,7 @@ mod tests { JwksCache::new(&OidcConfig { issuer, + ca_bundle: None, dangerously_allow_insecure_http: true, jwks_allowed_origins: Vec::new(), audience: TEST_AUDIENCE.to_owned(), @@ -1763,6 +1907,7 @@ mod tests { fn test_oidc_config(issuer: &str) -> OidcConfig { OidcConfig { issuer: issuer.to_string(), + ca_bundle: None, dangerously_allow_insecure_http: true, jwks_allowed_origins: Vec::new(), audience: "test-audience".to_string(), diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 9000ab1324..46d01d3aec 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -162,6 +162,12 @@ struct RunArgs { #[arg(long, env = "OPENSHELL_OIDC_ISSUER")] oidc_issuer: Option, + /// Path to a PEM CA bundle for an OIDC issuer signed by a private CA. + /// Must be a regular file no larger than 1 MiB. The certificates augment + /// platform trust roots for OIDC requests only. + #[arg(long, env = "OPENSHELL_OIDC_CA_BUNDLE")] + oidc_ca_bundle: Option, + /// Development only: permit OIDC metadata and JWKS over HTTP when the /// endpoint uses a numeric loopback address. #[arg( @@ -563,6 +569,7 @@ fn prepare_server_config_with_drivers( if let Some(issuer) = args.oidc_issuer.clone() { config = config.with_oidc(openshell_core::OidcConfig { issuer, + ca_bundle: args.oidc_ca_bundle.clone(), dangerously_allow_insecure_http: args.oidc_dangerously_allow_insecure_http, jwks_allowed_origins: args.oidc_jwks_allowed_origins.clone(), audience: args.oidc_audience.clone(), @@ -1153,6 +1160,9 @@ fn merge_file_into_args(args: &mut RunArgs, file: &GatewayFileSection, matches: if args.oidc_issuer.is_none() && arg_defaulted(matches, "oidc_issuer") { args.oidc_issuer = Some(oidc.issuer.clone()); } + if args.oidc_ca_bundle.is_none() && arg_defaulted(matches, "oidc_ca_bundle") { + args.oidc_ca_bundle.clone_from(&oidc.ca_bundle); + } if arg_defaulted(matches, "oidc_dangerously_allow_insecure_http") { args.oidc_dangerously_allow_insecure_http = oidc.dangerously_allow_insecure_http; } @@ -2938,6 +2948,7 @@ compute_driver = "podman" let _g2 = EnvVarGuard::remove("OPENSHELL_OIDC_AUDIENCE"); let _g3 = EnvVarGuard::remove("OPENSHELL_OIDC_DANGEROUSLY_ALLOW_INSECURE_HTTP"); let _g4 = EnvVarGuard::remove("OPENSHELL_OIDC_JWKS_ALLOWED_ORIGINS"); + let _g5 = EnvVarGuard::remove("OPENSHELL_OIDC_CA_BUNDLE"); let (mut args, matches) = parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]); @@ -2945,6 +2956,7 @@ compute_driver = "podman" r#" [openshell.gateway.oidc] issuer = "https://idp.example.com" +ca_bundle = "/etc/openshell/oidc-ca.pem" audience = "openshell-cli" dangerously_allow_insecure_http = true jwks_allowed_origins = ["https://keys.example.com"] @@ -2953,6 +2965,10 @@ jwks_allowed_origins = ["https://keys.example.com"] merge_file_into_args(&mut args, &file.openshell.gateway, &matches); assert_eq!(args.oidc_issuer.as_deref(), Some("https://idp.example.com")); + assert_eq!( + args.oidc_ca_bundle.as_deref(), + Some(std::path::Path::new("/etc/openshell/oidc-ca.pem")) + ); assert_eq!(args.oidc_audience, "openshell-cli"); assert!(args.oidc_dangerously_allow_insecure_http); assert_eq!( diff --git a/crates/openshell-server/src/grpc/mutation_replay/tests.rs b/crates/openshell-server/src/grpc/mutation_replay/tests.rs index 28dfc688f2..0f65163b69 100644 --- a/crates/openshell-server/src/grpc/mutation_replay/tests.rs +++ b/crates/openshell-server/src/grpc/mutation_replay/tests.rs @@ -937,6 +937,7 @@ async fn unrelated_oidc_configuration_does_not_reset_mtls_admission_identity() { state.store.put_message(&template).await.unwrap(); Arc::get_mut(&mut state).unwrap().config.oidc = Some(openshell_core::OidcConfig { issuer: "https://new.example.com".into(), + ca_bundle: None, dangerously_allow_insecure_http: false, jwks_allowed_origins: Vec::new(), audience: "openshell-cli".into(), diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index df3ea173ad..7d7a519665 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -437,7 +437,7 @@ discovery endpoint or its TLS CA. | server.ocsfLog.schemaVersion | string | `""` | Optional OCSF downgrade target. Empty emits native OCSF 1.8.0. Supported values: "1.1", "1.3". | | 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.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.caConfigMapName | string | `""` | Name of a ConfigMap containing a CA certificate bundle (key: ca.crt) for verifying the OIDC issuer's TLS certificate. These certificates augment platform trust roots for OIDC requests only. Required when the issuer uses a non-public CA (e.g. OpenShift ingress, private PKI). | | server.oidc.dangerouslyAllowInsecureHttp | bool | `false` | Development only: permit cleartext OIDC requests to numeric loopback addresses. This never permits HTTP to hostnames or non-loopback addresses. | | server.oidc.issuer | string | `""` | OIDC issuer URL (e.g. https://keycloak.example.com/realms/openshell). | | server.oidc.jwksAllowedOrigins | list | `[]` | Additional trusted HTTPS origins allowed to serve JWKS. The issuer origin is always allowed. Entries must not include a path or query. | diff --git a/deploy/helm/openshell/templates/_gateway-workload.tpl b/deploy/helm/openshell/templates/_gateway-workload.tpl index 919d3a4313..afca90e1c0 100644 --- a/deploy/helm/openshell/templates/_gateway-workload.tpl +++ b/deploy/helm/openshell/templates/_gateway-workload.tpl @@ -112,16 +112,7 @@ spec: {{- end }} # Most gateway settings live in the ConfigMap-backed TOML file # mounted at /etc/openshell/gateway.toml. Secret-bearing settings use - # env vars that the TOML references by name. Some process-level - # settings consumed by libraries outside gateway code also remain here. - {{- if and .Values.server.oidc.issuer .Values.server.oidc.caConfigMapName }} - # OIDC issuer custom-CA: rustls/reqwest read SSL_CERT_FILE for - # outbound TLS verification. This is a process-level env var - # consumed by the TLS stack itself, not by gateway code, so it - # cannot be represented in the gateway TOML schema. - - name: SSL_CERT_FILE - value: /etc/openshell-tls/oidc-ca/ca.crt - {{- end }} + # env vars that the TOML references by name. - name: OPENSHELL_TELEMETRY_ENABLED value: {{ .Values.server.telemetryEnabled | quote }} {{- if .Values.server.providerTokenGrants.spiffe.enabled }} diff --git a/deploy/helm/openshell/templates/gateway-config.yaml b/deploy/helm/openshell/templates/gateway-config.yaml index a3e0210a35..3f22a17619 100644 --- a/deploy/helm/openshell/templates/gateway-config.yaml +++ b/deploy/helm/openshell/templates/gateway-config.yaml @@ -156,6 +156,9 @@ data: [openshell.gateway.oidc] issuer = {{ .Values.server.oidc.issuer | quote }} + {{- if .Values.server.oidc.caConfigMapName }} + ca_bundle = "/etc/openshell-tls/oidc-ca/ca.crt" + {{- end }} dangerously_allow_insecure_http = {{ .Values.server.oidc.dangerouslyAllowInsecureHttp }} jwks_allowed_origins = {{ .Values.server.oidc.jwksAllowedOrigins | toJson }} audience = {{ .Values.server.oidc.audience | quote }} diff --git a/deploy/helm/openshell/tests/gateway_config_test.yaml b/deploy/helm/openshell/tests/gateway_config_test.yaml index 16ccfaee6b..6957f7abd7 100644 --- a/deploy/helm/openshell/tests/gateway_config_test.yaml +++ b/deploy/helm/openshell/tests/gateway_config_test.yaml @@ -294,6 +294,25 @@ tests: path: data["gateway.toml"] pattern: '(?m)^jwks_allowed_origins\s*=\s*\["https://keys.example.com"\]$' + - it: scopes the OIDC CA bundle to the OIDC client + template: templates/gateway-config.yaml + set: + server.oidc.issuer: https://issuer.example.com + server.oidc.caConfigMapName: openshell-oidc-ca + asserts: + - matchRegex: + path: data["gateway.toml"] + pattern: '(?ms)\[openshell\.gateway\.oidc\].*?ca_bundle\s*=\s*"/etc/openshell-tls/oidc-ca/ca\.crt"' + + - it: omits the OIDC CA bundle path when no ConfigMap is configured + template: templates/gateway-config.yaml + set: + server.oidc.issuer: https://issuer.example.com + asserts: + - notMatchRegex: + path: data["gateway.toml"] + pattern: '(?m)^ca_bundle\s*=' + - it: treats a null OTLP map as disabled template: templates/gateway-config.yaml set: @@ -482,6 +501,11 @@ tests: - equal: path: spec.template.spec.volumes[3].configMap.name value: openshell-oidc-ca + - notContains: + path: spec.template.spec.containers[0].env + content: + name: SSL_CERT_FILE + any: true # Regression for the P1 bug Drew flagged: grpc_endpoint MUST live in the # Kubernetes driver table, not in [openshell.gateway]. The gateway-side diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index 4bf21b88a8..ac3597e027 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -529,7 +529,8 @@ server: # -- Dot-separated path to the scopes array in the JWT claims. scopesClaim: "" # -- Name of a ConfigMap containing a CA certificate bundle (key: ca.crt) - # for verifying the OIDC issuer's TLS certificate. Required when the + # for verifying the OIDC issuer's TLS certificate. These certificates + # augment platform trust roots for OIDC requests only. Required when the # issuer uses a non-public CA (e.g. OpenShift ingress, private PKI). caConfigMapName: "" diff --git a/docs/how-it-works/gateways/configuration.mdx b/docs/how-it-works/gateways/configuration.mdx index 1308871cea..8e7412c500 100644 --- a/docs/how-it-works/gateways/configuration.mdx +++ b/docs/how-it-works/gateways/configuration.mdx @@ -218,6 +218,7 @@ service_name = "openshell-gateway" [openshell.gateway.oidc] issuer = "https://idp.example.com/realms/openshell" +ca_bundle = "/etc/openshell/oidc-ca.pem" # Optional private issuer CA. audience = "openshell-cli" jwks_ttl_secs = 3600 # Must be greater than zero. jwks_allowed_origins = ["https://keys.example.com"] # Default: issuer origin only. @@ -268,6 +269,8 @@ Local Docker, Podman, and VM gateways can also set `[openshell.gateway.mtls_auth The client-certificate handshake policy is derived and has no `require_client_auth` TOML field. This preserves bearer-only OIDC clients and prevents a file setting from silently weakening CA-only gateways. +`[openshell.gateway.oidc] ca_bundle` points to a certificate-only PEM bundle for an issuer signed by a private CA. The path must resolve to a regular file no larger than 1 MiB (1,048,576 bytes). The bundle augments platform trust roots for OIDC discovery and JWKS requests only; it does not replace native CA discovery or change trust for provider refresh, token exchange, telemetry, Vault, or other gateway HTTPS clients. Set the same value with `--oidc-ca-bundle` or `OPENSHELL_OIDC_CA_BUNDLE`. For Helm deployments, `server.oidc.caConfigMapName` mounts the ConfigMap's `ca.crt` key and renders this path automatically. + `[openshell.gateway.tls]` supports optional SNI-based dual-certificate mode for deployments that need separate internal and external server certificates. Set `external_cert_path` and `external_key_path` to point at the external (e.g. ACME/publicly-trusted) certificate and key. List the hostnames that should be served with the external certificate in `external_server_names`. Connections whose TLS SNI hostname matches one of those names receive the external certificate; all other connections (including those with no SNI) receive the primary internal certificate from `cert_path`/`key_path`. Both fields must be set together — providing only one is a configuration error. On Kubernetes with the Helm chart, the external certificate is managed automatically when `certManager.serverIssuerRef.name` is set; the chart populates these fields from the cert-manager-issued external server certificate. `[openshell.gateway] policy_validation_failure_mode` controls what sandbox supervisors do when a complete candidate policy fails runtime validation. The default, `fail_closed`, deactivates the previous network policy, closes relays pinned to it, and denies new egress until a valid generation loads. `retain_last_valid` leaves the previous valid generation active. Both modes reject the candidate atomically; startup keeps the workload unstarted until the effective policy and matching provider configuration pass admission. A rejected startup exposes `ConfigurationInvalid` and remains available for policy/provider repair in either mode. Gateway mutation paths that can preflight a known effective scope reject invalid candidates before persistence and leave the active policy unchanged regardless of this setting. Changing the value requires restarting the gateway so it can reload `gateway.toml` and distribute the new posture to sandbox supervisors. diff --git a/docs/kubernetes/access-control.mdx b/docs/kubernetes/access-control.mdx index 5b295e3211..90e9edc158 100644 --- a/docs/kubernetes/access-control.mdx +++ b/docs/kubernetes/access-control.mdx @@ -54,7 +54,7 @@ The `audience` value must match the client ID configured in your identity provid | `server.oidc.issuer` | `""` | OIDC issuer URL. Empty disables OIDC. | | `server.oidc.dangerouslyAllowInsecureHttp` | `false` | Development-only acknowledgement for numeric-loopback HTTP. It does not allow cluster-service or remote HTTP issuers. | | `server.oidc.jwksAllowedOrigins` | `[]` | Additional trusted HTTPS origins allowed to serve JWKS. | -| `server.oidc.caConfigMapName` | `""` | ConfigMap containing the private issuer CA in `ca.crt`. | +| `server.oidc.caConfigMapName` | `""` | ConfigMap containing the private issuer CA in `ca.crt`; it augments platform roots for OIDC requests only. | | `server.oidc.audience` | `openshell-cli` | Expected `aud` claim in the JWT. | | `server.oidc.jwksTtl` | `3600` | JWKS key cache TTL in seconds. Must be greater than zero. | | `server.oidc.rolesClaim` | `""` | Dot-separated path to the roles array in JWT claims. | @@ -65,7 +65,9 @@ The `audience` value must match the client ID configured in your identity provid The issuer must use HTTPS. The gateway rejects discovery and JWKS redirects, limits response sizes, requires a JSON media type, and rejects a `jwks_uri` on a different origin unless that origin appears in `jwksAllowedOrigins`. Use -`server.oidc.caConfigMapName` for issuers signed by a private CA. +`server.oidc.caConfigMapName` for issuers signed by a private CA. The mounted +CA does not replace the gateway's platform roots, so unrelated HTTPS clients +such as provider token refresh continue to trust public services. ### Auth-only mode vs. RBAC mode diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 6382428946..e05aebcc6e 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -955,7 +955,7 @@ credential failures. | Vault credential driver returns HTTP 403 / `Vault Kubernetes auth denied the configured role` on provider create | Vault's `auth/kubernetes` method or the gateway login role is not provisioned, or the role is not bound to the gateway service account and namespace | In Vault: `bao auth enable kubernetes` and `bao write auth/kubernetes/config kubernetes_host=... kubernetes_ca_cert=@...`; ensure the login role's `bound_service_account_names`/`bound_service_account_namespaces` match the gateway SA and namespace and its policy grants the credential paths | | CLI TLS error | Local mTLS bundle does not match server cert/CA | Check `~/.config/openshell/gateways//mtls/` | | Edge or OIDC gateway returns `Unauthenticated` | Stored login expired, audience/scopes mismatch, or gateway auth configuration changed | `openshell gateway info`, `openshell gateway login `, gateway auth logs | -| Gateway exits during OIDC initialization | Issuer is not HTTPS, discovery redirected, metadata used a non-JSON media type or exceeded its size limit, or `jwks_uri` uses an untrusted origin | Use an HTTPS issuer; mount a private CA with `server.oidc.caConfigMapName`; keep JWKS on the issuer origin or explicitly add its HTTPS origin to `server.oidc.jwksAllowedOrigins`. Numeric-loopback HTTP is development-only and also requires `server.oidc.dangerouslyAllowInsecureHttp=true` | +| Gateway exits during OIDC initialization | Issuer is not HTTPS, discovery redirected, metadata used a non-JSON media type or exceeded its size limit, the configured `ca_bundle` is missing, invalid, non-regular, or larger than 1 MiB, or `jwks_uri` uses an untrusted origin | Use an HTTPS issuer; mount a private CA with `server.oidc.caConfigMapName` and confirm `[openshell.gateway.oidc] ca_bundle` names the mounted regular-file `ca.crt` and is no larger than 1 MiB; keep JWKS on the issuer origin or explicitly add its HTTPS origin to `server.oidc.jwksAllowedOrigins`. The issuer CA augments platform roots for OIDC only. Numeric-loopback HTTP is development-only and also requires `server.oidc.dangerouslyAllowInsecureHttp=true` | | Gateway fails before serving health after enabling an interceptor | Interceptor endpoint unavailable or manifest/binding validation failed | Gateway and interceptor logs; interceptor socket; `binding_policy`, phases, and failure policy | | Authenticated interceptor or middleware rejects gateway calls | Private CA or hostname mismatch, expected audience or issuer mismatch, stale/unknown `kid`, or malformed extension token | `tls_ca_cert_path`, registration `audience`, service verifier config and logs; fetch well-known metadata only through the already-trusted gateway TLS endpoint | | Provider profiles disappear after enabling an interceptor catalog | `provider_profile_sources` selected only an authoritative interceptor or returned invalid/duplicate IDs | Inspect source list and interceptor `Describe`/catalog logs; include `user` when composition with imported profiles is intended |