greentic_session/
redis_store.rs

1use crate::error::{invalid_argument, not_found, redis_error, serde_error};
2use crate::store::SessionStore;
3use greentic_types::{GResult, SessionData, SessionKey, TenantCtx, UserId};
4use redis::{Commands, Connection};
5use uuid::Uuid;
6
7const DEFAULT_NAMESPACE: &str = "greentic:session";
8
9/// Redis-backed session store that mirrors the in-memory semantics.
10pub struct RedisSessionStore {
11    client: redis::Client,
12    namespace: String,
13}
14
15impl RedisSessionStore {
16    /// Creates a store using the default namespace prefix.
17    pub fn new(client: redis::Client) -> Self {
18        Self::with_namespace(client, DEFAULT_NAMESPACE)
19    }
20
21    /// Creates a store with a custom namespace prefix.
22    pub fn with_namespace(client: redis::Client, namespace: impl Into<String>) -> Self {
23        Self {
24            client,
25            namespace: namespace.into(),
26        }
27    }
28
29    fn conn(&self) -> GResult<Connection> {
30        self.client.get_connection().map_err(redis_error)
31    }
32
33    fn session_entry_key(&self, key: &SessionKey) -> String {
34        format!("{}:session:{}", self.namespace, key.as_str())
35    }
36
37    fn user_lookup_key(&self, ctx: &TenantCtx, user: &UserId) -> String {
38        let team = ctx
39            .team_id
40            .as_ref()
41            .or(ctx.team.as_ref())
42            .map(|v| v.as_str())
43            .unwrap_or("-");
44        format!(
45            "{}:user:{}:{}:{}:{}",
46            self.namespace,
47            ctx.env.as_str(),
48            ctx.tenant_id.as_str(),
49            team,
50            user.as_str()
51        )
52    }
53
54    fn ensure_alignment(ctx: &TenantCtx, data: &SessionData) -> GResult<()> {
55        if ctx.env != data.tenant_ctx.env || ctx.tenant_id != data.tenant_ctx.tenant_id {
56            return Err(invalid_argument(
57                "session data tenant context does not match provided TenantCtx",
58            ));
59        }
60        Ok(())
61    }
62
63    fn serialize(data: &SessionData) -> GResult<String> {
64        serde_json::to_string(data).map_err(serde_error)
65    }
66
67    fn deserialize(payload: String) -> GResult<SessionData> {
68        serde_json::from_str(&payload).map_err(serde_error)
69    }
70
71    fn mapping_sources<'a>(
72        ctx_hint: Option<&'a TenantCtx>,
73        data: &'a SessionData,
74    ) -> Option<(&'a TenantCtx, UserId)> {
75        if let Some(user) = data
76            .tenant_ctx
77            .user_id
78            .clone()
79            .or_else(|| data.tenant_ctx.user.clone())
80        {
81            Some((&data.tenant_ctx, user))
82        } else {
83            ctx_hint.and_then(|ctx| {
84                ctx.user_id
85                    .clone()
86                    .or_else(|| ctx.user.clone())
87                    .map(|user| (ctx, user))
88            })
89        }
90    }
91
92    fn store_user_mapping(
93        &self,
94        conn: &mut Connection,
95        ctx_hint: Option<&TenantCtx>,
96        data: &SessionData,
97        key: &SessionKey,
98    ) -> GResult<()> {
99        if let Some((ctx, user)) = Self::mapping_sources(ctx_hint, data) {
100            let lookup_key = self.user_lookup_key(ctx, &user);
101            conn.set::<_, _, ()>(lookup_key, key.as_str())
102                .map_err(redis_error)?;
103        }
104        Ok(())
105    }
106
107    fn remove_user_mapping(
108        &self,
109        conn: &mut Connection,
110        data: &SessionData,
111        key: &SessionKey,
112    ) -> GResult<()> {
113        if let Some((ctx, user)) = Self::mapping_sources(None, data) {
114            let lookup_key = self.user_lookup_key(ctx, &user);
115            let stored: Option<String> = conn.get(&lookup_key).map_err(redis_error)?;
116            if stored
117                .as_deref()
118                .map(|value| value == key.as_str())
119                .unwrap_or(false)
120            {
121                let _: () = conn.del(lookup_key).map_err(redis_error)?;
122            }
123        }
124        Ok(())
125    }
126}
127
128impl SessionStore for RedisSessionStore {
129    fn create_session(&self, ctx: &TenantCtx, data: SessionData) -> GResult<SessionKey> {
130        Self::ensure_alignment(ctx, &data)?;
131        let key = SessionKey::new(Uuid::new_v4().to_string());
132        let payload = Self::serialize(&data)?;
133        let mut conn = self.conn()?;
134        conn.set::<_, _, ()>(self.session_entry_key(&key), payload)
135            .map_err(redis_error)?;
136        self.store_user_mapping(&mut conn, Some(ctx), &data, &key)?;
137        Ok(key)
138    }
139
140    fn get_session(&self, key: &SessionKey) -> GResult<Option<SessionData>> {
141        let mut conn = self.conn()?;
142        let payload: Option<String> = conn.get(self.session_entry_key(key)).map_err(redis_error)?;
143        payload.map(Self::deserialize).transpose()
144    }
145
146    fn update_session(&self, key: &SessionKey, data: SessionData) -> GResult<()> {
147        let mut conn = self.conn()?;
148        let entry_key = self.session_entry_key(key);
149        let existing: Option<String> = conn.get(&entry_key).map_err(redis_error)?;
150        let Some(existing_payload) = existing else {
151            return Err(not_found(key));
152        };
153        let previous = Self::deserialize(existing_payload)?;
154        let payload = Self::serialize(&data)?;
155        conn.set::<_, _, ()>(&entry_key, payload)
156            .map_err(redis_error)?;
157        self.remove_user_mapping(&mut conn, &previous, key)?;
158        self.store_user_mapping(&mut conn, None, &data, key)
159    }
160
161    fn remove_session(&self, key: &SessionKey) -> GResult<()> {
162        let mut conn = self.conn()?;
163        let entry_key = self.session_entry_key(key);
164        let existing: Option<String> = conn.get(&entry_key).map_err(redis_error)?;
165        let Some(payload) = existing else {
166            return Err(not_found(key));
167        };
168        let data = Self::deserialize(payload)?;
169        let _: () = conn.del(entry_key).map_err(redis_error)?;
170        self.remove_user_mapping(&mut conn, &data, key)
171    }
172
173    fn find_by_user(
174        &self,
175        ctx: &TenantCtx,
176        user: &UserId,
177    ) -> GResult<Option<(SessionKey, SessionData)>> {
178        let mut conn = self.conn()?;
179        let lookup_key = self.user_lookup_key(ctx, user);
180        let stored: Option<String> = conn.get(&lookup_key).map_err(redis_error)?;
181        let Some(raw_key) = stored else {
182            return Ok(None);
183        };
184        let session_key = SessionKey::new(raw_key);
185        match self.get_session(&session_key)? {
186            Some(data) => Ok(Some((session_key, data))),
187            None => {
188                let _: () = conn.del(&lookup_key).map_err(redis_error)?;
189                Ok(None)
190            }
191        }
192    }
193}