Skip to main content

delegated_redis/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3
4//! Redis-backed trust state for [`delegated`] evaluators.
5//!
6//! This crate provides [`RedisTrustState`], a production-oriented implementation of
7//! [`TrustStateStore`] and [`TrustStateAdmin`] using Redis atomic `SET NX` for nonce
8//! consumption and persistent flags for revocation and emergency deny.
9//!
10//! See the [operations guide](https://github.com/abendrothj/delegated/blob/main/crates/delegated-redis/OPERATIONS.md)
11//! for key layout, Sentinel/cluster notes, and lifecycle procedures.
12//!
13//! [`delegated`]: https://docs.rs/delegated
14//! [`TrustStateStore`]: delegated::TrustStateStore
15//! [`TrustStateAdmin`]: delegated::TrustStateAdmin
16
17use chrono::{DateTime, Utc};
18use delegated::{TrustStateAdmin, TrustStateError, TrustStateStore};
19use redis::{Client, Commands, RedisError};
20
21/// Shared trust state backed by Redis `SET NX EX` nonce consumption.
22///
23/// Connect one instance per process and reuse it across requests. Constructing a
24/// fresh store per request adds connection overhead without security benefit.
25///
26/// # Examples
27///
28/// ```
29/// use delegated::TrustStateStore;
30/// use delegated_redis::RedisTrustState;
31///
32/// # fn example() -> Result<(), delegated::TrustStateError> {
33/// let state = RedisTrustState::new("redis://127.0.0.1/")?;
34/// assert!(!state.is_token_revoked("https://issuer.example", "token-1")?);
35/// # Ok(())
36/// # }
37/// ```
38pub struct RedisTrustState {
39    client: Client,
40    prefix: String,
41}
42
43impl RedisTrustState {
44    /// Connects to Redis with the default `delegated` key prefix.
45    ///
46    /// `redis_url` must be a URL understood by the `redis` crate, for example
47    /// `redis://127.0.0.1:6379` or `rediss://user:pass@host:6380/0`.
48    pub fn new(redis_url: &str) -> Result<Self, TrustStateError> {
49        Self::with_prefix(redis_url, "delegated")
50    }
51
52    /// Connects to Redis with a custom key prefix for multi-tenant clusters.
53    ///
54    /// Use a distinct prefix per environment or authorization surface that shares
55    /// the same Redis deployment.
56    pub fn with_prefix(
57        redis_url: &str,
58        prefix: impl Into<String>,
59    ) -> Result<Self, TrustStateError> {
60        let client = Client::open(redis_url).map_err(map_error)?;
61        Ok(Self {
62            client,
63            prefix: prefix.into(),
64        })
65    }
66
67    fn key(&self, category: &str, issuer: &str, identifier: &str) -> String {
68        format!("{}:{}:{}:{}", self.prefix, category, issuer, identifier)
69    }
70
71    fn connection(&self) -> Result<redis::Connection, TrustStateError> {
72        self.client.get_connection().map_err(map_error)
73    }
74
75    fn exists(&self, key: &str) -> Result<bool, TrustStateError> {
76        let mut connection = self.connection()?;
77        connection.exists(key).map_err(map_error)
78    }
79
80    fn set_flag(&self, key: &str) -> Result<(), TrustStateError> {
81        let mut connection = self.connection()?;
82        connection.set::<_, _, ()>(key, "1").map_err(map_error)
83    }
84}
85
86impl TrustStateStore for RedisTrustState {
87    fn is_token_revoked(&self, issuer: &str, token_id: &str) -> Result<bool, TrustStateError> {
88        self.exists(&self.key("revoked", issuer, token_id))
89    }
90
91    fn is_agent_denied(&self, issuer: &str, agent_id: &str) -> Result<bool, TrustStateError> {
92        self.exists(&self.key("denied", issuer, agent_id))
93    }
94
95    fn consume_nonce(
96        &self,
97        issuer: &str,
98        nonce: &str,
99        observed_at: DateTime<Utc>,
100        valid_until: DateTime<Utc>,
101    ) -> Result<bool, TrustStateError> {
102        let ttl = (valid_until - observed_at)
103            .num_seconds()
104            .max(1)
105            .min(i64::from(u32::MAX)) as u64;
106        let key = self.key("nonce", issuer, nonce);
107        let mut connection = self.connection()?;
108        let set: Option<String> = redis::cmd("SET")
109            .arg(&key)
110            .arg("1")
111            .arg("NX")
112            .arg("EX")
113            .arg(ttl)
114            .query(&mut connection)
115            .map_err(map_error)?;
116        Ok(set.is_some())
117    }
118}
119
120impl TrustStateAdmin for RedisTrustState {
121    fn revoke_token(&self, issuer: &str, token_id: &str) -> Result<(), TrustStateError> {
122        self.set_flag(&self.key("revoked", issuer, token_id))
123    }
124
125    fn deny_agent(&self, issuer: &str, agent_id: &str) -> Result<(), TrustStateError> {
126        self.set_flag(&self.key("denied", issuer, agent_id))
127    }
128}
129
130fn map_error(error: RedisError) -> TrustStateError {
131    TrustStateError::new(error.to_string())
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use chrono::{Duration, TimeZone, Utc};
138    use delegated::{DelegationTokenBuilder, Evaluator, OperationContext, PinnedIssuerKeys};
139    use ed25519_dalek::SigningKey;
140    use std::sync::{Arc, Barrier};
141    use std::thread;
142
143    fn redis_url() -> Option<String> {
144        std::env::var("REDIS_URL")
145            .ok()
146            .filter(|value| !value.is_empty())
147    }
148
149    fn unique_prefix() -> String {
150        format!(
151            "delegated-test-{}",
152            std::time::SystemTime::now()
153                .duration_since(std::time::UNIX_EPOCH)
154                .unwrap_or_default()
155                .as_nanos()
156        )
157    }
158
159    fn now() -> DateTime<Utc> {
160        Utc.with_ymd_and_hms(2026, 7, 1, 12, 0, 0).single().unwrap()
161    }
162
163    #[test]
164    fn redis_nonce_consumption_is_single_use() {
165        let Some(url) = redis_url() else {
166            eprintln!("skipping redis_nonce_consumption_is_single_use: REDIS_URL unset");
167            return;
168        };
169        let state = RedisTrustState::with_prefix(&url, unique_prefix()).unwrap();
170        assert!(
171            state
172                .consume_nonce(
173                    "issuer",
174                    "nonce-000000000001",
175                    now(),
176                    now() + Duration::hours(1)
177                )
178                .unwrap()
179        );
180        assert!(
181            !state
182                .consume_nonce(
183                    "issuer",
184                    "nonce-000000000001",
185                    now(),
186                    now() + Duration::hours(1)
187                )
188                .unwrap()
189        );
190    }
191
192    #[test]
193    fn redis_revocation_and_deny_are_visible_to_evaluator() {
194        let Some(url) = redis_url() else {
195            eprintln!(
196                "skipping redis_revocation_and_deny_are_visible_to_evaluator: REDIS_URL unset"
197            );
198            return;
199        };
200        let state = RedisTrustState::with_prefix(&url, unique_prefix()).unwrap();
201        state
202            .revoke_token("https://issuer.example", "token-1")
203            .unwrap();
204        assert!(
205            state
206                .is_token_revoked("https://issuer.example", "token-1")
207                .unwrap()
208        );
209        state
210            .deny_agent("https://issuer.example", "agent:bad")
211            .unwrap();
212        assert!(
213            state
214                .is_agent_denied("https://issuer.example", "agent:bad")
215                .unwrap()
216        );
217    }
218
219    #[test]
220    fn redis_nonce_is_atomic_under_concurrency() {
221        let Some(url) = redis_url() else {
222            eprintln!("skipping redis_nonce_is_atomic_under_concurrency: REDIS_URL unset");
223            return;
224        };
225        let prefix = unique_prefix();
226        let key = SigningKey::from_bytes(&[7; 32]);
227        let keys = PinnedIssuerKeys::new();
228        keys.insert("https://issuer.example", "issuer-1", key.verifying_key())
229            .unwrap();
230        let issued = now() - Duration::minutes(1);
231        let expires = now() + Duration::minutes(10);
232        let token = DelegationTokenBuilder::new()
233            .token_id("token-1")
234            .issuer("https://issuer.example")
235            .agent_id("agent:scheduler")
236            .delegator_id("user:alice")
237            .audience("calendar-api")
238            .allowed_action("calendar.create")
239            .allowed_resource("calendar:alice")
240            .max_delegation_depth(0)
241            .issued_at(issued)
242            .expires_at(expires)
243            .nonce("nonce-concurrent-001")
244            .key_id("issuer-1")
245            .build_and_sign(&key)
246            .unwrap();
247        let request =
248            serde_json::to_vec(&delegated::envelope(token, Some("request-1".into()))).unwrap();
249        let operation = OperationContext::new("calendar-api", "calendar.create")
250            .with_resource("calendar:alice")
251            .with_delegation_depth(0);
252
253        let state = Arc::new(RedisTrustState::with_prefix(&url, prefix).unwrap());
254        let request = Arc::new(request);
255        let keys = Arc::new(keys);
256        let barrier = Arc::new(Barrier::new(8));
257        let mut workers = Vec::new();
258        for _ in 0..8 {
259            let state = Arc::clone(&state);
260            let request = Arc::clone(&request);
261            let keys = Arc::clone(&keys);
262            let barrier = Arc::clone(&barrier);
263            let operation = operation.clone();
264            workers.push(thread::spawn(move || {
265                barrier.wait();
266                Evaluator::new(keys.as_ref(), state.as_ref())
267                    .evaluate(request.as_ref(), &operation, now())
268                    .0
269                    .allowed
270            }));
271        }
272        let allowed = workers
273            .into_iter()
274            .map(|worker| worker.join().unwrap())
275            .filter(|allowed| *allowed)
276            .count();
277        assert_eq!(allowed, 1);
278    }
279}