harden(db): case-insensitive email unique index + dup-email test; list_users pagination TODO; from_db note

This commit is contained in:
2026-06-02 14:42:04 +02:00
parent f8ec2d7cf1
commit bea9b6b39a
4 changed files with 43 additions and 4 deletions
+11 -3
View File
@@ -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));
+3 -1
View File
@@ -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<Vec<User>, sqlx::Error>
where
E: sqlx::PgExecutor<'e>,
+26
View File
@@ -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:?}"
);
}