From 9ef805a3ad849b29dbcada498bc046d9e460dc29 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 11 Aug 2026 15:45:52 -0700 Subject: [PATCH 1/3] fix(billing): withhold the payer credit pool from v2 status readers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /api/v2/billing/status` resolved the workspace's payer and projected that payer's pooled allowances — credits used, credit limit, credits remaining, and the payer entity's storage usage and quota — to any caller holding only `read` on the workspace, including a personal API key. The payer pool is shared across every workspace that payer funds, and the platform already treats it as privileged: the workspace credit-availability surface computes `canViewPayerPool` from `canManageWorkspaceBilling` and substitutes member-scoped or null figures for everyone else. The new versioned endpoint had no equivalent gate. `credits` and `storage` are now projected only to a caller who may manage the resolved payer's billing: the billed account holder of a personally hosted workspace, an admin of the hosting organization, or a workspace API key, which only a workspace admin can provision. The endpoint stays at `read` so a plain member keeps the plan, period, and standing the workspace UI already shows them, and an exceeded pooled limit still reports as `limit_exceeded` without disclosing the numbers behind it. Both fields are nullable on the wire and in the regenerated OpenAPI spec. The decision lives in the application use case, resolved from canonical workspace state, not in the route: billing authority is payer identity and organization role, which the workspace permission ladder cannot express — a plain workspace `admin` is deliberately not enough. --- apps/docs/openapi-v2-billing.json | 86 ++++++++++-------- .../app/api/v2/billing/status/route.test.ts | 11 +++ apps/sim/lib/api/contracts/v2/billing.ts | 16 +++- .../lib/api/contracts/v2/openapi/billing.ts | 2 +- .../application/billing-use-cases.test.ts | 88 +++++++++++++++++++ .../billing/application/get-billing-status.ts | 45 +++++++--- .../core/workspace-billing-authority.test.ts | 64 ++++++++++++++ .../core/workspace-billing-authority.ts | 40 +++++++++ scripts/openapi/generator.test.ts | 24 +++-- 9 files changed, 319 insertions(+), 57 deletions(-) create mode 100644 apps/sim/lib/billing/core/workspace-billing-authority.test.ts create mode 100644 apps/sim/lib/billing/core/workspace-billing-authority.ts diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 8741f76d582..31cf4c8bf2d 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -36,7 +36,7 @@ "get": { "operationId": "getBillingStatus", "summary": "Get Billing Status", - "description": "Return the current plan, billing standing, credit allowance, and storage quota. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.", + "description": "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.", "tags": ["Billing"], "parameters": [ { @@ -543,47 +543,61 @@ "description": "Current billing standing." }, "credits": { - "type": "object", - "properties": { - "used": { - "type": "number", - "description": "Credits consumed so far. The counter is reset by Stripe invoice webhooks, so on a paid plan it covers the current billing period; on the free plan nothing resets it and the value is lifetime consumption." - }, - "limit": { - "type": "number", - "description": "Credit allowance for the reporting window — per billing period on a paid plan, lifetime on the free plan." + "anyOf": [ + { + "type": "object", + "properties": { + "used": { + "type": "number", + "description": "Credits consumed so far. The counter is reset by Stripe invoice webhooks, so on a paid plan it covers the current billing period; on the free plan nothing resets it and the value is lifetime consumption." + }, + "limit": { + "type": "number", + "description": "Credit allowance for the reporting window — per billing period on a paid plan, lifetime on the free plan." + }, + "remaining": { + "type": "number", + "description": "Allowance minus consumption, over the same window." + } + }, + "required": ["used", "limit", "remaining"], + "additionalProperties": false }, - "remaining": { - "type": "number", - "description": "Allowance minus consumption, over the same window." + { + "type": "null" } - }, - "required": ["used", "limit", "remaining"], - "additionalProperties": false, - "description": "Credit usage and allowance. Periodic on a paid plan; lifetime on the free plan, where the counter never resets." + ], + "description": "The payer's credit usage and allowance — periodic on a paid plan, lifetime on the free plan, where the counter never resets. Null when the caller cannot manage that payer's billing." }, "storage": { - "type": "object", - "properties": { - "usedBytes": { - "type": "number", - "minimum": 0, - "description": "Storage currently consumed, in bytes." - }, - "limitBytes": { - "type": "number", - "minimum": 0, - "description": "Storage quota, in bytes." + "anyOf": [ + { + "type": "object", + "properties": { + "usedBytes": { + "type": "number", + "minimum": 0, + "description": "Storage currently consumed, in bytes." + }, + "limitBytes": { + "type": "number", + "minimum": 0, + "description": "Storage quota, in bytes." + }, + "percentUsed": { + "type": "number", + "minimum": 0, + "description": "Percentage of the storage quota consumed." + } + }, + "required": ["usedBytes", "limitBytes", "percentUsed"], + "additionalProperties": false }, - "percentUsed": { - "type": "number", - "minimum": 0, - "description": "Percentage of the storage quota consumed." + { + "type": "null" } - }, - "required": ["usedBytes", "limitBytes", "percentUsed"], - "additionalProperties": false, - "description": "Current storage consumption and quota." + ], + "description": "The payer's storage consumption and quota, or null when the caller cannot manage that payer's billing." } }, "required": ["workspaceId", "period", "plan", "status", "credits", "storage"], diff --git a/apps/sim/app/api/v2/billing/status/route.test.ts b/apps/sim/app/api/v2/billing/status/route.test.ts index 26a517e4cd4..e664f896b36 100644 --- a/apps/sim/app/api/v2/billing/status/route.test.ts +++ b/apps/sim/app/api/v2/billing/status/route.test.ts @@ -69,6 +69,17 @@ describe('GET /api/v2/billing/status', () => { expect(response.headers.get('x-ratelimit-limit')).toBe('100') }) + it('serializes a withheld payer pool as null without failing response validation', async () => { + mocks.execute.mockResolvedValueOnce({ ...result, credits: null, storage: null }) + + const response = await GET( + new NextRequest('http://localhost:3000/api/v2/billing/status?workspaceId=workspace-1') + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { ...result, credits: null, storage: null } }) + }) + it('projects typed workspace-policy errors', async () => { mocks.execute.mockRejectedValueOnce( new OrchestrationError('forbidden', 'API key is not authorized for this workspace') diff --git a/apps/sim/lib/api/contracts/v2/billing.ts b/apps/sim/lib/api/contracts/v2/billing.ts index 4bb28f7403c..b3fc7bce2a3 100644 --- a/apps/sim/lib/api/contracts/v2/billing.ts +++ b/apps/sim/lib/api/contracts/v2/billing.ts @@ -35,6 +35,14 @@ export const v2BillingStatusQuerySchema = z.object({ /** * Current billing standing, credit allowance, and storage quota. Ledger rows * and source analytics deliberately live outside this status resource. + * + * `credits` and `storage` report the resolved payer's pooled allowances, which + * are shared across every workspace that payer funds. They are populated only + * for a caller who may manage that payer's billing — the billed account + * holder, an admin of the hosting organization, or a workspace API key, which + * an admin of that workspace provisioned. Any other workspace member reads + * both as `null` while still seeing the plan, period, and standing that the + * workspace already surfaces to them. */ export const v2BillingStatusDataSchema = z .object({ @@ -78,8 +86,9 @@ export const v2BillingStatusDataSchema = z ), remaining: z.number().describe('Allowance minus consumption, over the same window.'), }) + .nullable() .describe( - 'Credit usage and allowance. Periodic on a paid plan; lifetime on the free plan, where the counter never resets.' + "The payer's credit usage and allowance — periodic on a paid plan, lifetime on the free plan, where the counter never resets. Null when the caller cannot manage that payer's billing." ), storage: z .object({ @@ -87,7 +96,10 @@ export const v2BillingStatusDataSchema = z limitBytes: z.number().nonnegative().describe('Storage quota, in bytes.'), percentUsed: z.number().nonnegative().describe('Percentage of the storage quota consumed.'), }) - .describe('Current storage consumption and quota.'), + .nullable() + .describe( + "The payer's storage consumption and quota, or null when the caller cannot manage that payer's billing." + ), }) .meta({ id: 'V2BillingStatus', diff --git a/apps/sim/lib/api/contracts/v2/openapi/billing.ts b/apps/sim/lib/api/contracts/v2/openapi/billing.ts index 5cd25391635..194c474fffa 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/billing.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/billing.ts @@ -80,7 +80,7 @@ const routes = [ operationId: 'getBillingStatus', summary: 'Get Billing Status', description: - 'Return the current plan, billing standing, credit allowance, and storage quota. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.', + "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.", errors: [...WORKSPACE_ERRORS, 'NotFound'], success: { description: 'The current billing and storage status.' }, }), diff --git a/apps/sim/lib/billing/application/billing-use-cases.test.ts b/apps/sim/lib/billing/application/billing-use-cases.test.ts index 9e51e5d4d4a..35c8c1df9f4 100644 --- a/apps/sim/lib/billing/application/billing-use-cases.test.ts +++ b/apps/sim/lib/billing/application/billing-use-cases.test.ts @@ -24,6 +24,11 @@ const mocks = vi.hoisted(() => ({ getUsageLogs: vi.fn(), getWorkspaceUsageLogs: vi.fn(), recordAudit: vi.fn(), + canManageWorkspaceBilling: vi.fn(), +})) + +vi.mock('@/lib/billing/core/workspace-billing-authority', () => ({ + canUserManageWorkspaceBilling: mocks.canManageWorkspaceBilling, })) vi.mock('@/lib/workspaces/application/workspace-context', () => ({ @@ -94,6 +99,7 @@ describe('billing application use cases', () => { vi.clearAllMocks() mocks.loadWorkspace.mockResolvedValue(workspaceContext) mocks.resolvePermission.mockResolvedValue('read') + mocks.canManageWorkspaceBilling.mockResolvedValue(false) mocks.checkUsageStatus.mockResolvedValue({ currentUsage: 1, limit: 10, isExceeded: false }) mocks.checkAttributedBlocks.mockResolvedValue({ blocked: false }) mocks.toUsageLimitSubscription.mockReturnValue(null) @@ -173,6 +179,88 @@ describe('billing application use cases', () => { expect(mocks.recordAudit).not.toHaveBeenCalled() }) + it('withholds the payer pool from a workspace member who cannot manage billing', async () => { + mocks.resolvePermission.mockResolvedValue('read') + mocks.canManageWorkspaceBilling.mockResolvedValue(false) + + const result = await getBillingStatus.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1' }, + }) + + expect(result.credits).toBeNull() + expect(result.storage).toBeNull() + expect(result).toMatchObject({ workspaceId: 'workspace-1', plan: 'free', status: 'active' }) + expect(mocks.canManageWorkspaceBilling).toHaveBeenCalledWith(workspaceContext, 'user-1') + }) + + it('withholds the payer pool from a workspace admin who cannot manage billing', async () => { + mocks.resolvePermission.mockResolvedValue('admin') + mocks.canManageWorkspaceBilling.mockResolvedValue(false) + + const result = await getBillingStatus.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1' }, + }) + + expect(result.credits).toBeNull() + expect(result.storage).toBeNull() + }) + + it('still reports an exceeded payer limit without disclosing the pool', async () => { + mocks.canManageWorkspaceBilling.mockResolvedValue(false) + mocks.checkUsageStatus.mockResolvedValue({ currentUsage: 40, limit: 10, isExceeded: true }) + + const result = await getBillingStatus.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1' }, + }) + + expect(result.status).toBe('limit_exceeded') + expect(result.credits).toBeNull() + }) + + it('projects the payer pool to a member who can manage billing', async () => { + mocks.canManageWorkspaceBilling.mockResolvedValue(true) + + const result = await getBillingStatus.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1' }, + }) + + expect(result.credits).toEqual({ used: 200, limit: 2_000, remaining: 1_800 }) + expect(result.storage).toEqual({ + usedBytes: 5_242_880, + limitBytes: 1_073_741_824, + percentUsed: 0.48828125, + }) + }) + + it('never consults human billing authority for a workspace key', async () => { + const result = await getBillingStatus.execute({ principal: workspacePrincipal, input: {} }) + + expect(result.credits).not.toBeNull() + expect(mocks.canManageWorkspaceBilling).not.toHaveBeenCalled() + }) + + it('always reports the account-scoped pool the caller owns', async () => { + mocks.canManageWorkspaceBilling.mockResolvedValue(false) + mocks.getSubscription.mockResolvedValue({ plan: 'pro' }) + mocks.deriveBillingContext.mockReturnValue({ + billingEntity: { type: 'user', id: 'user-1' }, + billingPeriod: { + start: new Date('2026-01-01T00:00:00Z'), + end: new Date('2026-02-01T00:00:00Z'), + }, + }) + mocks.checkBillingBlocked.mockResolvedValue({ blocked: false }) + + const result = await getBillingStatus.execute({ principal: personalPrincipal, input: {} }) + + expect(result.credits).toEqual({ used: 200, limit: 2_000, remaining: 1_800 }) + expect(result.storage).not.toBeNull() + }) + it('uses the personal principal as account authority', async () => { mocks.getSubscription.mockResolvedValue({ plan: 'pro' }) mocks.deriveBillingContext.mockReturnValue({ diff --git a/apps/sim/lib/billing/application/get-billing-status.ts b/apps/sim/lib/billing/application/get-billing-status.ts index a4b27ca4684..56c30462bba 100644 --- a/apps/sim/lib/billing/application/get-billing-status.ts +++ b/apps/sim/lib/billing/application/get-billing-status.ts @@ -13,6 +13,7 @@ import { } from '@/lib/billing/core/billing-attribution' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { deriveBillingContext } from '@/lib/billing/core/usage-log' +import { canUserManageWorkspaceBilling } from '@/lib/billing/core/workspace-billing-authority' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getStorageLimitForBillingContext, @@ -26,16 +27,35 @@ export interface GetBillingStatusInput { workspaceId?: string } +export interface BillingCreditsStatus { + used: number + limit: number + remaining: number +} + +export interface BillingStorageStatus { + usedBytes: number + limitBytes: number + percentUsed: number +} + +/** + * `credits` and `storage` describe the resolved payer's pooled allowances, not + * the caller's own consumption, so they are only projected to a caller who may + * manage that payer's billing. Every other workspace member reads them as + * `null` while still seeing the plan and standing the workspace UI already + * shows them. + */ export interface BillingStatusResult { workspaceId: string | null period: { start: string; end: string } plan: string status: 'active' | 'limit_exceeded' | 'billing_blocked' - credits: { used: number; limit: number; remaining: number } - storage: { usedBytes: number; limitBytes: number; percentUsed: number } + credits: BillingCreditsStatus | null + storage: BillingStorageStatus | null } -function storageStatus(usedBytes: number, limitBytes: number): BillingStatusResult['storage'] { +function storageStatus(usedBytes: number, limitBytes: number): BillingStorageStatus { return { usedBytes, limitBytes, @@ -48,7 +68,7 @@ export const getBillingStatus = defineAuthorizedBillingReadUseCase({ requestedWorkspaceId: (input: GetBillingStatusInput) => input.workspaceId, execute: async ({ principal, scope }): Promise => { if (scope.kind === 'workspace') { - const [attribution, storageContext] = await Promise.all([ + const [attribution, storageContext, canViewPayerPool] = await Promise.all([ principal.kind === 'personal_api_key' ? resolveBillingAttribution({ actorUserId: principal.userId, @@ -56,6 +76,9 @@ export const getBillingStatus = defineAuthorizedBillingReadUseCase({ }) : resolveSystemBillingAttribution(scope.workspace.workspaceId), resolveStorageBillingContext(scope.workspace.workspaceId), + principal.kind === 'personal_api_key' + ? canUserManageWorkspaceBilling(scope.workspace, principal.userId) + : Promise.resolve(true), ]) const [usage, block, storageUsedBytes] = await Promise.all([ checkUsageStatus(attribution.billedAccountUserId, toUsageLimitSubscription(attribution)), @@ -68,12 +91,14 @@ export const getBillingStatus = defineAuthorizedBillingReadUseCase({ period: attribution.billingPeriod, plan: attribution.payerSubscription?.plan ?? 'free', status: block.blocked ? 'billing_blocked' : usage.isExceeded ? 'limit_exceeded' : 'active', - credits: { - used: dollarsToCredits(usage.currentUsage), - limit: dollarsToCredits(usage.limit), - remaining: dollarsToCredits(usage.limit - usage.currentUsage), - }, - storage: storageStatus(storageUsedBytes, storageLimitBytes), + credits: canViewPayerPool + ? { + used: dollarsToCredits(usage.currentUsage), + limit: dollarsToCredits(usage.limit), + remaining: dollarsToCredits(usage.limit - usage.currentUsage), + } + : null, + storage: canViewPayerPool ? storageStatus(storageUsedBytes, storageLimitBytes) : null, } } diff --git a/apps/sim/lib/billing/core/workspace-billing-authority.test.ts b/apps/sim/lib/billing/core/workspace-billing-authority.test.ts new file mode 100644 index 00000000000..9afb928c16d --- /dev/null +++ b/apps/sim/lib/billing/core/workspace-billing-authority.test.ts @@ -0,0 +1,64 @@ +/** + * @vitest-environment node + */ +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { canUserManageWorkspaceBilling } from '@/lib/billing/core/workspace-billing-authority' + +const personalWorkspace = { + workspaceOrganizationId: null, + billedAccountUserId: 'billing-owner-1', +} +const organizationWorkspace = { + workspaceOrganizationId: 'organization-1', + billedAccountUserId: 'billing-owner-1', +} + +describe('canUserManageWorkspaceBilling', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() + }) + + it('grants the billed account holder of a personally hosted workspace', async () => { + await expect(canUserManageWorkspaceBilling(personalWorkspace, 'billing-owner-1')).resolves.toBe( + true + ) + }) + + it('refuses any other member of a personally hosted workspace', async () => { + await expect(canUserManageWorkspaceBilling(personalWorkspace, 'user-1')).resolves.toBe(false) + }) + + it('grants an admin of the hosting organization', async () => { + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + + await expect(canUserManageWorkspaceBilling(organizationWorkspace, 'user-1')).resolves.toBe(true) + }) + + it('grants the owner of the hosting organization', async () => { + queueTableRows(schemaMock.member, [{ role: 'owner' }]) + + await expect(canUserManageWorkspaceBilling(organizationWorkspace, 'user-1')).resolves.toBe(true) + }) + + it('refuses a plain member of the hosting organization', async () => { + queueTableRows(schemaMock.member, [{ role: 'member' }]) + + await expect(canUserManageWorkspaceBilling(organizationWorkspace, 'user-1')).resolves.toBe( + false + ) + }) + + it('refuses a non-member, including the billed account holder, on an organization host', async () => { + queueTableRows(schemaMock.member, []) + + await expect( + canUserManageWorkspaceBilling(organizationWorkspace, 'billing-owner-1') + ).resolves.toBe(false) + }) +}) diff --git a/apps/sim/lib/billing/core/workspace-billing-authority.ts b/apps/sim/lib/billing/core/workspace-billing-authority.ts new file mode 100644 index 00000000000..c11eaed9870 --- /dev/null +++ b/apps/sim/lib/billing/core/workspace-billing-authority.ts @@ -0,0 +1,40 @@ +import { db } from '@sim/db' +import { member } from '@sim/db/schema' +import { isOrgAdminRole } from '@sim/platform-authz/workspace' +import { and, eq } from 'drizzle-orm' + +/** + * Canonical workspace state needed to decide payer-billing authority. + */ +export interface WorkspaceBillingAuthorityContext { + workspaceOrganizationId: string | null + billedAccountUserId: string +} + +/** + * Server-side counterpart of `canManageWorkspaceBilling`, resolved from + * canonical workspace state instead of a viewer-facing host context. + * + * An organization-hosted workspace answers to its organization admins; a + * personally hosted workspace answers only to its billed account holder. A + * plain workspace `admin` is deliberately not sufficient: workspace roles + * govern the workspace's resources, not the payer's pooled credit and storage + * allowances, which are shared across every workspace the payer funds. + */ +export async function canUserManageWorkspaceBilling( + context: WorkspaceBillingAuthorityContext, + userId: string +): Promise { + if (context.workspaceOrganizationId) { + const [membership] = await db + .select({ role: member.role }) + .from(member) + .where( + and(eq(member.organizationId, context.workspaceOrganizationId), eq(member.userId, userId)) + ) + .limit(1) + return isOrgAdminRole(membership?.role) + } + + return context.billedAccountUserId === userId +} diff --git a/scripts/openapi/generator.test.ts b/scripts/openapi/generator.test.ts index 532162ca88e..1162abaa8e1 100644 --- a/scripts/openapi/generator.test.ts +++ b/scripts/openapi/generator.test.ts @@ -560,17 +560,25 @@ describe('OpenAPI generator', () => { const billingStatus = schemas.V2BillingStatus as JsonObject const dataProperties = billingStatus.properties as JsonObject const storage = dataProperties.storage as JsonObject + const credits = dataProperties.credits as JsonObject expect(Object.keys(paths).sort()).toEqual(['/api/v2/billing/logs', '/api/v2/billing/status']) expect(data.$ref).toBe('#/components/schemas/V2BillingStatus') - expect(storage).toMatchObject({ - required: ['usedBytes', 'limitBytes', 'percentUsed'], - properties: { - usedBytes: { type: 'number', minimum: 0 }, - limitBytes: { type: 'number', minimum: 0 }, - percentUsed: { type: 'number', minimum: 0 }, - }, - }) + expect(storage.anyOf).toEqual([ + expect.objectContaining({ + required: ['usedBytes', 'limitBytes', 'percentUsed'], + properties: { + usedBytes: expect.objectContaining({ type: 'number', minimum: 0 }), + limitBytes: expect.objectContaining({ type: 'number', minimum: 0 }), + percentUsed: expect.objectContaining({ type: 'number', minimum: 0 }), + }, + }), + { type: 'null' }, + ]) + expect(credits.anyOf).toEqual([ + expect.objectContaining({ required: ['used', 'limit', 'remaining'] }), + { type: 'null' }, + ]) }) it('uses string wire values for transformed boolean defaults', () => { From 8ff822ee5675ad7ee5104be5f1f1f32d299e1592 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 11 Aug 2026 15:53:19 -0700 Subject: [PATCH 2/3] fix(billing): deny the payer pool to actor-less workspace API keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass gated `credits` and `storage` on billing authority for personal API keys but let a `workspace_api_key` principal through unconditionally, which left the excluded role a way back in. Any workspace `admin` may mint a workspace API key, and a workspace `admin` is deliberately not a billing manager, so an admin who reads `null` as themselves could mint a key and read the full pool with it. On an organization-hosted workspace that pool is the organization's, spanning workspaces the admin has no standing in. Billing authority is payer identity or an organization admin role — a property of a person. A workspace API key is deliberately actor-less, so it can never satisfy it and now reads both fields as `null`. Attributing the key to its creator was rejected: it would launder the same workspace-admin role, it breaks when the creator's authority is revoked while the key lives on, and substituting a key's owner for the acting principal is what the application operation boundary forbids. The reasoning sits in TSDoc at the decision point. The key keeps the plan, period, and standing it needs to monitor a workspace, including `limit_exceeded` and `billing_blocked`. No in-repo caller reads `credits` or `storage` from this endpoint. The payer storage pool is now read only once disclosure is authorized, so a caller who may not see it no longer triggers the query at all. --- apps/docs/openapi-v2-billing.json | 6 +- apps/sim/lib/api/contracts/v2/billing.ts | 15 +++-- .../lib/api/contracts/v2/openapi/billing.ts | 2 +- .../application/billing-use-cases.test.ts | 54 +++++++++++---- .../billing/application/get-billing-status.ts | 65 +++++++++++++++---- 5 files changed, 104 insertions(+), 38 deletions(-) diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 31cf4c8bf2d..dc39ce7bb69 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -36,7 +36,7 @@ "get": { "operationId": "getBillingStatus", "summary": "Get Billing Status", - "description": "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.", + "description": "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing; they are always null for a workspace API key. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.", "tags": ["Billing"], "parameters": [ { @@ -567,7 +567,7 @@ "type": "null" } ], - "description": "The payer's credit usage and allowance — periodic on a paid plan, lifetime on the free plan, where the counter never resets. Null when the caller cannot manage that payer's billing." + "description": "The payer's credit usage and allowance — periodic on a paid plan, lifetime on the free plan, where the counter never resets. Null when the caller cannot manage that payer's billing. Always null for a workspace API key." }, "storage": { "anyOf": [ @@ -597,7 +597,7 @@ "type": "null" } ], - "description": "The payer's storage consumption and quota, or null when the caller cannot manage that payer's billing." + "description": "The payer's storage consumption and quota, or null when the caller cannot manage that payer's billing. Always null for a workspace API key." } }, "required": ["workspaceId", "period", "plan", "status", "credits", "storage"], diff --git a/apps/sim/lib/api/contracts/v2/billing.ts b/apps/sim/lib/api/contracts/v2/billing.ts index b3fc7bce2a3..b39a33beb2d 100644 --- a/apps/sim/lib/api/contracts/v2/billing.ts +++ b/apps/sim/lib/api/contracts/v2/billing.ts @@ -38,11 +38,12 @@ export const v2BillingStatusQuerySchema = z.object({ * * `credits` and `storage` report the resolved payer's pooled allowances, which * are shared across every workspace that payer funds. They are populated only - * for a caller who may manage that payer's billing — the billed account - * holder, an admin of the hosting organization, or a workspace API key, which - * an admin of that workspace provisioned. Any other workspace member reads - * both as `null` while still seeing the plan, period, and standing that the - * workspace already surfaces to them. + * for a caller who may manage that payer's billing: the billed account holder, + * or an admin of the hosting organization. Billing authority is a property of + * a person, so an actor-less workspace API key never qualifies. Every other + * caller reads both as `null` while still seeing the plan, period, and + * standing that the workspace already surfaces to them — enough to monitor for + * `limit_exceeded` and `billing_blocked`. */ export const v2BillingStatusDataSchema = z .object({ @@ -88,7 +89,7 @@ export const v2BillingStatusDataSchema = z }) .nullable() .describe( - "The payer's credit usage and allowance — periodic on a paid plan, lifetime on the free plan, where the counter never resets. Null when the caller cannot manage that payer's billing." + "The payer's credit usage and allowance — periodic on a paid plan, lifetime on the free plan, where the counter never resets. Null when the caller cannot manage that payer's billing. Always null for a workspace API key." ), storage: z .object({ @@ -98,7 +99,7 @@ export const v2BillingStatusDataSchema = z }) .nullable() .describe( - "The payer's storage consumption and quota, or null when the caller cannot manage that payer's billing." + "The payer's storage consumption and quota, or null when the caller cannot manage that payer's billing. Always null for a workspace API key." ), }) .meta({ diff --git a/apps/sim/lib/api/contracts/v2/openapi/billing.ts b/apps/sim/lib/api/contracts/v2/openapi/billing.ts index 194c474fffa..d282d7625cd 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/billing.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/billing.ts @@ -80,7 +80,7 @@ const routes = [ operationId: 'getBillingStatus', summary: 'Get Billing Status', description: - "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.", + "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing; they are always null for a workspace API key. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.", errors: [...WORKSPACE_ERRORS, 'NotFound'], success: { description: 'The current billing and storage status.' }, }), diff --git a/apps/sim/lib/billing/application/billing-use-cases.test.ts b/apps/sim/lib/billing/application/billing-use-cases.test.ts index 35c8c1df9f4..3836e3ccd15 100644 --- a/apps/sim/lib/billing/application/billing-use-cases.test.ts +++ b/apps/sim/lib/billing/application/billing-use-cases.test.ts @@ -167,18 +167,53 @@ describe('billing application use cases', () => { }) expect(result.workspaceId).toBe('workspace-1') - expect(result.storage).toEqual({ - usedBytes: 5_242_880, - limitBytes: 1_073_741_824, - percentUsed: 0.48828125, - }) + expect(result).toMatchObject({ plan: 'free', status: 'active' }) expect(mocks.resolveSystemAttribution).toHaveBeenCalledWith('workspace-1') - expect(mocks.resolveStorageContext).toHaveBeenCalledWith('workspace-1') expect(mocks.resolvePermission).not.toHaveBeenCalled() expect(mocks.resolveAttribution).not.toHaveBeenCalled() expect(mocks.recordAudit).not.toHaveBeenCalled() }) + /** + * A workspace API key is actor-less, and any workspace `admin` may mint one, + * so granting it the pool would launder the exact role the projection + * excludes — across the whole organization on an organization-hosted + * workspace. + */ + it('withholds the payer pool from an actor-less workspace key', async () => { + const result = await getBillingStatus.execute({ + principal: workspacePrincipal, + input: {}, + }) + + expect(result.credits).toBeNull() + expect(result.storage).toBeNull() + expect(mocks.canManageWorkspaceBilling).not.toHaveBeenCalled() + }) + + it('never reads the payer storage pool it may not disclose', async () => { + await getBillingStatus.execute({ principal: workspacePrincipal, input: {} }) + await getBillingStatus.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1' }, + }) + + expect(mocks.resolveStorageContext).not.toHaveBeenCalled() + expect(mocks.getStorageUsageForContext).not.toHaveBeenCalled() + }) + + it('still reports a workspace key an exceeded pooled limit it cannot read', async () => { + mocks.checkAttributedBlocks.mockResolvedValue({ blocked: true }) + + const result = await getBillingStatus.execute({ + principal: workspacePrincipal, + input: {}, + }) + + expect(result.status).toBe('billing_blocked') + expect(result.credits).toBeNull() + }) + it('withholds the payer pool from a workspace member who cannot manage billing', async () => { mocks.resolvePermission.mockResolvedValue('read') mocks.canManageWorkspaceBilling.mockResolvedValue(false) @@ -236,13 +271,6 @@ describe('billing application use cases', () => { }) }) - it('never consults human billing authority for a workspace key', async () => { - const result = await getBillingStatus.execute({ principal: workspacePrincipal, input: {} }) - - expect(result.credits).not.toBeNull() - expect(mocks.canManageWorkspaceBilling).not.toHaveBeenCalled() - }) - it('always reports the account-scoped pool the caller owns', async () => { mocks.canManageWorkspaceBilling.mockResolvedValue(false) mocks.getSubscription.mockResolvedValue({ plan: 'pro' }) diff --git a/apps/sim/lib/billing/application/get-billing-status.ts b/apps/sim/lib/billing/application/get-billing-status.ts index 56c30462bba..d0d30f319d4 100644 --- a/apps/sim/lib/billing/application/get-billing-status.ts +++ b/apps/sim/lib/billing/application/get-billing-status.ts @@ -1,5 +1,5 @@ import { defineAuthorizedBillingReadUseCase } from '@/lib/billing/application/authorized-billing-read-use-case' -import { billingOperations } from '@/lib/billing/application/operations' +import { type BillingReadPrincipal, billingOperations } from '@/lib/billing/application/operations' import { checkBillingBlocked, checkBillingEntityBlocked, @@ -13,7 +13,10 @@ import { } from '@/lib/billing/core/billing-attribution' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { deriveBillingContext } from '@/lib/billing/core/usage-log' -import { canUserManageWorkspaceBilling } from '@/lib/billing/core/workspace-billing-authority' +import { + canUserManageWorkspaceBilling, + type WorkspaceBillingAuthorityContext, +} from '@/lib/billing/core/workspace-billing-authority' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getStorageLimitForBillingContext, @@ -42,9 +45,9 @@ export interface BillingStorageStatus { /** * `credits` and `storage` describe the resolved payer's pooled allowances, not * the caller's own consumption, so they are only projected to a caller who may - * manage that payer's billing. Every other workspace member reads them as - * `null` while still seeing the plan and standing the workspace UI already - * shows them. + * manage that payer's billing — see {@link canReadPayerPool}. Every other + * caller reads them as `null` while still seeing the plan and standing the + * workspace UI already shows them. */ export interface BillingStatusResult { workspaceId: string | null @@ -63,29 +66,63 @@ function storageStatus(usedBytes: number, limitBytes: number): BillingStorageSta } } +/** + * Resolves whether a caller may read the resolved payer's pooled allowances. + * + * Only a human principal can hold billing authority: it is payer identity, or + * an admin role in the hosting organization, and never a workspace role. A + * workspace API key is deliberately actor-less — `WorkspaceApiKeyPrincipal` + * carries a key and a workspace but no user — so there is no identity to + * evaluate that authority against, and it is therefore never a billing + * manager. + * + * Attributing the key to whoever created it would close the gap on paper and + * open a worse one. Any workspace `admin` may mint a workspace key, and a + * workspace `admin` is not a billing manager, so the key would launder exactly + * the role this projection excludes. The pool is the payer's, not the + * workspace's — organization-wide on an organization-hosted workspace — so it + * spans workspaces that admin has no standing in at all. Substituting a key's + * owner for the acting principal is also what the application operation + * boundary forbids outright, and the creator's authority can be revoked while + * the key keeps working. + * + * A workspace key still reads the plan, period, and standing it needs to + * monitor the workspace, including `limit_exceeded` and `billing_blocked`. + */ +function canReadPayerPool( + principal: BillingReadPrincipal, + workspace: WorkspaceBillingAuthorityContext +): Promise { + if (principal.kind !== 'personal_api_key') return Promise.resolve(false) + return canUserManageWorkspaceBilling(workspace, principal.userId) +} + +/** Reads the payer's storage pool. Only called once disclosure is authorized. */ +async function resolvePayerStorage(workspaceId: string): Promise { + const storageContext = await resolveStorageBillingContext(workspaceId) + const usedBytes = await getStorageUsageForBillingContext(storageContext) + return storageStatus(usedBytes, getStorageLimitForBillingContext(storageContext)) +} + export const getBillingStatus = defineAuthorizedBillingReadUseCase({ operation: billingOperations.readStatus, requestedWorkspaceId: (input: GetBillingStatusInput) => input.workspaceId, execute: async ({ principal, scope }): Promise => { if (scope.kind === 'workspace') { - const [attribution, storageContext, canViewPayerPool] = await Promise.all([ + const [attribution, canViewPayerPool] = await Promise.all([ principal.kind === 'personal_api_key' ? resolveBillingAttribution({ actorUserId: principal.userId, workspaceId: scope.workspace.workspaceId, }) : resolveSystemBillingAttribution(scope.workspace.workspaceId), - resolveStorageBillingContext(scope.workspace.workspaceId), - principal.kind === 'personal_api_key' - ? canUserManageWorkspaceBilling(scope.workspace, principal.userId) - : Promise.resolve(true), + canReadPayerPool(principal, scope.workspace), ]) - const [usage, block, storageUsedBytes] = await Promise.all([ + const [usage, block, storage] = await Promise.all([ checkUsageStatus(attribution.billedAccountUserId, toUsageLimitSubscription(attribution)), checkAttributedBillingBlocks(attribution), - getStorageUsageForBillingContext(storageContext), + canViewPayerPool ? resolvePayerStorage(scope.workspace.workspaceId) : null, ]) - const storageLimitBytes = getStorageLimitForBillingContext(storageContext) return { workspaceId: scope.workspace.workspaceId, period: attribution.billingPeriod, @@ -98,7 +135,7 @@ export const getBillingStatus = defineAuthorizedBillingReadUseCase({ remaining: dollarsToCredits(usage.limit - usage.currentUsage), } : null, - storage: canViewPayerPool ? storageStatus(storageUsedBytes, storageLimitBytes) : null, + storage, } } From 79a3486a364b6a34cb1a31350c9a5b07780f7fdb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Tue, 11 Aug 2026 15:57:47 -0700 Subject: [PATCH 3/3] chore(billing): tidy payer-pool concealment cleanup --- .../application/billing-use-cases.test.ts | 20 ++++++------- .../billing/application/get-billing-status.ts | 28 +++++++++---------- .../core/workspace-billing-authority.ts | 14 ++-------- 3 files changed, 25 insertions(+), 37 deletions(-) diff --git a/apps/sim/lib/billing/application/billing-use-cases.test.ts b/apps/sim/lib/billing/application/billing-use-cases.test.ts index 3836e3ccd15..08bc56447c6 100644 --- a/apps/sim/lib/billing/application/billing-use-cases.test.ts +++ b/apps/sim/lib/billing/application/billing-use-cases.test.ts @@ -24,11 +24,11 @@ const mocks = vi.hoisted(() => ({ getUsageLogs: vi.fn(), getWorkspaceUsageLogs: vi.fn(), recordAudit: vi.fn(), - canManageWorkspaceBilling: vi.fn(), + canUserManageWorkspaceBilling: vi.fn(), })) vi.mock('@/lib/billing/core/workspace-billing-authority', () => ({ - canUserManageWorkspaceBilling: mocks.canManageWorkspaceBilling, + canUserManageWorkspaceBilling: mocks.canUserManageWorkspaceBilling, })) vi.mock('@/lib/workspaces/application/workspace-context', () => ({ @@ -99,7 +99,7 @@ describe('billing application use cases', () => { vi.clearAllMocks() mocks.loadWorkspace.mockResolvedValue(workspaceContext) mocks.resolvePermission.mockResolvedValue('read') - mocks.canManageWorkspaceBilling.mockResolvedValue(false) + mocks.canUserManageWorkspaceBilling.mockResolvedValue(false) mocks.checkUsageStatus.mockResolvedValue({ currentUsage: 1, limit: 10, isExceeded: false }) mocks.checkAttributedBlocks.mockResolvedValue({ blocked: false }) mocks.toUsageLimitSubscription.mockReturnValue(null) @@ -188,7 +188,7 @@ describe('billing application use cases', () => { expect(result.credits).toBeNull() expect(result.storage).toBeNull() - expect(mocks.canManageWorkspaceBilling).not.toHaveBeenCalled() + expect(mocks.canUserManageWorkspaceBilling).not.toHaveBeenCalled() }) it('never reads the payer storage pool it may not disclose', async () => { @@ -216,7 +216,7 @@ describe('billing application use cases', () => { it('withholds the payer pool from a workspace member who cannot manage billing', async () => { mocks.resolvePermission.mockResolvedValue('read') - mocks.canManageWorkspaceBilling.mockResolvedValue(false) + mocks.canUserManageWorkspaceBilling.mockResolvedValue(false) const result = await getBillingStatus.execute({ principal: personalPrincipal, @@ -226,12 +226,12 @@ describe('billing application use cases', () => { expect(result.credits).toBeNull() expect(result.storage).toBeNull() expect(result).toMatchObject({ workspaceId: 'workspace-1', plan: 'free', status: 'active' }) - expect(mocks.canManageWorkspaceBilling).toHaveBeenCalledWith(workspaceContext, 'user-1') + expect(mocks.canUserManageWorkspaceBilling).toHaveBeenCalledWith(workspaceContext, 'user-1') }) it('withholds the payer pool from a workspace admin who cannot manage billing', async () => { mocks.resolvePermission.mockResolvedValue('admin') - mocks.canManageWorkspaceBilling.mockResolvedValue(false) + mocks.canUserManageWorkspaceBilling.mockResolvedValue(false) const result = await getBillingStatus.execute({ principal: personalPrincipal, @@ -243,7 +243,7 @@ describe('billing application use cases', () => { }) it('still reports an exceeded payer limit without disclosing the pool', async () => { - mocks.canManageWorkspaceBilling.mockResolvedValue(false) + mocks.canUserManageWorkspaceBilling.mockResolvedValue(false) mocks.checkUsageStatus.mockResolvedValue({ currentUsage: 40, limit: 10, isExceeded: true }) const result = await getBillingStatus.execute({ @@ -256,7 +256,7 @@ describe('billing application use cases', () => { }) it('projects the payer pool to a member who can manage billing', async () => { - mocks.canManageWorkspaceBilling.mockResolvedValue(true) + mocks.canUserManageWorkspaceBilling.mockResolvedValue(true) const result = await getBillingStatus.execute({ principal: personalPrincipal, @@ -272,7 +272,7 @@ describe('billing application use cases', () => { }) it('always reports the account-scoped pool the caller owns', async () => { - mocks.canManageWorkspaceBilling.mockResolvedValue(false) + mocks.canUserManageWorkspaceBilling.mockResolvedValue(false) mocks.getSubscription.mockResolvedValue({ plan: 'pro' }) mocks.deriveBillingContext.mockReturnValue({ billingEntity: { type: 'user', id: 'user-1' }, diff --git a/apps/sim/lib/billing/application/get-billing-status.ts b/apps/sim/lib/billing/application/get-billing-status.ts index d0d30f319d4..e3520a38178 100644 --- a/apps/sim/lib/billing/application/get-billing-status.ts +++ b/apps/sim/lib/billing/application/get-billing-status.ts @@ -66,6 +66,14 @@ function storageStatus(usedBytes: number, limitBytes: number): BillingStorageSta } } +function creditsStatus(usage: { currentUsage: number; limit: number }): BillingCreditsStatus { + return { + used: dollarsToCredits(usage.currentUsage), + limit: dollarsToCredits(usage.limit), + remaining: dollarsToCredits(usage.limit - usage.currentUsage), + } +} + /** * Resolves whether a caller may read the resolved payer's pooled allowances. * @@ -89,15 +97,15 @@ function storageStatus(usedBytes: number, limitBytes: number): BillingStorageSta * A workspace key still reads the plan, period, and standing it needs to * monitor the workspace, including `limit_exceeded` and `billing_blocked`. */ -function canReadPayerPool( +async function canReadPayerPool( principal: BillingReadPrincipal, workspace: WorkspaceBillingAuthorityContext ): Promise { - if (principal.kind !== 'personal_api_key') return Promise.resolve(false) + if (principal.kind !== 'personal_api_key') return false return canUserManageWorkspaceBilling(workspace, principal.userId) } -/** Reads the payer's storage pool. Only called once disclosure is authorized. */ +/** Only invoked once payer-pool disclosure is authorized. */ async function resolvePayerStorage(workspaceId: string): Promise { const storageContext = await resolveStorageBillingContext(workspaceId) const usedBytes = await getStorageUsageForBillingContext(storageContext) @@ -128,13 +136,7 @@ export const getBillingStatus = defineAuthorizedBillingReadUseCase({ period: attribution.billingPeriod, plan: attribution.payerSubscription?.plan ?? 'free', status: block.blocked ? 'billing_blocked' : usage.isExceeded ? 'limit_exceeded' : 'active', - credits: canViewPayerPool - ? { - used: dollarsToCredits(usage.currentUsage), - limit: dollarsToCredits(usage.limit), - remaining: dollarsToCredits(usage.limit - usage.currentUsage), - } - : null, + credits: canViewPayerPool ? creditsStatus(usage) : null, storage, } } @@ -163,11 +165,7 @@ export const getBillingStatus = defineAuthorizedBillingReadUseCase({ : usage.isExceeded ? 'limit_exceeded' : 'active', - credits: { - used: dollarsToCredits(usage.currentUsage), - limit: dollarsToCredits(usage.limit), - remaining: dollarsToCredits(usage.limit - usage.currentUsage), - }, + credits: creditsStatus(usage), storage: storageStatus(storageUsedBytes, storageLimitBytes), } }, diff --git a/apps/sim/lib/billing/core/workspace-billing-authority.ts b/apps/sim/lib/billing/core/workspace-billing-authority.ts index c11eaed9870..c536dbe8f53 100644 --- a/apps/sim/lib/billing/core/workspace-billing-authority.ts +++ b/apps/sim/lib/billing/core/workspace-billing-authority.ts @@ -1,7 +1,4 @@ -import { db } from '@sim/db' -import { member } from '@sim/db/schema' -import { isOrgAdminRole } from '@sim/platform-authz/workspace' -import { and, eq } from 'drizzle-orm' +import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils' /** * Canonical workspace state needed to decide payer-billing authority. @@ -26,14 +23,7 @@ export async function canUserManageWorkspaceBilling( userId: string ): Promise { if (context.workspaceOrganizationId) { - const [membership] = await db - .select({ role: member.role }) - .from(member) - .where( - and(eq(member.organizationId, context.workspaceOrganizationId), eq(member.userId, userId)) - ) - .limit(1) - return isOrgAdminRole(membership?.role) + return isOrganizationAdminOrOwner(userId, context.workspaceOrganizationId) } return context.billedAccountUserId === userId