8e7e7201d3
* fix(db): drop delete_user_account RPC that bypassed BFL retention delete_user_account disabled the retention/immutability/audit triggers, deleted audit_log rows, and cascaded auth.users, destroying 7 years of legally retained rakenskapsinformation (BFL 7 kap 2 paragraf). It was SECURITY DEFINER with only a self-only guard and no REVOKE, so any authenticated user could call it via PostgREST. The product path already uses anonymize_user_account, which so far existed only on production (drift). This migration drops the dangerous RPC, commits the prod definition of anonymize_user_account verbatim, adds the profiles tombstone columns it writes (also drift), and locks grants down to authenticated only. Closes #342 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: log profiles tombstone-column drift-capture decision Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
83 lines
3.5 KiB
PL/PgSQL
83 lines
3.5 KiB
PL/PgSQL
-- Drop delete_user_account and commit anonymize_user_account to the repo.
|
|
--
|
|
-- WHY THIS MIGRATION EXISTS (issue #342, critical)
|
|
-- ------------------------------------------------
|
|
-- public.delete_user_account (last defined by 20260415000000_schema_sync.sql)
|
|
-- DISABLEs the BFL retention triggers (block_document_deletion,
|
|
-- enforce_retention_journal_entries, enforce_journal_entry_immutability,
|
|
-- audit_log_no_delete, and friends), DELETEs audit_log rows for every company
|
|
-- the user created, and then DELETEs the auth.users row, cascading away
|
|
-- journal entries and documents. That destroys rakenskapsinformation that
|
|
-- BFL 7 kap 2 paragraf requires us to retain for 7 years. It is SECURITY
|
|
-- DEFINER with only a self-only guard (auth.uid() = target_user_id) and no
|
|
-- REVOKE, so any authenticated user could call it via PostgREST and legally
|
|
-- wipe their own company's books. The function must not exist at all: the
|
|
-- retention triggers are the legal backstop and no callable path may disable
|
|
-- them.
|
|
--
|
|
-- The product path already uses public.anonymize_user_account
|
|
-- (app/api/account/delete/route.ts): it scrubs PII from profiles and removes
|
|
-- memberships/keys, but leaves all bookkeeping data untouched. That function
|
|
-- so far existed only on production with no migration in the repo; this
|
|
-- migration commits the production definition verbatim (drift capture, no
|
|
-- behavior change) and locks down its grants in house style.
|
|
|
|
-- 1. Drop the retention-bypassing RPC.
|
|
DROP FUNCTION IF EXISTS public.delete_user_account(uuid);
|
|
|
|
-- 2. Drift capture: production's profiles table carries the tombstone columns
|
|
-- the function writes, but no repo migration ever added them. Add them
|
|
-- idempotently so from-scratch databases (CI replay, self-hosted) match.
|
|
ALTER TABLE public.profiles ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
|
|
ALTER TABLE public.profiles ADD COLUMN IF NOT EXISTS anonymized_at timestamptz;
|
|
|
|
-- 3. Commit the production definition of anonymize_user_account verbatim.
|
|
CREATE OR REPLACE FUNCTION public.anonymize_user_account(target_user_id uuid)
|
|
RETURNS void
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path TO 'public'
|
|
AS $function$
|
|
DECLARE
|
|
blocker_count int;
|
|
BEGIN
|
|
IF auth.uid() IS DISTINCT FROM target_user_id THEN
|
|
RAISE EXCEPTION 'Can only delete your own account';
|
|
END IF;
|
|
|
|
SELECT count(*) INTO blocker_count
|
|
FROM public.company_members cm
|
|
JOIN public.companies c ON c.id = cm.company_id
|
|
WHERE cm.user_id = target_user_id
|
|
AND cm.role = 'owner'
|
|
AND c.archived_at IS NULL;
|
|
|
|
IF blocker_count > 0 THEN
|
|
RAISE EXCEPTION 'Cannot delete account: user still owns % active compan(y/ies)', blocker_count
|
|
USING ERRCODE = 'P0001';
|
|
END IF;
|
|
|
|
DELETE FROM public.company_members WHERE user_id = target_user_id;
|
|
DELETE FROM public.team_members WHERE user_id = target_user_id;
|
|
DELETE FROM public.bankid_identities WHERE user_id = target_user_id;
|
|
|
|
DELETE FROM public.user_preferences WHERE user_id = target_user_id;
|
|
DELETE FROM public.api_keys WHERE user_id = target_user_id;
|
|
|
|
UPDATE public.profiles
|
|
SET email = NULL,
|
|
full_name = NULL,
|
|
avatar_url = NULL,
|
|
deleted_at = now(),
|
|
anonymized_at = now(),
|
|
updated_at = now()
|
|
WHERE id = target_user_id;
|
|
END;
|
|
$function$;
|
|
|
|
-- 4. Grants: self-only guard inside, but never callable by anon/PUBLIC.
|
|
REVOKE ALL ON FUNCTION public.anonymize_user_account(uuid) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.anonymize_user_account(uuid) TO authenticated;
|
|
|
|
NOTIFY pgrst, 'reload schema';
|