1use std::collections::HashMap;
31use std::sync::Mutex;
32use std::time::{Duration, Instant};
33
34#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
39pub enum ApproveOnUsePolicy {
40 #[default]
42 Never,
43 Session,
46 PerCall,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum ApprovalGate {
55 NotRequired,
58 AlreadyApproved,
62 PromptRequired,
67}
68
69#[derive(Debug, Clone)]
70struct ApprovedAt {
71 at: Instant,
72 ttl: Duration,
73}
74
75impl ApprovedAt {
76 fn is_live(&self) -> bool {
77 self.at.elapsed() < self.ttl
78 }
79}
80
81#[derive(Debug, Default)]
85pub struct SessionApprovalCache {
86 entries: Mutex<HashMap<String, ApprovedAt>>,
87}
88
89impl SessionApprovalCache {
90 pub fn new() -> Self {
91 Self::default()
92 }
93
94 pub fn record_session(&self, path: impl Into<String>, ttl: Duration) {
104 let mut state = self.entries.lock().expect("approval cache poisoned");
105 state.insert(
106 path.into(),
107 ApprovedAt {
108 at: Instant::now(),
109 ttl,
110 },
111 );
112 }
113
114 pub fn is_approved(&self, path: &str) -> bool {
118 let mut state = self.entries.lock().expect("approval cache poisoned");
119 if let Some(entry) = state.get(path) {
120 if entry.is_live() {
121 return true;
122 }
123 state.remove(path);
124 }
125 false
126 }
127
128 pub fn evaluate(&self, path: &str, policy: ApproveOnUsePolicy) -> ApprovalGate {
132 match policy {
133 ApproveOnUsePolicy::Never => ApprovalGate::NotRequired,
134 ApproveOnUsePolicy::PerCall => ApprovalGate::PromptRequired,
135 ApproveOnUsePolicy::Session => {
136 if self.is_approved(path) {
137 ApprovalGate::AlreadyApproved
138 } else {
139 ApprovalGate::PromptRequired
140 }
141 }
142 }
143 }
144
145 pub fn forget(&self, path: &str) -> bool {
149 let mut state = self.entries.lock().expect("approval cache poisoned");
150 state.remove(path).is_some()
151 }
152
153 pub fn clear(&self) {
156 let mut state = self.entries.lock().expect("approval cache poisoned");
157 state.clear();
158 }
159
160 pub fn sweep_expired(&self) -> usize {
164 let mut state = self.entries.lock().expect("approval cache poisoned");
165 let before = state.len();
166 state.retain(|_, e| e.is_live());
167 before - state.len()
168 }
169
170 pub fn len(&self) -> usize {
171 self.entries.lock().expect("approval cache poisoned").len()
172 }
173
174 pub fn is_empty(&self) -> bool {
175 self.len() == 0
176 }
177}
178
179use std::sync::Arc;
184
185use crate::alias::{AliasResolverError, SecretResolver};
186use secrecy::SecretString;
187
188pub struct ApprovalGatedResolver<R, F>
217where
218 R: SecretResolver,
219 F: Fn(&str) -> ApproveOnUsePolicy + Send + Sync,
220{
221 inner: R,
222 cache: Arc<SessionApprovalCache>,
223 policy_for_path: F,
224}
225
226impl<R, F> ApprovalGatedResolver<R, F>
227where
228 R: SecretResolver,
229 F: Fn(&str) -> ApproveOnUsePolicy + Send + Sync,
230{
231 pub fn new(inner: R, cache: Arc<SessionApprovalCache>, policy_for_path: F) -> Self {
232 Self {
233 inner,
234 cache,
235 policy_for_path,
236 }
237 }
238
239 pub fn cache(&self) -> &Arc<SessionApprovalCache> {
244 &self.cache
245 }
246}
247
248impl<R, F> SecretResolver for ApprovalGatedResolver<R, F>
249where
250 R: SecretResolver,
251 F: Fn(&str) -> ApproveOnUsePolicy + Send + Sync,
252{
253 fn resolve(&self, path: &str) -> Result<SecretString, AliasResolverError> {
254 let policy = (self.policy_for_path)(path);
255 match self.cache.evaluate(path, policy) {
256 ApprovalGate::NotRequired | ApprovalGate::AlreadyApproved => self.inner.resolve(path),
257 ApprovalGate::PromptRequired => {
258 let label = match policy {
259 ApproveOnUsePolicy::Never => "never",
260 ApproveOnUsePolicy::Session => "session",
261 ApproveOnUsePolicy::PerCall => "per-call",
262 };
263 Err(AliasResolverError::Backend {
264 path: path.to_owned(),
265 message: format!(
266 "approve-on-use policy `{label}` requires user approval; \
267 surface secrets_request_use_approval and retry"
268 ),
269 })
270 }
271 }
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use std::thread::sleep;
279
280 fn ttl_long() -> Duration {
281 Duration::from_secs(300)
282 }
283
284 #[test]
287 fn evaluate_never_policy_returns_not_required() {
288 let cache = SessionApprovalCache::new();
289 assert_eq!(
290 cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::Never),
291 ApprovalGate::NotRequired
292 );
293 }
294
295 #[test]
296 fn evaluate_per_call_always_prompts_even_with_cache_hit() {
297 let cache = SessionApprovalCache::new();
298 cache.record_session("team/jira/api-key", ttl_long());
299 assert_eq!(
300 cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::PerCall),
301 ApprovalGate::PromptRequired
302 );
303 }
304
305 #[test]
306 fn evaluate_session_returns_already_approved_when_cached() {
307 let cache = SessionApprovalCache::new();
308 cache.record_session("team/jira/api-key", ttl_long());
309 assert_eq!(
310 cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::Session),
311 ApprovalGate::AlreadyApproved
312 );
313 }
314
315 #[test]
316 fn evaluate_session_prompts_when_cache_miss() {
317 let cache = SessionApprovalCache::new();
318 assert_eq!(
319 cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::Session),
320 ApprovalGate::PromptRequired
321 );
322 }
323
324 #[test]
327 fn cached_approval_expires_after_ttl() {
328 let cache = SessionApprovalCache::new();
329 cache.record_session("team/jira/api-key", Duration::from_millis(20));
330 sleep(Duration::from_millis(40));
331 assert_eq!(
332 cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::Session),
333 ApprovalGate::PromptRequired
334 );
335 }
336
337 #[test]
338 fn is_approved_drops_expired_entry_lazily() {
339 let cache = SessionApprovalCache::new();
340 cache.record_session("a/b/c", Duration::from_millis(10));
341 sleep(Duration::from_millis(20));
342 assert!(!cache.is_approved("a/b/c"));
343 assert_eq!(cache.len(), 0, "expired entry should be evicted on access");
344 }
345
346 #[test]
349 fn forget_evicts_existing_entry() {
350 let cache = SessionApprovalCache::new();
351 cache.record_session("a/b/c", ttl_long());
352 assert!(cache.forget("a/b/c"));
353 assert!(!cache.is_approved("a/b/c"));
354 }
355
356 #[test]
357 fn forget_returns_false_for_missing_entry() {
358 let cache = SessionApprovalCache::new();
359 assert!(!cache.forget("a/b/c"));
360 }
361
362 #[test]
363 fn clear_drops_all_entries() {
364 let cache = SessionApprovalCache::new();
365 cache.record_session("a/b/c", ttl_long());
366 cache.record_session("d/e/f", ttl_long());
367 cache.clear();
368 assert!(cache.is_empty());
369 }
370
371 #[test]
374 fn record_session_replaces_existing_entry() {
375 let cache = SessionApprovalCache::new();
376 cache.record_session("a/b/c", Duration::from_millis(10));
377 sleep(Duration::from_millis(20));
378 cache.record_session("a/b/c", ttl_long());
381 assert!(cache.is_approved("a/b/c"));
382 }
383
384 #[test]
387 fn sweep_expired_drops_only_stale_entries() {
388 let cache = SessionApprovalCache::new();
389 cache.record_session("stale", Duration::from_millis(10));
390 cache.record_session("fresh", ttl_long());
391 sleep(Duration::from_millis(20));
392 assert_eq!(cache.sweep_expired(), 1);
393 assert!(cache.is_approved("fresh"));
394 assert!(!cache.is_approved("stale"));
395 }
396
397 use crate::alias::{AliasResolverError, SecretResolver};
400 use secrecy::{ExposeSecret, SecretString};
401 use std::sync::Mutex;
402
403 struct CountingResolver {
406 secrets: std::collections::HashMap<String, String>,
407 calls: Mutex<u32>,
408 }
409
410 impl CountingResolver {
411 fn new(entries: &[(&str, &str)]) -> Self {
412 Self {
413 secrets: entries
414 .iter()
415 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
416 .collect(),
417 calls: Mutex::new(0),
418 }
419 }
420 }
421
422 impl SecretResolver for CountingResolver {
423 fn resolve(&self, path: &str) -> Result<SecretString, AliasResolverError> {
424 *self.calls.lock().unwrap() += 1;
425 self.secrets
426 .get(path)
427 .map(|v| SecretString::from(v.clone()))
428 .ok_or_else(|| AliasResolverError::NotFound {
429 path: path.to_owned(),
430 })
431 }
432 }
433
434 #[test]
435 fn gated_resolver_passes_through_never_policy() {
436 let inner = CountingResolver::new(&[("team/x/y", "value-1")]);
437 let cache = Arc::new(SessionApprovalCache::new());
438 let gated = ApprovalGatedResolver::new(inner, cache, |_| ApproveOnUsePolicy::Never);
439 let v = gated.resolve("team/x/y").unwrap();
440 assert_eq!(v.expose_secret(), "value-1");
441 }
442
443 #[test]
444 fn gated_resolver_refuses_session_policy_without_cache_hit() {
445 let inner = CountingResolver::new(&[("team/x/y", "value-1")]);
446 let cache = Arc::new(SessionApprovalCache::new());
447 let gated =
448 ApprovalGatedResolver::new(inner, cache.clone(), |_| ApproveOnUsePolicy::Session);
449 let err = gated.resolve("team/x/y").unwrap_err();
450 match err {
451 AliasResolverError::Backend { path, message } => {
452 assert_eq!(path, "team/x/y");
453 assert!(
454 message.contains("session") && message.contains("user approval"),
455 "unexpected message: {message}"
456 );
457 }
458 other => panic!("expected Backend gate-required error, got {other:?}"),
459 }
460 }
465
466 #[test]
467 fn gated_resolver_passes_session_policy_after_cache_record() {
468 let inner = CountingResolver::new(&[("team/x/y", "value-1")]);
469 let cache = Arc::new(SessionApprovalCache::new());
470 cache.record_session("team/x/y", ttl_long());
471 let gated = ApprovalGatedResolver::new(inner, cache, |_| ApproveOnUsePolicy::Session);
472 let v = gated.resolve("team/x/y").unwrap();
473 assert_eq!(v.expose_secret(), "value-1");
474 }
475
476 #[test]
477 fn gated_resolver_always_refuses_per_call_even_with_cache() {
478 let inner = CountingResolver::new(&[("team/x/y", "value-1")]);
479 let cache = Arc::new(SessionApprovalCache::new());
480 cache.record_session("team/x/y", ttl_long());
481 let gated = ApprovalGatedResolver::new(inner, cache, |_| ApproveOnUsePolicy::PerCall);
482 let err = gated.resolve("team/x/y").unwrap_err();
483 assert!(matches!(err, AliasResolverError::Backend { .. }));
484 }
485
486 #[test]
487 fn gated_resolver_does_not_touch_inner_on_refusal() {
488 let cache = Arc::new(SessionApprovalCache::new());
491 let inner_box: Box<dyn SecretResolver> =
492 Box::new(CountingResolver::new(&[("team/x/y", "value-1")]));
493 let counter = Arc::new(Mutex::new(0u32));
497 let counter_clone = Arc::clone(&counter);
498 struct ProxyResolver {
499 inner: Box<dyn SecretResolver>,
500 counter: Arc<Mutex<u32>>,
501 }
502 impl SecretResolver for ProxyResolver {
503 fn resolve(&self, path: &str) -> Result<SecretString, AliasResolverError> {
504 *self.counter.lock().unwrap() += 1;
505 self.inner.resolve(path)
506 }
507 }
508 let proxy = ProxyResolver {
509 inner: inner_box,
510 counter: counter_clone,
511 };
512 let gated = ApprovalGatedResolver::new(proxy, cache, |_| ApproveOnUsePolicy::Session);
513 let _ = gated.resolve("team/x/y").unwrap_err();
514 assert_eq!(
515 *counter.lock().unwrap(),
516 0,
517 "inner resolver must not be touched on gate refusal"
518 );
519 }
520
521 #[test]
522 fn gated_resolver_call_count_zero_after_refusal() {
523 let cache = Arc::new(SessionApprovalCache::new());
524 let inner = CountingResolver::new(&[("team/prod-db/password", "v")]);
525 let gated = ApprovalGatedResolver::new(inner, cache, |path| {
526 if path == "team/prod-db/password" {
527 ApproveOnUsePolicy::PerCall
528 } else {
529 ApproveOnUsePolicy::Never
530 }
531 });
532 let _ = gated.resolve("team/prod-db/password").unwrap_err();
533 }
538
539 #[test]
540 fn gated_resolver_cache_accessor_exposes_handle_for_orchestrator() {
541 let inner = CountingResolver::new(&[]);
542 let cache = Arc::new(SessionApprovalCache::new());
543 let gated =
544 ApprovalGatedResolver::new(inner, Arc::clone(&cache), |_| ApproveOnUsePolicy::Session);
545 gated.cache().record_session("a/b/c", ttl_long());
549 assert!(cache.is_approved("a/b/c"));
550 }
551}