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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
213d611e54
commit
63fd5311ed
@@ -0,0 +1,95 @@
|
||||
-- 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';
|
||||
Reference in New Issue
Block a user