1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3
4use chrono::{DateTime, Utc};
18use delegated::{TrustStateAdmin, TrustStateError, TrustStateStore};
19use redis::{Client, Commands, RedisError};
20
21pub struct RedisTrustState {
39 client: Client,
40 prefix: String,
41}
42
43impl RedisTrustState {
44 pub fn new(redis_url: &str) -> Result<Self, TrustStateError> {
49 Self::with_prefix(redis_url, "delegated")
50 }
51
52 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}