agentmux_srv/backend/storage/
agent_credentials.rs1use rusqlite::params;
13
14use super::error::StoreError;
15use super::store::Store;
16
17#[derive(Debug, Clone, Default)]
18pub struct AgentCredential {
19 pub client_id: String,
20 pub client_secret: String,
21 pub token_endpoint: String,
22 pub access_token: String,
25 pub expires_at: i64,
26}
27
28impl AgentCredential {
29 pub fn is_valid(&self) -> bool {
33 !self.access_token.is_empty() && self.expires_at - agentmux_common::time::now_secs() > 300
34 }
35}
36
37fn scoped_key(agent_id: &str) -> String {
42 format!("{}{}", super::muxbus::current_agent_credential_prefix(), agent_id.to_lowercase())
43}
44
45impl Store {
46 pub fn agent_credential_load(&self, agent_id: &str) -> Result<Option<AgentCredential>, StoreError> {
47 let key = scoped_key(agent_id);
48 let conn = self.conn.lock().unwrap();
49 let mut stmt = conn.prepare(
50 "SELECT client_id, client_secret, token_endpoint, access_token, expires_at
51 FROM db_agent_credentials WHERE agent_id = ?1",
52 )?;
53 match stmt.query_row(params![key], |row| {
54 Ok(AgentCredential {
55 client_id: row.get(0)?,
56 client_secret: row.get(1)?,
57 token_endpoint: row.get(2)?,
58 access_token: row.get(3)?,
59 expires_at: row.get(4)?,
60 })
61 }) {
62 Ok(c) => Ok(Some(c)),
63 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
64 Err(e) => Err(e.into()),
65 }
66 }
67
68 pub fn agent_credential_save(
72 &self,
73 agent_id: &str,
74 client_id: &str,
75 client_secret: &str,
76 token_endpoint: &str,
77 ) -> Result<(), StoreError> {
78 let key = scoped_key(agent_id);
79 let conn = self.conn.lock().unwrap();
80 conn.execute(
81 "INSERT INTO db_agent_credentials
82 (agent_id, client_id, client_secret, token_endpoint, created_at)
83 VALUES (?1, ?2, ?3, ?4, ?5)
84 ON CONFLICT(agent_id) DO UPDATE SET
85 client_id = excluded.client_id,
86 client_secret = excluded.client_secret,
87 token_endpoint = excluded.token_endpoint",
88 params![key, client_id, client_secret, token_endpoint, agentmux_common::time::now_secs()],
89 )?;
90 Ok(())
91 }
92
93 pub fn agent_credential_save_token(
97 &self,
98 agent_id: &str,
99 access_token: &str,
100 expires_at: i64,
101 ) -> Result<(), StoreError> {
102 let key = scoped_key(agent_id);
103 let conn = self.conn.lock().unwrap();
104 conn.execute(
105 "UPDATE db_agent_credentials SET access_token = ?2, expires_at = ?3 WHERE agent_id = ?1",
106 params![key, access_token, expires_at],
107 )?;
108 Ok(())
109 }
110
111 pub fn agent_credential_invalidate_token(&self, agent_id: &str) -> Result<(), StoreError> {
119 let key = scoped_key(agent_id);
120 let conn = self.conn.lock().unwrap();
121 conn.execute(
122 "UPDATE db_agent_credentials SET access_token = '', expires_at = 0 WHERE agent_id = ?1",
123 params![key],
124 )?;
125 Ok(())
126 }
127
128 pub fn agent_credential_delete(&self, agent_id: &str) -> Result<(), StoreError> {
134 let key = scoped_key(agent_id);
135 let conn = self.conn.lock().unwrap();
136 conn.execute("DELETE FROM db_agent_credentials WHERE agent_id = ?1", params![key])?;
137 Ok(())
138 }
139
140 pub fn agent_credentials_clear_all(&self) -> Result<(), StoreError> {
151 self.agent_credentials_clear_prefix(&super::muxbus::current_agent_credential_prefix())
152 }
153
154 pub fn agent_credentials_purge_agent(&self, agent_id: &str) -> Result<usize, StoreError> {
156 let conn = self.conn.lock().unwrap();
157 Ok(conn.execute(
158 "DELETE FROM db_agent_credentials
159 WHERE agent_id = ?1 OR (instr(agent_id, '/') > 0 AND substr(agent_id, instr(agent_id, '/') + 1) = ?1)",
160 params![agent_id.to_lowercase()],
161 )?)
162 }
163
164 fn agent_credentials_clear_prefix(&self, prefix: &str) -> Result<(), StoreError> {
167 let conn = self.conn.lock().unwrap();
168 if prefix.is_empty() {
169 conn.execute("DELETE FROM db_agent_credentials WHERE instr(agent_id, '/') = 0", [])?;
172 } else {
173 conn.execute(
174 "DELETE FROM db_agent_credentials WHERE substr(agent_id, 1, length(?1)) = ?1",
175 params![prefix],
176 )?;
177 }
178 Ok(())
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 fn shared_store() -> Store {
190 Store::open_shared(std::path::Path::new(":memory:")).unwrap()
191 }
192
193 #[test]
196 fn a_channels_credentials_are_its_own_rows_and_clear_all_clears_only_those() {
197 let store = shared_store();
198 let conn = store.conn.lock().unwrap();
199 for key in ["agentx", "local-main-x/agentx", "local-main-y/agentx"] {
200 conn.execute(
201 "INSERT INTO db_agent_credentials (agent_id, client_id) VALUES (?1, 'c')",
202 params![key],
203 )
204 .unwrap();
205 }
206 let count = |conn: &rusqlite::Connection| -> i64 {
207 conn.query_row("SELECT COUNT(*) FROM db_agent_credentials", [], |r| r.get(0)).unwrap()
208 };
209 assert_eq!(count(&conn), 3);
210 drop(conn);
211 let left = |store: &Store| -> Vec<String> {
212 let conn = store.conn.lock().unwrap();
213 let mut stmt = conn.prepare("SELECT agent_id FROM db_agent_credentials ORDER BY agent_id").unwrap();
214 stmt.query_map([], |r| r.get(0)).unwrap().collect::<Result<_, _>>().unwrap()
215 };
216 store.agent_credentials_clear_prefix("").unwrap();
218 assert_eq!(left(&store), vec!["local-main-x/agentx".to_string(), "local-main-y/agentx".to_string()]);
219 store.agent_credentials_clear_prefix("local-main-x/").unwrap();
221 assert_eq!(left(&store), vec!["local-main-y/agentx".to_string()]);
222 }
223
224 #[test]
226 fn purging_an_agent_removes_its_rows_in_every_channel() {
227 let store = shared_store();
228 {
229 let conn = store.conn.lock().unwrap();
230 for key in ["agentx", "local-main-x/agentx", "dev-main-y/agentx", "agentxy", "local-main-x/agenty"] {
231 conn.execute("INSERT INTO db_agent_credentials (agent_id, client_id) VALUES (?1, 'c')", params![key])
232 .unwrap();
233 }
234 }
235 assert_eq!(store.agent_credentials_purge_agent("AgentX").unwrap(), 3);
236 let conn = store.conn.lock().unwrap();
237 let left: Vec<String> = conn
238 .prepare("SELECT agent_id FROM db_agent_credentials ORDER BY agent_id")
239 .unwrap()
240 .query_map([], |r| r.get(0))
241 .unwrap()
242 .collect::<Result<_, _>>()
243 .unwrap();
244 assert_eq!(left, vec!["agentxy".to_string(), "local-main-x/agenty".to_string()]);
245 }
246
247 #[test]
248 fn load_returns_none_when_unprovisioned() {
249 let store = shared_store();
250 assert!(store.agent_credential_load("agentx").unwrap().is_none());
251 }
252
253 #[test]
254 fn save_then_load_round_trips() {
255 let store = shared_store();
256 store
257 .agent_credential_save("AgentX", "client-1", "secret-1", "https://auth.example.com/oauth2/token")
258 .unwrap();
259
260 let loaded = store.agent_credential_load("agentx").unwrap().unwrap();
263 assert_eq!(loaded.client_id, "client-1");
264 assert_eq!(loaded.client_secret, "secret-1");
265 assert_eq!(loaded.token_endpoint, "https://auth.example.com/oauth2/token");
266 assert_eq!(loaded.access_token, "");
267 assert!(!loaded.is_valid()); }
269
270 #[test]
271 fn save_is_idempotent_and_updates_in_place() {
272 let store = shared_store();
273 store.agent_credential_save("agentx", "client-1", "secret-1", "endpoint-1").unwrap();
274 store.agent_credential_save("agentx", "client-2", "secret-2", "endpoint-2").unwrap();
275
276 let loaded = store.agent_credential_load("agentx").unwrap().unwrap();
277 assert_eq!(loaded.client_id, "client-2");
278 assert_eq!(loaded.client_secret, "secret-2");
279 }
280
281 #[test]
282 fn save_token_caches_access_token_and_expiry() {
283 let store = shared_store();
284 store.agent_credential_save("agentx", "client-1", "secret-1", "endpoint-1").unwrap();
285
286 let future = agentmux_common::time::now_secs() + 3600;
287 store.agent_credential_save_token("agentx", "tok-abc", future).unwrap();
288
289 let loaded = store.agent_credential_load("agentx").unwrap().unwrap();
290 assert_eq!(loaded.access_token, "tok-abc");
291 assert_eq!(loaded.expires_at, future);
292 assert!(loaded.is_valid());
293 }
294
295 #[test]
296 fn save_token_is_a_noop_when_not_provisioned() {
297 let store = shared_store();
298 store.agent_credential_save_token("agentx", "tok-abc", agentmux_common::time::now_secs() + 3600).unwrap();
300 assert!(store.agent_credential_load("agentx").unwrap().is_none());
301 }
302
303 #[test]
304 fn invalidate_token_clears_token_and_expiry_but_keeps_client() {
305 let store = shared_store();
306 store.agent_credential_save("agentx", "client-1", "secret-1", "endpoint-1").unwrap();
307 store
308 .agent_credential_save_token("agentx", "tok-abc", agentmux_common::time::now_secs() + 3600)
309 .unwrap();
310
311 store.agent_credential_invalidate_token("agentx").unwrap();
312
313 let loaded = store.agent_credential_load("agentx").unwrap().unwrap();
314 assert_eq!(loaded.access_token, "");
315 assert_eq!(loaded.expires_at, 0);
316 assert!(!loaded.is_valid());
317 assert_eq!(loaded.client_id, "client-1");
319 assert_eq!(loaded.client_secret, "secret-1");
320 }
321
322 #[test]
323 fn invalidate_token_is_a_noop_when_not_provisioned() {
324 let store = shared_store();
325 store.agent_credential_invalidate_token("agentx").unwrap();
326 assert!(store.agent_credential_load("agentx").unwrap().is_none());
327 }
328
329 #[test]
330 fn delete_removes_only_that_agents_row() {
331 let store = shared_store();
332 store.agent_credential_save("agentx", "client-1", "secret-1", "endpoint-1").unwrap();
333 store.agent_credential_save("agenty", "client-2", "secret-2", "endpoint-2").unwrap();
334
335 store.agent_credential_delete("AgentX").unwrap();
336
337 assert!(store.agent_credential_load("agentx").unwrap().is_none());
338 assert!(store.agent_credential_load("agenty").unwrap().is_some());
339 store.agent_credential_delete("agentx").unwrap();
341 }
342
343 #[test]
344 fn clear_all_wipes_every_agent_credential() {
345 let store = shared_store();
346 store.agent_credential_save("agentx", "client-1", "secret-1", "endpoint-1").unwrap();
347 store.agent_credential_save("agenty", "client-2", "secret-2", "endpoint-2").unwrap();
348
349 store.agent_credentials_clear_all().unwrap();
350
351 assert!(store.agent_credential_load("agentx").unwrap().is_none());
352 assert!(store.agent_credential_load("agenty").unwrap().is_none());
353 }
354
355 #[test]
356 fn clear_all_is_a_noop_on_an_empty_table() {
357 let store = shared_store();
358 store.agent_credentials_clear_all().unwrap();
359 assert!(store.agent_credential_load("agentx").unwrap().is_none());
360 }
361
362 #[test]
363 fn is_valid_respects_the_300s_early_refresh_margin() {
364 let mut cred = AgentCredential {
365 client_id: "c".to_string(),
366 client_secret: "s".to_string(),
367 token_endpoint: "e".to_string(),
368 access_token: "tok".to_string(),
369 expires_at: agentmux_common::time::now_secs() + 301,
370 };
371 assert!(cred.is_valid());
372
373 cred.expires_at = agentmux_common::time::now_secs() + 299;
374 assert!(!cred.is_valid());
375 }
376
377 #[test]
378 fn is_valid_false_with_no_access_token() {
379 let cred = AgentCredential {
380 expires_at: agentmux_common::time::now_secs() + 3600,
381 ..Default::default()
382 };
383 assert!(!cred.is_valid());
384 }
385}