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:
@@ -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()
|
||||
);
|
||||
@@ -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};
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
Reference in New Issue
Block a user