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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions crates/openshell-ocsf/src/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,19 @@ impl OcsfCapture {
pub fn events(&self) -> Vec<OcsfEvent> {
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<serde_json::Value> {
self.events()
.iter()
.map(|event| event.to_json().expect("serialize OCSF event"))
.collect()
}
}

#[cfg(test)]
Expand Down Expand Up @@ -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");
}
}
3 changes: 3 additions & 0 deletions crates/openshell-server/src/auth/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
122 changes: 122 additions & 0 deletions crates/openshell-server/src/auth/audit/test_support.rs
Original file line number Diff line number Diff line change
@@ -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<F: Future>(path: &str, handler: F) -> (F::Output, Vec<Value>) {
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}"
);
}
26 changes: 7 additions & 19 deletions crates/openshell-server/src/auth/authenticator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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]);

Expand Down
24 changes: 1 addition & 23 deletions crates/openshell-server/src/auth/guard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,30 +130,8 @@ pub fn require_operator<T>(
#[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() {
Expand Down
31 changes: 31 additions & 0 deletions crates/openshell-server/src/auth/principal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down
30 changes: 7 additions & 23 deletions crates/openshell-server/src/auth/workspace_authz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading