* 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>
184 lines
7.5 KiB
TypeScript
184 lines
7.5 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { getClient, getPool, withUserContext } from './setup'
|
|
import { insertAuthUser, seedCompany } from './fixtures'
|
|
|
|
/**
|
|
* Account deletion RPCs (issue #342):
|
|
*
|
|
* Migration 20260706100000 drops public.delete_user_account, the SECURITY
|
|
* DEFINER RPC that disabled the BFL retention triggers, deleted audit_log
|
|
* rows, and cascaded auth.users, destroying rakenskapsinformation that
|
|
* BFL 7 kap 2 paragraf requires us to retain for 7 years. These tests pin
|
|
* that the function stays gone and that the surviving anonymize-only flow
|
|
* (public.anonymize_user_account) is present, SECURITY DEFINER, and not
|
|
* callable by anon/PUBLIC.
|
|
*/
|
|
describe('account deletion RPCs (pg)', () => {
|
|
it('delete_user_account no longer exists in pg_proc', async () => {
|
|
const { rows } = await getPool().query<{ n: number }>(
|
|
`SELECT count(*)::int AS n
|
|
FROM pg_proc p
|
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
WHERE n.nspname = 'public' AND p.proname = 'delete_user_account'`,
|
|
)
|
|
expect(rows[0]!.n).toBe(0)
|
|
})
|
|
|
|
it('anonymize_user_account exists and is SECURITY DEFINER', async () => {
|
|
const { rows } = await getPool().query<{ prosecdef: boolean }>(
|
|
`SELECT p.prosecdef
|
|
FROM pg_proc p
|
|
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
WHERE n.nspname = 'public' AND p.proname = 'anonymize_user_account'`,
|
|
)
|
|
expect(rows).toHaveLength(1)
|
|
expect(rows[0]!.prosecdef).toBe(true)
|
|
})
|
|
|
|
it('anonymize_user_account has no EXECUTE grant for anon or PUBLIC, but authenticated has it', async () => {
|
|
// anon inherits any PUBLIC grant, so anon=false also proves PUBLIC=false;
|
|
// the aclexplode check makes the PUBLIC assertion explicit (grantee oid 0).
|
|
const { rows } = await getPool().query<{
|
|
anon_can_execute: boolean
|
|
authenticated_can_execute: boolean
|
|
public_grant_count: number
|
|
}>(
|
|
`SELECT
|
|
has_function_privilege('anon', 'public.anonymize_user_account(uuid)', 'EXECUTE')
|
|
AS anon_can_execute,
|
|
has_function_privilege('authenticated', 'public.anonymize_user_account(uuid)', 'EXECUTE')
|
|
AS authenticated_can_execute,
|
|
(SELECT count(*)::int
|
|
FROM pg_proc p
|
|
JOIN pg_namespace n ON n.oid = p.pronamespace,
|
|
LATERAL aclexplode(coalesce(p.proacl, acldefault('f', p.proowner))) AS acl
|
|
WHERE n.nspname = 'public'
|
|
AND p.proname = 'anonymize_user_account'
|
|
AND acl.grantee = 0
|
|
AND acl.privilege_type = 'EXECUTE') AS public_grant_count`,
|
|
)
|
|
expect(rows[0]!.anon_can_execute).toBe(false)
|
|
expect(rows[0]!.public_grant_count).toBe(0)
|
|
expect(rows[0]!.authenticated_can_execute).toBe(true)
|
|
})
|
|
|
|
it('denies EXECUTE to the anon role at call time', async () => {
|
|
const client = await getClient()
|
|
try {
|
|
await client.query('BEGIN')
|
|
await client.query('SET LOCAL ROLE anon')
|
|
await expect(
|
|
client.query('SELECT public.anonymize_user_account($1)', [randomUUID()]),
|
|
).rejects.toThrow(/permission denied/i)
|
|
} finally {
|
|
await client.query('ROLLBACK').catch(() => {})
|
|
client.release()
|
|
}
|
|
})
|
|
|
|
it('refuses to anonymize while the user still owns an active company', async () => {
|
|
const { userId } = await seedCompany()
|
|
await withUserContext(userId, async (client) => {
|
|
await expect(
|
|
client.query('SELECT public.anonymize_user_account($1)', [userId]),
|
|
).rejects.toThrow(/still owns/i)
|
|
})
|
|
})
|
|
|
|
it('refuses to anonymize another user', async () => {
|
|
const userId = await insertAuthUser()
|
|
const victimId = await insertAuthUser()
|
|
await withUserContext(userId, async (client) => {
|
|
await expect(
|
|
client.query('SELECT public.anonymize_user_account($1)', [victimId]),
|
|
).rejects.toThrow(/only delete your own account/i)
|
|
})
|
|
})
|
|
|
|
it('anonymizes a company-less user: scrubs profile PII, sets tombstones', async () => {
|
|
const userId = await insertAuthUser()
|
|
// insertAuthUser may or may not fire a profiles trigger depending on the
|
|
// harness image; make the profile row deterministic.
|
|
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])
|
|
const { rows } = await client.query<{
|
|
email: string | null
|
|
full_name: string | null
|
|
deleted_at: string | null
|
|
anonymized_at: string | null
|
|
}>(
|
|
`SELECT email, full_name, deleted_at, anonymized_at
|
|
FROM public.profiles WHERE id = $1`,
|
|
[userId],
|
|
)
|
|
expect(rows).toHaveLength(1)
|
|
expect(rows[0]!.email).toBeNull()
|
|
expect(rows[0]!.full_name).toBeNull()
|
|
expect(rows[0]!.deleted_at).not.toBeNull()
|
|
expect(rows[0]!.anonymized_at).not.toBeNull()
|
|
})
|
|
// 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)
|
|
})
|
|
})
|
|
})
|