diff --git a/crates/openshell-ocsf/src/events/mod.rs b/crates/openshell-ocsf/src/events/mod.rs index 22ecbb6dfc..2cfbbc616c 100644 --- a/crates/openshell-ocsf/src/events/mod.rs +++ b/crates/openshell-ocsf/src/events/mod.rs @@ -174,6 +174,11 @@ impl OcsfEvent { self.base_mut().metadata.correlation_uid = Some(uid.into()); } + /// Add a field that is not represented by the typed OCSF event class. + pub fn add_unmapped(&mut self, key: &str, value: impl Into) { + self.base_mut().add_unmapped(key, value); + } + /// Returns mutable base data common to all event classes. pub(crate) fn base_mut(&mut self) -> &mut BaseEventData { match self { @@ -240,6 +245,30 @@ mod tests { assert_eq!(deserialized.class_uid(), 4002); } + #[test] + fn add_unmapped_preserves_existing_fields() { + let ctx = test_sandbox_context(); + let mut event = HttpActivityBuilder::new(&ctx) + .activity(ActivityId::Reset) + .action(ActionId::Allowed) + .severity(SeverityId::Informational) + .http_request(HttpRequest::new( + "GET", + Url::new("https", "example.com", "/", 443), + )) + .unmapped("existing", "value") + .build(); + + event.add_unmapped("resource_revision", "18446744073709551615"); + + let json = serde_json::to_value(event).unwrap(); + assert_eq!(json["unmapped"]["existing"], "value"); + assert_eq!( + json["unmapped"]["resource_revision"], + "18446744073709551615" + ); + } + #[test] fn test_roundtrip_ssh_activity() { let ctx = test_sandbox_context(); diff --git a/crates/openshell-supervisor-network/README.md b/crates/openshell-supervisor-network/README.md index 956bdd0df1..105519cd44 100644 --- a/crates/openshell-supervisor-network/README.md +++ b/crates/openshell-supervisor-network/README.md @@ -106,3 +106,10 @@ placement and bounded result, never token material. The primary audit record includes notable credential outcomes such as withholding or unavailable material; successful preparation does not create another routine HTTP record. A completed preparation span does not establish that the target received the request. + +Correlated requests pin the selected policy generation, endpoint identity and +provider installation. These describe the configuration used by that request, +not whichever revision is current when an operator later reads its records. +Revision values remain decimal strings so full-width values survive export. +OCSF stores these request configuration identities in `unmapped`; they are not +firewall-rule versions or claims about the currently active configuration. diff --git a/crates/openshell-supervisor-network/src/l7/correlation.rs b/crates/openshell-supervisor-network/src/l7/correlation.rs index c610d41a66..506e7469f0 100644 --- a/crates/openshell-supervisor-network/src/l7/correlation.rs +++ b/crates/openshell-supervisor-network/src/l7/correlation.rs @@ -38,10 +38,30 @@ struct RequestState { outcome: Option<&'static str>, policy_span: Option, middleware: [Option; 2], + resources: ResourceCorrelationState, credential: CredentialState, credential_span: Option, } +#[derive(Debug, Default, PartialEq, Eq)] +struct ResourceCorrelationState { + policy_runtime_generation: Option, + policy_endpoint: Option, + provider_credential: Option, +} + +#[derive(Debug, PartialEq, Eq)] +struct PolicyEndpointResources { + policy_hash: String, + endpoint_id: String, +} + +#[derive(Debug, PartialEq, Eq)] +struct ProviderCredentialResources { + environment_revision: String, + installation_id: String, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum CredentialMechanism { StaticPlaceholder, @@ -335,6 +355,11 @@ impl RequestCorrelation { "server.address" = %ctx.host, "server.port" = ctx.port, "openshell.policy.name" = %ctx.policy_name, + "openshell.policy.runtime_generation" = tracing::field::Empty, + "openshell.policy.hash" = tracing::field::Empty, + "openshell.endpoint.id" = tracing::field::Empty, + "openshell.provider.environment.revision" = tracing::field::Empty, + "openshell.provider.credential.installation.id" = tracing::field::Empty, "openshell.target.reached" = false, "openshell.request.delivery_started" = false, "openshell.policy.decision" = tracing::field::Empty, @@ -360,6 +385,7 @@ impl RequestCorrelation { outcome: None, policy_span: None, middleware: [None, None], + resources: ResourceCorrelationState::default(), credential: CredentialState::default(), credential_span: None, })), @@ -448,6 +474,75 @@ impl RequestCorrelation { .upstream_status = Some(status); } + pub(crate) fn record_policy_runtime_generation(&self, generation: u64) { + { + let mut resources = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let resources = &mut resources.resources; + if resources.policy_runtime_generation.is_some() { + return; + } + resources.policy_runtime_generation = Some(generation); + } + self.span.record( + "openshell.policy.runtime_generation", + tracing::field::display(generation), + ); + } + + pub(crate) fn record_selected_policy_endpoint(&self, policy_hash: &str, endpoint_id: &str) { + { + let mut resources = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let resources = &mut resources.resources; + if resources.policy_endpoint.is_some() { + return; + } + let Some(policy_endpoint) = policy_endpoint_resources(policy_hash, endpoint_id) else { + return; + }; + resources.policy_endpoint = Some(policy_endpoint); + } + self.span.record("openshell.policy.hash", policy_hash); + self.span.record("openshell.endpoint.id", endpoint_id); + } + + pub(crate) fn record_provider_credential_snapshot( + &self, + snapshot: &openshell_core::provider_credentials::ProviderCredentialSnapshot, + ) { + let installation_id = { + let mut resources = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let resources = &mut resources.resources; + if resources.provider_credential.is_some() { + return; + } + let Ok(installation_id) = uuid::Uuid::parse_str(&snapshot.installation_id) else { + return; + }; + resources.provider_credential = Some(ProviderCredentialResources { + environment_revision: snapshot.revision.to_string(), + installation_id: installation_id.hyphenated().to_string(), + }); + installation_id + }; + self.span.record( + "openshell.provider.environment.revision", + tracing::field::display(snapshot.revision), + ); + self.span.record( + "openshell.provider.credential.installation.id", + tracing::field::display(installation_id.hyphenated()), + ); + } + pub(crate) fn record_target_reached(&self) { self.span.record("openshell.target.reached", true); } @@ -818,8 +913,17 @@ impl RequestCorrelation { self.emit_correlated(event); } - fn emit_correlated(&self, mut event: OcsfEvent) { + fn enrich_event(&self, event: &mut OcsfEvent) { event.set_correlation_uid(self.request_id.clone()); + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + add_resource_fields(event, &state.resources); + } + + fn emit_correlated(&self, mut event: OcsfEvent) { + self.enrich_event(&mut event); self.span .in_scope(|| crate::telemetry::emit_correlated(event)); } @@ -900,7 +1004,7 @@ pub fn emit_phase_events( continue; } } - event.set_correlation_uid(correlation.request_id.clone()); + correlation.enrich_event(&mut event); span.in_scope(|| crate::telemetry::emit_correlated(event)); } } @@ -982,6 +1086,51 @@ impl AsyncWrite for AuditedClient<'_, C> { } } +fn policy_endpoint_resources( + policy_hash: &str, + endpoint_id: &str, +) -> Option { + const ENDPOINT_PREFIX: &str = "endpoint:v1:"; + if !is_lower_hex_digest(policy_hash) + || endpoint_id + .strip_prefix(ENDPOINT_PREFIX) + .is_none_or(|digest| !is_lower_hex_digest(digest)) + { + return None; + } + Some(PolicyEndpointResources { + policy_hash: policy_hash.to_string(), + endpoint_id: endpoint_id.to_string(), + }) +} + +fn is_lower_hex_digest(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn add_resource_fields(event: &mut OcsfEvent, resources: &ResourceCorrelationState) { + if let Some(generation) = resources.policy_runtime_generation { + event.add_unmapped("policy_runtime_generation", generation.to_string()); + } + if let Some(policy_endpoint) = &resources.policy_endpoint { + event.add_unmapped("policy_hash", policy_endpoint.policy_hash.clone()); + event.add_unmapped("endpoint_id", policy_endpoint.endpoint_id.clone()); + } + if let Some(provider_credential) = &resources.provider_credential { + event.add_unmapped( + "provider_environment_revision", + provider_credential.environment_revision.clone(), + ); + event.add_unmapped( + "provider_credential_installation_id", + provider_credential.installation_id.clone(), + ); + } +} + fn bounded_http_method(method: &str) -> &'static str { [ "CONNECT", "DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "TRACE", @@ -1058,6 +1207,51 @@ fn trim_optional_whitespace(mut value: &[u8]) -> &[u8] { mod tests { use super::*; + #[test] + fn resource_pins_keep_the_first_valid_identity_and_full_width_revision() { + let correlation = RequestCorrelation::new(&request(), &L7EvalContext::default()); + correlation.record_policy_runtime_generation(u64::MAX); + correlation.record_policy_runtime_generation(1); + correlation.record_selected_policy_endpoint("not-a-digest", "endpoint:v1:invalid"); + let hash = "a".repeat(64); + let endpoint = format!("endpoint:v1:{}", "b".repeat(64)); + correlation.record_selected_policy_endpoint(&hash, &endpoint); + correlation.record_selected_policy_endpoint(&"c".repeat(64), &endpoint); + let mut snapshot = openshell_core::provider_credentials::ProviderCredentialSnapshot { + installation_id: "not-a-uuid".into(), + revision: u64::MAX, + child_env: std::collections::HashMap::new(), + dynamic_credentials: std::collections::HashMap::new(), + }; + correlation.record_provider_credential_snapshot(&snapshot); + snapshot.installation_id = "00000000-0000-4000-8000-000000000006".into(); + correlation.record_provider_credential_snapshot(&snapshot); + snapshot.revision = 1; + correlation.record_provider_credential_snapshot(&snapshot); + let mut event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .http_request(HttpRequest::new( + "GET", + L7EvalContext::default().ocsf_url("/"), + )) + .build(); + correlation.enrich_event(&mut event); + let value = serde_json::to_value(event).unwrap(); + assert_eq!( + value["unmapped"]["policy_runtime_generation"], + u64::MAX.to_string() + ); + assert_eq!( + value["unmapped"]["provider_environment_revision"], + u64::MAX.to_string() + ); + assert_eq!(value["unmapped"]["policy_hash"], hash); + assert_eq!(value["unmapped"]["endpoint_id"], endpoint); + assert_eq!( + value["unmapped"]["provider_credential_installation_id"], + snapshot.installation_id + ); + } + fn request() -> L7Request { L7Request { action: "GET".into(), diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index be3f8426e3..75a5c18a30 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -178,6 +178,9 @@ pub(crate) async fn prepare_inspected_request( ) -> Result<(crate::l7::provider::L7Request, L7EvalContext)> { let mut scoped = ctx.clone(); let snapshot = crate::l7::token_grant_injection::pin_live_credentials(&mut scoped); + if let (Some(correlation), Some(snapshot)) = (correlation, snapshot.as_deref()) { + correlation.record_provider_credential_snapshot(snapshot); + } // Admission-gated injection reads credentials only from the pinned snapshot, // so a context without one never acquires a grant. let grant_snapshot = snapshot.as_deref().filter(|snapshot| { @@ -1499,6 +1502,8 @@ where let correlation = RequestCorrelation::new(&req, ctx); let mut audited_client = correlation.observe_response(client); let client = &mut audited_client; + correlation + .record_policy_runtime_generation(engine.generation_guard().captured_generation()); if !request_authority_matches_endpoint(&req, ctx) { reject_request_authority_mismatch(client, ctx, &req.action, Some(&correlation)).await?; return Ok(()); @@ -1543,6 +1548,7 @@ where .await?; return Ok(()); }; + correlation.record_selected_policy_endpoint(&config.policy_hash, &config.endpoint_id); let observer = EndpointObserver::begin_captured( ctx.endpoint_observation_tx.as_ref(), config, @@ -2530,6 +2536,9 @@ where let correlation = RequestCorrelation::new(&req, ctx); let mut audited_client = correlation.observe_response(client); let client = &mut audited_client; + correlation + .record_policy_runtime_generation(engine.generation_guard().captured_generation()); + correlation.record_selected_policy_endpoint(&config.policy_hash, &config.endpoint_id); if !request_authority_matches_endpoint(&req, ctx) { reject_request_authority_mismatch(client, ctx, &req.action, Some(&correlation)).await?; return Ok(()); @@ -3034,6 +3043,9 @@ where let correlation = RequestCorrelation::new(&req, ctx); let mut audited_client = correlation.observe_response(client); let client = &mut audited_client; + correlation + .record_policy_runtime_generation(engine.generation_guard().captured_generation()); + correlation.record_selected_policy_endpoint(&config.policy_hash, &config.endpoint_id); let observer = EndpointObserver::begin_captured( ctx.endpoint_observation_tx.as_ref(), config, @@ -3351,6 +3363,9 @@ where let correlation = RequestCorrelation::new(&req, ctx); let mut audited_client = correlation.observe_response(client); let client = &mut audited_client; + correlation + .record_policy_runtime_generation(engine.generation_guard().captured_generation()); + correlation.record_selected_policy_endpoint(&config.policy_hash, &config.endpoint_id); if !request_authority_matches_endpoint(&req, ctx) { reject_request_authority_mismatch(client, ctx, &req.action, Some(&correlation)).await?; @@ -4181,6 +4196,7 @@ where .then(|| RequestCorrelation::new(&req, ctx)); let fallback_request_id = uuid::Uuid::new_v4().to_string(); if let Some(correlation) = correlation.as_ref() { + correlation.record_policy_runtime_generation(generation_guard.captured_generation()); correlation.record_policy_decision("not_applicable"); } if !request_authority_matches_endpoint(&req, ctx) { @@ -4229,7 +4245,11 @@ where req.action, ctx.host, ctx.port, redacted_target, )) .build(); - egress_emit!(event); + if let Some(correlation) = correlation.as_ref() { + correlation.emit(event); + } else { + egress_emit!(event); + } } let mut response_selection = None; @@ -4335,6 +4355,9 @@ where let injected = match crate::l7::token_grant_injection::pin_live_credentials(&mut grant_ctx) { Some(snapshot) => { + if let Some(correlation) = correlation.as_ref() { + correlation.record_provider_credential_snapshot(&snapshot); + } crate::l7::token_grant_injection::inject_for_selectors( req, &grant_ctx, @@ -4732,6 +4755,11 @@ network_policies: .iter() .all(|attribute| !attribute.key.as_str().starts_with("openshell.credential.")) ); + assert!( + span.attributes + .iter() + .all(|attribute| { !attribute.key.as_str().starts_with("openshell.provider.") }) + ); assert!( ocsf.json_events() .iter() @@ -6186,6 +6214,98 @@ network_policies: (config, tunnel_engine, ctx, fixture) } + #[tokio::test] + async fn inspected_request_records_the_atomically_pinned_provider_snapshot() { + const REQUEST_ID: &str = "00000000-0000-4000-8000-000000000041"; + + let (config, tunnel_engine, ctx, fixture) = + rest_token_grant_relay_context(Ok("grant-token")); + let expected_snapshot = ctx + .provider_credentials + .as_ref() + .expect("provider state") + .snapshot(); + let request = crate::l7::provider::L7Request { + action: "GET".into(), + target: "/v1/projects".into(), + query_params: TestHashMap::new(), + raw_header: + b"GET /v1/projects HTTP/1.1\r\nHost: api.example.test\r\nConnection: close\r\n\r\n" + .to_vec(), + body_length: crate::l7::provider::BodyLength::None, + }; + let request_info = L7RequestInfo { + action: request.action.clone(), + target: request.target.clone(), + query_params: TestHashMap::new(), + graphql: None, + jsonrpc: None, + }; + let exporter = opentelemetry_sdk::trace::InMemorySpanExporterBuilder::new().build(); + let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with(openshell_otel::layer( + &provider, + "provider-resource-correlation-test", + )); + + async { + tracing::callsite::rebuild_interest_cache(); + let correlation = + RequestCorrelation::with_request_id(&request, &ctx, REQUEST_ID.into()); + let (_, scoped) = prepare_inspected_request( + request, + &ctx, + &tunnel_engine, + &config, + &request_info, + REQUEST_ID, + None, + Some(&correlation), + ) + .await + .expect("prepare inspected request"); + assert_eq!( + scoped.provider_credential_revision, + Some(expected_snapshot.revision) + ); + assert_eq!( + scoped.provider_credential_installation_id.as_deref(), + Some(expected_snapshot.installation_id.as_str()) + ); + correlation.record_final_http_outcome("allowed"); + } + .with_subscriber(subscriber) + .await; + provider.force_flush().expect("flush spans"); + + let attributes = exporter + .get_finished_spans() + .expect("finished spans") + .into_iter() + .find(|span| span.name == "openshell.http.request") + .expect("HTTP enforcement span") + .attributes + .into_iter() + .map(|attribute| { + ( + attribute.key.as_str().to_string(), + attribute.value.to_string(), + ) + }) + .collect::>(); + assert_eq!( + attributes["openshell.provider.environment.revision"], + expected_snapshot.revision.to_string() + ); + assert_eq!( + attributes["openshell.provider.credential.installation.id"], + expected_snapshot.installation_id + ); + fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); + } + fn middleware_relay_context( middleware_impl: &str, on_error: &str, @@ -9857,11 +9977,19 @@ network_policies: let (endpoints, generation) = engine .query_endpoint_configs_with_generation(&input) .unwrap(); - let configs: Vec<_> = endpoints + let mut configs: Vec<_> = endpoints .iter() .map(|endpoint| crate::l7::parse_l7_config(endpoint).unwrap()) .collect(); + let expected_policy_hash = "a".repeat(64); + let expected_endpoint_id = format!("endpoint:v1:{}", "b".repeat(64)); + configs[0].policy_hash = expected_policy_hash.clone(); + configs[0].endpoint_id = expected_endpoint_id.clone(); let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); + let expected_generation = tunnel_engine + .generation_guard() + .captured_generation() + .to_string(); let ctx = L7EvalContext { host: "gateway.example.test".into(), port: 443, @@ -9870,29 +9998,44 @@ network_policies: binary_path: "/usr/bin/node".into(), ..Default::default() }; + let exporter = opentelemetry_sdk::trace::InMemorySpanExporterBuilder::new().build(); + let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let ocsf = openshell_ocsf::test_support::OcsfCapture::default(); + let subscriber = tracing_subscriber::registry() + .with(openshell_otel::layer( + &provider, + "websocket-resource-correlation-test", + )) + .with(ocsf.clone()); let (mut app, mut relay_client) = tokio::io::duplex(8192); let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); - let relay = tokio::spawn(async move { - if route_selected { - relay_with_route_selection( - &configs, - tunnel_engine, - &mut relay_client, - &mut relay_upstream, - &ctx, - ) - .await - } else { - relay_with_inspection( - &configs[0], - tunnel_engine, - &mut relay_client, - &mut relay_upstream, - &ctx, - ) - .await + let relay = tokio::spawn( + async move { + tracing::callsite::rebuild_interest_cache(); + if route_selected { + relay_with_route_selection( + &configs, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + } else { + relay_with_inspection( + &configs[0], + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + } } - }); + .with_subscriber(subscriber), + ); app.write_all( b"GET /ws HTTP/1.1\r\nHost: gateway.example.test\r\nConnection: close\r\n\r\n", @@ -9914,8 +10057,9 @@ network_policies: name.eq_ignore_ascii_case("x-openshell-request-id") .then(|| value.trim()) }) - .expect("denial should carry a supervisor receipt"); - assert!(uuid::Uuid::parse_str(request_id).is_ok()); + .expect("denial should carry a supervisor receipt") + .to_string(); + assert!(uuid::Uuid::parse_str(&request_id).is_ok()); let mut upstream_bytes = [0u8; 16]; assert!(matches!( @@ -9933,6 +10077,42 @@ network_policies: .expect("relay should finish") .unwrap() .unwrap(); + provider.force_flush().expect("flush spans"); + + let attributes = exporter + .get_finished_spans() + .expect("finished spans") + .into_iter() + .find(|span| span.name == "openshell.http.request") + .expect("HTTP enforcement span") + .attributes + .into_iter() + .map(|attribute| { + ( + attribute.key.as_str().to_string(), + attribute.value.to_string(), + ) + }) + .collect::>(); + assert_eq!(attributes["openshell.request.id"], request_id); + assert_eq!( + attributes["openshell.policy.runtime_generation"], + expected_generation + ); + assert_eq!(attributes["openshell.policy.hash"], expected_policy_hash); + assert_eq!(attributes["openshell.endpoint.id"], expected_endpoint_id); + + let event = ocsf + .json_events() + .into_iter() + .find(|event| event["metadata"]["correlation_uid"] == request_id) + .expect("correlated WebSocket handshake event"); + assert_eq!( + event["unmapped"]["policy_runtime_generation"], + expected_generation + ); + assert_eq!(event["unmapped"]["policy_hash"], expected_policy_hash); + assert_eq!(event["unmapped"]["endpoint_id"], expected_endpoint_id); } #[tokio::test] @@ -9949,18 +10129,39 @@ network_policies: async fn passthrough_relay_injects_token_grant_authorization_header() { let (generation_guard, ctx, fixture) = passthrough_token_grant_relay_context(Ok("grant-token")); + let expected_generation = generation_guard.captured_generation().to_string(); + let expected_snapshot = ctx + .provider_credentials + .as_ref() + .expect("provider state") + .snapshot(); + let exporter = opentelemetry_sdk::trace::InMemorySpanExporterBuilder::new().build(); + let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let ocsf = openshell_ocsf::test_support::OcsfCapture::default(); + let subscriber = tracing_subscriber::registry() + .with(openshell_otel::layer( + &provider, + "passthrough-provider-resource-correlation-test", + )) + .with(ocsf.clone()); let (mut app, mut relay_client) = tokio::io::duplex(8192); let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); - let relay = tokio::spawn(async move { - relay_passthrough_with_credentials( - &mut relay_client, - &mut relay_upstream, - &ctx, - &generation_guard, - None, - ) - .await - }); + let relay = tokio::spawn( + async move { + tracing::callsite::rebuild_interest_cache(); + relay_passthrough_with_credentials( + &mut relay_client, + &mut relay_upstream, + &ctx, + &generation_guard, + None, + ) + .await + } + .with_subscriber(subscriber), + ); app.write_all( b"GET /v1/projects HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer stale-token\r\nConnection: close\r\n\r\n", @@ -10004,6 +10205,48 @@ network_policies: .expect("relay should finish") .unwrap() .unwrap(); + provider.force_flush().expect("flush spans"); + + let attributes = exporter + .get_finished_spans() + .expect("finished spans") + .into_iter() + .find(|span| span.name == "openshell.http.request") + .expect("HTTP enforcement span") + .attributes + .into_iter() + .map(|attribute| { + ( + attribute.key.as_str().to_string(), + attribute.value.to_string(), + ) + }) + .collect::>(); + assert_eq!( + attributes["openshell.provider.environment.revision"], + expected_snapshot.revision.to_string() + ); + assert_eq!( + attributes["openshell.provider.credential.installation.id"], + expected_snapshot.installation_id + ); + let request_event = ocsf + .json_events() + .into_iter() + .find(|event| { + event["message"] + .as_str() + .is_some_and(|message| message.starts_with("HTTP_REQUEST ")) + }) + .expect("passthrough HTTP activity event"); + assert_eq!( + request_event["metadata"]["correlation_uid"], + attributes["openshell.request.id"] + ); + assert_eq!( + request_event["unmapped"]["policy_runtime_generation"], + expected_generation + ); fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); } @@ -10983,24 +11226,39 @@ network_policies: let tunnel_engine = engine .clone_engine_for_tunnel(engine.current_generation()) .unwrap(); + let expected_generation = tunnel_engine + .generation_guard() + .captured_generation() + .to_string(); let configs = encoded_slash_scoping_configs(); let (activity_tx, mut activity_rx) = tokio::sync::mpsc::channel(1); let ctx = L7EvalContext { activity_tx: Some(activity_tx), ..encoded_slash_scoping_ctx() }; + let exporter = opentelemetry_sdk::trace::InMemorySpanExporterBuilder::new().build(); + let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with(openshell_otel::layer( + &provider, + "route-selection-resource-correlation-test", + )); let (mut app, mut relay_client) = tokio::io::duplex(8192); let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); - let relay = tokio::spawn(async move { - relay_with_route_selection( - &configs, - tunnel_engine, - &mut relay_client, - &mut relay_upstream, - &ctx, - ) - .await - }); + let relay = tokio::spawn( + async move { + relay_with_route_selection( + &configs, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + } + .with_subscriber(subscriber), + ); app.write_all( b"GET /other HTTP/1.1\r\nHost: gateway.example.test\r\nConnection: close\r\n\r\n", @@ -11048,6 +11306,28 @@ network_policies: .expect("relay should finish") .unwrap() .unwrap(); + provider.force_flush().expect("flush spans"); + let attributes = exporter + .get_finished_spans() + .expect("finished spans") + .into_iter() + .find(|span| span.name == "openshell.http.request") + .expect("HTTP enforcement span") + .attributes + .into_iter() + .map(|attribute| { + ( + attribute.key.as_str().to_string(), + attribute.value.to_string(), + ) + }) + .collect::>(); + assert_eq!( + attributes["openshell.policy.runtime_generation"], + expected_generation + ); + assert!(!attributes.contains_key("openshell.policy.hash")); + assert!(!attributes.contains_key("openshell.endpoint.id")); } /// Canonicalization runs before the matching config is known, so diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index e6ec6ead5f..3fdbfbcce0 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -12,7 +12,6 @@ use miette::Result; use openshell_core::host_pattern::HostSelector; -use openshell_core::mcp::is_mcp_protocol; use openshell_core::policy::{ FilesystemPolicy, LandlockCompatibility, LandlockPolicy, ProcessPolicy, }; @@ -2455,11 +2454,20 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St // Every inspected protocol must bind grant reporting to the // policy selected before credential acquisition. ep["policy_hash"] = policy_hash.clone().into(); - if is_mcp_protocol(&e.protocol) { - // Derive endpoint identity from the policy endpoint while - // it is still available. Request handling carries this - // opaque value through exact path selection and never - // recomputes identity from a concrete request host. + if matches!( + PolicyL7Protocol::parse(&e.protocol), + Some( + PolicyL7Protocol::Rest + | PolicyL7Protocol::Graphql + | PolicyL7Protocol::JsonRpc + | PolicyL7Protocol::Mcp + | PolicyL7Protocol::Websocket + ) + ) { + // Derive endpoint identity from the configured policy + // endpoint while it is still available. Request handling + // carries this opaque value through path selection and + // never recomputes identity from a concrete request. ep["endpoint_id"] = openshell_core::endpoint_status::endpoint_id(e).into(); } @@ -3493,7 +3501,16 @@ mod tests { // this metadata independently and compare all remaining configuration. expected["policy_hash"] = deterministic_policy_hash(canonical_policy).into(); assert_eq!(proto_config["policy_hash"], expected["policy_hash"]); - if is_mcp_protocol(&endpoint.protocol) { + if matches!( + PolicyL7Protocol::parse(&endpoint.protocol), + Some( + PolicyL7Protocol::Rest + | PolicyL7Protocol::Graphql + | PolicyL7Protocol::JsonRpc + | PolicyL7Protocol::Mcp + | PolicyL7Protocol::Websocket + ) + ) { expected["endpoint_id"] = openshell_core::endpoint_status::endpoint_id(endpoint).into(); assert_eq!(proto_config["endpoint_id"], expected["endpoint_id"]); } else { @@ -3648,6 +3665,8 @@ network_policies: ), ), ]; + let expected_endpoint_count = 5; + let mut endpoint_ids = Vec::with_capacity(expected_endpoint_count); for (protocol, host, allowed, denied) in cases { let network_input = NetworkInput { @@ -3666,6 +3685,8 @@ network_policies: .query_endpoint_config(&network_input) .expect("query protobuf endpoint config") .unwrap_or_else(|| panic!("{protocol}: expected protobuf endpoint config")); + let selected = crate::l7::parse_l7_config(&proto_config) + .unwrap_or_else(|| panic!("{protocol}: expected parsed protobuf endpoint config")); let yaml_config: serde_json::Value = serde_json::from_str( &yaml_config .to_json_str() @@ -3685,6 +3706,21 @@ network_policies: .find(|endpoint| endpoint.host == host) .expect("fixture contains the exact endpoint"); assert_endpoint_config_parity(&yaml_config, &proto_config, &proto, endpoint); + if !selected.endpoint_id.is_empty() { + endpoint_ids.push(selected.endpoint_id.clone()); + } + if protocol != "MCP" { + let (sender, mut receiver) = + openshell_core::endpoint_status::endpoint_status_channel(); + assert!( + crate::l7::EndpointObserver::begin(Some(&sender), &selected).is_none(), + "{protocol}: generic endpoint identity must not enable MCP observation" + ); + assert!( + receiver.try_recv().is_err(), + "{protocol}: selecting the endpoint must not publish MCP status" + ); + } assert_eq!( yaml_config.get("mcp_versions").is_some(), protocol == "MCP", @@ -3699,6 +3735,14 @@ network_policies: "{protocol}: equivalent denied request must fail both ingress formats" ); } + + endpoint_ids.sort_unstable(); + endpoint_ids.dedup(); + assert_eq!( + endpoint_ids.len(), + expected_endpoint_count, + "each configured L7 endpoint must retain a distinct stable identity" + ); } #[test] diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 4e067b8e2d..eb5a93d7f8 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -5579,6 +5579,9 @@ async fn inject_token_grant_for_forward_request( let Some(snapshot) = crate::l7::token_grant_injection::pin_live_credentials(l7_ctx) else { return Ok(request.raw_header); }; + if let Some(correlation) = correlation { + correlation.record_provider_credential_snapshot(&snapshot); + } Ok(crate::l7::token_grant_injection::inject_for_selectors( request, l7_ctx, @@ -5965,6 +5968,7 @@ async fn handle_forward_proxy( crate::l7::correlation::RequestCorrelation::new(&correlation_request, &l7_ctx); let mut audited_client = correlation.observe_response(client); let client = &mut audited_client; + correlation.record_policy_runtime_generation(forward_generation_guard.captured_generation()); correlation.record_policy_decision("allow"); correlation.record_enforcement_action("allow"); forward_request_bytes = correlation_request.raw_header; @@ -6109,6 +6113,10 @@ async fn handle_forward_proxy( .await?; return Ok(()); }; + correlation.record_selected_policy_endpoint( + &l7_config.config.policy_hash, + &l7_config.config.endpoint_id, + ); endpoint_observer = crate::l7::EndpointObserver::begin_captured( l7_ctx.endpoint_observation_tx.as_ref(), &l7_config.config, @@ -14548,6 +14556,90 @@ network_policies: fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); } + #[tokio::test] + async fn forward_proxy_token_grant_records_the_atomically_pinned_provider_snapshot() { + use std::collections::BTreeMap; + use tracing::instrument::WithSubscriber as _; + use tracing_subscriber::layer::SubscriberExt as _; + + let (mut ctx, fixture) = forward_token_grant_context(Ok("grant-token")); + let expected_snapshot = ctx + .provider_credentials + .as_ref() + .expect("provider state") + .snapshot(); + let raw = b"GET http://api.example.test:8080/v1/projects HTTP/1.1\r\nHost: api.example.test:8080\r\nAuthorization: Bearer stale-token\r\nConnection: close\r\n\r\n".to_vec(); + let request = crate::l7::provider::L7Request { + action: "GET".into(), + target: "/v1/projects".into(), + query_params: TestHashMap::new(), + raw_header: raw.clone(), + body_length: crate::l7::provider::BodyLength::None, + }; + let exporter = opentelemetry_sdk::trace::InMemorySpanExporterBuilder::new().build(); + let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let subscriber = tracing_subscriber::registry().with(openshell_otel::layer( + &provider, + "forward-provider-resource-correlation-test", + )); + + async { + tracing::callsite::rebuild_interest_cache(); + let correlation = crate::l7::correlation::RequestCorrelation::with_request_id( + &request, + &ctx, + FORWARD_REQUEST_ID.into(), + ); + let with_token = inject_token_grant_for_forward_request( + "GET", + "/v1/projects", + raw, + &mut ctx, + None, + FORWARD_REQUEST_ID, + None, + Some(&correlation), + ) + .await + .expect("forward token grant should inject"); + assert!( + String::from_utf8_lossy(&with_token) + .contains("Authorization: Bearer grant-token\r\n") + ); + correlation.record_final_http_outcome("allowed"); + } + .with_subscriber(subscriber) + .await; + provider.force_flush().expect("flush spans"); + + let attributes = exporter + .get_finished_spans() + .expect("finished spans") + .into_iter() + .find(|span| span.name == "openshell.http.request") + .expect("HTTP enforcement span") + .attributes + .into_iter() + .map(|attribute| { + ( + attribute.key.as_str().to_string(), + attribute.value.to_string(), + ) + }) + .collect::>(); + assert_eq!( + attributes["openshell.provider.environment.revision"], + expected_snapshot.revision.to_string() + ); + assert_eq!( + attributes["openshell.provider.credential.installation.id"], + expected_snapshot.installation_id + ); + fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); + } + #[tokio::test] async fn forward_proxy_injects_token_exchange_before_rewriting_request() { let (mut ctx, fixture) = forward_token_exchange_context(Ok("grant-token"));