1use super::*;
2
3#[derive(Debug, Clone)]
4pub struct AuthApiKeyRecord {
5 pub id: i64,
6 pub label: String,
7 pub prefix: String,
8 pub created_at: String,
9 pub revoked_at: Option<String>,
10 pub expires_at: Option<String>,
11 pub last_used_at: Option<String>,
12 pub rotated_from_key_id: Option<i64>,
13 pub scopes: Vec<String>,
14}
15
16impl Database {
17 pub fn upsert_auth_password_hash(&self, password_hash: &str) -> Result<(), MicroClawError> {
18 let conn = self.lock_conn();
19 let now = chrono::Utc::now().to_rfc3339();
20 conn.execute(
21 "INSERT INTO auth_passwords(id, password_hash, created_at, updated_at)
22 VALUES(1, ?1, ?2, ?2)
23 ON CONFLICT(id) DO UPDATE SET
24 password_hash = excluded.password_hash,
25 updated_at = excluded.updated_at",
26 params![password_hash, now],
27 )?;
28 Ok(())
29 }
30
31 pub fn get_auth_password_hash(&self) -> Result<Option<String>, MicroClawError> {
32 let conn = self.lock_conn();
33 let value = conn
34 .query_row(
35 "SELECT password_hash FROM auth_passwords WHERE id = 1",
36 [],
37 |row| row.get::<_, String>(0),
38 )
39 .optional()?;
40 Ok(value)
41 }
42
43 pub fn clear_auth_password_hash(&self) -> Result<bool, MicroClawError> {
44 let conn = self.lock_conn();
45 let rows = conn.execute("DELETE FROM auth_passwords WHERE id = 1", [])?;
46 Ok(rows > 0)
47 }
48
49 pub fn create_auth_session(
50 &self,
51 session_id: &str,
52 label: Option<&str>,
53 expires_at: &str,
54 ) -> Result<(), MicroClawError> {
55 let conn = self.lock_conn();
56 let now = chrono::Utc::now().to_rfc3339();
57 conn.execute(
58 "INSERT INTO auth_sessions(session_id, label, created_at, expires_at, last_seen_at, revoked_at)
59 VALUES(?1, ?2, ?3, ?4, ?3, NULL)",
60 params![session_id, label, now, expires_at],
61 )?;
62 Ok(())
63 }
64
65 pub fn validate_auth_session(&self, session_id: &str) -> Result<bool, MicroClawError> {
66 let conn = self.lock_conn();
67 let now = chrono::Utc::now().to_rfc3339();
68 let valid = conn
69 .query_row(
70 "SELECT 1
71 FROM auth_sessions
72 WHERE session_id = ?1
73 AND revoked_at IS NULL
74 AND expires_at > ?2
75 LIMIT 1",
76 params![session_id, now],
77 |_| Ok(()),
78 )
79 .optional()?
80 .is_some();
81 if valid {
82 let _ = conn.execute(
83 "UPDATE auth_sessions SET last_seen_at = ?2 WHERE session_id = ?1",
84 params![session_id, now],
85 );
86 }
87 Ok(valid)
88 }
89
90 pub fn revoke_auth_session(&self, session_id: &str) -> Result<bool, MicroClawError> {
91 let conn = self.lock_conn();
92 let now = chrono::Utc::now().to_rfc3339();
93 let rows = conn.execute(
94 "UPDATE auth_sessions
95 SET revoked_at = COALESCE(revoked_at, ?2)
96 WHERE session_id = ?1",
97 params![session_id, now],
98 )?;
99 Ok(rows > 0)
100 }
101
102 pub fn revoke_all_auth_sessions(&self) -> Result<usize, MicroClawError> {
103 let conn = self.lock_conn();
104 let now = chrono::Utc::now().to_rfc3339();
105 let rows = conn.execute(
106 "UPDATE auth_sessions
107 SET revoked_at = COALESCE(revoked_at, ?1)
108 WHERE revoked_at IS NULL",
109 params![now],
110 )?;
111 Ok(rows)
112 }
113
114 pub fn create_api_key(
115 &self,
116 label: &str,
117 key_hash: &str,
118 prefix: &str,
119 scopes: &[String],
120 expires_at: Option<&str>,
121 rotated_from_key_id: Option<i64>,
122 ) -> Result<i64, MicroClawError> {
123 let conn = self.lock_conn();
124 let now = chrono::Utc::now().to_rfc3339();
125 let tx = conn.unchecked_transaction()?;
126 tx.execute(
127 "INSERT INTO api_keys(label, key_hash, prefix, created_at, expires_at, rotated_from_key_id)
128 VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
129 params![label, key_hash, prefix, now, expires_at, rotated_from_key_id],
130 )?;
131 let key_id = tx.last_insert_rowid();
132 for scope in scopes {
133 tx.execute(
134 "INSERT OR IGNORE INTO api_key_scopes(api_key_id, scope) VALUES(?1, ?2)",
135 params![key_id, scope],
136 )?;
137 }
138 tx.commit()?;
139 Ok(key_id)
140 }
141
142 pub fn list_api_keys(&self) -> Result<Vec<AuthApiKeyRecord>, MicroClawError> {
143 let conn = self.lock_conn();
144 let mut stmt = conn.prepare(
145 "SELECT id, label, prefix, created_at, revoked_at, expires_at, last_used_at, rotated_from_key_id
146 FROM api_keys
147 ORDER BY id DESC",
148 )?;
149 let mut rows = stmt.query([])?;
150 let mut out = Vec::new();
151 while let Some(row) = rows.next()? {
152 let id: i64 = row.get(0)?;
153 let mut scopes_stmt = conn.prepare(
154 "SELECT scope FROM api_key_scopes WHERE api_key_id = ?1 ORDER BY scope ASC",
155 )?;
156 let scopes = scopes_stmt
157 .query_map(params![id], |r| r.get::<_, String>(0))?
158 .collect::<Result<Vec<_>, _>>()?;
159 out.push(AuthApiKeyRecord {
160 id,
161 label: row.get(1)?,
162 prefix: row.get(2)?,
163 created_at: row.get(3)?,
164 revoked_at: row.get(4)?,
165 expires_at: row.get(5)?,
166 last_used_at: row.get(6)?,
167 rotated_from_key_id: row.get(7)?,
168 scopes,
169 });
170 }
171 Ok(out)
172 }
173
174 pub fn rotate_api_key_revoke_old(&self, old_key_id: i64) -> Result<bool, MicroClawError> {
175 self.revoke_api_key(old_key_id)
176 }
177
178 pub fn revoke_api_key(&self, key_id: i64) -> Result<bool, MicroClawError> {
179 let conn = self.lock_conn();
180 let now = chrono::Utc::now().to_rfc3339();
181 let rows = conn.execute(
182 "UPDATE api_keys
183 SET revoked_at = COALESCE(revoked_at, ?2)
184 WHERE id = ?1",
185 params![key_id, now],
186 )?;
187 Ok(rows > 0)
188 }
189
190 pub fn validate_api_key_hash(
191 &self,
192 key_hash: &str,
193 ) -> Result<Option<(i64, Vec<String>)>, MicroClawError> {
194 let conn = self.lock_conn();
195 let row = conn
196 .query_row(
197 "SELECT id FROM api_keys
198 WHERE key_hash = ?1
199 AND revoked_at IS NULL
200 AND (expires_at IS NULL OR expires_at > ?2)
201 LIMIT 1",
202 params![key_hash, chrono::Utc::now().to_rfc3339()],
203 |r| r.get::<_, i64>(0),
204 )
205 .optional()?;
206 let Some(key_id) = row else {
207 return Ok(None);
208 };
209 let now = chrono::Utc::now().to_rfc3339();
210 let _ = conn.execute(
211 "UPDATE api_keys SET last_used_at = ?2 WHERE id = ?1",
212 params![key_id, now],
213 );
214 let mut stmt = conn
215 .prepare("SELECT scope FROM api_key_scopes WHERE api_key_id = ?1 ORDER BY scope ASC")?;
216 let scopes = stmt
217 .query_map(params![key_id], |r| r.get::<_, String>(0))?
218 .collect::<Result<Vec<_>, _>>()?;
219 Ok(Some((key_id, scopes)))
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use crate::internal::storage::db::test_support::*;
226
227 #[test]
228 fn test_api_key_expiry_and_rotation_and_audit_logs() {
229 let (db, dir) = test_db();
230 let scopes = vec![
231 "operator.read".to_string(),
232 "operator.approvals".to_string(),
233 ];
234 let key_id = db
235 .create_api_key(
236 "k1",
237 "hash-k1",
238 "prefix-k1",
239 &scopes,
240 Some(&(chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339()),
241 None,
242 )
243 .unwrap();
244 let valid = db.validate_api_key_hash("hash-k1").unwrap();
245 assert!(valid.is_some());
246
247 let expired_id = db
248 .create_api_key(
249 "k2",
250 "hash-k2",
251 "prefix-k2",
252 &scopes,
253 Some(&(chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339()),
254 Some(key_id),
255 )
256 .unwrap();
257 let expired = db.validate_api_key_hash("hash-k2").unwrap();
258 assert!(expired.is_none());
259 assert!(db.rotate_api_key_revoke_old(key_id).unwrap());
260
261 let keys = db.list_api_keys().unwrap();
262 let rotated = keys.iter().find(|k| k.id == expired_id).unwrap();
263 assert_eq!(rotated.rotated_from_key_id, Some(key_id));
264
265 db.log_audit_event(
266 "operator",
267 "tester",
268 "auth.api_key.rotate",
269 Some("k1"),
270 "ok",
271 None,
272 )
273 .unwrap();
274 let logs = db.list_audit_logs(Some("operator"), 20).unwrap();
275 assert!(!logs.is_empty());
276
277 cleanup(&dir);
278 }
279}