1mod outcome;
4#[cfg(test)]
5mod tests;
6
7use std::{
8 borrow::Cow,
9 collections::HashMap,
10 marker::PhantomData,
11 net::IpAddr,
12 panic::{AssertUnwindSafe, catch_unwind},
13 sync::{
14 Arc, Mutex, PoisonError,
15 atomic::{AtomicBool, Ordering},
16 },
17 time::Duration,
18};
19
20use chrono::{DateTime, TimeDelta, Utc};
21use uuid::Uuid;
22use zeroize::Zeroizing;
23
24pub use outcome::{
25 AuthSession, Authenticated, IssuedToken, NewSession, Outcome, PurgeReport, Rejection, SessionInfo, StepUpOutcome,
26};
27
28use crate::{
29 checks::{Check, Checks, Decision},
30 client::{ClientContext, HeaderSource, resolve_client_ip},
31 clock::{Clock, SystemClock},
32 config::{GuardConfig, Mode, StepUpPolicy},
33 error::GuardError,
34 event::{ClientSummary, EventKind, SessionEvent, SessionOrigin, mask_ip},
35 fingerprint::{Baseline, Fingerprint, UaCache},
36 intel::{IpIntel, NoIpIntel},
37 store::{ClaimScope, Rotation, SessionRecord, SessionStore, SessionUpdate, Supersede},
38 subject::Subject,
39 token::{SessionToken, TokenHash},
40};
41
42use secrecy::SecretString;
43
44type Hook = Arc<dyn Fn(&SessionEvent) + Send + Sync>;
45
46pub struct Guard<St, S = Uuid> {
50 inner: Arc<Inner<St>>,
51 _subject: PhantomData<fn() -> S>,
52}
53
54struct Inner<St> {
55 store: St,
56 config: GuardConfig,
57 intel: Box<dyn IpIntel>,
58 clock: Box<dyn Clock>,
59 hook: Option<Hook>,
60 ua_cache: UaCache,
61 xff_warned: AtomicBool,
62 shadow_seen: Mutex<ShadowSeen>,
63}
64
65struct ShadowSeen {
67 seen: HashMap<(Uuid, EventKind), DateTime<Utc>>,
68 pruned_at: DateTime<Utc>,
69}
70
71impl<St, S> Clone for Guard<St, S> {
72 fn clone(&self) -> Self {
73 Self {
74 inner: Arc::clone(&self.inner),
75 _subject: PhantomData,
76 }
77 }
78}
79
80impl<St, S> std::fmt::Debug for Guard<St, S> {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 f.debug_struct("Guard")
83 .field("config", &self.inner.config)
84 .finish_non_exhaustive()
85 }
86}
87
88#[must_use]
90pub struct GuardBuilder<St, S = Uuid> {
91 store: St,
92 config: GuardConfig,
93 intel: Box<dyn IpIntel>,
94 clock: Box<dyn Clock>,
95 hook: Option<Hook>,
96 _subject: PhantomData<fn() -> S>,
97}
98
99impl<St: SessionStore> Guard<St> {
100 pub fn new(store: St) -> Self {
102 Self::builder(store, GuardConfig::default()).build()
103 }
104
105 pub fn builder(store: St, config: GuardConfig) -> GuardBuilder<St> {
108 GuardBuilder {
109 store,
110 config,
111 intel: Box::new(NoIpIntel),
112 clock: Box::new(SystemClock),
113 hook: None,
114 _subject: PhantomData,
115 }
116 }
117}
118
119impl<St: SessionStore, S: Subject> GuardBuilder<St, S> {
120 pub fn subject<T: Subject>(self) -> GuardBuilder<St, T> {
122 GuardBuilder {
123 store: self.store,
124 config: self.config,
125 intel: self.intel,
126 clock: self.clock,
127 hook: self.hook,
128 _subject: PhantomData,
129 }
130 }
131
132 pub fn ip_intel(mut self, intel: impl IpIntel) -> Self {
134 self.intel = Box::new(intel);
135 self
136 }
137
138 pub fn on_event(mut self, hook: impl Fn(&SessionEvent) + Send + Sync + 'static) -> Self {
151 self.hook = Some(Arc::new(hook));
152 self
153 }
154
155 pub fn clock(mut self, clock: impl Clock) -> Self {
157 self.clock = Box::new(clock);
158 self
159 }
160
161 pub fn build(self) -> Guard<St, S> {
163 Guard {
164 inner: Arc::new(Inner {
165 store: self.store,
166 config: self.config,
167 intel: self.intel,
168 clock: self.clock,
169 hook: self.hook,
170 ua_cache: UaCache::new(UA_CACHE_CAPACITY),
171 xff_warned: AtomicBool::new(false),
172 shadow_seen: Mutex::new(ShadowSeen {
173 seen: HashMap::new(),
174 pruned_at: DateTime::<Utc>::MIN_UTC,
175 }),
176 }),
177 _subject: PhantomData,
178 }
179 }
180}
181
182const UA_CACHE_CAPACITY: usize = 1024;
183
184const SHADOW_SEEN_CAPACITY: usize = 100_000;
186
187#[derive(Clone, Copy, Debug, PartialEq, Eq)]
189enum TokenState {
190 Current,
192 Grace,
194 Orphaned,
196 Reuse,
198}
199
200struct Resolved<S> {
202 lookup: crate::store::TokenLookup,
203 hash: TokenHash,
204 subject: S,
205 state: TokenState,
206}
207
208impl<St: SessionStore, S: Subject> Guard<St, S> {
209 pub async fn login(
219 &self,
220 subject: &S,
221 ctx: &ClientContext,
222 previous_token: Option<&str>,
223 ) -> Result<NewSession, GuardError> {
224 let inner = &*self.inner;
225 if let Some(hash) = previous_token.and_then(|t| self.presented_hash(t))
226 && let Some(old) = inner.store.find_by_token(&hash).await?
227 && inner.store.delete_session(old.session.id).await?
228 {
229 self.emit(old.session.id, &old.session.subject, EventKind::Logout, true, ctx, None)
230 .await;
231 }
232
233 let encoded = crate::subject::encode(subject)?;
234 let session_id = Uuid::new_v4();
235 let token = self.new_token(session_id)?;
236 let now = self.now();
237 let info = inner.intel.lookup(ctx.ip);
238 let fingerprint = Fingerprint::capture_cached(ctx, &info, &inner.ua_cache);
239 let client = ClientSummary::observed(info, fingerprint.clone());
240 let keep = inner.config.keep_origin_client;
241 let origin = SessionOrigin {
242 at: now,
243 ip_prefix: mask_ip(ctx.ip),
244 ip: keep.then_some(ctx.ip),
245 user_agent: keep.then(|| self.user_agent(ctx)).flatten(),
246 client: if keep {
247 client.clone()
248 } else {
249 client.clone().minimized()
250 },
251 };
252 let record = SessionRecord {
253 id: session_id,
254 subject: encoded,
255 created_at: now,
256 authenticated_at: now,
257 last_seen_at: now,
258 expires_at: now + inner.config.session_lifetime,
259 step_up_required: false,
260 baseline: Baseline::new(fingerprint),
261 origin: Some(origin),
262 };
263 inner.store.insert_session(&record, &token.hash()).await?;
264 self.emit_with(record.id, &record.subject, EventKind::Login, true, ctx, None, client)
265 .await;
266 Ok(NewSession {
267 session_id: record.id,
268 token: self.issue(&token, record.expires_at),
269 })
270 }
271
272 pub async fn authenticate(&self, token: &str, ctx: &ClientContext) -> Result<Outcome<S>, GuardError> {
281 self.authenticate_with(token, ctx, &self.inner.config.checks).await
282 }
283
284 pub async fn authenticate_with(
291 &self,
292 token: &str,
293 ctx: &ClientContext,
294 checks: &Checks,
295 ) -> Result<Outcome<S>, GuardError> {
296 self.authenticate_inner(token, ctx, checks).await
297 }
298
299 fn new_token(&self, session_id: Uuid) -> Result<SessionToken, GuardError> {
302 if self.inner.store.sharded() {
303 SessionToken::generate_in_bucket(crate::store::session_bucket(session_id))
304 } else {
305 SessionToken::generate()
306 }
307 }
308
309 fn plan_rotation(
311 &self,
312 session_id: Uuid,
313 state: TokenState,
314 issued_at: DateTime<Utc>,
315 hash: TokenHash,
316 now: DateTime<Utc>,
317 ) -> Result<Option<(Rotation, SessionToken)>, GuardError> {
318 let config = &self.inner.config;
319 let supersede = match state {
320 TokenState::Current if issued_at <= now - config.rotation_interval => Supersede::Exact(hash),
321 TokenState::Orphaned => Supersede::CurrentIssuedBefore(now - config.rotation_grace),
322 _ => return Ok(None),
323 };
324 let token = self.new_token(session_id)?;
325 let rotation = Rotation {
326 new_token: token.hash(),
327 supersede,
328 prune_superseded_before: now - config.reuse_detection,
329 };
330 Ok(Some((rotation, token)))
331 }
332
333 fn assess(
335 &self,
336 baseline: &Baseline,
337 fingerprint: &Fingerprint,
338 state: TokenState,
339 now: DateTime<Utc>,
340 checks: &Checks,
341 ) -> crate::risk::Assessment {
342 let window = self.inner.config.ping_pong_window;
343 let mut assessment = crate::risk::assess(baseline, fingerprint, now, window, checks);
344 if state == TokenState::Orphaned {
345 assessment.add(Check::OrphanedToken, checks);
346 }
347 assessment
348 }
349
350 async fn authenticate_inner(
351 &self,
352 token: &str,
353 ctx: &ClientContext,
354 checks: &Checks,
355 ) -> Result<Outcome<S>, GuardError> {
356 let inner = &*self.inner;
357 let config = &inner.config;
358 let now = self.now();
359 let resolved = match self.resolve(token, now).await? {
360 Ok(resolved) => resolved,
361 Err(rejection) => return Ok(Outcome::Rejected(rejection)),
362 };
363 let Resolved {
364 lookup,
365 hash,
366 subject,
367 state,
368 } = resolved;
369 let session = &lookup.session;
370
371 if state == TokenState::Reuse {
372 self.revoke_for_reuse(session, ctx).await?;
373 return Ok(Outcome::Rejected(Rejection::Revoked));
374 }
375
376 let fingerprint = self.fingerprint(ctx);
377 let assessment = self.assess(&session.baseline, &fingerprint, state, now, checks);
378 let decision = config.thresholds.decide(assessment.score);
379 crate::telemetry::assessed(decision, &assessment.signals);
380 let shadow = config.mode == Mode::Shadow;
381 let step_up = decision == Decision::StepUp;
382
383 if decision == Decision::Revoke || (step_up && config.step_up_policy == StepUpPolicy::Revoke) {
384 self.revoke_for_risk(session, ctx, &assessment, !shadow).await?;
385 if !shadow {
386 return Ok(Outcome::Rejected(Rejection::Revoked));
387 }
388 } else if session.step_up_required && !shadow {
389 return Ok(Outcome::Rejected(Rejection::StepUpRequired));
390 } else if step_up {
391 let enforced = config.step_up_policy == StepUpPolicy::Require && !shadow;
392 if enforced {
393 let mut update = SessionUpdate::touch(session.id, now);
394 update.step_up_required = Some(true);
395 update.mark_used =
396 (state == TokenState::Current && lookup.token.first_used_at.is_none()).then_some(hash);
397 inner.store.apply(&update).await?;
398 }
399 self.emit(
402 session.id,
403 &session.subject,
404 EventKind::StepUpRequired,
405 enforced,
406 ctx,
407 Some(&assessment),
408 )
409 .await;
410 if enforced {
411 return Ok(Outcome::Rejected(Rejection::StepUpRequired));
412 }
413 }
414
415 let mut update = SessionUpdate::touch(session.id, now);
416 if decision == Decision::Allow {
417 let mut baseline = session.baseline.clone();
418 if baseline.absorb(&fingerprint, now, checks) {
419 update.baseline = Some(baseline);
420 }
421 }
422
423 let new_token = match self.plan_rotation(lookup.session.id, state, lookup.token.issued_at, hash, now)? {
424 Some((rotation, token)) => {
425 update.rotation = Some(rotation);
426 Some(token)
427 }
428 None => None,
429 };
430 update.mark_used = match (state, &update.rotation) {
434 (TokenState::Current, _) if lookup.token.first_used_at.is_none() => Some(hash),
435 (TokenState::Orphaned, Some(rotation)) => Some(rotation.new_token),
436 _ => None,
437 };
438
439 let stale = now - session.last_seen_at >= config.last_seen_write_interval;
440 let mut rotated = None;
441 if update.rotation.is_some() || update.mark_used.is_some() || update.baseline.is_some() || stale {
442 let did_rotate = inner.store.apply(&update).await?;
443 if did_rotate && let Some(token) = &new_token {
444 rotated = Some(self.issue(token, session.expires_at));
445 if state == TokenState::Orphaned {
446 self.emit(session.id, &session.subject, EventKind::TokenReissued, true, ctx, None)
447 .await;
448 }
449 }
450 }
451
452 Ok(Outcome::Authenticated(Authenticated {
453 session: AuthSession::new(session, subject),
454 rotated,
455 assessment,
456 }))
457 }
458
459 pub async fn peek(&self, token: &str) -> Result<Option<SessionInfo<S>>, GuardError> {
467 Ok(match self.resolve(token, self.now()).await? {
468 Ok(resolved) if matches!(resolved.state, TokenState::Current | TokenState::Grace) => {
469 SessionInfo::from_record(&resolved.lookup.session)
470 }
471 _ => None,
472 })
473 }
474
475 pub async fn complete_step_up(
486 &self,
487 token: &str,
488 ctx: &ClientContext,
489 verified_subject: &S,
490 ) -> Result<StepUpOutcome, GuardError> {
491 let inner = &*self.inner;
492 let now = self.now();
493 let resolved = match self.resolve(token, now).await? {
494 Ok(resolved) => resolved,
495 Err(rejection) => return Ok(StepUpOutcome::Rejected(rejection)),
496 };
497 let session = &resolved.lookup.session;
498 if resolved.state == TokenState::Reuse {
499 self.revoke_for_reuse(session, ctx).await?;
500 return Ok(StepUpOutcome::Rejected(Rejection::Revoked));
501 }
502 if resolved.subject != *verified_subject {
503 return Ok(StepUpOutcome::SubjectMismatch);
504 }
505
506 let token = self.new_token(session.id)?;
507 let mut update = SessionUpdate::touch(session.id, now);
508 update.baseline = Some(Baseline::new(self.fingerprint(ctx)));
509 update.step_up_required = Some(false);
510 update.authenticated_at = Some(now);
511 let supersede = match resolved.state {
513 TokenState::Current => Supersede::Exact(resolved.hash),
514 _ => Supersede::Current,
515 };
516 let rotation = Rotation {
517 new_token: token.hash(),
518 supersede,
519 prune_superseded_before: now - inner.config.reuse_detection,
520 };
521 update.rotation = Some(rotation);
522 if inner.store.apply(&update).await? {
523 self.emit(
524 session.id,
525 &session.subject,
526 EventKind::StepUpCompleted,
527 true,
528 ctx,
529 None,
530 )
531 .await;
532 } else {
533 update.rotation = Some(Rotation {
536 supersede: Supersede::Current,
537 ..rotation
538 });
539 if !inner.store.apply(&update).await? {
540 return Ok(StepUpOutcome::Rejected(Rejection::Revoked));
541 }
542 }
543 Ok(StepUpOutcome::Completed(self.issue(&token, session.expires_at)))
544 }
545
546 pub async fn logout(&self, token: &str, ctx: &ClientContext) -> Result<bool, GuardError> {
552 let inner = &*self.inner;
553 let Some(hash) = self.presented_hash(token) else {
554 return Ok(false);
555 };
556 let Some(lookup) = inner.store.find_by_token(&hash).await? else {
557 return Ok(false);
558 };
559 let deleted = inner.store.delete_session(lookup.session.id).await?;
560 if deleted {
561 self.emit(
562 lookup.session.id,
563 &lookup.session.subject,
564 EventKind::Logout,
565 true,
566 ctx,
567 None,
568 )
569 .await;
570 }
571 Ok(deleted)
572 }
573
574 pub async fn revoke_session(&self, subject: &S, session_id: Uuid, ctx: &ClientContext) -> Result<bool, GuardError> {
582 let encoded = crate::subject::encode(subject)?;
583 let sessions = self.inner.store.list_subject_sessions(&encoded).await?;
584 if !sessions.iter().any(|s| s.id == session_id) {
585 return Ok(false);
586 }
587 let deleted = self.inner.store.delete_session(session_id).await?;
588 if deleted {
589 self.emit(session_id, &encoded, EventKind::Revoked, true, ctx, None)
590 .await;
591 }
592 Ok(deleted)
593 }
594
595 pub async fn revoke_all_for_subject(&self, subject: &S, ctx: &ClientContext) -> Result<u64, GuardError> {
602 let encoded = crate::subject::encode(subject)?;
603 let deleted = self.inner.store.delete_subject_sessions(&encoded).await?;
604 for &session_id in &deleted {
605 self.emit(session_id, &encoded, EventKind::Revoked, true, ctx, None)
606 .await;
607 }
608 Ok(deleted.len() as u64)
609 }
610
611 pub async fn list_sessions(&self, subject: &S) -> Result<Vec<SessionInfo<S>>, GuardError> {
617 let encoded = crate::subject::encode(subject)?;
618 let now = self.now();
619 let records = self.inner.store.list_subject_sessions(&encoded).await?;
620 Ok(records
621 .iter()
622 .filter(|r| self.is_live(r, now))
623 .filter_map(SessionInfo::from_record)
624 .collect())
625 }
626
627 pub async fn purge_expired(&self) -> Result<PurgeReport, GuardError> {
634 let config = &self.inner.config;
635 let now = self.now();
636 let sessions = self.inner.store.delete_expired(now, now - config.idle_timeout).await?;
637 let events = self
638 .inner
639 .store
640 .delete_events_before(now - config.event_retention)
641 .await?;
642 Ok(PurgeReport { sessions, events })
643 }
644
645 #[cfg(feature = "tokio")]
653 #[cfg_attr(docsrs, doc(cfg(feature = "tokio")))]
654 pub fn spawn_purge(&self, every: Duration) -> tokio::task::JoinHandle<()> {
655 let guard = self.clone();
656 tokio::spawn(async move {
657 let mut ticker = tokio::time::interval(every);
658 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
659 loop {
660 ticker.tick().await;
661 match guard.purge_expired().await {
662 Ok(report) => tracing::info!(
663 sessions = report.sessions,
664 events = report.events,
665 "purged expired sessions"
666 ),
667 Err(error) => tracing::warn!(%error, "purging expired sessions failed"),
668 }
669 }
670 })
671 }
672
673 pub fn client_context<H: HeaderSource + ?Sized>(&self, peer: IpAddr, headers: &H) -> ClientContext {
679 let config = &self.inner.config;
680 let forwarded: Vec<&str> = headers
683 .header_values("x-forwarded-for")
684 .map(|v| std::str::from_utf8(v).unwrap_or(""))
685 .collect();
686 if config.trusted_proxies.is_empty()
687 && !forwarded.is_empty()
688 && !self.inner.xff_warned.swap(true, Ordering::Relaxed)
689 {
690 tracing::warn!(
691 "X-Forwarded-For received but no trusted proxies are configured; using the peer address. \
692 Configure trusted_proxies if the app runs behind a reverse proxy."
693 );
694 }
695 let ip = resolve_client_ip(peer, forwarded, &config.trusted_proxies);
696 ClientContext::from_headers(
697 ip,
698 headers,
699 config.tls_fingerprint_header.as_deref(),
700 config.max_header_len,
701 )
702 }
703
704 pub fn cookie_token<'a, H: HeaderSource + ?Sized>(&self, headers: &'a H) -> Option<&'a str> {
706 headers
707 .header_values("cookie")
708 .filter_map(|v| std::str::from_utf8(v).ok())
709 .find_map(|v| self.inner.config.cookie.find_in(v))
710 }
711
712 pub fn clear_cookie(&self) -> String {
714 self.inner.config.cookie.clear_cookie()
715 }
716
717 #[cfg(feature = "aide")]
718 pub(crate) fn cookie_name(&self) -> &str {
719 self.inner.config.cookie.cookie_name()
720 }
721
722 pub fn requires_reauth(&self, session: &AuthSession<S>, max_age: Duration) -> bool {
728 self.now() - session.authenticated_at > td(max_age)
729 }
730
731 pub fn is_session_token(&self, value: &str) -> bool {
734 self.presented_hash(value).is_some()
735 }
736
737 fn now(&self) -> DateTime<Utc> {
738 self.inner.clock.now()
739 }
740
741 fn fingerprint(&self, ctx: &ClientContext) -> Fingerprint {
742 Fingerprint::capture_cached(ctx, &self.inner.intel.lookup(ctx.ip), &self.inner.ua_cache)
743 }
744
745 fn user_agent(&self, ctx: &ClientContext) -> Option<String> {
747 ctx.user_agent
748 .as_deref()
749 .map(|ua| crate::client::truncate(ua, self.inner.config.max_header_len).to_owned())
750 }
751
752 fn client_summary(&self, ctx: &ClientContext) -> ClientSummary {
753 let info = self.inner.intel.lookup(ctx.ip);
754 let fingerprint = Fingerprint::capture_cached(ctx, &info, &self.inner.ua_cache);
755 ClientSummary::observed(info, fingerprint)
756 }
757
758 fn presented_hash(&self, presented: &str) -> Option<TokenHash> {
759 let bare = match &self.inner.config.token_prefix {
760 Some(prefix) => presented.strip_prefix(prefix.as_str())?,
761 None => presented,
762 };
763 TokenHash::from_presented(bare)
764 }
765
766 fn issue(&self, token: &SessionToken, expires_at: DateTime<Utc>) -> IssuedToken {
767 let prefix = self.inner.config.token_prefix.as_deref().unwrap_or("");
770 let mut value = String::with_capacity(prefix.len() + token.expose().len());
771 value.push_str(prefix);
772 value.push_str(token.expose());
773 let max_age = (expires_at - self.now()).num_seconds();
774 let set_cookie = Zeroizing::new(self.inner.config.cookie.set_cookie(&value, max_age));
775 IssuedToken {
776 value: SecretString::from(value),
777 expires_at,
778 set_cookie,
779 }
780 }
781
782 fn is_live(&self, record: &SessionRecord, now: DateTime<Utc>) -> bool {
783 now < record.expires_at && now - record.last_seen_at < self.inner.config.idle_timeout
784 }
785
786 fn classify(&self, lookup: &crate::store::TokenLookup, now: DateTime<Utc>) -> TokenState {
787 match lookup.token.superseded_at {
788 None => TokenState::Current,
789 Some(at) if now - at <= self.inner.config.rotation_grace => TokenState::Grace,
790 Some(_) if !lookup.newer_token_used => TokenState::Orphaned,
791 Some(_) => TokenState::Reuse,
792 }
793 }
794
795 fn orphan_too_old(&self, lookup: &crate::store::TokenLookup, now: DateTime<Utc>) -> bool {
797 match (self.inner.config.orphan_reissue_window, lookup.token.superseded_at) {
798 (Some(window), Some(at)) => now - at > window,
799 _ => false,
800 }
801 }
802
803 async fn resolve(&self, token: &str, now: DateTime<Utc>) -> Result<Result<Resolved<S>, Rejection>, GuardError> {
805 let store = &self.inner.store;
806 let Some(hash) = self.presented_hash(token) else {
807 return Ok(Err(Rejection::NotAuthenticated));
808 };
809 let Some(lookup) = store.find_by_token(&hash).await? else {
810 return Ok(Err(Rejection::NotAuthenticated));
811 };
812 if !self.is_live(&lookup.session, now) {
813 store.delete_session(lookup.session.id).await?;
814 return Ok(Err(Rejection::Expired));
815 }
816 let Some(subject) = crate::subject::decode::<S>(&lookup.session.subject) else {
817 tracing::warn!(session_id = %lookup.session.id, "stored subject does not decode as the guard's subject type");
818 return Ok(Err(Rejection::NotAuthenticated));
819 };
820 let state = self.classify(&lookup, now);
821 if state == TokenState::Orphaned && self.orphan_too_old(&lookup, now) {
822 return Ok(Err(Rejection::NotAuthenticated));
824 }
825 Ok(Ok(Resolved {
826 lookup,
827 hash,
828 subject,
829 state,
830 }))
831 }
832
833 async fn revoke_for_risk(
836 &self,
837 session: &SessionRecord,
838 ctx: &ClientContext,
839 assessment: &crate::risk::Assessment,
840 enforced: bool,
841 ) -> Result<(), GuardError> {
842 if !enforced || self.inner.store.delete_session(session.id).await? {
843 self.emit(
844 session.id,
845 &session.subject,
846 EventKind::RiskRevoked,
847 enforced,
848 ctx,
849 Some(assessment),
850 )
851 .await;
852 }
853 Ok(())
854 }
855
856 async fn revoke_for_reuse(&self, session: &SessionRecord, ctx: &ClientContext) -> Result<(), GuardError> {
858 if self.inner.store.delete_session(session.id).await? {
859 self.emit(session.id, &session.subject, EventKind::TokenReuse, true, ctx, None)
860 .await;
861 }
862 Ok(())
863 }
864
865 async fn emit(
870 &self,
871 session_id: Uuid,
872 subject: &str,
873 kind: EventKind,
874 enforced: bool,
875 ctx: &ClientContext,
876 assessment: Option<&crate::risk::Assessment>,
877 ) {
878 let client = self.client_summary(ctx);
879 self.emit_with(session_id, subject, kind, enforced, ctx, assessment, client)
880 .await;
881 }
882
883 #[allow(clippy::too_many_arguments)]
885 async fn emit_with(
886 &self,
887 session_id: Uuid,
888 subject: &str,
889 kind: EventKind,
890 enforced: bool,
891 ctx: &ClientContext,
892 assessment: Option<&crate::risk::Assessment>,
893 client: ClientSummary,
894 ) {
895 let inner = &*self.inner;
896 let config = &inner.config;
897 let now = self.now();
898 if !enforced {
899 if self.seen_recently(session_id, kind, now) {
900 return;
901 }
902 match inner
903 .store
904 .claim_event(ClaimScope::Session(session_id), kind, now, config.shadow_dedup_window)
905 .await
906 {
907 Ok(true) => {}
908 Ok(false) => return,
909 Err(error) => {
911 tracing::error!(%session_id, kind = kind.as_str(), %error, "failed to claim session event");
912 }
913 }
914 }
915 let alert = if inner.hook.is_some()
916 && enforced
917 && kind.is_security_relevant()
918 && !config.alert_dedup_window.is_zero()
919 {
920 match inner
921 .store
922 .claim_event(ClaimScope::Subject(subject), kind, now, config.alert_dedup_window)
923 .await
924 {
925 Ok(claimed) => claimed,
926 Err(error) => {
928 tracing::error!(%session_id, kind = kind.as_str(), %error, "failed to claim subject alert");
929 true
930 }
931 }
932 } else {
933 true
934 };
935
936 let event = SessionEvent {
937 session_id,
938 subject: subject.to_owned(),
939 kind,
940 enforced,
941 score: assessment.map(|a| a.score),
942 signals: assessment.map(|a| a.signals.clone()).unwrap_or_default(),
943 ip: ctx.ip,
944 user_agent: self.user_agent(ctx),
945 client,
946 at: now,
947 };
948 if kind.is_security_relevant() {
949 tracing::warn!(%session_id, kind = kind.as_str(), enforced, score = ?event.score, "session event");
950 } else {
951 tracing::debug!(%session_id, kind = kind.as_str(), "session event");
952 }
953 let stored = if config.minimize_stored_events {
954 Cow::Owned(SessionEvent {
955 ip: mask_ip(event.ip),
956 user_agent: None,
957 client: event.client.clone().minimized(),
958 ..event.clone()
959 })
960 } else {
961 Cow::Borrowed(&event)
962 };
963 if let Err(error) = inner.store.record_event(&stored).await {
964 tracing::error!(%session_id, kind = kind.as_str(), %error, "failed to record session event");
965 }
966 if alert
967 && let Some(hook) = &inner.hook
968 && catch_unwind(AssertUnwindSafe(|| hook(&event))).is_err()
969 {
970 tracing::error!(%session_id, kind = kind.as_str(), "on_event hook panicked");
971 }
972 crate::telemetry::event(kind, enforced);
973 }
974
975 fn seen_recently(&self, session_id: Uuid, kind: EventKind, now: DateTime<Utc>) -> bool {
979 let window = self.inner.config.shadow_dedup_window;
980 let mut state = self.inner.shadow_seen.lock().unwrap_or_else(PoisonError::into_inner);
981 if now - state.pruned_at >= window || state.seen.len() >= SHADOW_SEEN_CAPACITY {
982 state.seen.retain(|_, at| now - *at < window);
983 state.pruned_at = now;
984 if state.seen.len() >= SHADOW_SEEN_CAPACITY {
985 state.seen.clear();
987 }
988 }
989 match state.seen.get(&(session_id, kind)) {
990 Some(at) if now - *at < window => true,
991 _ => {
992 state.seen.insert((session_id, kind), now);
993 false
994 }
995 }
996 }
997}
998
999fn td(duration: Duration) -> TimeDelta {
1000 TimeDelta::from_std(duration).unwrap_or(TimeDelta::MAX)
1001}