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.