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 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};
|
||||||
|
|||||||
@@ -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