1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19#[non_exhaustive]
20pub enum EventKind {
21 Login,
23 Logout,
25 StepUpRequired,
27 StepUpCompleted,
29 RiskRevoked,
32 TokenReuse,
35 TokenReissued,
37 Revoked,
41}
42
43impl EventKind {
44 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 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 pub fn parse(name: &str) -> Option<Self> {
72 Self::ALL.into_iter().find(|k| k.as_str() == name)
73 }
74
75 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#[derive(Clone, Debug, PartialEq, Eq)]
102#[non_exhaustive]
103pub struct SessionEvent {
104 pub session_id: Uuid,
106 pub subject: String,
108 pub kind: EventKind,
110 pub enforced: bool,
113 pub score: Option<u32>,
115 pub signals: Vec<Check>,
117 pub ip: IpAddr,
119 pub user_agent: Option<String>,
121 pub client: ClientSummary,
124 pub at: DateTime<Utc>,
126}
127
128impl SessionEvent {
129 pub fn subject<S: Subject>(&self) -> Option<S> {
131 crate::subject::decode(&self.subject)
132 }
133}
134
135#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(default)]
139#[non_exhaustive]
140pub struct ClientSummary {
141 pub country: Option<String>,
143 pub city: Option<String>,
145 pub asn: Option<u32>,
147 pub network: Option<String>,
149 pub anonymity: Option<Anonymity>,
151 pub browser: Option<String>,
153 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 pub(crate) fn minimized(mut self) -> Self {
173 self.city = None;
174 self
175 }
176}
177
178#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
182#[non_exhaustive]
183pub struct SessionOrigin {
184 pub at: DateTime<Utc>,
186 pub ip_prefix: IpAddr,
188 #[serde(default)]
190 pub ip: Option<IpAddr>,
191 #[serde(default)]
193 pub user_agent: Option<String>,
194 #[serde(default)]
196 pub client: ClientSummary,
197}
198
199pub(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}