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    pub const fn is_security_relevant(self) -> bool {
77        matches!(
78            self,
79            EventKind::StepUpRequired | EventKind::RiskRevoked | EventKind::TokenReuse | EventKind::TokenReissued
80        )
81    }
82}
83
84impl fmt::Display for EventKind {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.write_str(self.as_str())
87    }
88}
89
90/// One security event.
91///
92/// The subject is kept in its stored form (canonical JSON, see [`Subject`]); use
93/// [`subject`](Self::subject) to get it back as your type.
94///
95/// The [`on_event`](crate::GuardBuilder::on_event) hook gets every client detail, for alerts.
96/// The copy written to the store leaves out the precise ones unless
97/// [`minimize_stored_events`](crate::GuardConfigBuilder::minimize_stored_events) is turned off.
98#[derive(Clone, Debug, PartialEq, Eq)]
99#[non_exhaustive]
100pub struct SessionEvent {
101    /// The session concerned.
102    pub session_id: Uuid,
103    /// The session's subject, as stored.
104    pub subject: String,
105    /// What happened.
106    pub kind: EventKind,
107    /// `false` when the decision was only recorded (shadow mode, or
108    /// [`StepUpPolicy::Allow`](crate::StepUpPolicy::Allow)).
109    pub enforced: bool,
110    /// Risk score, for risk decisions.
111    pub score: Option<u32>,
112    /// Signals that fired, for risk decisions.
113    pub signals: Vec<Check>,
114    /// Client IP. The stored copy keeps only its /24 or /48 network address by default.
115    pub ip: IpAddr,
116    /// Client User-Agent, truncated to the header cap. Not stored by default.
117    pub user_agent: Option<String>,
118    /// Where the request came from and what sent it. The stored copy leaves out the city by
119    /// default.
120    pub client: ClientSummary,
121    /// When it happened.
122    pub at: DateTime<Utc>,
123}
124
125impl SessionEvent {
126    /// The subject as `S`, or `None` if the stored value does not parse as `S`.
127    pub fn subject<S: Subject>(&self) -> Option<S> {
128        crate::subject::decode(&self.subject)
129    }
130}
131
132/// What the guard knew about the client of an event: [`IpIntel`](crate::IpIntel) data and the
133/// parsed User-Agent. Each field is `None` when its source has no answer.
134#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(default)]
136#[non_exhaustive]
137pub struct ClientSummary {
138    /// ISO 3166-1 alpha-2 country code.
139    pub country: Option<String>,
140    /// City name.
141    pub city: Option<String>,
142    /// Autonomous system number.
143    pub asn: Option<u32>,
144    /// Name of the organization behind the autonomous system.
145    pub network: Option<String>,
146    /// Anonymizer the network belongs to.
147    pub anonymity: Option<Anonymity>,
148    /// Browser family.
149    pub browser: Option<String>,
150    /// Operating system family.
151    pub os: Option<String>,
152}
153
154impl ClientSummary {
155    pub(crate) fn observed(info: IpInfo, fingerprint: Fingerprint) -> Self {
156        Self {
157            country: fingerprint.country,
158            city: info.city.filter(|c| !c.is_empty()),
159            asn: fingerprint.asn,
160            network: info.network.filter(|n| !n.is_empty()),
161            anonymity: fingerprint.anonymity,
162            browser: fingerprint.browser,
163            os: fingerprint.os,
164        }
165    }
166
167    /// The summary as kept in the store under `minimize_stored_events`: the city could undo
168    /// the IP masking.
169    pub(crate) fn minimized(mut self) -> Self {
170        self.city = None;
171        self
172    }
173}
174
175/// Where a session started, recorded at login. The precise parts (`ip`, `user_agent`, the
176/// city) are kept only with
177/// [`keep_origin_client`](crate::GuardConfigBuilder::keep_origin_client).
178#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
179#[non_exhaustive]
180pub struct SessionOrigin {
181    /// Login time.
182    pub at: DateTime<Utc>,
183    /// The client IP's /24 (IPv4) or /48 (IPv6) network address.
184    pub ip_prefix: IpAddr,
185    /// Client IP. Only with `keep_origin_client`.
186    #[serde(default)]
187    pub ip: Option<IpAddr>,
188    /// Client User-Agent, truncated to the header cap. Only with `keep_origin_client`.
189    #[serde(default)]
190    pub user_agent: Option<String>,
191    /// Where the login came from and what sent it. No city unless `keep_origin_client`.
192    #[serde(default)]
193    pub client: ClientSummary,
194}
195
196/// Reduces an address to its /24 (IPv4) or /48 (IPv6) network address.
197pub(crate) fn mask_ip(ip: IpAddr) -> IpAddr {
198    let len = if ip.is_ipv4() { 24 } else { 48 };
199    ipnet::IpNet::new(ip, len).map_or(ip, |net| net.trunc().addr())
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn names_round_trip_and_match_serde() {
208        for kind in EventKind::ALL {
209            assert_eq!(EventKind::parse(kind.as_str()), Some(kind));
210            assert_eq!(serde_json::to_string(&kind).unwrap(), format!("\"{kind}\""));
211        }
212        assert_eq!(EventKind::parse("nope"), None);
213    }
214
215    #[test]
216    fn client_summary_reads_missing_fields_as_none() {
217        let client: ClientSummary = serde_json::from_str(r#"{"country":"BE","later":1}"#).unwrap();
218        assert_eq!(client.country.as_deref(), Some("BE"));
219        assert_eq!(client.city, None);
220        let all = ClientSummary {
221            country: Some("BE".into()),
222            city: Some("Ghent".into()),
223            asn: Some(64500),
224            network: Some("Example Net".into()),
225            anonymity: Some(Anonymity::Vpn),
226            browser: Some("Firefox".into()),
227            os: Some("Linux".into()),
228        };
229        let json = serde_json::to_string(&all).unwrap();
230        assert_eq!(serde_json::from_str::<ClientSummary>(&json).unwrap(), all);
231    }
232
233    #[test]
234    fn session_origin_reads_missing_fields_as_none() {
235        let origin: SessionOrigin =
236            serde_json::from_str(r#"{"at":"2026-03-01T12:00:00Z","ip_prefix":"203.0.113.0"}"#).unwrap();
237        assert_eq!(origin.ip_prefix, "203.0.113.0".parse::<IpAddr>().unwrap());
238        assert_eq!(origin.ip, None);
239        assert_eq!(origin.user_agent, None);
240        assert_eq!(origin.client, ClientSummary::default());
241        let full = SessionOrigin {
242            ip: Some("203.0.113.7".parse().unwrap()),
243            user_agent: Some("Firefox".into()),
244            client: ClientSummary {
245                city: Some("Ghent".into()),
246                ..ClientSummary::default()
247            },
248            ..origin
249        };
250        let json = serde_json::to_string(&full).unwrap();
251        assert_eq!(serde_json::from_str::<SessionOrigin>(&json).unwrap(), full);
252    }
253
254    #[test]
255    fn mask_ip_keeps_the_network() {
256        assert_eq!(
257            mask_ip("203.0.113.7".parse().unwrap()),
258            "203.0.113.0".parse::<IpAddr>().unwrap()
259        );
260        assert_eq!(
261            mask_ip("2001:db8:1:2::7".parse().unwrap()),
262            "2001:db8:1::".parse::<IpAddr>().unwrap()
263        );
264    }
265
266    #[test]
267    fn subject_decodes() {
268        let id = Uuid::new_v4();
269        let event = SessionEvent {
270            session_id: Uuid::new_v4(),
271            subject: crate::subject::encode(&id).unwrap(),
272            kind: EventKind::Login,
273            enforced: true,
274            score: None,
275            signals: vec![],
276            ip: "203.0.113.7".parse().unwrap(),
277            user_agent: None,
278            client: ClientSummary::default(),
279            at: Utc::now(),
280        };
281        assert_eq!(event.subject::<Uuid>(), Some(id));
282        assert_eq!(event.subject::<i64>(), None);
283    }
284}