greentic_session/
inmemory.rs

1use crate::error::SessionResult;
2use crate::error::{GreenticError, invalid_argument, not_found};
3use crate::store::SessionStore;
4use greentic_types::{EnvId, SessionData, SessionKey, TeamId, TenantCtx, TenantId, UserId};
5use parking_lot::RwLock;
6use std::collections::HashMap;
7use uuid::Uuid;
8
9/// Simple in-memory implementation backed by hash maps.
10pub struct InMemorySessionStore {
11    sessions: RwLock<HashMap<SessionKey, SessionData>>,
12    user_index: RwLock<HashMap<UserLookupKey, SessionKey>>,
13}
14
15impl Default for InMemorySessionStore {
16    fn default() -> Self {
17        Self::new()
18    }
19}
20
21impl InMemorySessionStore {
22    /// Constructs an empty store.
23    pub fn new() -> Self {
24        Self {
25            sessions: RwLock::new(HashMap::new()),
26            user_index: RwLock::new(HashMap::new()),
27        }
28    }
29
30    fn next_key() -> SessionKey {
31        SessionKey::new(Uuid::new_v4().to_string())
32    }
33
34    fn normalize_team(ctx: &TenantCtx) -> Option<&TeamId> {
35        ctx.team_id.as_ref().or(ctx.team.as_ref())
36    }
37
38    fn normalize_user(ctx: &TenantCtx) -> Option<&UserId> {
39        ctx.user_id.as_ref().or(ctx.user.as_ref())
40    }
41
42    fn ctx_mismatch(expected: &TenantCtx, provided: &TenantCtx, reason: &str) -> GreenticError {
43        let expected_team = Self::normalize_team(expected)
44            .map(|t| t.as_str())
45            .unwrap_or("-");
46        let provided_team = Self::normalize_team(provided)
47            .map(|t| t.as_str())
48            .unwrap_or("-");
49        let expected_user = Self::normalize_user(expected)
50            .map(|u| u.as_str())
51            .unwrap_or("-");
52        let provided_user = Self::normalize_user(provided)
53            .map(|u| u.as_str())
54            .unwrap_or("-");
55        invalid_argument(format!(
56            "tenant context mismatch ({reason}): expected env={}, tenant={}, team={}, user={}, got env={}, tenant={}, team={}, user={}",
57            expected.env.as_str(),
58            expected.tenant_id.as_str(),
59            expected_team,
60            expected_user,
61            provided.env.as_str(),
62            provided.tenant_id.as_str(),
63            provided_team,
64            provided_user
65        ))
66    }
67
68    fn ensure_alignment(ctx: &TenantCtx, data: &SessionData) -> SessionResult<()> {
69        let stored = &data.tenant_ctx;
70        if ctx.env != stored.env || ctx.tenant_id != stored.tenant_id {
71            return Err(Self::ctx_mismatch(stored, ctx, "env/tenant must match"));
72        }
73        if Self::normalize_team(ctx) != Self::normalize_team(stored) {
74            return Err(Self::ctx_mismatch(stored, ctx, "team must match"));
75        }
76        if let Some(stored_user) = Self::normalize_user(stored) {
77            let Some(provided_user) = Self::normalize_user(ctx) else {
78                return Err(Self::ctx_mismatch(
79                    stored,
80                    ctx,
81                    "user required by session but missing in caller context",
82                ));
83            };
84            if stored_user != provided_user {
85                return Err(Self::ctx_mismatch(
86                    stored,
87                    ctx,
88                    "user must match stored session",
89                ));
90            }
91        }
92        Ok(())
93    }
94
95    fn ensure_ctx_preserved(existing: &TenantCtx, candidate: &TenantCtx) -> SessionResult<()> {
96        if existing.env != candidate.env || existing.tenant_id != candidate.tenant_id {
97            return Err(Self::ctx_mismatch(
98                existing,
99                candidate,
100                "env/tenant cannot change for an existing session",
101            ));
102        }
103        if Self::normalize_team(existing) != Self::normalize_team(candidate) {
104            return Err(Self::ctx_mismatch(
105                existing,
106                candidate,
107                "team cannot change for an existing session",
108            ));
109        }
110        match (
111            Self::normalize_user(existing),
112            Self::normalize_user(candidate),
113        ) {
114            (Some(a), Some(b)) if a == b => {}
115            (Some(_), Some(_)) | (Some(_), None) => {
116                return Err(Self::ctx_mismatch(
117                    existing,
118                    candidate,
119                    "user cannot change for an existing session",
120                ));
121            }
122            (None, Some(_)) => {
123                return Err(Self::ctx_mismatch(
124                    existing,
125                    candidate,
126                    "user cannot be introduced when none was stored",
127                ));
128            }
129            (None, None) => {}
130        }
131        Ok(())
132    }
133
134    fn lookup_from_ctx(ctx: &TenantCtx) -> Option<UserLookupKey> {
135        let user = ctx.user_id.clone().or_else(|| ctx.user.clone())?;
136        Some(UserLookupKey::from_ctx(ctx, &user))
137    }
138
139    fn lookup_from_data(data: &SessionData) -> Option<UserLookupKey> {
140        let user = data
141            .tenant_ctx
142            .user_id
143            .clone()
144            .or_else(|| data.tenant_ctx.user.clone())?;
145        Some(UserLookupKey::from_ctx(&data.tenant_ctx, &user))
146    }
147
148    fn record_user_mapping(
149        &self,
150        ctx_hint: Option<&TenantCtx>,
151        data: &SessionData,
152        key: &SessionKey,
153    ) {
154        let lookup =
155            Self::lookup_from_data(data).or_else(|| ctx_hint.and_then(Self::lookup_from_ctx));
156        if let Some(entry) = lookup {
157            self.user_index.write().insert(entry, key.clone());
158        }
159    }
160
161    fn purge_user_mapping(&self, data: &SessionData, key: &SessionKey) {
162        if let Some(entry) = Self::lookup_from_data(data) {
163            let mut guard = self.user_index.write();
164            if guard
165                .get(&entry)
166                .map(|existing| existing == key)
167                .unwrap_or(false)
168            {
169                guard.remove(&entry);
170            }
171        }
172    }
173}
174
175impl SessionStore for InMemorySessionStore {
176    fn create_session(&self, ctx: &TenantCtx, data: SessionData) -> SessionResult<SessionKey> {
177        Self::ensure_alignment(ctx, &data)?;
178        let key = Self::next_key();
179        self.sessions.write().insert(key.clone(), data.clone());
180        self.record_user_mapping(Some(ctx), &data, &key);
181        Ok(key)
182    }
183
184    fn get_session(&self, key: &SessionKey) -> SessionResult<Option<SessionData>> {
185        Ok(self.sessions.read().get(key).cloned())
186    }
187
188    fn update_session(&self, key: &SessionKey, data: SessionData) -> SessionResult<()> {
189        let mut sessions = self.sessions.write();
190        let Some(previous) = sessions.get(key).cloned() else {
191            return Err(not_found(key));
192        };
193        Self::ensure_ctx_preserved(&previous.tenant_ctx, &data.tenant_ctx)?;
194        sessions.insert(key.clone(), data.clone());
195        drop(sessions);
196        self.purge_user_mapping(&previous, key);
197        self.record_user_mapping(None, &data, key);
198        Ok(())
199    }
200
201    fn remove_session(&self, key: &SessionKey) -> SessionResult<()> {
202        if let Some(old) = self.sessions.write().remove(key) {
203            self.purge_user_mapping(&old, key);
204            Ok(())
205        } else {
206            Err(not_found(key))
207        }
208    }
209
210    fn find_by_user(
211        &self,
212        ctx: &TenantCtx,
213        user: &UserId,
214    ) -> SessionResult<Option<(SessionKey, SessionData)>> {
215        let lookup = UserLookupKey::from_ctx(ctx, user);
216        if let Some(stored_key) = self.user_index.read().get(&lookup).cloned() {
217            if let Some(data) = self.sessions.read().get(&stored_key).cloned() {
218                let stored_ctx = &data.tenant_ctx;
219                if stored_ctx.env == ctx.env
220                    && stored_ctx.tenant_id == ctx.tenant_id
221                    && Self::normalize_team(stored_ctx) == Self::normalize_team(ctx)
222                {
223                    if let Some(stored_user) = Self::normalize_user(stored_ctx)
224                        && stored_user != user
225                    {
226                        self.user_index.write().remove(&lookup);
227                        return Ok(None);
228                    }
229                    return Ok(Some((stored_key, data)));
230                }
231                self.user_index.write().remove(&lookup);
232            }
233            self.user_index.write().remove(&lookup);
234        }
235        Ok(None)
236    }
237}
238
239#[derive(Clone, Eq, PartialEq, Hash)]
240struct UserLookupKey {
241    env: EnvId,
242    tenant: TenantId,
243    team: Option<TeamId>,
244    user: UserId,
245}
246
247impl UserLookupKey {
248    fn from_ctx(ctx: &TenantCtx, user: &UserId) -> Self {
249        Self {
250            env: ctx.env.clone(),
251            tenant: ctx.tenant_id.clone(),
252            team: ctx.team_id.clone().or_else(|| ctx.team.clone()),
253            user: user.clone(),
254        }
255    }
256}