1use agentguard_auth::{ApiKeyIdentity, ApiKeyStore};
4use anyhow::{bail, Context, Result};
5use serde_json::json;
6use std::path::Path;
7use std::time::Duration;
8
9pub struct CreateKeyOptions<'a> {
10 pub path: &'a Path,
11 pub prefix: &'a str,
12 pub subject_type: &'a str,
13 pub subject_id: &'a str,
14 pub tenant_id: Option<&'a str>,
15 pub scopes: Vec<String>,
16 pub ttl_seconds: u64,
17}
18
19pub fn create(options: CreateKeyOptions<'_>, output: &str) -> Result<()> {
20 if options.ttl_seconds == 0 {
21 bail!("--ttl-seconds must be greater than zero");
22 }
23 if options.scopes.is_empty() || options.scopes.iter().any(|scope| scope.trim().is_empty()) {
24 bail!("at least one non-empty --scope is required");
25 }
26 let identity = ApiKeyIdentity::new(
27 options.subject_type,
28 options.subject_id,
29 options.tenant_id.map(str::to_owned),
30 )?;
31 let path = options.path;
32 let store = ApiKeyStore::load_from_file(path)
33 .with_context(|| format!("load API-key store {}", path.display()))?;
34 let (key, raw_secret) = store.create_bound(
35 options.prefix,
36 options.scopes,
37 Some(Duration::from_secs(options.ttl_seconds)),
38 identity,
39 )?;
40 store
41 .save_to_file(path)
42 .with_context(|| format!("persist API-key store {}", path.display()))?;
43
44 let result = json!({
45 "id": key.id,
46 "prefix": key.prefix,
47 "raw_secret": raw_secret,
48 "scopes": key.scopes,
49 "identity": key.identity,
50 "created_at": key.created_at,
51 "expires_at": key.expires_at,
52 });
53 if output == "json" {
54 println!("{}", serde_json::to_string_pretty(&result)?);
55 } else {
56 println!("API key created; copy the secret now (it will not be shown again):");
57 println!(" id: {}", key.id);
58 println!(
59 " subject: {}::{}",
60 options.subject_type, options.subject_id
61 );
62 println!(" scopes: {}", key.scopes.join(", "));
63 println!(" secret: {raw_secret}");
64 }
65 Ok(())
66}
67
68pub fn list(path: impl AsRef<Path>, output: &str) -> Result<()> {
69 let path = path.as_ref();
70 let store = ApiKeyStore::load_from_file(path)
71 .with_context(|| format!("load API-key store {}", path.display()))?;
72 let mut keys = store.list();
73 keys.sort_by(|left, right| {
74 left.created_at
75 .cmp(&right.created_at)
76 .then(left.id.cmp(&right.id))
77 });
78 let metadata: Vec<_> = keys
79 .into_iter()
80 .map(|key| {
81 json!({
82 "id": key.id,
83 "prefix": key.prefix,
84 "scopes": key.scopes,
85 "identity": key.identity,
86 "created_at": key.created_at,
87 "expires_at": key.expires_at,
88 "revoked_at": key.revoked_at,
89 })
90 })
91 .collect();
92 if output == "json" {
93 println!("{}", serde_json::to_string_pretty(&metadata)?);
94 } else if metadata.is_empty() {
95 println!("No API keys in {}", path.display());
96 } else {
97 for key in metadata {
98 let scopes = key["scopes"]
99 .as_array()
100 .map(|items| {
101 items
102 .iter()
103 .filter_map(|scope| scope.as_str())
104 .collect::<Vec<_>>()
105 .join(",")
106 })
107 .unwrap_or_default();
108 let identity = key["identity"]
109 .as_object()
110 .map(|identity| {
111 format!(
112 "{}::{}",
113 identity
114 .get("subject_type")
115 .and_then(|value| value.as_str())
116 .unwrap_or_default(),
117 identity
118 .get("subject_id")
119 .and_then(|value| value.as_str())
120 .unwrap_or_default(),
121 )
122 })
123 .unwrap_or_else(|| "unbound (legacy)".into());
124 println!(
125 "{} {} scopes={} identity={} status={}",
126 key["id"].as_str().unwrap_or_default(),
127 key["prefix"].as_str().unwrap_or_default(),
128 scopes,
129 identity,
130 if key["revoked_at"].is_null() {
131 "active"
132 } else {
133 "revoked"
134 },
135 );
136 }
137 }
138 Ok(())
139}
140
141pub fn revoke(path: impl AsRef<Path>, id: &str, output: &str) -> Result<()> {
142 let path = path.as_ref();
143 let store = ApiKeyStore::load_from_file(path)
144 .with_context(|| format!("load API-key store {}", path.display()))?;
145 store
146 .revoke(id)
147 .with_context(|| format!("revoke API key {id}"))?;
148 store
149 .save_to_file(path)
150 .with_context(|| format!("persist API-key store {}", path.display()))?;
151 if output == "json" {
152 println!("{}", json!({"id": id, "revoked": true}));
153 } else {
154 println!("Revoked API key {id}");
155 }
156 Ok(())
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn create_persists_bound_key_metadata() {
165 let dir = tempfile::tempdir().unwrap();
166 let path = dir.path().join("keys.json");
167 create(
168 CreateKeyOptions {
169 path: &path,
170 prefix: "ag_test",
171 subject_type: "Agent",
172 subject_id: "research",
173 tenant_id: Some("tenant-a"),
174 scopes: vec!["authorize".into()],
175 ttl_seconds: 3600,
176 },
177 "json",
178 )
179 .unwrap();
180 let store = ApiKeyStore::load_from_file(&path).unwrap();
181 let keys = store.list();
182 assert_eq!(keys.len(), 1);
183 assert_eq!(keys[0].identity.as_ref().unwrap().subject_id, "research");
184 assert!(keys[0].has_scope("authorize"));
185 assert_eq!(
186 keys[0].identity.as_ref().unwrap().tenant_id.as_deref(),
187 Some("tenant-a")
188 );
189 }
190
191 #[test]
192 fn create_rejects_invalid_identity_or_zero_expiry() {
193 let dir = tempfile::tempdir().unwrap();
194 let path = dir.path().join("keys.json");
195 let options = |subject_type, ttl_seconds| CreateKeyOptions {
196 path: &path,
197 prefix: "ag_test",
198 subject_type,
199 subject_id: "alice",
200 tenant_id: None,
201 scopes: vec!["authorize".into()],
202 ttl_seconds,
203 };
204 assert!(create(options("Service", 60), "json").is_err());
205 assert!(create(options("User", 0), "json").is_err());
206 assert!(!path.exists());
207 }
208
209 #[test]
210 fn revoke_is_durable_and_unknown_id_does_not_rewrite_store() {
211 let dir = tempfile::tempdir().unwrap();
212 let path = dir.path().join("keys.json");
213 let store = ApiKeyStore::new();
214 let (key, _) = store
215 .create_bound(
216 "ag_test",
217 vec!["authorize".into()],
218 None,
219 ApiKeyIdentity::new("User", "alice", None).unwrap(),
220 )
221 .unwrap();
222 store.save_to_file(&path).unwrap();
223 revoke(&path, &key.id, "json").unwrap();
224 let reopened = ApiKeyStore::load_from_file(&path).unwrap();
225 assert!(reopened.list()[0].revoked_at.is_some());
226 let before = std::fs::read(&path).unwrap();
227 assert!(revoke(&path, "missing", "json").is_err());
228 assert_eq!(std::fs::read(&path).unwrap(), before);
229 }
230}