diff --git a/crates/openshell-ocsf/src/test_support.rs b/crates/openshell-ocsf/src/test_support.rs index 07d3a9188..fe603e923 100644 --- a/crates/openshell-ocsf/src/test_support.rs +++ b/crates/openshell-ocsf/src/test_support.rs @@ -40,6 +40,19 @@ impl OcsfCapture { pub fn events(&self) -> Vec { self.0.lock().expect("capture lock").clone() } + + /// Events captured so far, as their exported JSON. + /// + /// # Panics + /// + /// Panics if an event does not serialize. + #[must_use] + pub fn json_events(&self) -> Vec { + self.events() + .iter() + .map(|event| event.to_json().expect("serialize OCSF event")) + .collect() + } } #[cfg(test)] @@ -68,5 +81,6 @@ mod tests { let events = capture.events(); assert_eq!(events.len(), 1, "{events:?}"); assert_eq!(events[0].base().message.as_deref(), Some("captured")); + assert_eq!(capture.json_events()[0]["message"], "captured"); } } diff --git a/crates/openshell-server/src/auth/audit.rs b/crates/openshell-server/src/auth/audit.rs index 2584f4207..4d4248ceb 100644 --- a/crates/openshell-server/src/auth/audit.rs +++ b/crates/openshell-server/src/auth/audit.rs @@ -22,6 +22,9 @@ use tonic::Status; use super::identity::IdentityProvider; use super::principal::{Principal, SandboxIdentitySource}; +#[cfg(test)] +pub mod test_support; + /// Where a gateway connection came from. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ConnectionSource { diff --git a/crates/openshell-server/src/auth/audit/test_support.rs b/crates/openshell-server/src/auth/audit/test_support.rs new file mode 100644 index 000000000..56a08b12e --- /dev/null +++ b/crates/openshell-server/src/auth/audit/test_support.rs @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Fixtures for tests that assert on gateway audit events. + +use super::{ConnectionSource, RequestOrigin}; +use openshell_ocsf::test_support::OcsfCapture; +use openshell_ocsf::validation::schema::{ + load_class_schema, validate_known_fields, validate_required_fields, +}; +use serde_json::Value; +use std::future::Future; + +/// The `x-request-id` a test request carries. +pub const REQUEST_ID: &str = "6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f"; + +/// The address a test request comes from. +pub const PEER: &str = "203.0.113.7:51234"; + +/// The IP address of [`PEER`] as events record it. +pub const PEER_IP: &str = "203.0.113.7"; + +/// A direct connection from [`PEER`]. +pub fn peer() -> ConnectionSource { + ConnectionSource::Peer(PEER.parse().expect("peer address")) +} + +/// A request from [`PEER`] carrying [`REQUEST_ID`]. +pub fn origin() -> RequestOrigin { + let mut headers = http::HeaderMap::new(); + headers.insert("x-request-id", REQUEST_ID.parse().expect("request ID")); + RequestOrigin::new(Some(peer()), &headers) +} + +/// Run `handler` as a routed request to `path` from [`origin`], returning +/// its output and the OCSF events it emitted. +pub async fn audited(path: &str, handler: F) -> (F::Output, Vec) { + let capture = OcsfCapture::default(); + let output = { + let _guard = capture.install(); + super::with_request(path.to_string(), origin(), handler).await + }; + (output, capture.json_events()) +} + +/// Assert that `event` records the request from [`origin`]. +pub fn assert_origin(event: &Value) { + assert_eq!(event["src_endpoint"]["ip"], PEER_IP, "{event}"); + assert_eq!(event["metadata"]["correlation_uid"], REQUEST_ID, "{event}"); +} + +/// Assert that `event` never mentions `secret`. +pub fn assert_redacted(event: &Value, secret: &str) { + assert!( + !event.to_string().contains(secret), + "event mentions {secret}: {event}" + ); +} + +fn assert_conforms(event: &Value, class: &str) { + let schema = load_class_schema(class); + validate_required_fields(event, &schema); + validate_known_fields(event, &schema); +} + +/// Assert that `event` is an Authentication failure for `reason` on a +/// request to `path`. +pub fn assert_authentication_failure(event: &Value, path: &str, reason: &str) { + assert_conforms(event, "authentication"); + assert_eq!(event["class_uid"], 3002, "{event}"); + assert_eq!(event["activity_id"], 1, "{event}"); + assert_eq!(event["status_id"], 2, "{event}"); + assert_eq!(event["action_id"], 2, "{event}"); + assert_eq!(event["disposition_id"], 2, "{event}"); + assert_eq!(event["status_detail"], reason, "{event}"); + assert_eq!(event["service"]["name"], "openshell-gateway", "{event}"); + assert_eq!(event["http_request"]["url"]["path"], path, "{event}"); +} + +/// Assert that `event` is an Authentication logon for a request to `path`. +pub fn assert_logon(event: &Value, path: &str) { + assert_conforms(event, "authentication"); + assert_eq!(event["class_uid"], 3002, "{event}"); + assert_eq!(event["activity_id"], 1, "{event}"); + assert_eq!(event["status_id"], 1, "{event}"); + assert_eq!(event["action_id"], 1, "{event}"); + assert_eq!(event["disposition_id"], 1, "{event}"); + assert_eq!(event["severity_id"], 1, "{event}"); + assert_eq!(event["service"]["name"], "openshell-gateway", "{event}"); + assert_eq!(event["http_request"]["url"]["path"], path, "{event}"); +} + +/// Assert that `event` is an API Activity denial for `reason` on a request +/// to `path`. +pub fn assert_denial(event: &Value, path: &str, reason: &str) { + assert_conforms(event, "api_activity"); + assert_eq!(event["class_uid"], 6003, "{event}"); + assert_eq!(event["action_id"], 2, "{event}"); + assert_eq!(event["disposition_id"], 2, "{event}"); + assert_eq!(event["status_id"], 2, "{event}"); + assert_eq!(event["status_detail"], reason, "{event}"); + assert_eq!(event["http_request"]["url"]["path"], path, "{event}"); +} + +/// Assert that `finding` is the sandbox boundary finding paired with +/// `denial`. +pub fn assert_boundary_finding(denial: &Value, finding: &Value) { + assert_conforms(finding, "detection_finding"); + assert_eq!(finding["class_uid"], 2004, "{finding}"); + assert_eq!( + finding["finding_info"]["uid"], "sandbox-authorization-boundary", + "{finding}" + ); + assert_eq!(finding["severity_id"], 4, "{finding}"); + assert_eq!(finding["is_alert"], true, "{finding}"); + let correlation_uid = &denial["metadata"]["correlation_uid"]; + assert!(correlation_uid.is_string(), "{denial}"); + assert_eq!( + &finding["metadata"]["correlation_uid"], correlation_uid, + "{finding}" + ); +} diff --git a/crates/openshell-server/src/auth/authenticator.rs b/crates/openshell-server/src/auth/authenticator.rs index 2527b7b43..db789213f 100644 --- a/crates/openshell-server/src/auth/authenticator.rs +++ b/crates/openshell-server/src/auth/authenticator.rs @@ -269,20 +269,8 @@ pub mod test_support { mod tests { use super::test_support::MockAuthenticator; use super::*; - use crate::auth::identity::{Identity, IdentityProvider}; - use crate::auth::principal::UserPrincipal; - fn user_principal(subject: &str) -> Principal { - Principal::User(UserPrincipal { - identity: Identity { - subject: subject.to_string(), - display_name: None, - roles: vec![], - scopes: vec![], - provider: IdentityProvider::Oidc, - }, - }) - } + use crate::auth::principal::test_support::user; #[test] fn bearer_credential_matches_scheme_case_insensitively() { @@ -303,10 +291,10 @@ mod tests { #[tokio::test] async fn chain_returns_first_match() { let first = Arc::new(MockAuthenticator::returning(Ok(Authentication::Accepted( - user_principal("alice"), + user("alice"), )))); let second = Arc::new(MockAuthenticator::returning(Ok(Authentication::Accepted( - user_principal("bob"), + user("bob"), )))); let chain = AuthenticatorChain::new(vec![first.clone(), second.clone()]); let result = chain @@ -333,7 +321,7 @@ mod tests { Authentication::NotApplicable, ))); let second = Arc::new(MockAuthenticator::returning(Ok(Authentication::Accepted( - user_principal("bob"), + user("bob"), )))); let chain = AuthenticatorChain::new(vec![first.clone(), second.clone()]); let result = chain @@ -358,7 +346,7 @@ mod tests { Status::unauthenticated("bad token"), )))); let second = Arc::new(MockAuthenticator::returning(Ok(Authentication::Accepted( - user_principal("bob"), + user("bob"), )))); let chain = AuthenticatorChain::new(vec![first.clone(), second.clone()]); let err = chain @@ -416,7 +404,7 @@ mod tests { peer_decline(), )))); let second = Arc::new(MockAuthenticator::returning(Ok(Authentication::Accepted( - user_principal("bob"), + user("bob"), )))); let chain = AuthenticatorChain::new(vec![first, second]); diff --git a/crates/openshell-server/src/auth/guard.rs b/crates/openshell-server/src/auth/guard.rs index cfea707e9..221893294 100644 --- a/crates/openshell-server/src/auth/guard.rs +++ b/crates/openshell-server/src/auth/guard.rs @@ -130,30 +130,8 @@ pub fn require_operator( #[cfg(test)] mod tests { use super::*; - use crate::auth::identity::{Identity, IdentityProvider}; - use crate::auth::principal::{SandboxIdentitySource, SandboxPrincipal, UserPrincipal}; - fn user(subject: &str) -> Principal { - Principal::User(UserPrincipal { - identity: Identity { - subject: subject.to_string(), - display_name: None, - roles: vec![], - scopes: vec![], - provider: IdentityProvider::Oidc, - }, - }) - } - - fn sandbox(id: &str) -> Principal { - Principal::Sandbox(SandboxPrincipal { - sandbox_id: id.to_string(), - source: SandboxIdentitySource::BootstrapJwt { - issuer: "openshell-gateway:test".to_string(), - }, - trust_domain: Some("openshell".to_string()), - }) - } + use crate::auth::principal::test_support::{sandbox, user}; #[test] fn user_principal_bypasses_equality_check() { diff --git a/crates/openshell-server/src/auth/principal.rs b/crates/openshell-server/src/auth/principal.rs index 15c676cf7..e9809133d 100644 --- a/crates/openshell-server/src/auth/principal.rs +++ b/crates/openshell-server/src/auth/principal.rs @@ -124,6 +124,37 @@ impl Principal { } } +/// Principals for tests. +#[cfg(test)] +pub mod test_support { + use super::{Principal, SandboxIdentitySource, SandboxPrincipal, UserPrincipal}; + use crate::auth::identity::{Identity, IdentityProvider}; + + /// An OIDC user with no roles or scopes. + pub fn user(subject: &str) -> Principal { + Principal::User(UserPrincipal { + identity: Identity { + subject: subject.to_string(), + display_name: None, + roles: vec![], + scopes: vec![], + provider: IdentityProvider::Oidc, + }, + }) + } + + /// A sandbox holding a gateway JWT. + pub fn sandbox(sandbox_id: &str) -> Principal { + Principal::Sandbox(SandboxPrincipal { + sandbox_id: sandbox_id.to_string(), + source: SandboxIdentitySource::BootstrapJwt { + issuer: "openshell-gateway:test".to_string(), + }, + trust_domain: Some("openshell".to_string()), + }) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/openshell-server/src/auth/workspace_authz.rs b/crates/openshell-server/src/auth/workspace_authz.rs index 2ea3dda3c..d7c746ada 100644 --- a/crates/openshell-server/src/auth/workspace_authz.rs +++ b/crates/openshell-server/src/auth/workspace_authz.rs @@ -276,8 +276,10 @@ fn role_satisfies(member_role: ProtoWorkspaceRole, min_role: MinWorkspaceRole) - #[cfg(test)] mod tests { use super::*; + use crate::auth::audit::test_support::assert_boundary_finding; use crate::auth::identity::{Identity, IdentityProvider}; - use crate::auth::principal::{SandboxIdentitySource, SandboxPrincipal, UserPrincipal}; + use crate::auth::principal::UserPrincipal; + use crate::auth::principal::test_support::sandbox; use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::{WorkspaceMember, WorkspaceRole as ProtoWorkspaceRole}; use std::collections::HashMap; @@ -298,16 +300,6 @@ mod tests { }) } - fn sandbox_principal() -> Principal { - Principal::Sandbox(SandboxPrincipal { - sandbox_id: "sandbox-a".to_string(), - source: SandboxIdentitySource::BootstrapJwt { - issuer: "openshell-gateway:test".to_string(), - }, - trust_domain: Some("openshell".to_string()), - }) - } - async fn add_member(store: &Store, workspace: &str, subject: &str, role: ProtoWorkspaceRole) { let member = WorkspaceMember { metadata: Some(ObjectMeta { @@ -442,21 +434,13 @@ mod tests { let capture = openshell_ocsf::test_support::OcsfCapture::default(); let _guard = capture.install(); - require_platform_admin("openshell-admin", &sandbox_principal()) + require_platform_admin("openshell-admin", &sandbox("sandbox-a")) .expect_err("sandbox must be denied"); - let events: Vec<_> = capture - .events() - .iter() - .map(|event| event.to_json().unwrap()) - .collect(); + let events = capture.json_events(); assert_eq!(events.len(), 2, "{events:?}"); assert_eq!(events[0]["status_detail"], "platform_admin_required"); - assert_eq!(events[1]["class_uid"], 2004); - assert_eq!( - events[0]["metadata"]["correlation_uid"], - events[1]["metadata"]["correlation_uid"] - ); + assert_boundary_finding(&events[0], &events[1]); } #[tokio::test] @@ -520,7 +504,7 @@ mod tests { #[tokio::test] async fn sandbox_principal_passes_through() { let store = test_store().await; - let principal = sandbox_principal(); + let principal = sandbox("sandbox-a"); let result = authorize_workspace( &store, "openshell-admin", diff --git a/crates/openshell-server/src/grpc/auth_rpc.rs b/crates/openshell-server/src/grpc/auth_rpc.rs index b635bf041..2f9382580 100644 --- a/crates/openshell-server/src/grpc/auth_rpc.rs +++ b/crates/openshell-server/src/grpc/auth_rpc.rs @@ -526,7 +526,9 @@ async fn ensure_sandbox_exists( mod tests { use super::*; use crate::ServerState; - use crate::auth::audit::{ConnectionSource, RequestOrigin}; + use crate::auth::audit::test_support::{ + self, REQUEST_ID, assert_boundary_finding, assert_origin, assert_redacted, audited, + }; use crate::auth::compute_driver::ISSUE_SANDBOX_TOKEN_PATH; use crate::auth::identity::Identity; use crate::auth::principal::{Principal, SandboxPrincipal, UserPrincipal}; @@ -675,36 +677,11 @@ mod tests { } const GET_CURRENT_USER_PATH: &str = "/openshell.v1.OpenShell/GetCurrentUser"; - const REQUEST_ID: &str = "6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f"; - /// Run `handler` as a routed request to `path`, returning its result and - /// the OCSF events it emitted. - async fn audited(path: &str, handler: F) -> (F::Output, Vec) { - let capture = openshell_ocsf::test_support::OcsfCapture::default(); - let output = { - let _guard = capture.install(); - let mut headers = http::HeaderMap::new(); - headers.insert("x-request-id", REQUEST_ID.parse().unwrap()); - let origin = RequestOrigin::new( - Some(ConnectionSource::Peer("203.0.113.7:51234".parse().unwrap())), - &headers, - ); - audit::with_request(path.to_string(), origin, handler).await - }; - let events = capture - .events() - .iter() - .map(|event| event.to_json().unwrap()) - .collect(); - (output, events) - } - - fn assert_denial(event: &serde_json::Value, path: &str, reason: &str) { - assert_eq!(event["class_uid"], 6003, "{event}"); - assert_eq!(event["status_detail"], reason); - assert_eq!(event["http_request"]["url"]["path"], path); - assert_eq!(event["src_endpoint"]["ip"], "203.0.113.7"); - assert_eq!(event["metadata"]["correlation_uid"], REQUEST_ID); + /// Assert that `event` denies the request from the shared test origin. + fn assert_routed_denial(event: &serde_json::Value, path: &str, reason: &str) { + test_support::assert_denial(event, path, reason); + assert_origin(event); } fn compute_driver_principal(runtime_identity: &str) -> Principal { @@ -895,9 +872,8 @@ mod tests { events[0]["http_request"]["url"]["path"], REFRESH_SANDBOX_TOKEN_PATH ); - assert_eq!(events[0]["src_endpoint"]["ip"], "203.0.113.7"); - assert_eq!(events[0]["metadata"]["correlation_uid"], REQUEST_ID); - assert!(!events[0].to_string().contains(&first)); + assert_origin(&events[0]); + assert_redacted(&events[0], &first); } #[tokio::test] @@ -946,8 +922,8 @@ mod tests { REFRESH_SANDBOX_TOKEN_PATH ); assert_eq!(events[0]["metadata"]["correlation_uid"], REQUEST_ID); - assert!(!events[0].to_string().contains(¤t)); - assert_denial( + assert_redacted(&events[0], ¤t); + assert_routed_denial( &events[1], REFRESH_SANDBOX_TOKEN_PATH, "extension_service_not_selected", @@ -1237,8 +1213,7 @@ mod tests { events[0]["http_request"]["url"]["path"], ISSUE_SANDBOX_TOKEN_PATH ); - assert_eq!(events[0]["src_endpoint"]["ip"], "203.0.113.7"); - assert_eq!(events[0]["metadata"]["correlation_uid"], REQUEST_ID); + assert_origin(&events[0]); } #[tokio::test] @@ -1255,9 +1230,8 @@ mod tests { let err = result.expect_err("a gateway JWT must refresh instead"); assert_eq!(err.code(), tonic::Code::PermissionDenied); assert_eq!(events.len(), 2, "{events:?}"); - assert_denial(&events[0], ISSUE_SANDBOX_TOKEN_PATH, "method_not_permitted"); - assert_eq!(events[1]["class_uid"], 2004); - assert_eq!(events[1]["metadata"]["correlation_uid"], REQUEST_ID); + assert_routed_denial(&events[0], ISSUE_SANDBOX_TOKEN_PATH, "method_not_permitted"); + assert_boundary_finding(&events[0], &events[1]); } #[tokio::test] @@ -1269,8 +1243,8 @@ mod tests { let err = result.expect_err("sandboxes have no user identity"); assert_eq!(err.code(), tonic::Code::PermissionDenied); assert_eq!(events.len(), 2, "{events:?}"); - assert_denial(&events[0], GET_CURRENT_USER_PATH, "method_not_permitted"); - assert_eq!(events[1]["class_uid"], 2004); + assert_routed_denial(&events[0], GET_CURRENT_USER_PATH, "method_not_permitted"); + assert_boundary_finding(&events[0], &events[1]); } #[tokio::test] @@ -1378,27 +1352,18 @@ mod tests { events[0]["http_request"]["url"]["path"], ISSUE_SANDBOX_TOKEN_PATH ); - assert_eq!(events[0]["src_endpoint"]["ip"], "203.0.113.7"); - assert_eq!(events[0]["metadata"]["correlation_uid"], REQUEST_ID); - assert!(!events[0].to_string().contains("canary-driver-credential")); + assert_origin(&events[0]); + assert_redacted(&events[0], "canary-driver-credential"); } #[tokio::test] async fn refresh_rejects_user_principal() { - use crate::auth::identity::{Identity, IdentityProvider}; let state = state_with_issuer().await; let mut req = Request::new(RefreshSandboxTokenRequest { extension_service_names: Vec::new(), }); - req.extensions_mut().insert(Principal::User(UserPrincipal { - identity: Identity { - subject: "alice".to_string(), - display_name: None, - roles: vec![], - scopes: vec![], - provider: IdentityProvider::Oidc, - }, - })); + req.extensions_mut() + .insert(crate::auth::principal::test_support::user("alice")); let (result, events) = audited( REFRESH_SANDBOX_TOKEN_PATH, Box::pin(handle_refresh_sandbox_token(&state, req)), @@ -1407,7 +1372,7 @@ mod tests { let err = result.expect_err("user must not refresh"); assert_eq!(err.code(), tonic::Code::PermissionDenied); assert_eq!(events.len(), 1, "users raise no finding: {events:?}"); - assert_denial( + assert_routed_denial( &events[0], REFRESH_SANDBOX_TOKEN_PATH, "method_not_permitted", @@ -1434,12 +1399,12 @@ mod tests { let err = result.expect_err("K8s SA principal must not refresh"); assert_eq!(err.code(), tonic::Code::PermissionDenied); assert_eq!(events.len(), 2, "{events:?}"); - assert_denial( + assert_routed_denial( &events[0], REFRESH_SANDBOX_TOKEN_PATH, "method_not_permitted", ); - assert_eq!(events[1]["class_uid"], 2004); + assert_boundary_finding(&events[0], &events[1]); } #[tokio::test] diff --git a/crates/openshell-server/src/grpc/mutation_replay/tests.rs b/crates/openshell-server/src/grpc/mutation_replay/tests.rs index ad294c6f7..fe9e2c20d 100644 --- a/crates/openshell-server/src/grpc/mutation_replay/tests.rs +++ b/crates/openshell-server/src/grpc/mutation_replay/tests.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; +use crate::auth::audit::test_support::{assert_boundary_finding, assert_origin, audited}; use crate::grpc::test_support::{authed_request, test_server_state}; use openshell_core::proto::datamodel::v1::ObjectMeta; use openshell_core::proto::open_shell_server::OpenShell; @@ -1032,14 +1033,6 @@ async fn restart_after_effect_without_success_persistence_never_reexecutes() { ); } -fn captured_json(capture: &openshell_ocsf::test_support::OcsfCapture) -> Vec { - capture - .events() - .iter() - .map(|event| event.to_json().unwrap()) - .collect() -} - #[tokio::test] async fn sandbox_request_with_a_request_id_records_the_denial() { let state = test_server_state().await; @@ -1048,26 +1041,20 @@ async fn sandbox_request_with_a_request_id_records_the_denial() { request_id: uuid::Uuid::new_v4().to_string(), ..Default::default() }); - request.extensions_mut().insert(Principal::Sandbox( - crate::auth::principal::SandboxPrincipal { - sandbox_id: "sb-a".to_string(), - source: crate::auth::principal::SandboxIdentitySource::BootstrapJwt { - issuer: "openshell-gateway:test".to_string(), - }, - trust_domain: Some("openshell".to_string()), - }, - )); + request + .extensions_mut() + .insert(crate::auth::principal::test_support::sandbox("sb-a")); let capture = openshell_ocsf::test_support::OcsfCapture::default(); let _guard = capture.install(); let err = run(&state, request).await.unwrap_err(); assert_eq!(err.code(), Code::PermissionDenied); - let events = captured_json(&capture); + let events = capture.json_events(); assert_eq!(events.len(), 2, "{events:?}"); assert_eq!(events[0]["status_detail"], "method_not_permitted"); assert_eq!(events[0]["actor"]["user"]["type"], "Sandbox"); - assert_eq!(events[1]["class_uid"], 2004); + assert_boundary_finding(&events[0], &events[1]); } /// Refuses its caller from inside the admission worker, as a handler whose @@ -1099,11 +1086,11 @@ impl Mutation for DeniedInWorker { request: Request, ) -> Result, Status> { let principal = request.extensions().get::().unwrap(); - crate::auth::audit::emit_authorization_denial( + Err(crate::auth::audit::deny( principal, crate::auth::audit::AuthzDenial::PlatformAdminRequired, - ); - Err(Status::permission_denied("membership revoked")) + "membership revoked", + )) } fn capture(response: &Response) -> Result { resource_success(response.get_ref().workspace.as_ref()) @@ -1122,30 +1109,15 @@ async fn denial_in_the_admission_worker_keeps_the_request_context() { name: "worker-denial".to_string(), request_id: uuid::Uuid::new_v4().to_string(), }); - let request_id = "6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f"; - let mut headers = http::HeaderMap::new(); - headers.insert("x-request-id", request_id.parse().unwrap()); - let origin = crate::auth::audit::RequestOrigin::new( - Some(crate::auth::audit::ConnectionSource::Peer( - "203.0.113.7:51234".parse().unwrap(), - )), - &headers, - ); - let capture = openshell_ocsf::test_support::OcsfCapture::default(); - let _guard = capture.install(); - let err = crate::auth::audit::with_request( - "/openshell.v1.OpenShell/CreateWorkspace".to_string(), - origin, + let (result, events) = audited( + "/openshell.v1.OpenShell/CreateWorkspace", run(&state, request), ) - .await - .unwrap_err(); + .await; - assert_eq!(err.code(), Code::PermissionDenied); - let events = captured_json(&capture); + assert_eq!(result.unwrap_err().code(), Code::PermissionDenied); assert_eq!(events.len(), 1, "{events:?}"); assert_eq!(events[0]["api"]["operation"], "CreateWorkspace"); - assert_eq!(events[0]["src_endpoint"]["ip"], "203.0.113.7"); - assert_eq!(events[0]["metadata"]["correlation_uid"], request_id); + assert_origin(&events[0]); } diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 4a5f6dc25..0e3dc96a2 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -8221,10 +8221,9 @@ fn materialize_global_settings( #[cfg(test)] mod tests { use super::*; + use crate::auth::audit::test_support::assert_boundary_finding; use crate::auth::identity::{Identity, IdentityProvider}; - use crate::auth::principal::{ - Principal, SandboxIdentitySource, SandboxPrincipal, UserPrincipal, - }; + use crate::auth::principal::{Principal, UserPrincipal}; use crate::grpc::test_support::{authed_request, test_server_state}; /// An in-memory store with the example profiles imported at platform scope. @@ -8453,13 +8452,7 @@ mod tests { pub(super) fn with_sandbox(mut request: Request, sandbox_id: &str) -> Request { request .extensions_mut() - .insert(Principal::Sandbox(SandboxPrincipal { - sandbox_id: sandbox_id.to_string(), - source: SandboxIdentitySource::BootstrapJwt { - issuer: "openshell-gateway:test".to_string(), - }, - trust_domain: Some("openshell".to_string()), - })); + .insert(crate::auth::principal::test_support::sandbox(sandbox_id)); request } @@ -11697,18 +11690,14 @@ mod tests { .await .expect_err("missing name must not leak existence to sandbox callers"); assert_eq!(err.code(), Code::PermissionDenied); - let events: Vec<_> = capture - .events() - .iter() - .map(|event| event.to_json().unwrap()) - .collect(); + let events = capture.json_events(); assert_eq!(events.len(), 2, "{events:?}"); assert_eq!(events[0]["status_detail"], "cross_sandbox"); assert_eq!( events[0]["resources"], serde_json::json!([{"name": "missing-sandbox", "type": "sandbox"}]) ); - assert_eq!(events[1]["class_uid"], 2004); + assert_boundary_finding(&events[0], &events[1]); } #[tokio::test] @@ -11810,14 +11799,10 @@ mod tests { .expect_err("later frame must not switch sandbox ids"); assert_eq!(err.code(), Code::PermissionDenied); - let events: Vec<_> = capture - .events() - .iter() - .map(|event| event.to_json().unwrap()) - .collect(); + let events = capture.json_events(); assert_eq!(events.len(), 2, "{events:?}"); assert_eq!(events[0]["status_detail"], "cross_sandbox"); - assert_eq!(events[1]["class_uid"], 2004); + assert_boundary_finding(&events[0], &events[1]); } #[tokio::test] diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index eae8f3fa2..cac481b14 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -4087,6 +4087,10 @@ async fn run_exec_with_russh( #[cfg(test)] mod tests { use super::*; + use crate::auth::audit::test_support::{ + assert_authentication_failure, assert_boundary_finding, assert_logon, assert_origin, + assert_redacted, audited, + }; use crate::compute::NoopTestDriver; #[test] @@ -4516,7 +4520,6 @@ mod tests { } const FORWARD_TCP_PATH: &str = "/openshell.v1.OpenShell/ForwardTcp"; - const FORWARD_REQUEST_ID: &str = "6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f"; fn forward_caller() -> crate::auth::principal::Principal { authed_request(()) @@ -4535,30 +4538,11 @@ mod tests { Result, Vec, ) { - let capture = openshell_ocsf::test_support::OcsfCapture::default(); - let result = { - let _guard = capture.install(); - let mut headers = http::HeaderMap::new(); - headers.insert("x-request-id", FORWARD_REQUEST_ID.parse().unwrap()); - let origin = crate::auth::audit::RequestOrigin::new( - Some(crate::auth::audit::ConnectionSource::Peer( - "203.0.113.7:51234".parse().unwrap(), - )), - &headers, - ); - crate::auth::audit::with_request( - FORWARD_TCP_PATH.to_string(), - origin, - acquire_forward_connection_guard(state, &forward_caller(), init, sandbox), - ) - .await - }; - let events = capture - .events() - .iter() - .map(|event| event.to_json().unwrap()) - .collect(); - (result, events) + audited( + FORWARD_TCP_PATH, + acquire_forward_connection_guard(state, &forward_caller(), init, sandbox), + ) + .await } async fn store_ssh_session( @@ -4595,15 +4579,13 @@ mod tests { fn assert_ssh_token_failure(events: &[serde_json::Value], reason: &str, token: &str) { assert_eq!(events.len(), 1, "{events:?}"); let event = &events[0]; - assert_eq!(event["class_uid"], 3002); - assert_eq!(event["status_id"], 2); - assert_eq!(event["status_detail"], reason); + assert_authentication_failure(event, FORWARD_TCP_PATH, reason); assert_eq!(event["auth_protocol"], "ssh_session_token"); assert_eq!(event["user"]["uid"], "dev-user"); - assert_eq!(event["http_request"]["url"]["path"], FORWARD_TCP_PATH); - assert_eq!(event["src_endpoint"]["ip"], "203.0.113.7"); - assert_eq!(event["metadata"]["correlation_uid"], FORWARD_REQUEST_ID); - assert!(token.is_empty() || !event.to_string().contains(token)); + assert_origin(event); + if !token.is_empty() { + assert_redacted(event, token); + } } #[tokio::test] @@ -4728,9 +4710,9 @@ mod tests { result.expect("older clients still send a session token"); assert_eq!(events.len(), 1, "{events:?}"); - assert_eq!(events[0]["status_id"], 1); + assert_logon(&events[0], FORWARD_TCP_PATH); assert_eq!(events[0]["auth_protocol"], "ssh_session_token"); - assert!(!events[0].to_string().contains("canary-ssh-token")); + assert_redacted(&events[0], "canary-ssh-token"); } #[tokio::test] @@ -4751,15 +4733,11 @@ mod tests { assert!(repeated.is_empty(), "{repeated:?}"); assert_eq!(events.len(), 1, "{events:?}"); let event = &events[0]; - assert_eq!(event["class_uid"], 3002); - assert_eq!(event["status_id"], 1); - assert_eq!(event["severity_id"], 1); + assert_logon(event, FORWARD_TCP_PATH); assert_eq!(event["auth_protocol"], "ssh_session_token"); assert_eq!(event["user"]["uid"], "dev-user"); - assert_eq!(event["http_request"]["url"]["path"], FORWARD_TCP_PATH); - assert_eq!(event["src_endpoint"]["ip"], "203.0.113.7"); - assert_eq!(event["metadata"]["correlation_uid"], FORWARD_REQUEST_ID); - assert!(!event.to_string().contains("canary-ssh-token")); + assert_origin(event); + assert_redacted(event, "canary-ssh-token"); } #[tokio::test] @@ -9973,8 +9951,6 @@ mod tests { #[tokio::test] async fn sandbox_naming_another_sandbox_is_recorded_as_cross_sandbox() { - use crate::auth::principal::{Principal, SandboxIdentitySource, SandboxPrincipal}; - let state = test_server_state().await; for name in ["own", "victim"] { state @@ -9983,13 +9959,7 @@ mod tests { .await .unwrap(); } - let principal = Principal::Sandbox(SandboxPrincipal { - sandbox_id: "sandbox-own".to_string(), - source: SandboxIdentitySource::BootstrapJwt { - issuer: "openshell-gateway:test".to_string(), - }, - trust_domain: Some("openshell".to_string()), - }); + let principal = crate::auth::principal::test_support::sandbox("sandbox-own"); let capture = openshell_ocsf::test_support::OcsfCapture::default(); let _guard = capture.install(); @@ -10015,32 +9985,20 @@ mod tests { .expect_err("another sandbox must be refused"); assert_eq!(err.code(), tonic::Code::PermissionDenied); - let events: Vec<_> = capture - .events() - .iter() - .map(|event| event.to_json().unwrap()) - .collect(); + let events = capture.json_events(); assert_eq!(events.len(), 2, "{events:?}"); assert_eq!(events[0]["status_detail"], "cross_sandbox"); assert_eq!( events[0]["resources"], serde_json::json!([{"name": "victim", "type": "sandbox"}]) ); - assert_eq!(events[1]["class_uid"], 2004); + assert_boundary_finding(&events[0], &events[1]); } #[tokio::test] async fn sandbox_whose_own_record_is_gone_is_denied_without_a_finding() { - use crate::auth::principal::{Principal, SandboxIdentitySource, SandboxPrincipal}; - let state = test_server_state().await; - let principal = Principal::Sandbox(SandboxPrincipal { - sandbox_id: "sandbox-own".to_string(), - source: SandboxIdentitySource::BootstrapJwt { - issuer: "openshell-gateway:test".to_string(), - }, - trust_domain: Some("openshell".to_string()), - }); + let principal = crate::auth::principal::test_support::sandbox("sandbox-own"); let capture = openshell_ocsf::test_support::OcsfCapture::default(); let _guard = capture.install(); diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index a9473ffff..cc08e2ebc 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -3326,11 +3326,7 @@ mod tests { .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(); + let events = capture.json_events(); 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()); @@ -3342,18 +3338,20 @@ mod tests { mod auth_router { use super::*; + use crate::auth::audit::test_support::{ + PEER_IP, REQUEST_ID, assert_authentication_failure, assert_boundary_finding, + assert_denial, assert_logon, assert_redacted, peer, + }; use crate::auth::authenticator::AuthRejection; use crate::auth::authenticator::test_support::MockAuthenticator; use crate::auth::identity::{Identity, IdentityProvider}; + use crate::auth::principal::test_support::{sandbox, user}; use crate::auth::principal::{ Principal, SandboxIdentitySource, SandboxPrincipal, UserPrincipal, }; use http_body_util::Full; use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; use openshell_ocsf::test_support::OcsfCapture; - use openshell_ocsf::validation::schema::{ - load_class_schema, validate_known_fields, validate_required_fields, - }; use serde::Serialize; use std::sync::Arc; use std::sync::Mutex; @@ -3410,18 +3408,6 @@ mod tests { .map(|v| v.to_str().unwrap().to_string()) } - fn user_principal(subject: &str) -> Principal { - Principal::User(UserPrincipal { - identity: Identity { - subject: subject.to_string(), - display_name: None, - roles: vec![], - scopes: vec![], - provider: IdentityProvider::Oidc, - }, - }) - } - fn provider_writer_principal() -> Principal { Principal::User(UserPrincipal { identity: Identity { @@ -3444,16 +3430,6 @@ mod tests { } } - fn sandbox_principal() -> Principal { - Principal::Sandbox(SandboxPrincipal { - sandbox_id: "sandbox-a".to_string(), - source: SandboxIdentitySource::BootstrapJwt { - issuer: "openshell-gateway:test".to_string(), - }, - trust_domain: Some("openshell".to_string()), - }) - } - fn operator_principal() -> OperatorPrincipal { let mut identity = mtls_identity("operator"); identity.roles = vec!["operator".to_string()]; @@ -3513,7 +3489,7 @@ mod tests { user.scopes = vec!["openshell:all".into()]; for principal in [ Principal::User(UserPrincipal { identity: user }), - sandbox_principal(), + sandbox("sandbox-a"), Principal::Peer(crate::auth::principal::PeerPrincipal { replica_id: "replica".into(), pod_uid: "pod".into(), @@ -3719,7 +3695,7 @@ mod tests { #[tokio::test] async fn user_principal_lands_in_request_extensions() { let mock = Arc::new(MockAuthenticator::returning(Ok(Authentication::Accepted( - user_principal("alice"), + user("alice"), )))); let chain = AuthenticatorChain::new(vec![mock]); let (recorder, seen) = PrincipalRecorder::new(); @@ -3738,7 +3714,7 @@ mod tests { #[tokio::test] async fn sandbox_principal_lands_in_request_extensions() { let mock = Arc::new(MockAuthenticator::returning(Ok(Authentication::Accepted( - sandbox_principal(), + sandbox("sandbox-a"), )))); let chain = AuthenticatorChain::new(vec![mock]); let (recorder, seen) = PrincipalRecorder::new(); @@ -3757,7 +3733,7 @@ mod tests { #[tokio::test] async fn sandbox_principal_can_call_allowlisted_method() { let mock = Arc::new(MockAuthenticator::returning(Ok(Authentication::Accepted( - sandbox_principal(), + sandbox("sandbox-a"), )))); let chain = AuthenticatorChain::new(vec![mock]); let (recorder, seen) = PrincipalRecorder::new(); @@ -3868,7 +3844,7 @@ mod tests { "/openshell.v1.OpenShell/ApproveDraftChunk", ] { let mock = Arc::new(MockAuthenticator::returning(Ok(Authentication::Accepted( - sandbox_principal(), + sandbox("sandbox-a"), )))); let chain = AuthenticatorChain::new(vec![mock]); let (recorder, seen) = PrincipalRecorder::new(); @@ -3882,20 +3858,13 @@ mod tests { } fn assert_authorization_denial(event: &serde_json::Value, operation: &str, reason: &str) { - let schema = load_class_schema("api_activity"); - validate_required_fields(event, &schema); - validate_known_fields(event, &schema); - assert_eq!(event["class_uid"], 6003); - assert_eq!(event["action_id"], 2); - assert_eq!(event["disposition_id"], 2); - assert_eq!(event["status_id"], 2); - assert_eq!(event["status_detail"], reason); + assert_denial( + event, + &format!("/openshell.v1.OpenShell/{operation}"), + reason, + ); assert_eq!(event["api"]["operation"], operation); assert_eq!(event["api"]["service"]["name"], "openshell.v1.OpenShell"); - assert_eq!( - event["http_request"]["url"]["path"], - format!("/openshell.v1.OpenShell/{operation}") - ); } #[tokio::test] @@ -3903,15 +3872,14 @@ mod tests { let capture = OcsfCapture::default(); let _guard = capture.install(); let chain = AuthenticatorChain::new(vec![Arc::new(MockAuthenticator::returning(Ok( - Authentication::Accepted(sandbox_principal()), + Authentication::Accepted(sandbox("sandbox-a")), )))]); let (recorder, _) = PrincipalRecorder::new(); let mut router = AuthGrpcRouter::new(recorder, Some(chain), None); - let request_id = "6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f"; let request = Request::builder() .uri("/openshell.v1.OpenShell/CreateProvider") .header("authorization", "Bearer canary-secret-token-9f2c") - .header("x-request-id", request_id) + .header("x-request-id", REQUEST_ID) .body(Full::new(Bytes::new())) .unwrap(); @@ -3928,19 +3896,10 @@ mod tests { assert_eq!(events[0]["severity_id"], 3); let finding = &events[1]; - let schema = load_class_schema("detection_finding"); - validate_required_fields(finding, &schema); - validate_known_fields(finding, &schema); - assert_eq!(finding["class_uid"], 2004); - assert_eq!(finding["severity_id"], 4); - assert_eq!(finding["is_alert"], true); - assert_eq!( - finding["finding_info"]["uid"], - "sandbox-authorization-boundary" - ); + assert_boundary_finding(&events[0], finding); for event in &events { - assert_eq!(event["metadata"]["correlation_uid"], request_id); - assert!(!event.to_string().contains("canary-secret-token")); + assert_eq!(event["metadata"]["correlation_uid"], REQUEST_ID); + assert_redacted(event, "canary-secret-token"); } } @@ -3949,7 +3908,7 @@ mod tests { let capture = OcsfCapture::default(); let _guard = capture.install(); let chain = AuthenticatorChain::new(vec![Arc::new(MockAuthenticator::returning(Ok( - Authentication::Accepted(user_principal("alice")), + Authentication::Accepted(user("alice")), )))]); let policy = AuthzPolicy { admin_role: "openshell-admin".to_string(), @@ -3980,7 +3939,7 @@ mod tests { let capture = OcsfCapture::default(); let _guard = capture.install(); let chain = AuthenticatorChain::new(vec![Arc::new(MockAuthenticator::returning(Ok( - Authentication::Accepted(sandbox_principal()), + Authentication::Accepted(sandbox("sandbox-a")), )))]); let (recorder, _) = PrincipalRecorder::new(); let mut router = AuthGrpcRouter::new(recorder, Some(chain), None); @@ -4001,7 +3960,7 @@ mod tests { let capture = OcsfCapture::default(); let _guard = capture.install(); let chain = AuthenticatorChain::new(vec![Arc::new(MockAuthenticator::returning(Ok( - Authentication::Accepted(sandbox_principal()), + Authentication::Accepted(sandbox("sandbox-a")), )))]); let (recorder, seen) = PrincipalRecorder::new(); let mut router = AuthGrpcRouter::new(recorder, Some(chain), None); @@ -4096,7 +4055,7 @@ mod tests { let capture = OcsfCapture::default(); let _guard = capture.install(); let chain = AuthenticatorChain::new(vec![Arc::new(MockAuthenticator::returning(Ok( - Authentication::Accepted(sandbox_principal()), + Authentication::Accepted(sandbox("sandbox-a")), )))]); let mut router = AuthGrpcRouter::new(CrossSandboxHandler, Some(chain), None) .with_connection_source(ConnectionSource::Peer("10.42.0.7:51234".parse().unwrap())); @@ -4115,10 +4074,7 @@ mod tests { events[0]["resources"], serde_json::json!([{"uid": "sandbox-b", "type": "sandbox"}]) ); - assert_eq!(events[1]["class_uid"], 2004); - let correlation_uid = events[0]["metadata"]["correlation_uid"].clone(); - assert!(correlation_uid.is_string()); - assert_eq!(events[1]["metadata"]["correlation_uid"], correlation_uid); + assert_boundary_finding(&events[0], &events[1]); } #[tokio::test] @@ -4202,41 +4158,26 @@ mod tests { assert_eq!(grpc_status(&res).as_deref(), Some("16")); } - fn captured_json(capture: &OcsfCapture) -> Vec { - capture - .events() - .iter() - .map(|event| event.to_json().unwrap()) - .collect() - } - /// Captured events other than authentication events. fn authorization_events(capture: &OcsfCapture) -> Vec { - captured_json(capture) + capture + .json_events() .into_iter() .filter(|event| event["class_uid"] != 3002) .collect() } - fn assert_authentication_failure( + /// Assert that `event` is the router's unidentified Authentication + /// failure for `reason`, at `severity_id`. + fn assert_router_authentication_failure( event: &serde_json::Value, path: &str, reason: &str, severity_id: u8, ) { - let schema = load_class_schema("authentication"); - validate_required_fields(event, &schema); - validate_known_fields(event, &schema); - assert_eq!(event["class_uid"], 3002); - assert_eq!(event["activity_id"], 1); - assert_eq!(event["status_id"], 2); - assert_eq!(event["action_id"], 2); - assert_eq!(event["disposition_id"], 2); + assert_authentication_failure(event, path, reason); assert_eq!(event["severity_id"], severity_id); - assert_eq!(event["status_detail"], reason); assert_eq!(event["user"]["type_id"], 0); - assert_eq!(event["service"]["name"], "openshell-gateway"); - assert_eq!(event["http_request"]["url"]["path"], path); assert_eq!(event["metadata"]["product"]["name"], "OpenShell Gateway"); } @@ -4254,9 +4195,9 @@ mod tests { let res = router.call(empty_request(path)).await.unwrap(); assert_eq!(grpc_status(&res).as_deref(), Some("16")); - let events = captured_json(&capture); + let events = capture.json_events(); assert_eq!(events.len(), 1, "{events:?}"); - assert_authentication_failure(&events[0], path, "missing_credential", 2); + assert_router_authentication_failure(&events[0], path, "missing_credential", 2); assert!(events[0].get("auth_protocol_id").is_none()); } @@ -4272,9 +4213,9 @@ mod tests { let res = router.call(empty_request(path)).await.unwrap(); assert_eq!(grpc_status(&res).as_deref(), Some("16")); - let events = captured_json(&capture); + let events = capture.json_events(); assert_eq!(events.len(), 1, "{events:?}"); - assert_authentication_failure(&events[0], path, "missing_credential", 2); + assert_router_authentication_failure(&events[0], path, "missing_credential", 2); assert_eq!(events[0]["auth_protocol_id"], 99); assert_eq!(events[0]["auth_protocol"], "mtls"); } @@ -4299,11 +4240,11 @@ mod tests { assert!(seen.lock().unwrap().is_none()); assert_eq!(grpc_status(&res).as_deref(), Some("16")); - let events = captured_json(&capture); + let events = capture.json_events(); assert_eq!(events.len(), 1, "{events:?}"); - assert_authentication_failure(&events[0], path, "conflicting_credentials", 3); + assert_router_authentication_failure(&events[0], path, "conflicting_credentials", 3); assert_eq!(events[0]["auth_protocol"], "mtls"); - assert!(!events[0].to_string().contains("canary-secret-token")); + assert_redacted(&events[0], "canary-secret-token"); } async fn call_with_rejection( @@ -4327,7 +4268,7 @@ mod tests { assert!(seen.lock().unwrap().is_none()); let status = tonic::Status::from_header_map(res.headers()).expect("grpc status"); - (status, captured_json(&capture)) + (status, capture.json_events()) } #[tokio::test] @@ -4350,14 +4291,12 @@ mod tests { let status = tonic::Status::from_header_map(res.headers()).expect("grpc status"); assert_eq!(status.code(), tonic::Code::Unauthenticated); assert_eq!(status.message(), "missing authorization header"); - let events = captured_json(&capture); + let events = capture.json_events(); assert_eq!(events.len(), 1, "{events:?}"); - assert_authentication_failure(&events[0], path, "invalid_credential", 3); + assert_router_authentication_failure(&events[0], path, "invalid_credential", 3); 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, @@ -4378,22 +4317,18 @@ mod tests { router.call(request).await.unwrap(); - let mut events = captured_json(&capture); + let mut events = capture.json_events(); 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; + let event = missing_credential_event(peer(), REQUEST_ID).await; assert_eq!( event["src_endpoint"], - serde_json::json!({"ip": "203.0.113.7", "port": 51234}) + serde_json::json!({"ip": PEER_IP, "port": 51234}) ); assert_eq!(event["metadata"]["correlation_uid"], REQUEST_ID); } @@ -4406,7 +4341,7 @@ mod tests { ) .await; - assert_eq!(event["src_endpoint"]["ip"], "203.0.113.7"); + assert_eq!(event["src_endpoint"]["ip"], PEER_IP); } #[tokio::test] @@ -4420,7 +4355,7 @@ mod tests { serde_json::json!({"name": "edge-tunnel"}) ); assert!(event["metadata"].get("correlation_uid").is_none()); - assert!(!event.to_string().contains("attacker")); + assert_redacted(&event, "attacker"); } #[tokio::test] @@ -4432,9 +4367,9 @@ mod tests { async { Ok::<_, Infallible>(Response::new(tonic::body::Body::empty())) } }); let chain = AuthenticatorChain::new(vec![Arc::new(MockAuthenticator::returning(Ok( - Authentication::Accepted(user_principal("alice")), + Authentication::Accepted(user("alice")), )))]); - let source = ConnectionSource::Peer("203.0.113.7:51234".parse().unwrap()); + let source = peer(); let mut router = AuthGrpcRouter::new(inner, Some(chain), None).with_connection_source(source); @@ -4462,11 +4397,11 @@ mod tests { assert_eq!(status.code(), tonic::Code::Unauthenticated); assert_eq!(status.message(), "invalid token: ExpiredSignature"); assert_eq!(events.len(), 1, "{events:?}"); - assert_authentication_failure(&events[0], path, "expired_credential", 2); + assert_router_authentication_failure(&events[0], path, "expired_credential", 2); assert_eq!(events[0]["auth_protocol_id"], 4); assert_eq!(events[0]["auth_protocol"], "OpenID"); - assert!(!events[0].to_string().contains("canary-secret-token")); - assert!(!events[0].to_string().contains("ExpiredSignature")); + assert_redacted(&events[0], "canary-secret-token"); + assert_redacted(&events[0], "ExpiredSignature"); } #[tokio::test] @@ -4486,7 +4421,7 @@ mod tests { assert_eq!(status.code(), tonic::Code::Unauthenticated); assert_eq!(events.len(), 1, "{events:?}"); - assert_authentication_failure(&events[0], path, "stale_identity", 3); + assert_router_authentication_failure(&events[0], path, "stale_identity", 3); assert_eq!(events[0]["auth_protocol_id"], 99); assert_eq!(events[0]["auth_protocol"], "sandbox_jwt"); } @@ -4500,32 +4435,16 @@ mod tests { .unwrap() } - fn assert_logon(event: &serde_json::Value, path: &str) { - let schema = load_class_schema("authentication"); - validate_required_fields(event, &schema); - validate_known_fields(event, &schema); - assert_eq!(event["class_uid"], 3002); - assert_eq!(event["activity_id"], 1); - assert_eq!(event["status_id"], 1); - assert_eq!(event["action_id"], 1); - assert_eq!(event["disposition_id"], 1); - assert_eq!(event["severity_id"], 1); - assert_eq!(event["service"]["name"], "openshell-gateway"); - assert_eq!(event["http_request"]["url"]["path"], path); - } - #[tokio::test] async fn credential_logs_on_once_and_exempt_requests_emit_nothing() { let capture = OcsfCapture::default(); let _guard = capture.install(); let chain = AuthenticatorChain::new(vec![Arc::new(MockAuthenticator::returning(Ok( - Authentication::Accepted(user_principal("alice")), + Authentication::Accepted(user("alice")), )))]); let (recorder, _) = PrincipalRecorder::new(); - let mut router = AuthGrpcRouter::new(recorder, Some(chain), None) - .with_connection_source(ConnectionSource::Peer( - "203.0.113.7:51234".parse().unwrap(), - )); + let mut router = + AuthGrpcRouter::new(recorder, Some(chain), None).with_connection_source(peer()); let path = "/openshell.v1.OpenShell/ListSandboxes"; for request in [ @@ -4545,7 +4464,7 @@ mod tests { assert!(grpc_status(&res).is_none()); } - let events = captured_json(&capture); + let events = capture.json_events(); assert_eq!(events.len(), 1, "{events:?}"); assert_logon(&events[0], path); assert_eq!( @@ -4555,10 +4474,10 @@ mod tests { assert_eq!(events[0]["auth_protocol_id"], 4); assert_eq!( events[0]["src_endpoint"], - serde_json::json!({"ip": "203.0.113.7", "port": 51234}) + serde_json::json!({"ip": PEER_IP, "port": 51234}) ); assert_eq!(events[0]["metadata"]["correlation_uid"], REQUEST_ID); - assert!(!events[0].to_string().contains("canary-secret-token")); + assert_redacted(&events[0], "canary-secret-token"); } #[tokio::test] @@ -4566,7 +4485,7 @@ mod tests { let capture = OcsfCapture::default(); let _guard = capture.install(); let chain = AuthenticatorChain::new(vec![Arc::new(MockAuthenticator::returning(Ok( - Authentication::Accepted(sandbox_principal()), + Authentication::Accepted(sandbox("sandbox-a")), )))]); let (recorder, _) = PrincipalRecorder::new(); let mut router = AuthGrpcRouter::new(recorder, Some(chain), None); @@ -4579,7 +4498,7 @@ mod tests { .unwrap(); } - let events = captured_json(&capture); + let events = capture.json_events(); assert_eq!(events.len(), 2, "{events:?}"); for event in &events { assert_logon(event, path); @@ -4612,9 +4531,9 @@ mod tests { AuthGrpcRouter::new( PrincipalRecorder::new().0, Some(AuthenticatorChain::new(vec![Arc::new( - MockAuthenticator::returning(Ok(Authentication::Accepted( - user_principal("alice"), - ))), + MockAuthenticator::returning(Ok(Authentication::Accepted(user( + "alice", + )))), )])), None, ), @@ -4634,7 +4553,7 @@ mod tests { router.call(request).await.unwrap(); } - let events = captured_json(&capture); + let events = capture.json_events(); assert_eq!(events.len(), 1, "{case}: {events:?}"); } } @@ -4657,7 +4576,7 @@ mod tests { .await .unwrap(); - let events = captured_json(&capture); + let events = capture.json_events(); assert_eq!(events.len(), 1, "{events:?}"); assert_logon(&events[0], path); assert_eq!(events[0]["user"]["type"], "Gateway Peer"); @@ -4671,7 +4590,7 @@ mod tests { "/openshell.v1.OpenShell/RefreshSandboxToken", ] { for (principal, principal_type) in [ - (user_principal("alice"), "User"), + (user("alice"), "User"), (Principal::Operator(operator_principal()), "Admin"), ( Principal::Peer(crate::auth::principal::PeerPrincipal { @@ -4696,7 +4615,7 @@ mod tests { assert_eq!(grpc_status(&res).as_deref(), Some("7"), "{path}"); assert!(seen.lock().unwrap().is_none(), "{path}"); - let events = captured_json(&capture); + let events = capture.json_events(); assert_eq!(events.len(), 2, "{principal_type} {path}: {events:?}"); assert_logon(&events[0], path); assert_eq!(events[0]["user"]["type"], principal_type); @@ -4744,7 +4663,7 @@ mod tests { .unwrap(); assert!(seen.lock().unwrap().is_some(), "{path} reached the handler"); - let events = captured_json(&capture); + let events = capture.json_events(); assert!(events.is_empty(), "{path}: {events:?}"); } } @@ -4758,7 +4677,7 @@ mod tests { for _ in 0..2 { let chain = AuthenticatorChain::new(vec![Arc::new(MockAuthenticator::returning( - Ok(Authentication::Accepted(user_principal("alice"))), + Ok(Authentication::Accepted(user("alice"))), ))]); let mut connection = AuthGrpcRouter::new(PrincipalRecorder::new().0, Some(chain), None) @@ -4769,7 +4688,7 @@ mod tests { .unwrap(); } - let events = captured_json(&capture); + let events = capture.json_events(); assert_eq!(events.len(), 1, "{events:?}"); } @@ -4826,7 +4745,7 @@ mod tests { } } - let events = captured_json(&capture); + let events = capture.json_events(); assert_eq!(events.len(), 2, "{principal_type}: {events:?}"); for event in &events { assert_logon(event, path); @@ -4850,7 +4769,7 @@ mod tests { .unwrap(); assert!(seen.lock().unwrap().is_some()); - let events = captured_json(&capture); + let events = capture.json_events(); assert!(events.is_empty(), "{events:?}"); }