agentguard/commands/
apikey.rs

1//! Operator commands for managing standalone PDP API-key records.
2
3use 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}