Skip to content

Commit ea3c4ad

Browse files
robotizeitclaude
andcommitted
feat(security): autonomous agent token recovery via Vault self-issue
Add Strategy 3 to TokenProvider::refresh(): when Vault read and env fallback both yield no new token, generate a 32-byte CSPRNG token, write it to the agent's Vault KV path, and swap it in-memory. Since Stacker validates by reading the same Vault KV path, the next signed poll is accepted — no operator intervention or stacker-cli re-install required. Emit a token_self_issued audit event (target=audit, level=warn) so SIEM/SOC pipelines can distinguish agent-initiated recovery from Stacker-pushed rotations. Tests cover both the happy path (read 404 → write 200 → token swapped) and the unauthorized path (write 403 → token unchanged), with mock expectations to verify Strategy 3 actually hit Vault. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9b44aed commit ea3c4ad

3 files changed

Lines changed: 124 additions & 1 deletion

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/security/audit_log.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ impl AuditLogger {
4646
info!(target: "audit", event = "token_rotated", agent_id, request_id = request_id.unwrap_or(""));
4747
}
4848

49+
pub fn token_self_issued(&self, deployment_hash: &str, reason: &str) {
50+
warn!(target: "audit", event = "token_self_issued", deployment_hash, reason);
51+
}
52+
4953
pub fn internal_error(
5054
&self,
5155
agent_id: Option<&str>,

src/security/token_provider.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,28 @@ impl TokenProvider {
108108
return Ok(true);
109109
}
110110

111+
// Strategy 3: self-issue a new token and write it to Vault.
112+
// Stacker validates by reading from the same Vault KV path, so a token
113+
// the agent writes there is immediately accepted on the next poll.
114+
if let Some(vault) = &self.vault_client {
115+
let new_token = generate_secure_token();
116+
match vault
117+
.store_agent_token(&self.deployment_hash, &new_token, None)
118+
.await
119+
{
120+
Ok(()) => {
121+
let mut token = self.token.write().await;
122+
*token = new_token;
123+
super::audit_log::AuditLogger::new()
124+
.token_self_issued(&self.deployment_hash, "primary_strategies_failed");
125+
return Ok(true);
126+
}
127+
Err(e) => {
128+
warn!(error = %e, "Vault self-issue failed; token unchanged");
129+
}
130+
}
131+
}
132+
111133
debug!("No new token available after refresh attempt");
112134
Ok(false)
113135
}
@@ -121,6 +143,14 @@ impl TokenProvider {
121143
}
122144
}
123145

146+
fn generate_secure_token() -> String {
147+
use ring::rand::{SecureRandom, SystemRandom};
148+
let rng = SystemRandom::new();
149+
let mut bytes = [0u8; 32];
150+
rng.fill(&mut bytes).expect("CSPRNG failure");
151+
bytes.iter().map(|b| format!("{:02x}", b)).collect()
152+
}
153+
124154
#[cfg(test)]
125155
mod tests {
126156
use super::*;
@@ -190,4 +220,93 @@ mod tests {
190220
tp2.swap("b".into()).await;
191221
assert_eq!(tp.get().await, "b");
192222
}
223+
224+
#[tokio::test]
225+
async fn refresh_self_issues_token_when_vault_has_no_entry() {
226+
use crate::security::vault_client::VaultClient;
227+
use mockito::Server;
228+
229+
let _guard = env_lock().lock().unwrap();
230+
231+
let mut server = Server::new_async().await;
232+
233+
// Strategy 1: Vault read returns 404 (KV entry was deleted)
234+
let read = server
235+
.mock("GET", "/v1/status_panel/dep-abc/status_panel_token")
236+
.with_status(404)
237+
.with_body(r#"{"errors":[]}"#)
238+
.expect(1)
239+
.create_async()
240+
.await;
241+
242+
// Strategy 3: Vault write accepted (must be called exactly once)
243+
let write = server
244+
.mock("POST", "/v1/status_panel/dep-abc/status_panel_token")
245+
.with_status(200)
246+
.with_body("{}")
247+
.expect(1)
248+
.create_async()
249+
.await;
250+
251+
let _addr = EnvGuard::set("VAULT_ADDRESS", &server.url());
252+
let _tok = EnvGuard::set("VAULT_TOKEN", "vault-root");
253+
let _prefix = EnvGuard::set("VAULT_AGENT_PATH_PREFIX", "status_panel");
254+
// Strategy 2 is a no-op: env token matches current
255+
let _agent = EnvGuard::set("AGENT_TOKEN", "stale-token");
256+
257+
let vault = VaultClient::from_env().unwrap().unwrap();
258+
let tp = TokenProvider::new("stale-token".into(), Some(vault), "dep-abc".into());
259+
260+
let changed = tp.refresh().await.unwrap();
261+
assert!(changed, "expected self-issued token to be applied");
262+
263+
let new_token = tp.get().await;
264+
assert_ne!(new_token, "stale-token");
265+
assert_eq!(new_token.len(), 64, "expected 32-byte hex token");
266+
assert!(new_token.chars().all(|c| c.is_ascii_hexdigit()));
267+
268+
read.assert_async().await;
269+
write.assert_async().await;
270+
}
271+
272+
#[tokio::test]
273+
async fn refresh_keeps_token_when_vault_write_forbidden() {
274+
use crate::security::vault_client::VaultClient;
275+
use mockito::Server;
276+
277+
let _guard = env_lock().lock().unwrap();
278+
279+
let mut server = Server::new_async().await;
280+
281+
// Strategy 1: Vault read returns 404
282+
let _read = server
283+
.mock("GET", "/v1/status_panel/dep-xyz/status_panel_token")
284+
.with_status(404)
285+
.with_body(r#"{"errors":[]}"#)
286+
.create_async()
287+
.await;
288+
289+
// Strategy 3: Vault write rejected (agent lacks write capability)
290+
let write = server
291+
.mock("POST", "/v1/status_panel/dep-xyz/status_panel_token")
292+
.with_status(403)
293+
.with_body(r#"{"errors":["permission denied"]}"#)
294+
.expect(1)
295+
.create_async()
296+
.await;
297+
298+
let _addr = EnvGuard::set("VAULT_ADDRESS", &server.url());
299+
let _tok = EnvGuard::set("VAULT_TOKEN", "vault-root");
300+
let _prefix = EnvGuard::set("VAULT_AGENT_PATH_PREFIX", "status_panel");
301+
let _agent = EnvGuard::set("AGENT_TOKEN", "stale-token");
302+
303+
let vault = VaultClient::from_env().unwrap().unwrap();
304+
let tp = TokenProvider::new("stale-token".into(), Some(vault), "dep-xyz".into());
305+
306+
let changed = tp.refresh().await.unwrap();
307+
assert!(!changed, "Strategy 3 must not claim success on write failure");
308+
assert_eq!(tp.get().await, "stale-token", "token must not change on failure");
309+
310+
write.assert_async().await;
311+
}
193312
}

0 commit comments

Comments
 (0)