Skip to main content

session_guard/
subject.rs

1//! The opaque subject a session belongs to.
2
3use serde::{Serialize, de::DeserializeOwned};
4
5use crate::error::GuardError;
6
7/// Who a session belongs to: a user id, an account/tenant pair, an API client, anything.
8///
9/// The guard never interprets the subject. It serializes it to JSON for the store and
10/// deserializes it back when a session is authenticated. Blanket-implemented for every type
11/// with the right bounds, e.g. [`uuid::Uuid`], [`i64`], [`String`] or your own
12/// `#[derive(Serialize, Deserialize, PartialEq, Eq, Clone)]` struct.
13///
14/// The serialized form must be deterministic, because stores find a subject's sessions by
15/// comparing it: equal values must produce equal JSON. Avoid `HashMap` fields inside subjects
16/// (use `BTreeMap`), and keep the representation stable across releases of your app.
17pub trait Subject: Serialize + DeserializeOwned + Eq + Clone + Send + Sync + 'static {}
18
19impl<T> Subject for T where T: Serialize + DeserializeOwned + Eq + Clone + Send + Sync + 'static {}
20
21/// Canonical text form written to the store.
22pub(crate) fn encode<S: Subject>(subject: &S) -> Result<String, GuardError> {
23    serde_json::to_string(subject).map_err(|e| GuardError::Subject(e.to_string()))
24}
25
26/// Parses a stored subject. `None` means the stored value no longer matches `S`.
27pub(crate) fn decode<S: Subject>(stored: &str) -> Option<S> {
28    serde_json::from_str(stored).ok()
29}
30
31#[cfg(test)]
32mod tests {
33    use std::collections::BTreeMap;
34
35    use serde::Deserialize;
36    use uuid::Uuid;
37
38    use super::*;
39
40    fn round_trip<S: Subject + std::fmt::Debug>(subject: S) {
41        let stored = encode(&subject).unwrap();
42        assert_eq!(decode::<S>(&stored), Some(subject));
43    }
44
45    #[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)]
46    struct TenantUser {
47        tenant: u32,
48        user: String,
49        tags: BTreeMap<String, String>,
50    }
51
52    #[test]
53    fn common_subjects_round_trip() {
54        round_trip(Uuid::new_v4());
55        round_trip(-42_i64);
56        round_trip(u64::MAX);
57        round_trip("user:ΓΌ@example.com\"';--".to_owned());
58        round_trip(TenantUser {
59            tenant: 7,
60            user: "ann".into(),
61            tags: BTreeMap::from([("b".into(), "2".into()), ("a".into(), "1".into())]),
62        });
63    }
64
65    #[test]
66    fn encoding_is_deterministic() {
67        let id = Uuid::new_v4();
68        assert_eq!(encode(&id).unwrap(), encode(&id).unwrap());
69    }
70
71    #[test]
72    fn mismatched_stored_values_are_rejected() {
73        assert_eq!(decode::<Uuid>("\"nope\""), None);
74        assert_eq!(decode::<i64>("\"12\""), None);
75    }
76}