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}