diff --git a/crates/openshell-ocsf/schemas/ocsf/README.md b/crates/openshell-ocsf/schemas/ocsf/README.md
index 3f1a70ddf5..2ec7b7ab0d 100644
--- a/crates/openshell-ocsf/schemas/ocsf/README.md
+++ b/crates/openshell-ocsf/schemas/ocsf/README.md
@@ -22,12 +22,13 @@ for offline test validation.
- `authentication` [3002]
- `base_event` [0]
-### Objects (22)
+### Objects (23)
- `metadata`, `network_endpoint`, `network_proxy`, `process`, `actor`
- `device`, `container`, `product`, `firewall_rule`, `finding_info`
- `evidences`, `http_request`, `http_response`, `url`, `attack`
-- `remediation`, `connection_info`, `ai_model`, `api`, `user`, `service`, `os`
+- `remediation`, `connection_info`, `ai_model`, `api`, `user`, `service`, `os`,
+ `resource_details`
### Profiles (1)
diff --git a/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/resource_details.json b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/resource_details.json
new file mode 100644
index 0000000000..625587c17d
--- /dev/null
+++ b/crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/resource_details.json
@@ -0,0 +1,291 @@
+{
+ "attributes": {
+ "data": {
+ "type": "json_t",
+ "description": "Additional data describing the resource.",
+ "requirement": "optional",
+ "caption": "Data",
+ "type_name": "JSON"
+ },
+ "name": {
+ "type": "string_t",
+ "description": "The name of the resource.",
+ "requirement": "recommended",
+ "caption": "Name",
+ "type_name": "String",
+ "observable": 38
+ },
+ "owner": {
+ "type": "object_t",
+ "description": "The details of the entity that owns the resource. This object includes properties such as the owner's name, unique identifier, type, domain, and other relevant attributes that help identify the resource owner within the environment.",
+ "requirement": "recommended",
+ "caption": "Owner",
+ "object_type": "user",
+ "object_name": "User"
+ },
+ "type": {
+ "type": "string_t",
+ "description": "The resource type as defined by the event source.",
+ "requirement": "optional",
+ "caption": "Type",
+ "type_name": "String"
+ },
+ "version": {
+ "type": "string_t",
+ "description": "The version of the resource. For example 1.2.3.",
+ "requirement": "optional",
+ "caption": "Version",
+ "type_name": "String"
+ },
+ "ip": {
+ "type": "ip_t",
+ "description": "The IP address of the resource, in either IPv4 or IPv6 format.",
+ "requirement": "recommended",
+ "caption": "IP Address",
+ "type_name": "IP Address"
+ },
+ "group": {
+ "type": "object_t",
+ "description": "The name of the related resource group.",
+ "requirement": "optional",
+ "caption": "Group",
+ "object_type": "group",
+ "object_name": "Group"
+ },
+ "uid": {
+ "type": "resource_uid_t",
+ "description": "The unique identifier of the resource.",
+ "requirement": "recommended",
+ "caption": "Unique ID",
+ "type_name": "Resource UID"
+ },
+ "hostname": {
+ "type": "hostname_t",
+ "description": "The fully qualified name of the resource.",
+ "requirement": "recommended",
+ "caption": "Hostname",
+ "type_name": "Hostname"
+ },
+ "labels": {
+ "type": "string_t",
+ "description": "The list of labels associated to the resource.",
+ "is_array": true,
+ "requirement": "optional",
+ "caption": "Labels",
+ "type_name": "String"
+ },
+ "namespace": {
+ "type": "string_t",
+ "description": "The namespace is useful when similar entities exist that you need to keep separate.",
+ "requirement": "optional",
+ "caption": "Namespace",
+ "type_name": "String"
+ },
+ "role": {
+ "type": "string_t",
+ "description": "The role of the resource in the context of the event or finding, normalized to the caption of the role_id value. In the case of 'Other', it is defined by the event source.",
+ "requirement": "optional",
+ "caption": "Role",
+ "type_name": "String"
+ },
+ "tags": {
+ "type": "object_t",
+ "description": "The list of tags; {key:value} pairs associated to the resource.",
+ "is_array": true,
+ "requirement": "optional",
+ "caption": "Tags",
+ "object_type": "key_value_object",
+ "object_name": "Key:Value object"
+ },
+ "agent_list": {
+ "type": "object_t",
+ "description": "A list of agent objects associated with a device, endpoint, or resource.",
+ "is_array": true,
+ "requirement": "optional",
+ "caption": "Agent List",
+ "object_type": "agent",
+ "object_name": "Agent"
+ },
+ "cloud_partition": {
+ "type": "string_t",
+ "description": "The logical grouping or isolated segment within a cloud provider's infrastructure where the resource is located. Examples include AWS partitions (aws, aws-cn, aws-us-gov), Azure cloud environments (AzureCloud, AzureUSGovernment, AzureChinaCloud), or similar logical divisions in other cloud providers.",
+ "requirement": "optional",
+ "caption": "Cloud Partition",
+ "profiles": [
+ "cloud"
+ ],
+ "type_name": "String"
+ },
+ "created_time": {
+ "type": "timestamp_t",
+ "description": "The time when the resource was created.",
+ "requirement": "optional",
+ "caption": "Created Time",
+ "type_name": "Timestamp"
+ },
+ "criticality": {
+ "type": "string_t",
+ "description": "The criticality of the resource as defined by the event source.",
+ "requirement": "optional",
+ "caption": "Criticality",
+ "type_name": "String"
+ },
+ "data_classification": {
+ "type": "object_t",
+ "description": "The Data Classification object includes information about data classification levels and data category types.",
+ "group": "context",
+ "requirement": "recommended",
+ "caption": "Data Classification",
+ "object_type": "data_classification",
+ "profiles": [
+ "data_classification"
+ ],
+ "@deprecated": {
+ "message": "Use the attribute data_classifications instead",
+ "since": "1.4.0"
+ },
+ "object_name": "Data Classification"
+ },
+ "data_classifications": {
+ "type": "object_t",
+ "description": "A list of Data Classification objects, that include information about data classification levels and data category types, identified by a classifier.",
+ "group": "context",
+ "is_array": true,
+ "requirement": "recommended",
+ "caption": "Data Classification",
+ "object_type": "data_classification",
+ "profiles": [
+ "data_classification"
+ ],
+ "object_name": "Data Classification"
+ },
+ "is_backed_up": {
+ "type": "boolean_t",
+ "description": "Indicates whether the device or resource has a backup enabled, such as an automated snapshot or a cloud backup. For example, this is indicated by the cloudBackupEnabled value within JAMF Pro mobile devices or the registration of an AWS ARN with the AWS Backup service.",
+ "requirement": "optional",
+ "caption": "Back Ups Configured",
+ "type_name": "Boolean"
+ },
+ "modified_time": {
+ "type": "timestamp_t",
+ "description": "The time when the resource was last modified.",
+ "requirement": "optional",
+ "caption": "Modified Time",
+ "type_name": "Timestamp"
+ },
+ "provider": {
+ "type": "string_t",
+ "description": "The cloud service provider that hosts or manages the resource. This field is typically used when the resource is managed by a different provider than the one generating the event or finding. Examples include AWS, Azure, GCP (Google Cloud Platform), Oracle Cloud, IBM Cloud, Alibaba Cloud, or other public, private, or hybrid cloud providers.",
+ "requirement": "optional",
+ "caption": "Cloud Provider",
+ "profiles": [
+ "cloud"
+ ],
+ "type_name": "String"
+ },
+ "region": {
+ "type": "string_t",
+ "description": "The cloud region where the resource is hosted, as defined by the cloud provider. This represents the physical or logical geographic area containing the infrastructure supporting the resource. Examples include AWS regions (us-east-1, eu-west-1), Azure regions (East US, West Europe), GCP regions (us-central1, europe-west1), or Oracle Cloud regions (us-ashburn-1, uk-london-1).",
+ "requirement": "optional",
+ "caption": "Region",
+ "profiles": [
+ "cloud"
+ ],
+ "type_name": "String"
+ },
+ "resource_relationship": {
+ "type": "object_t",
+ "description": "A graph representation showing how this resource relates to and interacts with other entities in the environment. This can include parent/child relationships, dependencies, or other connections.",
+ "requirement": "optional",
+ "caption": "Resource Relationship",
+ "object_type": "graph",
+ "object_name": "Graph"
+ },
+ "role_id": {
+ "type": "integer_t",
+ "enum": {
+ "3": {
+ "description": "The resource was impacted or affected by the event/finding.",
+ "caption": "Affected"
+ },
+ "0": {
+ "description": "The role is unknown.",
+ "caption": "Unknown"
+ },
+ "1": {
+ "description": "The resource is the primary target or subject of the event/finding.",
+ "caption": "Target"
+ },
+ "2": {
+ "description": "The resource is acting as the initiator or performer in the context of the event/finding.",
+ "caption": "Actor"
+ },
+ "99": {
+ "description": "The role is not mapped. See the role attribute, which contains a data source specific value.",
+ "caption": "Other"
+ },
+ "4": {
+ "description": "The resource is related to or associated with the event/finding.",
+ "caption": "Related"
+ }
+ },
+ "description": "The normalized identifier of the resource's role in the context of the event or finding.",
+ "requirement": "recommended",
+ "caption": "Role ID",
+ "sibling": "role",
+ "type_name": "Integer"
+ },
+ "uid_alt": {
+ "type": "resource_uid_t",
+ "description": "The alternative unique identifier of the resource.",
+ "requirement": "optional",
+ "caption": "Alternate ID",
+ "type_name": "Resource UID"
+ },
+ "zone": {
+ "type": "string_t",
+ "description": "The availability zone within a cloud region where the resource is located. Examples include AWS availability zones (us-east-1a, us-east-1b), Azure availability zones (1, 2, 3 within a region), GCP zones (us-central1-a, us-central1-b), or Oracle Cloud availability domains (AD-1, AD-2, AD-3).",
+ "requirement": "optional",
+ "caption": "Cloud Availability Zone",
+ "profiles": [
+ "cloud"
+ ],
+ "type_name": "String"
+ },
+ "created_time_dt": {
+ "type": "datetime_t",
+ "description": "The time when the resource was created.",
+ "requirement": "optional",
+ "caption": "Created Time",
+ "profiles": [
+ "datetime"
+ ],
+ "type_name": "Datetime"
+ },
+ "modified_time_dt": {
+ "type": "datetime_t",
+ "description": "The time when the resource was last modified.",
+ "requirement": "optional",
+ "caption": "Modified Time",
+ "profiles": [
+ "datetime"
+ ],
+ "type_name": "Datetime"
+ }
+ },
+ "name": "resource_details",
+ "description": "The Resource Details object describes details about resources that were affected by the activity/event.",
+ "extends": "_resource",
+ "constraints": {
+ "at_least_one": [
+ "name",
+ "uid"
+ ]
+ },
+ "caption": "Resource Details",
+ "profiles": [
+ "cloud",
+ "data_classification",
+ "datetime"
+ ]
+}
diff --git a/crates/openshell-ocsf/src/builders/api_activity.rs b/crates/openshell-ocsf/src/builders/api_activity.rs
index 1d1f2b98f2..fe88434804 100644
--- a/crates/openshell-ocsf/src/builders/api_activity.rs
+++ b/crates/openshell-ocsf/src/builders/api_activity.rs
@@ -3,11 +3,13 @@
//! Builder for API Activity [6003] events.
-use crate::builders::EventContext;
-use crate::enums::{SeverityId, StatusId};
+use crate::builders::{EventContext, EventOrigin};
+use crate::enums::{ActionId, DispositionId, SeverityId, StatusId};
use crate::events::base_event::BaseEventData;
use crate::events::{ApiActivityEvent, OcsfEvent};
-use crate::objects::{Actor, AiModel, Api, Endpoint, HttpRequest, Process};
+use crate::objects::{
+ Actor, AiModel, Api, Endpoint, HttpRequest, Process, ResourceDetails, Service, User,
+};
const MAX_MODEL_LEN: usize = 256;
@@ -31,8 +33,16 @@ pub struct ApiActivityBuilder<'a> {
status: Option,
message: Option,
api_operation: String,
+ api_service: Option,
+ actor: Option,
+ src_endpoint: Option,
+ action: Option,
+ disposition: Option,
+ status_detail: Option,
+ correlation_uid: Option,
http_request: Option,
dst_endpoint: Option,
+ resources: Vec,
ai_model: Option,
unmapped: serde_json::Map,
}
@@ -46,8 +56,16 @@ impl<'a> ApiActivityBuilder<'a> {
status: None,
message: None,
api_operation: operation.into(),
+ api_service: None,
+ actor: None,
+ src_endpoint: None,
+ action: None,
+ disposition: None,
+ status_detail: None,
+ correlation_uid: None,
http_request: None,
dst_endpoint: None,
+ resources: Vec::new(),
ai_model: None,
unmapped: serde_json::Map::new(),
}
@@ -71,6 +89,42 @@ impl<'a> ApiActivityBuilder<'a> {
self
}
+ /// Set the service that exposes the operation.
+ #[must_use]
+ pub fn api_service(mut self, service: Service) -> Self {
+ self.api_service = Some(service);
+ self
+ }
+
+ /// Set the authenticated principal that made the call. Without one, the
+ /// actor is the supervisor process.
+ #[must_use]
+ pub fn actor_user(mut self, user: User) -> Self {
+ self.actor = Some(Actor::user(user));
+ self
+ }
+
+ /// Set the caller's endpoint. Without one, the source is the proxy
+ /// endpoint.
+ #[must_use]
+ pub fn src_endpoint(mut self, endpoint: Endpoint) -> Self {
+ self.src_endpoint = Some(endpoint);
+ self
+ }
+
+ #[must_use]
+ pub fn status_detail(mut self, detail: impl Into) -> Self {
+ self.status_detail = Some(detail.into());
+ self
+ }
+
+ /// Set the identifier shared by events about the same request.
+ #[must_use]
+ pub fn correlation_uid(mut self, uid: impl Into) -> Self {
+ self.correlation_uid = Some(uid.into());
+ self
+ }
+
#[must_use]
pub fn http_request(mut self, req: HttpRequest) -> Self {
self.http_request = Some(req);
@@ -83,6 +137,13 @@ impl<'a> ApiActivityBuilder<'a> {
self
}
+ /// Add a resource the call acted on.
+ #[must_use]
+ pub fn resource(mut self, resource: ResourceDetails) -> Self {
+ self.resources.push(resource);
+ self
+ }
+
/// Attach the `ai_operation` profile with model identity.
/// Model name and provider are sanitized (newlines stripped, length capped).
#[must_use]
@@ -105,10 +166,19 @@ impl<'a> ApiActivityBuilder<'a> {
#[must_use]
pub fn build(self) -> OcsfEvent {
- let mut profiles: Vec<&str> = vec!["container", "host"];
+ // Gateway events carry a container only when they concern a sandbox.
+ let mut profiles: Vec<&str> = match self.ctx.origin {
+ EventOrigin::Gateway { .. } if self.ctx.container().is_none() => vec!["host"],
+ _ => vec!["container", "host"],
+ };
+ if self.action.is_some() {
+ profiles.push("security_control");
+ }
if self.ai_model.is_some() {
profiles.push("ai_operation");
}
+ let mut metadata = self.ctx.metadata(&profiles);
+ metadata.correlation_uid = self.correlation_uid;
let mut base = BaseEventData::new(
6003,
"API Activity",
@@ -117,8 +187,11 @@ impl<'a> ApiActivityBuilder<'a> {
99,
"Other",
self.severity,
- self.ctx.metadata(&profiles),
+ metadata,
);
+ if let Some(detail) = self.status_detail {
+ base.set_status_detail(detail);
+ }
if let Some(ai_model) = self.ai_model {
base.set_ai_model(ai_model);
}
@@ -130,25 +203,160 @@ impl<'a> ApiActivityBuilder<'a> {
OcsfEvent::ApiActivity(ApiActivityEvent {
base,
- api: Api::new(&self.api_operation),
- actor: Actor {
- process: Process::new("openshell-supervisor", 1),
+ api: Api {
+ service: self.api_service,
+ ..Api::new(&self.api_operation)
},
- src_endpoint: self.ctx.proxy_endpoint(),
+ actor: self
+ .actor
+ .unwrap_or_else(|| Actor::process(Process::new("openshell-supervisor", 1))),
+ src_endpoint: self
+ .src_endpoint
+ .unwrap_or_else(|| self.ctx.proxy_endpoint()),
http_request: self.http_request,
http_response: None,
dst_endpoint: self.dst_endpoint,
- action: None,
- disposition: None,
+ resources: (!self.resources.is_empty()).then_some(self.resources),
+ action: self.action,
+ disposition: self.disposition,
})
}
}
+impl_action_disposition_setters!(ApiActivityBuilder);
+
#[cfg(test)]
mod tests {
use super::*;
- use crate::builders::test_sandbox_context;
+ use crate::builders::{test_gateway_context, test_sandbox_context};
+ use crate::enums::{HttpMethod, UserTypeId};
use crate::objects::Url;
+ use crate::validation::schema::{
+ load_class_schema, load_object_schema, validate_enum_value, validate_known_fields,
+ validate_required_fields,
+ };
+
+ fn gateway_denial() -> serde_json::Value {
+ let ctx = test_gateway_context();
+ ApiActivityBuilder::new(&ctx, "GetSandbox")
+ .api_service(Service::new("openshell.v1.OpenShell"))
+ .actor_user(User::other("sb-7f3a", "Sandbox"))
+ .src_endpoint(Endpoint::from_ip_str("10.42.0.7", 51234))
+ .action(ActionId::Denied)
+ .disposition(DispositionId::Blocked)
+ .severity(SeverityId::Medium)
+ .status(StatusId::Failure)
+ .status_detail("cross_sandbox")
+ .resource(ResourceDetails::new("sandbox", "sb-9c41"))
+ .correlation_uid("6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f")
+ .http_request(HttpRequest {
+ http_method: HttpMethod::Post,
+ url: Some(Url::from_path("/openshell.v1.OpenShell/GetSandbox")),
+ })
+ .message("authorization denied: cross_sandbox")
+ .build()
+ .to_json()
+ .unwrap()
+ }
+
+ #[test]
+ fn gateway_denial_matches_vendored_schema() {
+ let json = gateway_denial();
+
+ let class = load_class_schema("api_activity");
+ validate_required_fields(&json, &class);
+ validate_known_fields(&json, &class);
+ for field in [
+ "activity_id",
+ "action_id",
+ "disposition_id",
+ "severity_id",
+ "status_id",
+ ] {
+ validate_enum_value(&json, field, &class);
+ }
+
+ let actor = load_object_schema("actor");
+ validate_required_fields(&json["actor"], &actor);
+ validate_known_fields(&json["actor"], &actor);
+ let user = load_object_schema("user");
+ validate_required_fields(&json["actor"]["user"], &user);
+ validate_known_fields(&json["actor"]["user"], &user);
+
+ let api = load_object_schema("api");
+ validate_required_fields(&json["api"], &api);
+ validate_known_fields(&json["api"], &api);
+ let service = load_object_schema("service");
+ validate_required_fields(&json["api"]["service"], &service);
+
+ let resource = load_object_schema("resource_details");
+ validate_required_fields(&json["resources"][0], &resource);
+ validate_known_fields(&json["resources"][0], &resource);
+
+ let metadata = load_object_schema("metadata");
+ validate_required_fields(&json["metadata"], &metadata);
+ validate_known_fields(&json["metadata"], &metadata);
+ }
+
+ #[test]
+ fn gateway_denial_records_the_principal_and_outcome() {
+ let json = gateway_denial();
+
+ assert_eq!(json["class_uid"], 6003);
+ assert_eq!(json["activity_id"], 99);
+ assert_eq!(
+ json["actor"],
+ serde_json::json!({"user": {"uid": "sb-7f3a", "type_id": 99, "type": "Sandbox"}})
+ );
+ assert_eq!(
+ json["api"],
+ serde_json::json!({
+ "operation": "GetSandbox",
+ "service": {"name": "openshell.v1.OpenShell"}
+ })
+ );
+ assert_eq!(
+ json["resources"],
+ serde_json::json!([{"uid": "sb-9c41", "type": "sandbox"}])
+ );
+ assert_eq!(json["src_endpoint"]["ip"], "10.42.0.7");
+ assert_eq!(json["action_id"], 2);
+ assert_eq!(json["disposition_id"], 2);
+ assert_eq!(json["status_id"], 2);
+ assert_eq!(json["status_detail"], "cross_sandbox");
+ assert_eq!(
+ json["metadata"]["correlation_uid"],
+ "6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f"
+ );
+ assert_eq!(
+ json["metadata"]["profiles"],
+ serde_json::json!(["host", "security_control"])
+ );
+ assert!(json.get("container").is_none());
+ }
+
+ #[test]
+ fn gateway_denial_shorthand_names_principal_operation_and_reason() {
+ let ctx = test_gateway_context();
+ let event = ApiActivityBuilder::new(&ctx, "GetSandbox")
+ .actor_user(User::new("alice@example.com", UserTypeId::User))
+ .src_endpoint(Endpoint::from_ip_str("203.0.113.7", 443))
+ .action(ActionId::Denied)
+ .severity(SeverityId::Medium)
+ .status_detail("not_workspace_member")
+ .resource(ResourceDetails::named("workspace", "team a]"))
+ .http_request(HttpRequest {
+ http_method: HttpMethod::Post,
+ url: Some(Url::from_path("/openshell.v1.OpenShell/GetSandbox")),
+ })
+ .build();
+
+ assert_eq!(
+ event.format_shorthand(),
+ "API:OTHER [MED] DENIED user:alice@example.com from 203.0.113.7:443 \
+ [op:/openshell.v1.OpenShell/GetSandbox workspace:team\\u{20}a\\] reason:not_workspace_member]"
+ );
+ }
#[test]
fn test_api_activity_builder_with_ai_model() {
diff --git a/crates/openshell-ocsf/src/builders/finding.rs b/crates/openshell-ocsf/src/builders/finding.rs
index c21eddd258..aea063a527 100644
--- a/crates/openshell-ocsf/src/builders/finding.rs
+++ b/crates/openshell-ocsf/src/builders/finding.rs
@@ -3,7 +3,7 @@
//! Builder for Detection Finding [2004] events.
-use crate::builders::EventContext;
+use crate::builders::{EventContext, EventOrigin};
use crate::enums::{ActionId, ActivityId, ConfidenceId, DispositionId, RiskLevelId, SeverityId};
use crate::events::base_event::BaseEventData;
use crate::events::{DetectionFindingEvent, OcsfEvent};
@@ -25,6 +25,7 @@ pub struct DetectionFindingBuilder<'a> {
risk_level: Option,
message: Option,
log_source: Option,
+ correlation_uid: Option,
unmapped: serde_json::Map,
}
@@ -46,6 +47,7 @@ impl<'a> DetectionFindingBuilder<'a> {
risk_level: None,
message: None,
log_source: None,
+ correlation_uid: None,
unmapped: serde_json::Map::new(),
}
}
@@ -76,6 +78,13 @@ impl<'a> DetectionFindingBuilder<'a> {
self
}
+ /// Set the identifier shared by events about the same incident.
+ #[must_use]
+ pub fn correlation_uid(mut self, uid: impl Into) -> Self {
+ self.correlation_uid = Some(uid.into());
+ self
+ }
+
/// Add a source-specific attribute that is not defined by the OCSF class.
#[must_use]
pub fn unmapped(mut self, key: &str, value: impl Into) -> Self {
@@ -114,12 +123,18 @@ impl<'a> DetectionFindingBuilder<'a> {
#[must_use]
pub fn build(self) -> OcsfEvent {
let activity_name = self.activity.finding_label().to_string();
- let mut metadata = self
- .ctx
- .metadata(&["security_control", "container", "host"]);
+ // Gateway events carry a container only when they concern a sandbox.
+ let profiles: &[&str] = match self.ctx.origin {
+ EventOrigin::Gateway { .. } if self.ctx.container().is_none() => {
+ &["security_control", "host"]
+ }
+ _ => &["security_control", "container", "host"],
+ };
+ let mut metadata = self.ctx.metadata(profiles);
if let Some(source) = self.log_source {
metadata.log_source = Some(source);
}
+ metadata.correlation_uid = self.correlation_uid;
let mut base = BaseEventData::new(
2004,
@@ -168,7 +183,43 @@ impl_builder_setters!(DetectionFindingBuilder, no_status);
#[cfg(test)]
mod tests {
use super::*;
- use crate::builders::test_sandbox_context;
+ use crate::builders::{test_gateway_context, test_sandbox_context};
+
+ #[test]
+ fn gateway_finding_declares_container_only_with_a_sandbox() {
+ // Gateway-wide findings name no sandbox; MXC findings name the
+ // sandbox they concern.
+ for (sandbox_id, profiles) in [
+ ("", serde_json::json!(["security_control", "host"])),
+ (
+ "sb-1",
+ serde_json::json!(["security_control", "container", "host"]),
+ ),
+ ] {
+ let mut ctx = test_gateway_context();
+ ctx.sandbox_id = sandbox_id.to_string();
+ let json = DetectionFindingBuilder::new(&ctx)
+ .finding_info(FindingInfo::new(
+ "cross-sandbox-access",
+ "Cross-Sandbox Access",
+ ))
+ .correlation_uid("6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f")
+ .build()
+ .to_json()
+ .unwrap();
+
+ assert_eq!(
+ json["metadata"]["correlation_uid"],
+ "6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f"
+ );
+ assert_eq!(json["metadata"]["profiles"], profiles, "{sandbox_id:?}");
+ assert_eq!(
+ json.get("container").is_some(),
+ !sandbox_id.is_empty(),
+ "{sandbox_id:?}"
+ );
+ }
+ }
#[test]
fn test_detection_finding_builder() {
diff --git a/crates/openshell-ocsf/src/builders/http.rs b/crates/openshell-ocsf/src/builders/http.rs
index 8dd94a1905..b027421bd0 100644
--- a/crates/openshell-ocsf/src/builders/http.rs
+++ b/crates/openshell-ocsf/src/builders/http.rs
@@ -183,7 +183,7 @@ impl<'a, Context> HttpActivityBuilder<'a, Context> {
#[must_use]
pub fn actor_process(mut self, process: crate::objects::Process) -> Self {
- self.actor = Some(Actor { process });
+ self.actor = Some(Actor::process(process));
self
}
diff --git a/crates/openshell-ocsf/src/builders/mod.rs b/crates/openshell-ocsf/src/builders/mod.rs
index d45efa9264..96c966b732 100644
--- a/crates/openshell-ocsf/src/builders/mod.rs
+++ b/crates/openshell-ocsf/src/builders/mod.rs
@@ -99,7 +99,7 @@ macro_rules! impl_actor_process_setter {
/// Set the acting process.
#[must_use]
pub fn actor_process(mut self, process: $crate::objects::Process) -> Self {
- self.actor = Some($crate::objects::Actor { process });
+ self.actor = Some($crate::objects::Actor::process(process));
self
}
}
@@ -283,6 +283,22 @@ pub(crate) fn test_sandbox_context() -> EventContext {
}
}
+#[cfg(test)]
+pub(crate) fn test_gateway_context() -> EventContext {
+ EventContext {
+ sandbox_id: String::new(),
+ sandbox_name: String::new(),
+ container_image: String::new(),
+ hostname: "openshell-gateway-0".to_string(),
+ product_version: "0.1.0".to_string(),
+ proxy_ip: IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
+ proxy_port: 0,
+ origin: EventOrigin::Gateway {
+ name: "openshell".to_string(),
+ },
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/openshell-ocsf/src/builders/network.rs b/crates/openshell-ocsf/src/builders/network.rs
index 001100abc4..09f4c4ff0d 100644
--- a/crates/openshell-ocsf/src/builders/network.rs
+++ b/crates/openshell-ocsf/src/builders/network.rs
@@ -221,7 +221,7 @@ impl<'a, EndpointState> NetworkActivityBuilder<'a, EndpointState> {
#[must_use]
pub fn actor_process(mut self, process: crate::objects::Process) -> Self {
- self.actor = Some(Actor { process });
+ self.actor = Some(Actor::process(process));
self
}
diff --git a/crates/openshell-ocsf/src/events/api_activity.rs b/crates/openshell-ocsf/src/events/api_activity.rs
index e687b15796..199e46cd00 100644
--- a/crates/openshell-ocsf/src/events/api_activity.rs
+++ b/crates/openshell-ocsf/src/events/api_activity.rs
@@ -7,7 +7,7 @@ use serde::Deserialize;
use super::base_event::BaseEventData;
use crate::enums::{ActionId, DispositionId};
-use crate::objects::{Actor, Api, Endpoint, HttpRequest, HttpResponse};
+use crate::objects::{Actor, Api, Endpoint, HttpRequest, HttpResponse, ResourceDetails};
/// API Activity event (`class_uid` 6003).
///
@@ -40,6 +40,10 @@ pub struct ApiActivityEvent {
#[serde(default)]
pub dst_endpoint: Option,
+ /// Resources the call acted on.
+ #[serde(default)]
+ pub resources: Option>,
+
/// Action taken (allowed, denied, etc.).
#[serde(default)]
pub action: Option,
@@ -73,6 +77,7 @@ impl serde::Serialize for ApiActivityEvent {
insert_optional!(obj, "http_request", self.http_request);
insert_optional!(obj, "http_response", self.http_response);
insert_optional!(obj, "dst_endpoint", self.dst_endpoint);
+ insert_optional!(obj, "resources", self.resources);
insert_enum_pair!(obj, "action", self.action);
insert_enum_pair!(obj, "disposition", self.disposition);
@@ -108,13 +113,12 @@ mod tests {
ApiActivityEvent {
base,
api: Api::new("POST /v1/messages"),
- actor: Actor {
- process: Process::new("supervisor", 1),
- },
+ actor: Actor::process(Process::new("supervisor", 1)),
src_endpoint: Endpoint::from_domain("api.anthropic.com", 443),
http_request: None,
http_response: None,
dst_endpoint: None,
+ resources: None,
action: None,
disposition: None,
}
diff --git a/crates/openshell-ocsf/src/events/process_activity.rs b/crates/openshell-ocsf/src/events/process_activity.rs
index a5378d3aff..4499260346 100644
--- a/crates/openshell-ocsf/src/events/process_activity.rs
+++ b/crates/openshell-ocsf/src/events/process_activity.rs
@@ -95,9 +95,7 @@ mod tests {
},
),
process: Process::new("python3", 42).with_cmd_line("python3 /app/main.py"),
- actor: Some(Actor {
- process: Process::new("openshell-sandbox", 1),
- }),
+ actor: Some(Actor::process(Process::new("openshell-sandbox", 1))),
launch_type: Some(LaunchTypeId::Spawn),
exit_code: None,
action: Some(ActionId::Allowed),
diff --git a/crates/openshell-ocsf/src/format/shorthand.rs b/crates/openshell-ocsf/src/format/shorthand.rs
index fb5781a9a5..050601da37 100644
--- a/crates/openshell-ocsf/src/format/shorthand.rs
+++ b/crates/openshell-ocsf/src/format/shorthand.rs
@@ -5,9 +5,10 @@
//!
//! Pattern: ` [context]`
+use crate::enums::UserTypeId;
use crate::events::OcsfEvent;
use crate::events::base_event::BaseEventData;
-use crate::objects::Url;
+use crate::objects::{Endpoint, Url, User};
/// Format a timestamp (ms since epoch) as `HH:MM:SS.mmm`.
///
@@ -127,6 +128,36 @@ fn reason_text(text: Option<&str>) -> Option {
Some(truncate_with_ellipsis(&text, MAX_REASON_LEN))
}
+/// Render a principal as `type:id`, or just the id when its type is unknown.
+fn principal_label(user: &User) -> String {
+ let principal = user
+ .uid
+ .as_deref()
+ .or(user.name.as_deref())
+ .unwrap_or_default();
+ let principal = escape_context_field(&truncate_with_ellipsis(principal, MAX_REASON_LEN));
+ if user.type_id == UserTypeId::Unknown {
+ principal
+ } else {
+ format!(
+ "{}:{principal}",
+ escape_context_field(&user.type_label.to_lowercase())
+ )
+ }
+}
+
+/// Render a caller's endpoint as ` from host:port`.
+fn peer_label(endpoint: &Endpoint) -> String {
+ let port = endpoint.port.map_or(String::new(), |p| format!(":{p}"));
+ let host = endpoint
+ .domain
+ .as_deref()
+ .or(endpoint.ip.as_deref())
+ .or(endpoint.name.as_deref())
+ .unwrap_or("unknown");
+ format!(" from {}{port}", escape_context_field(host))
+}
+
/// Format a `[reason:...]` tag from `status_detail` (or `message` fallback)
/// for denied events. Returns an empty string if neither field is set.
fn reason_tag(base: &BaseEventData) -> String {
@@ -208,7 +239,8 @@ impl OcsfEvent {
let actor_str = e
.actor
.as_ref()
- .map(|a| format!("{}({})", a.process.name, a.process.pid))
+ .and_then(|a| a.process.as_ref())
+ .map(|p| format!("{}({})", p.name, p.pid))
.unwrap_or_default();
let dst = e
.dst_endpoint
@@ -278,7 +310,8 @@ impl OcsfEvent {
let actor_str = e
.actor
.as_ref()
- .map(|a| format!("{}({})", a.process.name, a.process.pid))
+ .and_then(|a| a.process.as_ref())
+ .map(|p| format!("{}({})", p.name, p.pid))
.unwrap_or_default();
let url_str = e
.http_request
@@ -319,6 +352,46 @@ impl OcsfEvent {
format!("HTTP:{method} {sev}{detail}{rule_ctx}{outcome_ctx}{message_ctx}")
}
+ Self::ApiActivity(e) if e.actor.user.is_some() => {
+ let activity = e.base.activity_name.to_uppercase();
+ let action = e
+ .action
+ .map_or(String::new(), |a| format!(" {}", a.label().to_uppercase()));
+ let principal = e
+ .actor
+ .user
+ .as_ref()
+ .map(principal_label)
+ .unwrap_or_default();
+ let peer = peer_label(&e.src_endpoint);
+ let operation = e
+ .http_request
+ .as_ref()
+ .and_then(|r| r.url.as_ref())
+ .and_then(|u| u.path.as_deref())
+ .unwrap_or(&e.api.operation);
+ let mut context = vec![format!(
+ "op:{}",
+ escape_context_field(&truncate_with_ellipsis(operation, MAX_REASON_LEN))
+ )];
+ for resource in e.resources.iter().flatten() {
+ let Some(id) = resource.uid.as_deref().or(resource.name.as_deref()) else {
+ continue;
+ };
+ context.push(format!(
+ "{}:{}",
+ escape_context_field(resource.type_label.as_deref().unwrap_or("resource")),
+ escape_context_field(&truncate_with_ellipsis(id, MAX_REASON_LEN))
+ ));
+ }
+ if let Some(reason) = reason_text(e.base.status_detail.as_deref()) {
+ context.push(format!("reason:{}", escape_context_field(&reason)));
+ }
+ let context = context.join(" ");
+
+ format!("API:{activity} {sev}{action} {principal}{peer} [{context}]")
+ }
+
Self::ApiActivity(e) => {
let model_ctx = e
.base
@@ -409,36 +482,8 @@ impl OcsfEvent {
let action = e
.action
.map_or(String::new(), |a| format!(" {}", a.label().to_uppercase()));
- let principal = e
- .user
- .uid
- .as_deref()
- .or(e.user.name.as_deref())
- .unwrap_or_default();
- let principal =
- escape_context_field(&truncate_with_ellipsis(principal, MAX_REASON_LEN));
- let principal = if e.user.type_id == crate::enums::UserTypeId::Unknown {
- principal
- } else {
- format!(
- "{}:{principal}",
- escape_context_field(&e.user.type_label.to_lowercase())
- )
- };
- let peer = e
- .src_endpoint
- .as_ref()
- .map(|ep| {
- let port = ep.port.map_or(String::new(), |p| format!(":{p}"));
- let host = ep
- .domain
- .as_deref()
- .or(ep.ip.as_deref())
- .or(ep.name.as_deref())
- .unwrap_or("unknown");
- format!(" from {}{port}", escape_context_field(host))
- })
- .unwrap_or_default();
+ let principal = principal_label(&e.user);
+ let peer = e.src_endpoint.as_ref().map(peer_label).unwrap_or_default();
let mut context = Vec::new();
if let Some(protocol) = e.auth_protocol.map(|p| {
e.auth_protocol_label
@@ -680,9 +725,7 @@ mod tests {
src_endpoint: None,
dst_endpoint: Some(Endpoint::from_domain("api.example.com", 443)),
proxy_endpoint: None,
- actor: Some(Actor {
- process: Process::new("python3", 42),
- }),
+ actor: Some(Actor::process(Process::new("python3", 42))),
firewall_rule: Some(FirewallRule::new("default-egress", "mechanistic")),
connection_info: None,
action: Some(ActionId::Allowed),
@@ -709,9 +752,7 @@ mod tests {
src_endpoint: None,
dst_endpoint: Some(Endpoint::from_ip_str("93.184.216.34", 443)),
proxy_endpoint: None,
- actor: Some(Actor {
- process: Process::new("node", 1234),
- }),
+ actor: Some(Actor::process(Process::new("node", 1234))),
firewall_rule: Some(FirewallRule::new("bypass-detect", "nftables")),
connection_info: Some(ConnectionInfo::new("tcp")),
action: Some(ActionId::Denied),
@@ -739,9 +780,7 @@ mod tests {
src_endpoint: None,
dst_endpoint: None,
proxy_endpoint: None,
- actor: Some(Actor {
- process: Process::new("curl", 88),
- }),
+ actor: Some(Actor::process(Process::new("curl", 88))),
firewall_rule: Some(FirewallRule::new("default-egress", "mechanistic")),
action: Some(ActionId::Allowed),
disposition: None,
@@ -856,9 +895,7 @@ mod tests {
src_endpoint: None,
dst_endpoint: Some(Endpoint::from_domain("169.254.169.254", 80)),
proxy_endpoint: None,
- actor: Some(Actor {
- process: Process::new("curl", 1618),
- }),
+ actor: Some(Actor::process(Process::new("curl", 1618))),
firewall_rule: Some(FirewallRule::new("-", "ssrf")),
connection_info: None,
action: Some(ActionId::Denied),
@@ -885,9 +922,7 @@ mod tests {
src_endpoint: None,
dst_endpoint: Some(Endpoint::from_domain("api.example.com", 443)),
proxy_endpoint: None,
- actor: Some(Actor {
- process: Process::new("python3", 42),
- }),
+ actor: Some(Actor::process(Process::new("python3", 42))),
firewall_rule: Some(FirewallRule::new("default-egress", "mechanistic")),
connection_info: None,
action: Some(ActionId::Allowed),
@@ -946,9 +981,7 @@ mod tests {
src_endpoint: None,
dst_endpoint: None,
proxy_endpoint: None,
- actor: Some(Actor {
- process: Process::new("curl", 1618),
- }),
+ actor: Some(Actor::process(Process::new("curl", 1618))),
firewall_rule: Some(FirewallRule::new("aws_iam", "ssrf")),
action: Some(ActionId::Denied),
disposition: Some(DispositionId::Blocked),
@@ -1069,9 +1102,7 @@ mod tests {
src_endpoint: None,
dst_endpoint: None,
proxy_endpoint: None,
- actor: Some(Actor {
- process: Process::new("curl", 68),
- }),
+ actor: Some(Actor::process(Process::new("curl", 68))),
firewall_rule: Some(FirewallRule::new("allow_host_9876", "mechanistic")),
action: Some(ActionId::Allowed),
disposition: None,
diff --git a/crates/openshell-ocsf/src/lib.rs b/crates/openshell-ocsf/src/lib.rs
index e8eeedfeee..b9878eb68a 100644
--- a/crates/openshell-ocsf/src/lib.rs
+++ b/crates/openshell-ocsf/src/lib.rs
@@ -57,7 +57,7 @@ pub use enums::{
pub use objects::{
Actor, AiModel, Api, Attack, ConnectionInfo, Container, Device, Endpoint, Evidence,
FindingInfo, FirewallRule, HttpRequest, HttpResponse, Image, Metadata, OsInfo, Process,
- Product, Remediation, Service, Tactic, Technique, Url, User,
+ Product, Remediation, ResourceDetails, Service, Tactic, Technique, Url, User,
};
// --- Sanitizers ---
diff --git a/crates/openshell-ocsf/src/objects/api.rs b/crates/openshell-ocsf/src/objects/api.rs
index a4c85b19bc..de62e7a902 100644
--- a/crates/openshell-ocsf/src/objects/api.rs
+++ b/crates/openshell-ocsf/src/objects/api.rs
@@ -5,6 +5,8 @@
use serde::{Deserialize, Serialize};
+use super::Service;
+
/// The API object describes the API request.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Api {
@@ -13,6 +15,9 @@ pub struct Api {
/// The API version (e.g., "v1").
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option,
+ /// The service that exposes the operation.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub service: Option,
}
impl Api {
@@ -21,6 +26,7 @@ impl Api {
Self {
operation: operation.into(),
version: None,
+ service: None,
}
}
}
diff --git a/crates/openshell-ocsf/src/objects/mod.rs b/crates/openshell-ocsf/src/objects/mod.rs
index 9462fe988c..9025834bfe 100644
--- a/crates/openshell-ocsf/src/objects/mod.rs
+++ b/crates/openshell-ocsf/src/objects/mod.rs
@@ -15,6 +15,7 @@ mod firewall_rule;
mod http;
mod metadata;
mod process;
+mod resource;
mod service;
mod user;
@@ -30,5 +31,6 @@ pub use firewall_rule::FirewallRule;
pub use http::{HttpRequest, HttpResponse, Url};
pub use metadata::{Metadata, Product};
pub use process::{Actor, Process};
+pub use resource::ResourceDetails;
pub use service::Service;
pub use user::User;
diff --git a/crates/openshell-ocsf/src/objects/process.rs b/crates/openshell-ocsf/src/objects/process.rs
index 216fba2005..bb51781217 100644
--- a/crates/openshell-ocsf/src/objects/process.rs
+++ b/crates/openshell-ocsf/src/objects/process.rs
@@ -5,6 +5,8 @@
use serde::{Deserialize, Serialize};
+use super::User;
+
/// OCSF Process object.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Process {
@@ -74,10 +76,38 @@ impl Process {
}
/// OCSF Actor object — the entity that initiated the event.
+///
+/// The schema requires at least one attribute; the constructors set exactly
+/// one of `process` or `user`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Actor {
/// The process that performed the action.
- pub process: Process,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub process: Option,
+
+ /// The principal that performed the action.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub user: Option,
+}
+
+impl Actor {
+ /// An actor identified by the process that performed the action.
+ #[must_use]
+ pub fn process(process: Process) -> Self {
+ Self {
+ process: Some(process),
+ user: None,
+ }
+ }
+
+ /// An actor identified by an authenticated principal.
+ #[must_use]
+ pub fn user(user: User) -> Self {
+ Self {
+ process: None,
+ user: Some(user),
+ }
+ }
}
#[cfg(test)]
@@ -131,10 +161,17 @@ mod tests {
#[test]
fn test_actor_serialization() {
- let actor = Actor {
- process: Process::new("python3", 42),
- };
+ let actor = Actor::process(Process::new("python3", 42));
let json = serde_json::to_value(&actor).unwrap();
assert_eq!(json["process"]["name"], "python3");
+ assert!(json.get("user").is_none());
+ }
+
+ #[test]
+ fn test_user_actor_serialization() {
+ let actor = Actor::user(User::new("alice", crate::enums::UserTypeId::User));
+ let json = serde_json::to_value(&actor).unwrap();
+ assert_eq!(json["user"]["uid"], "alice");
+ assert!(json.get("process").is_none());
}
}
diff --git a/crates/openshell-ocsf/src/objects/resource.rs b/crates/openshell-ocsf/src/objects/resource.rs
new file mode 100644
index 0000000000..894806fd2c
--- /dev/null
+++ b/crates/openshell-ocsf/src/objects/resource.rs
@@ -0,0 +1,67 @@
+// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+//! OCSF `resource_details` object.
+
+use serde::{Deserialize, Serialize};
+
+/// OCSF Resource Details object — a resource an event acted on.
+///
+/// The schema requires at least one of `name` or `uid`; the constructors
+/// always set one.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ResourceDetails {
+ /// Stable identifier.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub uid: Option,
+
+ /// Display name.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub name: Option,
+
+ /// Resource type (e.g. "sandbox", "workspace").
+ #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
+ pub type_label: Option,
+}
+
+impl ResourceDetails {
+ /// A resource of `type_label` identified by `uid`.
+ #[must_use]
+ pub fn new(type_label: impl Into, uid: impl Into) -> Self {
+ Self {
+ uid: Some(uid.into()),
+ name: None,
+ type_label: Some(type_label.into()),
+ }
+ }
+
+ /// A resource of `type_label` known only by its name.
+ #[must_use]
+ pub fn named(type_label: impl Into, name: impl Into) -> Self {
+ Self {
+ uid: None,
+ name: Some(name.into()),
+ type_label: Some(type_label.into()),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn resource_serializes_type_under_its_schema_name() {
+ let json = serde_json::to_value(ResourceDetails::new("sandbox", "sb-1")).unwrap();
+ assert_eq!(json, serde_json::json!({"uid": "sb-1", "type": "sandbox"}));
+ }
+
+ #[test]
+ fn named_resource_has_no_uid() {
+ let json = serde_json::to_value(ResourceDetails::named("workspace", "default")).unwrap();
+ assert_eq!(
+ json,
+ serde_json::json!({"name": "default", "type": "workspace"})
+ );
+ }
+}
diff --git a/crates/openshell-server/src/auth/audit.rs b/crates/openshell-server/src/auth/audit.rs
index f78315594d..89bd99ea8a 100644
--- a/crates/openshell-server/src/auth/audit.rs
+++ b/crates/openshell-server/src/auth/audit.rs
@@ -1,17 +1,22 @@
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
-//! OCSF security events for gateway authentication outcomes.
+//! OCSF security events for gateway authentication and authorization
+//! outcomes.
//!
//! Events carry a low-cardinality reason category, never presented
-//! credentials or raw verification errors.
+//! credentials, raw verification errors, or request bodies.
use openshell_ocsf::{
- ActionId, AuthProtocolId, AuthenticationBuilder, DispositionId, Endpoint, HttpMethod,
- HttpRequest, Service, SeverityId, StatusId, Url, User, ocsf_emit,
+ ActionId, ActivityId, ApiActivityBuilder, AuthProtocolId, AuthenticationBuilder, ConfidenceId,
+ DetectionFindingBuilder, DispositionId, Endpoint, FindingInfo, HttpMethod, HttpRequest,
+ ResourceDetails, Service, SeverityId, StatusId, Url, User, UserTypeId, ocsf_emit,
};
+use std::future::Future;
use std::net::SocketAddr;
+use super::principal::Principal;
+
/// Where a gateway connection came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionSource {
@@ -129,3 +134,243 @@ pub fn emit_authentication_failure(
}
ocsf_emit!(builder.build());
}
+
+tokio::task_local! {
+ static REQUEST: RequestContext;
+}
+
+/// The request whose authorization decisions handlers are making.
+#[derive(Debug, Clone)]
+struct RequestContext {
+ path: String,
+ origin: RequestOrigin,
+}
+
+/// Run a request handler so the authorization denials it raises are
+/// recorded against `path` and `origin`.
+///
+/// The scope covers the handler future only. Wrap work the handler spawns in
+/// [`in_current_request`]; denials raised while a streaming response body is
+/// polled are recorded without the request's path and origin.
+pub async fn with_request(path: String, origin: RequestOrigin, handler: F) -> F::Output {
+ REQUEST
+ .scope(RequestContext { path, origin }, handler)
+ .await
+}
+
+/// Carry the current request's audit context into `future`, for work a
+/// handler runs in a spawned task.
+pub fn in_current_request(future: F) -> impl Future