diff --git a/apps/sim/app/api/files/uploads/finalizers.ts b/apps/sim/app/api/files/uploads/finalizers.ts index 3c998f1be30..5b8ec79a0a8 100644 --- a/apps/sim/app/api/files/uploads/finalizers.ts +++ b/apps/sim/app/api/files/uploads/finalizers.ts @@ -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, diff --git a/apps/sim/app/api/public-api-route-handler.test.ts b/apps/sim/app/api/public-api-route-handler.test.ts index 33757c33864..be102a65828 100644 --- a/apps/sim/app/api/public-api-route-handler.test.ts +++ b/apps/sim/app/api/public-api-route-handler.test.ts @@ -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, }, })) @@ -35,7 +43,13 @@ vi.mock('@sim/logger', () => ({ }), getRequestContext: () => requestContextState.current, runWithRequestContext: async ( - context: { requestId: string; method?: string; path?: string }, + context: { + requestId: string + method?: string + path?: string + apiKeyId?: string + apiKeyType?: 'personal' | 'workspace' + }, callback: () => T | Promise ): Promise => { requestContextState.current = context @@ -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 = [] + 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 }) diff --git a/apps/sim/app/api/public-api-route-handler.ts b/apps/sim/app/api/public-api-route-handler.ts index af25a4fe3b4..bbc5a0bc16f 100644 --- a/apps/sim/app/api/public-api-route-handler.ts +++ b/apps/sim/app/api/public-api-route-handler.ts @@ -1,3 +1,4 @@ +import { getRequestContext, runWithRequestContext } from '@sim/logger' import type { NextRequest, NextResponse } from 'next/server' import type { AnyApiRouteContract } from '@/lib/api/contracts' import { type ParsedRequest, type ParseRequestOptions, parseRequest } from '@/lib/api/server' @@ -55,20 +56,35 @@ export function withPublicApiRouteHandler({ 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'), diff --git a/apps/sim/app/api/v1/auth.ts b/apps/sim/app/api/v1/auth.ts index 0f391889005..c471f45496c 100644 --- a/apps/sim/app/api/v1/auth.ts +++ b/apps/sim/app/api/v1/auth.ts @@ -9,6 +9,7 @@ const logger = createLogger('V1Auth') export interface AuthResult { authenticated: boolean userId?: string + keyId?: string workspaceId?: string keyType?: 'personal' | 'workspace' error?: string @@ -19,6 +20,7 @@ export async function authenticateV1Request(request: NextRequest): Promise ({ - 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, @@ -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 { @@ -44,6 +63,7 @@ import { authenticateRequest, checkRateLimit, createRateLimitResponse, + resolveWorkspaceAccess, v1ValidationErrorResponse, } from '@/app/api/v1/middleware' @@ -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() @@ -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() diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 86e3012a4d6..2b3d3a4fca0 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -4,6 +4,11 @@ import { type NextRequest, NextResponse } from 'next/server' import type { ZodError } from 'zod' import { getValidationErrorMessage, isZodError, validationErrorResponse } from '@/lib/api/server' import { buildRateLimitHeaders, recordRateLimitSnapshot } from '@/lib/api/server/rate-limit-context' +import { + type BillingAttributionSnapshot, + resolveSystemBillingAttribution, + toUsageLimitSubscription, +} from '@/lib/billing/core/billing-attribution' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import type { SubscriptionPlan } from '@/lib/core/rate-limiter' import { getRateLimit, RateLimiter } from '@/lib/core/rate-limiter' @@ -83,8 +88,15 @@ export interface RateLimitResult { limit: number retryAfterMs?: number userId?: string + /** The API-key owner. Differs from `userId` for an admitted v2 workspace key. */ + principalUserId?: string + /** Frozen creator permission used to authorize an admitted v2 workspace key. */ + principalWorkspacePermission?: PermissionType + keyId?: string workspaceId?: string keyType?: 'personal' | 'workspace' + /** Frozen exact workspace payer decision for v2 workspace-key requests. */ + billingAttribution?: BillingAttributionSnapshot error?: string } @@ -94,6 +106,73 @@ export interface AuthorizedRequest { rateLimit: RateLimitResult } +export type V2ApiKeyIdentity = + | { + authenticated: true + keyType: 'personal' + keyId: string + principalUserId: string + actorUserId: string + } + | { + authenticated: true + keyType: 'workspace' + keyId: string + workspaceId: string + principalUserId: string + principalWorkspacePermission: PermissionType + actorUserId: string + billingAttribution: BillingAttributionSnapshot + } + +export type V2ApiKeyIdentityResult = V2ApiKeyIdentity | { authenticated: false; error?: string } + +/** + * Resolves the v2 principal/actor split without consulting a rate-limit bucket. + * Execution endpoints use this directly because their sync/async buckets are + * applied by execution preprocessing rather than the public endpoint bucket. + */ +export async function authenticateV2ApiKey(request: NextRequest): Promise { + const auth = await authenticateV1Request(request) + if (!auth.authenticated) return { authenticated: false, error: auth.error } + if (!auth.userId || !auth.keyId || !auth.keyType) { + throw new Error('Authenticated v2 API request is missing key identity') + } + + if (auth.keyType === 'personal') { + return { + authenticated: true, + keyType: 'personal', + keyId: auth.keyId, + principalUserId: auth.userId, + actorUserId: auth.userId, + } + } + if (!auth.workspaceId) { + throw new Error('Authenticated workspace API key is missing its workspace ID') + } + + const creatorPermission = await getUserEntityPermissions( + auth.userId, + 'workspace', + auth.workspaceId + ) + if (creatorPermission === null) { + return { authenticated: false, error: 'Invalid API key' } + } + const billingAttribution = await resolveSystemBillingAttribution(auth.workspaceId) + return { + authenticated: true, + keyType: 'workspace', + keyId: auth.keyId, + workspaceId: auth.workspaceId, + principalUserId: auth.userId, + principalWorkspacePermission: creatorPermission, + actorUserId: billingAttribution.actorUserId, + billingAttribution, + } +} + export function requireRateLimitUserId(rateLimit: RateLimitResult): string { if (!rateLimit.allowed) { throw new Error('Cannot authorize a denied public API request') @@ -108,6 +187,10 @@ export async function checkRateLimit( request: NextRequest, endpoint: ApiEndpoint = 'logs' ): Promise { + if (request.nextUrl.pathname.startsWith('/api/v2/')) { + return checkV2RateLimit(request, endpoint) + } + try { const auth = await authenticateV1Request(request) if (!auth.authenticated) { @@ -164,7 +247,7 @@ export async function checkRateLimit( limit: config.maxTokens, retryAfterMs: result.retryAfterMs, userId, - workspaceId: auth.workspaceId, + workspaceId: auth.keyType === 'workspace' ? auth.workspaceId : undefined, keyType: auth.keyType, } } catch (error) { @@ -179,6 +262,86 @@ export async function checkRateLimit( } } +/** + * Authenticates and rate-limits v2 without inheriting the key creator's payer. + * + * Personal keys keep their owner as both principal and actor. Workspace keys + * remain valid only while their creator belongs to the bound workspace, then + * use that workspace's billed account as the actor and its exact frozen payer + * subscription for the API rate-limit bucket. + */ +export async function checkV2RateLimit( + request: NextRequest, + endpoint: ApiEndpoint +): Promise { + const auth = await authenticateV2ApiKey(request) + if (!auth.authenticated) { + return { + allowed: false, + remaining: 0, + limit: 0, + resetAt: new Date(), + error: auth.error, + } + } + const principalUserId = auth.principalUserId + const actorUserId = auth.actorUserId + let subscription: { plan: string; referenceId: string } | null + let billingAttribution: BillingAttributionSnapshot | undefined + let principalWorkspacePermission: PermissionType | undefined + + if (auth.keyType === 'workspace') { + billingAttribution = auth.billingAttribution + subscription = toUsageLimitSubscription(billingAttribution) + principalWorkspacePermission = auth.principalWorkspacePermission + } else { + subscription = await getHighestPrioritySubscription(principalUserId, { + onError: 'throw', + }) + } + + const result = await rateLimiter.checkRateLimitWithSubscription( + actorUserId, + subscription, + 'api-endpoint', + false + ) + if (!result.allowed) { + logger.warn(`Rate limit exceeded for v2 actor ${actorUserId}`, { + endpoint, + principalUserId, + keyId: auth.keyId, + keyType: auth.keyType, + workspaceId: auth.keyType === 'workspace' ? auth.workspaceId : undefined, + remaining: result.remaining, + resetAt: result.resetAt, + }) + } + + const plan = (subscription?.plan || 'free') as SubscriptionPlan + const config = getRateLimit(plan, 'api-endpoint') + recordRateLimitSnapshot(request, { + limit: config.maxTokens, + remaining: result.remaining, + resetAt: result.resetAt, + }) + + return { + allowed: result.allowed, + remaining: result.remaining, + resetAt: result.resetAt, + limit: config.maxTokens, + retryAfterMs: result.retryAfterMs, + userId: actorUserId, + principalUserId, + principalWorkspacePermission, + keyId: auth.keyId, + workspaceId: auth.keyType === 'workspace' ? auth.workspaceId : undefined, + keyType: auth.keyType, + billingAttribution, + } +} + /** * Authenticates and rate-limits a public API request. * Returns NextResponse on failure, AuthorizedRequest on success. @@ -285,6 +448,15 @@ export async function resolveWorkspaceAccess( const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return scopeError + if (rateLimit.keyType === 'workspace' && rateLimit.principalUserId) { + if (!rateLimit.principalWorkspacePermission) { + throw new Error('Admitted v2 workspace API key is missing its principal permission') + } + return permissionSatisfies(rateLimit.principalWorkspacePermission, level) + ? null + : { status: 403, code: 'FORBIDDEN', message: 'Access denied' } + } + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (!permissionSatisfies(permission, level)) { return { status: 403, code: 'FORBIDDEN', message: 'Access denied' } 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 84734542d33..f34a45e2f15 100644 --- a/apps/sim/app/api/v2/billing/status/route.test.ts +++ b/apps/sim/app/api/v2/billing/status/route.test.ts @@ -168,4 +168,34 @@ describe('GET /api/v2/billing/status', () => { expect(response.status).toBe(403) expect(mockCheckUsageStatus).not.toHaveBeenCalled() }) + + it('reuses the admitted workspace key payer snapshot for billing status', async () => { + const billingAttribution = { + actorUserId: 'payer-1', + workspaceId: 'ws-1', + organizationId: 'org-1', + billedAccountUserId: 'payer-1', + billingEntity: { type: 'organization', id: 'org-1' }, + billingPeriod: { + start: '2026-07-01T00:00:00.000Z', + end: '2026-08-01T00:00:00.000Z', + }, + payerSubscription: null, + } + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + userId: 'payer-1', + principalUserId: 'creator-1', + keyType: 'workspace', + workspaceId: 'ws-1', + billingAttribution, + }) + + const response = await callStatus('?workspaceId=ws-1') + + expect(response.status).toBe(200) + expect(mockResolveBillingAttribution).not.toHaveBeenCalled() + expect(mockToUsageLimitSubscription).toHaveBeenCalledWith(billingAttribution) + expect(mockCheckAttributedBillingBlocks).toHaveBeenCalledWith(billingAttribution) + }) }) diff --git a/apps/sim/app/api/v2/billing/status/route.ts b/apps/sim/app/api/v2/billing/status/route.ts index 868e7dd0c06..0c08cbe6944 100644 --- a/apps/sim/app/api/v2/billing/status/route.ts +++ b/apps/sim/app/api/v2/billing/status/route.ts @@ -32,10 +32,16 @@ export const GET = withPublicApiRouteHandler({ let data: V2BillingStatusData if (workspaceFilter.workspaceId) { - const attribution = await resolveBillingAttribution({ - actorUserId: userId, - workspaceId: workspaceFilter.workspaceId, - }) + const attribution = + rateLimit.keyType === 'workspace' + ? rateLimit.billingAttribution + : await resolveBillingAttribution({ + actorUserId: userId, + workspaceId: workspaceFilter.workspaceId, + }) + if (!attribution || attribution.workspaceId !== workspaceFilter.workspaceId) { + throw new Error('Workspace API request is missing its billing attribution') + } const [usage, block] = await Promise.all([ checkUsageStatus(attribution.billedAccountUserId, toUsageLimitSubscription(attribution)), checkAttributedBillingBlocks(attribution), diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index adb94296696..702755a7592 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -156,6 +156,22 @@ describe('GET /api/v2/credentials', () => { ) }) + it('uses the key creator for credential visibility instead of the billing actor', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + userId: 'billing-actor', + principalUserId: 'key-creator', + }) + + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + + expect(res.status).toBe(200) + expect(mockCheckWorkspaceAccess).toHaveBeenCalledWith(WORKSPACE_ID, 'key-creator') + expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'key-creator' }) + ) + }) + it('accepts only OAuth and service-account type filters', async () => { await callList(`workspaceId=${WORKSPACE_ID}&type=oauth&providerId=slack`) expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index c61307198f1..7b0eff614fe 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -24,10 +24,11 @@ export const GET = withPublicApiRouteHandler({ * rows and shared-type admin access decide what this caller sees, so the * workspace permission is re-read here for the `canAdmin` bit. */ - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + const principalUserId = rateLimit.principalUserId ?? userId + const workspaceAccess = await checkWorkspaceAccess(workspaceId, principalUserId) const credentials = await listVisibleWorkspaceCredentials({ workspaceId, - userId, + userId: principalUserId, workspaceAccess, types: type ? [type] : ['oauth', 'service_account'], providerId, diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts index 87d477083ab..a8eeea4af17 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts @@ -22,16 +22,20 @@ export const POST = withPublicApiRouteHandler({ const session = await getOwnedUploadSession({ uploadId, workspaceId, - userId, + userId: rateLimit.principalUserId ?? userId, purpose: 'workspace_file', uploadToken: input.headers['upload-token'], }) + const actorUserId = session.metadata.actorUserId + if (typeof actorUserId !== 'string') { + throw new Error('Workspace file upload is missing its attribution actor') + } const result = await completeUploadSession({ session, finalize: async (claimed) => { const finalized = await finalizeWorkspaceFileUpload({ session: claimed, - actor: { id: userId }, + actor: { id: actorUserId }, request, source: 'api', }) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts index 926feab0e97..62c61b93d08 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts @@ -20,7 +20,7 @@ export const POST = withPublicApiRouteHandler({ const session = await getOwnedUploadSession({ uploadId, workspaceId, - userId, + userId: rateLimit.principalUserId ?? userId, purpose: 'workspace_file', uploadToken: input.headers['upload-token'], }) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts index 49d085ffe68..7ca52ba9212 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts @@ -21,7 +21,7 @@ export const DELETE = withPublicApiRouteHandler({ const session = await getOwnedUploadSession({ uploadId, workspaceId, - userId, + userId: rateLimit.principalUserId ?? userId, purpose: 'workspace_file', uploadToken: input.headers['upload-token'], }) diff --git a/apps/sim/app/api/v2/files/uploads/route.test.ts b/apps/sim/app/api/v2/files/uploads/route.test.ts index 884dfbe87ce..569a2933e47 100644 --- a/apps/sim/app/api/v2/files/uploads/route.test.ts +++ b/apps/sim/app/api/v2/files/uploads/route.test.ts @@ -138,7 +138,7 @@ describe('POST /api/v2/files/uploads', () => { fileName: 'file.csv', contentType: 'text/csv', fileSize: 10, - metadata: { folderId: null }, + metadata: { folderId: null, actorUserId: 'user-1' }, localOrigin: 'http://localhost:3000', }) }) @@ -206,7 +206,9 @@ describe('POST /api/v2/files/uploads', () => { expect.any(Object) ) expect(mockCreateUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ metadata: { folderId: 'folder-reports' } }) + expect.objectContaining({ + metadata: { folderId: 'folder-reports', actorUserId: 'user-1' }, + }) ) }) }) diff --git a/apps/sim/app/api/v2/files/uploads/route.ts b/apps/sim/app/api/v2/files/uploads/route.ts index 60ef13d4bd0..b8f20e83cfd 100644 --- a/apps/sim/app/api/v2/files/uploads/route.ts +++ b/apps/sim/app/api/v2/files/uploads/route.ts @@ -27,12 +27,12 @@ export const POST = withPublicApiRouteHandler({ if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') const session = await createUploadSession({ workspaceId, - userId, + userId: rateLimit.principalUserId ?? userId, purpose: 'workspace_file', fileName: name, contentType, fileSize: size, - metadata: { folderId: resolution.folderId }, + metadata: { folderId: resolution.folderId, actorUserId: userId }, localOrigin: request.nextUrl.origin, }) return v2Data( diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts index c189de04389..e506e45362f 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/route.ts @@ -7,7 +7,6 @@ import { import { checkAttributedUsageLimits, resolveBillingAttribution, - resolveSystemBillingAttribution, } from '@/lib/billing/core/billing-attribution' import { isPayloadSizeLimitError, @@ -151,8 +150,11 @@ export const POST = withPublicApiRouteHandler({ */ const billingAttribution = rateLimit.keyType === 'workspace' - ? await resolveSystemBillingAttribution(workspaceId) + ? rateLimit.billingAttribution : await resolveBillingAttribution({ actorUserId: userId, workspaceId }) + if (!billingAttribution || billingAttribution.workspaceId !== workspaceId) { + throw new Error('Workspace API request is missing its billing attribution') + } const usage = await checkAttributedUsageLimits(billingAttribution) if (usage.isExceeded) { return v2Error( diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts index 62a81397f64..886c00645fa 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.test.ts @@ -166,9 +166,7 @@ describe('POST knowledge-document multipart completion', () => { await resolveAttribution() expect(mockResolveKnowledgeDocumentUploadAttribution).toHaveBeenCalledWith({ - workspaceId: WORKSPACE_ID, - userId: 'user-1', - rateLimit: RATE_LIMIT, + session: SESSION, }) }) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts index 05b111c7cbf..65ee0c18120 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/complete/route.ts @@ -31,7 +31,7 @@ export const POST = withPublicApiRouteHandler({ knowledgeBaseId, uploadId, workspaceId, - userId, + userId: rateLimit.principalUserId ?? userId, uploadToken: input.headers['upload-token'], }) const result = await completeUploadSession({ @@ -44,7 +44,7 @@ export const POST = withPublicApiRouteHandler({ workspaceId, userId, resolveAttribution: () => - resolveKnowledgeDocumentUploadAttribution({ workspaceId, userId, rateLimit }), + resolveKnowledgeDocumentUploadAttribution({ session: claimed }), source: 'api', requestId, request, diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts index 8b7320f397a..153aeef3b07 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/parts/route.ts @@ -28,7 +28,7 @@ export const POST = withPublicApiRouteHandler({ knowledgeBaseId, uploadId, workspaceId, - userId, + userId: rateLimit.principalUserId ?? userId, uploadToken: input.headers['upload-token'], }) const parts = await createUploadPartUrls({ diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts index a7ba8567f2a..82d4121833e 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/[uploadId]/route.ts @@ -29,7 +29,7 @@ export const DELETE = withPublicApiRouteHandler({ knowledgeBaseId, uploadId, workspaceId, - userId, + userId: rateLimit.principalUserId ?? userId, uploadToken: input.headers['upload-token'], }) const aborted = await abortKnowledgeDocumentUpload(session, knowledgeBaseId) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts index 0d0b0462332..8e7ef1937f8 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts @@ -111,6 +111,7 @@ describe('POST /api/v2/knowledge/[id]/documents/uploads', () => { metadata: { tag1: 'product', processingOptions: { recipe: 'default', lang: 'en' }, + billingAttribution: { actorUserId: 'user-1' }, }, localOrigin: 'http://localhost:3000', }) diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts index 509a01f6de0..ad5a0481333 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts @@ -40,12 +40,12 @@ export const POST = withPublicApiRouteHandler({ const session = await createKnowledgeDocumentUploadSession({ workspaceId, - userId, + userId: rateLimit.principalUserId ?? userId, knowledgeBaseId, fileName: name, contentType, fileSize: size, - metadata, + metadata: { ...metadata, billingAttribution: billing }, localOrigin: request.nextUrl.origin, }) return v2Data( diff --git a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts index 4ef4bf2d8fa..33fa4f27ebb 100644 --- a/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts +++ b/apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.ts @@ -7,9 +7,9 @@ import type { import { v2KnowledgeDocumentUploadMetadataSchema } from '@/lib/api/contracts/v2/knowledge' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { + assertBillingAttributionSnapshot, checkAttributedUsageLimits, resolveBillingAttribution, - resolveSystemBillingAttribution, } from '@/lib/billing/core/billing-attribution' import { OrchestrationError } from '@/lib/core/orchestration/types' import { performUploadKnowledgeDocument } from '@/lib/knowledge/orchestration' @@ -52,16 +52,28 @@ export async function resolveKnowledgeDocumentUploadAccess(params: { * admission there would strand uploaded parts and fail idempotent completion retries. */ export async function resolveKnowledgeDocumentUploadAttribution(params: { - workspaceId: string - userId: string - rateLimit: RateLimitResult + workspaceId?: string + userId?: string + rateLimit?: RateLimitResult + session?: UploadSessionRecord }): Promise { - return params.rateLimit.keyType === 'workspace' - ? resolveSystemBillingAttribution(params.workspaceId) - : resolveBillingAttribution({ - actorUserId: params.userId, - workspaceId: params.workspaceId, - }) + if (params.session) { + return assertBillingAttributionSnapshot(params.session.metadata.billingAttribution) + } + if (!params.workspaceId || !params.userId || !params.rateLimit) { + throw new Error('Knowledge document upload attribution is missing request context') + } + if (params.rateLimit.keyType === 'workspace') { + const attribution = params.rateLimit.billingAttribution + if (!attribution || attribution.workspaceId !== params.workspaceId) { + throw new Error('Workspace API request is missing its billing attribution') + } + return attribution + } + return resolveBillingAttribution({ + actorUserId: params.userId, + workspaceId: params.workspaceId, + }) } /** Admission check for a new upload session. Enforced only at session creation. */ @@ -180,8 +192,9 @@ export function knowledgeDocumentFileUrl(session: UploadSessionRecord): string { } function knowledgeDocumentInputFor(session: UploadSessionRecord) { + const { billingAttribution: _billingAttribution, ...publicMetadata } = session.metadata const { processingOptions: _processingOptions, ...documentTags } = - v2KnowledgeDocumentUploadMetadataSchema.parse(session.metadata) + v2KnowledgeDocumentUploadMetadataSchema.parse(publicMetadata) return { filename: session.fileName, fileUrl: knowledgeDocumentFileUrl(session), @@ -236,7 +249,8 @@ export async function finalizeKnowledgeDocumentUpload(params: { actorEmail?: string | null }): Promise<{ value: CreatedKnowledgeDocument; completedFileId: string }> { const { claimed, knowledgeBaseId, workspaceId, requestId } = params - const { processingOptions } = v2KnowledgeDocumentUploadMetadataSchema.parse(claimed.metadata) + const { billingAttribution: _billingAttribution, ...publicMetadata } = claimed.metadata + const { processingOptions } = v2KnowledgeDocumentUploadMetadataSchema.parse(publicMetadata) const document = knowledgeDocumentInputFor(claimed) const bound = await findBoundKnowledgeDocument({ diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index dc6f3fdc829..8f6a641b384 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -6,7 +6,6 @@ import { isZodError } from '@/lib/api/server' import { checkAttributedUsageLimits, resolveBillingAttribution, - resolveSystemBillingAttribution, } from '@/lib/billing/core/billing-attribution' import { ALL_TAG_SLOTS } from '@/lib/knowledge/constants' import { recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' @@ -51,9 +50,16 @@ export const POST = withPublicApiRouteHandler({ const hasBillableQuery = Boolean(query?.trim()) const billingAttribution = hasBillableQuery ? rateLimit.keyType === 'workspace' - ? await resolveSystemBillingAttribution(workspaceId) + ? rateLimit.billingAttribution : await resolveBillingAttribution({ actorUserId: userId, workspaceId }) : undefined + if ( + rateLimit.keyType === 'workspace' && + hasBillableQuery && + (!billingAttribution || billingAttribution.workspaceId !== workspaceId) + ) { + throw new Error('Workspace API request is missing its billing attribution') + } const billingActorUserId = billingAttribution?.actorUserId ?? userId if (billingAttribution) { const usage = await checkAttributedUsageLimits(billingAttribution) diff --git a/apps/sim/app/api/v2/lib/gate.ts b/apps/sim/app/api/v2/lib/gate.ts index d9bf214eece..9fba00c3f1e 100644 --- a/apps/sim/app/api/v2/lib/gate.ts +++ b/apps/sim/app/api/v2/lib/gate.ts @@ -10,14 +10,15 @@ import { v2Error } from '@/app/api/v2/lib/response' * answers 404 as if it did not exist, so an ungated caller cannot distinguish * "not in the rollout cohort" from "no such endpoint". * - * Deliberately keyed on `userId` only. A workspace- or org-keyed gate would - * have to read membership for a caller-supplied id before authorization has - * run, and its 404-vs-403 split would then leak whether that workspace's org - * is in the cohort — the trap the per-domain table gate has to work around by - * running late. Keyed on the authenticated user, the check is safe to run - * first and is uniform across every v2 route. + * Personal keys are keyed on their authenticated user. Admitted workspace keys + * are keyed on the workspace's billing actor and exact organization, both of + * which came from the key's server-side workspace binding rather than caller + * input. */ -export async function v2ApiGateError(userId: string): Promise { - if (await isFeatureEnabled('v2-api', { userId })) return null +export async function v2ApiGateError( + userId: string, + organizationId?: string +): Promise { + if (await isFeatureEnabled('v2-api', { userId, orgId: organizationId })) return null return v2Error('NOT_FOUND', 'Not found') } diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index 475f73cb5c5..ad23f1dc307 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -16,6 +16,7 @@ import type { RateLimitResult, WorkspaceAccessError } from '@/app/api/v1/middlew export type V2ErrorCode = | 'BAD_REQUEST' | 'UNAUTHORIZED' + | 'PERSONAL_KEY_REQUIRED' | 'FORBIDDEN' | 'NOT_FOUND' | 'CONFLICT' @@ -31,6 +32,7 @@ export type V2ErrorCode = const STATUS_BY_CODE: Record = { BAD_REQUEST: 400, UNAUTHORIZED: 401, + PERSONAL_KEY_REQUIRED: 403, USAGE_LIMIT_EXCEEDED: 402, FORBIDDEN: 403, NOT_FOUND: 404, diff --git a/apps/sim/app/api/v2/secrets/[name]/route.test.ts b/apps/sim/app/api/v2/secrets/[name]/route.test.ts index 15db1c86543..54c9598cc97 100644 --- a/apps/sim/app/api/v2/secrets/[name]/route.test.ts +++ b/apps/sim/app/api/v2/secrets/[name]/route.test.ts @@ -149,7 +149,35 @@ describe('PUT /api/v2/secrets/[name]', () => { }) }) + it('authorizes as the key creator while attributing the write to the billing actor', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + userId: 'billing-actor', + principalUserId: 'key-creator', + }) + + const res = await callSet('workspace') + + expect(res.status).toBe(201) + expect(mockCheckWorkspaceAccess).toHaveBeenCalledWith(WORKSPACE_ID, 'key-creator') + expect(mockGetWorkspaceEnvKeyAdminAccess).toHaveBeenCalledWith({ + workspaceId: WORKSPACE_ID, + envKeys: ['STRIPE_API_KEY'], + userId: 'key-creator', + }) + expect(mockSetWorkspaceSecret).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'billing-actor' }) + ) + expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'key-creator' }) + ) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ actorId: 'billing-actor' }) + ) + }) + it('updates an existing workspace secret only for a secret admin', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT_OK, keyType: 'personal' }) mockGetWorkspaceEnvKeyAdminAccess.mockResolvedValue({ adminKeys: new Set(), knownKeys: new Set(['STRIPE_API_KEY']), @@ -170,6 +198,7 @@ describe('PUT /api/v2/secrets/[name]', () => { }) it('sets only the caller-owned personal secret catalog', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT_OK, keyType: 'personal' }) const res = await callSet('personal') expect(res.status).toBe(201) @@ -195,6 +224,14 @@ describe('PUT /api/v2/secrets/[name]', () => { expect(res.status).toBe(400) expect(mockSetWorkspaceSecret).not.toHaveBeenCalled() }) + + it('requires a personal key for personal secrets', async () => { + const res = await callSet('personal') + + expect(res.status).toBe(403) + expect((await res.json()).error.code).toBe('PERSONAL_KEY_REQUIRED') + expect(mockSetPersonalSecret).not.toHaveBeenCalled() + }) }) describe('DELETE /api/v2/secrets/[name]', () => { @@ -221,6 +258,7 @@ describe('DELETE /api/v2/secrets/[name]', () => { }) it('returns 404 when the scoped secret does not exist', async () => { + mockCheckRateLimit.mockResolvedValue({ ...RATE_LIMIT_OK, keyType: 'personal' }) mockDeletePersonalSecret.mockResolvedValue(false) const res = await callDelete('personal') diff --git a/apps/sim/app/api/v2/secrets/[name]/route.ts b/apps/sim/app/api/v2/secrets/[name]/route.ts index ca559713c46..e91370c537b 100644 --- a/apps/sim/app/api/v2/secrets/[name]/route.ts +++ b/apps/sim/app/api/v2/secrets/[name]/route.ts @@ -14,6 +14,7 @@ import { setPersonalSecret, setWorkspaceSecret, } from '@/lib/credentials/secret-values' +import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' @@ -56,7 +57,7 @@ async function getSecretMetadata(params: { name: string scope: V2SecretScope userId: string - workspaceAccess: Awaited> + workspaceAccess: Pick }): Promise { const { workspaceId, name, scope, userId, workspaceAccess } = params const rows = await listVisibleWorkspaceCredentials({ @@ -88,13 +89,18 @@ export const PUT = withPublicApiRouteHandler({ const { workspaceId, scope, value } = input.body const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) + const isWorkspaceKey = rateLimit.keyType === 'workspace' + if (isWorkspaceKey && scope === 'personal') { + return v2Error('PERSONAL_KEY_REQUIRED', 'Personal secrets require a personal API key') + } - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + const principalUserId = rateLimit.principalUserId ?? userId + const workspaceAccess = await checkWorkspaceAccess(workspaceId, principalUserId) if (scope === 'workspace') { const permissionError = await workspaceSecretAccessError({ workspaceId, name, - userId, + userId: principalUserId, canWrite: workspaceAccess.canWrite, canAdmin: workspaceAccess.canAdmin, }) @@ -105,7 +111,13 @@ export const PUT = withPublicApiRouteHandler({ scope === 'workspace' ? await setWorkspaceSecret({ workspaceId, name, value, userId }) : await setPersonalSecret({ userId, name, value }) - const secret = await getSecretMetadata({ workspaceId, name, scope, userId, workspaceAccess }) + const secret = await getSecretMetadata({ + workspaceId, + name, + scope, + userId: principalUserId, + workspaceAccess, + }) recordAudit({ workspaceId, @@ -132,13 +144,18 @@ export const DELETE = withPublicApiRouteHandler({ const { workspaceId, scope } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) + const isWorkspaceKey = rateLimit.keyType === 'workspace' + if (isWorkspaceKey && scope === 'personal') { + return v2Error('PERSONAL_KEY_REQUIRED', 'Personal secrets require a personal API key') + } - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + const principalUserId = rateLimit.principalUserId ?? userId + const workspaceAccess = await checkWorkspaceAccess(workspaceId, principalUserId) if (scope === 'workspace') { const permissionError = await workspaceSecretAccessError({ workspaceId, name, - userId, + userId: principalUserId, canWrite: workspaceAccess.canWrite, canAdmin: workspaceAccess.canAdmin, }) diff --git a/apps/sim/app/api/v2/secrets/route.test.ts b/apps/sim/app/api/v2/secrets/route.test.ts index 08b096ec84e..225fe49dff3 100644 --- a/apps/sim/app/api/v2/secrets/route.test.ts +++ b/apps/sim/app/api/v2/secrets/route.test.ts @@ -96,7 +96,7 @@ describe('GET /api/v2/secrets', () => { }) expect(JSON.stringify(body)).not.toContain('value') expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( - expect.objectContaining({ types: ['env_workspace', 'env_personal'] }) + expect.objectContaining({ types: ['env_workspace'] }) ) }) @@ -116,6 +116,30 @@ describe('GET /api/v2/secrets', () => { expect((await res.json()).data).toEqual([]) }) + it('uses the key creator for workspace-secret visibility, never the billing actor', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + userId: 'billing-actor', + principalUserId: 'key-creator', + }) + + const res = await callList(`workspaceId=${WORKSPACE_ID}`) + + expect(res.status).toBe(200) + expect(mockCheckWorkspaceAccess).toHaveBeenCalledWith(WORKSPACE_ID, 'key-creator') + expect(mockListVisibleWorkspaceCredentials).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'key-creator' }) + ) + }) + + it('requires a personal key when personal scope is explicit', async () => { + const res = await callList(`workspaceId=${WORKSPACE_ID}&scope=personal`) + + expect(res.status).toBe(403) + expect((await res.json()).error.code).toBe('PERSONAL_KEY_REQUIRED') + expect(mockListVisibleWorkspaceCredentials).not.toHaveBeenCalled() + }) + it('maps scope and sort filters to the credential catalog', async () => { await callList( `workspaceId=${WORKSPACE_ID}&scope=workspace&search=STRIPE&sortBy=name&sortOrder=desc` diff --git a/apps/sim/app/api/v2/secrets/route.ts b/apps/sim/app/api/v2/secrets/route.ts index a2ca345b892..8b31019e92b 100644 --- a/apps/sim/app/api/v2/secrets/route.ts +++ b/apps/sim/app/api/v2/secrets/route.ts @@ -3,7 +3,7 @@ import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2CursorList, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' +import { v2CursorList, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' import { secretCredentialTypes, toV2Secret } from '@/app/api/v2/secrets/utils' export const dynamic = 'force-dynamic' @@ -17,20 +17,29 @@ export const GET = withPublicApiRouteHandler({ const { workspaceId, scope, search, sortBy, sortOrder } = input.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) + const isWorkspaceKey = rateLimit.keyType === 'workspace' + if (isWorkspaceKey && scope === 'personal') { + return v2Error('PERSONAL_KEY_REQUIRED', 'Personal secrets require a personal API key') + } - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) + const principalUserId = rateLimit.principalUserId ?? userId + const workspaceAccess = await checkWorkspaceAccess(workspaceId, principalUserId) const credentials = await listVisibleWorkspaceCredentials({ workspaceId, - userId, + userId: principalUserId, workspaceAccess, - types: [...secretCredentialTypes(scope)], + types: [...secretCredentialTypes(isWorkspaceKey ? 'workspace' : scope)], search, sortBy: sortBy === 'name' ? 'displayName' : sortBy, sortOrder, }) const secrets = credentials - .filter((row) => row.type === 'env_workspace' || row.envOwnerUserId === userId) - .map((row) => toV2Secret(row, userId)) + .filter( + (row) => + row.type === 'env_workspace' || + (!isWorkspaceKey && row.envOwnerUserId === principalUserId) + ) + .map((row) => toV2Secret(row, principalUserId)) return v2CursorList(secrets, null, { rateLimit }) }, diff --git a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts index 1ef1f1f6e09..49c97ed3cc5 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/cancel-runs/route.ts @@ -40,7 +40,7 @@ export const POST = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const access = await checkAccess(tableId, userId, 'write') + const access = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!access.ok) return v2TableAccessError(access) if (access.table.workspaceId !== workspaceId) { diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts index 1c6d1092e17..a58649b0f23 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/route.ts @@ -34,7 +34,7 @@ export const POST = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!result.ok) return v2TableAccessError(result) const { table } = result @@ -80,7 +80,7 @@ export const PATCH = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!result.ok) return v2TableAccessError(result) const { table } = result @@ -120,7 +120,7 @@ export const DELETE = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!result.ok) return v2TableAccessError(result) const { table } = result diff --git a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts index 7f2345f73be..9c58ddf0e9b 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/columns/run/route.ts @@ -42,7 +42,7 @@ export const POST = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const access = await checkAccess(tableId, userId, 'write') + const access = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!access.ok) return v2TableAccessError(access) if (access.table.workspaceId !== workspaceId) { diff --git a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts index 1582877d704..f742b725c01 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/exports/route.ts @@ -22,7 +22,11 @@ export const POST = withPublicApiRouteHandler({ const { workspaceId, format } = input.body const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const access = await checkAccess(input.params.tableId, userId, 'read') + const access = await checkAccess( + input.params.tableId, + rateLimit.principalUserId ?? userId, + 'read' + ) if (!access.ok || access.table.workspaceId !== workspaceId) { return v2Error('NOT_FOUND', 'Table not found') } diff --git a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts index 66d06b07f0c..25f51689709 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/groups/route.ts @@ -41,7 +41,7 @@ export const GET = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'read') + const result = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'read') // Mask not-authorized and not-found alike so cross-workspace existence never leaks. if (!result.ok || result.table.workspaceId !== workspaceId) { return v2Error('NOT_FOUND', 'Table not found') @@ -127,7 +127,7 @@ export const POST = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!result.ok || result.table.workspaceId !== validated.workspaceId) { return v2Error('NOT_FOUND', 'Table not found') } @@ -199,7 +199,7 @@ export const PATCH = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!result.ok || result.table.workspaceId !== validated.workspaceId) { return v2Error('NOT_FOUND', 'Table not found') } @@ -269,7 +269,7 @@ export const DELETE = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!result.ok || result.table.workspaceId !== validated.workspaceId) { return v2Error('NOT_FOUND', 'Table not found') } diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts index a38f876dfbb..f003b6de155 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts @@ -42,7 +42,7 @@ export const POST = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const accessResult = await checkAccess(tableId, userId, 'read') + const accessResult = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'read') // Mask not-authorized and not-found alike so cross-workspace existence never leaks. if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts index 6b446248750..edc230121b7 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.test.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.test.ts @@ -167,6 +167,23 @@ describe('DELETE /api/v2/tables/[tableId]', () => { expect(mockRecordAudit).not.toHaveBeenCalled() }) + it('authorizes as the key creator while attributing the delete to the billing actor', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + userId: 'billing-actor', + principalUserId: 'key-creator', + }) + mockPerformDeleteTable.mockResolvedValue({ success: true }) + + const res = await callDelete() + + expect(res.status).toBe(200) + expect(mockCheckAccess).toHaveBeenCalledWith('table-1', 'key-creator', 'write') + expect(mockPerformDeleteTable).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'billing-actor' }) + ) + }) + it('returns 423 LOCKED for a delete-locked table instead of a 500', async () => { mockPerformDeleteTable.mockResolvedValue({ success: false, diff --git a/apps/sim/app/api/v2/tables/[tableId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/route.ts index 2f41f7ff910..6ca4ffd972d 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/route.ts @@ -55,7 +55,7 @@ export const GET = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'read') + const result = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'read') // Mask not-authorized and not-found alike so cross-workspace existence never leaks. if (!result.ok) return v2Error('NOT_FOUND', 'Table not found') @@ -103,7 +103,7 @@ export const PATCH = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!result.ok) return v2TableAccessError(result) const { table } = result @@ -220,7 +220,7 @@ export const DELETE = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!result.ok) return v2TableAccessError(result) if (result.table.workspaceId !== workspaceId) { diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts index 121c88c7e42..59464422650 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]/route.ts @@ -33,7 +33,7 @@ export const POST = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const access = await checkAccess(tableId, userId, 'write') + const access = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!access.ok) return v2TableAccessError(access) if (access.table.workspaceId !== workspaceId) { diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts index 8834f457a9e..0dcd5158a69 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/[rowId]/route.ts @@ -42,7 +42,7 @@ export const GET = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'read') + const result = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'read') // Mask not-authorized and not-found alike so cross-workspace existence never leaks. if (!result.ok) return v2Error('NOT_FOUND', 'Table not found') @@ -99,7 +99,7 @@ export const PATCH = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!result.ok) return v2TableAccessError(result) const { table } = result @@ -148,7 +148,7 @@ export const DELETE = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!result.ok) return v2TableAccessError(result) if (result.table.workspaceId !== workspaceId) { diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts index 18ebe72ea1f..48e7cc2b8cb 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/find/route.ts @@ -39,7 +39,7 @@ export const POST = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const accessResult = await checkAccess(tableId, userId, 'read') + const accessResult = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'read') // Mask not-authorized and not-found alike so cross-workspace existence never leaks. if (!accessResult.ok || accessResult.table.workspaceId !== workspaceId) { return v2Error('NOT_FOUND', 'Table not found') diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts index 0be13bffdb4..a4c0df96009 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/route.ts @@ -58,7 +58,7 @@ async function handleBatchInsert( userId: string, rateLimit: RateLimitResult ): Promise { - const accessResult = await checkAccess(tableId, userId, 'write') + const accessResult = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!accessResult.ok) return v2TableAccessError(accessResult) const { table } = accessResult @@ -115,7 +115,7 @@ export const GET = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const accessResult = await checkAccess(tableId, userId, 'read') + const accessResult = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'read') // Mask not-authorized and not-found alike so cross-workspace existence never leaks. if (!accessResult.ok) return v2Error('NOT_FOUND', 'Table not found') @@ -181,7 +181,7 @@ export const POST = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const accessResult = await checkAccess(tableId, userId, 'write') + const accessResult = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!accessResult.ok) return v2TableAccessError(accessResult) const { table } = accessResult @@ -230,7 +230,7 @@ export const PUT = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const accessResult = await checkAccess(tableId, userId, 'write') + const accessResult = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!accessResult.ok) return v2TableAccessError(accessResult) const { table } = accessResult @@ -287,7 +287,7 @@ export const DELETE = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const accessResult = await checkAccess(tableId, userId, 'write') + const accessResult = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!accessResult.ok) return v2TableAccessError(accessResult) const { table } = accessResult diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts index 346b8a74b15..ae87f05afb3 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/upsert/route.ts @@ -30,7 +30,7 @@ export const POST = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, validated.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!result.ok) return v2TableAccessError(result) const { table } = result diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts index 0df7c7243c9..96a4e342aa6 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/[viewId]/route.ts @@ -31,7 +31,7 @@ export const GET = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'read') + const result = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'read') // Mask not-authorized and not-found alike so cross-workspace existence never leaks. if (!result.ok || result.table.workspaceId !== workspaceId) { return v2Error('NOT_FOUND', 'Table not found') @@ -62,7 +62,7 @@ export const PATCH = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!result.ok) return v2TableAccessError(result) if (result.table.workspaceId !== workspaceId) { @@ -105,7 +105,7 @@ export const DELETE = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!result.ok) return v2TableAccessError(result) if (result.table.workspaceId !== workspaceId) { diff --git a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts index 841a5c8947e..d6e16757a74 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/views/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/views/route.ts @@ -31,7 +31,7 @@ export const GET = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'read') + const result = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'read') // Mask not-authorized and not-found alike so cross-workspace existence never leaks. if (!result.ok || result.table.workspaceId !== workspaceId) { return v2Error('NOT_FOUND', 'Table not found') @@ -67,7 +67,7 @@ export const POST = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const result = await checkAccess(tableId, userId, 'write') + const result = await checkAccess(tableId, rateLimit.principalUserId ?? userId, 'write') if (!result.ok) return v2TableAccessError(result) if (result.table.workspaceId !== workspaceId) { diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts index d6902239d8f..867e4840800 100644 --- a/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/download/route.ts @@ -22,7 +22,7 @@ export const GET = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) const record = await requireTableExport(input.params.exportId, workspaceId) - const access = await checkAccess(record.tableId, userId, 'read') + const access = await checkAccess(record.tableId, rateLimit.principalUserId ?? userId, 'read') if (!access.ok || access.table.workspaceId !== workspaceId) { return v2Error('NOT_FOUND', 'Table export not found') } diff --git a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts index 721652921f4..8a66f5007fe 100644 --- a/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts +++ b/apps/sim/app/api/v2/tables/exports/[exportId]/route.ts @@ -32,7 +32,11 @@ export const GET = withPublicApiRouteHandler({ const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const record = await authorizeExport(input.params.exportId, workspaceId, userId) + const record = await authorizeExport( + input.params.exportId, + workspaceId, + rateLimit.principalUserId ?? userId + ) if (!record) return v2Error('NOT_FOUND', 'Table export not found') return v2Data(toV2TableExport(record), { rateLimit }) } catch (error) { @@ -51,7 +55,11 @@ export const DELETE = withPublicApiRouteHandler({ const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) - const record = await authorizeExport(input.params.exportId, workspaceId, userId) + const record = await authorizeExport( + input.params.exportId, + workspaceId, + rateLimit.principalUserId ?? userId + ) if (!record) return v2Error('NOT_FOUND', 'Table export not found') return v2Data(toV2TableExport(await cancelTableExportResource(record)), { rateLimit }) } catch (error) { diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts index 626b4cc2f8b..b7a5beaf521 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts @@ -23,10 +23,11 @@ export const POST = withPublicApiRouteHandler({ const { workspaceId } = input.query const scopeError = await resolveWorkspaceScope(rateLimit, workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) + const ownerUserId = rateLimit.principalUserId ?? userId const upload = await getOwnedTableImportUpload({ importId: input.params.importId, workspaceId, - userId, + userId: ownerUserId, uploadToken: input.headers['upload-token'], }) const existing = await findOwnedTableImport({ diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts index 779c90acb5e..d107d038ce4 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/parts/route.ts @@ -20,7 +20,7 @@ export const POST = withPublicApiRouteHandler({ const session = await getOwnedTableImportUpload({ importId: input.params.importId, workspaceId, - userId, + userId: rateLimit.principalUserId ?? userId, uploadToken: input.headers['upload-token'], }) const parts = await createUploadPartUrls({ diff --git a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts index 7b5587a3ff0..4ccb3ace8cf 100644 --- a/apps/sim/app/api/v2/tables/imports/[importId]/route.ts +++ b/apps/sim/app/api/v2/tables/imports/[importId]/route.ts @@ -26,7 +26,7 @@ export const GET = withPublicApiRouteHandler({ const record = await getOwnedTableImport({ importId: input.params.importId, workspaceId: input.query.workspaceId, - userId, + userId: rateLimit.principalUserId ?? userId, }) return v2Data(await toV2TableImport(record), { rateLimit }) } catch (error) { @@ -45,18 +45,19 @@ export const DELETE = withPublicApiRouteHandler({ const scopeError = await resolveWorkspaceScope(rateLimit, input.query.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) const uploadToken = input.headers['upload-token'] + const ownerUserId = rateLimit.principalUserId ?? userId const record = uploadToken ? await abortTableImportUpload({ importId: input.params.importId, workspaceId: input.query.workspaceId, - userId, + userId: ownerUserId, uploadToken, }) : await cancelTableImportResource( await getOwnedTableImport({ importId: input.params.importId, workspaceId: input.query.workspaceId, - userId, + userId: ownerUserId, }) ) return v2Data(toV2TableImport(record), { rateLimit }) diff --git a/apps/sim/app/api/v2/tables/imports/route.test.ts b/apps/sim/app/api/v2/tables/imports/route.test.ts index f5c6a5f7541..08bcee839ea 100644 --- a/apps/sim/app/api/v2/tables/imports/route.test.ts +++ b/apps/sim/app/api/v2/tables/imports/route.test.ts @@ -106,7 +106,12 @@ describe('POST /api/v2/tables/imports', () => { requestBody, 'user-1', 'http://localhost:3000', - null + null, + { + actorUserId: 'user-1', + ownerUserId: 'user-1', + useOwnerTimezone: false, + } ) expect(mockToV2CreateTableImport).toHaveBeenCalledWith(created) expect(await response.json()).toEqual({ data: responseData }) @@ -141,7 +146,49 @@ describe('POST /api/v2/tables/imports', () => { expect(mockCreateTableImportResource).toHaveBeenCalledWith( requestBody, 'user-1', - 'http://localhost:3000' + 'http://localhost:3000', + undefined, + { + actorUserId: 'user-1', + ownerUserId: 'user-1', + useOwnerTimezone: false, + } + ) + }) + + it('keeps the key creator as upload owner while attributing the import to the payer', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT, + userId: 'payer-1', + principalUserId: 'creator-1', + }) + const requestBody = { + workspaceId: WORKSPACE_ID, + source: { type: 'upload', name: 'data.csv', contentType: 'text/csv', size: 128 }, + target: { type: 'existing', tableId: 'table-1', mode: 'append' }, + } + mockCreateTableImportResource.mockResolvedValue({ record: { id: 'import-1' }, upload: null }) + mockToV2CreateTableImport.mockReturnValue({}) + + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/tables/imports', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }) + ) + + expect(response.status).toBe(201) + expect(mockCreateTableImportResource).toHaveBeenCalledWith( + requestBody, + 'creator-1', + 'http://localhost:3000', + undefined, + { + actorUserId: 'payer-1', + ownerUserId: 'creator-1', + useOwnerTimezone: false, + } ) }) }) diff --git a/apps/sim/app/api/v2/tables/imports/route.ts b/apps/sim/app/api/v2/tables/imports/route.ts index 8a97f9f36ad..7bd9efb8f15 100644 --- a/apps/sim/app/api/v2/tables/imports/route.ts +++ b/apps/sim/app/api/v2/tables/imports/route.ts @@ -21,6 +21,11 @@ export const POST = withPublicApiRouteHandler({ try { const scopeError = await resolveWorkspaceScope(rateLimit, input.body.workspaceId) if (scopeError) return v2WorkspaceAccessError(scopeError) + const importAttribution = { + ownerUserId: rateLimit.principalUserId ?? userId, + actorUserId: userId, + useOwnerTimezone: rateLimit.keyType !== 'workspace', + } let created: Awaited> if (input.body.target.type === 'new') { const resolution = await resolveFolderPathIdentity({ @@ -31,12 +36,19 @@ export const POST = withPublicApiRouteHandler({ if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') created = await createTableImportResource( input.body, - userId, + importAttribution.ownerUserId, request.nextUrl.origin, - resolution.folderId + resolution.folderId, + importAttribution ) } else { - created = await createTableImportResource(input.body, userId, request.nextUrl.origin) + created = await createTableImportResource( + input.body, + importAttribution.ownerUserId, + request.nextUrl.origin, + undefined, + importAttribution + ) } return v2Data(toV2CreateTableImport(created), { rateLimit, status: 201 }) } catch (error) { diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts index ac9cdc87de5..d08c4ae52bd 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.test.ts @@ -18,7 +18,7 @@ import { import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockAuthenticateV1Request, + mockAuthenticateV2ApiKey, mockClaimExecutionId, mockEnqueue, mockExecuteWorkflowCore, @@ -29,7 +29,7 @@ const { mockReleaseExecutionSlot, mockValidatePublicApiAllowed, } = vi.hoisted(() => ({ - mockAuthenticateV1Request: vi.fn(), + mockAuthenticateV2ApiKey: vi.fn(), mockClaimExecutionId: vi.fn(), mockEnqueue: vi.fn().mockResolvedValue('workflow-execution:execution-123'), mockExecuteWorkflowCore: vi.fn(), @@ -41,8 +41,8 @@ const { mockValidatePublicApiAllowed: vi.fn(), })) -vi.mock('@/app/api/v1/auth', () => ({ - authenticateV1Request: mockAuthenticateV1Request, +vi.mock('@/app/api/v1/middleware', () => ({ + authenticateV2ApiKey: mockAuthenticateV2ApiKey, })) vi.mock('@/lib/billing/calculations/usage-reservation', () => ({ @@ -178,12 +178,14 @@ describe('POST /api/v2/workflows/[id]/execute', () => { resetDbChainMock() setEnv({ NEXT_PUBLIC_APP_URL: 'http://localhost:3000' }) mockGenerateId.mockReturnValue('execution-123') - mockAuthenticateV1Request.mockResolvedValue({ + mockAuthenticateV2ApiKey.mockResolvedValue({ authenticated: true, - userId: 'key-user-1', - keyType: 'workspace', - workspaceId: 'workspace-1', + actorUserId: 'key-user-1', + principalUserId: 'key-user-1', + keyId: 'key-personal', + keyType: 'personal', }) + mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: true }) mockAuthorize.mockResolvedValue({ allowed: true, workflow: workflowRecord }) mockClaimExecutionId.mockImplementation(async (executionId: string) => ({ key: `workflow-execution-id:${executionId}`, @@ -300,11 +302,14 @@ describe('POST /api/v2/workflows/[id]/execute', () => { }) it('masks a workspace-key/workflow mismatch as 404', async () => { - mockAuthenticateV1Request.mockResolvedValue({ + mockAuthenticateV2ApiKey.mockResolvedValue({ authenticated: true, - userId: 'key-user-1', + actorUserId: 'actor-1', + principalUserId: 'key-user-1', + keyId: 'key-workspace', keyType: 'workspace', workspaceId: 'other-workspace', + billingAttribution: { ...billingAttribution, workspaceId: 'other-workspace' }, }) const res = await callExecute({ input: {} }) @@ -313,10 +318,40 @@ describe('POST /api/v2/workflows/[id]/execute', () => { expect((await res.json()).error.code).toBe('NOT_FOUND') }) - it('rejects personal keys when the workspace disallows them', async () => { - mockAuthenticateV1Request.mockResolvedValue({ + it('executes a bound workspace workflow as its frozen billing actor and payer', async () => { + mockAuthenticateV2ApiKey.mockResolvedValue({ authenticated: true, + actorUserId: 'actor-1', + principalUserId: 'key-user-1', + keyId: 'key-workspace', + keyType: 'workspace', + workspaceId: 'workspace-1', + billingAttribution, + }) + const res = await callExecute({ input: { hello: 'workspace' } }) + + expect(res.status).toBe(200) + expect(mockAuthorize).toHaveBeenCalledWith({ + workflowId: 'workflow-1', userId: 'key-user-1', + action: 'read', + }) + expect(mockPreprocessExecution).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'actor-1', + useAuthenticatedUserAsActor: false, + workflowRecord, + billingAttribution, + }) + ) + }) + + it('rejects personal keys when the workspace disallows them', async () => { + mockAuthenticateV2ApiKey.mockResolvedValue({ + authenticated: true, + actorUserId: 'key-user-1', + principalUserId: 'key-user-1', + keyId: 'key-personal', keyType: 'personal', }) mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: false }) @@ -371,7 +406,7 @@ describe('POST /api/v2/workflows/[id]/execute', () => { }) it('runs the anonymous public path sync but refuses async', async () => { - mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) + mockAuthenticateV2ApiKey.mockResolvedValue({ authenticated: false, error: 'API key required' }) dbChainMockFns.limit.mockResolvedValueOnce([ { isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, ]) @@ -379,7 +414,7 @@ describe('POST /api/v2/workflows/[id]/execute', () => { const okRes = await callExecute({ input: {} }) expect(okRes.status).toBe(200) - mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) + mockAuthenticateV2ApiKey.mockResolvedValue({ authenticated: false, error: 'API key required' }) dbChainMockFns.limit.mockResolvedValueOnce([ { isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, ]) @@ -388,7 +423,7 @@ describe('POST /api/v2/workflows/[id]/execute', () => { }) it('401s non-public workflows without a key', async () => { - mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) + mockAuthenticateV2ApiKey.mockResolvedValue({ authenticated: false, error: 'API key required' }) dbChainMockFns.limit.mockResolvedValueOnce([ { isPublicApi: false, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' }, ]) diff --git a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts index 4947f04ba32..0d329925235 100644 --- a/apps/sim/app/api/v2/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[id]/execute/route.ts @@ -10,6 +10,7 @@ import { v2ExecuteWorkflowContract, } from '@/lib/api/contracts/v2/workflows' import { parseRequest } from '@/lib/api/server' +import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { tryAdmit } from '@/lib/core/admission/gate' import { ADMISSION_ERROR_DESCRIPTOR } from '@/lib/core/admission/transient-failure' import { generateRequestId } from '@/lib/core/utils/request' @@ -26,7 +27,7 @@ import { hasAgentStreamPolicy, } from '@/lib/workflows/streaming/agent-stream-protocol' import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' -import { authenticateV1Request } from '@/app/api/v1/auth' +import { authenticateV2ApiKey } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { type V2ErrorCode, v2Data, v2Error, v2ValidationError } from '@/app/api/v2/lib/response' import { @@ -100,15 +101,21 @@ export const POST = withRouteHandler( const { id: workflowId } = await context.params let userId: string + let principalUserId: string let isPublicApiAccess = false let apiKeyType: 'personal' | 'workspace' | undefined let apiKeyWorkspaceId: string | undefined + let billingAttribution: BillingAttributionSnapshot | undefined - const auth = await authenticateV1Request(req) - if (auth.authenticated && auth.userId) { - userId = auth.userId + const auth = await authenticateV2ApiKey(req) + if (auth.authenticated) { + userId = auth.actorUserId + principalUserId = auth.principalUserId apiKeyType = auth.keyType - apiKeyWorkspaceId = auth.workspaceId + if (auth.keyType === 'workspace') { + apiKeyWorkspaceId = auth.workspaceId + billingAttribution = auth.billingAttribution + } } else { if (req.headers.has('x-api-key')) { return v2Error('UNAUTHORIZED', auth.error || 'Unauthorized') @@ -136,10 +143,14 @@ export const POST = withRouteHandler( throw err } userId = wf.userId + principalUserId = wf.userId isPublicApiAccess = true } - const gate = await v2ApiGateError(userId) + const organizationId = billingAttribution?.organizationId ?? undefined + const gate = organizationId + ? await v2ApiGateError(userId, organizationId) + : await v2ApiGateError(userId) if (gate) return gate const ticket = tryAdmit() @@ -206,7 +217,7 @@ export const POST = withRouteHandler( const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({ workflowId, - userId, + userId: principalUserId, action: 'read', }) // Mask authorization failures as 404 so cross-workspace existence never leaks. @@ -214,7 +225,6 @@ export const POST = withRouteHandler( return v2Error('NOT_FOUND', 'Workflow not found') } const workflowRecord = workflowAuthorization.workflow - if (apiKeyType === 'workspace' && workflowRecord.workspaceId !== apiKeyWorkspaceId) { return v2Error('NOT_FOUND', 'Workflow not found') } @@ -234,6 +244,7 @@ export const POST = withRouteHandler( executionId: requestedExecutionId, useAuthenticatedUserAsActor: apiKeyType === 'personal', workflowRecord, + upstreamBillingAttribution: billingAttribution, includeFileBase64: body.includeFileBase64, base64MaxBytes: body.base64MaxBytes, selectedOutputs: body.selectedOutputs, diff --git a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts index 20a8e07fc1a..b4df1538758 100644 --- a/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[id]/runs/[runId]/route.test.ts @@ -1,19 +1,22 @@ /** * @vitest-environment node */ -import { createMockRequest, workflowAuthzMockFns } from '@sim/testing' +import { + createMockRequest, + dbChainMockFns, + resetDbChainMock, + workflowAuthzMockFns, +} from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAuthenticateV1Request, mockGetWorkflowExecutionStatus, mockCancel } = vi.hoisted( - () => ({ - mockAuthenticateV1Request: vi.fn(), - mockGetWorkflowExecutionStatus: vi.fn(), - mockCancel: vi.fn(), - }) -) +const { mockAuthenticateV2ApiKey, mockGetWorkflowExecutionStatus, mockCancel } = vi.hoisted(() => ({ + mockAuthenticateV2ApiKey: vi.fn(), + mockGetWorkflowExecutionStatus: vi.fn(), + mockCancel: vi.fn(), +})) -vi.mock('@/app/api/v1/auth', () => ({ - authenticateV1Request: mockAuthenticateV1Request, +vi.mock('@/app/api/v1/middleware', () => ({ + authenticateV2ApiKey: mockAuthenticateV2ApiKey, })) vi.mock('@/lib/workspaces/utils', () => ({ @@ -56,11 +59,13 @@ function callStatus(query = '') { describe('v2 runs status + cancel', () => { beforeEach(() => { vi.clearAllMocks() - mockAuthenticateV1Request.mockResolvedValue({ + resetDbChainMock() + mockAuthenticateV2ApiKey.mockResolvedValue({ authenticated: true, - userId: 'key-user-1', - keyType: 'workspace', - workspaceId: 'workspace-1', + actorUserId: 'key-user-1', + principalUserId: 'key-user-1', + keyId: 'key-1', + keyType: 'personal', }) mockAuthorize.mockResolvedValue({ allowed: true, workflow: workflowRecord }) }) @@ -159,16 +164,25 @@ describe('v2 runs status + cancel', () => { }) it('masks cross-workspace access as 404', async () => { - mockAuthenticateV1Request.mockResolvedValue({ + mockAuthenticateV2ApiKey.mockResolvedValue({ authenticated: true, - userId: 'key-user-1', + actorUserId: 'payer-1', + principalUserId: 'key-user-1', + keyId: 'key-1', keyType: 'workspace', workspaceId: 'other-workspace', + billingAttribution: { organizationId: null }, }) + dbChainMockFns.limit.mockResolvedValue([workflowRecord]) const res = await callStatus() expect(res.status).toBe(404) + expect(mockAuthorize).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + userId: 'key-user-1', + action: 'read', + }) expect(mockGetWorkflowExecutionStatus).not.toHaveBeenCalled() }) @@ -200,7 +214,7 @@ describe('v2 runs status + cancel', () => { }) it('401s without an API key (no session/anonymous path on runs)', async () => { - mockAuthenticateV1Request.mockResolvedValue({ authenticated: false, error: 'API key required' }) + mockAuthenticateV2ApiKey.mockResolvedValue({ authenticated: false, error: 'API key required' }) const res = await callStatus() diff --git a/apps/sim/app/api/v2/workflows/import/route.ts b/apps/sim/app/api/v2/workflows/import/route.ts index 8ddd288d6a3..d040522aa31 100644 --- a/apps/sim/app/api/v2/workflows/import/route.ts +++ b/apps/sim/app/api/v2/workflows/import/route.ts @@ -65,7 +65,8 @@ export const POST = withPublicApiRouteHandler({ name, description, workflow: input.body.workflow, - userId, + userId: rateLimit.principalUserId ?? userId, + actorUserId: userId, requestId, }) diff --git a/apps/sim/app/api/v2/workflows/lib/access.ts b/apps/sim/app/api/v2/workflows/lib/access.ts index 404b820ac89..eb225b50663 100644 --- a/apps/sim/app/api/v2/workflows/lib/access.ts +++ b/apps/sim/app/api/v2/workflows/lib/access.ts @@ -1,12 +1,12 @@ -import type { workflow as workflowTable } from '@sim/db/schema' +import type { workflow } from '@sim/db/schema' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import type { NextRequest, NextResponse } from 'next/server' import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' -import { authenticateV1Request } from '@/app/api/v1/auth' +import { authenticateV2ApiKey } from '@/app/api/v1/middleware' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2Error } from '@/app/api/v2/lib/response' -type WorkflowRecord = typeof workflowTable.$inferSelect +type WorkflowRecord = typeof workflow.$inferSelect export type V2WorkflowAccess = | { @@ -28,29 +28,31 @@ export async function resolveV2WorkflowAccess( workflowId: string, action: 'read' | 'write' ): Promise { - const auth = await authenticateV1Request(request) - if (!auth.authenticated || !auth.userId) { + const auth = await authenticateV2ApiKey(request) + if (!auth.authenticated) { return { ok: false, response: v2Error('UNAUTHORIZED', auth.error || 'Unauthorized') } } - const gate = await v2ApiGateError(auth.userId) + const gate = + auth.keyType === 'workspace' && auth.billingAttribution.organizationId + ? await v2ApiGateError(auth.actorUserId, auth.billingAttribution.organizationId) + : await v2ApiGateError(auth.actorUserId) if (gate) return { ok: false, response: gate } const authorization = await authorizeWorkflowByWorkspacePermission({ workflowId, - userId: auth.userId, + userId: auth.principalUserId, action, }) if (!authorization.allowed || !authorization.workflow) { return { ok: false, response: v2Error('NOT_FOUND', 'Workflow not found') } } - const workflow = authorization.workflow as WorkflowRecord - - if (auth.keyType === 'workspace' && workflow.workspaceId !== auth.workspaceId) { + const workflowRecord = authorization.workflow as WorkflowRecord + if (auth.keyType === 'workspace' && workflowRecord.workspaceId !== auth.workspaceId) { return { ok: false, response: v2Error('NOT_FOUND', 'Workflow not found') } } - if (auth.keyType === 'personal' && workflow.workspaceId) { - const settings = await getWorkspaceBillingSettings(workflow.workspaceId) + if (auth.keyType === 'personal' && workflowRecord.workspaceId) { + const settings = await getWorkspaceBillingSettings(workflowRecord.workspaceId) if (!settings?.allowPersonalApiKeys) { return { ok: false, @@ -59,5 +61,10 @@ export async function resolveV2WorkflowAccess( } } - return { ok: true, userId: auth.userId, keyType: auth.keyType, workflow } + return { + ok: true, + userId: auth.actorUserId, + keyType: auth.keyType, + workflow: workflowRecord, + } } diff --git a/apps/sim/app/api/v2/workflows/route.test.ts b/apps/sim/app/api/v2/workflows/route.test.ts index 9ab8c6575ae..ea2923c600f 100644 --- a/apps/sim/app/api/v2/workflows/route.test.ts +++ b/apps/sim/app/api/v2/workflows/route.test.ts @@ -431,4 +431,23 @@ describe('POST /api/v2/workflows', () => { }) ) }) + + it('keeps the key creator as owner while recording the workspace payer as actor', async () => { + mockCheckRateLimit.mockResolvedValue({ + ...RATE_LIMIT_OK, + userId: 'payer-1', + principalUserId: 'creator-1', + }) + + const res = await callPost(VALID_BODY) + + expect(res.status).toBe(201) + expect(mockPerformCreateWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'creator-1', + actorUserId: 'payer-1', + workspaceId: 'workspace-1', + }) + ) + }) }) diff --git a/apps/sim/app/api/v2/workflows/route.ts b/apps/sim/app/api/v2/workflows/route.ts index 3e0a42c8171..ac80c3b02ac 100644 --- a/apps/sim/app/api/v2/workflows/route.ts +++ b/apps/sim/app/api/v2/workflows/route.ts @@ -109,8 +109,10 @@ export const POST = withPublicApiRouteHandler({ if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') await assertFolderMutable(resolution.folderId) + const ownerUserId = rateLimit.principalUserId ?? userId const result = await performCreateWorkflow({ - userId, + userId: ownerUserId, + actorUserId: userId, workspaceId, name, description, diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 22c64e1708b..a898e64bb06 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -108,9 +108,9 @@ const FEATURE_FLAGS = { description: 'Gate the whole /api/v2 HTTP surface (workflows incl. execute/executions, tables, logs, ' + 'knowledge, files, audit-logs, billing). One check per request, immediately after auth: ' + - 'when off, every v2 route returns 404 as if the surface does not exist. The gate is keyed ' + - 'on userId only — it never reads workspace/org membership, so an ungated caller learns ' + - 'nothing beyond "no such route". Off-AppConfig falls back to V2_API.', + 'when off, every v2 route returns 404 as if the surface does not exist. Personal keys use ' + + 'their user id; workspace keys use their server-bound workspace billing actor and exact ' + + 'organization after key admission. Off-AppConfig falls back to V2_API.', fallback: 'V2_API', }, 'tables-v2-api': { diff --git a/apps/sim/lib/table/orchestration/import-resource.ts b/apps/sim/lib/table/orchestration/import-resource.ts index 8cbb8cae36c..11f908694dc 100644 --- a/apps/sim/lib/table/orchestration/import-resource.ts +++ b/apps/sim/lib/table/orchestration/import-resource.ts @@ -60,11 +60,25 @@ interface CreateTableImportResult { upload: CreatedUploadSession | null } +interface TableImportAttribution { + /** API/session principal that owns the continuation resource. */ + ownerUserId: string + /** Actor attributed to the imported table and rows. */ + actorUserId: string + /** Personal/session calls may inherit the owner's preference; shared keys may not. */ + useOwnerTimezone: boolean +} + export async function createTableImportResource( body: V2CreateTableImportBody, userId: string, localOrigin: string, - resolvedFolderId?: string | null + resolvedFolderId?: string | null, + attribution: TableImportAttribution = { + ownerUserId: userId, + actorUserId: userId, + useOwnerTimezone: true, + } ): Promise { await assertWorkspaceWrite(userId, body.workspaceId) await validateTarget(body.workspaceId, body.target, resolvedFolderId) @@ -79,12 +93,17 @@ export async function createTableImportResource( const upload = await createUploadSession({ id: importId, workspaceId: body.workspaceId, - userId, + userId: attribution.ownerUserId, purpose: 'table_import', fileName: body.source.name, contentType: body.source.contentType, fileSize: body.source.size, - metadata: { tableImport: body, tableImportFolderId: resolvedFolderId ?? null }, + metadata: { + tableImport: body, + tableImportFolderId: resolvedFolderId ?? null, + tableImportActorUserId: attribution.actorUserId, + tableImportUseOwnerTimezone: attribution.useOwnerTimezone, + }, localOrigin, }) return { record: resourceFromUpload(upload, body), upload } @@ -96,7 +115,9 @@ export async function createTableImportResource( record: await startTableImport({ id: importId, workspaceId: body.workspaceId, - userId, + ownerUserId: attribution.ownerUserId, + actorUserId: attribution.actorUserId, + useOwnerTimezone: attribution.useOwnerTimezone, source: body.source, target: body.target, folderId: resolvedFolderId, @@ -130,10 +151,17 @@ export async function startUploadedTableImport( folderId = storedFolderId } await validateTarget(workspaceId, body.target, folderId) + const actorUserId = upload.metadata.tableImportActorUserId + const useOwnerTimezone = upload.metadata.tableImportUseOwnerTimezone + if (typeof actorUserId !== 'string' || typeof useOwnerTimezone !== 'boolean') { + throw new Error('Table import upload is missing its attribution metadata') + } return startTableImport({ id: upload.id, workspaceId, - userId: upload.userId, + ownerUserId: upload.userId, + actorUserId, + useOwnerTimezone, source: body.source, target: body.target, folderId, @@ -201,11 +229,12 @@ export async function findOwnedTableImport(params: { .limit(1) if (!job) return null const payload = parseImportJobPayload(job.payload) - if (payload.userId !== params.userId) return null + const ownerUserId = payload.ownerUserId ?? payload.userId + if (ownerUserId !== params.userId) return null return { id: job.id, workspaceId: job.workspaceId, - userId: payload.userId, + userId: ownerUserId, source: v2TableImportSourceSchema.parse(payload.source), target: v2TableImportTargetSchema.parse(payload.target), options: payload.options, @@ -261,7 +290,9 @@ export function toV2CreateTableImport(result: CreateTableImportResult): V2Create interface StartTableImportParams { id: string workspaceId: string - userId: string + ownerUserId: string + actorUserId: string + useOwnerTimezone: boolean source: V2TableImportSource target: V2TableImportTarget folderId?: string | null @@ -276,7 +307,8 @@ async function startTableImport(params: StartTableImportParams): Promise + /** User persisted as the imported workflow owner. */ userId: string + /** Actor used for audit attribution when it differs from the owner. */ + actorUserId?: string requestId: string } @@ -271,6 +274,7 @@ export async function importWorkflowIntoWorkspace( folderId, deduplicate: true, userId, + actorUserId: params.actorUserId, requestId, }) diff --git a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts index 07588af4460..ed5fc4739f4 100644 --- a/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts +++ b/apps/sim/lib/workflows/orchestration/workflow-lifecycle.ts @@ -17,7 +17,10 @@ import { deduplicateWorkflowName } from '@/lib/workflows/utils' const logger = createLogger('WorkflowLifecycle') export interface PerformCreateWorkflowParams { + /** User persisted as the workflow owner. */ userId: string + /** Actor used for audit attribution when it differs from the owner. */ + actorUserId?: string workspaceId: string name: string id?: string @@ -250,7 +253,7 @@ export async function performCreateWorkflow( recordAudit({ workspaceId: params.workspaceId, - actorId: params.userId, + actorId: params.actorUserId ?? params.userId, action: AuditAction.WORKFLOW_CREATED, resourceType: AuditResourceType.WORKFLOW, resourceId: workflowId, diff --git a/packages/audit/src/log.test.ts b/packages/audit/src/log.test.ts index 98a71773e65..ae1acf63137 100644 --- a/packages/audit/src/log.test.ts +++ b/packages/audit/src/log.test.ts @@ -10,6 +10,10 @@ import { } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const { mockGetRequestContext } = vi.hoisted(() => ({ + mockGetRequestContext: vi.fn(), +})) + vi.mock('@sim/db', () => ({ ...dbChainMock, auditLog: { id: 'id', workspaceId: 'workspace_id' }, @@ -22,6 +26,7 @@ vi.mock('drizzle-orm', () => ({ sql: vi.fn(), })) vi.mock('@sim/logger', () => ({ + getRequestContext: mockGetRequestContext, createLogger: () => ({ info: vi.fn(), warn: vi.fn(), @@ -74,6 +79,7 @@ describe('AuditResourceType', () => { describe('recordAudit', () => { beforeEach(() => { vi.clearAllMocks() + mockGetRequestContext.mockReturnValue(undefined) resetDbChainMock() requestUtilsMockFns.mockGetClientIp.mockImplementation( (request: { headers: { get(name: string): string | null } }) => @@ -227,6 +233,36 @@ describe('recordAudit', () => { ) }) + it('adds API key identity from the request context to metadata', async () => { + mockGetRequestContext.mockReturnValue({ + requestId: 'request-1', + apiKeyId: 'key-1', + apiKeyType: 'workspace', + }) + + recordAudit({ + workspaceId: 'ws-1', + actorId: 'payer-1', + actorName: 'Workspace Payer', + action: AuditAction.WORKFLOW_CREATED, + resourceType: AuditResourceType.WORKFLOW, + metadata: { source: 'api', apiKeyId: 'untrusted-value' }, + }) + + await flush() + + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'payer-1', + metadata: { + source: 'api', + apiKeyId: 'key-1', + apiKeyType: 'workspace', + }, + }) + ) + }) + it('does not throw when the database insert fails', async () => { dbChainMockFns.values.mockImplementation(() => Promise.reject(new Error('DB connection lost'))) diff --git a/packages/audit/src/log.ts b/packages/audit/src/log.ts index 93381ae43b7..7d6c9b33e3c 100644 --- a/packages/audit/src/log.ts +++ b/packages/audit/src/log.ts @@ -1,5 +1,5 @@ import { auditLog, db, user } from '@sim/db' -import { createLogger } from '@sim/logger' +import { createLogger, getRequestContext } from '@sim/logger' import { generateShortId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import type { AuditActionType, AuditResourceTypeValue } from './types' @@ -77,6 +77,7 @@ function buildAuditRow( params: AuditLogParams, actor: { actorId: string | null; actorName?: string | null; actorEmail?: string | null } ) { + const requestContext = getRequestContext() return { id: generateShortId(), workspaceId: params.workspaceId || null, @@ -88,7 +89,11 @@ function buildAuditRow( actorEmail: actor.actorEmail ?? undefined, resourceName: params.resourceName, description: params.description, - metadata: params.metadata ?? {}, + metadata: { + ...(params.metadata ?? {}), + ...(requestContext?.apiKeyId ? { apiKeyId: requestContext.apiKeyId } : {}), + ...(requestContext?.apiKeyType ? { apiKeyType: requestContext.apiKeyType } : {}), + }, ipAddress: params.request ? getClientIp(params.request) : undefined, userAgent: params.request?.headers.get('user-agent') ?? undefined, } diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index 25e6e3f9ae3..47a2daa085d 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -241,6 +241,8 @@ export class Logger { requestId: reqCtx.requestId, method: reqCtx.method, path: reqCtx.path, + apiKeyId: reqCtx.apiKeyId, + apiKeyType: reqCtx.apiKeyType, ...this.metadata, } : this.metadata diff --git a/packages/logger/src/request-context.ts b/packages/logger/src/request-context.ts index 698e243747c..0a1d5a3d652 100644 --- a/packages/logger/src/request-context.ts +++ b/packages/logger/src/request-context.ts @@ -2,6 +2,8 @@ export interface RequestContext { requestId: string method?: string path?: string + apiKeyId?: string + apiKeyType?: 'personal' | 'workspace' } /**