csaf-models 0.2.1

CSAF 2.0/2.1 data models, SQLite management, and user models
Documentation
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne

//! User model with Argon2id password hashing.

use argon2::password_hash::SaltString;
use argon2::password_hash::rand_core::OsRng;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};

use crate::db::DbPool;

/// A user account.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
    /// Database ID.
    pub id: i64,
    /// Unique UUID.
    pub uuid: String,
    /// Login name.
    pub login: String,
    /// Display name.
    pub name: String,
    /// Email address.
    pub email: String,
    /// API key.
    pub apikey: String,
    /// Whether the account is active.
    pub is_active: bool,
    /// Whether the account has admin privileges.
    pub is_admin: bool,
    /// Account creation timestamp.
    pub created_at: String,
    /// Last seen timestamp.
    pub last_seen: Option<String>,
}

/// Hash a password using Argon2id (RFC 9106).
///
/// # Errors
///
/// Returns an error string if hashing fails.
pub fn hash_password(password: &str) -> Result<String, String> {
    let salt = SaltString::generate(&mut OsRng);
    let argon2 = Argon2::default();
    argon2
        .hash_password(password.as_bytes(), &salt)
        .map(|h| h.to_string())
        .map_err(|e| format!("Password hashing failed: {e}"))
}

/// Verify a password against an Argon2id hash.
///
/// # Errors
///
/// Returns an error string if verification fails or the hash is invalid.
pub fn verify_password(password: &str, hash: &str) -> Result<bool, String> {
    let parsed_hash = PasswordHash::new(hash).map_err(|e| format!("Invalid password hash: {e}"))?;
    Ok(Argon2::default()
        .verify_password(password.as_bytes(), &parsed_hash)
        .is_ok())
}

/// Create a new user in the database.
///
/// # Errors
///
/// Returns a database error if the insert fails.
#[allow(clippy::too_many_arguments)]
pub fn create_user(
    conn: &Connection,
    login: &str,
    name: &str,
    email: &str,
    password: &str,
    is_admin: bool,
) -> Result<User, String> {
    let uuid = uuid::Uuid::new_v4().to_string();
    let apikey = uuid::Uuid::new_v4().to_string();
    let pwdhash = hash_password(password)?;

    conn.execute(
        "INSERT INTO users (uuid, login, name, email, pwdhash, apikey, is_admin) \
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
        params![
            uuid,
            login,
            name,
            email,
            pwdhash,
            apikey,
            i32::from(is_admin)
        ],
    )
    .map_err(|e| format!("Failed to create user: {e}"))?;

    let id = conn.last_insert_rowid();

    Ok(User {
        id,
        uuid,
        login: login.to_owned(),
        name: name.to_owned(),
        email: email.to_owned(),
        apikey,
        is_active: true,
        is_admin,
        created_at: chrono::Utc::now().to_rfc3339(),
        last_seen: None,
    })
}

/// Find a user by login name.
///
/// # Errors
///
/// Returns a database error if the query fails.
pub fn find_by_login(pool: &DbPool, login: &str) -> Result<Option<User>, String> {
    pool.with_conn(|conn| {
        let mut stmt = conn.prepare(
            "SELECT id, uuid, login, name, email, apikey, is_active, is_admin, \
             created_at, last_seen FROM users WHERE login = ?1",
        )?;

        let user = stmt
            .query_row(params![login], |row| {
                Ok(User {
                    id: row.get("id")?,
                    uuid: row.get("uuid")?,
                    login: row.get("login")?,
                    name: row.get("name")?,
                    email: row.get("email")?,
                    apikey: row.get("apikey")?,
                    is_active: row.get::<_, i32>("is_active")? != 0,
                    is_admin: row.get::<_, i32>("is_admin")? != 0,
                    created_at: row.get("created_at")?,
                    last_seen: row.get("last_seen")?,
                })
            })
            .optional()?;

        Ok(user)
    })
    .map_err(|e| format!("Database query failed: {e}"))
}

use rusqlite::OptionalExtension;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_hash_and_verify_password() {
        // Test fixture — not a real credential.
        let test_input = "secure_p@ssw0rd_2026";
        let hash = hash_password(test_input).expect("hashing failed");
        assert!(verify_password(test_input, &hash).expect("verify failed"));
        assert!(!verify_password("wrong_input", &hash).expect("verify failed"));
    }

    #[test]
    fn test_create_user() {
        let pool = DbPool::open_in_memory().expect("DB open failed");
        pool.with_conn(|conn| {
            let user = create_user(
                conn,
                "testuser",
                "Test User",
                "test@example.com",
                "password123",
                false,
            )
            .expect("user creation failed");
            assert_eq!(user.login, "testuser");
            assert!(!user.is_admin);
            assert!(user.is_active);
            Ok(())
        })
        .expect("with_conn failed");
    }

    #[test]
    fn test_find_by_login() {
        let pool = DbPool::open_in_memory().expect("DB open failed");
        pool.with_conn(|conn| {
            create_user(conn, "admin", "Admin", "admin@test.com", "admin123", true)
                .expect("user creation failed");
            Ok(())
        })
        .expect("with_conn failed");

        let user = find_by_login(&pool, "admin")
            .expect("query failed")
            .expect("user not found");
        assert_eq!(user.login, "admin");
        assert!(user.is_admin);

        let missing = find_by_login(&pool, "nonexistent").expect("query failed");
        assert!(missing.is_none());
    }
}