From 4b7486e73d03425d2d5cb3606325f89281e6ff79 Mon Sep 17 00:00:00 2001 From: krishicks Date: Wed, 21 Oct 2026 17:43:15 +0000 Subject: [PATCH] test(auth): share fixtures for gateway audit event tests (#4778) Tests for the gateway's OCSF events each carried their own copy of the same fixtures: converting captured events to JSON, the request ID and peer address, running a handler inside a routed request, and checking event shapes. They now share them: - OcsfCapture::json_events returns captured events as their exported JSON. - auth::audit::test_support holds the test request origin, audited() for running a handler as a routed request, and assertions for authentication failures, logons, denials, boundary findings, the request origin, and redacted secrets. The event assertions also check each event against its OCSF class schema, which the handler tests did not do before. - auth::principal::test_support builds the OIDC user and gateway-JWT sandbox principals that several test modules defined identically. Refs #2912 Signed-off-by: Kris Hicks --- crates/openshell-ocsf/src/test_support.rs | 14 ++ crates/openshell-server/src/auth/audit.rs | 3 + .../src/auth/audit/test_support.rs | 122 ++++++++++ .../src/auth/authenticator.rs | 24 +- crates/openshell-server/src/auth/guard.rs | 24 +- crates/openshell-server/src/auth/principal.rs | 31 +++ .../src/auth/workspace_authz.rs | 30 +-- crates/openshell-server/src/grpc/auth_rpc.rs | 81 ++----- .../src/grpc/mutation_replay/tests.rs | 56 ++--- crates/openshell-server/src/grpc/policy.rs | 29 +-- crates/openshell-server/src/grpc/sandbox.rs | 88 ++----- crates/openshell-server/src/multiplex.rs | 227 ++++++------------ 12 files changed, 324 insertions(+), 405 deletions(-) create mode 100644 crates/openshell-server/src/auth/audit/test_support.rs 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:?}"); }