1use std::time::{Duration, Instant};
5
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct SecretRequest {
13 pub secret_key: String,
15 pub reason: Option<String>,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum GrantKind {
26 Secret(String),
28 Tool(String),
30}
31
32impl std::fmt::Display for GrantKind {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 match self {
35 Self::Secret(_) => write!(f, "Secret(<redacted>)"),
36 Self::Tool(name) => write!(f, "Tool({name})"),
37 }
38 }
39}
40
41#[derive(Debug)]
42pub struct Grant {
43 pub(crate) kind: GrantKind,
44 pub(crate) granted_at: Instant,
45 pub(crate) ttl: Duration,
46}
47
48impl Grant {
49 #[must_use]
50 pub fn new(kind: GrantKind, ttl: Duration) -> Self {
51 Self {
52 kind,
53 granted_at: Instant::now(),
54 ttl,
55 }
56 }
57
58 #[must_use]
59 pub fn is_expired(&self) -> bool {
60 self.granted_at.elapsed() >= self.ttl
61 }
62}
63
64#[derive(Debug, Default)]
70pub struct PermissionGrants {
71 grants: Vec<Grant>,
72}
73
74impl Drop for PermissionGrants {
75 fn drop(&mut self) {
76 if !self.grants.is_empty() {
79 tracing::warn!(
80 count = self.grants.len(),
81 "PermissionGrants dropped with active grants — revoking"
82 );
83 self.grants.clear();
84 }
85 }
86}
87
88impl PermissionGrants {
89 pub fn add(&mut self, kind: GrantKind, ttl: Duration) {
91 tracing::debug!(kind = %kind, ?ttl, "permission grant added");
93 self.grants.push(Grant::new(kind, ttl));
94 }
95
96 pub fn sweep_expired(&mut self) {
98 let before = self.grants.len();
99 self.grants.retain(|g| {
100 let expired = g.is_expired();
101 if expired {
102 tracing::debug!(kind = %g.kind, "permission grant expired and revoked");
103 }
104 !expired
105 });
106 let removed = before - self.grants.len();
107 if removed > 0 {
108 tracing::debug!(removed, "swept expired grants");
109 }
110 }
111
112 #[must_use]
116 pub fn is_active(&mut self, kind: &GrantKind) -> bool {
117 self.sweep_expired();
118 self.grants.iter().any(|g| &g.kind == kind)
119 }
120
121 pub fn grant_secret(&mut self, key: impl Into<String>, ttl: Duration) {
126 self.sweep_expired();
127 let key = key.into();
128 tracing::debug!("vault secret granted to sub-agent (key redacted), ttl={ttl:?}");
129 self.add(GrantKind::Secret(key), ttl);
130 }
131
132 #[must_use]
136 pub fn is_empty_grants(&self) -> bool {
137 self.grants.is_empty()
138 }
139
140 pub fn revoke_all(&mut self) {
142 let count = self.grants.len();
143 self.grants.clear();
144 if count > 0 {
145 tracing::debug!(count, "all permission grants revoked");
146 }
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn grant_is_active_before_expiry() {
156 let mut pg = PermissionGrants::default();
157 pg.add(
158 GrantKind::Secret("api-key".into()),
159 Duration::from_secs(300),
160 );
161 assert!(pg.is_active(&GrantKind::Secret("api-key".into())));
162 }
163
164 #[test]
165 fn sweep_expired_removes_instant_ttl() {
166 let mut pg = PermissionGrants::default();
167 pg.grants.push(Grant {
168 kind: GrantKind::Tool("shell".into()),
169 granted_at: Instant::now().checked_sub(Duration::from_secs(10)).unwrap(),
170 ttl: Duration::from_secs(1), });
172 assert!(!pg.is_active(&GrantKind::Tool("shell".into())));
174 assert!(pg.grants.is_empty());
175 }
176
177 #[test]
178 fn revoke_all_clears_all_grants() {
179 let mut pg = PermissionGrants::default();
180 pg.add(GrantKind::Secret("token".into()), Duration::from_secs(60));
181 pg.add(GrantKind::Tool("web".into()), Duration::from_secs(60));
182 pg.revoke_all();
183 assert!(pg.grants.is_empty());
184 }
185
186 #[test]
187 fn grant_secret_is_active() {
188 let mut pg = PermissionGrants::default();
189 pg.grant_secret("db-password", Duration::from_secs(120));
190 assert!(pg.is_active(&GrantKind::Secret("db-password".into())));
191 }
192
193 #[test]
194 fn whitespace_description_invalid() {
195 let k = GrantKind::Secret("my-secret-key".into());
197 let display = k.to_string();
198 assert!(
199 !display.contains("my-secret-key"),
200 "secret key must be redacted in Display"
201 );
202 assert!(display.contains("redacted"));
203 }
204
205 #[test]
206 fn tool_grant_display_shows_name() {
207 let k = GrantKind::Tool("shell".into());
208 assert_eq!(k.to_string(), "Tool(shell)");
209 }
210
211 #[test]
212 fn partial_sweep_keeps_non_expired_grants() {
213 let mut pg = PermissionGrants::default();
214
215 pg.grants.push(Grant {
217 kind: GrantKind::Tool("expired-tool".into()),
218 granted_at: Instant::now().checked_sub(Duration::from_secs(10)).unwrap(),
219 ttl: Duration::from_secs(1),
220 });
221
222 pg.add(
224 GrantKind::Secret("live-key".into()),
225 Duration::from_secs(300),
226 );
227
228 pg.sweep_expired();
229
230 assert_eq!(pg.grants.len(), 1, "only live grant should remain");
231 assert_eq!(pg.grants[0].kind, GrantKind::Secret("live-key".into()));
232 }
233
234 #[test]
235 fn duplicate_grant_for_same_key_both_tracked() {
236 let mut pg = PermissionGrants::default();
237 pg.add(GrantKind::Secret("my-key".into()), Duration::from_secs(60));
238 pg.add(GrantKind::Secret("my-key".into()), Duration::from_secs(60));
239
240 assert_eq!(pg.grants.len(), 2);
242 assert!(pg.is_active(&GrantKind::Secret("my-key".into())));
243
244 pg.revoke_all();
246 assert!(pg.grants.is_empty());
247 }
248}