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
|
-- 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
|
-- org_id. Passwords are stored only as argon2id PHC strings.
|
||||||
-- plain UNIQUE suffices. 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 (
|
CREATE TABLE app_user (
|
||||||
id UUID PRIMARY KEY,
|
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 <> ''),
|
password_hash TEXT NOT NULL CHECK (password_hash <> ''),
|
||||||
role TEXT NOT NULL CHECK (role IN ('admin', 'editor')),
|
role TEXT NOT NULL CHECK (role IN ('admin', 'editor')),
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_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
|
where
|
||||||
E: sqlx::PgExecutor<'e>,
|
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)
|
let row = sqlx::query(&sql)
|
||||||
.bind(email)
|
.bind(email)
|
||||||
@@ -96,6 +97,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// List all users, ordered by email.
|
/// 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>
|
pub async fn list_users<'e, E>(executor: E) -> Result<Vec<User>, sqlx::Error>
|
||||||
where
|
where
|
||||||
E: sqlx::PgExecutor<'e>,
|
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();
|
let emails: Vec<&str> = users.iter().map(|u| u.email.as_str()).collect();
|
||||||
assert_eq!(emails, vec!["amy@example.com", "zoe@example.com"]);
|
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.
|
/// 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 {
|
pub fn from_db(value: String) -> Email {
|
||||||
Email(value)
|
Email(value)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user