Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/sim/app/api/files/uploads/finalizers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export async function finalizeWorkspaceFileUpload(params: {
const metadata = session.metadata as { folderId?: string | null }
const registered = await registerUploadedWorkspaceFile({
workspaceId,
userId: session.userId,
userId: actor.id,
key: session.storageKey,
originalName: session.fileName,
contentType: session.contentType,
Expand Down
48 changes: 46 additions & 2 deletions apps/sim/app/api/public-api-route-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@ const {
mockLoggerError: vi.fn(),
mockLoggerInfo: vi.fn(),
requestContextState: {
current: undefined as { requestId: string; method?: string; path?: string } | undefined,
current: undefined as
| {
requestId: string
method?: string
path?: string
apiKeyId?: string
apiKeyType?: 'personal' | 'workspace'
}
| undefined,
},
}))

Expand All @@ -35,7 +43,13 @@ vi.mock('@sim/logger', () => ({
}),
getRequestContext: () => requestContextState.current,
runWithRequestContext: async <T>(
context: { requestId: string; method?: string; path?: string },
context: {
requestId: string
method?: string
path?: string
apiKeyId?: string
apiKeyType?: 'personal' | 'workspace'
},
callback: () => T | Promise<T>
): Promise<T> => {
requestContextState.current = context
Expand Down Expand Up @@ -186,6 +200,36 @@ describe('withPublicApiRouteHandler', () => {
expect(mockHandler).not.toHaveBeenCalled()
})

it('uses the workspace organization gate and exposes key identity in request context', async () => {
const contextSeenByHandler: Array<typeof requestContextState.current> = []
mockHandler.mockImplementationOnce(() => {
contextSeenByHandler.push(requestContextState.current)
})
mockCheckRateLimit.mockImplementation(async (request: NextRequest) => {
const workspaceRateLimit = {
...RATE_LIMIT,
userId: 'payer-1',
keyId: 'key-1',
keyType: 'workspace' as const,
billingAttribution: { organizationId: 'org-1' },
}
recordRateLimitSnapshot(request, workspaceRateLimit)
return workspaceRateLimit
})

const response = await GET(listRequest())

expect(response.status).toBe(200)
expect(mockGate).toHaveBeenCalledWith('payer-1', 'org-1')
expect(contextSeenByHandler).toEqual([
expect.objectContaining({
requestId: 'outer-request-id',
apiKeyId: 'key-1',
apiKeyType: 'workspace',
}),
])
})

it('fails fast when an allowed rate-limit result has no user ID', async () => {
mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT, userId: undefined })

Expand Down
38 changes: 27 additions & 11 deletions apps/sim/app/api/public-api-route-handler.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getRequestContext, runWithRequestContext } from '@sim/logger'
import type { NextRequest, NextResponse } from 'next/server'
Comment thread
TheodoreSpeaks marked this conversation as resolved.
import type { AnyApiRouteContract } from '@/lib/api/contracts'
import { type ParsedRequest, type ParseRequestOptions, parseRequest } from '@/lib/api/server'
Expand Down Expand Up @@ -55,20 +56,35 @@ export function withPublicApiRouteHandler<C extends AnyApiRouteContract>({
throw new Error('Allowed public API request is missing a user ID')
}
const userId = rateLimit.userId
const gate = await v2ApiGateError(userId)
const organizationId = rateLimit.billingAttribution?.organizationId ?? undefined
const gate = organizationId
? await v2ApiGateError(userId, organizationId)
: await v2ApiGateError(userId)
if (gate) return gate

const parsed = await parseRequest(contract, request, context ?? {}, {
validationErrorResponse: v2ValidationError,
...parseOptions,
})
if (!parsed.success) return parsed.response
const invokeHandler = async () => {
const parsed = await parseRequest(contract, request, context ?? {}, {
validationErrorResponse: v2ValidationError,
...parseOptions,
})
if (!parsed.success) return parsed.response

return handler({
request,
input: parsed.data,
auth: { requestId, userId, rateLimit },
})
return handler({
request,
input: parsed.data,
auth: { requestId, userId, rateLimit },
})
}

if (rateLimit.keyId && rateLimit.keyType) {
const requestContext = getRequestContext()
if (!requestContext) throw new Error('V2 API request is missing its request context')
return runWithRequestContext(
{ ...requestContext, apiKeyId: rateLimit.keyId, apiKeyType: rateLimit.keyType },
invokeHandler
)
}
return invokeHandler()
},
{
unhandledErrorResponse: () => v2Error('INTERNAL_ERROR', 'Internal server error'),
Expand Down
7 changes: 6 additions & 1 deletion apps/sim/app/api/v1/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const logger = createLogger('V1Auth')
export interface AuthResult {
authenticated: boolean
userId?: string
keyId?: string
workspaceId?: string
keyType?: 'personal' | 'workspace'
error?: string
Expand All @@ -19,6 +20,7 @@ export async function authenticateV1Request(request: NextRequest): Promise<AuthR
return {
authenticated: true,
userId: ANONYMOUS_USER_ID,
keyId: 'auth-disabled',
keyType: 'personal',
}
}
Expand All @@ -36,7 +38,9 @@ export async function authenticateV1Request(request: NextRequest): Promise<AuthR
const result = await authenticateApiKeyFromHeader(apiKey)

if (!result.success) {
logger.warn('Invalid API key attempted', { keyPrefix: apiKey.slice(0, 8) })
logger.warn('Invalid API key attempted', {
keyPrefix: apiKey.slice(0, 8),
})
return {
authenticated: false,
error: result.error || 'Invalid API key',
Expand All @@ -48,6 +52,7 @@ export async function authenticateV1Request(request: NextRequest): Promise<AuthR
return {
authenticated: true,
userId: result.userId!,
keyId: result.keyId!,
workspaceId: result.workspaceId,
keyType: result.keyType,
}
Expand Down
168 changes: 161 additions & 7 deletions apps/sim/app/api/v1/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,23 @@ import {
recordRateLimitSnapshot,
} from '@/lib/api/server/rate-limit-context'

const { mockAuthenticateV1Request, mockGetSubscription, mockCheckRateLimit, mockGetRateLimit } =
vi.hoisted(() => ({
mockAuthenticateV1Request: vi.fn(),
mockGetSubscription: vi.fn(),
mockCheckRateLimit: vi.fn(),
mockGetRateLimit: vi.fn(),
}))
const {
mockAuthenticateV1Request,
mockGetSubscription,
mockCheckRateLimit,
mockGetRateLimit,
mockResolveSystemBillingAttribution,
mockToUsageLimitSubscription,
mockGetUserEntityPermissions,
} = vi.hoisted(() => ({
mockAuthenticateV1Request: vi.fn(),
mockGetSubscription: vi.fn(),
mockCheckRateLimit: vi.fn(),
mockGetRateLimit: vi.fn(),
mockResolveSystemBillingAttribution: vi.fn(),
mockToUsageLimitSubscription: vi.fn(),
mockGetUserEntityPermissions: vi.fn(),
}))

vi.mock('@/app/api/v1/auth', () => ({
authenticateV1Request: mockAuthenticateV1Request,
Expand All @@ -33,6 +43,15 @@ vi.mock('@/lib/billing/core/subscription', () => ({
getHighestPrioritySubscription: mockGetSubscription,
}))

vi.mock('@/lib/billing/core/billing-attribution', () => ({
resolveSystemBillingAttribution: mockResolveSystemBillingAttribution,
toUsageLimitSubscription: mockToUsageLimitSubscription,
}))

vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: mockGetUserEntityPermissions,
}))

vi.mock('@/lib/core/rate-limiter', () => ({
getRateLimit: mockGetRateLimit,
RateLimiter: class {
Expand All @@ -44,6 +63,7 @@ import {
authenticateRequest,
checkRateLimit,
createRateLimitResponse,
resolveWorkspaceAccess,
v1ValidationErrorResponse,
} from '@/app/api/v1/middleware'

Expand All @@ -54,6 +74,10 @@ function request() {
return createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/v1/workflows')
}

function v2Request() {
return createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/v2/workflows')
}

describe('checkRateLimit', () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down Expand Up @@ -108,6 +132,136 @@ describe('checkRateLimit', () => {
})
})

describe('v2 attribution', () => {
const BILLING_ATTRIBUTION = {
actorUserId: 'billing-actor',
workspaceId: 'workspace-1',
organizationId: 'organization-1',
billedAccountUserId: 'billing-actor',
billingEntity: { type: 'organization', id: 'organization-1' },
billingPeriod: {
start: '2026-07-01T00:00:00.000Z',
end: '2026-08-01T00:00:00.000Z',
},
payerSubscription: null,
}
const WORKSPACE_SUBSCRIPTION = { plan: 'team', referenceId: 'organization-1' }

beforeEach(() => {
vi.clearAllMocks()
mockGetRateLimit.mockReturnValue(TEAM_BUCKET)
mockCheckRateLimit.mockResolvedValue({
allowed: true,
remaining: 399,
resetAt: new Date('2026-07-28T18:28:48.354Z'),
})
mockGetUserEntityPermissions.mockResolvedValue('read')
mockResolveSystemBillingAttribution.mockResolvedValue(BILLING_ATTRIBUTION)
mockToUsageLimitSubscription.mockReturnValue(WORKSPACE_SUBSCRIPTION)
})

it('keeps a personal key owner as both principal and actor', async () => {
mockAuthenticateV1Request.mockResolvedValue({
authenticated: true,
userId: 'person-1',
keyId: 'key-personal',
keyType: 'personal',
})
const personalSubscription = { plan: 'pro', referenceId: 'person-1' }
mockGetSubscription.mockResolvedValue(personalSubscription)

const result = await checkRateLimit(v2Request(), 'workflows')

expect(result).toMatchObject({
userId: 'person-1',
principalUserId: 'person-1',
keyId: 'key-personal',
keyType: 'personal',
})
expect(mockCheckRateLimit).toHaveBeenCalledWith(
'person-1',
personalSubscription,
'api-endpoint',
false
)
expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled()
})

it('uses the exact workspace billing actor and payer without reading the creator subscription', async () => {
mockAuthenticateV1Request.mockResolvedValue({
authenticated: true,
userId: 'key-creator',
keyId: 'key-workspace',
keyType: 'workspace',
workspaceId: 'workspace-1',
})

const result = await checkRateLimit(v2Request(), 'workflows')

expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(
'key-creator',
'workspace',
'workspace-1'
)
expect(mockGetSubscription).not.toHaveBeenCalled()
expect(mockResolveSystemBillingAttribution).toHaveBeenCalledWith('workspace-1')
expect(mockCheckRateLimit).toHaveBeenCalledWith(
'billing-actor',
WORKSPACE_SUBSCRIPTION,
'api-endpoint',
false
)
expect(result).toMatchObject({
userId: 'billing-actor',
principalUserId: 'key-creator',
keyId: 'key-workspace',
workspaceId: 'workspace-1',
keyType: 'workspace',
billingAttribution: BILLING_ATTRIBUTION,
principalWorkspacePermission: 'read',
})
})

it('preserves the creator permission when authorizing a workspace key', async () => {
const rateLimit = {
allowed: true,
remaining: 399,
limit: TEAM_BUCKET.maxTokens,
resetAt: new Date('2026-07-28T18:28:48.354Z'),
userId: 'billing-actor',
principalUserId: 'key-creator',
principalWorkspacePermission: 'read' as const,
workspaceId: 'workspace-1',
keyType: 'workspace' as const,
}

await expect(
resolveWorkspaceAccess(rateLimit, 'billing-actor', 'workspace-1', 'read')
).resolves.toBeNull()
await expect(
resolveWorkspaceAccess(rateLimit, 'billing-actor', 'workspace-1', 'write')
).resolves.toMatchObject({ status: 403, message: 'Access denied' })
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
})

it('rejects a workspace key after its creator loses workspace membership', async () => {
mockAuthenticateV1Request.mockResolvedValue({
authenticated: true,
userId: 'former-member',
keyId: 'key-workspace',
keyType: 'workspace',
workspaceId: 'workspace-1',
})
mockGetUserEntityPermissions.mockResolvedValue(null)

const result = await checkRateLimit(v2Request(), 'workflows')

expect(result).toMatchObject({ allowed: false, limit: 0, error: 'Invalid API key' })
expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled()
expect(mockCheckRateLimit).not.toHaveBeenCalled()
})
})

describe('authenticateRequest', () => {
beforeEach(() => {
vi.clearAllMocks()
Expand Down
Loading