microclaw-engine 0.10.1

Channel-independent Agent Engine and default services for MicroClaw
Documentation
use super::*;

#[derive(Debug, Clone)]
pub struct AuthApiKeyRecord {
    pub id: i64,
    pub label: String,
    pub prefix: String,
    pub created_at: String,
    pub revoked_at: Option<String>,
    pub expires_at: Option<String>,
    pub last_used_at: Option<String>,
    pub rotated_from_key_id: Option<i64>,
    pub scopes: Vec<String>,
}

impl Database {
    pub fn upsert_auth_password_hash(&self, password_hash: &str) -> Result<(), MicroClawError> {
        let conn = self.lock_conn();
        let now = chrono::Utc::now().to_rfc3339();
        conn.execute(
            "INSERT INTO auth_passwords(id, password_hash, created_at, updated_at)
             VALUES(1, ?1, ?2, ?2)
             ON CONFLICT(id) DO UPDATE SET
                password_hash = excluded.password_hash,
                updated_at = excluded.updated_at",
            params![password_hash, now],
        )?;
        Ok(())
    }

    pub fn get_auth_password_hash(&self) -> Result<Option<String>, MicroClawError> {
        let conn = self.lock_conn();
        let value = conn
            .query_row(
                "SELECT password_hash FROM auth_passwords WHERE id = 1",
                [],
                |row| row.get::<_, String>(0),
            )
            .optional()?;
        Ok(value)
    }

    pub fn clear_auth_password_hash(&self) -> Result<bool, MicroClawError> {
        let conn = self.lock_conn();
        let rows = conn.execute("DELETE FROM auth_passwords WHERE id = 1", [])?;
        Ok(rows > 0)
    }

    pub fn create_auth_session(
        &self,
        session_id: &str,
        label: Option<&str>,
        expires_at: &str,
    ) -> Result<(), MicroClawError> {
        let conn = self.lock_conn();
        let now = chrono::Utc::now().to_rfc3339();
        conn.execute(
            "INSERT INTO auth_sessions(session_id, label, created_at, expires_at, last_seen_at, revoked_at)
             VALUES(?1, ?2, ?3, ?4, ?3, NULL)",
            params![session_id, label, now, expires_at],
        )?;
        Ok(())
    }

    pub fn validate_auth_session(&self, session_id: &str) -> Result<bool, MicroClawError> {
        let conn = self.lock_conn();
        let now = chrono::Utc::now().to_rfc3339();
        let valid = conn
            .query_row(
                "SELECT 1
                 FROM auth_sessions
                 WHERE session_id = ?1
                   AND revoked_at IS NULL
                   AND expires_at > ?2
                 LIMIT 1",
                params![session_id, now],
                |_| Ok(()),
            )
            .optional()?
            .is_some();
        if valid {
            let _ = conn.execute(
                "UPDATE auth_sessions SET last_seen_at = ?2 WHERE session_id = ?1",
                params![session_id, now],
            );
        }
        Ok(valid)
    }

    pub fn revoke_auth_session(&self, session_id: &str) -> Result<bool, MicroClawError> {
        let conn = self.lock_conn();
        let now = chrono::Utc::now().to_rfc3339();
        let rows = conn.execute(
            "UPDATE auth_sessions
             SET revoked_at = COALESCE(revoked_at, ?2)
             WHERE session_id = ?1",
            params![session_id, now],
        )?;
        Ok(rows > 0)
    }

    pub fn revoke_all_auth_sessions(&self) -> Result<usize, MicroClawError> {
        let conn = self.lock_conn();
        let now = chrono::Utc::now().to_rfc3339();
        let rows = conn.execute(
            "UPDATE auth_sessions
             SET revoked_at = COALESCE(revoked_at, ?1)
             WHERE revoked_at IS NULL",
            params![now],
        )?;
        Ok(rows)
    }

    pub fn create_api_key(
        &self,
        label: &str,
        key_hash: &str,
        prefix: &str,
        scopes: &[String],
        expires_at: Option<&str>,
        rotated_from_key_id: Option<i64>,
    ) -> Result<i64, MicroClawError> {
        let conn = self.lock_conn();
        let now = chrono::Utc::now().to_rfc3339();
        let tx = conn.unchecked_transaction()?;
        tx.execute(
            "INSERT INTO api_keys(label, key_hash, prefix, created_at, expires_at, rotated_from_key_id)
             VALUES(?1, ?2, ?3, ?4, ?5, ?6)",
            params![label, key_hash, prefix, now, expires_at, rotated_from_key_id],
        )?;
        let key_id = tx.last_insert_rowid();
        for scope in scopes {
            tx.execute(
                "INSERT OR IGNORE INTO api_key_scopes(api_key_id, scope) VALUES(?1, ?2)",
                params![key_id, scope],
            )?;
        }
        tx.commit()?;
        Ok(key_id)
    }

    pub fn list_api_keys(&self) -> Result<Vec<AuthApiKeyRecord>, MicroClawError> {
        let conn = self.lock_conn();
        let mut stmt = conn.prepare(
            "SELECT id, label, prefix, created_at, revoked_at, expires_at, last_used_at, rotated_from_key_id
             FROM api_keys
             ORDER BY id DESC",
        )?;
        let mut rows = stmt.query([])?;
        let mut out = Vec::new();
        while let Some(row) = rows.next()? {
            let id: i64 = row.get(0)?;
            let mut scopes_stmt = conn.prepare(
                "SELECT scope FROM api_key_scopes WHERE api_key_id = ?1 ORDER BY scope ASC",
            )?;
            let scopes = scopes_stmt
                .query_map(params![id], |r| r.get::<_, String>(0))?
                .collect::<Result<Vec<_>, _>>()?;
            out.push(AuthApiKeyRecord {
                id,
                label: row.get(1)?,
                prefix: row.get(2)?,
                created_at: row.get(3)?,
                revoked_at: row.get(4)?,
                expires_at: row.get(5)?,
                last_used_at: row.get(6)?,
                rotated_from_key_id: row.get(7)?,
                scopes,
            });
        }
        Ok(out)
    }

    pub fn rotate_api_key_revoke_old(&self, old_key_id: i64) -> Result<bool, MicroClawError> {
        self.revoke_api_key(old_key_id)
    }

    pub fn revoke_api_key(&self, key_id: i64) -> Result<bool, MicroClawError> {
        let conn = self.lock_conn();
        let now = chrono::Utc::now().to_rfc3339();
        let rows = conn.execute(
            "UPDATE api_keys
             SET revoked_at = COALESCE(revoked_at, ?2)
             WHERE id = ?1",
            params![key_id, now],
        )?;
        Ok(rows > 0)
    }

    pub fn validate_api_key_hash(
        &self,
        key_hash: &str,
    ) -> Result<Option<(i64, Vec<String>)>, MicroClawError> {
        let conn = self.lock_conn();
        let row = conn
            .query_row(
                "SELECT id FROM api_keys
                 WHERE key_hash = ?1
                   AND revoked_at IS NULL
                   AND (expires_at IS NULL OR expires_at > ?2)
                 LIMIT 1",
                params![key_hash, chrono::Utc::now().to_rfc3339()],
                |r| r.get::<_, i64>(0),
            )
            .optional()?;
        let Some(key_id) = row else {
            return Ok(None);
        };
        let now = chrono::Utc::now().to_rfc3339();
        let _ = conn.execute(
            "UPDATE api_keys SET last_used_at = ?2 WHERE id = ?1",
            params![key_id, now],
        );
        let mut stmt = conn
            .prepare("SELECT scope FROM api_key_scopes WHERE api_key_id = ?1 ORDER BY scope ASC")?;
        let scopes = stmt
            .query_map(params![key_id], |r| r.get::<_, String>(0))?
            .collect::<Result<Vec<_>, _>>()?;
        Ok(Some((key_id, scopes)))
    }
}

#[cfg(test)]
mod tests {
    use crate::internal::storage::db::test_support::*;

    #[test]
    fn test_api_key_expiry_and_rotation_and_audit_logs() {
        let (db, dir) = test_db();
        let scopes = vec![
            "operator.read".to_string(),
            "operator.approvals".to_string(),
        ];
        let key_id = db
            .create_api_key(
                "k1",
                "hash-k1",
                "prefix-k1",
                &scopes,
                Some(&(chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339()),
                None,
            )
            .unwrap();
        let valid = db.validate_api_key_hash("hash-k1").unwrap();
        assert!(valid.is_some());

        let expired_id = db
            .create_api_key(
                "k2",
                "hash-k2",
                "prefix-k2",
                &scopes,
                Some(&(chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339()),
                Some(key_id),
            )
            .unwrap();
        let expired = db.validate_api_key_hash("hash-k2").unwrap();
        assert!(expired.is_none());
        assert!(db.rotate_api_key_revoke_old(key_id).unwrap());

        let keys = db.list_api_keys().unwrap();
        let rotated = keys.iter().find(|k| k.id == expired_id).unwrap();
        assert_eq!(rotated.rotated_from_key_id, Some(key_id));

        db.log_audit_event(
            "operator",
            "tester",
            "auth.api_key.rotate",
            Some("k1"),
            "ok",
            None,
        )
        .unwrap();
        let logs = db.list_audit_logs(Some("operator"), 20).unwrap();
        assert!(!logs.is_empty());

        cleanup(&dir);
    }
}