From 87fc0798c6d0dfefb654584a76655743a7c57a69 Mon Sep 17 00:00:00 2001 From: Kris Hicks Date: Thu, 15 Oct 2026 23:18:37 -0700 Subject: [PATCH] feat(auth): record peer address and request ID on authentication events Authentication events now carry the connection's immediate peer as src_endpoint, or edge-tunnel for WebSocket-tunneled requests, and the request's x-request-id as metadata.correlation_uid when it is a UUID. Refs #2912 Signed-off-by: Kris Hicks --- .../src/builders/authentication.rs | 43 ++++- crates/openshell-ocsf/src/builders/mod.rs | 1 + .../openshell-ocsf/src/events/api_activity.rs | 1 + .../src/events/app_lifecycle.rs | 1 + .../openshell-ocsf/src/events/base_event.rs | 1 + .../src/events/config_state_change.rs | 1 + .../src/events/detection_finding.rs | 1 + .../src/events/http_activity.rs | 1 + .../src/events/network_activity.rs | 1 + .../src/events/process_activity.rs | 1 + .../openshell-ocsf/src/events/ssh_activity.rs | 1 + crates/openshell-ocsf/src/format/jsonl.rs | 1 + crates/openshell-ocsf/src/format/shorthand.rs | 28 ++- crates/openshell-ocsf/src/objects/endpoint.rs | 18 ++ crates/openshell-ocsf/src/objects/metadata.rs | 6 + .../src/tracing_layers/event_bridge.rs | 1 + crates/openshell-server/src/auth/audit.rs | 51 ++++- crates/openshell-server/src/grpc/auth_rpc.rs | 18 +- .../src/grpc/provider_credentials_tests.rs | 9 +- crates/openshell-server/src/lib.rs | 4 +- crates/openshell-server/src/multiplex.rs | 177 +++++++++++++++++- crates/openshell-server/src/ws_tunnel.rs | 6 +- .../src/policy_dns/mod.rs | 1 + docs/observability/ocsf-json-export.mdx | 4 +- 24 files changed, 361 insertions(+), 16 deletions(-) diff --git a/crates/openshell-ocsf/src/builders/authentication.rs b/crates/openshell-ocsf/src/builders/authentication.rs index fcd14db542..a12cf2a5d0 100644 --- a/crates/openshell-ocsf/src/builders/authentication.rs +++ b/crates/openshell-ocsf/src/builders/authentication.rs @@ -23,6 +23,7 @@ pub struct AuthenticationBuilder<'a> { status_detail: Option, src_endpoint: Option, http_request: Option, + correlation_uid: Option, message: Option, } @@ -42,6 +43,7 @@ impl<'a> AuthenticationBuilder<'a> { status_detail: None, src_endpoint: None, http_request: None, + correlation_uid: None, message: None, } } @@ -68,6 +70,13 @@ impl<'a> AuthenticationBuilder<'a> { self } + /// Set the identifier shared by events about the same request. + #[must_use] + pub fn correlation_uid(mut self, uid: impl Into) -> Self { + self.correlation_uid = Some(uid.into()); + self + } + #[must_use] pub fn src_endpoint(mut self, endpoint: Endpoint) -> Self { self.src_endpoint = Some(endpoint); @@ -83,6 +92,8 @@ impl<'a> AuthenticationBuilder<'a> { #[must_use] pub fn build(self) -> OcsfEvent { + let mut metadata = self.ctx.metadata(&["security_control", "host"]); + metadata.correlation_uid = self.correlation_uid; let mut base = BaseEventData::new( 3002, "Authentication", @@ -91,7 +102,7 @@ impl<'a> AuthenticationBuilder<'a> { 1, "Logon", self.severity, - self.ctx.metadata(&["security_control", "host"]), + metadata, ); if let Some(status) = self.status { base.set_status(status); @@ -224,6 +235,36 @@ mod tests { serde_json::json!(["security_control", "host"]) ); assert!(json.get("container").is_none()); + assert!(json["metadata"].get("correlation_uid").is_none()); + } + + #[test] + fn correlated_event_from_a_named_source_matches_vendored_schema() { + let ctx = test_sandbox_context(); + let json = AuthenticationBuilder::new( + &ctx, + User::unidentified(), + Service::new("openshell-gateway"), + ) + .src_endpoint(Endpoint::from_name("edge-tunnel")) + .correlation_uid("6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f") + .build() + .to_json() + .unwrap(); + + let endpoint = load_object_schema("network_endpoint"); + validate_known_fields(&json["src_endpoint"], &endpoint); + let metadata = load_object_schema("metadata"); + validate_required_fields(&json["metadata"], &metadata); + validate_known_fields(&json["metadata"], &metadata); + assert_eq!( + json["src_endpoint"], + serde_json::json!({"name": "edge-tunnel"}) + ); + assert_eq!( + json["metadata"]["correlation_uid"], + "6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f" + ); } #[test] diff --git a/crates/openshell-ocsf/src/builders/mod.rs b/crates/openshell-ocsf/src/builders/mod.rs index 8fb3a82e12..d45efa9264 100644 --- a/crates/openshell-ocsf/src/builders/mod.rs +++ b/crates/openshell-ocsf/src/builders/mod.rs @@ -213,6 +213,7 @@ impl EventContext { profiles: profiles.iter().map(|s| (*s).to_string()).collect(), uid: Some(uuid::Uuid::new_v4().to_string()), log_source: None, + correlation_uid: None, } } diff --git a/crates/openshell-ocsf/src/events/api_activity.rs b/crates/openshell-ocsf/src/events/api_activity.rs index 456c0c768b..e687b15796 100644 --- a/crates/openshell-ocsf/src/events/api_activity.rs +++ b/crates/openshell-ocsf/src/events/api_activity.rs @@ -101,6 +101,7 @@ mod tests { profiles: vec!["ai_operation".to_string()], uid: None, log_source: None, + correlation_uid: None, }, ); base.set_ai_model(AiModel::new("claude-3-haiku", "anthropic")); diff --git a/crates/openshell-ocsf/src/events/app_lifecycle.rs b/crates/openshell-ocsf/src/events/app_lifecycle.rs index 103b385e2a..747b34ce9f 100644 --- a/crates/openshell-ocsf/src/events/app_lifecycle.rs +++ b/crates/openshell-ocsf/src/events/app_lifecycle.rs @@ -43,6 +43,7 @@ mod tests { profiles: vec!["container".to_string()], uid: Some("sandbox-abc123".to_string()), log_source: None, + correlation_uid: None, }, ); base.set_message("Starting sandbox"); diff --git a/crates/openshell-ocsf/src/events/base_event.rs b/crates/openshell-ocsf/src/events/base_event.rs index f781ff1393..78bd927612 100644 --- a/crates/openshell-ocsf/src/events/base_event.rs +++ b/crates/openshell-ocsf/src/events/base_event.rs @@ -232,6 +232,7 @@ mod tests { profiles: vec!["container".to_string(), "host".to_string()], uid: Some("sandbox-abc123".to_string()), log_source: None, + correlation_uid: None, } } diff --git a/crates/openshell-ocsf/src/events/config_state_change.rs b/crates/openshell-ocsf/src/events/config_state_change.rs index d2c8b900c1..c84f76e056 100644 --- a/crates/openshell-ocsf/src/events/config_state_change.rs +++ b/crates/openshell-ocsf/src/events/config_state_change.rs @@ -78,6 +78,7 @@ mod tests { profiles: vec!["security_control".to_string()], uid: Some("sandbox-abc123".to_string()), log_source: None, + correlation_uid: None, }, ); base.set_message("Policy reloaded successfully"); diff --git a/crates/openshell-ocsf/src/events/detection_finding.rs b/crates/openshell-ocsf/src/events/detection_finding.rs index 0c928be44c..7e0545083f 100644 --- a/crates/openshell-ocsf/src/events/detection_finding.rs +++ b/crates/openshell-ocsf/src/events/detection_finding.rs @@ -108,6 +108,7 @@ mod tests { profiles: vec!["security_control".to_string()], uid: Some("sandbox-abc123".to_string()), log_source: None, + correlation_uid: None, }, ), finding_info: FindingInfo::new("nssh1-replay-abc", "NSSH1 Nonce Replay Attack") diff --git a/crates/openshell-ocsf/src/events/http_activity.rs b/crates/openshell-ocsf/src/events/http_activity.rs index dc5c9f8484..e15153def3 100644 --- a/crates/openshell-ocsf/src/events/http_activity.rs +++ b/crates/openshell-ocsf/src/events/http_activity.rs @@ -119,6 +119,7 @@ mod tests { profiles: vec!["security_control".to_string()], uid: Some("sandbox-abc123".to_string()), log_source: None, + correlation_uid: None, }, ), http_request: Some(HttpRequest::new( diff --git a/crates/openshell-ocsf/src/events/network_activity.rs b/crates/openshell-ocsf/src/events/network_activity.rs index 86cebaa8e2..96cf3c5dac 100644 --- a/crates/openshell-ocsf/src/events/network_activity.rs +++ b/crates/openshell-ocsf/src/events/network_activity.rs @@ -114,6 +114,7 @@ mod tests { profiles: vec!["security_control".to_string(), "network_proxy".to_string()], uid: Some("sandbox-abc123".to_string()), log_source: None, + correlation_uid: None, }, ), src_endpoint: Some(Endpoint::from_ip_str("10.42.0.2", 54321)), diff --git a/crates/openshell-ocsf/src/events/process_activity.rs b/crates/openshell-ocsf/src/events/process_activity.rs index ade85a1950..a5378d3aff 100644 --- a/crates/openshell-ocsf/src/events/process_activity.rs +++ b/crates/openshell-ocsf/src/events/process_activity.rs @@ -91,6 +91,7 @@ mod tests { profiles: vec!["container".to_string()], uid: Some("sandbox-abc123".to_string()), log_source: None, + correlation_uid: None, }, ), process: Process::new("python3", 42).with_cmd_line("python3 /app/main.py"), diff --git a/crates/openshell-ocsf/src/events/ssh_activity.rs b/crates/openshell-ocsf/src/events/ssh_activity.rs index 29ffdeca38..83e5ec98a6 100644 --- a/crates/openshell-ocsf/src/events/ssh_activity.rs +++ b/crates/openshell-ocsf/src/events/ssh_activity.rs @@ -108,6 +108,7 @@ mod tests { profiles: vec!["security_control".to_string()], uid: Some("sandbox-abc123".to_string()), log_source: None, + correlation_uid: None, }, ), src_endpoint: Some(Endpoint::from_ip_str("10.42.0.1", 48201)), diff --git a/crates/openshell-ocsf/src/format/jsonl.rs b/crates/openshell-ocsf/src/format/jsonl.rs index 27fa8fb700..d89ea1d135 100644 --- a/crates/openshell-ocsf/src/format/jsonl.rs +++ b/crates/openshell-ocsf/src/format/jsonl.rs @@ -43,6 +43,7 @@ mod tests { profiles: vec!["container".to_string()], uid: Some("sandbox-abc123".to_string()), log_source: None, + correlation_uid: None, }, ); base.set_time(1_742_054_400_000); diff --git a/crates/openshell-ocsf/src/format/shorthand.rs b/crates/openshell-ocsf/src/format/shorthand.rs index c51caacdc1..fb5781a9a5 100644 --- a/crates/openshell-ocsf/src/format/shorthand.rs +++ b/crates/openshell-ocsf/src/format/shorthand.rs @@ -430,7 +430,13 @@ impl OcsfEvent { .as_ref() .map(|ep| { let port = ep.port.map_or(String::new(), |p| format!(":{p}")); - format!(" from {}{port}", ep.domain_or_ip()) + let host = ep + .domain + .as_deref() + .or(ep.ip.as_deref()) + .or(ep.name.as_deref()) + .unwrap_or("unknown"); + format!(" from {}{port}", escape_context_field(host)) }) .unwrap_or_default(); let mut context = Vec::new(); @@ -614,6 +620,7 @@ mod tests { profiles: vec!["security_control".to_string()], uid: Some("sandbox-abc123".to_string()), log_source: None, + correlation_uid: None, } } @@ -1125,6 +1132,25 @@ mod tests { ); } + #[test] + fn test_authentication_shorthand_names_an_unaddressed_source() { + let ctx = crate::builders::test_sandbox_context(); + let event = crate::builders::AuthenticationBuilder::new( + &ctx, + User::unidentified(), + Service::new("openshell-gateway"), + ) + .action(ActionId::Denied) + .severity(crate::enums::SeverityId::Low) + .src_endpoint(Endpoint::from_name("edge-tunnel")) + .build(); + + assert_eq!( + event.format_shorthand(), + "AUTH:LOGON [LOW] DENIED unknown from edge-tunnel" + ); + } + #[test] fn test_authentication_success_shorthand_labels_principal_type() { let ctx = crate::builders::test_sandbox_context(); diff --git a/crates/openshell-ocsf/src/objects/endpoint.rs b/crates/openshell-ocsf/src/objects/endpoint.rs index 3f6aae8c35..f70f1938a6 100644 --- a/crates/openshell-ocsf/src/objects/endpoint.rs +++ b/crates/openshell-ocsf/src/objects/endpoint.rs @@ -21,6 +21,10 @@ pub struct Endpoint { /// Port number. #[serde(skip_serializing_if = "Option::is_none")] pub port: Option, + + /// Short name for an endpoint with no address of its own. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, } impl Endpoint { @@ -31,6 +35,18 @@ impl Endpoint { domain: Some(name.to_string()), ip: None, port: Some(port), + name: None, + } + } + + /// Create an endpoint identified only by a short name. + #[must_use] + pub fn from_name(name: &str) -> Self { + Self { + domain: None, + ip: None, + port: None, + name: Some(name.to_string()), } } @@ -41,6 +57,7 @@ impl Endpoint { domain: None, ip: Some(addr.to_string()), port: Some(port), + name: None, } } @@ -51,6 +68,7 @@ impl Endpoint { domain: None, ip: Some(addr.to_string()), port: Some(port), + name: None, } } diff --git a/crates/openshell-ocsf/src/objects/metadata.rs b/crates/openshell-ocsf/src/objects/metadata.rs index c580d0d840..37d3fd2d26 100644 --- a/crates/openshell-ocsf/src/objects/metadata.rs +++ b/crates/openshell-ocsf/src/objects/metadata.rs @@ -25,6 +25,10 @@ pub struct Metadata { /// Log source path (e.g., "/dev/kmsg" for bypass detection). #[serde(skip_serializing_if = "Option::is_none")] pub log_source: Option, + + /// Identifier shared by events about the same request. + #[serde(skip_serializing_if = "Option::is_none")] + pub correlation_uid: Option, } /// OCSF Product object — identifies the software generating events. @@ -75,6 +79,7 @@ mod tests { profiles: vec!["security_control".to_string(), "container".to_string()], uid: Some("sandbox-abc123".to_string()), log_source: None, + correlation_uid: None, }; let json = serde_json::to_value(&metadata).unwrap(); @@ -92,6 +97,7 @@ mod tests { profiles: vec![], uid: None, log_source: Some("/dev/kmsg".to_string()), + correlation_uid: None, }; let json = serde_json::to_value(&metadata).unwrap(); diff --git a/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs b/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs index 7921d0dd56..c4a5ccb895 100644 --- a/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs +++ b/crates/openshell-ocsf/src/tracing_layers/event_bridge.rs @@ -125,6 +125,7 @@ mod tests { profiles: vec![], uid: None, log_source: None, + correlation_uid: None, }, ), }) diff --git a/crates/openshell-server/src/auth/audit.rs b/crates/openshell-server/src/auth/audit.rs index 6ad233d903..f78315594d 100644 --- a/crates/openshell-server/src/auth/audit.rs +++ b/crates/openshell-server/src/auth/audit.rs @@ -7,9 +7,49 @@ //! credentials or raw verification errors. use openshell_ocsf::{ - ActionId, AuthProtocolId, AuthenticationBuilder, DispositionId, HttpMethod, HttpRequest, - Service, SeverityId, StatusId, Url, User, ocsf_emit, + ActionId, AuthProtocolId, AuthenticationBuilder, DispositionId, Endpoint, HttpMethod, + HttpRequest, Service, SeverityId, StatusId, Url, User, ocsf_emit, }; +use std::net::SocketAddr; + +/// Where a gateway connection came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionSource { + /// A direct TCP connection. The address is the immediate peer, which is + /// a proxy or load balancer when one fronts the gateway. + Peer(SocketAddr), + /// A connection carried over the WebSocket tunnel endpoint. + EdgeTunnel, +} + +impl ConnectionSource { + fn endpoint(self) -> Endpoint { + match self { + Self::Peer(addr) => Endpoint::from_ip(addr.ip().to_canonical(), addr.port()), + Self::EdgeTunnel => Endpoint::from_name("edge-tunnel"), + } + } +} + +/// Request details recorded on every audit event about the request. +#[derive(Debug, Clone, Default)] +pub struct RequestOrigin { + source: Option, + request_id: Option, +} + +impl RequestOrigin { + /// Only a canonical UUID `x-request-id` is kept: the value is + /// client-controlled and the gateway generates one when it is absent. + pub fn new(source: Option, headers: &http::HeaderMap) -> Self { + let request_id = headers + .get("x-request-id") + .and_then(|value| value.to_str().ok()) + .filter(|value| openshell_core::endpoint_failure::is_canonical_request_id(value)) + .map(str::to_owned); + Self { source, request_id } + } +} use super::authenticator::{AuthFailureReason, AuthMechanism}; @@ -58,6 +98,7 @@ fn with_protocol( /// Emit an Authentication failure event for a gRPC request to `path`. pub fn emit_authentication_failure( path: &str, + origin: &RequestOrigin, mechanism: Option, reason: AuthFailureReason, ) { @@ -80,5 +121,11 @@ pub fn emit_authentication_failure( if let Some(mechanism) = mechanism { builder = with_protocol(builder, mechanism); } + if let Some(source) = origin.source { + builder = builder.src_endpoint(source.endpoint()); + } + if let Some(request_id) = &origin.request_id { + builder = builder.correlation_uid(request_id); + } ocsf_emit!(builder.build()); } diff --git a/crates/openshell-server/src/grpc/auth_rpc.rs b/crates/openshell-server/src/grpc/auth_rpc.rs index 2c565bc91b..57b009b7a0 100644 --- a/crates/openshell-server/src/grpc/auth_rpc.rs +++ b/crates/openshell-server/src/grpc/auth_rpc.rs @@ -15,7 +15,7 @@ //! ordinary RPCs or select another successor. use crate::ServerState; -use crate::auth::audit; +use crate::auth::audit::{self, ConnectionSource, RequestOrigin}; use crate::auth::authenticator::{AuthFailureReason, AuthMechanism, AuthRejection}; use crate::auth::identity::IdentityProvider; use crate::auth::principal::{Principal, SandboxIdentitySource}; @@ -271,6 +271,10 @@ pub async fn handle_refresh_sandbox_token( .map_err(|rejection| { audit::emit_authentication_failure( REFRESH_SANDBOX_TOKEN_PATH, + &RequestOrigin::new( + request.extensions().get::().copied(), + request.metadata().as_ref(), + ), Some(rejection.mechanism), rejection.reason, ); @@ -784,6 +788,13 @@ mod tests { let _guard = capture.install(); let mut replay = request(); set_refresh_authorization(&mut replay, &first); + replay.extensions_mut().insert(ConnectionSource::Peer( + "203.0.113.7:51234".parse().expect("peer address"), + )); + replay.metadata_mut().insert( + "x-request-id", + "6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f".parse().unwrap(), + ); handle_refresh_sandbox_token(&state, replay) .await .expect_err("consumed predecessor must be rejected") @@ -807,6 +818,11 @@ mod tests { events[0]["http_request"]["url"]["path"], "/openshell.v1.OpenShell/RefreshSandboxToken" ); + assert_eq!(events[0]["src_endpoint"]["ip"], "203.0.113.7"); + assert_eq!( + events[0]["metadata"]["correlation_uid"], + "6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f" + ); assert!(!events[0].to_string().contains(&first)); } diff --git a/crates/openshell-server/src/grpc/provider_credentials_tests.rs b/crates/openshell-server/src/grpc/provider_credentials_tests.rs index 8e4c6a2fde..a29a3a44bc 100644 --- a/crates/openshell-server/src/grpc/provider_credentials_tests.rs +++ b/crates/openshell-server/src/grpc/provider_credentials_tests.rs @@ -747,7 +747,7 @@ async fn real_tls_operator_boundary_and_admin_capabilities() { let service = MultiplexService::new(state); let server = tokio::spawn(async move { loop { - let (stream, _) = listener.accept().await.unwrap(); + let (stream, peer) = listener.accept().await.unwrap(); openshell_core::net::set_tcp_nodelay_best_effort(&stream); let acceptor = acceptor.acceptor(); let service = service.clone(); @@ -756,7 +756,12 @@ async fn real_tls_operator_boundary_and_admin_capabilities() { let identity = extract_peer_identity(&stream); let fingerprint = extract_peer_certificate_sha256(&stream); let _ = service - .serve_with_verified_certificate(stream, identity, fingerprint) + .serve_with_verified_certificate( + stream, + identity, + fingerprint, + crate::auth::audit::ConnectionSource::Peer(peer), + ) .await; } }); diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index f57905b3e3..06eed25352 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -241,6 +241,7 @@ fn spawn_gateway_extension_token_refresh( } }); } +pub use auth::audit::ConnectionSource; pub use multiplex::{MultiplexService, MultiplexedService}; pub use persistence::Store; use sandbox_index::SandboxIndex; @@ -1197,6 +1198,7 @@ fn spawn_gateway_connection( tls_stream, peer_identity, certificate_sha256, + ConnectionSource::Peer(addr), ) .await { @@ -1223,7 +1225,7 @@ fn spawn_gateway_connection( }); } else { tokio::spawn(async move { - if let Err(e) = service.serve(stream).await { + if let Err(e) = service.serve(stream, ConnectionSource::Peer(addr)).await { if is_benign_connection_close(e.as_ref()) { debug!(error = %e, client = %addr, "Connection closed"); } else { diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 6ab93a1fe8..909017609b 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -43,7 +43,7 @@ use tracing_opentelemetry::OpenTelemetrySpanExt as _; use crate::{ OpenShellService, ServerState, - auth::audit, + auth::audit::{self, ConnectionSource, RequestOrigin}, auth::authenticator::{AuthFailureReason, AuthMechanism, Authentication, AuthenticatorChain}, auth::authz::AuthzPolicy, auth::identity::Identity, @@ -236,11 +236,15 @@ impl MultiplexService { } /// Serve a connection, routing to gRPC or HTTP based on content-type. - pub async fn serve(&self, stream: S) -> Result<(), Box> + pub async fn serve( + &self, + stream: S, + source: ConnectionSource, + ) -> Result<(), Box> where S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { - self.serve_with_peer_identity(stream, None).await + self.serve_with_peer_identity(stream, None, source).await } /// Serve a TLS connection with an optional mTLS peer identity. @@ -248,11 +252,12 @@ impl MultiplexService { &self, stream: S, peer_identity: Option, + source: ConnectionSource, ) -> Result<(), Box> where S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { - self.serve_with_verified_certificate(stream, peer_identity, None) + self.serve_with_verified_certificate(stream, peer_identity, None, source) .await } @@ -262,6 +267,7 @@ impl MultiplexService { stream: S, peer_identity: Option, certificate_sha256: Option, + source: ConnectionSource, ) -> Result<(), Box> where S: AsyncRead + AsyncWrite + Unpin + Send + 'static, @@ -308,7 +314,8 @@ impl MultiplexService { self.state.config.mtls_auth.enabled, self.state.config.auth.allow_unauthenticated_users, ) - .with_operator(operator); + .with_operator(operator) + .with_connection_source(source); let grpc_service = GrpcRateLimitService::new(grpc_service, self.state.grpc_rate_limiter.clone()); let http_service = http_router(self.state.clone()); @@ -1036,6 +1043,7 @@ pub struct AuthGrpcRouter { mtls_auth_enabled: bool, allow_unauthenticated_users: bool, operator: Option, + connection_source: Option, } impl AuthGrpcRouter { @@ -1064,6 +1072,7 @@ impl AuthGrpcRouter { mtls_auth_enabled, allow_unauthenticated_users, operator: None, + connection_source: None, } } @@ -1071,6 +1080,11 @@ impl AuthGrpcRouter { self.operator = operator; self } + + fn with_connection_source(mut self, source: ConnectionSource) -> Self { + self.connection_source = Some(source); + self + } } fn unauthenticated_dev_user_principal() -> Principal { @@ -1111,12 +1125,17 @@ where let mtls_auth_enabled = self.mtls_auth_enabled; let allow_unauthenticated_users = self.allow_unauthenticated_users; let operator = self.operator.clone(); + let connection_source = self.connection_source; let mut inner = self.inner.clone(); Box::pin(async move { let mut req = req; + if let Some(source) = connection_source { + req.extensions_mut().insert(source); + } let path = req.uri().path().to_string(); + let origin = |headers: &http::HeaderMap| RequestOrigin::new(connection_source, headers); // Health probes and reflection — truly unauthenticated. if oidc::is_unauthenticated_method(&path) { @@ -1127,6 +1146,7 @@ where if req.headers().contains_key(http::header::AUTHORIZATION) { audit::emit_authentication_failure( &path, + &origin(req.headers()), Some(AuthMechanism::Mtls), AuthFailureReason::ConflictingCredentials, ); @@ -1148,7 +1168,12 @@ where } _ => (None, AuthFailureReason::MissingCredential), }; - audit::emit_authentication_failure(&path, mechanism, reason); + audit::emit_authentication_failure( + &path, + &origin(req.headers()), + mechanism, + reason, + ); return Ok(status_response(tonic::Status::unauthenticated( "missing authorization header", ))); @@ -1157,6 +1182,7 @@ where Err(rejection) => { audit::emit_authentication_failure( &path, + &origin(req.headers()), Some(rejection.mechanism), rejection.reason, ); @@ -1167,6 +1193,7 @@ where let Some(identity) = peer_identity else { audit::emit_authentication_failure( &path, + &origin(req.headers()), Some(AuthMechanism::Mtls), AuthFailureReason::MissingCredential, ); @@ -3228,6 +3255,54 @@ mod tests { assert_eq!(normalize_http_path("/"), "unknown"); } + #[tokio::test] + async fn served_connection_reports_its_peer_and_generated_request_id() { + use openshell_core::proto::{ListSandboxesRequest, open_shell_client::OpenShellClient}; + use openshell_ocsf::test_support::OcsfCapture; + + let mut state = crate::grpc::test_support::test_server_state().await; + Arc::get_mut(&mut state).unwrap().config.mtls_auth.enabled = true; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let service = MultiplexService::new(state); + tokio::spawn(async move { + let (stream, peer) = listener.accept().await.unwrap(); + openshell_core::net::set_tcp_nodelay_best_effort(&stream); + let _ = service.serve(stream, ConnectionSource::Peer(peer)).await; + }); + let capture = OcsfCapture::default(); + let _guard = capture.install(); + + let channel = tonic::transport::Endpoint::from_shared(format!("http://{address}")) + .unwrap() + .connect() + .await + .unwrap(); + let error = OpenShellClient::new(channel) + .list_sandboxes(ListSandboxesRequest::default()) + .await + .expect_err("a request without a client certificate must be rejected"); + let returned_request_id = error + .metadata() + .get("x-request-id") + .and_then(|value| value.to_str().ok()) + .expect("the gateway returns the request ID it generated"); + + assert_eq!(error.code(), tonic::Code::Unauthenticated); + let events: Vec<_> = capture + .events() + .iter() + .map(|event| event.to_json().unwrap()) + .collect(); + assert_eq!(events.len(), 1, "{events:?}"); + assert_eq!(events[0]["src_endpoint"]["ip"], "127.0.0.1"); + assert!(events[0]["src_endpoint"]["port"].as_u64().is_some()); + assert_eq!( + events[0]["metadata"]["correlation_uid"], + returned_request_id + ); + } + mod auth_router { use super::*; use crate::auth::authenticator::AuthRejection; @@ -3996,6 +4071,96 @@ mod tests { assert_eq!(events[0]["auth_protocol"], "k8s_service_account"); } + const REQUEST_ID: &str = "6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f"; + + async fn missing_credential_event( + source: ConnectionSource, + request_id: &str, + ) -> serde_json::Value { + let capture = OcsfCapture::default(); + let _guard = capture.install(); + let chain = AuthenticatorChain::new(vec![Arc::new(MockAuthenticator::returning(Ok( + Authentication::NotApplicable, + )))]); + let (recorder, _) = PrincipalRecorder::new(); + let mut router = + AuthGrpcRouter::new(recorder, Some(chain), None).with_connection_source(source); + let request = Request::builder() + .uri("/openshell.v1.OpenShell/ListSandboxes") + .header("x-request-id", request_id) + .body(Full::new(Bytes::new())) + .unwrap(); + + router.call(request).await.unwrap(); + + let mut events = captured_json(&capture); + assert_eq!(events.len(), 1, "{events:?}"); + events.remove(0) + } + + #[tokio::test] + async fn authentication_failure_carries_peer_address_and_request_id() { + let event = missing_credential_event( + ConnectionSource::Peer("203.0.113.7:51234".parse().unwrap()), + REQUEST_ID, + ) + .await; + + assert_eq!( + event["src_endpoint"], + serde_json::json!({"ip": "203.0.113.7", "port": 51234}) + ); + assert_eq!(event["metadata"]["correlation_uid"], REQUEST_ID); + } + + #[tokio::test] + async fn ipv4_mapped_peer_address_is_reported_as_ipv4() { + let event = missing_credential_event( + ConnectionSource::Peer("[::ffff:203.0.113.7]:51234".parse().unwrap()), + REQUEST_ID, + ) + .await; + + assert_eq!(event["src_endpoint"]["ip"], "203.0.113.7"); + } + + #[tokio::test] + async fn tunnel_source_is_named_and_non_uuid_request_ids_are_dropped() { + let event = + missing_credential_event(ConnectionSource::EdgeTunnel, "attacker [controlled]") + .await; + + assert_eq!( + event["src_endpoint"], + serde_json::json!({"name": "edge-tunnel"}) + ); + assert!(event["metadata"].get("correlation_uid").is_none()); + assert!(!event.to_string().contains("attacker")); + } + + #[tokio::test] + async fn connection_source_reaches_handlers() { + let seen = Arc::new(Mutex::new(None)); + let recorded = seen.clone(); + let inner = tower::service_fn(move |req: Request>| { + *recorded.lock().unwrap() = req.extensions().get::().copied(); + async { Ok::<_, Infallible>(Response::new(tonic::body::Body::empty())) } + }); + let chain = AuthenticatorChain::new(vec![Arc::new(MockAuthenticator::returning(Ok( + Authentication::Accepted(user_principal("alice")), + )))]); + let source = ConnectionSource::Peer("203.0.113.7:51234".parse().unwrap()); + let mut router = + AuthGrpcRouter::new(inner, Some(chain), None).with_connection_source(source); + + router + .call(empty_request("/openshell.v1.OpenShell/ListSandboxes")) + .await + .unwrap(); + + assert_eq!(*seen.lock().unwrap(), Some(source)); + } + #[tokio::test] async fn rejected_oidc_credential_emits_classified_authentication_failure() { let path = "/openshell.v1.OpenShell/ListSandboxes"; diff --git a/crates/openshell-server/src/ws_tunnel.rs b/crates/openshell-server/src/ws_tunnel.rs index 055a093175..da3c5a8a43 100644 --- a/crates/openshell-server/src/ws_tunnel.rs +++ b/crates/openshell-server/src/ws_tunnel.rs @@ -30,6 +30,7 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tracing::{debug, warn}; use crate::ServerState; +use crate::auth::audit::ConnectionSource; /// Create the WebSocket tunnel router. pub fn router(state: Arc) -> Router { @@ -63,7 +64,10 @@ async fn handle_ws_tunnel( let (tunnel_read, tunnel_write) = tokio::io::split(tunnel_stream); let service_task = tokio::spawn(async move { - if let Err(e) = service.serve(service_stream).await { + if let Err(e) = service + .serve(service_stream, ConnectionSource::EdgeTunnel) + .await + { debug!(error = %e, "WS tunnel: multiplex service error"); } }); diff --git a/crates/openshell-supervisor-network/src/policy_dns/mod.rs b/crates/openshell-supervisor-network/src/policy_dns/mod.rs index ee1a5cc122..63fb8eeca2 100644 --- a/crates/openshell-supervisor-network/src/policy_dns/mod.rs +++ b/crates/openshell-supervisor-network/src/policy_dns/mod.rs @@ -484,6 +484,7 @@ fn dns_query_endpoint(name: &NormalizedName) -> Endpoint { domain: Some(name.as_str().to_string()), ip: None, port: None, + name: None, } } diff --git a/docs/observability/ocsf-json-export.mdx b/docs/observability/ocsf-json-export.mdx index 5d91ec187d..3302dad700 100644 --- a/docs/observability/ocsf-json-export.mdx +++ b/docs/observability/ocsf-json-export.mdx @@ -35,7 +35,9 @@ The gateway emits an Authentication (`class_uid: 3002`) event when it rejects a | `stale_identity` | Medium | The credential names a sandbox runtime or gateway pod that has since been replaced or stopped. | | `identity_provider_unavailable` | Medium | The OIDC key endpoint, Kubernetes API, compute driver, or gateway database could not verify the credential. | -These events never include presented credentials, headers, or verification error text. +`src_endpoint` holds the address of the connection's immediate peer, which is the proxy or load balancer when one fronts the gateway, or the name `edge-tunnel` for requests carried over the WebSocket tunnel. `metadata.correlation_uid` holds the request's `x-request-id` when it is a UUID. The gateway generates one when the client sends none and returns it in the response. A client-supplied ID is recorded as sent, so it identifies the request, not the client. + +These events never include presented credentials, other request headers, or verification error text. Without JSONL enabled, gateway OCSF events still appear as shorthand in console output, subject to the diagnostic log filter. Events associated with a sandbox also appear in its gateway log stream; gateway-wide events have no sandbox association.