Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions architecture/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ 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. Shared
gateways reject OIDC authentication-only mode unless
`allow_oidc_auth_only` is set explicitly, and authenticated gRPC methods fail
closed when no user, sandbox, mTLS, or explicit local-dev principal can be
derived.
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
Expand Down
201 changes: 199 additions & 2 deletions crates/openshell-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,12 @@ 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. In that mode any valid token from the issuer can
/// call user and admin APIs, so shared deployments must opt in explicitly.
#[serde(default)]
pub allow_oidc_auth_only: bool,
}

const fn default_jwks_ttl_secs() -> u64 {
Expand Down Expand Up @@ -705,6 +711,96 @@ impl Config {
self.service_routing.enable_loopback_service_http = enabled;
self
}

/// Validate auth settings that depend on the deployment posture.
///
/// Local loopback gateways can rely on developer-oriented auth shortcuts,
/// but Kubernetes and externally-bound gateways must fail closed unless
/// the operator chose an explicit weak mode.
pub fn validate_gateway_auth_posture(&self) -> Result<(), String> {
self.validate_gateway_auth_posture_with_extra_bind_addresses(&[])
}

/// Validate auth settings against the configured listener set plus
/// runtime-provided gateway listeners from compute drivers.
pub fn validate_gateway_auth_posture_with_extra_bind_addresses(
&self,
extra_bind_addresses: &[SocketAddr],
) -> Result<(), String> {
let shared =
self.is_shared_gateway_deployment_with_extra_bind_addresses(extra_bind_addresses);

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 shared && !admin_set && !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 explicitly accept that any valid issuer token is authorized"
.to_string(),
);
}
}

if self.is_kubernetes_gateway_deployment() && self.mtls_auth.enabled {
return Err(
"mTLS user authentication is not supported with the Kubernetes compute driver; \
configure OIDC or a trusted fronting proxy for user authentication"
.to_string(),
);
}

let has_user_authenticator = self.oidc.is_some();
if shared
&& !has_user_authenticator
&& !self.mtls_auth.enabled
&& !self.auth.allow_unauthenticated_users
{
return Err(
"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"
.to_string(),
);
}

Ok(())
}

/// Whether this gateway serves a shared or remotely reachable gRPC API.
#[must_use]
pub fn is_shared_gateway_deployment(&self) -> bool {
self.is_shared_gateway_deployment_with_extra_bind_addresses(&[])
}

#[must_use]
fn is_shared_gateway_deployment_with_extra_bind_addresses(
&self,
extra_bind_addresses: &[SocketAddr],
) -> bool {
self.is_kubernetes_gateway_deployment()
|| !self.bind_address.ip().is_loopback()
|| extra_bind_addresses
.iter()
.any(|addr| !addr.ip().is_loopback())
}

#[must_use]
fn is_kubernetes_gateway_deployment(&self) -> bool {
self.compute_drivers
.iter()
.any(|driver| driver == ComputeDriverKind::Kubernetes.as_str())
}
}

impl Default for ServiceRoutingConfig {
Expand Down Expand Up @@ -789,8 +885,8 @@ mod tests {
#[cfg(unix)]
use super::is_reachable_unix_socket;
use super::{
ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayJwtConfig, detect_driver,
docker_host_unix_socket_path, is_unix_socket, normalize_compute_driver_name,
ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, GatewayJwtConfig, OidcConfig,
detect_driver, docker_host_unix_socket_path, is_unix_socket, normalize_compute_driver_name,
podman_socket_candidates_from_env, podman_socket_responds,
};
#[cfg(unix)]
Expand Down Expand Up @@ -857,6 +953,107 @@ 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(),
}
}

#[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().is_ok());
}

#[test]
fn gateway_auth_posture_rejects_shared_oidc_auth_only_without_override() {
let cfg = Config::new(None)
.with_compute_drivers([ComputeDriverKind::Kubernetes])
.with_oidc(oidc_config("", ""));

let err = cfg.validate_gateway_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_compute_drivers([ComputeDriverKind::Kubernetes])
.with_oidc(oidc_config("", ""));
cfg.auth.allow_oidc_auth_only = true;

assert!(cfg.validate_gateway_auth_posture().is_ok());
}

#[test]
fn gateway_auth_posture_allows_shared_oidc_rbac() {
let cfg = Config::new(None)
.with_compute_drivers([ComputeDriverKind::Kubernetes])
.with_oidc(oidc_config("openshell-admin", "openshell-user"));

assert!(cfg.validate_gateway_auth_posture().is_ok());
}

#[test]
fn gateway_auth_posture_rejects_partial_oidc_roles() {
let cfg = Config::new(None).with_oidc(oidc_config("openshell-admin", ""));

let err = cfg.validate_gateway_auth_posture().unwrap_err();
assert!(err.contains("OIDC RBAC misconfiguration"));
}

#[test]
fn gateway_auth_posture_rejects_shared_gateway_without_auth_path() {
let cfg = Config::new(None).with_compute_drivers([ComputeDriverKind::Kubernetes]);

let err = cfg.validate_gateway_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).with_compute_drivers([ComputeDriverKind::Kubernetes]);
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().unwrap_err();
assert!(err.contains("require an explicit auth path"));
}

#[test]
fn gateway_auth_posture_rejects_runtime_non_loopback_listener_without_auth_path() {
let cfg = Config::new(None);
let driver_bind: SocketAddr = "172.18.0.1:17670".parse().expect("valid address");

let err = cfg
.validate_gateway_auth_posture_with_extra_bind_addresses(&[driver_bind])
.unwrap_err();
assert!(err.contains("require an explicit auth path"));
}

#[test]
fn gateway_auth_posture_rejects_kubernetes_mtls_user_auth() {
let mut cfg = Config::new(None)
.with_compute_drivers([ComputeDriverKind::Kubernetes])
.with_oidc(oidc_config("openshell-admin", "openshell-user"));
cfg.mtls_auth.enabled = true;

let err = cfg.validate_gateway_auth_posture().unwrap_err();
assert!(err.contains("mTLS user authentication is not supported"));
}

#[test]
fn gateway_jwt_ttl_defaults_to_non_expiring() {
let cfg: GatewayJwtConfig = serde_json::from_value(serde_json::json!({
Expand Down
3 changes: 1 addition & 2 deletions crates/openshell-server/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -469,8 +469,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"
);
}

Expand Down
2 changes: 2 additions & 0 deletions crates/openshell-server/src/config_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,11 +394,13 @@ grpc_endpoint = "https://openshell-gateway.agents.svc:8080"
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]
Expand Down
43 changes: 25 additions & 18 deletions crates/openshell-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,27 +222,12 @@ pub(crate) async fn run_server(
if database_url.is_empty() {
return Err(Error::config("database_url is required"));
}
config
.validate_gateway_auth_posture()
.map_err(Error::config)?;

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());
Expand All @@ -263,6 +248,28 @@ pub(crate) async fn run_server(
supervisor_sessions.clone(),
)
.await?;
config
.validate_gateway_auth_posture_with_extra_bind_addresses(compute.gateway_bind_addresses())
.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);
Some(Arc::new(cache))
} else {
None
};

let mut state = ServerState::new(
config.clone(),
store.clone(),
Expand Down
31 changes: 23 additions & 8 deletions crates/openshell-server/src/multiplex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -442,9 +442,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<AuthenticatorChain> {
let mut authenticators: Vec<Arc<dyn crate::auth::authenticator::Authenticator>> = Vec::new();
if let Some(k8s) = state.k8s_sa_authenticator.clone() {
Expand All @@ -469,8 +469,8 @@ fn build_authenticator_chain(state: &ServerState) -> Option<AuthenticatorChain>
/// - 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
Expand Down Expand Up @@ -588,9 +588,9 @@ where
} else if allow_unauthenticated_users {
unauthenticated_dev_user_principal()
} else {
// No auth configured — pass through for dev /
// fronting-proxy deployments.
return inner.ready().await?.call(req).await;
return Ok(status_response(tonic::Status::unauthenticated(
"gateway authentication is not configured",
)));
};

match principal {
Expand Down Expand Up @@ -1547,6 +1547,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(
Expand Down
Loading
Loading