From 321b93a898b8859ea9c0643a26c2f4e3b362e503 Mon Sep 17 00:00:00 2001 From: krishicks Date: Wed, 21 Oct 2026 14:32:35 +0000 Subject: [PATCH] feat(auth): emit OCSF events for SSH session and sandbox token checks (#4762) Some credentials are checked by handlers rather than by the gateway's authenticators, so their rejections only reached the caller and plain logs. They now emit the same OCSF events as the router's checks: - SSH session tokens. A ForwardTcp SSH connection whose token is missing, unknown, revoked, minted for another sandbox, or expired emits an Authentication failure with protocol ssh_session_token and a reason category. An accepted token logs on once, like other credentials. The router has already authenticated the caller, so these events name that caller. - IssueSandboxToken. A compute-driver identity that no longer matches the sandbox's recorded runtime emits an Authentication failure (stale_identity); a sandbox with no record, or no usable recorded identity, emits unknown_identity. A sandbox calling it with a gateway JWT is refused with method_not_permitted and raises the sandbox boundary finding. - RefreshSandboxToken. A caller that is not a gateway-JWT sandbox is refused with method_not_permitted. A request for an extension service the sandbox policy does not select is refused with the new extension_service_not_selected reason, without a finding, because a policy update can race the supervisor's request. - Sandbox logons on IssueSandboxToken and RefreshSandboxToken. The router logged a compute-driver credential on IssueSandboxToken, or a gateway JWT on RefreshSandboxToken, before the handler finished checking the recorded runtime or the token lineage, so a credential that failed that check recorded a success and then a failure. Those handlers now record the logon once their check passes. Other credentials on these methods are fully verified by the router and still log on there, even when authorization then refuses them. - GetCurrentUser. A non-user principal is refused with method_not_permitted. The router already blocks such callers, so this is defense in depth. Callers receive the same status codes and messages as before, and the events never include the tokens. Refs #2912 Signed-off-by: Kris Hicks --- crates/openshell-server/src/auth/audit.rs | 178 ++++++-- .../src/auth/authenticator.rs | 2 + .../openshell-server/src/auth/sandbox_jwt.rs | 6 +- crates/openshell-server/src/grpc/auth_rpc.rs | 388 +++++++++++++++--- crates/openshell-server/src/grpc/sandbox.rs | 297 +++++++++++++- crates/openshell-server/src/multiplex.rs | 106 ++++- docs/observability/ocsf-json-export.mdx | 7 +- 7 files changed, 834 insertions(+), 150 deletions(-) diff --git a/crates/openshell-server/src/auth/audit.rs b/crates/openshell-server/src/auth/audit.rs index 4864f513b..8c4af3b77 100644 --- a/crates/openshell-server/src/auth/audit.rs +++ b/crates/openshell-server/src/auth/audit.rs @@ -101,6 +101,7 @@ fn with_protocol( AuthMechanism::SandboxJwt => builder.auth_protocol_other("sandbox_jwt"), AuthMechanism::ComputeDriver => builder.auth_protocol_other("compute_driver"), AuthMechanism::GatewayPeer => builder.auth_protocol_other("k8s_service_account"), + AuthMechanism::SshSessionToken => builder.auth_protocol_other("ssh_session_token"), } } @@ -110,23 +111,46 @@ pub fn emit_authentication_failure( origin: &RequestOrigin, mechanism: Option, reason: AuthFailureReason, +) { + authentication_failure(path, origin, User::unidentified(), mechanism, reason); +} + +/// Emit an Authentication failure event for a credential that a handler +/// verifies itself, after the router established `principal`. +pub fn emit_credential_failure( + principal: &Principal, + mechanism: AuthMechanism, + reason: AuthFailureReason, +) { + let (path, origin) = current_request(); + authentication_failure( + &path, + &origin, + principal_user(principal), + Some(mechanism), + reason, + ); +} + +fn authentication_failure( + path: &str, + origin: &RequestOrigin, + user: User, + mechanism: Option, + reason: AuthFailureReason, ) { let ctx = crate::gateway_ocsf::context("", ""); - let mut builder = AuthenticationBuilder::new( - &ctx, - User::unidentified(), - Service::new("openshell-gateway"), - ) - .action(ActionId::Denied) - .disposition(DispositionId::Blocked) - .severity(severity(reason)) - .status(StatusId::Failure) - .status_detail(category(reason)) - .http_request(HttpRequest { - http_method: HttpMethod::Post, - url: Some(Url::from_path(path)), - }) - .message(format!("authentication failed: {}", category(reason))); + let mut builder = AuthenticationBuilder::new(&ctx, user, Service::new("openshell-gateway")) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(severity(reason)) + .status(StatusId::Failure) + .status_detail(category(reason)) + .http_request(HttpRequest { + http_method: HttpMethod::Post, + url: Some(Url::from_path(path)), + }) + .message(format!("authentication failed: {}", category(reason))); if let Some(mechanism) = mechanism { builder = with_protocol(builder, mechanism); } @@ -226,27 +250,56 @@ fn credential_digest( principal: &Principal, headers: &http::HeaderMap, peer_certificate_sha256: Option<&str>, +) -> Option<[u8; 32]> { + let credential = match principal { + Principal::Operator(operator) => operator.certificate_sha256.as_str(), + Principal::User(user) if user.identity.provider == IdentityProvider::Mtls => { + peer_certificate_sha256.unwrap_or_default() + } + _ => bearer_credential(headers).unwrap_or_default(), + }; + digest(principal, None, credential) +} + +/// A digest of `principal`, the mechanism when it is not the principal's +/// own, and the credential, kept apart by separators. +fn digest( + principal: &Principal, + mechanism: Option, + credential: &str, ) -> Option<[u8; 32]> { let mut material = principal.audit_actor().into_bytes(); material.push(0); - match principal { - Principal::Operator(operator) => { - material.extend_from_slice(operator.certificate_sha256.as_bytes()); - } - Principal::User(user) if user.identity.provider == IdentityProvider::Mtls => { - material.extend_from_slice(peer_certificate_sha256.unwrap_or_default().as_bytes()); - } - _ => { - if let Some(token) = bearer_credential(headers) { - material.extend_from_slice(token.as_bytes()); - } - } + if let Some(mechanism) = mechanism { + material.extend_from_slice(format!("{mechanism:?}").as_bytes()); + material.push(0); } + material.extend_from_slice(credential.as_bytes()); openshell_crypto::sha256(&material).ok() } +/// Whether the handler for `path` finishes verifying `principal`'s +/// credential, and so records its logon itself once it accepts it: the +/// recorded runtime of a compute-driver credential on `IssueSandboxToken`, +/// and the lineage of a gateway JWT on `RefreshSandboxToken`. Other +/// credentials are fully verified by the router. +fn handler_verifies(path: &str, principal: &Principal) -> bool { + let Principal::Sandbox(sandbox) = principal else { + return false; + }; + match sandbox.source { + SandboxIdentitySource::ComputeDriver { .. } => { + path == super::compute_driver::ISSUE_SANDBOX_TOKEN_PATH + } + SandboxIdentitySource::BootstrapJwt { .. } => { + path == super::sandbox_jwt::REFRESH_SANDBOX_TOKEN_PATH + } + } +} + /// Emit an Authentication logon event for a request to `path` that -/// authenticated as `principal`, unless its credential is already logged on. +/// authenticated as `principal`, unless its credential is already logged on +/// or the handler finishes verifying it. /// /// `peer_certificate_sha256` fingerprints the connection's verified client /// certificate, which identifies an mTLS user's credential. @@ -258,13 +311,49 @@ pub fn emit_logon( headers: &http::HeaderMap, peer_certificate_sha256: Option<&str>, ) { - // Without a digest the credential cannot be deduplicated, so it logs on. - if !authenticated(principal) - || credential_digest(principal, headers, peer_certificate_sha256) - .is_some_and(|digest| !logons.log_on(digest, Instant::now())) + if authenticated(principal) + && !handler_verifies(path, principal) + && logs_on( + logons, + credential_digest(principal, headers, peer_certificate_sha256), + ) { - return; + logon(path, origin, principal, mechanism(principal)); } +} + +/// Emit an Authentication logon event for the current request once its +/// handler has finished verifying `principal`'s credential, unless the +/// credential is already logged on. +pub fn emit_handler_logon(logons: &Logons, principal: &Principal, headers: &http::HeaderMap) { + if logs_on(logons, credential_digest(principal, headers, None)) { + let (path, origin) = current_request(); + logon(&path, &origin, principal, mechanism(principal)); + } +} + +/// Emit an Authentication logon event for the SSH session `token` that a +/// handler accepted from `principal`, unless it is already logged on. +pub fn emit_ssh_session_logon(logons: &Logons, principal: &Principal, token: &str) { + let mechanism = AuthMechanism::SshSessionToken; + if logs_on(logons, digest(principal, Some(mechanism), token)) { + let (path, origin) = current_request(); + logon(&path, &origin, principal, Some(mechanism)); + } +} + +/// Whether the credential with `digest` makes a new logon. Without a digest +/// the credential cannot be deduplicated, so it logs on. +fn logs_on(logons: &Logons, digest: Option<[u8; 32]>) -> bool { + digest.is_none_or(|digest| logons.log_on(digest, Instant::now())) +} + +fn logon( + path: &str, + origin: &RequestOrigin, + principal: &Principal, + mechanism: Option, +) { let ctx = crate::gateway_ocsf::context("", ""); let mut builder = AuthenticationBuilder::new( &ctx, @@ -280,7 +369,7 @@ pub fn emit_logon( url: Some(Url::from_path(path)), }) .message("authentication succeeded"); - if let Some(mechanism) = mechanism(principal) { + if let Some(mechanism) = mechanism { builder = with_protocol(builder, mechanism); } if let Some(source) = origin.source { @@ -315,6 +404,14 @@ pub async fn with_request(path: String, origin: RequestOrigin, handle .await } +/// The path and origin of the request a handler is serving, or empty +/// values outside one. +fn current_request() -> (String, RequestOrigin) { + REQUEST + .try_with(|request| (request.path.clone(), request.origin.clone())) + .unwrap_or_default() +} + /// 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 { @@ -352,6 +449,9 @@ pub enum AuthzDenial<'a> { PlatformAdminRequired, /// The operation requires a verified mTLS operator. OperatorRequired, + /// A sandbox requested a credential for an extension service its policy + /// does not select. + ExtensionServiceNotSelected { service: &'a str }, } impl AuthzDenial<'_> { @@ -366,6 +466,7 @@ impl AuthzDenial<'_> { Self::InsufficientWorkspaceRole { .. } => "insufficient_workspace_role", Self::PlatformAdminRequired => "platform_admin_required", Self::OperatorRequired => "operator_required", + Self::ExtensionServiceNotSelected { .. } => "extension_service_not_selected", } } @@ -381,6 +482,10 @@ impl AuthzDenial<'_> { | Self::InsufficientWorkspaceRole { workspace } => { Some(ResourceDetails::named("workspace", bounded(workspace))) } + Self::ExtensionServiceNotSelected { service } => Some(ResourceDetails::named( + "extension_service", + bounded(service), + )), _ => None, } } @@ -427,10 +532,7 @@ fn principal_user(principal: &Principal) -> User { /// 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(); + let (path, origin) = current_request(); emit_authorization_denial_for(&path, &origin, principal, denial); } diff --git a/crates/openshell-server/src/auth/authenticator.rs b/crates/openshell-server/src/auth/authenticator.rs index 783fb7a3b..b94cdd596 100644 --- a/crates/openshell-server/src/auth/authenticator.rs +++ b/crates/openshell-server/src/auth/authenticator.rs @@ -99,6 +99,8 @@ pub enum AuthMechanism { SandboxJwt, ComputeDriver, GatewayPeer, + /// The `CreateSshSession` token an SSH relay presents. + SshSessionToken, } /// A rejected authentication attempt: the status returned to the caller and diff --git a/crates/openshell-server/src/auth/sandbox_jwt.rs b/crates/openshell-server/src/auth/sandbox_jwt.rs index 6cf046375..57c64385f 100644 --- a/crates/openshell-server/src/auth/sandbox_jwt.rs +++ b/crates/openshell-server/src/auth/sandbox_jwt.rs @@ -42,6 +42,10 @@ use openshell_core::jwt::{ }; use openshell_core::sandbox_generation::SandboxGenerationId; +/// The method on which a sandbox renews its gateway JWT, and the only one that +/// accepts a just-consumed bearer. +pub const REFRESH_SANDBOX_TOKEN_PATH: &str = "/openshell.v1.OpenShell/RefreshSandboxToken"; + /// SPIFFE-shaped subject prefix. Embedded in the `sub` claim of every /// minted token so a future migration to per-sandbox certs or SPIRE can /// reuse the same subject namespace without breaking handler equality @@ -318,7 +322,7 @@ impl Authenticator for SandboxSessionJwtAuthenticator { // Refresh performs its own lineage check so the immediately consumed // bearer can recover an already-committed successor after a lost // response. Every other RPC accepts only the current bearer. - if path != "/openshell.v1.OpenShell/RefreshSandboxToken" { + if path != REFRESH_SANDBOX_TOKEN_PATH { crate::auth::sandbox_session::authorize_persisted(&self.store, &authenticated).await?; } Ok(Authentication::Accepted(Principal::Sandbox( diff --git a/crates/openshell-server/src/grpc/auth_rpc.rs b/crates/openshell-server/src/grpc/auth_rpc.rs index ea654503a..b53329a07 100644 --- a/crates/openshell-server/src/grpc/auth_rpc.rs +++ b/crates/openshell-server/src/grpc/auth_rpc.rs @@ -15,10 +15,11 @@ //! ordinary RPCs or select another successor. use crate::ServerState; -use crate::auth::audit::{self, ConnectionSource, RequestOrigin}; +use crate::auth::audit::{self, AuthzDenial, ConnectionSource, RequestOrigin}; use crate::auth::authenticator::{AuthFailureReason, AuthMechanism, AuthRejection}; use crate::auth::identity::IdentityProvider; -use crate::auth::principal::{Principal, SandboxIdentitySource}; +use crate::auth::principal::{Principal, SandboxIdentitySource, SandboxPrincipal}; +use crate::auth::sandbox_jwt::REFRESH_SANDBOX_TOKEN_PATH; use openshell_core::proto::{ ExtensionServiceCredential, GetCurrentUserRequest, GetCurrentUserResponse, GetSandboxConfigRequest, IssueSandboxTokenRequest, IssueSandboxTokenResponse, @@ -37,6 +38,7 @@ pub async fn handle_get_current_user( ) -> Result, Status> { let principal = super::extract_principal(&request)?; let Some(identity) = principal.user_identity().cloned() else { + audit::emit_authorization_denial(&principal, AuthzDenial::MethodNotPermitted); return Err(Status::permission_denied( "GetCurrentUser requires a user principal", )); @@ -68,7 +70,8 @@ pub async fn handle_issue_sandbox_token( .cloned() .ok_or_else(|| Status::unauthenticated("missing principal"))?; - let Principal::Sandbox(sandbox) = principal else { + let Principal::Sandbox(sandbox) = &principal else { + audit::emit_authorization_denial(&principal, AuthzDenial::MethodNotPermitted); return Err(Status::permission_denied( "IssueSandboxToken requires a sandbox principal", )); @@ -85,6 +88,7 @@ pub async fn handle_issue_sandbox_token( sandbox_id = %sandbox.sandbox_id, "IssueSandboxToken rejected: non-bootstrap principal source" ); + audit::emit_authorization_denial(&principal, AuthzDenial::MethodNotPermitted); return Err(Status::permission_denied( "this principal cannot mint a sandbox token; use RefreshSandboxToken", )); @@ -101,11 +105,28 @@ pub async fn handle_issue_sandbox_token( Status::unavailable("sandbox session minting is not configured on this gateway") })?; - let sandbox_record = ensure_sandbox_exists(state, &sandbox.sandbox_id).await?; - let metadata = sandbox_record - .metadata - .as_ref() - .ok_or_else(|| Status::permission_denied("sandbox runtime identity is unavailable"))?; + // The driver vouched for a runtime; it must be the one recorded at + // creation, so these refusals are authentication failures. + let reject = |reason, message: &str| { + audit::emit_credential_failure(&principal, AuthMechanism::ComputeDriver, reason); + Status::permission_denied(message) + }; + let sandbox_record = ensure_sandbox_exists(state, &sandbox.sandbox_id) + .await + .inspect_err(|status| { + let reason = if status.code() == tonic::Code::Internal { + AuthFailureReason::IdentityProviderUnavailable + } else { + AuthFailureReason::UnknownIdentity + }; + audit::emit_credential_failure(&principal, AuthMechanism::ComputeDriver, reason); + })?; + let metadata = sandbox_record.metadata.as_ref().ok_or_else(|| { + reject( + AuthFailureReason::UnknownIdentity, + "sandbox runtime identity is unavailable", + ) + })?; let expected_driver = metadata .annotations .get(crate::compute::COMPUTE_DRIVER_ANNOTATION); @@ -118,14 +139,21 @@ pub async fn handle_issue_sandbox_token( driver_name, "IssueSandboxToken rejected: compute runtime identity mismatch" ); - return Err(Status::permission_denied( + return Err(reject( + AuthFailureReason::StaleIdentity, "compute runtime identity does not match the sandbox", )); } let identity = crate::auth::sandbox_session::PersistedSandboxIdentity::read(&metadata.annotations) - .map_err(|_| Status::permission_denied("sandbox runtime identity is invalid"))?; + .map_err(|_| { + reject( + AuthFailureReason::UnknownIdentity, + "sandbox runtime identity is invalid", + ) + })?; + audit::emit_handler_logon(&state.logons, &principal, request.metadata().as_ref()); let authentication = session_authority.mint_persisted_launch(&sandbox.sandbox_id, &identity)?; let token = authentication .supervisor @@ -148,9 +176,6 @@ pub async fn handle_issue_sandbox_token( })) } -#[allow(clippy::result_large_err, clippy::unused_async)] -const REFRESH_SANDBOX_TOKEN_PATH: &str = "/openshell.v1.OpenShell/RefreshSandboxToken"; - /// The authenticator chain defers lineage checks for refresh to this handler, /// so its rejections are audited here. async fn authenticate_refresh( @@ -226,6 +251,7 @@ pub async fn handle_refresh_sandbox_token( .ok_or_else(|| Status::unauthenticated("missing principal"))?; let Principal::Sandbox(sandbox) = principal else { + audit::emit_authorization_denial(&principal, AuthzDenial::MethodNotPermitted); return Err(Status::permission_denied( "RefreshSandboxToken requires a sandbox principal", )); @@ -238,6 +264,10 @@ pub async fn handle_refresh_sandbox_token( sandbox_id = %sandbox.sandbox_id, "RefreshSandboxToken rejected: non-gateway-JWT principal source" ); + audit::emit_authorization_denial( + &Principal::Sandbox(sandbox.clone()), + AuthzDenial::MethodNotPermitted, + ); return Err(Status::permission_denied( "this principal cannot refresh; use IssueSandboxToken for bootstrap", )); @@ -281,6 +311,11 @@ pub async fn handle_refresh_sandbox_token( ); rejection.status })?; + audit::emit_handler_logon( + &state.logons, + &Principal::Sandbox(sandbox.clone()), + request.metadata().as_ref(), + ); let (successor, should_rotate) = match authorization { crate::auth::sandbox_session::RefreshAuthorization::Current(identity) => ( identity @@ -334,7 +369,7 @@ pub async fn handle_refresh_sandbox_token( .supervisor_middleware_services; mint_extension_credentials( issuer, - &sandbox.sandbox_id, + &sandbox, &requested_extension_services, &available, successor.refresh_replay.as_ref(), @@ -394,7 +429,7 @@ fn current_unix_seconds() -> i64 { #[allow(clippy::result_large_err)] fn mint_extension_credentials( issuer: &crate::auth::sandbox_jwt::ExtensionJwtIssuer, - sandbox_id: &str, + sandbox: &SandboxPrincipal, requested_names: &[String], available_services: &[openshell_core::proto::SupervisorMiddlewareService], refresh_replay: Option<&crate::auth::sandbox_session::GatewayRefreshReplay>, @@ -429,6 +464,10 @@ fn mint_extension_credentials( .iter() .map(|name| { let service = available.get(name.as_str()).ok_or_else(|| { + audit::emit_authorization_denial( + &Principal::Sandbox(sandbox.clone()), + AuthzDenial::ExtensionServiceNotSelected { service: name }, + ); Status::permission_denied(format!( "extension service '{name}' is not selected by the sandbox policy" )) @@ -446,7 +485,7 @@ fn mint_extension_credentials( issuer.mint_extension_token( &audience, ExtensionCallerKind::Supervisor, - Some(sandbox_id), + Some(&sandbox.sandbox_id), ttl, ) }, @@ -454,7 +493,7 @@ fn mint_extension_credentials( issuer.mint_extension_token_with_metadata( &audience, ExtensionCallerKind::Supervisor, - Some(sandbox_id), + Some(&sandbox.sandbox_id), ttl, replay.issued_at, replay @@ -495,6 +534,7 @@ async fn ensure_sandbox_exists( mod tests { use super::*; use crate::ServerState; + use crate::auth::compute_driver::ISSUE_SANDBOX_TOKEN_PATH; use crate::auth::identity::Identity; use crate::auth::principal::{Principal, SandboxPrincipal, UserPrincipal}; use crate::auth::sandbox_jwt::{ExtensionJwtIssuer, SandboxSessionJwtAuthority}; @@ -639,6 +679,58 @@ mod tests { token } + const GET_CURRENT_USER_PATH: &str = "/openshell.v1.OpenShell/GetCurrentUser"; + const REQUEST_ID: &str = "6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f"; + + /// Run `handler` as a routed request to `path`, returning its result and + /// the OCSF events it emitted. + async fn audited(path: &str, handler: F) -> (F::Output, Vec) { + let capture = openshell_ocsf::test_support::OcsfCapture::default(); + let output = { + let _guard = capture.install(); + let mut headers = http::HeaderMap::new(); + headers.insert("x-request-id", REQUEST_ID.parse().unwrap()); + let origin = RequestOrigin::new( + Some(ConnectionSource::Peer("203.0.113.7:51234".parse().unwrap())), + &headers, + ); + audit::with_request(path.to_string(), origin, handler).await + }; + let events = capture + .events() + .iter() + .map(|event| event.to_json().unwrap()) + .collect(); + (output, events) + } + + fn assert_denial(event: &serde_json::Value, path: &str, reason: &str) { + assert_eq!(event["class_uid"], 6003, "{event}"); + assert_eq!(event["status_detail"], reason); + assert_eq!(event["http_request"]["url"]["path"], path); + assert_eq!(event["src_endpoint"]["ip"], "203.0.113.7"); + assert_eq!(event["metadata"]["correlation_uid"], REQUEST_ID); + } + + fn compute_driver_principal(runtime_identity: &str) -> Principal { + use crate::auth::principal::SandboxIdentitySource; + Principal::Sandbox(SandboxPrincipal { + sandbox_id: "sandbox-a".to_string(), + source: SandboxIdentitySource::ComputeDriver { + driver_name: "kubernetes".to_string(), + runtime_identity: runtime_identity.to_string(), + }, + trust_domain: Some("openshell".to_string()), + }) + } + + fn sandbox_a() -> SandboxPrincipal { + let Principal::Sandbox(sandbox) = sandbox_principal("sandbox-a") else { + unreachable!("sandbox principal") + }; + sandbox + } + fn set_refresh_authorization(request: &mut Request, token: &str) { let value = format!("Bearer {token}") .parse() @@ -821,7 +913,7 @@ mod tests { assert_eq!(events[0]["auth_protocol"], "sandbox_jwt"); assert_eq!( events[0]["http_request"]["url"]["path"], - "/openshell.v1.OpenShell/RefreshSandboxToken" + REFRESH_SANDBOX_TOKEN_PATH ); assert_eq!(events[0]["src_endpoint"]["ip"], "203.0.113.7"); assert_eq!( @@ -832,12 +924,8 @@ mod tests { } #[tokio::test] - async fn successful_or_unauthorized_refresh_emits_no_authentication_event() { - use openshell_ocsf::test_support::OcsfCapture; - + async fn refresh_denies_unselected_extension_services_without_a_finding() { let state = state_with_issuer().await; - let capture = OcsfCapture::default(); - let _guard = capture.install(); let mut unauthorized = Request::new(RefreshSandboxTokenRequest { extension_service_names: vec!["unknown-service".to_string()], }); @@ -845,9 +933,13 @@ mod tests { .extensions_mut() .insert(sandbox_principal("sandbox-a")); let current = authorize_refresh(&state, &mut unauthorized).await; - handle_refresh_sandbox_token(&state, unauthorized) - .await - .expect_err("unknown extension service must be rejected"); + let (result, events) = audited( + REFRESH_SANDBOX_TOKEN_PATH, + Box::pin(handle_refresh_sandbox_token(&state, unauthorized)), + ) + .await; + let error = result.expect_err("unknown extension service must be rejected"); + assert_eq!(error.code(), tonic::Code::PermissionDenied); let mut refresh = Request::new(RefreshSandboxTokenRequest { extension_service_names: Vec::new(), }); @@ -855,11 +947,38 @@ mod tests { .extensions_mut() .insert(sandbox_principal("sandbox-a")); set_refresh_authorization(&mut refresh, ¤t); - handle_refresh_sandbox_token(&state, refresh) - .await - .expect("refresh OK"); + let (result, refreshed) = audited( + REFRESH_SANDBOX_TOKEN_PATH, + Box::pin(handle_refresh_sandbox_token(&state, refresh)), + ) + .await; + result.expect("refresh OK"); - assert!(capture.events().is_empty(), "{:?}", capture.events()); + assert!(refreshed.is_empty(), "already logged on: {refreshed:?}"); + assert_eq!( + events.len(), + 2, + "a logon, then a denial that a policy race keeps from being a finding: {events:?}" + ); + assert_eq!(events[0]["class_uid"], 3002); + assert_eq!(events[0]["status_id"], 1); + assert_eq!(events[0]["auth_protocol"], "sandbox_jwt"); + assert_eq!(events[0]["user"]["uid"], "sandbox-a"); + assert_eq!( + events[0]["http_request"]["url"]["path"], + REFRESH_SANDBOX_TOKEN_PATH + ); + assert_eq!(events[0]["metadata"]["correlation_uid"], REQUEST_ID); + assert!(!events[0].to_string().contains(¤t)); + assert_denial( + &events[1], + REFRESH_SANDBOX_TOKEN_PATH, + "extension_service_not_selected", + ); + assert_eq!( + events[1]["resources"], + serde_json::json!([{"type": "extension_service", "name": "unknown-service"}]) + ); } #[tokio::test] @@ -902,7 +1021,7 @@ mod tests { let credentials = mint_extension_credentials( issuer, - "sandbox-a", + &sandbox_a(), &["content-guard".to_string()], &available, None, @@ -915,7 +1034,7 @@ mod tests { let error = mint_extension_credentials( issuer, - "sandbox-a", + &sandbox_a(), &["attacker-chosen-audience".to_string()], &available, None, @@ -952,7 +1071,7 @@ mod tests { // able to obtain a bearer token it would then send over plaintext. let error = mint_extension_credentials( issuer, - "sandbox-a", + &sandbox_a(), &["legacy-guard".to_string()], &available, None, @@ -972,7 +1091,7 @@ mod tests { }]; let error = mint_extension_credentials( issuer, - "sandbox-a", + &sandbox_a(), &["content-guard".to_string(), "content-guard".to_string()], &available, None, @@ -1119,24 +1238,62 @@ mod tests { #[tokio::test] async fn issue_rejects_mismatched_compute_runtime_identity() { - use crate::auth::principal::SandboxIdentitySource; - let state = state_with_issuer().await; let mut req = Request::new(IssueSandboxTokenRequest {}); req.extensions_mut() - .insert(Principal::Sandbox(SandboxPrincipal { - sandbox_id: "sandbox-a".to_string(), - source: SandboxIdentitySource::ComputeDriver { - driver_name: "kubernetes".to_string(), - runtime_identity: "replacement-runtime".to_string(), - }, - trust_domain: Some("openshell".to_string()), - })); + .insert(compute_driver_principal("replacement-runtime")); - let err = handle_issue_sandbox_token(&state, req) - .await - .expect_err("mismatched runtime must not receive a token"); + let (result, events) = audited( + ISSUE_SANDBOX_TOKEN_PATH, + handle_issue_sandbox_token(&state, req), + ) + .await; + let err = result.expect_err("mismatched runtime must not receive a token"); assert_eq!(err.code(), tonic::Code::PermissionDenied); + assert_eq!(events.len(), 1, "{events:?}"); + assert_eq!(events[0]["class_uid"], 3002); + assert_eq!(events[0]["status_id"], 2); + assert_eq!(events[0]["status_detail"], "stale_identity"); + assert_eq!(events[0]["auth_protocol"], "compute_driver"); + assert_eq!(events[0]["user"]["uid"], "sandbox-a"); + assert_eq!( + events[0]["http_request"]["url"]["path"], + ISSUE_SANDBOX_TOKEN_PATH + ); + assert_eq!(events[0]["src_endpoint"]["ip"], "203.0.113.7"); + assert_eq!(events[0]["metadata"]["correlation_uid"], REQUEST_ID); + } + + #[tokio::test] + async fn issue_with_a_gateway_token_is_a_boundary_violation() { + let state = state_with_issuer().await; + let mut req = Request::new(IssueSandboxTokenRequest {}); + req.extensions_mut().insert(sandbox_principal("sandbox-a")); + + let (result, events) = audited( + ISSUE_SANDBOX_TOKEN_PATH, + handle_issue_sandbox_token(&state, req), + ) + .await; + let err = result.expect_err("a gateway JWT must refresh instead"); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + assert_eq!(events.len(), 2, "{events:?}"); + assert_denial(&events[0], ISSUE_SANDBOX_TOKEN_PATH, "method_not_permitted"); + assert_eq!(events[1]["class_uid"], 2004); + assert_eq!(events[1]["metadata"]["correlation_uid"], REQUEST_ID); + } + + #[tokio::test] + async fn current_user_denies_a_sandbox_principal() { + let mut req = Request::new(GetCurrentUserRequest {}); + req.extensions_mut().insert(sandbox_principal("sandbox-a")); + + let (result, events) = audited(GET_CURRENT_USER_PATH, handle_get_current_user(req)).await; + let err = result.expect_err("sandboxes have no user identity"); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + assert_eq!(events.len(), 2, "{events:?}"); + assert_denial(&events[0], GET_CURRENT_USER_PATH, "method_not_permitted"); + assert_eq!(events[1]["class_uid"], 2004); } #[tokio::test] @@ -1154,10 +1311,99 @@ mod tests { }, trust_domain: Some("openshell".to_string()), })); - let err = handle_issue_sandbox_token(&state, req) - .await - .expect_err("missing sandbox must not receive a token"); + let (result, events) = audited( + ISSUE_SANDBOX_TOKEN_PATH, + handle_issue_sandbox_token(&state, req), + ) + .await; + let err = result.expect_err("missing sandbox must not receive a token"); assert_eq!(err.code(), tonic::Code::NotFound); + assert_eq!(events.len(), 1, "{events:?}"); + assert_eq!(events[0]["status_id"], 2); + assert_eq!(events[0]["status_detail"], "unknown_identity"); + assert_eq!(events[0]["auth_protocol"], "compute_driver"); + assert_eq!(events[0]["user"]["uid"], "sandbox-deleted"); + } + + #[tokio::test] + async fn issue_without_a_recorded_runtime_identity_is_unknown_identity() { + let state = state_with_issuer().await; + // The driver binding is recorded, but not the runtime identity a + // token is minted from. + let mut sandbox = state + .store + .get_message::("sandbox-a") + .await + .unwrap() + .unwrap(); + let metadata = sandbox.metadata.as_mut().expect("metadata"); + metadata.id = "sandbox-b".to_string(); + metadata.name = "sandbox-b".to_string(); + metadata.annotations.retain(|key, _| { + key == crate::compute::COMPUTE_DRIVER_ANNOTATION + || key == crate::compute::COMPUTE_RUNTIME_IDENTITY_ANNOTATION + }); + state.store.put_message(&sandbox).await.unwrap(); + let Principal::Sandbox(mut principal) = compute_driver_principal("test-runtime") else { + unreachable!("sandbox principal") + }; + principal.sandbox_id = "sandbox-b".to_string(); + let mut req = Request::new(IssueSandboxTokenRequest {}); + req.extensions_mut().insert(Principal::Sandbox(principal)); + + let (result, events) = audited( + ISSUE_SANDBOX_TOKEN_PATH, + handle_issue_sandbox_token(&state, req), + ) + .await; + let err = result.expect_err("no recorded identity to mint from"); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + assert_eq!(err.message(), "sandbox runtime identity is invalid"); + assert_eq!(events.len(), 1, "{events:?}"); + assert_eq!(events[0]["status_detail"], "unknown_identity"); + assert_eq!(events[0]["auth_protocol"], "compute_driver"); + } + + #[tokio::test] + async fn issue_logs_on_once_the_runtime_matches() { + let state = state_with_issuer().await; + let request = || { + let mut req = Request::new(IssueSandboxTokenRequest {}); + req.extensions_mut() + .insert(compute_driver_principal("test-runtime")); + req.metadata_mut().insert( + "authorization", + "Bearer canary-driver-credential".parse().unwrap(), + ); + req + }; + + let (first, events) = audited( + ISSUE_SANDBOX_TOKEN_PATH, + handle_issue_sandbox_token(&state, request()), + ) + .await; + let (second, repeated) = audited( + ISSUE_SANDBOX_TOKEN_PATH, + handle_issue_sandbox_token(&state, request()), + ) + .await; + first.expect("matching runtime receives a token"); + second.expect("matching runtime receives a token"); + + assert!(repeated.is_empty(), "{repeated:?}"); + assert_eq!(events.len(), 1, "{events:?}"); + assert_eq!(events[0]["class_uid"], 3002); + assert_eq!(events[0]["status_id"], 1); + assert_eq!(events[0]["auth_protocol"], "compute_driver"); + assert_eq!(events[0]["user"]["uid"], "sandbox-a"); + assert_eq!( + events[0]["http_request"]["url"]["path"], + ISSUE_SANDBOX_TOKEN_PATH + ); + assert_eq!(events[0]["src_endpoint"]["ip"], "203.0.113.7"); + assert_eq!(events[0]["metadata"]["correlation_uid"], REQUEST_ID); + assert!(!events[0].to_string().contains("canary-driver-credential")); } #[tokio::test] @@ -1176,10 +1422,20 @@ mod tests { provider: IdentityProvider::Oidc, }, })); - let err = handle_refresh_sandbox_token(&state, req) - .await - .expect_err("user must not refresh"); + let (result, events) = audited( + REFRESH_SANDBOX_TOKEN_PATH, + Box::pin(handle_refresh_sandbox_token(&state, req)), + ) + .await; + let err = result.expect_err("user must not refresh"); assert_eq!(err.code(), tonic::Code::PermissionDenied); + assert_eq!(events.len(), 1, "users raise no finding: {events:?}"); + assert_denial( + &events[0], + REFRESH_SANDBOX_TOKEN_PATH, + "method_not_permitted", + ); + assert_eq!(events[0]["actor"]["user"]["uid"], "alice"); } #[tokio::test] @@ -1187,24 +1443,26 @@ mod tests { // Driver-bootstrap principals must use IssueSandboxToken, not // RefreshSandboxToken — the refresh path assumes a still-valid // gateway-minted JWT exists. - use crate::auth::principal::SandboxIdentitySource; let state = state_with_issuer().await; let mut req = Request::new(RefreshSandboxTokenRequest { extension_service_names: Vec::new(), }); req.extensions_mut() - .insert(Principal::Sandbox(SandboxPrincipal { - sandbox_id: "sandbox-a".to_string(), - source: SandboxIdentitySource::ComputeDriver { - driver_name: "kubernetes".to_string(), - runtime_identity: "test-runtime".to_string(), - }, - trust_domain: Some("openshell".to_string()), - })); - let err = handle_refresh_sandbox_token(&state, req) - .await - .expect_err("K8s SA principal must not refresh"); + .insert(compute_driver_principal("test-runtime")); + let (result, events) = audited( + REFRESH_SANDBOX_TOKEN_PATH, + Box::pin(handle_refresh_sandbox_token(&state, req)), + ) + .await; + let err = result.expect_err("K8s SA principal must not refresh"); assert_eq!(err.code(), tonic::Code::PermissionDenied); + assert_eq!(events.len(), 2, "{events:?}"); + assert_denial( + &events[0], + REFRESH_SANDBOX_TOKEN_PATH, + "method_not_permitted", + ); + 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 6edc693a1..9e3f51281 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -10,6 +10,7 @@ #![allow(clippy::cast_possible_wrap)] // Intentional u32->i32 conversions for proto compat use crate::ServerState; +use crate::auth::authenticator::{AuthFailureReason, AuthMechanism, AuthRejection}; use crate::auth::workspace_authz::{ AuthorizedWorkspaceScope, MinWorkspaceRole, authorize_list_workspace_selector, authorize_sandbox_workspace, authorize_workspace, @@ -2681,7 +2682,8 @@ pub(super) async fn handle_forward_tcp( return Err(Status::failed_precondition("sandbox is not ready")); } - let connection_guard = acquire_forward_connection_guard(state, &init, &sandbox).await?; + let connection_guard = + acquire_forward_connection_guard(state, &principal, &init, &sandbox).await?; let (channel_id, relay_rx) = crate::supervisor_session::open_routed_relay_with_target( state, sandbox.object_id(), @@ -2737,6 +2739,7 @@ impl Drop for ForwardConnectionGuard { /// a TCP target (older clients) is still validated and counted per token. async fn acquire_forward_connection_guard( state: &Arc, + principal: &crate::auth::principal::Principal, init: &TcpForwardInit, sandbox: &Sandbox, ) -> Result { @@ -2744,6 +2747,11 @@ async fn acquire_forward_connection_guard( let token = init.authorization_token.trim(); if token.is_empty() { if !matches!(init.target, Some(tcp_forward_init::Target::Tcp(_))) { + crate::auth::audit::emit_credential_failure( + principal, + AuthMechanism::SshSessionToken, + AuthFailureReason::MissingCredential, + ); return Err(Status::unauthenticated( "authorization_token is required for SSH targets", )); @@ -2756,7 +2764,17 @@ async fn acquire_forward_connection_guard( }); } - validate_ssh_forward_token(state, token, &sandbox_id).await?; + validate_ssh_forward_token(state, token, &sandbox_id) + .await + .map_err(|rejection| { + crate::auth::audit::emit_credential_failure( + principal, + rejection.mechanism, + rejection.reason, + ); + rejection.status + })?; + crate::auth::audit::emit_ssh_session_logon(&state.logons, principal, token); acquire_ssh_connection_slots( &state.ssh_connections_by_token, &state.ssh_connections_by_sandbox, @@ -2771,28 +2789,58 @@ async fn acquire_forward_connection_guard( }) } +#[allow(clippy::result_large_err)] async fn validate_ssh_forward_token( state: &Arc, token: &str, sandbox_id: &str, -) -> Result<(), Status> { +) -> Result<(), AuthRejection> { + let reject = + |reason, status| AuthRejection::new(AuthMechanism::SshSessionToken, reason, status); let session = state .store .get_message::(token) .await - .map_err(|e| Status::internal(format!("fetch SSH session failed: {e}")))? - .ok_or_else(|| Status::unauthenticated("SSH session token not found"))?; + .map_err(|e| { + reject( + AuthFailureReason::IdentityProviderUnavailable, + Status::internal(format!("fetch SSH session failed: {e}")), + ) + })? + .ok_or_else(|| { + reject( + AuthFailureReason::InvalidCredential, + Status::unauthenticated("SSH session token not found"), + ) + })?; if session.revoked || session.sandbox_id != sandbox_id { - return Err(Status::unauthenticated("SSH session token is not valid")); + // A token minted for another sandbox was issued for a different party. + let reason = if session.revoked { + AuthFailureReason::InvalidCredential + } else { + AuthFailureReason::AudienceOrIssuerMismatch + }; + return Err(reject( + reason, + Status::unauthenticated("SSH session token is not valid"), + )); } if let Some(expiration_time) = session.expiration_time.as_ref() { let now_ms = current_time_ms(); - let expires_at_ms = openshell_core::time::timestamp_to_millis(expiration_time) - .map_err(|error| Status::internal(error.to_string()))?; + let expires_at_ms = + openshell_core::time::timestamp_to_millis(expiration_time).map_err(|error| { + reject( + AuthFailureReason::IdentityProviderUnavailable, + Status::internal(error.to_string()), + ) + })?; if now_ms > expires_at_ms { - return Err(Status::unauthenticated("SSH session token expired")); + return Err(reject( + AuthFailureReason::ExpiredCredential, + Status::unauthenticated("SSH session token expired"), + )); } } @@ -4453,6 +4501,7 @@ mod tests { let guard = acquire_forward_connection_guard( &state, + &forward_caller(), &forward_init(loopback_tcp_target(), ""), &sandbox, ) @@ -4473,19 +4522,111 @@ mod tests { ); } + const FORWARD_TCP_PATH: &str = "/openshell.v1.OpenShell/ForwardTcp"; + const FORWARD_REQUEST_ID: &str = "6f1c2d3e-4b5a-4c6d-8e7f-9a0b1c2d3e4f"; + + fn forward_caller() -> crate::auth::principal::Principal { + authed_request(()) + .extensions_mut() + .remove::() + .expect("dev principal") + } + + /// Acquire a forward slot inside a routed `ForwardTcp` request, returning + /// the result and the OCSF events it emitted. + async fn acquire_audited( + state: &Arc, + init: &TcpForwardInit, + sandbox: &Sandbox, + ) -> ( + Result, + Vec, + ) { + let capture = openshell_ocsf::test_support::OcsfCapture::default(); + let result = { + let _guard = capture.install(); + let mut headers = http::HeaderMap::new(); + headers.insert("x-request-id", FORWARD_REQUEST_ID.parse().unwrap()); + let origin = crate::auth::audit::RequestOrigin::new( + Some(crate::auth::audit::ConnectionSource::Peer( + "203.0.113.7:51234".parse().unwrap(), + )), + &headers, + ); + crate::auth::audit::with_request( + FORWARD_TCP_PATH.to_string(), + origin, + acquire_forward_connection_guard(state, &forward_caller(), init, sandbox), + ) + .await + }; + let events = capture + .events() + .iter() + .map(|event| event.to_json().unwrap()) + .collect(); + (result, events) + } + + async fn store_ssh_session( + state: &ServerState, + token: &str, + sandbox_id: &str, + revoked: bool, + expires_at_ms: i64, + ) { + let session = SshSession { + metadata: Some(ObjectMeta { + id: token.to_string(), + name: format!("session-{token}"), + created_time: None, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_time: None, + }), + sandbox_id: sandbox_id.to_string(), + token: token.to_string(), + revoked, + expiration_time: openshell_core::time::optional_timestamp_from_legacy_millis( + expires_at_ms, + ) + .unwrap(), + }; + state.store.put_message(&session).await.unwrap(); + } + + /// Assert `events` is one SSH session token failure for `reason` that + /// never mentions `token`. + fn assert_ssh_token_failure(events: &[serde_json::Value], reason: &str, token: &str) { + assert_eq!(events.len(), 1, "{events:?}"); + let event = &events[0]; + assert_eq!(event["class_uid"], 3002); + assert_eq!(event["status_id"], 2); + assert_eq!(event["status_detail"], reason); + assert_eq!(event["auth_protocol"], "ssh_session_token"); + assert_eq!(event["user"]["uid"], "dev-user"); + assert_eq!(event["http_request"]["url"]["path"], FORWARD_TCP_PATH); + assert_eq!(event["src_endpoint"]["ip"], "203.0.113.7"); + assert_eq!(event["metadata"]["correlation_uid"], FORWARD_REQUEST_ID); + assert!(token.is_empty() || !event.to_string().contains(token)); + } + #[tokio::test] async fn ssh_forward_without_token_is_rejected() { let state = test_server_state().await; let sandbox = test_sandbox("fwd", Vec::new()); - let err = acquire_forward_connection_guard( + let (result, events) = acquire_audited( &state, &forward_init(tcp_forward_init::Target::Ssh(SshRelayTarget::default()), ""), &sandbox, ) - .await - .err() - .expect("ssh targets keep requiring the session token"); + .await; + let err = result + .err() + .expect("ssh targets keep requiring the session token"); assert_eq!(err.code(), tonic::Code::Unauthenticated); assert_eq!( @@ -4493,6 +4634,7 @@ mod tests { "authorization_token is required for SSH targets" ); assert_eq!(sandbox_connection_count(&state, sandbox.object_id()), 0); + assert_ssh_token_failure(&events, "missing_credential", ""); } #[tokio::test] @@ -4500,18 +4642,131 @@ mod tests { let state = test_server_state().await; let sandbox = test_sandbox("fwd", Vec::new()); - let err = acquire_forward_connection_guard( + let (result, events) = acquire_audited( &state, - &forward_init(loopback_tcp_target(), "not-a-session"), + &forward_init(loopback_tcp_target(), "canary-ssh-token-unknown"), &sandbox, ) - .await - .err() - .expect("a supplied token is always validated"); + .await; + let err = result.err().expect("a supplied token is always validated"); assert_eq!(err.code(), tonic::Code::Unauthenticated); assert_eq!(err.message(), "SSH session token not found"); assert_eq!(sandbox_connection_count(&state, sandbox.object_id()), 0); + assert_ssh_token_failure(&events, "invalid_credential", "canary-ssh-token-unknown"); + } + + #[tokio::test] + async fn rejected_ssh_session_tokens_name_their_reason() { + let state = test_server_state().await; + let sandbox = test_sandbox("fwd", Vec::new()); + let future_ms = current_time_ms() + 60_000; + store_ssh_session( + &state, + "canary-ssh-token-revoked", + "sandbox-fwd", + true, + future_ms, + ) + .await; + store_ssh_session( + &state, + "canary-ssh-token-other", + "sandbox-other", + false, + future_ms, + ) + .await; + store_ssh_session( + &state, + "canary-ssh-token-expired", + "sandbox-fwd", + false, + 1_000, + ) + .await; + + for (token, message, reason) in [ + ( + "canary-ssh-token-revoked", + "SSH session token is not valid", + "invalid_credential", + ), + ( + "canary-ssh-token-other", + "SSH session token is not valid", + "audience_or_issuer_mismatch", + ), + ( + "canary-ssh-token-expired", + "SSH session token expired", + "expired_credential", + ), + ] { + let (result, events) = acquire_audited( + &state, + &forward_init( + tcp_forward_init::Target::Ssh(SshRelayTarget::default()), + token, + ), + &sandbox, + ) + .await; + let err = result.err().expect(token); + + assert_eq!(err.code(), tonic::Code::Unauthenticated, "{token}"); + assert_eq!(err.message(), message, "{token}"); + assert_ssh_token_failure(&events, reason, token); + } + } + + #[tokio::test] + async fn session_token_on_a_tcp_target_logs_on() { + let state = test_server_state().await; + let sandbox = test_sandbox("fwd", Vec::new()); + store_ssh_session(&state, "canary-ssh-token-tcp", "sandbox-fwd", false, 0).await; + + let (result, events) = acquire_audited( + &state, + &forward_init(loopback_tcp_target(), "canary-ssh-token-tcp"), + &sandbox, + ) + .await; + result.expect("older clients still send a session token"); + + assert_eq!(events.len(), 1, "{events:?}"); + assert_eq!(events[0]["status_id"], 1); + assert_eq!(events[0]["auth_protocol"], "ssh_session_token"); + assert!(!events[0].to_string().contains("canary-ssh-token")); + } + + #[tokio::test] + async fn ssh_session_token_logs_on_once() { + let state = test_server_state().await; + let sandbox = test_sandbox("fwd", Vec::new()); + store_ssh_session(&state, "canary-ssh-token-valid", "sandbox-fwd", false, 0).await; + let init = forward_init( + tcp_forward_init::Target::Ssh(SshRelayTarget::default()), + "canary-ssh-token-valid", + ); + + let (first, events) = acquire_audited(&state, &init, &sandbox).await; + let (second, repeated) = acquire_audited(&state, &init, &sandbox).await; + first.expect("valid session token"); + second.expect("valid session token"); + + assert!(repeated.is_empty(), "{repeated:?}"); + assert_eq!(events.len(), 1, "{events:?}"); + let event = &events[0]; + assert_eq!(event["class_uid"], 3002); + assert_eq!(event["status_id"], 1); + assert_eq!(event["severity_id"], 1); + assert_eq!(event["auth_protocol"], "ssh_session_token"); + assert_eq!(event["user"]["uid"], "dev-user"); + assert_eq!(event["http_request"]["url"]["path"], FORWARD_TCP_PATH); + assert_eq!(event["src_endpoint"]["ip"], "203.0.113.7"); + assert_eq!(event["metadata"]["correlation_uid"], FORWARD_REQUEST_ID); + assert!(!event.to_string().contains("canary-ssh-token")); } #[tokio::test] @@ -4523,12 +4778,12 @@ mod tests { let mut guards = Vec::new(); for _ in 0..MAX_CONNECTIONS_PER_SANDBOX { guards.push( - acquire_forward_connection_guard(&state, &init, &sandbox) + acquire_forward_connection_guard(&state, &forward_caller(), &init, &sandbox) .await .expect("within the per-sandbox cap"), ); } - let err = acquire_forward_connection_guard(&state, &init, &sandbox) + let err = acquire_forward_connection_guard(&state, &forward_caller(), &init, &sandbox) .await .err() .expect("cap reached"); @@ -4540,7 +4795,7 @@ mod tests { ); guards.pop(); - let guard = acquire_forward_connection_guard(&state, &init, &sandbox) + let guard = acquire_forward_connection_guard(&state, &forward_caller(), &init, &sandbox) .await .expect("slot freed by a closed connection"); drop(guard); diff --git a/crates/openshell-server/src/multiplex.rs b/crates/openshell-server/src/multiplex.rs index 3ccf92da0..a9473ffff 100644 --- a/crates/openshell-server/src/multiplex.rs +++ b/crates/openshell-server/src/multiplex.rs @@ -4641,15 +4641,73 @@ mod tests { #[tokio::test] async fn logon_names_the_protocol_that_established_the_principal() { - for (principal, principal_type, protocol) in [ - ( - Principal::Peer(crate::auth::principal::PeerPrincipal { - replica_id: "gateway-1".to_string(), - pod_uid: "pod-1".to_string(), - }), - "Gateway Peer", - "k8s_service_account", - ), + let capture = OcsfCapture::default(); + let _guard = capture.install(); + let chain = 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(), + })), + )))]); + let mut router = AuthGrpcRouter::new(PrincipalRecorder::new().0, Some(chain), None); + let path = "/openshell.v1.OpenShell/PeerRelay"; + + router + .call(bearer_request(path, "peer-token", REQUEST_ID)) + .await + .unwrap(); + + let events = captured_json(&capture); + assert_eq!(events.len(), 1, "{events:?}"); + assert_logon(&events[0], path); + assert_eq!(events[0]["user"]["type"], "Gateway Peer"); + assert_eq!(events[0]["auth_protocol"], "k8s_service_account"); + } + + #[tokio::test] + async fn verified_credentials_log_on_before_a_sandbox_token_method_denies_them() { + for path in [ + "/openshell.v1.OpenShell/IssueSandboxToken", + "/openshell.v1.OpenShell/RefreshSandboxToken", + ] { + for (principal, principal_type) in [ + (user_principal("alice"), "User"), + (Principal::Operator(operator_principal()), "Admin"), + ( + Principal::Peer(crate::auth::principal::PeerPrincipal { + replica_id: "gateway-1".to_string(), + pod_uid: "pod-1".to_string(), + }), + "Gateway Peer", + ), + ] { + let capture = OcsfCapture::default(); + let _guard = capture.install(); + let chain = AuthenticatorChain::new(vec![Arc::new( + MockAuthenticator::returning(Ok(Authentication::Accepted(principal))), + )]); + let (recorder, seen) = PrincipalRecorder::new(); + let mut router = AuthGrpcRouter::new(recorder, Some(chain), None); + + let res = router + .call(bearer_request(path, "verified-token", REQUEST_ID)) + .await + .unwrap(); + + assert_eq!(grpc_status(&res).as_deref(), Some("7"), "{path}"); + assert!(seen.lock().unwrap().is_none(), "{path}"); + let events = captured_json(&capture); + assert_eq!(events.len(), 2, "{principal_type} {path}: {events:?}"); + assert_logon(&events[0], path); + assert_eq!(events[0]["user"]["type"], principal_type); + assert_eq!(events[1]["class_uid"], 6003); + } + } + } + + #[tokio::test] + async fn handlers_that_finish_verification_record_their_own_logon() { + for (principal, path) in [ ( Principal::Sandbox(SandboxPrincipal { sandbox_id: "sandbox-a".to_string(), @@ -4659,8 +4717,17 @@ mod tests { }, trust_domain: None, }), - "Sandbox", - "compute_driver", + "/openshell.v1.OpenShell/IssueSandboxToken", + ), + ( + Principal::Sandbox(SandboxPrincipal { + sandbox_id: "sandbox-a".to_string(), + source: SandboxIdentitySource::BootstrapJwt { + issuer: "openshell-gateway:test".to_string(), + }, + trust_domain: None, + }), + "/openshell.v1.OpenShell/RefreshSandboxToken", ), ] { let capture = OcsfCapture::default(); @@ -4668,22 +4735,17 @@ mod tests { let chain = AuthenticatorChain::new(vec![Arc::new(MockAuthenticator::returning( Ok(Authentication::Accepted(principal)), ))]); - let mut router = AuthGrpcRouter::new(PrincipalRecorder::new().0, Some(chain), None); - let path = "/openshell.v1.OpenShell/IssueSandboxToken"; + let (recorder, seen) = PrincipalRecorder::new(); + let mut router = AuthGrpcRouter::new(recorder, Some(chain), None); router - .call(bearer_request(path, "driver-or-peer-token", REQUEST_ID)) + .call(bearer_request(path, "sandbox-token", REQUEST_ID)) .await .unwrap(); - let events: Vec<_> = captured_json(&capture) - .into_iter() - .filter(|event| event["class_uid"] == 3002) - .collect(); - assert_eq!(events.len(), 1, "{protocol}: {events:?}"); - assert_logon(&events[0], path); - assert_eq!(events[0]["user"]["type"], principal_type); - assert_eq!(events[0]["auth_protocol"], protocol); + assert!(seen.lock().unwrap().is_some(), "{path} reached the handler"); + let events = captured_json(&capture); + assert!(events.is_empty(), "{path}: {events:?}"); } } diff --git a/docs/observability/ocsf-json-export.mdx b/docs/observability/ocsf-json-export.mdx index 583f41bfb..96e30c75c 100644 --- a/docs/observability/ocsf-json-export.mdx +++ b/docs/observability/ocsf-json-export.mdx @@ -21,7 +21,7 @@ This output is separate from the supervisor stderr and file output described bel Gateway records use the configured gateway name for both `device.uid` and `device.name`. Replicas of one installation share that identity; `device.hostname` identifies the emitting replica. Assign distinct gateway names to installations whose events are collected together. Reusing a name, including the default name, groups those installations under the same identity. Renaming a gateway changes its audit identity. -The gateway emits an Authentication (`class_uid: 3002`) event when it rejects a gRPC request during authentication. The caller receives the same error either way. `auth_protocol` names the mechanism involved, when one applies: `OpenID` for OIDC bearer tokens, or `mtls`, `sandbox_jwt`, `compute_driver`, or `k8s_service_account`. `status_detail` holds the reason category: +The gateway emits an Authentication (`class_uid: 3002`) event when it rejects a gRPC request during authentication. The caller receives the same error either way. It also emits one when a handler rejects a credential it checks itself: the SSH session token of a `ForwardTcp` SSH connection, or a compute-driver identity on `IssueSandboxToken` that no longer matches the sandbox's recorded runtime. Those events identify the already-authenticated caller in `user`. `auth_protocol` names the mechanism involved, when one applies: `OpenID` for OIDC bearer tokens, or `mtls`, `sandbox_jwt`, `compute_driver`, `k8s_service_account`, or `ssh_session_token`. `status_detail` holds the reason category: | Reason | Severity | Meaning | |---|---|---| @@ -37,11 +37,11 @@ The gateway emits an Authentication (`class_uid: 3002`) event when it rejects a `src_endpoint` holds the address of the connection's immediate peer, which is the proxy or load balancer when one fronts the gateway, or the name `edge-tunnel` for requests carried over the WebSocket tunnel. `metadata.correlation_uid` holds the request's `x-request-id` when it is a UUID. The gateway generates one when the client sends none and returns it in the response. A client-supplied ID is recorded as sent, so it identifies the request, not the client. -The gateway also emits an Informational Authentication event with `status: Success` the first time a replica accepts a credential, such as a bearer token or client certificate. Later requests with the same credential emit nothing, so supervisor polling does not repeat the event. A refreshed token or renewed certificate is a new credential and logs on again, as does a credential still in use an hour later. Each replica records its own logons, and a replica tracking more than 16,384 credentials forgets the oldest early. `user` identifies the principal the same way `actor.user` does for authorization denials, described below. The `src_endpoint` and `metadata.correlation_uid` come from the request that logged on. Requests admitted by `auth.allow_unauthenticated_users`, or by a gateway with no authentication configured, emit no success event. To select only rejected requests, filter Authentication events on `status_id: 2` (Failure) rather than on `class_uid` alone. +The gateway also emits an Informational Authentication event with `status: Success` the first time a replica accepts a credential, such as a bearer token, client certificate, or SSH session token. Later requests with the same credential emit nothing, so supervisor polling does not repeat the event. A refreshed token or renewed certificate is a new credential and logs on again, as does a credential still in use an hour later. Each replica records its own logons, and a replica tracking more than 16,384 credentials forgets the oldest early. `user` identifies the principal the same way `actor.user` does for authorization denials, described below. The `src_endpoint` and `metadata.correlation_uid` come from the request that logged on. A sandbox credential on `IssueSandboxToken` or `RefreshSandboxToken` logs on only after the gateway has checked the sandbox's recorded runtime or token lineage, so a credential that fails that check records no successful logon. Requests admitted by `auth.allow_unauthenticated_users`, or by a gateway with no authentication configured, emit no success event, though SSH session tokens they present still log on. To select only rejected requests, filter Authentication events on `status_id: 2` (Failure) rather than on `class_uid` alone. 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: +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, workspace, or extension service that was refused, when one applies, by `uid` for a sandbox ID or by `name` otherwise. `status_detail` holds the reason category: | Reason | Meaning | |---|---| @@ -54,6 +54,7 @@ The gateway emits an API Activity (`class_uid: 6003`) event with Medium severity | `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. | +| `extension_service_not_selected` | A sandbox requested a credential for an extension service its policy does not select. A policy update can race the request, so this raises no finding. | 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.