Skip to content

Commit 249ca20

Browse files
committed
feat(auth): emit OCSF logon events for accepted gateway credentials
When a gateway replica accepts a credential for the first time, it now emits an Informational OCSF Authentication event with status Success. The caller's request proceeds exactly as before. The event records: - Who: the principal (user, mTLS operator, sandbox, or gateway peer), with the same user types as authorization denials. - How: the authentication protocol (OpenID, mtls, sandbox_jwt, compute_driver, or k8s_service_account). - Where: the gRPC method of the request that logged on. - Correlation: the caller's address and request ID, the same fields the failure and denial events carry. Every gRPC request authenticates, and an idle sandbox's supervisor polls the gateway several times a minute, so an event per request would bury user logons. Each credential therefore logs on once: later requests with the same bearer token or client certificate emit nothing until the credential has been in use for an hour, when it logs on again. The replica's connections share this record, which holds up to 16,384 credentials and forgets the oldest early when full. It keys on a SHA-256 digest of the credential that authenticated the caller (the bearer token, the operator's certificate, or the mTLS user's subject), so headers the authenticator ignored cannot force new logons. Digests stay in memory. Requests admitted by allow_unauthenticated_users, or by a gateway with no authentication configured, emit no success event, since no credential was verified. Refs #2912 Signed-off-by: Kris Hicks <[email protected]>
1 parent ea265a2 commit 249ca20

5 files changed

Lines changed: 484 additions & 17 deletions

File tree

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

Lines changed: 177 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,14 @@ use openshell_ocsf::{
1212
DetectionFindingBuilder, DispositionId, Endpoint, FindingInfo, HttpMethod, HttpRequest,
1313
ResourceDetails, Service, SeverityId, StatusId, Url, User, UserTypeId, ocsf_emit,
1414
};
15+
use std::collections::{HashSet, VecDeque};
1516
use std::future::Future;
1617
use std::net::SocketAddr;
18+
use std::sync::{Arc, Mutex, PoisonError};
19+
use std::time::{Duration, Instant};
1720

18-
use super::principal::Principal;
21+
use super::identity::IdentityProvider;
22+
use super::principal::{Principal, SandboxIdentitySource};
1923

2024
/// Where a gateway connection came from.
2125
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -56,7 +60,7 @@ impl RequestOrigin {
5660
}
5761
}
5862

59-
use super::authenticator::{AuthFailureReason, AuthMechanism};
63+
use super::authenticator::{AuthFailureReason, AuthMechanism, bearer_credential};
6064

6165
fn category(reason: AuthFailureReason) -> &'static str {
6266
match reason {
@@ -135,6 +139,141 @@ pub fn emit_authentication_failure(
135139
ocsf_emit!(builder.build());
136140
}
137141

142+
/// How long a credential stays logged on before its next use records a new
143+
/// logon.
144+
const LOGON_WINDOW: Duration = Duration::from_hours(1);
145+
146+
/// Credentials tracked at once. When full, the oldest logon is forgotten
147+
/// early, so a busy gateway records some credentials more than once an hour.
148+
const MAX_LOGONS: usize = 16_384;
149+
150+
/// Credentials that have recently authenticated, shared by every connection
151+
/// to this gateway replica.
152+
///
153+
/// Every request authenticates, so recording each one would bury user logons
154+
/// under supervisor polling. A credential instead records one logon when the
155+
/// replica first accepts it, and again after [`LOGON_WINDOW`] if still in use.
156+
#[derive(Debug, Clone, Default)]
157+
pub struct Logons(Arc<Mutex<LogonState>>);
158+
159+
#[derive(Debug, Default)]
160+
struct LogonState {
161+
active: HashSet<[u8; 32]>,
162+
by_age: VecDeque<([u8; 32], Instant)>,
163+
}
164+
165+
impl Logons {
166+
/// Record a logon for `credential` at `now`, returning whether it is new.
167+
fn log_on(&self, credential: [u8; 32], now: Instant) -> bool {
168+
let mut state = self.0.lock().unwrap_or_else(PoisonError::into_inner);
169+
let LogonState { active, by_age } = &mut *state;
170+
while let Some(&(oldest, at)) = by_age.front() {
171+
if now.saturating_duration_since(at) < LOGON_WINDOW && by_age.len() < MAX_LOGONS {
172+
break;
173+
}
174+
by_age.pop_front();
175+
active.remove(&oldest);
176+
}
177+
if !active.insert(credential) {
178+
return false;
179+
}
180+
by_age.push_back((credential, now));
181+
true
182+
}
183+
}
184+
185+
/// Whether the gateway verified a credential to establish `principal`.
186+
fn authenticated(principal: &Principal) -> bool {
187+
match principal {
188+
Principal::User(user) => user.identity.provider != IdentityProvider::LocalDev,
189+
Principal::Anonymous => false,
190+
Principal::Operator(_) | Principal::Sandbox(_) | Principal::Peer(_) => true,
191+
}
192+
}
193+
194+
/// The mechanism that established `principal`, when it has an audit label.
195+
fn mechanism(principal: &Principal) -> Option<AuthMechanism> {
196+
match principal {
197+
Principal::Operator(_) => Some(AuthMechanism::Mtls),
198+
Principal::User(user) => match user.identity.provider {
199+
IdentityProvider::Oidc => Some(AuthMechanism::Oidc),
200+
IdentityProvider::Mtls => Some(AuthMechanism::Mtls),
201+
IdentityProvider::CloudflareAccess | IdentityProvider::LocalDev => None,
202+
},
203+
Principal::Sandbox(sandbox) => Some(match sandbox.source {
204+
SandboxIdentitySource::BootstrapJwt { .. } => AuthMechanism::SandboxJwt,
205+
SandboxIdentitySource::ComputeDriver { .. } => AuthMechanism::ComputeDriver,
206+
}),
207+
Principal::Peer(_) => Some(AuthMechanism::GatewayPeer),
208+
Principal::Anonymous => None,
209+
}
210+
}
211+
212+
/// A digest identifying the credential that established `principal`: the
213+
/// operator's certificate, the mTLS user's subject, or the bearer token.
214+
///
215+
/// Only verified material goes in, so headers the authenticator ignored
216+
/// cannot make a new logon.
217+
fn credential_digest(principal: &Principal, headers: &http::HeaderMap) -> Option<[u8; 32]> {
218+
let mut material = principal.audit_actor().into_bytes();
219+
material.push(0);
220+
match principal {
221+
Principal::Operator(operator) => {
222+
material.extend_from_slice(operator.certificate_sha256.as_bytes());
223+
}
224+
Principal::User(user) if user.identity.provider == IdentityProvider::Mtls => {}
225+
_ => {
226+
if let Some(token) = bearer_credential(headers) {
227+
material.extend_from_slice(token.as_bytes());
228+
}
229+
}
230+
}
231+
openshell_crypto::sha256(&material).ok()
232+
}
233+
234+
/// Emit an Authentication logon event for a request to `path` that
235+
/// authenticated as `principal`, unless its credential is already logged on.
236+
pub fn emit_logon(
237+
logons: &Logons,
238+
path: &str,
239+
origin: &RequestOrigin,
240+
principal: &Principal,
241+
headers: &http::HeaderMap,
242+
) {
243+
// Without a digest the credential cannot be deduplicated, so it logs on.
244+
if !authenticated(principal)
245+
|| credential_digest(principal, headers)
246+
.is_some_and(|digest| !logons.log_on(digest, Instant::now()))
247+
{
248+
return;
249+
}
250+
let ctx = crate::gateway_ocsf::context("", "");
251+
let mut builder = AuthenticationBuilder::new(
252+
&ctx,
253+
principal_user(principal),
254+
Service::new("openshell-gateway"),
255+
)
256+
.action(ActionId::Allowed)
257+
.disposition(DispositionId::Allowed)
258+
.severity(SeverityId::Informational)
259+
.status(StatusId::Success)
260+
.http_request(HttpRequest {
261+
http_method: HttpMethod::Post,
262+
url: Some(Url::from_path(path)),
263+
})
264+
.message("authentication succeeded");
265+
if let Some(mechanism) = mechanism(principal) {
266+
builder = with_protocol(builder, mechanism);
267+
}
268+
if let Some(source) = origin.source {
269+
builder = builder.src_endpoint(source.endpoint());
270+
}
271+
if let Some(request_id) = &origin.request_id {
272+
builder = builder.correlation_uid(request_id);
273+
}
274+
ocsf_emit!(builder.build());
275+
}
276+
138277
tokio::task_local! {
139278
static REQUEST: RequestContext;
140279
}
@@ -374,3 +513,39 @@ pub fn emit_authorization_denial_for(
374513
ocsf_emit!(finding.build());
375514
}
376515
}
516+
517+
#[cfg(test)]
518+
mod tests {
519+
use super::*;
520+
521+
#[test]
522+
fn credential_logs_on_again_after_the_window() {
523+
let logons = Logons::default();
524+
let start = Instant::now();
525+
526+
assert!(logons.log_on([1; 32], start));
527+
assert!(!logons.log_on([1; 32], start + Duration::from_mins(59)));
528+
assert!(logons.log_on([1; 32], start + LOGON_WINDOW));
529+
}
530+
531+
#[test]
532+
fn oldest_credential_is_forgotten_when_full() {
533+
let logons = Logons::default();
534+
let now = Instant::now();
535+
let credential = |n: usize| {
536+
let mut digest = [0; 32];
537+
digest[..8].copy_from_slice(&n.to_le_bytes());
538+
digest
539+
};
540+
for n in 0..MAX_LOGONS {
541+
assert!(logons.log_on(credential(n), now));
542+
}
543+
544+
assert!(logons.log_on(credential(MAX_LOGONS), now));
545+
assert!(logons.log_on(credential(0), now), "oldest was evicted");
546+
assert!(
547+
!logons.log_on(credential(MAX_LOGONS), now),
548+
"newest was kept"
549+
);
550+
}
551+
}

‎crates/openshell-server/src/lib.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,10 @@ pub struct ServerState {
345345
/// Optional K8s `ServiceAccount` authenticator for gateway peer RPCs.
346346
pub peer_authenticator: Option<Arc<auth::peer::PeerServiceAccountAuthenticator>>,
347347

348+
/// Credentials that have recently logged on, so each records one
349+
/// authentication success rather than one per request.
350+
pub(crate) logons: auth::audit::Logons,
351+
348352
/// Gateway-wide gRPC request rate limiter shared by every multiplex path.
349353
pub(crate) grpc_rate_limiter: Option<multiplex::GrpcRateLimiter>,
350354

@@ -463,6 +467,7 @@ impl ServerState {
463467
sandbox_session_jwt_authority: None,
464468
compute_driver_authenticator: None,
465469
peer_authenticator: None,
470+
logons: auth::audit::Logons::default(),
466471
grpc_rate_limiter,
467472
reflection_service,
468473
gateway_interceptors: None,

0 commit comments

Comments
 (0)