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
@@ -137,16 +137,17 @@ describe('POST /api/account/delete', () => {
|
||||
})
|
||||
expect(updateUserById).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
user_metadata: {},
|
||||
app_metadata: {},
|
||||
ban_duration: expect.any(String),
|
||||
})
|
||||
expect.objectContaining({ ban_duration: expect.any(String) })
|
||||
)
|
||||
const updatePayload = updateUserById.mock.calls[0][1]
|
||||
// Email must NOT be scrubbed: retaining it is what blocks re-signup
|
||||
// with the same address. Recovery goes through support instead.
|
||||
const updatePayload = updateUserById.mock.calls[0][1]
|
||||
expect(updatePayload).not.toHaveProperty('email')
|
||||
// Metadata is NOT wiped here: GoTrue merges metadata maps, so passing {}
|
||||
// was a no-op. The anonymize_user_account RPC scrubs auth.users directly
|
||||
// (migration 20260724150000).
|
||||
expect(updatePayload).not.toHaveProperty('user_metadata')
|
||||
expect(updatePayload).not.toHaveProperty('app_metadata')
|
||||
expect(adminSignOut).toHaveBeenCalledWith('user-1', 'global')
|
||||
expect(emitted).toHaveLength(1)
|
||||
expect(emitted[0]).toMatchObject({ userId: 'user-1' })
|
||||
|
||||
@@ -90,8 +90,8 @@ export async function POST(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
// Wipe PII in auth.users metadata and ban the tombstone row ~100 years.
|
||||
// DB functions can't reach supabase.auth.admin, so we do it here.
|
||||
// Ban the tombstone row ~100 years so login is impossible. The DB function
|
||||
// can't set the ban (GoTrue-managed), so we do it here.
|
||||
//
|
||||
// Note: auth.users.email is intentionally NOT scrubbed. The original
|
||||
// address is retained as a legitimate-interest tombstone so that:
|
||||
@@ -105,18 +105,18 @@ export async function POST(request: Request) {
|
||||
// after this point: login is impossible (row is banned) and the
|
||||
// profile is anonymized, so no UI ever surfaces it.
|
||||
//
|
||||
// user_metadata / app_metadata ARE wiped: they may contain display
|
||||
// name, avatar, or provider info that isn't needed for recovery.
|
||||
// The admin API replaces (not merges) these, so passing {} clears them.
|
||||
// user_metadata / app_metadata PII is scrubbed by the RPC itself, NOT
|
||||
// here: GoTrue's admin update MERGES metadata maps, so the previous
|
||||
// updateUserById(..., { user_metadata: {}, app_metadata: {} }) call was
|
||||
// a silent no-op that left the full name on the tombstone (found on
|
||||
// prod 2026-07-24, repaired by migration 20260724150000).
|
||||
const service = createServiceClient()
|
||||
try {
|
||||
await service.auth.admin.updateUserById(user.id, {
|
||||
user_metadata: {},
|
||||
app_metadata: {},
|
||||
ban_duration: '876000h',
|
||||
})
|
||||
} catch (err) {
|
||||
log.error('Failed to wipe metadata and ban anonymized user', { userId: user.id, err })
|
||||
log.error('Failed to ban anonymized user', { userId: user.id, err })
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user