-- 0056_audit_hardening.sql -- ---------------------------------------------------------------------------- -- Address several Tier-4/5 audit findings in one migration: -- -- 1. user_permission_overrides.user_id had no FK at all (data-model H1). -- Add an explicit reference to user(id) with onDelete='cascade' so a -- deleted user can't leave dangling override rows. -- -- 2. user_email_changes lacked a partial unique index on pending rows -- (concurrency H + GDPR follow-up). Without this, a malicious or -- confused admin can spam the email-change endpoint to generate -- multiple pending tokens, each emailing the operator's inbox. -- -- 3. user_port_roles.userId previously had no FK either - see data-model -- H1. Add the same cascade. -- -- Each statement is wrapped in DO blocks so the migration is replayable -- (idempotent) and tolerant of being run more than once. DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'fk_user_permission_overrides_user' AND table_name = 'user_permission_overrides' ) THEN ALTER TABLE user_permission_overrides ADD CONSTRAINT fk_user_permission_overrides_user FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE; END IF; END $$; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM information_schema.table_constraints WHERE constraint_name = 'fk_user_port_roles_user' AND table_name = 'user_port_roles' ) THEN ALTER TABLE user_port_roles ADD CONSTRAINT fk_user_port_roles_user FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE; END IF; END $$; -- Partial unique index: at most one pending row per user. Pending = both -- `applied_at` and `cancelled_at` are NULL. Lets old / completed rows -- accumulate as history without ever blocking a fresh change. CREATE UNIQUE INDEX IF NOT EXISTS idx_user_email_changes_one_pending ON user_email_changes (user_id) WHERE applied_at IS NULL AND cancelled_at IS NULL;