use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecretRequest {
pub secret_key: String,
pub reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GrantKind {
Secret(String),
Tool(String),
}
impl std::fmt::Display for GrantKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Secret(_) => write!(f, "Secret(<redacted>)"),
Self::Tool(name) => write!(f, "Tool({name})"),
}
}
}
#[derive(Debug)]
pub struct Grant {
pub(crate) kind: GrantKind,
pub(crate) granted_at: Instant,
pub(crate) ttl: Duration,
}
impl Grant {
#[must_use]
pub fn new(kind: GrantKind, ttl: Duration) -> Self {
Self {
kind,
granted_at: Instant::now(),
ttl,
}
}
#[must_use]
pub fn is_expired(&self) -> bool {
self.granted_at.elapsed() >= self.ttl
}
}
#[derive(Debug, Default)]
pub struct PermissionGrants {
grants: Vec<Grant>,
}
impl Drop for PermissionGrants {
fn drop(&mut self) {
if !self.grants.is_empty() {
tracing::warn!(
count = self.grants.len(),
"PermissionGrants dropped with active grants — revoking"
);
self.grants.clear();
}
}
}
impl PermissionGrants {
pub fn add(&mut self, kind: GrantKind, ttl: Duration) {
tracing::debug!(kind = %kind, ?ttl, "permission grant added");
self.grants.push(Grant::new(kind, ttl));
}
pub fn sweep_expired(&mut self) {
let before = self.grants.len();
self.grants.retain(|g| {
let expired = g.is_expired();
if expired {
tracing::debug!(kind = %g.kind, "permission grant expired and revoked");
}
!expired
});
let removed = before - self.grants.len();
if removed > 0 {
tracing::debug!(removed, "swept expired grants");
}
}
#[must_use]
pub fn is_active(&mut self, kind: &GrantKind) -> bool {
self.sweep_expired();
self.grants.iter().any(|g| &g.kind == kind)
}
pub fn grant_secret(&mut self, key: impl Into<String>, ttl: Duration) {
self.sweep_expired();
let key = key.into();
tracing::debug!("vault secret granted to sub-agent (key redacted), ttl={ttl:?}");
self.add(GrantKind::Secret(key), ttl);
}
#[must_use]
pub fn is_empty_grants(&self) -> bool {
self.grants.is_empty()
}
pub fn revoke_all(&mut self) {
let count = self.grants.len();
self.grants.clear();
if count > 0 {
tracing::debug!(count, "all permission grants revoked");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn grant_is_active_before_expiry() {
let mut pg = PermissionGrants::default();
pg.add(
GrantKind::Secret("api-key".into()),
Duration::from_secs(300),
);
assert!(pg.is_active(&GrantKind::Secret("api-key".into())));
}
#[test]
fn sweep_expired_removes_instant_ttl() {
let mut pg = PermissionGrants::default();
pg.grants.push(Grant {
kind: GrantKind::Tool("shell".into()),
granted_at: Instant::now() - Duration::from_secs(10),
ttl: Duration::from_secs(1), });
assert!(!pg.is_active(&GrantKind::Tool("shell".into())));
assert!(pg.grants.is_empty());
}
#[test]
fn revoke_all_clears_all_grants() {
let mut pg = PermissionGrants::default();
pg.add(GrantKind::Secret("token".into()), Duration::from_secs(60));
pg.add(GrantKind::Tool("web".into()), Duration::from_secs(60));
pg.revoke_all();
assert!(pg.grants.is_empty());
}
#[test]
fn grant_secret_is_active() {
let mut pg = PermissionGrants::default();
pg.grant_secret("db-password", Duration::from_secs(120));
assert!(pg.is_active(&GrantKind::Secret("db-password".into())));
}
#[test]
fn whitespace_description_invalid() {
let k = GrantKind::Secret("my-secret-key".into());
let display = k.to_string();
assert!(
!display.contains("my-secret-key"),
"secret key must be redacted in Display"
);
assert!(display.contains("redacted"));
}
#[test]
fn tool_grant_display_shows_name() {
let k = GrantKind::Tool("shell".into());
assert_eq!(k.to_string(), "Tool(shell)");
}
#[test]
fn partial_sweep_keeps_non_expired_grants() {
let mut pg = PermissionGrants::default();
pg.grants.push(Grant {
kind: GrantKind::Tool("expired-tool".into()),
granted_at: Instant::now() - Duration::from_secs(10),
ttl: Duration::from_secs(1),
});
pg.add(
GrantKind::Secret("live-key".into()),
Duration::from_secs(300),
);
pg.sweep_expired();
assert_eq!(pg.grants.len(), 1, "only live grant should remain");
assert_eq!(pg.grants[0].kind, GrantKind::Secret("live-key".into()));
}
#[test]
fn duplicate_grant_for_same_key_both_tracked() {
let mut pg = PermissionGrants::default();
pg.add(GrantKind::Secret("my-key".into()), Duration::from_secs(60));
pg.add(GrantKind::Secret("my-key".into()), Duration::from_secs(60));
assert_eq!(pg.grants.len(), 2);
assert!(pg.is_active(&GrantKind::Secret("my-key".into())));
pg.revoke_all();
assert!(pg.grants.is_empty());
}
}