From f8ec2d7cf13cdb412a1a86087e00845845c4c634 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 14:37:43 +0200 Subject: [PATCH] feat(db): users table + repository (create/by_id/by_email/list), audited Co-Authored-By: Claude Sonnet 4.6 --- crates/db/migrations/0006_users.sql | 11 +++ crates/db/src/lib.rs | 1 + crates/db/src/users.rs | 121 ++++++++++++++++++++++++++++ crates/db/tests/users.rs | 100 +++++++++++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 crates/db/migrations/0006_users.sql create mode 100644 crates/db/src/users.rs create mode 100644 crates/db/tests/users.rs diff --git a/crates/db/migrations/0006_users.sql b/crates/db/migrations/0006_users.sql new file mode 100644 index 0000000..c934fa8 --- /dev/null +++ b/crates/db/migrations/0006_users.sql @@ -0,0 +1,11 @@ +-- Users of this organization's instance. One database == one organization, so no +-- org_id. Email is stored already-normalized (lowercase) by the application, so a +-- plain UNIQUE suffices. Passwords are stored only as argon2id PHC strings. +CREATE TABLE app_user ( + id UUID PRIMARY KEY, + email TEXT NOT NULL UNIQUE CHECK (email <> ''), + password_hash TEXT NOT NULL CHECK (password_hash <> ''), + role TEXT NOT NULL CHECK (role IN ('admin', 'editor')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index 403e041..17267b8 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -5,6 +5,7 @@ pub mod authority; pub mod catalog; pub mod fields; pub mod seed; +pub mod users; pub mod vocab; use sqlx::postgres::{PgPool, PgPoolOptions}; diff --git a/crates/db/src/users.rs b/crates/db/src/users.rs new file mode 100644 index 0000000..b874b5e --- /dev/null +++ b/crates/db/src/users.rs @@ -0,0 +1,121 @@ +//! Users of this organization's instance. All SQL for users lives here. + +use domain::{ + AuditAction, AuditActor, Email, FieldChange, NewAuditEvent, NewUser, Role, User, UserId, +}; +use serde_json::json; +use sqlx::Row; + +use crate::audit; + +const ENTITY_TYPE: &str = "user"; + +const USER_COLUMNS: &str = "id, email, role"; + +/// Create a user and record a `created` audit entry (email + role only — never the +/// password hash), both on `conn`. Pass a transaction connection. +pub async fn create_user( + conn: &mut sqlx::PgConnection, + actor: AuditActor, + new: &NewUser, +) -> Result { + let id = UserId::new(); + + sqlx::query("INSERT INTO app_user (id, email, password_hash, role) VALUES ($1, $2, $3, $4)") + .bind(id.to_uuid()) + .bind(new.email.as_str()) + .bind(&new.password_hash) + .bind(new.role.as_str()) + .execute(&mut *conn) + .await?; + + audit::record( + &mut *conn, + &NewAuditEvent { + actor, + action: AuditAction::Created, + entity_type: ENTITY_TYPE.to_owned(), + entity_id: id.to_uuid(), + changes: vec![ + FieldChange { + field: "email".to_owned(), + before: None, + after: Some(json!(new.email.as_str())), + }, + FieldChange { + field: "role".to_owned(), + before: None, + after: Some(json!(new.role.as_str())), + }, + ], + }, + ) + .await?; + + Ok(id) +} + +/// Fetch a user by id. +pub async fn user_by_id<'e, E>(executor: E, id: UserId) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + let sql = format!("SELECT {USER_COLUMNS} FROM app_user WHERE id = $1"); + + let row = sqlx::query(&sql) + .bind(id.to_uuid()) + .fetch_optional(executor) + .await?; + + row.map(map_user).transpose() +} + +/// Fetch a user and their password hash by (normalized) email, for login. +pub async fn credentials_by_email<'e, E>( + executor: E, + email: &str, +) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + let sql = format!("SELECT {USER_COLUMNS}, password_hash FROM app_user WHERE email = $1"); + + let row = sqlx::query(&sql) + .bind(email) + .fetch_optional(executor) + .await?; + + match row { + Some(row) => { + let hash: String = row.try_get("password_hash")?; + + Ok(Some((map_user(row)?, hash))) + } + None => Ok(None), + } +} + +/// List all users, ordered by email. +pub async fn list_users<'e, E>(executor: E) -> Result, sqlx::Error> +where + E: sqlx::PgExecutor<'e>, +{ + let sql = format!("SELECT {USER_COLUMNS} FROM app_user ORDER BY email"); + + let rows = sqlx::query(&sql).fetch_all(executor).await?; + + rows.into_iter().map(map_user).collect() +} + +fn map_user(row: sqlx::postgres::PgRow) -> Result { + let role_str: String = row.try_get("role")?; + + let role = Role::from_db(&role_str) + .ok_or_else(|| sqlx::Error::Decode(format!("unknown role: {role_str}").into()))?; + + Ok(User { + id: UserId::from_uuid(row.try_get("id")?), + email: Email::from_db(row.try_get("email")?), + role, + }) +} diff --git a/crates/db/tests/users.rs b/crates/db/tests/users.rs new file mode 100644 index 0000000..e6a85be --- /dev/null +++ b/crates/db/tests/users.rs @@ -0,0 +1,100 @@ +use db::{Db, audit, users}; +use domain::{AuditAction, AuditActor, Email, NewUser, Role}; +use sqlx::PgPool; + +fn new_user(email: &str, role: Role) -> NewUser { + NewUser { + email: Email::parse(email).unwrap(), + password_hash: "$argon2id$dummy".to_owned(), + role, + } +} + +#[sqlx::test] +async fn create_then_fetch_by_id_and_email(pool: PgPool) { + let db = Db::from_pool(pool); + + let mut tx = db.pool().begin().await.unwrap(); + let id = users::create_user( + &mut tx, + AuditActor::System, + &new_user("anna@example.com", Role::Admin), + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let user = users::user_by_id(db.pool(), id).await.unwrap().unwrap(); + assert_eq!(user.email.as_str(), "anna@example.com"); + assert_eq!(user.role, Role::Admin); + + let (by_email, hash) = users::credentials_by_email(db.pool(), "anna@example.com") + .await + .unwrap() + .unwrap(); + assert_eq!(by_email.id, id); + assert_eq!(hash, "$argon2id$dummy"); +} + +#[sqlx::test] +async fn create_user_audits_email_and_role_but_never_the_hash(pool: PgPool) { + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + let id = users::create_user( + &mut tx, + AuditActor::System, + &new_user("anna@example.com", Role::Editor), + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let history = audit::history_for(db.pool(), "user", id.to_uuid()) + .await + .unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].action, AuditAction::Created); + let mut fields: Vec<&str> = history[0] + .changes + .iter() + .map(|c| c.field.as_str()) + .collect(); + fields.sort_unstable(); + assert_eq!(fields, vec!["email", "role"]); +} + +#[sqlx::test] +async fn missing_email_returns_none(pool: PgPool) { + let db = Db::from_pool(pool); + assert!( + users::credentials_by_email(db.pool(), "nobody@example.com") + .await + .unwrap() + .is_none() + ); +} + +#[sqlx::test] +async fn list_users_is_ordered_by_email(pool: PgPool) { + let db = Db::from_pool(pool); + let mut tx = db.pool().begin().await.unwrap(); + users::create_user( + &mut tx, + AuditActor::System, + &new_user("zoe@example.com", Role::Editor), + ) + .await + .unwrap(); + users::create_user( + &mut tx, + AuditActor::System, + &new_user("amy@example.com", Role::Admin), + ) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let users = users::list_users(db.pool()).await.unwrap(); + let emails: Vec<&str> = users.iter().map(|u| u.email.as_str()).collect(); + assert_eq!(emails, vec!["amy@example.com", "zoe@example.com"]); +}