From 9cd1213aaca7d3c6daff21ae1542fb406bdfb5fb Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 7 Jun 2026 20:45:34 +0200 Subject: [PATCH] fix(manager-core): F-S-013 partial unique index on app_user_invitations pending rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No unique constraint on (app_id, lower(email)) for pending rows meant calling users::invite("alice@…") N times created N rows with N valid tokens. Combined with accept_invite returning Ok(None) silently when the email already exists, an attacker who learned one token could permanently consume it without effect. Migration 0040 adds a partial unique index keyed on (app_id, lower(email)) WHERE accepted_at IS NULL. Re-inviting after a previous invite was accepted still works — the accepted row falls outside the index. The service-layer 409-conflict UX (the audit's secondary suggestion) is a separate follow-up; this commit closes the data-shape hole. AUDIT.md anchor: F-S-013. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../0039_app_user_invitations_unique_pending.sql | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 crates/manager-core/migrations/0039_app_user_invitations_unique_pending.sql diff --git a/crates/manager-core/migrations/0039_app_user_invitations_unique_pending.sql b/crates/manager-core/migrations/0039_app_user_invitations_unique_pending.sql new file mode 100644 index 0000000..61658e8 --- /dev/null +++ b/crates/manager-core/migrations/0039_app_user_invitations_unique_pending.sql @@ -0,0 +1,13 @@ +-- F-S-013 (audit 2026-06-07): partial unique index on pending +-- (app_id, lower(email)) for app_user_invitations. +-- +-- Without it, users::invite("alice@…") called N times creates N rows +-- with N valid tokens. Combined with accept_invite returning Ok(None) +-- silently when the email already exists, an attacker who learns one +-- invitation token can permanently consume it without effect. +-- +-- The constraint is partial so re-inviting after a previous invitation +-- was accepted still works (the accepted row sits outside the index). +CREATE UNIQUE INDEX IF NOT EXISTS idx_app_user_invitations_unique_pending + ON app_user_invitations (app_id, lower(email)) + WHERE accepted_at IS NULL;