Skip to content

Commit 321b93a

Browse files
authored
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 <khicks@nvidia.com>
1 parent ceecf02 commit 321b93a

7 files changed

Lines changed: 833 additions & 149 deletions

File tree

‎crates/openshell-server/src/auth/audit.rs‎

Lines changed: 139 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ fn with_protocol(
101101
AuthMechanism::SandboxJwt => builder.auth_protocol_other("sandbox_jwt"),
102102
AuthMechanism::ComputeDriver => builder.auth_protocol_other("compute_driver"),
103103
AuthMechanism::GatewayPeer => builder.auth_protocol_other("k8s_service_account"),
104+
AuthMechanism::SshSessionToken => builder.auth_protocol_other("ssh_session_token"),
104105
}
105106
}
106107

@@ -110,23 +111,46 @@ pub fn emit_authentication_failure(
110111
origin: &RequestOrigin,
111112
mechanism: Option<AuthMechanism>,
112113
reason: AuthFailureReason,
114+
) {
115+
authentication_failure(path, origin, User::unidentified(), mechanism, reason);
116+
}
117+
118+
/// Emit an Authentication failure event for a credential that a handler
119+
/// verifies itself, after the router established `principal`.
120+
pub fn emit_credential_failure(
121+
principal: &Principal,
122+
mechanism: AuthMechanism,
123+
reason: AuthFailureReason,
124+
) {
125+
let (path, origin) = current_request();
126+
authentication_failure(
127+
&path,
128+
&origin,
129+
principal_user(principal),
130+
Some(mechanism),
131+
reason,
132+
);
133+
}
134+
135+
fn authentication_failure(
136+
path: &str,
137+
origin: &RequestOrigin,
138+
user: User,
139+
mechanism: Option<AuthMechanism>,
140+
reason: AuthFailureReason,
113141
) {
114142
let ctx = crate::gateway_ocsf::context("", "");
115-
let mut builder = AuthenticationBuilder::new(
116-
&ctx,
117-
User::unidentified(),
118-
Service::new("openshell-gateway"),
119-
)
120-
.action(ActionId::Denied)
121-
.disposition(DispositionId::Blocked)
122-
.severity(severity(reason))
123-
.status(StatusId::Failure)
124-
.status_detail(category(reason))
125-
.http_request(HttpRequest {
126-
http_method: HttpMethod::Post,
127-
url: Some(Url::from_path(path)),
128-
})
129-
.message(format!("authentication failed: {}", category(reason)));
143+
let mut builder = AuthenticationBuilder::new(&ctx, user, Service::new("openshell-gateway"))
144+
.action(ActionId::Denied)
145+
.disposition(DispositionId::Blocked)
146+
.severity(severity(reason))
147+
.status(StatusId::Failure)
148+
.status_detail(category(reason))
149+
.http_request(HttpRequest {
150+
http_method: HttpMethod::Post,
151+
url: Some(Url::from_path(path)),
152+
})
153+
.message(format!("authentication failed: {}", category(reason)));
130154
if let Some(mechanism) = mechanism {
131155
builder = with_protocol(builder, mechanism);
132156
}
@@ -226,27 +250,56 @@ fn credential_digest(
226250
principal: &Principal,
227251
headers: &http::HeaderMap,
228252
peer_certificate_sha256: Option<&str>,
253+
) -> Option<[u8; 32]> {
254+
let credential = match principal {
255+
Principal::Operator(operator) => operator.certificate_sha256.as_str(),
256+
Principal::User(user) if user.identity.provider == IdentityProvider::Mtls => {
257+
peer_certificate_sha256.unwrap_or_default()
258+
}
259+
_ => bearer_credential(headers).unwrap_or_default(),
260+
};
261+
digest(principal, None, credential)
262+
}
263+
264+
/// A digest of `principal`, the mechanism when it is not the principal's
265+
/// own, and the credential, kept apart by separators.
266+
fn digest(
267+
principal: &Principal,
268+
mechanism: Option<AuthMechanism>,
269+
credential: &str,
229270
) -> Option<[u8; 32]> {
230271
let mut material = principal.audit_actor().into_bytes();
231272
material.push(0);
232-
match principal {
233-
Principal::Operator(operator) => {
234-
material.extend_from_slice(operator.certificate_sha256.as_bytes());
235-
}
236-
Principal::User(user) if user.identity.provider == IdentityProvider::Mtls => {
237-
material.extend_from_slice(peer_certificate_sha256.unwrap_or_default().as_bytes());
273+
if let Some(mechanism) = mechanism {
274+
material.extend_from_slice(format!("{mechanism:?}").as_bytes());
275+
material.push(0);
276+
}
277+
material.extend_from_slice(credential.as_bytes());
278+
openshell_crypto::sha256(&material).ok()
279+
}
280+
281+
/// Whether the handler for `path` finishes verifying `principal`'s
282+
/// credential, and so records its logon itself once it accepts it: the
283+
/// recorded runtime of a compute-driver credential on `IssueSandboxToken`,
284+
/// and the lineage of a gateway JWT on `RefreshSandboxToken`. Other
285+
/// credentials are fully verified by the router.
286+
fn handler_verifies(path: &str, principal: &Principal) -> bool {
287+
let Principal::Sandbox(sandbox) = principal else {
288+
return false;
289+
};
290+
match sandbox.source {
291+
SandboxIdentitySource::ComputeDriver { .. } => {
292+
path == super::compute_driver::ISSUE_SANDBOX_TOKEN_PATH
238293
}
239-
_ => {
240-
if let Some(token) = bearer_credential(headers) {
241-
material.extend_from_slice(token.as_bytes());
242-
}
294+
SandboxIdentitySource::BootstrapJwt { .. } => {
295+
path == super::sandbox_jwt::REFRESH_SANDBOX_TOKEN_PATH
243296
}
244297
}
245-
openshell_crypto::sha256(&material).ok()
246298
}
247299

248300
/// Emit an Authentication logon event for a request to `path` that
249-
/// authenticated as `principal`, unless its credential is already logged on.
301+
/// authenticated as `principal`, unless its credential is already logged on
302+
/// or the handler finishes verifying it.
250303
///
251304
/// `peer_certificate_sha256` fingerprints the connection's verified client
252305
/// certificate, which identifies an mTLS user's credential.
@@ -258,13 +311,49 @@ pub fn emit_logon(
258311
headers: &http::HeaderMap,
259312
peer_certificate_sha256: Option<&str>,
260313
) {
261-
// Without a digest the credential cannot be deduplicated, so it logs on.
262-
if !authenticated(principal)
263-
|| credential_digest(principal, headers, peer_certificate_sha256)
264-
.is_some_and(|digest| !logons.log_on(digest, Instant::now()))
314+
if authenticated(principal)
315+
&& !handler_verifies(path, principal)
316+
&& logs_on(
317+
logons,
318+
credential_digest(principal, headers, peer_certificate_sha256),
319+
)
265320
{
266-
return;
321+
logon(path, origin, principal, mechanism(principal));
322+
}
323+
}
324+
325+
/// Emit an Authentication logon event for the current request once its
326+
/// handler has finished verifying `principal`'s credential, unless the
327+
/// credential is already logged on.
328+
pub fn emit_handler_logon(logons: &Logons, principal: &Principal, headers: &http::HeaderMap) {
329+
if logs_on(logons, credential_digest(principal, headers, None)) {
330+
let (path, origin) = current_request();
331+
logon(&path, &origin, principal, mechanism(principal));
267332
}
333+
}
334+
335+
/// Emit an Authentication logon event for the SSH session `token` that a
336+
/// handler accepted from `principal`, unless it is already logged on.
337+
pub fn emit_ssh_session_logon(logons: &Logons, principal: &Principal, token: &str) {
338+
let mechanism = AuthMechanism::SshSessionToken;
339+
if logs_on(logons, digest(principal, Some(mechanism), token)) {
340+
let (path, origin) = current_request();
341+
logon(&path, &origin, principal, Some(mechanism));
342+
}
343+
}
344+
345+
/// Whether the credential with `digest` makes a new logon. Without a digest
346+
/// the credential cannot be deduplicated, so it logs on.
347+
fn logs_on(logons: &Logons, digest: Option<[u8; 32]>) -> bool {
348+
digest.is_none_or(|digest| logons.log_on(digest, Instant::now()))
349+
}
350+
351+
fn logon(
352+
path: &str,
353+
origin: &RequestOrigin,
354+
principal: &Principal,
355+
mechanism: Option<AuthMechanism>,
356+
) {
268357
let ctx = crate::gateway_ocsf::context("", "");
269358
let mut builder = AuthenticationBuilder::new(
270359
&ctx,
@@ -280,7 +369,7 @@ pub fn emit_logon(
280369
url: Some(Url::from_path(path)),
281370
})
282371
.message("authentication succeeded");
283-
if let Some(mechanism) = mechanism(principal) {
372+
if let Some(mechanism) = mechanism {
284373
builder = with_protocol(builder, mechanism);
285374
}
286375
if let Some(source) = origin.source {
@@ -315,6 +404,14 @@ pub async fn with_request<F: Future>(path: String, origin: RequestOrigin, handle
315404
.await
316405
}
317406

407+
/// The path and origin of the request a handler is serving, or empty
408+
/// values outside one.
409+
fn current_request() -> (String, RequestOrigin) {
410+
REQUEST
411+
.try_with(|request| (request.path.clone(), request.origin.clone()))
412+
.unwrap_or_default()
413+
}
414+
318415
/// Carry the current request's audit context into `future`, for work a
319416
/// handler runs in a spawned task.
320417
pub fn in_current_request<F: Future>(future: F) -> impl Future<Output = F::Output> {
@@ -352,6 +449,9 @@ pub enum AuthzDenial<'a> {
352449
PlatformAdminRequired,
353450
/// The operation requires a verified mTLS operator.
354451
OperatorRequired,
452+
/// A sandbox requested a credential for an extension service its policy
453+
/// does not select.
454+
ExtensionServiceNotSelected { service: &'a str },
355455
}
356456

357457
impl AuthzDenial<'_> {
@@ -366,6 +466,7 @@ impl AuthzDenial<'_> {
366466
Self::InsufficientWorkspaceRole { .. } => "insufficient_workspace_role",
367467
Self::PlatformAdminRequired => "platform_admin_required",
368468
Self::OperatorRequired => "operator_required",
469+
Self::ExtensionServiceNotSelected { .. } => "extension_service_not_selected",
369470
}
370471
}
371472

@@ -381,6 +482,10 @@ impl AuthzDenial<'_> {
381482
| Self::InsufficientWorkspaceRole { workspace } => {
382483
Some(ResourceDetails::named("workspace", bounded(workspace)))
383484
}
485+
Self::ExtensionServiceNotSelected { service } => Some(ResourceDetails::named(
486+
"extension_service",
487+
bounded(service),
488+
)),
384489
_ => None,
385490
}
386491
}
@@ -427,10 +532,7 @@ fn principal_user(principal: &Principal) -> User {
427532

428533
/// Emit an authorization denial raised by a handler for the current request.
429534
pub fn emit_authorization_denial(principal: &Principal, denial: AuthzDenial<'_>) {
430-
let request = REQUEST.try_with(Clone::clone).ok();
431-
let (path, origin) = request
432-
.map(|request| (request.path, request.origin))
433-
.unwrap_or_default();
535+
let (path, origin) = current_request();
434536
emit_authorization_denial_for(&path, &origin, principal, denial);
435537
}
436538

‎crates/openshell-server/src/auth/authenticator.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ pub enum AuthMechanism {
9999
SandboxJwt,
100100
ComputeDriver,
101101
GatewayPeer,
102+
/// The `CreateSshSession` token an SSH relay presents.
103+
SshSessionToken,
102104
}
103105

104106
/// A rejected authentication attempt: the status returned to the caller and

‎crates/openshell-server/src/auth/sandbox_jwt.rs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ use openshell_core::jwt::{
4242
};
4343
use openshell_core::sandbox_generation::SandboxGenerationId;
4444

45+
/// The method on which a sandbox renews its gateway JWT, and the only one that
46+
/// accepts a just-consumed bearer.
47+
pub const REFRESH_SANDBOX_TOKEN_PATH: &str = "/openshell.v1.OpenShell/RefreshSandboxToken";
48+
4549
/// SPIFFE-shaped subject prefix. Embedded in the `sub` claim of every
4650
/// minted token so a future migration to per-sandbox certs or SPIRE can
4751
/// reuse the same subject namespace without breaking handler equality
@@ -318,7 +322,7 @@ impl Authenticator for SandboxSessionJwtAuthenticator {
318322
// Refresh performs its own lineage check so the immediately consumed
319323
// bearer can recover an already-committed successor after a lost
320324
// response. Every other RPC accepts only the current bearer.
321-
if path != "/openshell.v1.OpenShell/RefreshSandboxToken" {
325+
if path != REFRESH_SANDBOX_TOKEN_PATH {
322326
crate::auth::sandbox_session::authorize_persisted(&self.store, &authenticated).await?;
323327
}
324328
Ok(Authentication::Accepted(Principal::Sandbox(

0 commit comments

Comments
 (0)