From 696a5e1ac707ee0e3d19e7350b8affc0251a5b9d Mon Sep 17 00:00:00 2001 From: TheGeneralist <180094941+thegeneralist01@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:30:02 +0200 Subject: [PATCH] feat(auth): add initialize_auth_schema, open_auth_db, and auth record types --- crates/archivr-core/src/database.rs | 141 ++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/crates/archivr-core/src/database.rs b/crates/archivr-core/src/database.rs index 69521dc..fdd80a1 100644 --- a/crates/archivr-core/src/database.rs +++ b/crates/archivr-core/src/database.rs @@ -73,6 +73,31 @@ pub struct TagRecord { pub full_path: String, } +#[derive(Debug, Clone)] +pub struct AuthUserRecord { + pub id: i64, + pub user_uid: String, + pub username: String, + pub password_hash: String, + pub status: String, +} + +#[derive(Debug, Clone)] +pub struct SessionRecord { + pub user_id: i64, + pub role_bits: u32, + pub last_seen_at: String, + pub session_uid: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct ApiTokenRecord { + pub token_uid: String, + pub name: String, + pub created_at: String, + pub last_used_at: Option, +} + pub fn database_path(archive_path: &Path) -> PathBuf { archive_path.join(DATABASE_FILE_NAME) } @@ -230,6 +255,101 @@ pub fn initialize_schema(conn: &Connection) -> Result<()> { Ok(()) } +pub fn initialize_auth_schema(conn: &Connection) -> Result<()> { + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "foreign_keys", "ON")?; + conn.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS roles ( + id INTEGER PRIMARY KEY, + role_uid TEXT NOT NULL UNIQUE, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + level INTEGER NOT NULL, + bit_position INTEGER NOT NULL UNIQUE, + is_builtin INTEGER NOT NULL DEFAULT 0 CHECK (is_builtin IN (0, 1)) + ); + + INSERT OR IGNORE INTO roles (role_uid, slug, name, level, bit_position, is_builtin) VALUES + ('role-guest', 'guest', 'Guest', 0, 0, 1), + ('role-user', 'user', 'User', 1, 1, 1), + ('role-admin', 'admin', 'Admin', 3, 2, 1), + ('role-owner', 'owner', 'Owner', 4, 3, 1); + + CREATE TABLE IF NOT EXISTS user_roles ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id INTEGER NOT NULL REFERENCES roles(id), + assigned_at TEXT NOT NULL, + assigned_by_user_id INTEGER REFERENCES users(id), + PRIMARY KEY (user_id, role_id) + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY, + session_uid TEXT NOT NULL UNIQUE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_bits INTEGER NOT NULL, + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + user_agent TEXT + ); + CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); + + CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY, + token_uid TEXT NOT NULL UNIQUE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + last_used_at TEXT, + expires_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens(user_id); + + CREATE TABLE IF NOT EXISTS instance_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + public_index_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_index_enabled IN (0, 1)), + public_entry_content_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_entry_content_enabled IN (0, 1)), + public_archive_submission_enabled INTEGER NOT NULL DEFAULT 0 CHECK (public_archive_submission_enabled IN (0, 1)), + default_entry_visibility INTEGER NOT NULL DEFAULT 2 + ); + + INSERT OR IGNORE INTO instance_settings + (id, public_index_enabled, public_entry_content_enabled, + public_archive_submission_enabled, default_entry_visibility) + VALUES (1, 0, 0, 0, 2); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY, + user_uid TEXT NOT NULL UNIQUE, + username TEXT NOT NULL UNIQUE, + email TEXT UNIQUE, + password_hash TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN ('active', 'disabled')), + role TEXT NOT NULL CHECK (role IN ('admin', 'user')), + created_at TEXT NOT NULL, + last_login_at TEXT + ); + "#, + )?; + Ok(()) +} + +pub fn open_auth_db(auth_db_path: &Path) -> Result { + if let Some(parent) = auth_db_path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!("failed to create auth DB directory {}", parent.display()) + })?; + } + let conn = Connection::open(auth_db_path).with_context(|| { + format!("failed to open auth database at {}", auth_db_path.display()) + })?; + initialize_auth_schema(&conn)?; + Ok(conn) +} + pub fn ensure_default_user(conn: &Connection) -> Result { if let Some(id) = conn .query_row( @@ -1149,4 +1269,25 @@ mod tests { let result = get_blob_by_sha256(&conn, "0000000000000000000000000000000000000000000000000000000000000000").unwrap(); assert!(result.is_none()); } + + #[test] + fn auth_schema_seeds_builtin_roles() { + let conn = Connection::open_in_memory().unwrap(); + initialize_auth_schema(&conn).unwrap(); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM roles WHERE is_builtin = 1", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 4); + let owner_bits: i64 = conn + .query_row("SELECT bit_position FROM roles WHERE slug = 'owner'", [], |r| r.get(0)) + .unwrap(); + assert_eq!(owner_bits, 3); + } + + #[test] + fn auth_schema_is_idempotent() { + let conn = Connection::open_in_memory().unwrap(); + initialize_auth_schema(&conn).unwrap(); + initialize_auth_schema(&conn).unwrap(); + } }