feat(db): users table + repository (create/by_id/by_email/list), audited

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 14:37:43 +02:00
parent 9597a42eeb
commit f8ec2d7cf1
4 changed files with 233 additions and 0 deletions
+11
View File
@@ -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()
);
+1
View File
@@ -5,6 +5,7 @@ pub mod authority;
pub mod catalog; pub mod catalog;
pub mod fields; pub mod fields;
pub mod seed; pub mod seed;
pub mod users;
pub mod vocab; pub mod vocab;
use sqlx::postgres::{PgPool, PgPoolOptions}; use sqlx::postgres::{PgPool, PgPoolOptions};
+121
View File
@@ -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<UserId, sqlx::Error> {
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<Option<User>, 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<Option<(User, String)>, 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<Vec<User>, 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<User, sqlx::Error> {
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,
})
}
+100
View File
@@ -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"]);
}