Skip to main content

session_guard/
event.rs

1//! Security events: what happened to a session, recorded in the store and passed to the hook.
2
3use std::{fmt, net::IpAddr};
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use crate::{
10    checks::Check,
11    fingerprint::Fingerprint,
12    intel::{Anonymity, IpInfo},
13    subject::Subject,
14};
15
16/// What happened.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19#[non_exhaustive]
20pub enum EventKind {
21    /// A session was created.
22    Login,
23    /// The client ended its session.
24    Logout,
25    /// The risk score reached the step-up threshold.
26    StepUpRequired,
27    /// The subject re-verified and the session was trusted again.
28    StepUpCompleted,
29    /// The risk score reached the revoke threshold (or step-up under
30    /// [`StepUpPolicy::Revoke`](crate::StepUpPolicy::Revoke)).
31    RiskRevoked,
32    /// A superseded token was presented after its successor was used: two parties hold the
33    /// session, so it was revoked. Always enforced, also in shadow mode.
34    TokenReuse,
35    /// A token whose successor never reached the client was replaced (lost response).
36    TokenReissued,
37    /// The app ended the session ([`Guard::revoke_session`](crate::Guard::revoke_session) or
38    /// [`Guard::revoke_all_for_subject`](crate::Guard::revoke_all_for_subject)). The event's
39    /// client is whoever asked, not the session's.
40    Revoked,
41}
42
43impl EventKind {
44    /// Every kind, in declaration order.
45    pub const ALL: [EventKind; 8] = [
46        EventKind::Login,
47        EventKind::Logout,
48        EventKind::StepUpRequired,
49        EventKind::StepUpCompleted,
50        EventKind::RiskRevoked,
51        EventKind::TokenReuse,
52        EventKind::TokenReissued,
53        EventKind::Revoked,
54    ];
55
56    /// Stable `snake_case` name, as stored.
57    pub const fn as_str(self) -> &'static str {
58        match self {
59            EventKind::Login => "login",
60            EventKind::Logout => "logout",
61            EventKind::StepUpRequired => "step_up_required",
62            EventKind::StepUpCompleted => "step_up_completed",
63            EventKind::RiskRevoked => "risk_revoked",
64            EventKind::TokenReuse => "token_reuse",
65            EventKind::TokenReissued => "token_reissued",
66            EventKind::Revoked => "revoked",
67        }
68    }
69
70    /// Parses [`as_str`](Self::as_str) output.
71    pub fn parse(name: &str) -> Option<Self> {
72        Self::ALL.into_iter().find(|k| k.as_str() == name)
73    }
74
75    /// Whether this kind points at a possible attack (logged as a warning).
76    ///
77    /// [`TokenReissued`](Self::TokenReissued) is not one: a lost response is routine, and the
78    /// attack signal is [`TokenReuse`](Self::TokenReuse).
79    pub const fn is_security_relevant(self) -> bool {
80        matches!(
81            self,
82            EventKind::StepUpRequired | EventKind::RiskRevoked | EventKind::TokenReuse
83        )
84    }
85}
86
87impl fmt::Display for EventKind {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        f.write_str(self.as_str())
90    }
91}
92
93/// One security event.
94///
95/// The subject is kept in its stored form (canonical JSON, see [`Subject`]); use
96/// [`subject`](Self::subject) to get it back as your type.
97///
98/// The [`on_event`](crate::GuardBuilder::on_event) hook gets every client detail, for alerts.
99/// The copy written to the store leaves out the precise ones unless
100/// [`minimize_stored_events`](crate::GuardConfigBuilder::minimize_stored_events) is turned off.
101#[derive(Clone, Debug, PartialEq, Eq)]
102#[non_exhaustive]
103pub struct SessionEvent {
104    /// The session concerned.
105    pub session_id: Uuid,
106    /// The session's subject, as stored.
107    pub subject: String,
108    /// What happened.
109    pub kind: EventKind,
110    /// `false` when the decision was only recorded (shadow mode, or
111    /// [`StepUpPolicy::Allow`](crate::StepUpPolicy::Allow)).
112    pub enforced: bool,
113    /// Risk score, for risk decisions.
114    pub score: Option<u32>,
115    /// Signals that fired, for risk decisions.
116    pub signals: Vec<Check>,
117    /// Client IP. The stored copy keeps only its /24 or /48 network address by default.
118    pub ip: IpAddr,
119    /// Client User-Agent, truncated to the header cap. Not stored by default.
120    pub user_agent: Option<String>,
121    /// Where the request came from and what sent it. The stored copy leaves out the city by
122    /// default.
123    pub client: ClientSummary,
124    /// When it happened.
125    pub at: DateTime<Utc>,
126}
127
128impl SessionEvent {
129    /// The subject as `S`, or `None` if the stored value does not parse as `S`.
130    pub fn subject<S: Subject>(&self) -> Option<S> {
131        crate::subject::decode(&self.subject)
132    }
133}
134
135/// What the guard knew about the client of an event: [`IpIntel`](crate::IpIntel) data and the
136/// parsed User-Agent. Each field is `None` when its source has no answer.
137#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(default)]
139#[non_exhaustive]
140pub struct ClientSummary {
141    /// ISO 3166-1 alpha-2 country code.
142    pub country: Option<String>,
143    /// City name.
144    pub city: Option<String>,
145    /// Autonomous system number.
146    pub asn: Option<u32>,
147    /// Name of the organization behind the autonomous system.
148    pub network: Option<String>,
149    /// Anonymizer the network belongs to.
150    pub anonymity: Option<Anonymity>,
151    /// Browser family.
152    pub browser: Option<String>,
153    /// Operating system family.
154    pub os: Option<String>,
155}
156
157impl ClientSummary {
158    pub(crate) fn observed(info: IpInfo, fingerprint: Fingerprint) -> Self {
159        Self {
160            country: fingerprint.country,
161            city: info.city.filter(|c| !c.is_empty()),
162            asn: fingerprint.asn,
163            network: info.network.filter(|n| !n.is_empty()),
164            anonymity: fingerprint.anonymity,
165            browser: fingerprint.browser,
166            os: fingerprint.os,
167        }
168    }
169
170    /// The summary as kept in the store under `minimize_stored_events`: the city could undo
171    /// the IP masking.
172    pub(crate) fn minimized(mut self) -> Self {
173        self.city = None;
174        self
175    }
176}
177
178/// Where a session started, recorded at login. The precise parts (`ip`, `user_agent`, the
179/// city) are kept only with
180/// [`keep_origin_client`](crate::GuardConfigBuilder::keep_origin_client).
181#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
182#[non_exhaustive]
183pub struct SessionOrigin {
184    /// Login time.
185    pub at: DateTime<Utc>,
186    /// The client IP's /24 (IPv4) or /48 (IPv6) network address.
187    pub ip_prefix: IpAddr,
188    /// Client IP. Only with `keep_origin_client`.
189    #[serde(default)]
190    pub ip: Option<IpAddr>,
191    /// Client User-Agent, truncated to the header cap. Only with `keep_origin_client`.
192    #[serde(default)]
193    pub user_agent: Option<String>,
194    /// Where the login came from and what sent it. No city unless `keep_origin_client`.
195    #[serde(default)]
196    pub client: ClientSummary,
197}
198
199/// Reduces an address to its /24 (IPv4) or /48 (IPv6) network address.
200pub(crate) fn mask_ip(ip: IpAddr) -> IpAddr {
201    let len = if ip.is_ipv4() { 24 } else { 48 };
202    ipnet::IpNet::new(ip, len).map_or(ip, |net| net.trunc().addr())
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn names_round_trip_and_match_serde() {
211        for kind in EventKind::ALL {
212            assert_eq!(EventKind::parse(kind.as_str()), Some(kind));
213            assert_eq!(serde_json::to_string(&kind).unwrap(), format!("\"{kind}\""));
214        }
215        assert_eq!(EventKind::parse("nope"), None);
216    }
217
218    #[test]
219    fn client_summary_reads_missing_fields_as_none() {
220        let client: ClientSummary = serde_json::from_str(r#"{"country":"BE","later":1}"#).unwrap();
221        assert_eq!(client.country.as_deref(), Some("BE"));
222        assert_eq!(client.city, None);
223        let all = ClientSummary {
224            country: Some("BE".into()),
225            city: Some("Ghent".into()),
226            asn: Some(64500),
227            network: Some("Example Net".into()),
228            anonymity: Some(Anonymity::Vpn),
229            browser: Some("Firefox".into()),
230            os: Some("Linux".into()),
231        };
232        let json = serde_json::to_string(&all).unwrap();
233        assert_eq!(serde_json::from_str::<ClientSummary>(&json).unwrap(), all);
234    }
235
236    #[test]
237    fn session_origin_reads_missing_fields_as_none() {
238        let origin: SessionOrigin =
239            serde_json::from_str(r#"{"at":"2026-03-01T12:00:00Z","ip_prefix":"203.0.113.0"}"#).unwrap();
240        assert_eq!(origin.ip_prefix, "203.0.113.0".parse::<IpAddr>().unwrap());
241        assert_eq!(origin.ip, None);
242        assert_eq!(origin.user_agent, None);
243        assert_eq!(origin.client, ClientSummary::default());
244        let full = SessionOrigin {
245            ip: Some("203.0.113.7".parse().unwrap()),
246            user_agent: Some("Firefox".into()),
247            client: ClientSummary {
248                city: Some("Ghent".into()),
249                ..ClientSummary::default()
250            },
251            ..origin
252        };
253        let json = serde_json::to_string(&full).unwrap();
254        assert_eq!(serde_json::from_str::<SessionOrigin>(&json).unwrap(), full);
255    }
256
257    #[test]
258    fn mask_ip_keeps_the_network() {
259        assert_eq!(
260            mask_ip("203.0.113.7".parse().unwrap()),
261            "203.0.113.0".parse::<IpAddr>().unwrap()
262        );
263        assert_eq!(
264            mask_ip("2001:db8:1:2::7".parse().unwrap()),
265            "2001:db8:1::".parse::<IpAddr>().unwrap()
266        );
267    }
268
269    #[test]
270    fn subject_decodes() {
271        let id = Uuid::new_v4();
272        let event = SessionEvent {
273            session_id: Uuid::new_v4(),
274            subject: crate::subject::encode(&id).unwrap(),
275            kind: EventKind::Login,
276            enforced: true,
277            score: None,
278            signals: vec![],
279            ip: "203.0.113.7".parse().unwrap(),
280            user_agent: None,
281            client: ClientSummary::default(),
282            at: Utc::now(),
283        };
284        assert_eq!(event.subject::<Uuid>(), Some(id));
285        assert_eq!(event.subject::<i64>(), None);
286    }
287}