From bea9b6b39a936f1c68f997c343a23c3fa0d76f04 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 14:42:04 +0200 Subject: [PATCH] harden(db): case-insensitive email unique index + dup-email test; list_users pagination TODO; from_db note --- crates/db/migrations/0006_users.sql | 14 +++++++++++--- crates/db/src/users.rs | 4 +++- crates/db/tests/users.rs | 26 ++++++++++++++++++++++++++ crates/domain/src/user.rs | 3 +++ 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/crates/db/migrations/0006_users.sql b/crates/db/migrations/0006_users.sql index c934fa8..b6efc36 100644 --- a/crates/db/migrations/0006_users.sql +++ b/crates/db/migrations/0006_users.sql @@ -1,11 +1,19 @@ -- 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. +-- org_id. Passwords are stored only as argon2id PHC strings. +-- +-- `updated_at` is maintained manually in UPDATE statements (as in the object table); +-- there is no auto-update trigger and no update path exists yet. CREATE TABLE app_user ( id UUID PRIMARY KEY, - email TEXT NOT NULL UNIQUE CHECK (email <> ''), + email TEXT NOT NULL 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() ); + +-- Case-insensitive uniqueness on email, enforced at the database. The application +-- stores normalized (lowercased) emails and looks up via `lower(email) = $1`, so this +-- functional unique index both backs those lookups and guarantees no case-variant +-- duplicate can exist even if a non-normalized value were ever written. +CREATE UNIQUE INDEX app_user_email_lower_key ON app_user (lower(email)); diff --git a/crates/db/src/users.rs b/crates/db/src/users.rs index b874b5e..0016577 100644 --- a/crates/db/src/users.rs +++ b/crates/db/src/users.rs @@ -78,7 +78,8 @@ pub async fn credentials_by_email<'e, E>( where E: sqlx::PgExecutor<'e>, { - let sql = format!("SELECT {USER_COLUMNS}, password_hash FROM app_user WHERE email = $1"); + // Match the `lower(email)` unique index; `email` is already normalized by callers. + let sql = format!("SELECT {USER_COLUMNS}, password_hash FROM app_user WHERE lower(email) = $1"); let row = sqlx::query(&sql) .bind(email) @@ -96,6 +97,7 @@ where } /// List all users, ordered by email. +// TODO: add LIMIT/keyset pagination before exposing this via the API. pub async fn list_users<'e, E>(executor: E) -> Result, sqlx::Error> where E: sqlx::PgExecutor<'e>, diff --git a/crates/db/tests/users.rs b/crates/db/tests/users.rs index e6a85be..11dc0ff 100644 --- a/crates/db/tests/users.rs +++ b/crates/db/tests/users.rs @@ -98,3 +98,29 @@ async fn list_users_is_ordered_by_email(pool: PgPool) { let emails: Vec<&str> = users.iter().map(|u| u.email.as_str()).collect(); assert_eq!(emails, vec!["amy@example.com", "zoe@example.com"]); } + +#[sqlx::test] +async fn duplicate_email_is_rejected(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("anna@example.com", Role::Admin), + ) + .await + .unwrap(); + // Same normalized email again — the lower(email) unique index must reject it. + let err = users::create_user( + &mut tx, + AuditActor::System, + &new_user("anna@example.com", Role::Editor), + ) + .await + .unwrap_err(); + assert!( + matches!(err, sqlx::Error::Database(_)), + "expected a unique-violation database error, got {err:?}" + ); +} diff --git a/crates/domain/src/user.rs b/crates/domain/src/user.rs index 57e37c9..a20bfaf 100644 --- a/crates/domain/src/user.rs +++ b/crates/domain/src/user.rs @@ -56,6 +56,9 @@ impl Email { } /// Reconstruct from a stored (already-validated) value, without re-validating. + /// For reading values back from the database only — never to construct an `Email` + /// destined to be written (writes must go through [`Email::parse`] so storage + /// stays normalized). pub fn from_db(value: String) -> Email { Email(value) }