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:
Mattsson
2026-07-24 16:36:35 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 213d611e54
commit 63fd5311ed
8 changed files with 334 additions and 78 deletions
+53
View File
@@ -127,4 +127,57 @@ describe('account deletion RPCs (pg)', () => {
})
// withUserContext rolls back, so the seeded rows do not leak.
})
it('scrubs auth.users metadata: user_metadata wiped, app keys dropped, provider kept', async () => {
// Migration 20260724150000: the route-level updateUserById "wipe" was a
// silent no-op (GoTrue merges metadata maps), so the tombstone kept the
// user's full name. The RPC now scrubs auth.users directly. Email must
// survive: it is the documented legitimate-interest tombstone.
const userId = await insertAuthUser()
await getPool().query(
`UPDATE auth.users
SET raw_user_meta_data = '{"full_name": "PG Real Person", "email_verified": true}'::jsonb,
raw_app_meta_data = '{"provider": "email", "providers": ["email"], "bankid_linked": true, "has_password": false}'::jsonb
WHERE id = $1`,
[userId],
)
await withUserContext(userId, async (client) => {
await client.query('SELECT public.anonymize_user_account($1)', [userId])
// The authenticated role has no SELECT on auth.users; drop back to the
// superuser session user to verify. Still inside the same transaction,
// so the rolled-back writes remain visible.
await client.query('RESET ROLE')
const { rows } = await client.query<{
email: string | null
user_meta: Record<string, unknown>
app_meta: Record<string, unknown>
}>(
`SELECT email, raw_user_meta_data AS user_meta, raw_app_meta_data AS app_meta
FROM auth.users WHERE id = $1`,
[userId],
)
expect(rows).toHaveLength(1)
expect(rows[0]!.email).toBe(`pg-real-${userId}@test.invalid`)
expect(rows[0]!.user_meta).toEqual({})
expect(rows[0]!.app_meta).toEqual({ provider: 'email', providers: ['email'] })
})
})
it('rejects a repeat invocation against an already-anonymized tombstone', async () => {
const userId = await insertAuthUser()
await getPool().query(
`INSERT INTO public.profiles (id, email, full_name)
VALUES ($1, $2, 'PG Real')
ON CONFLICT (id) DO UPDATE SET email = EXCLUDED.email, full_name = EXCLUDED.full_name`,
[userId, `pg-real-${userId}@test.invalid`],
)
await withUserContext(userId, async (client) => {
await client.query('SELECT public.anonymize_user_account($1)', [userId])
await expect(
client.query('SELECT public.anonymize_user_account($1)', [userId]),
).rejects.toThrow(/already deleted/i)
})
})
})