fix(manager-core): F-S-013 partial unique index on app_user_invitations pending rows

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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-07 20:45:34 +02:00
parent 0b7ef11333
commit 9cd1213aac

View File

@@ -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;