fix: wipe user secrets when user is soft-deleted (#24985)

Extend the delete_deleted_user_resources() trigger so that secrets
belonging to a soft-deleted user are removed in the same transaction as
the existing api_keys and user_links cleanup.

user_secrets.user_id has ON DELETE CASCADE, but Coder soft-deletes users
by flipping users.deleted rather than removing the row, so the foreign key
cascade never fires and secrets would otherwise survive deletion.

Assisted by Coder Agents.
This commit is contained in:
Zach
2026-05-11 09:07:30 -06:00
committed by GitHub
parent 81e2be69e9
commit b221632615
9 changed files with 207 additions and 27 deletions
@@ -0,0 +1,27 @@
-- Drop the BEFORE INSERT/UPDATE guard added by 000489.
DROP TRIGGER IF EXISTS trigger_upsert_user_secrets ON user_secrets;
DROP FUNCTION IF EXISTS insert_user_secret_fail_if_user_deleted;
-- Restore the previous body of delete_deleted_user_resources() from
-- 000194_trigger_delete_user_user_link.up.sql, dropping the
-- user_secrets cleanup added by 000489.
CREATE OR REPLACE FUNCTION delete_deleted_user_resources() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
BEGIN
IF (NEW.deleted) THEN
-- Remove their api_keys
DELETE FROM api_keys
WHERE user_id = OLD.id;
-- Remove their user_links
-- Their login_type is preserved in the users table.
-- Matching this user back to the link can still be done by their
-- email if the account is undeleted. Although that is not a guarantee.
DELETE FROM user_links
WHERE user_id = OLD.id;
END IF;
RETURN NEW;
END;
$$;
@@ -0,0 +1,64 @@
-- Extend the soft-delete cleanup trigger to also wipe user_secrets.
-- user_secrets.user_id has ON DELETE CASCADE, but Coder soft-deletes
-- users by flipping users.deleted instead of removing the row, so the
-- FK cascade never fires and secrets would otherwise survive deletion.
--
-- Backfill any rows that belonged to already-soft-deleted users before
-- replacing the function.
DELETE FROM
user_secrets
WHERE
user_id
IN (
SELECT id FROM users WHERE deleted
);
CREATE OR REPLACE FUNCTION delete_deleted_user_resources() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
BEGIN
IF (NEW.deleted) THEN
-- Remove their api_keys
DELETE FROM api_keys
WHERE user_id = OLD.id;
-- Remove their user_links
-- Their login_type is preserved in the users table.
-- Matching this user back to the link can still be done by their
-- email if the account is undeleted. Although that is not a guarantee.
DELETE FROM user_links
WHERE user_id = OLD.id;
-- Remove their user_secrets.
-- user_secrets.user_id has ON DELETE CASCADE, but soft-delete
-- does not remove the users row so the FK cascade never fires.
DELETE FROM user_secrets
WHERE user_id = OLD.id;
END IF;
RETURN NEW;
END;
$$;
-- Prevent adding new user_secrets for soft-deleted users.
-- Closes the window between an in-flight CreateUserSecret request
-- and the soft-delete UPDATE committing.
CREATE FUNCTION insert_user_secret_fail_if_user_deleted() RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
BEGIN
IF (NEW.user_id IS NOT NULL) THEN
IF (SELECT deleted FROM users WHERE id = NEW.user_id LIMIT 1) THEN
RAISE EXCEPTION 'Cannot create user_secret for deleted user';
END IF;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trigger_upsert_user_secrets
BEFORE INSERT OR UPDATE ON user_secrets
FOR EACH ROW
EXECUTE PROCEDURE insert_user_secret_fail_if_user_deleted();