diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts index 6849e05ffe6..f2bb5177a65 100644 --- a/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.test.ts @@ -9,12 +9,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckRateLimit, - mockValidateEnterpriseAuditAccess, + mockValidateV1EnterpriseAuditAccess, mockBuildOrgScopeCondition, mockGetOrgWorkspaceIds, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), - mockValidateEnterpriseAuditAccess: vi.fn(), + mockValidateV1EnterpriseAuditAccess: vi.fn(), mockBuildOrgScopeCondition: vi.fn(), mockGetOrgWorkspaceIds: vi.fn(), })) @@ -25,7 +25,7 @@ vi.mock('@/app/api/v1/middleware', () => ({ })) vi.mock('@/app/api/v1/audit-logs/auth', () => ({ - validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess, + validateV1EnterpriseAuditAccess: mockValidateV1EnterpriseAuditAccess, })) vi.mock('@/lib/audit-logs/query', () => ({ @@ -76,8 +76,9 @@ describe('GET /api/v1/audit-logs/[id]', () => { beforeEach(() => { vi.clearAllMocks() mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'admin-1' }) - mockValidateEnterpriseAuditAccess.mockResolvedValue({ + mockValidateV1EnterpriseAuditAccess.mockResolvedValue({ success: true, + userId: 'admin-1', context: { organizationId: ORG_ID, orgMemberIds: MEMBER_IDS }, }) mockGetOrgWorkspaceIds.mockResolvedValue(ORG_WORKSPACE_IDS) @@ -124,4 +125,28 @@ describe('GET /api/v1/audit-logs/[id]', () => { expect(body.data.ipAddress).toBeUndefined() expect(body.data.userAgent).toBeUndefined() }) + + it('returns the refusal for a workspace key without querying', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: true, + userId: 'admin-1', + keyType: 'workspace', + workspaceId: 'ws-org-1', + }) + const denied = new Response( + JSON.stringify({ error: 'Audit logs require a personal API key' }), + { + status: 403, + } + ) + mockValidateV1EnterpriseAuditAccess.mockResolvedValue({ success: false, response: denied }) + + const response = await callRoute('log-1') + + expect(response.status).toBe(403) + expect(mockValidateV1EnterpriseAuditAccess).toHaveBeenCalledWith( + expect.objectContaining({ keyType: 'workspace' }) + ) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.ts index 3ba25fdbfb1..2cfc9b27606 100644 --- a/apps/sim/app/api/v1/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.ts @@ -22,7 +22,7 @@ import { v1GetAuditLogContract } from '@/lib/api/contracts/v1/audit-logs' import { parseRequest } from '@/lib/api/server' import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/lib/audit-logs/query' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { validateV1EnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' @@ -49,7 +49,6 @@ export const GET = withRouteHandler( return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! const parsed = await parseRequest(v1GetAuditLogContract, request, context, { validationErrorResponse: () => NextResponse.json({ error: 'Invalid audit log ID' }, { status: 400 }), @@ -58,11 +57,12 @@ export const GET = withRouteHandler( const { id } = parsed.data.params - const authResult = await validateEnterpriseAuditAccess(userId) + const authResult = await validateV1EnterpriseAuditAccess(rateLimit) if (!authResult.success) { return authResult.response } + const { userId } = authResult const { organizationId, orgMemberIds } = authResult.context const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId) diff --git a/apps/sim/app/api/v1/audit-logs/auth.test.ts b/apps/sim/app/api/v1/audit-logs/auth.test.ts index d9aa8f48455..0d9230a6e1c 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.test.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.test.ts @@ -11,21 +11,34 @@ import { } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockIsOrganizationBillingBlocked } = vi.hoisted(() => ({ - mockIsOrganizationBillingBlocked: vi.fn(), -})) +const { mockIsOrganizationBillingBlocked, mockCheckOrganizationPersonalKeyRefusal } = vi.hoisted( + () => ({ + mockIsOrganizationBillingBlocked: vi.fn(), + mockCheckOrganizationPersonalKeyRefusal: vi.fn(), + }) +) vi.mock('@/lib/billing/core/access', () => ({ isOrganizationBillingBlocked: mockIsOrganizationBillingBlocked, })) -import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +vi.mock('@/app/api/v1/middleware', () => ({ + capabilityGovernedUserId: (rateLimit: { keyType?: string; userId?: string }) => + rateLimit.keyType === 'personal' ? (rateLimit.userId ?? null) : null, + checkOrganizationPersonalKeyRefusal: mockCheckOrganizationPersonalKeyRefusal, +})) + +import { + validateEnterpriseAuditAccess, + validateV1EnterpriseAuditAccess, +} from '@/app/api/v1/audit-logs/auth' describe('enterprise audit access', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() mockIsOrganizationBillingBlocked.mockResolvedValue(false) + mockCheckOrganizationPersonalKeyRefusal.mockResolvedValue(null) }) afterAll(() => { @@ -112,4 +125,72 @@ describe('enterprise audit access', () => { }) }) }) + + describe('v1 API-key access', () => { + const personalKey = { + allowed: true, + remaining: 1, + limit: 1, + resetAt: new Date(), + userId: 'viewer', + keyType: 'personal' as const, + } + + beforeEach(() => { + setEnvFlags({ isBillingEnabled: false, isAuditLogsEnabled: true }) + }) + + it('refuses a workspace key before resolving its creator as the subject', async () => { + const result = await validateV1EnterpriseAuditAccess({ + ...personalKey, + keyType: 'workspace', + workspaceId: 'workspace-a', + }) + + if (result.success) throw new Error('Expected the workspace key to be refused') + expect(result.response.status).toBe(403) + await expect(result.response.json()).resolves.toEqual({ + error: 'Audit logs require a personal API key', + }) + expect(dbChainMockFns.where).not.toHaveBeenCalled() + expect(mockCheckOrganizationPersonalKeyRefusal).not.toHaveBeenCalled() + }) + + it('authorizes a personal key held by an organization admin', async () => { + queueTableRows(schemaMock.member, [{ organizationId: 'org-1', role: 'admin' }]) + queueTableRows(schemaMock.member, [{ userId: 'viewer' }]) + + await expect(validateV1EnterpriseAuditAccess(personalKey)).resolves.toEqual({ + success: true, + userId: 'viewer', + context: { organizationId: 'org-1', orgMemberIds: ['viewer'] }, + }) + expect(mockCheckOrganizationPersonalKeyRefusal).toHaveBeenCalledWith(personalKey) + }) + + it('refuses a personal key its permission group withholds', async () => { + queueTableRows(schemaMock.member, [{ organizationId: 'org-1', role: 'admin' }]) + queueTableRows(schemaMock.member, [{ userId: 'viewer' }]) + const refusal = new Response(null, { status: 403 }) + mockCheckOrganizationPersonalKeyRefusal.mockResolvedValue(refusal) + + const result = await validateV1EnterpriseAuditAccess(personalKey) + + if (result.success) throw new Error('Expected the withheld personal key to be refused') + expect(result.response).toBe(refusal) + }) + + it('answers a non-admin with the role refusal, not the group configuration', async () => { + queueTableRows(schemaMock.member, [{ organizationId: 'org-1', role: 'member' }]) + mockCheckOrganizationPersonalKeyRefusal.mockResolvedValue(new Response(null, { status: 403 })) + + const result = await validateV1EnterpriseAuditAccess(personalKey) + + if (result.success) throw new Error('Expected the non-admin to be refused') + await expect(result.response.json()).resolves.toEqual({ + error: 'Organization admin or owner role required', + }) + expect(mockCheckOrganizationPersonalKeyRefusal).not.toHaveBeenCalled() + }) + }) }) diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts index 739e1c39918..0679d20b444 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -3,11 +3,20 @@ import { type EnterpriseAuditContext, resolveEnterpriseAuditAccess, } from '@/lib/audit-logs/authorization' +import { + capabilityGovernedUserId, + checkOrganizationPersonalKeyRefusal, + type RateLimitResult, +} from '@/app/api/v1/middleware' type AuthResult = | { success: true; context: EnterpriseAuditContext } | { success: false; response: NextResponse } +type V1AuthResult = + | { success: true; userId: string; context: EnterpriseAuditContext } + | { success: false; response: NextResponse } + /** * v1 wrapper: renders {@link resolveEnterpriseAuditAccess} as the v1 `{ error }` * response body. @@ -23,3 +32,37 @@ export async function validateEnterpriseAuditAccess( response: NextResponse.json({ error: result.message }, { status: result.status }), } } + +/** + * Authorizes a v1 API-key read of the organization audit trail with the same + * policy as `auditLogOperations`, which v1 does not route through. + * + * Workspace keys are refused (`workspaceApiKey: 'deny'`): their `userId` is the + * key's creator, so authorizing it would let a credential scoped to one + * workspace read every workspace in the organization whenever its creator is an + * organization admin. A personal key is then held to the user-global + * `personal_api_key.use` group decision, checked after the admin role so the + * refusal never describes an organization's configuration to a non-admin. + */ +export async function validateV1EnterpriseAuditAccess( + rateLimit: RateLimitResult +): Promise { + const userId = capabilityGovernedUserId(rateLimit) + if (!userId) { + return { + success: false, + response: NextResponse.json( + { error: 'Audit logs require a personal API key' }, + { status: 403 } + ), + } + } + + const access = await validateEnterpriseAuditAccess(userId) + if (!access.success) return access + + const personalKeyRefusal = await checkOrganizationPersonalKeyRefusal(rateLimit) + if (personalKeyRefusal) return { success: false, response: personalKeyRefusal } + + return { success: true, userId, context: access.context } +} diff --git a/apps/sim/app/api/v1/audit-logs/route.test.ts b/apps/sim/app/api/v1/audit-logs/route.test.ts index 2644d07132f..3ec68264bff 100644 --- a/apps/sim/app/api/v1/audit-logs/route.test.ts +++ b/apps/sim/app/api/v1/audit-logs/route.test.ts @@ -9,14 +9,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockCheckRateLimit, - mockValidateEnterpriseAuditAccess, + mockValidateV1EnterpriseAuditAccess, mockBuildOrgScopeCondition, mockGetOrgWorkspaceIds, mockQueryAuditLogs, mockBuildFilterConditions, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), - mockValidateEnterpriseAuditAccess: vi.fn(), + mockValidateV1EnterpriseAuditAccess: vi.fn(), mockBuildOrgScopeCondition: vi.fn(), mockGetOrgWorkspaceIds: vi.fn(), mockQueryAuditLogs: vi.fn(), @@ -31,7 +31,7 @@ vi.mock('@/app/api/v1/middleware', () => ({ })) vi.mock('@/app/api/v1/audit-logs/auth', () => ({ - validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess, + validateV1EnterpriseAuditAccess: mockValidateV1EnterpriseAuditAccess, })) vi.mock('@/lib/audit-logs/query', () => ({ @@ -61,8 +61,9 @@ describe('GET /api/v1/audit-logs', () => { beforeEach(() => { vi.clearAllMocks() mockCheckRateLimit.mockResolvedValue({ allowed: true, userId: 'admin-1' }) - mockValidateEnterpriseAuditAccess.mockResolvedValue({ + mockValidateV1EnterpriseAuditAccess.mockResolvedValue({ success: true, + userId: 'admin-1', context: { organizationId: ORG_ID, orgMemberIds: MEMBER_IDS }, }) mockGetOrgWorkspaceIds.mockResolvedValue(ORG_WORKSPACE_IDS) @@ -122,11 +123,35 @@ describe('GET /api/v1/audit-logs', () => { it('returns the auth failure response when enterprise access is denied', async () => { const denied = new Response(JSON.stringify({ error: 'nope' }), { status: 403 }) - mockValidateEnterpriseAuditAccess.mockResolvedValue({ success: false, response: denied }) + mockValidateV1EnterpriseAuditAccess.mockResolvedValue({ success: false, response: denied }) const response = await GET(makeRequest('')) expect(response.status).toBe(403) expect(mockQueryAuditLogs).not.toHaveBeenCalled() }) + + it('returns the refusal for a workspace key without querying', async () => { + mockCheckRateLimit.mockResolvedValue({ + allowed: true, + userId: 'admin-1', + keyType: 'workspace', + workspaceId: 'ws-org-1', + }) + const denied = new Response( + JSON.stringify({ error: 'Audit logs require a personal API key' }), + { + status: 403, + } + ) + mockValidateV1EnterpriseAuditAccess.mockResolvedValue({ success: false, response: denied }) + + const response = await GET(makeRequest('?workspaceId=ws-org-2')) + + expect(response.status).toBe(403) + expect(mockValidateV1EnterpriseAuditAccess).toHaveBeenCalledWith( + expect.objectContaining({ keyType: 'workspace' }) + ) + expect(mockQueryAuditLogs).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/v1/audit-logs/route.ts b/apps/sim/app/api/v1/audit-logs/route.ts index e1ddc69d9b1..26498d298a8 100644 --- a/apps/sim/app/api/v1/audit-logs/route.ts +++ b/apps/sim/app/api/v1/audit-logs/route.ts @@ -32,7 +32,7 @@ import { queryAuditLogs, } from '@/lib/audit-logs/query' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { validateV1EnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { @@ -63,13 +63,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return createRateLimitResponse(rateLimit) } - const userId = rateLimit.userId! - - const authResult = await validateEnterpriseAuditAccess(userId) + const authResult = await validateV1EnterpriseAuditAccess(rateLimit) if (!authResult.success) { return authResult.response } + const { userId } = authResult const { organizationId, orgMemberIds } = authResult.context const parsed = await parseRequest( diff --git a/apps/sim/app/api/v1/middleware.test.ts b/apps/sim/app/api/v1/middleware.test.ts index 3c92fdb58cd..7f06854ab59 100644 --- a/apps/sim/app/api/v1/middleware.test.ts +++ b/apps/sim/app/api/v1/middleware.test.ts @@ -30,6 +30,7 @@ const { mockGetUserEntityPermissions, mockGetWorkspaceBillingSettings, mockGetWorkspaceBilledAccountUserId, + mockIsCapabilityWithheldForUser, } = vi.hoisted(() => ({ mockAuthenticateV1Request: vi.fn(), mockGetSubscription: vi.fn(), @@ -38,6 +39,7 @@ const { mockGetUserEntityPermissions: vi.fn(), mockGetWorkspaceBillingSettings: vi.fn(), mockGetWorkspaceBilledAccountUserId: vi.fn(), + mockIsCapabilityWithheldForUser: vi.fn(), })) vi.mock('@/app/api/v1/auth', () => ({ @@ -57,6 +59,10 @@ vi.mock('@/lib/core/rate-limiter', () => ({ vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +vi.mock('@/lib/permission-groups/user-scope.server', () => ({ + isCapabilityWithheldForUser: mockIsCapabilityWithheldForUser, +})) + vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) @@ -69,6 +75,7 @@ vi.mock('@/lib/workspaces/utils', () => ({ import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { authenticateRequest, + checkOrganizationPersonalKeyRefusal, checkRateLimit, checkWorkspaceScope, createRateLimitResponse, @@ -425,6 +432,48 @@ describe('checkWorkspaceScope', () => { }) }) +describe('checkOrganizationPersonalKeyRefusal', () => { + const USER_ID = 'user-1' + const BASE = { allowed: true, remaining: 1, limit: 1, resetAt: new Date(), userId: USER_ID } + + beforeEach(() => { + vi.clearAllMocks() + mockIsCapabilityWithheldForUser.mockResolvedValue(false) + }) + + it("refuses a personal key its user-global group withholds, with the group's detail code", async () => { + mockIsCapabilityWithheldForUser.mockResolvedValue(true) + + const response = await checkOrganizationPersonalKeyRefusal({ ...BASE, keyType: 'personal' }) + + expect(mockIsCapabilityWithheldForUser).toHaveBeenCalledWith(USER_ID, 'personal_api_key.use') + expect(response?.status).toBe(403) + await expect(response?.json()).resolves.toMatchObject({ + error: expect.stringMatching(/personal API key/i), + details: { code: 'PERSONAL_API_KEYS_DISABLED' }, + }) + }) + + it('allows a personal key its group does not withhold', async () => { + await expect( + checkOrganizationPersonalKeyRefusal({ ...BASE, keyType: 'personal' }) + ).resolves.toBeNull() + }) + + it("never evaluates a workspace key against its creator's group", async () => { + mockIsCapabilityWithheldForUser.mockResolvedValue(true) + + const response = await checkOrganizationPersonalKeyRefusal({ + ...BASE, + keyType: 'workspace', + workspaceId: 'workspace-a', + }) + + expect(response).toBeNull() + expect(mockIsCapabilityWithheldForUser).not.toHaveBeenCalled() + }) +}) + describe('requireWorkspaceRequestActor', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index d390a2403f9..9c9aa5110b3 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -19,6 +19,7 @@ import { capabilityRefusal, isWorkspaceCapabilityWithheld, } from '@/lib/permission-groups/capability-assertions' +import { isCapabilityWithheldForUser } from '@/lib/permission-groups/user-scope.server' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' import { getWorkspaceBilledAccountUserId, @@ -512,6 +513,34 @@ export async function checkWorkspaceScope( return failure ? workspaceAccessErrorResponse(failure) : null } +/** + * The `personal_api_key.use` refusal for a v1 surface that authorizes against + * an organization rather than a workspace, such as the audit log. + * + * {@link checkWorkspaceScope} has no workspace to key the group decision on + * there, so this applies the user-global form, which falls back to the + * organization's default group — the same decision the v2 audit-log use case + * makes. Call it only after the caller's organization role verified, for the + * same disclosure reason {@link resolvePersonalKeyGroupRefusal} runs after the + * workspace role. + */ +export async function checkOrganizationPersonalKeyRefusal( + rateLimit: RateLimitResult +): Promise { + const governedUserId = capabilityGovernedUserId(rateLimit) + if (!governedUserId) return null + + // permission-group-enforced: personal_api_key.use — organization-scoped v1 surfaces have no workspace for the funnel to key on + if (!(await isCapabilityWithheldForUser(governedUserId, 'personal_api_key.use'))) return null + + return workspaceAccessErrorResponse({ + status: 403, + code: 'FORBIDDEN', + message: PERSONAL_KEY_DENIED, + details: { code: CAPABILITY_RULES['personal_api_key.use'].detailCode }, + }) +} + /** * The response a surface that conceals an inaccessible workspace should answer * a {@link resolveWorkspaceAccess} failure with.