harden(db): case-insensitive email unique index + dup-email test; list_users pagination TODO; from_db note
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user