Skip to content

Commit ed4996c

Browse files
committed
fix(billing): serialize credit caps with access revocation
1 parent f63e5a9 commit ed4996c

7 files changed

Lines changed: 260 additions & 58 deletions

File tree

‎apps/sim/app/api/organizations/[id]/members/[memberId]/usage-limit/route.test.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* @vitest-environment node
33
*/
44

5+
import { db } from '@sim/db'
56
import { member } from '@sim/db/schema'
67
import {
78
auditMock,
@@ -187,9 +188,10 @@ describe('PUT /api/organizations/[id]/members/[memberId]/usage-limit', () => {
187188
})
188189

189190
it('persists the limit as dollars (credits / 200) and audits', async () => {
191+
queueTableRows(member, [{ role: 'admin' }])
190192
const res = await PUT(putRequest({ creditLimit: 400 }), context())
191193
expect(res.status).toBe(200)
192-
expect(mockSetOrgMemberUsageLimit).toHaveBeenCalledWith('org-1', 'user-2', 2, 'admin-1')
194+
expect(mockSetOrgMemberUsageLimit).toHaveBeenCalledWith('org-1', 'user-2', 2, 'admin-1', db)
193195
expect(auditMock.recordAudit).toHaveBeenCalledTimes(1)
194196
await expect(res.json()).resolves.toEqual({
195197
success: true,
@@ -199,9 +201,10 @@ describe('PUT /api/organizations/[id]/members/[memberId]/usage-limit', () => {
199201
})
200202

201203
it('clears the cap when creditLimit is null', async () => {
204+
queueTableRows(member, [{ role: 'admin' }])
202205
const res = await PUT(putRequest({ creditLimit: null }), context())
203206
expect(res.status).toBe(200)
204-
expect(mockSetOrgMemberUsageLimit).toHaveBeenCalledWith('org-1', 'user-2', null, 'admin-1')
207+
expect(mockSetOrgMemberUsageLimit).toHaveBeenCalledWith('org-1', 'user-2', null, 'admin-1', db)
205208
})
206209

207210
it.each([400, null])(

‎apps/sim/app/api/v2/organizations/[organizationId]/usage/route.test.ts‎

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { recordAudit } from '@sim/audit'
44
import type { OAuthAccessTokenPrincipal, Principal } from '@sim/auth/principal'
5+
import { db } from '@sim/db'
56
import { member } from '@sim/db/schema'
67
import { queueTableRows, resetDbChainMock, resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
78
import { NextRequest } from 'next/server'
@@ -144,13 +145,14 @@ describe('organization credit-limit API', () => {
144145
async (principal) => {
145146
authenticate(principal)
146147
admin()
148+
admin()
147149
const response = await setLimit(
148150
request('members/external-user/usage-limit', { creditLimit: 400 }),
149151
context
150152
)
151153
expect(response.status).toBe(200)
152154
expect(await response.json()).toEqual({ data: { creditLimit: 400 } })
153-
expect(mocks.setLimit).toHaveBeenCalledWith('org', 'external-user', 2, 'actor')
155+
expect(mocks.setLimit).toHaveBeenCalledWith('org', 'external-user', 2, 'actor', db)
154156
expect(mocks.limitTarget).toHaveBeenCalledWith('org', 'external-user')
155157
expect(recordAudit).toHaveBeenCalledExactlyOnceWith(
156158
expect.objectContaining({
@@ -167,14 +169,69 @@ describe('organization credit-limit API', () => {
167169
)
168170

169171
it.each([null, 0])('supports the distinct cap value %s', async (creditLimit) => {
172+
admin()
170173
admin()
171174
expect(
172175
(await setLimit(request('members/external-user/usage-limit', { creditLimit }), context))
173176
.status
174177
).toBe(200)
175-
expect(mocks.setLimit).toHaveBeenCalledWith('org', 'external-user', creditLimit, 'actor')
178+
expect(mocks.setLimit).toHaveBeenCalledWith('org', 'external-user', creditLimit, 'actor', db)
179+
})
180+
181+
it('rechecks the target after acquiring mutation locks', async () => {
182+
admin()
183+
admin()
184+
mocks.limitTarget.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
185+
const response = await setLimit(
186+
request('members/external-user/usage-limit', { creditLimit: 400 }),
187+
context
188+
)
189+
expect(response.status).toBe(404)
190+
expect(mocks.limitTarget).toHaveBeenLastCalledWith('org', 'external-user', {
191+
executor: db,
192+
forShare: true,
193+
})
194+
expect(mocks.setLimit).not.toHaveBeenCalled()
195+
expect(recordAudit).not.toHaveBeenCalled()
196+
})
197+
198+
it('refuses an actor demoted while waiting for mutation locks', async () => {
199+
admin()
200+
queueTableRows(member, [{ role: 'member' }])
201+
const response = await setLimit(
202+
request('members/external-user/usage-limit', { creditLimit: 400 }),
203+
context
204+
)
205+
expect(response.status).toBe(403)
206+
expect(mocks.setLimit).not.toHaveBeenCalled()
207+
expect(recordAudit).not.toHaveBeenCalled()
176208
})
177209

210+
it.each([
211+
[personal, { disablePersonalApiKeys: true }],
212+
[oauth, { disableOAuthAppAccess: true }],
213+
[{ ...oauth, clientId: SIM_CLI_CLIENT_ID }, { disableCliAccess: true }],
214+
] as const)(
215+
'rechecks $0.kind credential policy inside the mutation',
216+
async (principal, restriction) => {
217+
authenticate(principal)
218+
admin()
219+
admin()
220+
mocks.config.mockResolvedValueOnce(null).mockResolvedValueOnce({
221+
...DEFAULT_PERMISSION_GROUP_CONFIG,
222+
...restriction,
223+
})
224+
const response = await setLimit(
225+
request('members/external-user/usage-limit', { creditLimit: 400 }),
226+
context
227+
)
228+
expect(response.status).toBe(403)
229+
expect(mocks.config).toHaveBeenLastCalledWith('org', db)
230+
expect(mocks.setLimit).not.toHaveBeenCalled()
231+
expect(recordAudit).not.toHaveBeenCalled()
232+
}
233+
)
234+
178235
it('returns credits and the resolved organization billing interval', async () => {
179236
admin()
180237
const response = await getLimit(request('members/external-user/usage-limit'), context)

‎apps/sim/lib/billing/application/member-usage-limits/use-cases.ts‎

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AuditAction, AuditResourceType } from '@sim/audit'
2+
import { db } from '@sim/db'
23
import { createLogger } from '@sim/logger'
34
import { memberUsageLimitOperations } from '@/lib/billing/application/member-usage-limits/operations'
45
import { getOrganizationSubscription } from '@/lib/billing/core/billing'
@@ -10,10 +11,12 @@ import {
1011
isOrgMemberUsageLimitTarget,
1112
setOrgMemberUsageLimit,
1213
} from '@/lib/billing/organizations/member-limits'
14+
import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership'
1315
import {
1416
defineAuthorizedOrganizationUseCase,
1517
type OrganizationUseCaseContext,
1618
} from '@/lib/core/application/authorized-organization-use-case'
19+
import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization'
1720
import { isHosted } from '@/lib/core/config/env-flags'
1821
import { OrchestrationError } from '@/lib/core/orchestration/types'
1922

@@ -67,16 +70,33 @@ export const updateOrganizationMemberUsageLimit = defineAuthorizedOrganizationUs
6770
operation: memberUsageLimitOperations.update,
6871
authorizeResource: requireMemberUsageLimitTarget,
6972
async execute({
73+
principal,
7074
input,
7175
context,
7276
}: OrganizationUseCaseContext<UpdateOrganizationMemberUsageLimitInput>) {
7377
const { organizationId, userId, creditLimit } = input
74-
await setOrgMemberUsageLimit(
75-
organizationId,
76-
userId,
77-
creditLimit === null ? null : creditsToDollars(creditLimit),
78-
context.userId
79-
)
78+
await db.transaction(async (tx) => {
79+
await acquireOrganizationUserMutationLocks(tx, { userId, organizationIds: [organizationId] })
80+
await authorizeOrganizationOperation(principal, memberUsageLimitOperations.update, input, {
81+
executor: tx,
82+
forUpdate: true,
83+
})
84+
if (
85+
!(await isOrgMemberUsageLimitTarget(organizationId, userId, {
86+
executor: tx,
87+
forShare: true,
88+
}))
89+
) {
90+
throw new OrchestrationError('not_found', 'Member not found')
91+
}
92+
await setOrgMemberUsageLimit(
93+
organizationId,
94+
userId,
95+
creditLimit === null ? null : creditsToDollars(creditLimit),
96+
context.userId,
97+
tx
98+
)
99+
})
80100
logger.info('Updated per-member usage limit', {
81101
organizationId,
82102
memberId: userId,

‎apps/sim/lib/billing/organizations/member-limits.postgres.test.ts‎

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,63 @@
11
/** @vitest-environment node */
22

3+
import { recordAudit } from '@sim/audit'
4+
import * as schema from '@sim/db/schema'
5+
import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
36
import { generateId } from '@sim/utils/id'
7+
import { eq } from 'drizzle-orm'
48
import { drizzle } from 'drizzle-orm/postgres-js'
59
import postgres from 'postgres'
610
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
711

8-
const { databaseUrl, select } = vi.hoisted(() => {
12+
const { databaseUrl, select, transaction } = vi.hoisted(() => {
913
const databaseUrl = process.env.BILLING_USAGE_TEST_DATABASE_URL
1014
if (databaseUrl && !['localhost', '127.0.0.1', '[::1]'].includes(new URL(databaseUrl).hostname)) {
1115
throw new Error('Usage integration tests require a disposable local database')
1216
}
13-
return { databaseUrl, select: vi.fn() }
17+
return { databaseUrl, select: vi.fn(), transaction: vi.fn() }
1418
})
1519
vi.unmock('drizzle-orm')
1620
vi.unmock('@sim/db/schema')
17-
vi.mock('@sim/db', () => ({ db: { select }, dbReplica: { select } }))
21+
vi.mock('@sim/db', () => ({ db: { select, transaction }, dbReplica: { select } }))
22+
vi.mock('@sim/audit', async (original) => ({
23+
...(await original<typeof import('@sim/audit')>()),
24+
recordAudit: vi.fn(),
25+
}))
1826
vi.mock('@/lib/billing/core/billing', () => ({ getOrganizationSubscription: vi.fn() }))
1927

28+
import { updateOrganizationMemberUsageLimit } from '@/lib/billing/application/member-usage-limits/use-cases'
2029
import { isOrgMemberUsageLimitTarget } from '@/lib/billing/organizations/member-limits'
30+
import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership'
2131

2232
const schemaName = `member_limits_${generateId().replaceAll('-', '')}`
2333
const connection = databaseUrl
2434
? postgres(databaseUrl, {
25-
max: 1,
35+
max: 3,
2636
prepare: false,
27-
connection: { search_path: schemaName },
37+
connection: { search_path: schemaName, application_name: schemaName },
2838
onnotice: () => undefined,
2939
})
3040
: undefined
3141

42+
const database = connection ? drizzle(connection, { schema }) : undefined
43+
3244
beforeAll(async () => {
3345
if (!connection) return
3446
await connection.unsafe(`CREATE SCHEMA "${schemaName}"`)
3547
await connection.unsafe(`
36-
CREATE TABLE member (id text PRIMARY KEY, organization_id text, user_id text);
48+
CREATE TABLE member (id text PRIMARY KEY, organization_id text, user_id text, role text DEFAULT 'member');
3749
CREATE TABLE workspace (id text PRIMARY KEY, organization_id text, archived_at timestamp);
3850
CREATE TABLE permissions (id text PRIMARY KEY, user_id text, entity_type text, entity_id text);
3951
CREATE TABLE "user" (id text PRIMARY KEY);
4052
INSERT INTO "user" VALUES ('unrelated');
41-
INSERT INTO member VALUES
53+
INSERT INTO member (id, organization_id, user_id) VALUES
4254
('m1', 'org', 'member'), ('m2', 'other', 'external'), ('m3', 'other', 'foreign-member');
55+
INSERT INTO member VALUES ('actor', 'org', 'actor', 'admin');
56+
CREATE TABLE organization_member_usage_limit (
57+
id text PRIMARY KEY, organization_id text, user_id text, usage_limit numeric,
58+
set_by text, created_at timestamp DEFAULT now(), updated_at timestamp DEFAULT now(),
59+
UNIQUE(organization_id, user_id)
60+
);
4361
INSERT INTO workspace VALUES
4462
('local', 'org', null), ('foreign', 'other', null), ('archived', 'org', now());
4563
INSERT INTO permissions VALUES
@@ -50,11 +68,13 @@ beforeAll(async () => {
5068
('p5', 'missing-workspace', 'workspace', 'missing'),
5169
('p6', 'revoked', 'workspace', 'local');
5270
`)
53-
const database = drizzle(connection)
54-
select.mockImplementation((fields) => database.select(fields))
71+
select.mockImplementation((fields) => database!.select(fields))
72+
transaction.mockImplementation((callback) => database!.transaction(callback))
73+
setEnvFlags({ isHosted: true })
5574
})
5675

5776
afterAll(async () => {
77+
resetEnvFlagsMock()
5878
if (!connection) return
5979
await connection.unsafe(`DROP SCHEMA "${schemaName}" CASCADE`)
6080
await connection.end()
@@ -81,3 +101,78 @@ describe.skipIf(!databaseUrl)('organization credit-limit target SQL', () => {
81101
expect(await isOrgMemberUsageLimitTarget('org', 'revoked')).toBe(false)
82102
})
83103
})
104+
105+
describe.skipIf(!databaseUrl)('organization credit-limit mutation races', () => {
106+
it.each(['member', 'external', 'archived-external'])(
107+
'sets and clears a cap for the eligible target %s',
108+
async (userId) => {
109+
const principal = { kind: 'session', userId: 'actor', sessionId: 'session' } as const
110+
await expect(
111+
updateOrganizationMemberUsageLimit.execute({
112+
principal,
113+
input: { organizationId: 'org', userId, creditLimit: 400 },
114+
})
115+
).resolves.toEqual({ creditLimit: 400 })
116+
const [cap] =
117+
await connection!`SELECT usage_limit, set_by FROM organization_member_usage_limit WHERE user_id = ${userId}`
118+
expect(Number(cap.usage_limit)).toBe(2)
119+
expect(cap.set_by).toBe('actor')
120+
await expect(
121+
updateOrganizationMemberUsageLimit.execute({
122+
principal,
123+
input: { organizationId: 'org', userId, creditLimit: null },
124+
})
125+
).resolves.toEqual({ creditLimit: null })
126+
expect(
127+
await connection!`SELECT id FROM organization_member_usage_limit WHERE user_id = ${userId}`
128+
).toHaveLength(0)
129+
}
130+
)
131+
132+
it.each(['organization-removal', 'workspace-revocation'] as const)(
133+
'rejects a target revoked by %s while the update waits',
134+
async (removalKind) => {
135+
const userId = `target-${removalKind}`
136+
await connection!`INSERT INTO permissions VALUES (${userId}, ${userId}, 'workspace', 'local')`
137+
vi.mocked(recordAudit).mockClear()
138+
const ready = Promise.withResolvers<void>()
139+
const release = Promise.withResolvers<void>()
140+
const removal = database!.transaction(async (tx) => {
141+
if (removalKind === 'organization-removal') {
142+
await acquireOrganizationUserMutationLocks(tx, { userId, organizationIds: ['org'] })
143+
}
144+
await tx.delete(schema.permissions).where(eq(schema.permissions.userId, userId))
145+
ready.resolve()
146+
await release.promise
147+
})
148+
await ready.promise
149+
const update = updateOrganizationMemberUsageLimit
150+
.execute({
151+
principal: { kind: 'session', userId: 'actor', sessionId: 'session' },
152+
input: { organizationId: 'org', userId, creditLimit: 400 },
153+
})
154+
.then(
155+
(result) => ({ result }),
156+
(error: unknown) => ({ error })
157+
)
158+
try {
159+
await vi.waitFor(
160+
async () => {
161+
const [waiting] = await connection!`SELECT count(*)::int AS count FROM pg_stat_activity
162+
WHERE application_name = ${schemaName} AND wait_event_type = 'Lock'`
163+
expect(waiting.count).toBeGreaterThan(0)
164+
},
165+
{ timeout: 2000 }
166+
)
167+
} finally {
168+
release.resolve()
169+
await removal
170+
}
171+
expect(await update).toMatchObject({ error: { code: 'not_found' } })
172+
const caps =
173+
await connection!`SELECT id FROM organization_member_usage_limit WHERE user_id = ${userId}`
174+
expect(caps).toHaveLength(0)
175+
expect(recordAudit).not.toHaveBeenCalled()
176+
}
177+
)
178+
})

‎apps/sim/lib/billing/organizations/member-limits.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,24 @@ const logger = createLogger('OrgMemberLimits')
2121
/**
2222
* Includes external collaborators whose explicit workspace access belongs to the organization.
2323
* Retained grants on archived workspaces still qualify so their caps remain manageable.
24+
* Mutations hold the organization fence before requesting a shared relationship lock;
25+
* together these stabilize workspace scope and access through the write.
2426
*/
2527
export async function isOrgMemberUsageLimitTarget(
2628
organizationId: string,
27-
userId: string
29+
userId: string,
30+
options: { executor?: DbOrTx; forShare?: boolean } = {}
2831
): Promise<boolean> {
29-
const [organizationMember] = await db
32+
const executor = options.executor ?? db
33+
const memberQuery = executor
3034
.select({ id: member.id })
3135
.from(member)
3236
.where(and(eq(member.organizationId, organizationId), eq(member.userId, userId)))
3337
.limit(1)
38+
const [organizationMember] = options.forShare ? await memberQuery.for('share') : await memberQuery
3439
if (organizationMember) return true
3540

36-
const [workspaceMember] = await db
41+
const workspaceQuery = executor
3742
.select({ id: permissions.id })
3843
.from(permissions)
3944
.innerJoin(workspace, eq(workspace.id, permissions.entityId))
@@ -45,6 +50,9 @@ export async function isOrgMemberUsageLimitTarget(
4550
)
4651
)
4752
.limit(1)
53+
const [workspaceMember] = options.forShare
54+
? await workspaceQuery.for('share', { of: permissions })
55+
: await workspaceQuery
4856
return Boolean(workspaceMember)
4957
}
5058

0 commit comments

Comments
 (0)