From 5a19aef1ca372aea043b05bad97842da6614c428 Mon Sep 17 00:00:00 2001 From: Kris Hicks Date: Mon, 19 Oct 2026 14:54:55 -0700 Subject: [PATCH] feat(auth): emit OCSF events for gateway authorization denials When the gateway refuses a caller that has already authenticated, it now emits an OCSF API Activity event. The caller gets exactly the same error as before. The event records: - Who: the principal (user, mTLS operator, sandbox, or gateway peer). - What: the gRPC method, plus the sandbox or workspace that was refused when there is one. - Why: a reason such as cross_sandbox, missing_role or not_workspace_member. - Correlation: the caller's address and request ID, the same fields the authentication events carry. That covers the denials made centrally by the router and the shared guards, plus the checks inside handlers that have no shared guard: a sandbox naming another sandbox (in either sandbox-name resolver), a non-user caller refused by mutation admission, a sandbox sending a config update it isn't allowed to, and a non-admin granting the workspace admin role. Denials raised in the mutation admission worker, which runs in a spawned task, keep the request's context. When a sandbox credential is refused for calling a method it may not call, touching another sandbox, or acting across workspaces, the gateway also emits a High-severity Detection Finding. It carries the same correlation_uid as the denial event. A real supervisor never makes these requests, so a finding means the credential is probably being misused. Two cases are recorded without a finding: a method the gateway doesn't recognize (unknown_method), so a newer supervisor doesn't look like a compromised one, and a supervisor whose own sandbox was deleted mid-request, which is refused but isn't misuse. The shorthand line names the refused sandbox or workspace, for example sandbox:, so operators reading console logs can see the target without JSONL or OTLP export. To support this, the OCSF crate's Actor can now hold a user instead of a process, and the API Activity builder gained the fields gateway events need. Supervisor inference events come out unchanged. Gateway events declare the container profile only when they concern a sandbox, as MXC findings do. The OCSF 1.8.0 resource_details schema is vendored so the refused sandbox or workspace goes in the standard resources field. Refs #2912 Signed-off-by: Kris Hicks --- crates/openshell-ocsf/schemas/ocsf/README.md | 5 +- .../ocsf/v1.8.0/objects/resource_details.json | 291 +++++++++++++++++ .../src/builders/api_activity.rs | 232 ++++++++++++- crates/openshell-ocsf/src/builders/finding.rs | 61 +++- crates/openshell-ocsf/src/builders/http.rs | 2 +- crates/openshell-ocsf/src/builders/mod.rs | 18 +- crates/openshell-ocsf/src/builders/network.rs | 2 +- .../openshell-ocsf/src/events/api_activity.rs | 12 +- .../src/events/process_activity.rs | 4 +- crates/openshell-ocsf/src/format/shorthand.rs | 139 ++++---- crates/openshell-ocsf/src/lib.rs | 2 +- crates/openshell-ocsf/src/objects/api.rs | 6 + crates/openshell-ocsf/src/objects/mod.rs | 2 + crates/openshell-ocsf/src/objects/process.rs | 45 ++- crates/openshell-ocsf/src/objects/resource.rs | 67 ++++ crates/openshell-server/src/auth/audit.rs | 253 ++++++++++++++- crates/openshell-server/src/auth/authz.rs | 59 ++-- crates/openshell-server/src/auth/guard.rs | 34 +- .../src/auth/workspace_authz.rs | 102 +++++- .../src/grpc/mutation_replay.rs | 10 +- .../src/grpc/mutation_replay/tests.rs | 118 +++++++ crates/openshell-server/src/grpc/policy.rs | 77 ++++- crates/openshell-server/src/grpc/sandbox.rs | 114 ++++++- crates/openshell-server/src/grpc/workspace.rs | 12 +- crates/openshell-server/src/multiplex.rs | 306 ++++++++++++++++-- docs/observability/logging.mdx | 8 + docs/observability/ocsf-json-export.mdx | 17 + 27 files changed, 1807 insertions(+), 191 deletions(-) create mode 100644 crates/openshell-ocsf/schemas/ocsf/v1.8.0/objects/resource_details.json create mode 100644 crates/openshell-ocsf/src/objects/resource.rs 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 { + let request = REQUEST.try_with(Clone::clone).ok(); + async move { + match request { + Some(request) => REQUEST.scope(request, future).await, + None => future.await, + } + } +} + +/// Why the gateway refused an authenticated caller. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthzDenial<'a> { + /// The principal type may not call the method, or may not call it with + /// these arguments. + MethodNotPermitted, + /// The method is not one the gateway knows, for example one added by a + /// newer client. + UnknownMethod, + /// The caller lacks the role the method requires. + MissingRole, + /// The caller's token lacks the scope the method requires. + MissingScope, + /// A sandbox principal referenced a sandbox other than its own by ID. + CrossSandbox { sandbox_id: &'a str }, + /// A sandbox principal referenced a sandbox other than its own by name. + CrossSandboxName { name: &'a str }, + /// The caller is not a member of the workspace. + NotWorkspaceMember { workspace: &'a str }, + /// The caller's workspace role is below the one the operation requires. + InsufficientWorkspaceRole { workspace: &'a str }, + /// The operation spans workspaces and requires a platform admin. + PlatformAdminRequired, + /// The operation requires a verified mTLS operator. + OperatorRequired, +} + +impl AuthzDenial<'_> { + fn category(self) -> &'static str { + match self { + Self::MethodNotPermitted => "method_not_permitted", + Self::UnknownMethod => "unknown_method", + Self::MissingRole => "missing_role", + Self::MissingScope => "missing_scope", + Self::CrossSandbox { .. } | Self::CrossSandboxName { .. } => "cross_sandbox", + Self::NotWorkspaceMember { .. } => "not_workspace_member", + Self::InsufficientWorkspaceRole { .. } => "insufficient_workspace_role", + Self::PlatformAdminRequired => "platform_admin_required", + Self::OperatorRequired => "operator_required", + } + } + + fn resource(self) -> Option { + match self { + Self::CrossSandbox { sandbox_id } => { + Some(ResourceDetails::new("sandbox", bounded(sandbox_id))) + } + Self::CrossSandboxName { name } => { + Some(ResourceDetails::named("sandbox", bounded(name))) + } + Self::NotWorkspaceMember { workspace } + | Self::InsufficientWorkspaceRole { workspace } => { + Some(ResourceDetails::named("workspace", bounded(workspace))) + } + _ => None, + } + } + + /// A sandbox principal is the supervisor, which never attempts these on + /// its own: one that does is compromised or impersonated. + fn is_boundary_violation(self, principal: &Principal) -> bool { + matches!(principal, Principal::Sandbox(_)) + && matches!( + self, + Self::MethodNotPermitted + | Self::CrossSandbox { .. } + | Self::CrossSandboxName { .. } + | Self::PlatformAdminRequired + ) + } +} + +/// Longest request-supplied identifier recorded on an event. +const MAX_REQUESTED_ID_LEN: usize = 128; + +/// Cap a request-supplied identifier so a caller cannot inflate events. +fn bounded(value: &str) -> String { + if value.len() <= MAX_REQUESTED_ID_LEN { + return value.to_string(); + } + let mut end = MAX_REQUESTED_ID_LEN; + while !value.is_char_boundary(end) { + end -= 1; + } + format!("{}...", &value[..end]) +} + +/// The principal as an OCSF user, identified but never with credentials. +fn principal_user(principal: &Principal) -> User { + match principal { + Principal::User(user) => User::new(&user.identity.subject, UserTypeId::User), + Principal::Operator(operator) => User::new(&operator.identity.subject, UserTypeId::Admin), + Principal::Sandbox(sandbox) => User::other(&sandbox.sandbox_id, "Sandbox"), + Principal::Peer(peer) => User::other(&peer.replica_id, "Gateway Peer"), + Principal::Anonymous => User::unidentified(), + } +} + +/// Emit an authorization denial raised by a handler for the current request. +pub fn emit_authorization_denial(principal: &Principal, denial: AuthzDenial<'_>) { + let request = REQUEST.try_with(Clone::clone).ok(); + let (path, origin) = request + .map(|request| (request.path, request.origin)) + .unwrap_or_default(); + emit_authorization_denial_for(&path, &origin, principal, denial); +} + +/// Emit an API Activity denial for a gRPC request to `path`, paired with a +/// Detection Finding when a sandbox principal crossed its boundary. +pub fn emit_authorization_denial_for( + path: &str, + origin: &RequestOrigin, + principal: &Principal, + denial: AuthzDenial<'_>, +) { + let ctx = crate::gateway_ocsf::context("", ""); + let category = denial.category(); + let finding = denial.is_boundary_violation(principal); + // Both events of a pair must share an identifier even when the request + // has no usable `x-request-id`. + let correlation_uid = origin + .request_id + .clone() + .or_else(|| finding.then(|| uuid::Uuid::new_v4().to_string())); + let (service, operation) = path + .strip_prefix('/') + .and_then(|path| path.split_once('/')) + .unwrap_or(("", "unknown")); + + let mut builder = ApiActivityBuilder::new(&ctx, operation) + .actor_user(principal_user(principal)) + .src_endpoint(origin.source.map_or_else( + || Endpoint::from_name("unknown"), + ConnectionSource::endpoint, + )) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .status_detail(category) + .message(format!("authorization denied: {category}")); + if !service.is_empty() { + builder = builder + .api_service(Service::new(service)) + .http_request(HttpRequest { + http_method: HttpMethod::Post, + url: Some(Url::from_path(path)), + }); + } + if let Some(resource) = denial.resource() { + builder = builder.resource(resource); + } + if let Some(uid) = &correlation_uid { + builder = builder.correlation_uid(uid); + } + ocsf_emit!(builder.build()); + + if let (true, Principal::Sandbox(sandbox)) = (finding, principal) { + let mut evidence = vec![ + ("sandbox_id", sandbox.sandbox_id.as_str()), + ("reason", category), + ]; + if !path.is_empty() { + evidence.push(("operation", path)); + } + let requested = match denial { + AuthzDenial::CrossSandbox { sandbox_id } => { + Some(("requested_sandbox_id", bounded(sandbox_id))) + } + AuthzDenial::CrossSandboxName { name } => { + Some(("requested_sandbox_name", bounded(name))) + } + _ => None, + }; + if let Some((key, value)) = &requested { + evidence.push((key, value)); + } + let mut finding = DetectionFindingBuilder::new(&ctx) + .activity(ActivityId::Open) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::High) + .is_alert(true) + .confidence(ConfidenceId::High) + .finding_info( + FindingInfo::new( + "sandbox-authorization-boundary", + "Sandbox Principal Exceeded Its Authorization", + ) + .with_desc( + "A sandbox credential was used for an operation outside its own sandbox. \ + The supervisor never does this, so the credential may be compromised.", + ), + ) + .evidence_pairs(&evidence) + .message(format!( + "sandbox authorization boundary violation: {category}" + )); + if let Some(uid) = &correlation_uid { + finding = finding.correlation_uid(uid); + } + ocsf_emit!(finding.build()); + } +} diff --git a/crates/openshell-server/src/auth/authz.rs b/crates/openshell-server/src/auth/authz.rs index 8d2e0eca48..943d4b79bb 100644 --- a/crates/openshell-server/src/auth/authz.rs +++ b/crates/openshell-server/src/auth/authz.rs @@ -12,6 +12,7 @@ //! authentication is handled by explicit application-layer authenticators, //! authorization is a gateway concern. +use super::audit::AuthzDenial; use super::identity::Identity; use super::{descriptor_authz, method_authz}; use tonic::Status; @@ -56,10 +57,19 @@ impl AuthzPolicy { } } +/// A failed RBAC check: what the caller lacked, and the status returned to +/// it. +#[derive(Debug)] +pub struct RbacDenial { + pub denial: AuthzDenial<'static>, + pub status: Status, +} + impl AuthzPolicy { /// Check whether the identity is authorized to call the given method. /// - /// Returns `Ok(())` if authorized, `Err(PERMISSION_DENIED)` if not. + /// Returns `Ok(())` if authorized, or the denial with a `PERMISSION_DENIED` + /// status if not. /// When both role names are empty, all authenticated callers are authorized /// (authentication-only mode for providers like GitHub). /// @@ -69,7 +79,7 @@ impl AuthzPolicy { /// role via `authorize_workspace()`. A known Bearer method with neither role /// annotation requires authentication only. #[allow(clippy::result_large_err)] - pub fn check(&self, identity: &Identity, method: &str) -> Result<(), Status> { + pub fn check(&self, identity: &Identity, method: &str) -> Result<(), RbacDenial> { let required = match descriptor_authz::lookup(method) { Some(entry) if entry.global_role.is_some() => Some(&self.admin_role), Some(entry) if entry.workspace_role.is_some() => Some(&self.user_role), @@ -96,9 +106,10 @@ impl AuthzPolicy { method = method, "authorization denied: missing role" ); - return Err(Status::permission_denied(format!( - "role '{required}' required" - ))); + return Err(RbacDenial { + denial: AuthzDenial::MissingRole, + status: Status::permission_denied(format!("role '{required}' required")), + }); } } @@ -110,7 +121,7 @@ impl AuthzPolicy { } #[allow(clippy::result_large_err, clippy::unused_self)] - fn check_scope(&self, identity: &Identity, method: &str) -> Result<(), Status> { + fn check_scope(&self, identity: &Identity, method: &str) -> Result<(), RbacDenial> { if identity.scopes.iter().any(|s| s == SCOPE_ALL) { return Ok(()); } @@ -136,9 +147,10 @@ impl AuthzPolicy { method = method, "authorization denied: missing scope" ); - Err(Status::permission_denied(format!( - "scope '{required_scope}' required" - ))) + Err(RbacDenial { + denial: AuthzDenial::MissingScope, + status: Status::permission_denied(format!("scope '{required_scope}' required")), + }) } } @@ -198,10 +210,12 @@ mod tests { fn user_blocked_for_platform_admin_methods() { let id = identity_with_roles(&["openshell-user"]); let policy = default_policy(); - assert!( + assert_eq!( policy .check(&id, "/openshell.v1.OpenShell/CreateWorkspace") - .is_err() + .expect_err("user role must not satisfy the admin role") + .denial, + AuthzDenial::MissingRole ); assert!( policy @@ -431,19 +445,22 @@ mod tests { ); let err = policy .check(&id, "/openshell.v1.OpenShell/AttachSandboxProvider") - .unwrap_err(); + .unwrap_err() + .status; assert_eq!(err.code(), tonic::Code::PermissionDenied); assert!(err.message().contains("sandbox:write")); let err = policy .check(&id, "/openshell.v1.OpenShell/ExposeService") - .unwrap_err(); + .unwrap_err() + .status; assert_eq!(err.code(), tonic::Code::PermissionDenied); assert!(err.message().contains("sandbox:write")); let err = policy .check(&id, "/openshell.v1.OpenShell/DeleteService") - .unwrap_err(); + .unwrap_err() + .status; assert_eq!(err.code(), tonic::Code::PermissionDenied); assert!(err.message().contains("sandbox:write")); } @@ -485,7 +502,8 @@ mod tests { &admin_without_scope, "/openshell.v1.OpenShell/RotateProviderCredential", ) - .unwrap_err(); + .unwrap_err() + .status; assert_eq!(err.code(), tonic::Code::PermissionDenied); assert!(err.message().contains("provider:write")); } @@ -503,7 +521,8 @@ mod tests { let wrong_scope = identity_with_roles_and_scopes(&["openshell-user"], &["sandbox:read"]); let err = policy .check(&wrong_scope, "/openshell.v1.OpenShell/GetSandboxConfig") - .unwrap_err(); + .unwrap_err() + .status; assert_eq!(err.code(), tonic::Code::PermissionDenied); assert!(err.message().contains("config:read")); } @@ -515,8 +534,9 @@ mod tests { let err = policy .check(&id, "/openshell.v1.OpenShell/ListSandboxes") .expect_err("identity without required scope must be denied"); - assert_eq!(err.code(), tonic::Code::PermissionDenied); - assert!(err.message().contains("sandbox:read")); + assert_eq!(err.denial, AuthzDenial::MissingScope); + assert_eq!(err.status.code(), tonic::Code::PermissionDenied); + assert!(err.status.message().contains("sandbox:read")); } #[test] @@ -569,7 +589,8 @@ mod tests { let policy = scoped_policy(); let err = policy .check(&id, "/openshell.v1.OpenShell/SomeFutureMethod") - .unwrap_err(); + .unwrap_err() + .status; assert!(err.message().contains("openshell:all")); } diff --git a/crates/openshell-server/src/auth/guard.rs b/crates/openshell-server/src/auth/guard.rs index 2ab00a125a..53dd73251c 100644 --- a/crates/openshell-server/src/auth/guard.rs +++ b/crates/openshell-server/src/auth/guard.rs @@ -8,10 +8,10 @@ //! `sandbox_id`. User principals retain the broad scope the RBAC layer //! already evaluated. +use super::audit::{AuthzDenial, emit_authorization_denial}; use super::principal::Principal; use super::principal::SandboxPrincipal; use tonic::Status; -use tracing::info; /// Reject a sandbox-class request whose body references a sandbox other /// than the one the calling principal was authenticated against. @@ -30,17 +30,21 @@ use tracing::info; pub fn ensure_sandbox_scope(principal: &Principal, claimed_sandbox_id: &str) -> Result<(), Status> { match principal { Principal::User(_) | Principal::Operator(_) => Ok(()), - Principal::Peer(_) => Err(Status::permission_denied( - "gateway peer principals may not call sandbox-scoped methods", - )), + Principal::Peer(_) => { + emit_authorization_denial(principal, AuthzDenial::MethodNotPermitted); + Err(Status::permission_denied( + "gateway peer principals may not call sandbox-scoped methods", + )) + } Principal::Sandbox(p) => { if p.sandbox_id == claimed_sandbox_id { Ok(()) } else { - info!( - principal_sandbox_id = %p.sandbox_id, - requested_sandbox_id = %claimed_sandbox_id, - "cross-sandbox access denied" + emit_authorization_denial( + principal, + AuthzDenial::CrossSandbox { + sandbox_id: claimed_sandbox_id, + }, ); Err(Status::permission_denied( "cross-sandbox access denied: principal does not own this sandbox", @@ -87,9 +91,12 @@ pub fn ensure_sandbox_principal_scope( ensure_sandbox_scope(principal, claimed_sandbox_id)?; Ok(p.clone()) } - Principal::User(_) | Principal::Operator(_) | Principal::Peer(_) => Err( - Status::permission_denied("supervisor RPCs require a sandbox principal"), - ), + Principal::User(_) | Principal::Operator(_) | Principal::Peer(_) => { + emit_authorization_denial(principal, AuthzDenial::MethodNotPermitted); + Err(Status::permission_denied( + "supervisor RPCs require a sandbox principal", + )) + } Principal::Anonymous => Err(Status::unauthenticated( "supervisor RPCs require an authenticated sandbox principal", )), @@ -116,7 +123,10 @@ pub fn require_operator( None | Some(Principal::Anonymous) => { Err(Status::unauthenticated("operator authentication required")) } - _ => Err(Status::permission_denied("verified mTLS operator required")), + Some(principal) => { + emit_authorization_denial(principal, AuthzDenial::OperatorRequired); + Err(Status::permission_denied("verified mTLS operator required")) + } } } diff --git a/crates/openshell-server/src/auth/workspace_authz.rs b/crates/openshell-server/src/auth/workspace_authz.rs index 0b8e351b08..fe1d4c017d 100644 --- a/crates/openshell-server/src/auth/workspace_authz.rs +++ b/crates/openshell-server/src/auth/workspace_authz.rs @@ -8,6 +8,7 @@ //! auth mode + scope + global role; this module validates workspace membership //! and workspace-level role. +use super::audit::{AuthzDenial, emit_authorization_denial}; use super::principal::Principal; use openshell_core::proto::{ WorkspaceRole as ProtoWorkspaceRole, WorkspaceSelector, @@ -158,6 +159,12 @@ pub async fn authorize_workspace( .map_err(|e| Status::internal(format!("membership lookup failed: {e}")))?; let Some(member) = member else { + emit_authorization_denial( + principal, + AuthzDenial::NotWorkspaceMember { + workspace: &workspace, + }, + ); let workspace_arg = shell_quote_for_hint(&workspace); let subject_arg = shell_quote_for_hint(&user.identity.subject); return Err(Status::permission_denied(format!( @@ -171,6 +178,12 @@ pub async fn authorize_workspace( .unwrap_or(ProtoWorkspaceRole::Unspecified); if !role_satisfies(member_role, min_role) { + emit_authorization_denial( + principal, + AuthzDenial::InsufficientWorkspaceRole { + workspace: &workspace, + }, + ); let workspace_arg = shell_quote_for_hint(&workspace); let subject_arg = shell_quote_for_hint(&user.identity.subject); let role = min_role.as_str(); @@ -194,9 +207,12 @@ pub async fn authorize_workspace( workspace, grant: AuthGrant::Sandbox, }), - Principal::Peer(_) => Err(Status::permission_denied( - "gateway peer principals cannot perform workspace operations", - )), + Principal::Peer(_) => { + emit_authorization_denial(principal, AuthzDenial::MethodNotPermitted); + Err(Status::permission_denied( + "gateway peer principals cannot perform workspace operations", + )) + } Principal::Anonymous => Err(Status::unauthenticated("authentication required")), } } @@ -222,20 +238,18 @@ pub async fn authorize_sandbox_workspace( /// `list_*` with `all_workspaces: true`. #[allow(clippy::result_large_err)] pub fn require_platform_admin(admin_role: &str, principal: &Principal) -> Result<(), Status> { - match principal { - Principal::Operator(_) => Ok(()), - Principal::User(user) if is_platform_admin(&user.identity.roles, admin_role) => Ok(()), - Principal::User(_) => Err(Status::permission_denied( - "platform admin role required for cross-workspace operations", - )), - Principal::Sandbox(_) => Err(Status::permission_denied( - "sandbox principals cannot perform cross-workspace operations", - )), - Principal::Peer(_) => Err(Status::permission_denied( - "gateway peer principals cannot perform cross-workspace operations", - )), - Principal::Anonymous => Err(Status::unauthenticated("authentication required")), - } + let message = match principal { + Principal::Operator(_) => return Ok(()), + Principal::User(user) if is_platform_admin(&user.identity.roles, admin_role) => { + return Ok(()); + } + Principal::User(_) => "platform admin role required for cross-workspace operations", + Principal::Sandbox(_) => "sandbox principals cannot perform cross-workspace operations", + Principal::Peer(_) => "gateway peer principals cannot perform cross-workspace operations", + Principal::Anonymous => return Err(Status::unauthenticated("authentication required")), + }; + emit_authorization_denial(principal, AuthzDenial::PlatformAdminRequired); + Err(Status::permission_denied(message)) } /// Check whether the caller's OIDC roles include the platform admin role. @@ -393,6 +407,60 @@ mod tests { ); } + #[tokio::test] + async fn workspace_denials_record_the_workspace_and_reason() { + let capture = openshell_ocsf::test_support::OcsfCapture::default(); + let _guard = capture.install(); + let store = test_store().await; + add_member(&store, "default", "user-c", ProtoWorkspaceRole::User).await; + + for (subject, reason) in [ + ("stranger", "not_workspace_member"), + ("user-c", "insufficient_workspace_role"), + ] { + let principal = user_principal(subject, &["openshell-user"]); + authorize_workspace( + &store, + "openshell-admin", + &principal, + "default", + MinWorkspaceRole::Admin, + ) + .await + .expect_err("must be denied"); + let event = capture.events().pop().unwrap().to_json().unwrap(); + assert_eq!(event["status_detail"], reason); + assert_eq!(event["actor"]["user"]["uid"], subject); + assert_eq!( + event["resources"], + serde_json::json!([{"name": "default", "type": "workspace"}]) + ); + } + assert_eq!(capture.events().len(), 2, "users never produce a finding"); + } + + #[tokio::test] + async fn sandbox_cross_workspace_request_raises_a_finding() { + let capture = openshell_ocsf::test_support::OcsfCapture::default(); + let _guard = capture.install(); + + require_platform_admin("openshell-admin", &sandbox_principal()) + .expect_err("sandbox must be denied"); + + let events: Vec<_> = capture + .events() + .iter() + .map(|event| event.to_json().unwrap()) + .collect(); + 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"] + ); + } + #[tokio::test] async fn non_member_rejected() { let store = test_store().await; diff --git a/crates/openshell-server/src/grpc/mutation_replay.rs b/crates/openshell-server/src/grpc/mutation_replay.rs index eafbbd00ca..9cfb799c4f 100644 --- a/crates/openshell-server/src/grpc/mutation_replay.rs +++ b/crates/openshell-server/src/grpc/mutation_replay.rs @@ -157,6 +157,10 @@ pub(super) async fn run( let request_id = validate_request_id(request.get_ref().request_id())?; let principal = super::extract_principal(&request)?; let Some(identity) = principal.user_identity() else { + crate::auth::audit::emit_authorization_denial( + &principal, + crate::auth::audit::AuthzDenial::MethodNotPermitted, + ); return Err(Status::permission_denied( "request admission requires a user principal", )); @@ -227,7 +231,7 @@ pub(super) async fn run( let state = Arc::clone(state); // Spawn BEFORE attempting MustCreate. Cancellation during the database write // must not drop the owner between durable admission and execution. - tokio::spawn(async move { + tokio::spawn(crate::auth::audit::in_current_request(async move { let _permit = permit; execute_owned( &state, @@ -239,7 +243,7 @@ pub(super) async fn run( scope, ) .await - }) + })) .await .map_err(|_| uncertain())? } @@ -642,7 +646,7 @@ async fn member_scope( ) .await?; if let Some(role) = role { - workspace::authorize_member_role(role, authz.grant)?; + workspace::authorize_member_role(role, authz.grant, principal)?; } named_scope(state, &authz.workspace).await } diff --git a/crates/openshell-server/src/grpc/mutation_replay/tests.rs b/crates/openshell-server/src/grpc/mutation_replay/tests.rs index 5903705aea..ad294c6f7b 100644 --- a/crates/openshell-server/src/grpc/mutation_replay/tests.rs +++ b/crates/openshell-server/src/grpc/mutation_replay/tests.rs @@ -1031,3 +1031,121 @@ async fn restart_after_effect_without_success_persistence_never_reexecutes() { original ); } + +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; + let mut request = Request::new(openshell_core::proto::UpdateConfigRequest { + global: true, + 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()), + }, + )); + 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); + 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); +} + +/// Refuses its caller from inside the admission worker, as a handler whose +/// authorization changed after admission would. +#[derive(Clone, PartialEq, Message)] +struct DeniedInWorker { + #[prost(string, tag = "1")] + name: String, + #[prost(string, tag = "3")] + request_id: String, +} + +impl Mutation for DeniedInWorker { + type Output = CreateWorkspaceResponse; + const METHOD: &'static str = "CreateWorkspace"; + fn request_id(&self) -> &str { + &self.request_id + } + async fn authorize(&self, state: &ServerState, principal: &Principal) -> Result { + CreateWorkspaceRequest { + name: self.name.clone(), + ..Default::default() + } + .authorize(state, principal) + .await + } + async fn execute( + _state: &Arc, + request: Request, + ) -> Result, Status> { + let principal = request.extensions().get::().unwrap(); + crate::auth::audit::emit_authorization_denial( + principal, + crate::auth::audit::AuthzDenial::PlatformAdminRequired, + ); + Err(Status::permission_denied("membership revoked")) + } + fn capture(response: &Response) -> Result { + resource_success(response.get_ref().workspace.as_ref()) + } + async fn restore(store: &Store, success: Success) -> Result { + Ok(CreateWorkspaceResponse { + workspace: Some(restore_resource(store, success).await?), + }) + } +} + +#[tokio::test] +async fn denial_in_the_admission_worker_keeps_the_request_context() { + let state = test_server_state().await; + let request = authed_request(DeniedInWorker { + 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, + run(&state, request), + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::PermissionDenied); + let events = captured_json(&capture); + 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); +} diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 7af4c6437f..eaf20d20cd 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -2754,8 +2754,22 @@ async fn resolve_sandbox_by_name_for_principal( .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))?; match principal { - Principal::Sandbox(_) => { + Principal::Sandbox(caller) => { let Some(sandbox) = sandbox else { + // A supervisor only ever names its own sandbox. When its own + // record is gone too, the sandbox was deleted, which is not + // misuse. + let caller_exists = store + .get_message::(&caller.sandbox_id) + .await + .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? + .is_some(); + if caller_exists { + crate::auth::audit::emit_authorization_denial( + principal, + crate::auth::audit::AuthzDenial::CrossSandboxName { name }, + ); + } return Err(Status::permission_denied( "sandbox not found or not owned by caller", )); @@ -4198,7 +4212,12 @@ async fn handle_update_config_inner( sandbox.object_workspace().to_string() }); if sandbox_caller { - validate_sandbox_caller_update(&req)?; + validate_sandbox_caller_update(&req).inspect_err(|_| { + crate::auth::audit::emit_authorization_denial( + principal, + crate::auth::audit::AuthzDenial::MethodNotPermitted, + ); + })?; } let key = req.setting_key.trim(); let has_policy = req.policy.is_some(); @@ -5449,6 +5468,9 @@ async fn ensure_log_stream_sandbox_scope( sandbox_id: &str, validated_sandbox_id: &mut Option, ) -> Result<(), Status> { + // Guard every batch, so switching to another sandbox mid-stream is + // recorded like any other cross-sandbox attempt. + crate::auth::guard::ensure_sandbox_scope(principal, sandbox_id)?; if let Some(validated) = validated_sandbox_id.as_deref() { if sandbox_id != validated { return Err(Status::permission_denied( @@ -5458,7 +5480,6 @@ async fn ensure_log_stream_sandbox_scope( return Ok(()); } - crate::auth::guard::ensure_sandbox_scope(principal, sandbox_id)?; state .store .get_message::(sandbox_id) @@ -11658,6 +11679,8 @@ mod tests { #[tokio::test] async fn sandbox_submit_policy_analysis_missing_name_returns_permission_denied() { let state = test_server_state().await; + let own = test_sandbox("sb-a", "own", ProtoSandboxPolicy::default(), vec![]); + state.store.put_message(&own).await.unwrap(); let req = with_sandbox( Request::new(SubmitPolicyAnalysisRequest { workspace_scope: Some(openshell_core::proto::workspace_selector("default")), @@ -11667,10 +11690,47 @@ mod tests { "sb-a", ); + let capture = openshell_ocsf::test_support::OcsfCapture::default(); + let _guard = capture.install(); + let err = handle_submit_policy_analysis(&state, req) .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(); + 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); + } + + #[tokio::test] + async fn sandbox_whose_own_record_is_gone_is_denied_without_a_finding() { + let state = test_server_state().await; + let req = with_sandbox( + Request::new(SubmitPolicyAnalysisRequest { + workspace_scope: Some(openshell_core::proto::workspace_selector("default")), + name: "own".to_string(), + ..Default::default() + }), + "sb-a", + ); + let capture = openshell_ocsf::test_support::OcsfCapture::default(); + let _guard = capture.install(); + + let err = handle_submit_policy_analysis(&state, req) + .await + .expect_err("a deleted sandbox must be refused"); + + assert_eq!(err.code(), Code::PermissionDenied); + assert!(capture.events().is_empty(), "deletion is not misuse"); } #[tokio::test] @@ -11738,15 +11798,26 @@ mod tests { let req = with_sandbox(Request::new(()), "sb-a"); let principal = req.extensions().get::().unwrap().clone(); let mut validated = None; + let capture = openshell_ocsf::test_support::OcsfCapture::default(); + let _guard = capture.install(); ensure_log_stream_sandbox_scope(&state, &principal, "sb-a", &mut validated) .await .expect("first frame should validate"); + assert!(capture.events().is_empty(), "own sandbox is not a denial"); let err = ensure_log_stream_sandbox_scope(&state, &principal, "sb-b", &mut validated) .await .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(); + assert_eq!(events.len(), 2, "{events:?}"); + assert_eq!(events[0]["status_detail"], "cross_sandbox"); + assert_eq!(events[1]["class_uid"], 2004); } #[tokio::test] diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index c1a7d3034d..8bdcb22deb 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -228,22 +228,27 @@ pub(super) async fn resolve_and_authorize_sandbox_name( .ok_or_else(|| Status::not_found("sandbox not found")); }; + let not_owned = || Status::permission_denied("sandbox not found or not owned by caller"); + // The caller's own record can disappear between authentication and this + // read when the sandbox is deleted; that is not misuse. let sandbox = state .store .get_message::(&sandbox_principal.sandbox_id) .await .map_err(|e| Status::internal(format!("fetch sandbox failed: {e}")))? - .filter(|sandbox| { - sandbox.metadata.as_ref().is_some_and(|metadata| { - metadata.name == sandbox_name - && (workspace.is_empty() || workspace == sandbox.object_workspace()) - }) - }); - let Some(sandbox) = sandbox else { - return Err(Status::permission_denied( - "sandbox not found or not owned by caller", - )); - }; + .ok_or_else(not_owned)?; + let names_itself = sandbox.metadata.as_ref().is_some_and(|metadata| { + metadata.name == sandbox_name + && (workspace.is_empty() || workspace == sandbox.object_workspace()) + }); + if !names_itself { + // A supervisor only ever names its own sandbox. + crate::auth::audit::emit_authorization_denial( + principal, + crate::auth::audit::AuthzDenial::CrossSandboxName { name: sandbox_name }, + ); + return Err(not_owned()); + } authorize_sandbox_workspace( &state.store, @@ -9687,6 +9692,93 @@ 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 + .store + .put_message(&test_sandbox(name, vec![])) + .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 capture = openshell_ocsf::test_support::OcsfCapture::default(); + let _guard = capture.install(); + + resolve_and_authorize_sandbox_name( + &state, + &principal, + "own", + "default", + MinWorkspaceRole::User, + ) + .await + .expect("a sandbox may name itself"); + assert!(capture.events().is_empty(), "own sandbox is not a denial"); + + let err = resolve_and_authorize_sandbox_name( + &state, + &principal, + "victim", + "default", + MinWorkspaceRole::User, + ) + .await + .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(); + 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); + } + + #[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 capture = openshell_ocsf::test_support::OcsfCapture::default(); + let _guard = capture.install(); + + let err = resolve_and_authorize_sandbox_name( + &state, + &principal, + "own", + "default", + MinWorkspaceRole::User, + ) + .await + .expect_err("a deleted sandbox must be refused"); + + assert_eq!(err.code(), tonic::Code::PermissionDenied); + assert!(capture.events().is_empty(), "deletion is not misuse"); + } + /// Workspace collection operations reject non-members, while operations on /// a sandbox reference hide both missing and unauthorized sandboxes as /// `NOT_FOUND` to avoid an object-existence oracle. diff --git a/crates/openshell-server/src/grpc/workspace.rs b/crates/openshell-server/src/grpc/workspace.rs index 8cbc04f527..5d61036118 100644 --- a/crates/openshell-server/src/grpc/workspace.rs +++ b/crates/openshell-server/src/grpc/workspace.rs @@ -457,7 +457,11 @@ pub(super) async fn handle_delete_workspace( })) } -pub(super) fn authorize_member_role(role: i32, grant: AuthGrant) -> Result<(), Status> { +pub(super) fn authorize_member_role( + role: i32, + grant: AuthGrant, + principal: &Principal, +) -> Result<(), Status> { let role = WorkspaceRole::try_from(role).unwrap_or(WorkspaceRole::Unspecified); if role == WorkspaceRole::Unspecified { return Err(Status::invalid_argument( @@ -465,6 +469,10 @@ pub(super) fn authorize_member_role(role: i32, grant: AuthGrant) -> Result<(), S )); } if role == WorkspaceRole::Admin && grant != AuthGrant::PlatformAdmin { + crate::auth::audit::emit_authorization_denial( + principal, + crate::auth::audit::AuthzDenial::PlatformAdminRequired, + ); return Err(Status::permission_denied( "only platform admins can assign the workspace admin role", )); @@ -495,7 +503,7 @@ pub(super) async fn handle_add_workspace_member( return Err(Status::invalid_argument("principal_subject is required")); } - authorize_member_role(req.role, authz.grant)?; + authorize_member_role(req.role, authz.grant, &principal)?; let count = state .store diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index de6050a065..29a85331a1 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -43,7 +43,7 @@ use tracing_opentelemetry::OpenTelemetrySpanExt as _; use crate::{ OpenShellService, ServerState, - auth::audit::{self, ConnectionSource, RequestOrigin}, + auth::audit::{self, AuthzDenial, ConnectionSource, RequestOrigin}, auth::authenticator::{AuthFailureReason, AuthMechanism, Authentication, AuthenticatorChain}, auth::authz::AuthzPolicy, auth::identity::Identity, @@ -1211,49 +1211,51 @@ where unauthenticated_dev_user_principal() }; - match principal { - Principal::Operator(_) => { - if !crate::auth::method_authz::is_operator_callable(&path) { - return Ok(status_response(tonic::Status::permission_denied( - "operators may not call supervisor, peer, or unknown methods", - ))); - } + let method_denied = |message: &str| { + let denial = if crate::auth::method_authz::lookup(&path).is_some() { + AuthzDenial::MethodNotPermitted + } else { + AuthzDenial::UnknownMethod + }; + Some((denial, tonic::Status::permission_denied(message))) + }; + let denial = match principal { + Principal::Operator(_) + if !crate::auth::method_authz::is_operator_callable(&path) => + { + method_denied("operators may not call supervisor, peer, or unknown methods") } - Principal::User(ref user) => { - if !crate::auth::method_authz::is_user_callable(&path) { - return Ok(status_response(tonic::Status::permission_denied( - "this method requires a sandbox principal", - ))); - } - if let Some(ref policy) = authz_policy - && let Err(status) = policy.check(&user.identity, &path) - { - return Ok(status_response(status)); - } + Principal::User(_) if !crate::auth::method_authz::is_user_callable(&path) => { + method_denied("this method requires a sandbox principal") } - Principal::Sandbox(_) => { - if !crate::auth::sandbox_methods::is_sandbox_callable(&path) { - return Ok(status_response(tonic::Status::permission_denied( - "sandbox principals may not call this method", - ))); - } + Principal::User(ref user) => authz_policy + .as_ref() + .and_then(|policy| policy.check(&user.identity, &path).err()) + .map(|rbac| (rbac.denial, rbac.status)), + Principal::Sandbox(_) + if !crate::auth::sandbox_methods::is_sandbox_callable(&path) => + { + method_denied("sandbox principals may not call this method") } - Principal::Peer(_) => { - if !crate::auth::method_authz::is_peer_callable(&path) { - return Ok(status_response(tonic::Status::permission_denied( - "gateway peer principals may not call this method", - ))); - } + Principal::Peer(_) if !crate::auth::method_authz::is_peer_callable(&path) => { + method_denied("gateway peer principals may not call this method") } Principal::Anonymous => { return Ok(status_response(tonic::Status::unauthenticated( "anonymous callers may not call authenticated methods", ))); } + Principal::Operator(_) | Principal::Sandbox(_) | Principal::Peer(_) => None, + }; + let origin = origin(req.headers()); + if let Some((denial, status)) = denial { + audit::emit_authorization_denial_for(&path, &origin, &principal, denial); + return Ok(status_response(status)); } req.extensions_mut().insert(principal); - inner.ready().await?.call(req).await + let call = inner.ready().await?.call(req); + audit::with_request(path, origin, call).await }) } } @@ -3850,6 +3852,246 @@ 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_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] + async fn sandbox_principal_on_admin_method_emits_denial_and_correlated_finding() { + let capture = OcsfCapture::default(); + let _guard = capture.install(); + let chain = AuthenticatorChain::new(vec![Arc::new(MockAuthenticator::returning(Ok( + Authentication::Accepted(sandbox_principal()), + )))]); + 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) + .body(Full::new(Bytes::new())) + .unwrap(); + + let res = router.call(request).await.unwrap(); + + assert_eq!(grpc_status(&res).as_deref(), Some("7")); + let events = captured_json(&capture); + assert_eq!(events.len(), 2, "{events:?}"); + assert_authorization_denial(&events[0], "CreateProvider", "method_not_permitted"); + assert_eq!( + events[0]["actor"], + serde_json::json!({"user": {"uid": "sandbox-a", "type_id": 99, "type": "Sandbox"}}) + ); + 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" + ); + for event in &events { + assert_eq!(event["metadata"]["correlation_uid"], request_id); + assert!(!event.to_string().contains("canary-secret-token")); + } + } + + #[tokio::test] + async fn user_missing_rbac_role_emits_denial_without_finding() { + let capture = OcsfCapture::default(); + let _guard = capture.install(); + let chain = AuthenticatorChain::new(vec![Arc::new(MockAuthenticator::returning(Ok( + Authentication::Accepted(user_principal("alice")), + )))]); + let policy = AuthzPolicy { + admin_role: "openshell-admin".to_string(), + user_role: "openshell-user".to_string(), + scopes_enabled: false, + }; + let (recorder, seen) = PrincipalRecorder::new(); + let mut router = AuthGrpcRouter::new(recorder, Some(chain), Some(policy)); + + let res = router + .call(empty_request("/openshell.v1.OpenShell/ListSandboxes")) + .await + .unwrap(); + + assert!(seen.lock().unwrap().is_none()); + assert_eq!(grpc_status(&res).as_deref(), Some("7")); + let events = captured_json(&capture); + assert_eq!(events.len(), 1, "{events:?}"); + assert_authorization_denial(&events[0], "ListSandboxes", "missing_role"); + assert_eq!( + events[0]["actor"], + serde_json::json!({"user": {"uid": "alice", "type_id": 1, "type": "User"}}) + ); + } + + #[tokio::test] + async fn sandbox_principal_on_unknown_method_is_denied_without_a_finding() { + let capture = OcsfCapture::default(); + let _guard = capture.install(); + let chain = AuthenticatorChain::new(vec![Arc::new(MockAuthenticator::returning(Ok( + Authentication::Accepted(sandbox_principal()), + )))]); + let (recorder, _) = PrincipalRecorder::new(); + let mut router = AuthGrpcRouter::new(recorder, Some(chain), None); + + let res = router + .call(empty_request("/openshell.v1.OpenShell/SomeFutureMethod")) + .await + .unwrap(); + + assert_eq!(grpc_status(&res).as_deref(), Some("7")); + let events = captured_json(&capture); + assert_eq!(events.len(), 1, "{events:?}"); + assert_authorization_denial(&events[0], "SomeFutureMethod", "unknown_method"); + } + + #[tokio::test] + async fn permitted_sandbox_call_emits_no_authorization_event() { + let capture = OcsfCapture::default(); + let _guard = capture.install(); + let chain = AuthenticatorChain::new(vec![Arc::new(MockAuthenticator::returning(Ok( + Authentication::Accepted(sandbox_principal()), + )))]); + let (recorder, seen) = PrincipalRecorder::new(); + let mut router = AuthGrpcRouter::new(recorder, Some(chain), None); + + router + .call(empty_request("/openshell.v1.OpenShell/GetSandboxConfig")) + .await + .unwrap(); + + assert!(seen.lock().unwrap().is_some()); + assert!(capture.events().is_empty()); + } + + #[tokio::test] + async fn peer_and_operator_denials_name_their_principal_type() { + for (principal_type, router) in [ + ( + "Admin", + AuthGrpcRouter::with_peer_identity( + PrincipalRecorder::new().0, + None, + None, + None, + true, + false, + ) + .with_operator(Some(operator_principal())), + ), + ( + "Gateway Peer", + AuthGrpcRouter::new( + PrincipalRecorder::new().0, + Some(AuthenticatorChain::new(vec![Arc::new( + MockAuthenticator::returning(Ok(Authentication::Accepted( + Principal::Peer(crate::auth::principal::PeerPrincipal { + replica_id: "gateway-1".to_string(), + pod_uid: "pod-1".to_string(), + }), + ))), + )])), + None, + ), + ), + ] { + let capture = OcsfCapture::default(); + let _guard = capture.install(); + let mut router = router; + + let res = router + .call(empty_request("/openshell.v1.OpenShell/ConnectSupervisor")) + .await + .unwrap(); + + assert_eq!(grpc_status(&res).as_deref(), Some("7"), "{principal_type}"); + let events = captured_json(&capture); + assert_eq!(events.len(), 1, "{principal_type}: {events:?}"); + assert_authorization_denial( + &events[0], + "ConnectSupervisor", + "method_not_permitted", + ); + assert_eq!(events[0]["actor"]["user"]["type"], principal_type); + } + } + + /// Calls the sandbox-scope guard for another sandbox, as a handler + /// would for a request body naming it. + #[derive(Clone)] + struct CrossSandboxHandler; + + impl Service> for CrossSandboxHandler { + type Response = Response; + type Error = Infallible; + type Future = Pin> + Send>>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: Request) -> Self::Future { + let principal = req.extensions().get::().cloned().unwrap(); + Box::pin(async move { + let status = crate::auth::guard::ensure_sandbox_scope(&principal, "sandbox-b") + .expect_err("another sandbox must be denied"); + Ok(status.into_http()) + }) + } + } + + #[tokio::test] + async fn handler_denial_is_recorded_against_the_routed_request() { + let capture = OcsfCapture::default(); + let _guard = capture.install(); + let chain = AuthenticatorChain::new(vec![Arc::new(MockAuthenticator::returning(Ok( + Authentication::Accepted(sandbox_principal()), + )))]); + let mut router = AuthGrpcRouter::new(CrossSandboxHandler, Some(chain), None) + .with_connection_source(ConnectionSource::Peer("10.42.0.7:51234".parse().unwrap())); + + let res = router + .call(empty_request("/openshell.v1.OpenShell/GetSandboxConfig")) + .await + .unwrap(); + + assert_eq!(grpc_status(&res).as_deref(), Some("7")); + let events = captured_json(&capture); + assert_eq!(events.len(), 2, "{events:?}"); + assert_authorization_denial(&events[0], "GetSandboxConfig", "cross_sandbox"); + assert_eq!(events[0]["src_endpoint"]["ip"], "10.42.0.7"); + assert_eq!( + 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); + } + #[tokio::test] async fn missing_principal_returns_unauthenticated() { let mock = Arc::new(MockAuthenticator::returning(Ok( diff --git a/docs/observability/logging.mdx b/docs/observability/logging.mdx index acf9ce14dd..ce7cab1ee1 100644 --- a/docs/observability/logging.mdx +++ b/docs/observability/logging.mdx @@ -61,6 +61,7 @@ OpenShell maps sandbox events to these OCSF classes: | `PROC:` | Process Activity | 1007 | Process start, exit, timeout, signal failures | | `FINDING:` | Detection Finding | 2004 | Security findings (nonce replay, proxy bypass, unsafe policy) | | `AUTH:` | Authentication | 3002 | Gateway authentication failures | +| `API:` | API Activity | 6003 | Inference calls, gateway authorization denials | | `CONFIG:` | Device Config State Change | 5019 | Policy load/reload, Landlock, TLS setup, provider and settings changes | | `LIFECYCLE:` | Application Lifecycle | 6002 | Sandbox supervisor start, SSH server ready | @@ -99,6 +100,7 @@ endpoint, use `EVENT` rather than a network or lifecycle event. - HTTP: `METHOD url` with the HTTP method and target - SSH: peer address and authentication type - Authentication: the principal as `type:id`, or `unknown` when none was established, and the peer address when known +- Authorization denial: the principal as `type:id` and the peer address when known, with the refused sandbox or workspace in the context - Process: `name(pid)` with exit code or command line - Config: description of what changed - Finding: quoted title with the stable finding type, optional confidence, and source-specific context attributes when available @@ -113,6 +115,12 @@ A gateway request rejected because it presented no credential: OCSF AUTH:LOGON [LOW] DENIED unknown [op:/openshell.v1.OpenShell/ListSandboxes reason:missing_credential] ``` +A sandbox credential used to read another sandbox's configuration: + +```text +OCSF API:OTHER [MED] DENIED sandbox:5d1c2e7a-0b3f-4c8e-9a61-7f2d4b6e8c10 from 10.42.0.7:51234 [op:/openshell.v1.OpenShell/GetSandboxConfig sandbox:9a3e61b0-4c2d-4f7e-8b15-2d6c0e9f7a43 reason:cross_sandbox] +``` + An allowed HTTPS connection: ```text diff --git a/docs/observability/ocsf-json-export.mdx b/docs/observability/ocsf-json-export.mdx index aa00b90d80..593ed88e88 100644 --- a/docs/observability/ocsf-json-export.mdx +++ b/docs/observability/ocsf-json-export.mdx @@ -39,6 +39,22 @@ The gateway emits an Authentication (`class_uid: 3002`) event when it rejects a These events never include presented credentials, other request headers, or verification error text. +The gateway emits an API Activity (`class_uid: 6003`) event with Medium severity when its method, role, scope, sandbox, workspace, or operator checks refuse an authenticated caller. `actor.user` identifies the principal: its `type` is `User`, `Admin` for an mTLS operator, `Sandbox`, or `Gateway Peer`, and `uid` is the subject, sandbox ID, or gateway replica ID. `api.operation` names the gRPC method, and `resources` names the sandbox or workspace that was refused, when one applies, by `uid` for a sandbox ID or by `name` otherwise. `status_detail` holds the reason category: + +| Reason | Meaning | +|---|---| +| `method_not_permitted` | The principal type may not call the method, or may not call it with these arguments. | +| `unknown_method` | The gateway does not recognize the method, for example one added in a newer client. | +| `missing_role` | The caller lacks the OIDC role the method requires. | +| `missing_scope` | The caller's token lacks the scope the method requires. | +| `cross_sandbox` | A sandbox principal referenced a sandbox other than its own, by ID or by name. | +| `not_workspace_member` | The caller is not a member of the workspace. | +| `insufficient_workspace_role` | The caller's workspace role is below the one the operation requires. | +| `platform_admin_required` | The operation spans workspaces or assigns the workspace admin role. | +| `operator_required` | The operation requires a verified mTLS operator. | + +When a sandbox principal is refused with `method_not_permitted`, `cross_sandbox`, or `platform_admin_required`, the gateway also emits a High-severity Detection Finding (`class_uid: 2004`) with `finding_info.uid` `sandbox-authorization-boundary`. Supervisors never make these requests, so the sandbox's credential may be compromised. Both events carry the same `metadata.correlation_uid`. Some methods answer a refused request with "not found" so callers cannot probe for resources; the event still records the denial. + Without JSONL enabled, gateway OCSF events still appear as shorthand in console output, subject to the diagnostic log filter. Events associated with a sandbox also appear in its gateway log stream; gateway-wide events have no sandbox association. ## Enable Sandbox JSON Export @@ -268,6 +284,7 @@ The `class_uid` field identifies the event type: | 3002 | Authentication | `AUTH:` | | 5019 | Device Config State Change | `CONFIG:` | | 6002 | Application Lifecycle | `LIFECYCLE:` | +| 6003 | API Activity | `API:` | HTTP Activity records include request or response details. Rejections that have parsed a request, including early credential-binding failures, include safe