feat(daemon): storage::userdb sqlite cache for enrollment registry

This commit is contained in:
michael
2026-04-27 06:29:36 -07:00
parent eb3362c81c
commit 90f7a0f4fc
2 changed files with 139 additions and 0 deletions

View File

@@ -1,3 +1,4 @@
pub(crate) mod credentials; pub(crate) mod credentials;
pub(crate) mod pending; pub(crate) mod pending;
pub(crate) mod policy; pub(crate) mod policy;
pub(crate) mod userdb;

View File

@@ -0,0 +1,138 @@
use authforge_common::types::Method;
use rusqlite::{params, Connection};
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Error)]
pub(crate) enum UserDbError {
#[error("sqlite: {0}")]
Sqlite(#[from] rusqlite::Error),
}
#[allow(dead_code)] // wired through AppState in Task 2.15.
pub(crate) struct UserDb {
conn: Connection,
}
#[allow(dead_code)] // wired through AppState in Task 2.15.
impl UserDb {
pub fn open(path: &Path) -> Result<Self, UserDbError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
let conn = Connection::open(path)?;
Self::init(&conn)?;
Ok(Self { conn })
}
pub fn open_in_memory() -> Result<Self, UserDbError> {
let conn = Connection::open_in_memory()?;
Self::init(&conn)?;
Ok(Self { conn })
}
fn init(conn: &Connection) -> Result<(), UserDbError> {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
has_fido2 INTEGER NOT NULL DEFAULT 0,
has_totp INTEGER NOT NULL DEFAULT 0,
last_seen INTEGER NOT NULL DEFAULT 0
) WITHOUT ROWID;
"#,
)?;
Ok(())
}
pub fn record_enrollment(&self, user: &str, method: Method) -> Result<(), UserDbError> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let col = method_col(method);
let sql = format!(
"INSERT INTO users (username, {col}, last_seen) VALUES (?1, 1, ?2)
ON CONFLICT(username) DO UPDATE SET {col} = 1, last_seen = ?2"
);
self.conn.execute(&sql, params![user, now])?;
Ok(())
}
pub fn drop_enrollment(&self, user: &str, method: Method) -> Result<(), UserDbError> {
let col = method_col(method);
let sql = format!("UPDATE users SET {col} = 0 WHERE username = ?1");
self.conn.execute(&sql, params![user])?;
Ok(())
}
pub fn users_with(&self, method: Method) -> Result<Vec<String>, UserDbError> {
let col = method_col(method);
let sql = format!("SELECT username FROM users WHERE {col} = 1");
let mut stmt = self.conn.prepare(&sql)?;
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
Ok(rows.filter_map(Result::ok).collect())
}
pub fn has_any(&self, user: &str) -> Result<bool, UserDbError> {
let row: Option<i64> = self
.conn
.query_row(
"SELECT 1 FROM users WHERE username = ?1 AND (has_fido2 = 1 OR has_totp = 1)",
params![user],
|r| r.get(0),
)
.ok();
Ok(row.is_some())
}
}
fn method_col(m: Method) -> &'static str {
match m {
Method::Fido2 => "has_fido2",
Method::Totp => "has_totp",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_in_memory_creates_schema() {
let db = UserDb::open_in_memory().unwrap();
let row: i64 = db
.conn
.query_row(
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name='users'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(row, 1);
}
#[test]
fn record_then_list_users_with_method() {
let db = UserDb::open_in_memory().unwrap();
db.record_enrollment("alice", Method::Fido2).unwrap();
db.record_enrollment("bob", Method::Totp).unwrap();
db.record_enrollment("alice", Method::Totp).unwrap();
let mut fido2 = db.users_with(Method::Fido2).unwrap();
fido2.sort();
assert_eq!(fido2, vec!["alice".to_string()]);
let mut totp = db.users_with(Method::Totp).unwrap();
totp.sort();
assert_eq!(totp, vec!["alice".to_string(), "bob".to_string()]);
assert!(db.has_any("alice").unwrap());
assert!(!db.has_any("nobody").unwrap());
db.drop_enrollment("alice", Method::Fido2).unwrap();
assert!(db.users_with(Method::Fido2).unwrap().is_empty());
// alice still has totp.
assert!(db.has_any("alice").unwrap());
}
}