Skip to main content

microclaw_engine/internal/storage/db/
auth.rs

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}