Skip to content

Commit a231e88

Browse files
committed
fix(billing): never let the identity record block an erasure, and keep later links unchecked
- Recording a deleted account's identity digests now runs in a savepoint and only logs on failure, so an anti-abuse write can never abort the holder's deletion; before this change nothing of the kind could block it. - A stats row the fallback creates gets only the check signup would have made: the email always, and the linked sign-ins only for a user created in the same auth request. A legacy user without a stats row who links a sign-in later is no longer withheld through the fallback. - Docs say exactly who is checked: every user Better Auth creates, SCIM- and SSO-provisioned ones included, is checked by email at creation; only a sign-in linked in a later request never withholds anything.
1 parent f97badf commit a231e88

6 files changed

Lines changed: 141 additions & 41 deletions

File tree

‎apps/sim/lib/auth/auth.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -646,7 +646,7 @@ export const auth = betterAuth({
646646

647647
try {
648648
const { ensureUserStatsExists } = await import('@/lib/billing/core/usage')
649-
await ensureUserStatsExists(account.userId)
649+
await ensureUserStatsExists(account.userId, context)
650650
} catch (error) {
651651
logger.error('[databaseHooks.account.create.after] Failed to ensure user stats', {
652652
userId: account.userId,

‎apps/sim/lib/billing/core/usage.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,25 @@ describe('withholding the free allowance from a returning deleted identity', ()
450450
expect(forfeited()).toBe(true)
451451
})
452452

453+
it('checks a fallback row’s linked sign-ins only for a user created in the same request', async () => {
454+
mockUserHasDeletedAccountIdentity.mockImplementation(
455+
async (_userId: string, { includeSignIns }: { includeSignIns: boolean }) => includeSignIns
456+
)
457+
dbChainMockFns.returning
458+
.mockResolvedValueOnce([{ id: 'stats-1' }])
459+
.mockResolvedValueOnce([{ id: 'stats-1' }])
460+
461+
await ensureUserStatsExists('user-1', {})
462+
await ensureUserStatsExists('user-1')
463+
expect(forfeited()).toBe(false)
464+
465+
const signupRequest = {}
466+
await handleNewUser('user-1', signupRequest)
467+
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'stats-1' }])
468+
await ensureUserStatsExists('user-1', signupRequest)
469+
expect(forfeited()).toBe(true)
470+
})
471+
453472
it('never fails the caller when withholding from a fallback row fails', async () => {
454473
mockUserHasDeletedAccountIdentity.mockResolvedValue(true)
455474
dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'stats-1' }])

‎apps/sim/lib/billing/core/usage.ts‎

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -186,23 +186,28 @@ function calculateOrgUsageLimit(
186186
/**
187187
* Users created in an auth request, keyed by Better Auth's endpoint context.
188188
* `user.create.after` and `account.create.after` of one signup receive the same
189-
* context object, which is how {@link handleNewSignInAccount} tells a signup's
190-
* first sign-in apart from a sign-in linked to an existing user later.
189+
* context object, which is how {@link handleNewSignInAccount} and
190+
* {@link ensureUserStatsExists} tell a signup's first sign-in apart from a
191+
* sign-in linked to an existing user later.
191192
*/
192193
const usersCreatedInAuthRequest = new WeakMap<object, string>()
193194

194195
/**
195196
* Handle new user setup when they join the platform.
196197
* Creates the userStats record with the free allowance, which is zero when the
197198
* user's email or an already-linked social sign-in belongs to an account
198-
* deleted within the retention window.
199+
* deleted within the retention window. Every user Better Auth creates comes
200+
* through here, including SCIM- and SSO-provisioned users, so their email is
201+
* always checked at creation.
199202
*
200203
* @param authRequest Better Auth's endpoint context for the signup request, when there is one
201204
*/
202205
export async function handleNewUser(userId: string, authRequest?: object | null): Promise<void> {
203206
if (authRequest) usersCreatedInAuthRequest.set(authRequest, userId)
204207
try {
205-
const freeTierEligible = !(await userHasDeletedAccountIdentity(userId))
208+
const freeTierEligible = !(await userHasDeletedAccountIdentity(userId, {
209+
includeSignIns: true,
210+
}))
206211
if (!freeTierEligible) {
207212
logger.info('Withholding the free allowance from a returning deleted-account identity', {
208213
userId,
@@ -229,9 +234,10 @@ export async function handleNewUser(userId: string, authRequest?: object | null)
229234
/**
230235
* The account-creation half of the signup check, for a social sign-in that was
231236
* not yet linked when {@link handleNewUser} ran. Only an account created in the
232-
* same auth request as its user is a signup; a sign-in linked to an existing
233-
* user later, including a SCIM- or SSO-provisioned one, never withholds
234-
* anything. Integration connections are skipped.
237+
* same auth request as its user is a signup. A sign-in linked to an existing
238+
* user in a later request never withholds anything; that includes a SCIM- or
239+
* SSO-provisioned user, whose email was already checked when it was created.
240+
* Integration connections are skipped.
235241
*
236242
* @param authRequest Better Auth's endpoint context for the request creating the account
237243
*/
@@ -258,12 +264,19 @@ export async function handleNewSignInAccount(
258264
* Creates one with default values if missing.
259265
* This is a fallback for cases where the user.create.after hook didn't fire
260266
* or failed (e.g., OAuth account linking to existing users). A row created
261-
* here gets the same deleted-identity check as signup; a failed check leaves
262-
* the allowance in place and never fails the caller.
267+
* here gets only the check signup would have made: the email always, and the
268+
* linked sign-ins only when the user was created in `authRequest`, so a sign-in
269+
* linked later never withholds. A failed check leaves the allowance in place
270+
* and never fails the caller.
263271
*
264272
* Always writes to the primary — never takes a read-routing executor.
273+
*
274+
* @param authRequest Better Auth's endpoint context, when called from an auth hook
265275
*/
266-
export async function ensureUserStatsExists(userId: string): Promise<void> {
276+
export async function ensureUserStatsExists(
277+
userId: string,
278+
authRequest?: object | null
279+
): Promise<void> {
267280
const created = await db
268281
.insert(userStats)
269282
.values({
@@ -277,7 +290,12 @@ export async function ensureUserStatsExists(userId: string): Promise<void> {
277290
if (created.length === 0) return
278291

279292
try {
280-
if (await userHasDeletedAccountIdentity(userId)) await forfeitFreeTierAllowance(userId)
293+
const includeSignIns = authRequest
294+
? usersCreatedInAuthRequest.get(authRequest) === userId
295+
: false
296+
if (await userHasDeletedAccountIdentity(userId, { includeSignIns })) {
297+
await forfeitFreeTierAllowance(userId)
298+
}
281299
} catch (error) {
282300
logger.error('Failed to withhold the free allowance from a fallback stats row', {
283301
userId,

‎apps/sim/lib/users/deleted-identity.integration.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,21 @@ describe('deleted account identities and the free allowance', () => {
248248
expect(await statsOf(stranger)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() })
249249
})
250250

251+
it('never withholds from a stats-less user’s fallback row for a sign-in linked in a later request', async () => {
252+
const google = { providerId: 'google', accountId: `google-${generateId()}` }
253+
await deleteOwnAccount(
254+
await signUp(`google-gone.${generateId()}@deleted-identity.test`, google)
255+
)
256+
const legacy = await insertUser(`legacy.${generateId()}@deleted-identity.test`)
257+
const laterRequest = {}
258+
await linkAccount(legacy, google)
259+
260+
await ensureUserStatsExists(legacy, laterRequest)
261+
await handleNewSignInAccount({ userId: legacy, ...google }, laterRequest)
262+
263+
expect(await statsOf(legacy)).toEqual({ freeTierEligible: true, limit: getFreeTierLimit() })
264+
})
265+
251266
it('records only sign-in identities, as keyed digests holding no address or subject', async () => {
252267
const email = `digest.${generateId()}@deleted-identity.test`
253268
const githubSubject = `gh-${generateId()}`
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Recording a deleted account's identities is an anti-abuse write inside the
3+
* holder's erasure transaction. It must never become a way to block the erasure.
4+
*/
5+
import { db } from '@sim/db'
6+
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock'
7+
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing/mocks/env-flags.mock'
8+
import { schemaMock } from '@sim/testing/mocks/schema.mock'
9+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
10+
import { recordDeletedAccountIdentities } from '@/lib/users/deleted-identity'
11+
12+
describe('recordDeletedAccountIdentities', () => {
13+
beforeEach(() => {
14+
resetDbChainMock()
15+
setEnvFlags({ isBillingEnabled: true })
16+
queueTableRows(schemaMock.user, [{ email: 'ada@example.com' }])
17+
})
18+
19+
afterEach(() => {
20+
resetEnvFlagsMock()
21+
})
22+
23+
it('never fails the deletion when the identity write fails', async () => {
24+
dbChainMockFns.onConflictDoUpdate.mockRejectedValueOnce(new Error('lock timeout'))
25+
26+
await expect(recordDeletedAccountIdentities(db, 'user-1')).resolves.toBeUndefined()
27+
expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalled()
28+
})
29+
})

‎apps/sim/lib/users/deleted-identity.ts‎

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
22
import { account, deletedAccountIdentity, user } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { hmacSha256Hex } from '@sim/security/hmac'
5+
import { getErrorMessage } from '@sim/utils/errors'
56
import { and, eq, gt, inArray, lte, sql } from 'drizzle-orm'
67
import { isSignInProviderAllowed, SIGN_IN_PROVIDER_IDS } from '@/lib/auth/constants'
78
import { env } from '@/lib/core/config/env'
@@ -138,34 +139,47 @@ async function loadSignInIdentity(executor: DbOrTx, userId: string): Promise<Sig
138139
* Records the sign-in identities of an account being deleted at its holder's
139140
* request. Runs inside the deletion transaction, before the `user` row and its
140141
* `account` rows cascade away, so the record and the erasure commit together.
142+
*
143+
* The write runs in a savepoint and never fails the deletion: an anti-abuse
144+
* record must not become a way to block an erasure request. On failure the
145+
* savepoint rolls back, nothing is recorded, and the deletion proceeds.
141146
*/
142147
export async function recordDeletedAccountIdentities(tx: DbOrTx, userId: string): Promise<void> {
143148
if (!isBillingEnabled) return
144149

145-
const hashes = identityHashes(await loadSignInIdentity(tx, userId))
146-
if (hashes.length === 0) return
147-
148-
await tx
149-
.delete(deletedAccountIdentity)
150-
.where(
151-
inArray(
152-
deletedAccountIdentity.identityHash,
153-
tx
154-
.select({ identityHash: deletedAccountIdentity.identityHash })
155-
.from(deletedAccountIdentity)
156-
.where(lte(deletedAccountIdentity.expiresAt, sql`now()`))
157-
.limit(EXPIRED_PURGE_BATCH)
158-
)
159-
)
160-
161-
const expiresAt = new Date(Date.now() + DELETED_IDENTITY_RETENTION_MS)
162-
await tx
163-
.insert(deletedAccountIdentity)
164-
.values(hashes.map((identityHash) => ({ identityHash, expiresAt })))
165-
.onConflictDoUpdate({
166-
target: deletedAccountIdentity.identityHash,
167-
set: { deletedAt: sql`now()`, expiresAt },
150+
try {
151+
await tx.transaction(async (savepoint) => {
152+
const hashes = identityHashes(await loadSignInIdentity(savepoint, userId))
153+
if (hashes.length === 0) return
154+
155+
await savepoint
156+
.delete(deletedAccountIdentity)
157+
.where(
158+
inArray(
159+
deletedAccountIdentity.identityHash,
160+
savepoint
161+
.select({ identityHash: deletedAccountIdentity.identityHash })
162+
.from(deletedAccountIdentity)
163+
.where(lte(deletedAccountIdentity.expiresAt, sql`now()`))
164+
.limit(EXPIRED_PURGE_BATCH)
165+
)
166+
)
167+
168+
const expiresAt = new Date(Date.now() + DELETED_IDENTITY_RETENTION_MS)
169+
await savepoint
170+
.insert(deletedAccountIdentity)
171+
.values(hashes.map((identityHash) => ({ identityHash, expiresAt })))
172+
.onConflictDoUpdate({
173+
target: deletedAccountIdentity.identityHash,
174+
set: { deletedAt: sql`now()`, expiresAt },
175+
})
176+
})
177+
} catch (error) {
178+
logger.warn('Could not record deleted-account identities; continuing the deletion', {
179+
userId,
180+
error: getErrorMessage(error),
168181
})
182+
}
169183
}
170184

171185
/**
@@ -196,15 +210,20 @@ export async function isDeletedAccountIdentity(identity: SignInIdentity): Promis
196210
}
197211

198212
/**
199-
* Whether an existing user's email, or any social sign-in already linked to it,
200-
* belonged to an account deleted within the retention window. Used when the
201-
* user's stats row is created, which in an OAuth signup happens after its first
202-
* sign-in account is committed. Fails open like {@link isDeletedAccountIdentity}.
213+
* Whether an existing user's identities belonged to an account deleted within
214+
* the retention window: always its email, and its linked social sign-ins only
215+
* when `includeSignIns` is set, which callers do only for a user created in the
216+
* current signup request (a sign-in linked later never withholds anything).
217+
* Fails open like {@link isDeletedAccountIdentity}.
203218
*/
204-
export async function userHasDeletedAccountIdentity(userId: string): Promise<boolean> {
219+
export async function userHasDeletedAccountIdentity(
220+
userId: string,
221+
{ includeSignIns }: { includeSignIns: boolean }
222+
): Promise<boolean> {
205223
if (!isBillingEnabled) return false
206224
try {
207-
return await isDeletedAccountIdentity(await loadSignInIdentity(db, userId))
225+
const identity = await loadSignInIdentity(db, userId)
226+
return await isDeletedAccountIdentity(includeSignIns ? identity : { email: identity.email })
208227
} catch (error) {
209228
logger.error('Deleted-identity lookup failed; granting the free allowance', { userId, error })
210229
return false

0 commit comments

Comments
 (0)