Skip to content

Commit bf0663e

Browse files
committed
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 <alangou@nvidia.com>
1 parent 1dc5985 commit bf0663e

17 files changed

Lines changed: 272 additions & 31 deletions

File tree

architecture/gateway.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ gateway maps the verified certificate subject to a user principal. Kubernetes
2929
deployments use mTLS for transport only and require OIDC or a trusted access
3030
proxy for user authentication unless the explicit unsafe local-development
3131
`allow_unauthenticated_users` switch is enabled.
32+
OIDC deployments normally enforce RBAC roles for user and admin APIs. Shared
33+
gateways reject OIDC authentication-only mode unless
34+
`allow_oidc_auth_only` is set explicitly, and authenticated gRPC methods fail
35+
closed when no user, sandbox, mTLS, or explicit local-dev principal can be
36+
derived.
3237
When that service port is bound to loopback, the listener can also accept
3338
plaintext HTTP on the same port for sandbox service subdomains only. That local
3439
browser path is enabled by default and disabled with

crates/openshell-core/src/config.rs

Lines changed: 133 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,12 @@ pub struct GatewayAuthConfig {
479479
/// gateway-minted sandbox JWTs.
480480
#[serde(default)]
481481
pub allow_unauthenticated_users: bool,
482+
483+
/// When true, an OIDC issuer may authenticate users without requiring
484+
/// configured RBAC roles. In that mode any valid token from the issuer can
485+
/// call user and admin APIs, so shared deployments must opt in explicitly.
486+
#[serde(default)]
487+
pub allow_oidc_auth_only: bool,
482488
}
483489

484490
const fn default_jwks_ttl_secs() -> u64 {
@@ -643,6 +649,66 @@ impl Config {
643649
self.service_routing.enable_loopback_service_http = enabled;
644650
self
645651
}
652+
653+
/// Validate auth settings that depend on the deployment posture.
654+
///
655+
/// Local loopback gateways can rely on developer-oriented auth shortcuts,
656+
/// but Kubernetes and externally-bound gateways must fail closed unless
657+
/// the operator chose an explicit weak mode.
658+
pub fn validate_gateway_auth_posture(&self) -> Result<(), String> {
659+
let shared = self.is_shared_gateway_deployment();
660+
661+
if let Some(oidc) = &self.oidc {
662+
let admin_set = !oidc.admin_role.is_empty();
663+
let user_set = !oidc.user_role.is_empty();
664+
665+
if admin_set != user_set {
666+
return Err(format!(
667+
"OIDC RBAC misconfiguration: admin_role={:?}, user_role={:?}. \
668+
Either set both roles (RBAC mode) or leave both empty (authentication-only mode).",
669+
oidc.admin_role, oidc.user_role,
670+
));
671+
}
672+
673+
if shared && !admin_set && !self.auth.allow_oidc_auth_only {
674+
return Err(
675+
"OIDC authentication-only mode is disabled for shared gateway deployments; \
676+
configure admin_role and user_role for RBAC, or set \
677+
auth.allow_oidc_auth_only=true to explicitly accept that any valid issuer token is authorized"
678+
.to_string(),
679+
);
680+
}
681+
}
682+
683+
let has_authenticator = self.oidc.is_some() || self.gateway_jwt.is_some();
684+
if shared
685+
&& !has_authenticator
686+
&& !self.mtls_auth.enabled
687+
&& !self.auth.allow_unauthenticated_users
688+
{
689+
return Err(
690+
"shared gateway deployments require an explicit auth path; configure OIDC, \
691+
mTLS user auth, gateway_jwt sandbox auth, or set auth.allow_unauthenticated_users=true \
692+
only behind a trusted local-dev/fronting-proxy boundary"
693+
.to_string(),
694+
);
695+
}
696+
697+
Ok(())
698+
}
699+
700+
/// Whether this gateway serves a shared or remotely reachable gRPC API.
701+
#[must_use]
702+
pub fn is_shared_gateway_deployment(&self) -> bool {
703+
self.compute_drivers
704+
.iter()
705+
.any(|driver| matches!(driver, ComputeDriverKind::Kubernetes))
706+
|| !self.bind_address.ip().is_loopback()
707+
|| self
708+
.extra_bind_addresses
709+
.iter()
710+
.any(|addr| !addr.ip().is_loopback())
711+
}
646712
}
647713

648714
impl Default for ServiceRoutingConfig {
@@ -727,9 +793,9 @@ mod tests {
727793
#[cfg(unix)]
728794
use super::is_reachable_unix_socket;
729795
use super::{
730-
ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayJwtConfig, detect_driver,
731-
docker_host_unix_socket_path, is_unix_socket, podman_socket_candidates_from_env,
732-
podman_socket_responds,
796+
ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayJwtConfig, OidcConfig,
797+
detect_driver, docker_host_unix_socket_path, is_unix_socket,
798+
podman_socket_candidates_from_env, podman_socket_responds,
733799
};
734800
#[cfg(unix)]
735801
use std::io::{Read as _, Write as _};
@@ -782,6 +848,70 @@ mod tests {
782848
assert!(!cfg.auth.allow_unauthenticated_users);
783849
}
784850

851+
fn oidc_config(admin_role: &str, user_role: &str) -> OidcConfig {
852+
OidcConfig {
853+
issuer: "https://issuer.example.com".to_string(),
854+
audience: "openshell-cli".to_string(),
855+
jwks_ttl_secs: 3600,
856+
roles_claim: "realm_access.roles".to_string(),
857+
admin_role: admin_role.to_string(),
858+
user_role: user_role.to_string(),
859+
scopes_claim: String::new(),
860+
}
861+
}
862+
863+
#[test]
864+
fn gateway_auth_posture_allows_loopback_oidc_auth_only_without_override() {
865+
let cfg = Config::new(None).with_oidc(oidc_config("", ""));
866+
867+
assert!(cfg.validate_gateway_auth_posture().is_ok());
868+
}
869+
870+
#[test]
871+
fn gateway_auth_posture_rejects_shared_oidc_auth_only_without_override() {
872+
let cfg = Config::new(None)
873+
.with_compute_drivers([ComputeDriverKind::Kubernetes])
874+
.with_oidc(oidc_config("", ""));
875+
876+
let err = cfg.validate_gateway_auth_posture().unwrap_err();
877+
assert!(err.contains("OIDC authentication-only mode"));
878+
}
879+
880+
#[test]
881+
fn gateway_auth_posture_allows_shared_oidc_auth_only_with_override() {
882+
let mut cfg = Config::new(None)
883+
.with_compute_drivers([ComputeDriverKind::Kubernetes])
884+
.with_oidc(oidc_config("", ""));
885+
cfg.auth.allow_oidc_auth_only = true;
886+
887+
assert!(cfg.validate_gateway_auth_posture().is_ok());
888+
}
889+
890+
#[test]
891+
fn gateway_auth_posture_allows_shared_oidc_rbac() {
892+
let cfg = Config::new(None)
893+
.with_compute_drivers([ComputeDriverKind::Kubernetes])
894+
.with_oidc(oidc_config("openshell-admin", "openshell-user"));
895+
896+
assert!(cfg.validate_gateway_auth_posture().is_ok());
897+
}
898+
899+
#[test]
900+
fn gateway_auth_posture_rejects_partial_oidc_roles() {
901+
let cfg = Config::new(None).with_oidc(oidc_config("openshell-admin", ""));
902+
903+
let err = cfg.validate_gateway_auth_posture().unwrap_err();
904+
assert!(err.contains("OIDC RBAC misconfiguration"));
905+
}
906+
907+
#[test]
908+
fn gateway_auth_posture_rejects_shared_gateway_without_auth_path() {
909+
let cfg = Config::new(None).with_compute_drivers([ComputeDriverKind::Kubernetes]);
910+
911+
let err = cfg.validate_gateway_auth_posture().unwrap_err();
912+
assert!(err.contains("require an explicit auth path"));
913+
}
914+
785915
#[test]
786916
fn gateway_jwt_ttl_defaults_to_non_expiring() {
787917
let cfg: GatewayJwtConfig = serde_json::from_value(serde_json::json!({

crates/openshell-server/src/cli.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -423,8 +423,7 @@ async fn run_from_args(mut args: RunArgs, matches: ArgMatches) -> Result<()> {
423423
&& config.gateway_jwt.is_none()
424424
{
425425
warn!(
426-
"Neither mTLS user auth nor OIDC nor sandbox JWT auth is configured — \
427-
the gateway has no authentication mechanism"
426+
"No gateway authentication path is configured; non-loopback or shared deployments will fail startup"
428427
);
429428
}
430429

crates/openshell-server/src/config_file.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,11 +385,13 @@ grpc_endpoint = "https://openshell-gateway.agents.svc:8080"
385385
let toml = r"
386386
[openshell.gateway.auth]
387387
allow_unauthenticated_users = true
388+
allow_oidc_auth_only = true
388389
";
389390
let tmp = write_tmp(toml);
390391
let file = load(tmp.path()).expect("valid auth config parses");
391392
let auth = file.openshell.gateway.auth.expect("auth config");
392393
assert!(auth.allow_unauthenticated_users);
394+
assert!(auth.allow_oidc_auth_only);
393395
}
394396

395397
#[test]

crates/openshell-server/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,9 @@ pub async fn run_server(
202202
if database_url.is_empty() {
203203
return Err(Error::config("database_url is required"));
204204
}
205+
config
206+
.validate_gateway_auth_posture()
207+
.map_err(Error::config)?;
205208

206209
let store = Arc::new(Store::connect(database_url).await?);
207210

crates/openshell-server/src/multiplex.rs

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -283,9 +283,9 @@ where
283283
/// for local single-user gateways, or to an unsafe local developer user when
284284
/// `auth.allow_unauthenticated_users` is explicitly enabled.
285285
///
286-
/// When neither OIDC nor sandbox credentials are configured (a barebones
287-
/// dev gateway), the chain is left as `None` so the router short-circuits
288-
/// to pass-through unless mTLS or local unauthenticated users are enabled.
286+
/// When neither OIDC nor sandbox credentials are configured, the chain is left
287+
/// as `None`; authenticated methods still fail closed unless mTLS or local
288+
/// unauthenticated users are enabled explicitly.
289289
fn build_authenticator_chain(state: &ServerState) -> Option<AuthenticatorChain> {
290290
let mut authenticators: Vec<Arc<dyn crate::auth::authenticator::Authenticator>> = Vec::new();
291291
if let Some(k8s) = state.k8s_sa_authenticator.clone() {
@@ -310,8 +310,8 @@ fn build_authenticator_chain(state: &ServerState) -> Option<AuthenticatorChain>
310310
/// - Strip any external `x-openshell-auth-source` marker first (so callers
311311
/// cannot spoof a sandbox identity).
312312
/// - Health probes / reflection bypass the chain entirely.
313-
/// - When no chain is configured (OIDC not configured), forward without
314-
/// authentication — preserves today's pass-through behavior.
313+
/// - When no chain is configured, authenticated methods fail closed unless
314+
/// mTLS user auth or the explicit local unauthenticated user mode applies.
315315
/// - Otherwise, run the chain. The first match produces a `Principal`.
316316
/// `Principal::User` is gated by the RBAC `AuthzPolicy`.
317317
/// `Principal::Sandbox` is gated by a supervisor-method allowlist, then
@@ -429,9 +429,9 @@ where
429429
} else if allow_unauthenticated_users {
430430
unauthenticated_dev_user_principal()
431431
} else {
432-
// No auth configured — pass through for dev /
433-
// fronting-proxy deployments.
434-
return inner.ready().await?.call(req).await;
432+
return Ok(status_response(tonic::Status::unauthenticated(
433+
"gateway authentication is not configured",
434+
)));
435435
};
436436

437437
match principal {
@@ -1117,6 +1117,21 @@ mod tests {
11171117
));
11181118
}
11191119

1120+
#[tokio::test]
1121+
async fn missing_chain_without_explicit_auth_fails_closed() {
1122+
let (recorder, seen) = PrincipalRecorder::new();
1123+
let mut router =
1124+
AuthGrpcRouter::with_peer_identity(recorder, None, None, None, false, false);
1125+
1126+
let res = router
1127+
.call(empty_request("/openshell.v1.OpenShell/ListSandboxes"))
1128+
.await
1129+
.unwrap();
1130+
1131+
assert!(seen.lock().unwrap().is_none());
1132+
assert_eq!(grpc_status(&res).as_deref(), Some("16"));
1133+
}
1134+
11201135
#[tokio::test]
11211136
async fn user_principal_lands_in_request_extensions() {
11221137
let mock = Arc::new(MockAuthenticator::returning(Ok(Some(user_principal(

deploy/helm/openshell/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ add `ci/values-spire.yaml` to the OpenShell release values files.
191191
| securityContext.runAsNonRoot | bool | `true` | Require the gateway container to run as a non-root user. |
192192
| securityContext.runAsUser | int | `1000` | UID assigned to the gateway container. |
193193
| 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. |
194+
| server.auth.allowOidcAuthOnly | bool | `false` | UNSAFE: allow OIDC authentication-only mode when adminRole and userRole are both empty. In this mode any valid token from the issuer can call user and admin APIs. Leave false for shared or production clusters. |
194195
| 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. |
195196
| server.dbUrl | string | `"sqlite:/var/openshell/openshell.db"` | Gateway database URL (used for the default SQLite backend). |
196197
| 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. |
@@ -201,14 +202,14 @@ add `ci/values-spire.yaml` to the OpenShell release values files.
201202
| server.grpcEndpoint | string | `""` | gRPC endpoint sandboxes call back into the gateway. Leave empty to derive it from the chart fullname, release namespace, service port, and disableTls flag, for example https://openshell.openshell.svc.cluster.local:8080. Override only when sandboxes must reach the gateway via a different hostname (e.g. an external ingress or a host alias). |
202203
| 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. |
203204
| server.logLevel | string | `"info"` | Gateway log level. |
204-
| 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. |
205+
| 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. |
205206
| 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. |
206207
| 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). |
207208
| server.oidc.issuer | string | `""` | OIDC issuer URL (e.g. https://keycloak.example.com/realms/openshell). |
208209
| server.oidc.jwksTtl | int | `3600` | JWKS key cache TTL in seconds. |
209210
| server.oidc.rolesClaim | string | `""` | Dot-separated path to the roles array in the JWT claims. Keycloak: "realm_access.roles", Entra ID: "roles", Okta: "groups". |
210211
| server.oidc.scopesClaim | string | `""` | Dot-separated path to the scopes array in the JWT claims. |
211-
| server.oidc.userRole | string | `""` | Role name for standard user access. |
212+
| server.oidc.userRole | string | `""` | Role name for standard user access. Set with adminRole for RBAC mode. |
212213
| server.providerTokenGrants.spiffe.enabled | bool | `false` | Mount the SPIFFE Workload API socket into sandbox pods for dynamic provider token grants. |
213214
| server.providerTokenGrants.spiffe.workloadApiSocketPath | string | `"/spiffe-workload-api/spire-agent.sock"` | Path to the SPIFFE Workload API socket mounted into sandbox pods. |
214215
| server.sandboxImage | string | `"ghcr.io/nvidia/openshell-community/sandboxes/base:latest"` | Default sandbox image used when requests do not specify one. |

deploy/helm/openshell/ci/values-keycloak.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ server:
3131
jwksTtl: 60
3232
# Keycloak puts realm roles at realm_access.roles in the JWT.
3333
rolesClaim: "realm_access.roles"
34-
# Leave both empty for authentication-only mode (any valid token is accepted).
34+
# RBAC mode: both roles must be set. Leave both empty only with
35+
# server.auth.allowOidcAuthOnly=true for authentication-only mode.
3536
adminRole: "openshell-admin"
3637
userRole: "openshell-user"

deploy/helm/openshell/templates/_helpers.tpl

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,20 @@ Validate chart values that Helm would otherwise accept silently.
163163
{{- $workloadKind := include "openshell.workloadKind" . -}}
164164
{{- $workload := .Values.workload | default dict -}}
165165
{{- $replicaCount := int (default 1 .Values.replicaCount) -}}
166+
{{- $oidcIssuer := default "" .Values.server.oidc.issuer -}}
167+
{{- $oidcAdminRole := default "" .Values.server.oidc.adminRole -}}
168+
{{- $oidcUserRole := default "" .Values.server.oidc.userRole -}}
169+
{{- $oidcAdminRoleSet := ne $oidcAdminRole "" -}}
170+
{{- $oidcUserRoleSet := ne $oidcUserRole "" -}}
166171
{{- if and (hasKey .Values "postgres") (kindIs "map" .Values.postgres) (hasKey .Values.postgres "enabled") -}}
167172
{{- 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." -}}
168173
{{- end -}}
174+
{{- if and $oidcIssuer (ne $oidcAdminRoleSet $oidcUserRoleSet) -}}
175+
{{- fail "server.oidc.adminRole and server.oidc.userRole must either both be set for OIDC RBAC or both be empty for authentication-only mode." -}}
176+
{{- end -}}
177+
{{- if and $oidcIssuer (not $oidcAdminRoleSet) (not .Values.server.auth.allowOidcAuthOnly) -}}
178+
{{- fail "OIDC authentication-only mode authorizes any valid issuer token. Set server.oidc.adminRole and server.oidc.userRole for RBAC, or set server.auth.allowOidcAuthOnly=true to opt in explicitly." -}}
179+
{{- end -}}
169180
{{- if not (or (eq $workloadKind "statefulset") (eq $workloadKind "deployment")) -}}
170181
{{- fail "workload.kind must be one of: statefulset, deployment." -}}
171182
{{- end -}}

deploy/helm/openshell/templates/gateway-config.yaml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,16 @@ data:
6565
client_ca_path = "/etc/openshell-tls/client-ca/ca.crt"
6666
{{- end }}
6767
68-
{{- if .Values.server.auth.allowUnauthenticatedUsers }}
68+
{{- if or .Values.server.auth.allowUnauthenticatedUsers .Values.server.auth.allowOidcAuthOnly }}
6969
7070
[openshell.gateway.auth]
71+
{{- if .Values.server.auth.allowUnauthenticatedUsers }}
7172
allow_unauthenticated_users = true
7273
{{- end }}
74+
{{- if .Values.server.auth.allowOidcAuthOnly }}
75+
allow_oidc_auth_only = true
76+
{{- end }}
77+
{{- end }}
7378
7479
[openshell.gateway.gateway_jwt]
7580
signing_key_path = "/etc/openshell-jwt/signing.pem"
@@ -87,10 +92,10 @@ data:
8792
{{- if .Values.server.oidc.rolesClaim }}
8893
roles_claim = {{ .Values.server.oidc.rolesClaim | quote }}
8994
{{- end }}
90-
{{- if .Values.server.oidc.adminRole }}
95+
{{- if or .Values.server.oidc.adminRole .Values.server.auth.allowOidcAuthOnly }}
9196
admin_role = {{ .Values.server.oidc.adminRole | quote }}
9297
{{- end }}
93-
{{- if .Values.server.oidc.userRole }}
98+
{{- if or .Values.server.oidc.userRole .Values.server.auth.allowOidcAuthOnly }}
9499
user_role = {{ .Values.server.oidc.userRole | quote }}
95100
{{- end }}
96101
{{- if .Values.server.oidc.scopesClaim }}

0 commit comments

Comments
 (0)