use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ApproveOnUsePolicy {
#[default]
Never,
Session,
PerCall,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalGate {
NotRequired,
AlreadyApproved,
PromptRequired,
}
#[derive(Debug, Clone)]
struct ApprovedAt {
at: Instant,
ttl: Duration,
}
impl ApprovedAt {
fn is_live(&self) -> bool {
self.at.elapsed() < self.ttl
}
}
#[derive(Debug, Default)]
pub struct SessionApprovalCache {
entries: Mutex<HashMap<String, ApprovedAt>>,
}
impl SessionApprovalCache {
pub fn new() -> Self {
Self::default()
}
pub fn record_session(&self, path: impl Into<String>, ttl: Duration) {
let mut state = self.entries.lock().expect("approval cache poisoned");
state.insert(
path.into(),
ApprovedAt {
at: Instant::now(),
ttl,
},
);
}
pub fn is_approved(&self, path: &str) -> bool {
let mut state = self.entries.lock().expect("approval cache poisoned");
if let Some(entry) = state.get(path) {
if entry.is_live() {
return true;
}
state.remove(path);
}
false
}
pub fn evaluate(&self, path: &str, policy: ApproveOnUsePolicy) -> ApprovalGate {
match policy {
ApproveOnUsePolicy::Never => ApprovalGate::NotRequired,
ApproveOnUsePolicy::PerCall => ApprovalGate::PromptRequired,
ApproveOnUsePolicy::Session => {
if self.is_approved(path) {
ApprovalGate::AlreadyApproved
} else {
ApprovalGate::PromptRequired
}
}
}
}
pub fn forget(&self, path: &str) -> bool {
let mut state = self.entries.lock().expect("approval cache poisoned");
state.remove(path).is_some()
}
pub fn clear(&self) {
let mut state = self.entries.lock().expect("approval cache poisoned");
state.clear();
}
pub fn sweep_expired(&self) -> usize {
let mut state = self.entries.lock().expect("approval cache poisoned");
let before = state.len();
state.retain(|_, e| e.is_live());
before - state.len()
}
pub fn len(&self) -> usize {
self.entries.lock().expect("approval cache poisoned").len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
use std::sync::Arc;
use crate::alias::{AliasResolverError, SecretResolver};
use secrecy::SecretString;
pub struct ApprovalGatedResolver<R, F>
where
R: SecretResolver,
F: Fn(&str) -> ApproveOnUsePolicy + Send + Sync,
{
inner: R,
cache: Arc<SessionApprovalCache>,
policy_for_path: F,
}
impl<R, F> ApprovalGatedResolver<R, F>
where
R: SecretResolver,
F: Fn(&str) -> ApproveOnUsePolicy + Send + Sync,
{
pub fn new(inner: R, cache: Arc<SessionApprovalCache>, policy_for_path: F) -> Self {
Self {
inner,
cache,
policy_for_path,
}
}
pub fn cache(&self) -> &Arc<SessionApprovalCache> {
&self.cache
}
}
impl<R, F> SecretResolver for ApprovalGatedResolver<R, F>
where
R: SecretResolver,
F: Fn(&str) -> ApproveOnUsePolicy + Send + Sync,
{
fn resolve(&self, path: &str) -> Result<SecretString, AliasResolverError> {
let policy = (self.policy_for_path)(path);
match self.cache.evaluate(path, policy) {
ApprovalGate::NotRequired | ApprovalGate::AlreadyApproved => self.inner.resolve(path),
ApprovalGate::PromptRequired => {
let label = match policy {
ApproveOnUsePolicy::Never => "never",
ApproveOnUsePolicy::Session => "session",
ApproveOnUsePolicy::PerCall => "per-call",
};
Err(AliasResolverError::Backend {
path: path.to_owned(),
message: format!(
"approve-on-use policy `{label}` requires user approval; \
surface secrets_request_use_approval and retry"
),
})
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread::sleep;
fn ttl_long() -> Duration {
Duration::from_secs(300)
}
#[test]
fn evaluate_never_policy_returns_not_required() {
let cache = SessionApprovalCache::new();
assert_eq!(
cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::Never),
ApprovalGate::NotRequired
);
}
#[test]
fn evaluate_per_call_always_prompts_even_with_cache_hit() {
let cache = SessionApprovalCache::new();
cache.record_session("team/jira/api-key", ttl_long());
assert_eq!(
cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::PerCall),
ApprovalGate::PromptRequired
);
}
#[test]
fn evaluate_session_returns_already_approved_when_cached() {
let cache = SessionApprovalCache::new();
cache.record_session("team/jira/api-key", ttl_long());
assert_eq!(
cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::Session),
ApprovalGate::AlreadyApproved
);
}
#[test]
fn evaluate_session_prompts_when_cache_miss() {
let cache = SessionApprovalCache::new();
assert_eq!(
cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::Session),
ApprovalGate::PromptRequired
);
}
#[test]
fn cached_approval_expires_after_ttl() {
let cache = SessionApprovalCache::new();
cache.record_session("team/jira/api-key", Duration::from_millis(20));
sleep(Duration::from_millis(40));
assert_eq!(
cache.evaluate("team/jira/api-key", ApproveOnUsePolicy::Session),
ApprovalGate::PromptRequired
);
}
#[test]
fn is_approved_drops_expired_entry_lazily() {
let cache = SessionApprovalCache::new();
cache.record_session("a/b/c", Duration::from_millis(10));
sleep(Duration::from_millis(20));
assert!(!cache.is_approved("a/b/c"));
assert_eq!(cache.len(), 0, "expired entry should be evicted on access");
}
#[test]
fn forget_evicts_existing_entry() {
let cache = SessionApprovalCache::new();
cache.record_session("a/b/c", ttl_long());
assert!(cache.forget("a/b/c"));
assert!(!cache.is_approved("a/b/c"));
}
#[test]
fn forget_returns_false_for_missing_entry() {
let cache = SessionApprovalCache::new();
assert!(!cache.forget("a/b/c"));
}
#[test]
fn clear_drops_all_entries() {
let cache = SessionApprovalCache::new();
cache.record_session("a/b/c", ttl_long());
cache.record_session("d/e/f", ttl_long());
cache.clear();
assert!(cache.is_empty());
}
#[test]
fn record_session_replaces_existing_entry() {
let cache = SessionApprovalCache::new();
cache.record_session("a/b/c", Duration::from_millis(10));
sleep(Duration::from_millis(20));
cache.record_session("a/b/c", ttl_long());
assert!(cache.is_approved("a/b/c"));
}
#[test]
fn sweep_expired_drops_only_stale_entries() {
let cache = SessionApprovalCache::new();
cache.record_session("stale", Duration::from_millis(10));
cache.record_session("fresh", ttl_long());
sleep(Duration::from_millis(20));
assert_eq!(cache.sweep_expired(), 1);
assert!(cache.is_approved("fresh"));
assert!(!cache.is_approved("stale"));
}
use crate::alias::{AliasResolverError, SecretResolver};
use secrecy::{ExposeSecret, SecretString};
use std::sync::Mutex;
struct CountingResolver {
secrets: std::collections::HashMap<String, String>,
calls: Mutex<u32>,
}
impl CountingResolver {
fn new(entries: &[(&str, &str)]) -> Self {
Self {
secrets: entries
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect(),
calls: Mutex::new(0),
}
}
}
impl SecretResolver for CountingResolver {
fn resolve(&self, path: &str) -> Result<SecretString, AliasResolverError> {
*self.calls.lock().unwrap() += 1;
self.secrets
.get(path)
.map(|v| SecretString::from(v.clone()))
.ok_or_else(|| AliasResolverError::NotFound {
path: path.to_owned(),
})
}
}
#[test]
fn gated_resolver_passes_through_never_policy() {
let inner = CountingResolver::new(&[("team/x/y", "value-1")]);
let cache = Arc::new(SessionApprovalCache::new());
let gated = ApprovalGatedResolver::new(inner, cache, |_| ApproveOnUsePolicy::Never);
let v = gated.resolve("team/x/y").unwrap();
assert_eq!(v.expose_secret(), "value-1");
}
#[test]
fn gated_resolver_refuses_session_policy_without_cache_hit() {
let inner = CountingResolver::new(&[("team/x/y", "value-1")]);
let cache = Arc::new(SessionApprovalCache::new());
let gated =
ApprovalGatedResolver::new(inner, cache.clone(), |_| ApproveOnUsePolicy::Session);
let err = gated.resolve("team/x/y").unwrap_err();
match err {
AliasResolverError::Backend { path, message } => {
assert_eq!(path, "team/x/y");
assert!(
message.contains("session") && message.contains("user approval"),
"unexpected message: {message}"
);
}
other => panic!("expected Backend gate-required error, got {other:?}"),
}
}
#[test]
fn gated_resolver_passes_session_policy_after_cache_record() {
let inner = CountingResolver::new(&[("team/x/y", "value-1")]);
let cache = Arc::new(SessionApprovalCache::new());
cache.record_session("team/x/y", ttl_long());
let gated = ApprovalGatedResolver::new(inner, cache, |_| ApproveOnUsePolicy::Session);
let v = gated.resolve("team/x/y").unwrap();
assert_eq!(v.expose_secret(), "value-1");
}
#[test]
fn gated_resolver_always_refuses_per_call_even_with_cache() {
let inner = CountingResolver::new(&[("team/x/y", "value-1")]);
let cache = Arc::new(SessionApprovalCache::new());
cache.record_session("team/x/y", ttl_long());
let gated = ApprovalGatedResolver::new(inner, cache, |_| ApproveOnUsePolicy::PerCall);
let err = gated.resolve("team/x/y").unwrap_err();
assert!(matches!(err, AliasResolverError::Backend { .. }));
}
#[test]
fn gated_resolver_does_not_touch_inner_on_refusal() {
let cache = Arc::new(SessionApprovalCache::new());
let inner_box: Box<dyn SecretResolver> =
Box::new(CountingResolver::new(&[("team/x/y", "value-1")]));
let counter = Arc::new(Mutex::new(0u32));
let counter_clone = Arc::clone(&counter);
struct ProxyResolver {
inner: Box<dyn SecretResolver>,
counter: Arc<Mutex<u32>>,
}
impl SecretResolver for ProxyResolver {
fn resolve(&self, path: &str) -> Result<SecretString, AliasResolverError> {
*self.counter.lock().unwrap() += 1;
self.inner.resolve(path)
}
}
let proxy = ProxyResolver {
inner: inner_box,
counter: counter_clone,
};
let gated = ApprovalGatedResolver::new(proxy, cache, |_| ApproveOnUsePolicy::Session);
let _ = gated.resolve("team/x/y").unwrap_err();
assert_eq!(
*counter.lock().unwrap(),
0,
"inner resolver must not be touched on gate refusal"
);
}
#[test]
fn gated_resolver_call_count_zero_after_refusal() {
let cache = Arc::new(SessionApprovalCache::new());
let inner = CountingResolver::new(&[("team/prod-db/password", "v")]);
let gated = ApprovalGatedResolver::new(inner, cache, |path| {
if path == "team/prod-db/password" {
ApproveOnUsePolicy::PerCall
} else {
ApproveOnUsePolicy::Never
}
});
let _ = gated.resolve("team/prod-db/password").unwrap_err();
}
#[test]
fn gated_resolver_cache_accessor_exposes_handle_for_orchestrator() {
let inner = CountingResolver::new(&[]);
let cache = Arc::new(SessionApprovalCache::new());
let gated =
ApprovalGatedResolver::new(inner, Arc::clone(&cache), |_| ApproveOnUsePolicy::Session);
gated.cache().record_session("a/b/c", ttl_long());
assert!(cache.is_approved("a/b/c"));
}
}