Skip to main content

praxis_policy_session_valkey/
store.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Praxis Contributors
3
4// `ValkeySessionStore` — the Valkey-backed `SessionStore`. Labels live in
5// a Redis SET per session so `append_labels` is a single atomic
6// server-side union (`SADD`), never a client-side read-modify-write that
7// would lose labels under concurrent cross-node appends.
8//
9// # Fail-closed mapping
10//
11//   - `SMEMBERS` on a missing key returns an empty set → `Ok(empty)`
12//     (unknown session). It is NOT an error.
13//   - connection/timeout/protocol/decode failures → `Err(Backend)` so the
14//     caller fails the request closed.
15//
16// # Sliding TTL
17//
18// `append_labels` issues `SADD` + `EXPIRE` in one atomic pipeline.
19// `load_labels` refreshes the TTL fail-open: the read already succeeded,
20// so a refresh failure is alarmed but the labels are still returned.
21
22use std::fmt::Write as _;
23use std::time::Duration;
24
25use async_trait::async_trait;
26use deadpool_redis::{Connection, Pool};
27use praxis_policy_apl_runtime::{SessionStore, SessionStoreError};
28use redis::AsyncCommands as _;
29use sha2::{Digest as _, Sha256};
30
31use crate::config::ValkeyConfig;
32use crate::connection::build_pool;
33use crate::error::BuildError;
34
35/// Valkey-backed session label store.
36/// The configured TTL as the `i64` valkey expects.
37///
38/// Saturating rather than wrapping. `EXPIRE` treats a non-positive TTL as
39/// "delete now", so a wrapped negative value would drop the session key and with
40/// it any accumulated taint. `ValkeyConfig::validate` already rejects a TTL this
41/// large, so this is the second of two guards; it exists because the consequence
42/// of getting it wrong is a silent downgrade rather than a visible failure.
43fn ttl_for_expire(ttl: u64) -> i64 {
44    i64::try_from(ttl).unwrap_or(i64::MAX)
45}
46
47/// A Valkey-backed store for session security labels.
48pub struct ValkeySessionStore {
49    pool: Pool,
50    key_prefix: String,
51    ttl_seconds: Option<u64>,
52    connect_timeout: Duration,
53    command_timeout: Duration,
54}
55
56impl ValkeySessionStore {
57    /// Build from validated config. The pool is created lazily, so this
58    /// does not dial Valkey — connection failures surface on first use
59    /// and correctly fail the request closed.
60    /// # Errors
61    ///
62    /// Returns `BuildError` when the connection URL cannot be built or the client
63    /// cannot be constructed from it.
64    pub fn from_config(cfg: &ValkeyConfig) -> Result<Self, BuildError> {
65        Ok(Self {
66            pool: build_pool(cfg)?,
67            key_prefix: cfg.key_prefix.clone(),
68            ttl_seconds: cfg.ttl_seconds,
69            connect_timeout: Duration::from_millis(cfg.connect_timeout_ms),
70            command_timeout: Duration::from_millis(cfg.command_timeout_ms),
71        })
72    }
73
74    /// Key schema: `<prefix>:<hex(sha256(session_id))>`. The full-width
75    /// digest keeps the Valkey keyspace collision-free and removes raw
76    /// session ids from it.
77    fn key(&self, session_id: &str) -> String {
78        let mut hasher = Sha256::new();
79        hasher.update(session_id.as_bytes());
80        let digest = hasher.finalize();
81        let mut hex = String::with_capacity(digest.len() * 2);
82        for byte in digest {
83            let _ = write!(hex, "{byte:02x}");
84        }
85        format!("{}:{}", self.key_prefix, hex)
86    }
87
88    /// Acquire a pooled connection, bounded by the connect timeout (the
89    /// fail-fast knob for a dead/slow endpoint, distinct from the
90    /// per-command timeout applied to SMEMBERS/SADD below).
91    async fn conn(&self) -> Result<Connection, SessionStoreError> {
92        match tokio::time::timeout(self.connect_timeout, self.pool.get()).await {
93            Ok(Ok(conn)) => Ok(conn),
94            Ok(Err(e)) => Err(backend(e)),
95            Err(_) => Err(SessionStoreError::Backend(
96                "valkey connection acquire timed out".to_owned(),
97            )),
98        }
99    }
100}
101
102/// Map any backend failure to the fail-closed `SessionStoreError`.
103fn backend(e: impl std::fmt::Display) -> SessionStoreError {
104    SessionStoreError::Backend(e.to_string())
105}
106
107#[async_trait]
108impl SessionStore for ValkeySessionStore {
109    async fn load_labels(&self, session_id: &str) -> Result<Vec<String>, SessionStoreError> {
110        let key = self.key(session_id);
111        let mut conn = self.conn().await?;
112
113        // SMEMBERS on a missing key returns an empty set (Ok), so an
114        // unknown session naturally maps to Ok(empty). Only a real
115        // backend failure becomes Err.
116        let labels: Vec<String> =
117            match tokio::time::timeout(self.command_timeout, conn.smembers(&key)).await {
118                Ok(res) => res.map_err(backend)?,
119                Err(_) => {
120                    return Err(SessionStoreError::Backend(
121                        "valkey SMEMBERS timed out".to_owned(),
122                    ));
123                },
124            };
125
126        // Sliding-TTL refresh is fail-open for the read: the labels were
127        // read successfully, so a refresh failure is alarmed, not failed
128        // closed. A persistently-failing refresh risks silent key
129        // expiry across requests — see the operator runbook.
130        if let Some(ttl) = self.ttl_seconds {
131            let refresh: Result<bool, _> = match tokio::time::timeout(
132                self.command_timeout,
133                conn.expire(&key, ttl_for_expire(ttl)),
134            )
135            .await
136            {
137                Ok(res) => res,
138                Err(_) => Ok(false), // treat timeout as a failed refresh
139            };
140            if let Err(e) = refresh {
141                tracing::warn!(
142                    alarm = "session_store_ttl_refresh_failed",
143                    error = %e,
144                    "valkey TTL refresh on load failed; returning read labels (fail-open)"
145                );
146            }
147        }
148
149        Ok(labels)
150    }
151
152    async fn append_labels(
153        &self,
154        session_id: &str,
155        labels: &[String],
156    ) -> Result<(), SessionStoreError> {
157        if labels.is_empty() {
158            return Ok(());
159        }
160        let key = self.key(session_id);
161        let mut conn = self.conn().await?;
162
163        // Atomic server-side union + optional TTL refresh in one round
164        // trip (MULTI/EXEC). SADD is a commutative merge, so concurrent
165        // cross-node appends never lose labels.
166        let mut pipe = redis::pipe();
167        pipe.atomic();
168        pipe.sadd(&key, labels).ignore();
169        if let Some(ttl) = self.ttl_seconds {
170            pipe.expire(&key, ttl_for_expire(ttl)).ignore();
171        }
172
173        match tokio::time::timeout(self.command_timeout, pipe.query_async::<()>(&mut conn)).await {
174            Ok(res) => res.map_err(backend)?,
175            Err(_) => {
176                return Err(SessionStoreError::Backend(
177                    "valkey append (SADD+EXPIRE) timed out".to_owned(),
178                ));
179            },
180        }
181        Ok(())
182    }
183}