Files
accounted/supabase/migrations/20260724150000_anonymize_scrub_auth_metadata.sql
T
Mattsson 63fd5311ed Bug/tic unlink (#1153)
* fix(tic): allow BankID link/unlink without a company context

/bankid/link and /bankid/unlink are user-level actions, but the extension
dispatcher resolved an active company for them, so a zero-company user
(fresh BankID signup, pre-onboarding) got a 500 'No company context' when
managing the connection from /settings/account. Mark both routes
skipCompanyContext and resolve the caller in-handler via requireAuth(),
which preserves the dispatcher's MFA/AAL2 enforcement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(tic): return 409 account_exists instead of 500 on BankID signup with taken email

The signup guard pre-checked profiles.email, but the authoritative store
is auth.users: anonymized account tombstones (and any profile drift) hold
the email in auth.users while profiles.email is NULL. The guard missed,
createUser failed with email_exists (422), and the route surfaced a
dead-end 500 'Kunde inte skapa kontot. Forsok igen.' where retrying can
never succeed.

Drop the profiles pre-check and let createUser's own uniqueness check be
the guard: map email_exists to the existing 409 account_exists response
(Swedish message), which the register page already handles with a toast
and a redirect to login. Also removes the TOCTOU window between the old
pre-check and createUser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(account): actually scrub auth.users metadata on account deletion

The delete route passed user_metadata: {} / app_metadata: {} to
auth.admin.updateUserById assuming replace semantics, but GoTrue MERGES
metadata maps, so the wipe was a silent no-op: the ~100-year tombstone
kept the user's full name in raw_user_meta_data (verified on production
2026-07-24).

Move the scrub into anonymize_user_account (migration 20260724150000):
raw_user_meta_data is cleared entirely, raw_app_meta_data drops the
app-specific keys (bankid_linked, has_password) while GoTrue's
provider/providers stay, and auth.users.email is still retained as the
documented legitimate-interest tombstone. The migration also repairs
existing tombstones (guarded by profiles.anonymized_at). The route keeps
only the ban, which the DB function cannot set.

Migration content already applied to staging; pg-real test extended.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: log BankID signup guard decision

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(account): address PR review findings on anonymize scrub

- anonymize_user_account now rejects repeat invocations against an
  already-anonymized tombstone (SQLSTATE P0002) instead of re-churning
  the scrubbed row
- note that the tombstone repair UPDATE runs atomically inside the
  migration transaction
- tic signup failure log hashes the email (sha256 prefix, matching the
  pnrHashPrefix pattern) instead of logging the raw address
- pg-real test for the double-invocation guard

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(anonymization): ensure raw_app_meta_data is not null before scrubbing keys

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:36:35 +02:00

96 lines
3.8 KiB
PL/PgSQL

-- Scrub auth.users metadata inside anonymize_user_account.
--
-- WHY
-- ---
-- app/api/account/delete/route.ts tried to wipe user_metadata/app_metadata
-- after the RPC by calling auth.admin.updateUserById(userId, { user_metadata:
-- {}, app_metadata: {} }). GoTrue MERGES metadata maps on admin update, so
-- passing an empty object is a no-op: the tombstone kept the user's full name
-- in raw_user_meta_data on a row we retain ~100 years (verified on production
-- 2026-07-24). Anonymization must actually remove the PII, so the scrub moves
-- into the SECURITY DEFINER function where a direct UPDATE is deterministic
-- and atomic with the profile scrub.
--
-- raw_user_meta_data is cleared entirely (full_name, avatar, any provider
-- leftovers). raw_app_meta_data only drops our app-specific keys
-- (bankid_linked, has_password): provider/providers stay, GoTrue owns those.
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;
-- Reject repeat invocations against an already-anonymized tombstone: the
-- account is gone, re-running would only churn the scrubbed row.
IF EXISTS (
SELECT 1 FROM public.profiles
WHERE id = target_user_id AND anonymized_at IS NOT NULL
) THEN
RAISE EXCEPTION 'Account is already deleted' USING ERRCODE = 'P0002';
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;
-- Scrub PII from the auth tombstone. auth.users.email is intentionally
-- kept (blocks re-signup + lets support verify identity for BFL-retained
-- data recovery; documented legitimate interest, see
-- app/api/account/delete/route.ts).
UPDATE auth.users
SET raw_user_meta_data = '{}'::jsonb,
raw_app_meta_data = coalesce(raw_app_meta_data, '{}'::jsonb) - 'bankid_linked' - 'has_password'
WHERE id = target_user_id;
END;
$function$;
REVOKE ALL ON FUNCTION public.anonymize_user_account(uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.anonymize_user_account(uuid) TO authenticated;
-- Repair existing tombstones: every already-anonymized profile whose auth row
-- still carries metadata. Guarded by anonymized_at so live users are untouched.
-- The migration runner applies this whole file in a single transaction, so the
-- UPDATE is atomic: it either scrubs all matching rows or none.
UPDATE auth.users u
SET raw_user_meta_data = '{}'::jsonb,
raw_app_meta_data = coalesce(u.raw_app_meta_data, '{}'::jsonb) - 'bankid_linked' - 'has_password'
FROM public.profiles p
WHERE p.id = u.id
AND p.anonymized_at IS NOT NULL
AND (u.raw_user_meta_data <> '{}'::jsonb
OR u.raw_app_meta_data ?| array['bankid_linked', 'has_password']);
NOTIFY pgrst, 'reload schema';