diff --git a/apps/sim/app/api/credentials/route.test.ts b/apps/sim/app/api/credentials/route.test.ts index 2b0c2bfed45..0cd669ecfa2 100644 --- a/apps/sim/app/api/credentials/route.test.ts +++ b/apps/sim/app/api/credentials/route.test.ts @@ -20,6 +20,9 @@ const { mockCheckWorkspaceAccess, mockGetCredentialActorContext, mockGetCredentialCreationWorkspaceContext, + mockGetBlockVisibility, + mockCreateIntegrationCredentialVisibility, + mockIsCredentialVisible, mockLoadWorkspace, mockResolveWorkspacePermission, mockSyncWorkspaceOAuthCredentials, @@ -28,6 +31,9 @@ const { mockCheckWorkspaceAccess: vi.fn(), mockGetCredentialActorContext: vi.fn(), mockGetCredentialCreationWorkspaceContext: vi.fn(), + mockGetBlockVisibility: vi.fn(), + mockCreateIntegrationCredentialVisibility: vi.fn(), + mockIsCredentialVisible: vi.fn(), mockLoadWorkspace: vi.fn(), mockResolveWorkspacePermission: vi.fn(), mockSyncWorkspaceOAuthCredentials: vi.fn(), @@ -37,6 +43,14 @@ const { vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/posthog/server', () => posthogServerMock) +vi.mock('@/lib/core/config/block-visibility', () => ({ + getBlockVisibility: mockGetBlockVisibility, +})) + +vi.mock('@/lib/integrations/credential-visibility.server', () => ({ + createIntegrationCredentialVisibility: mockCreateIntegrationCredentialVisibility, +})) + vi.mock('@/lib/workspaces/permissions/utils', () => ({ checkWorkspaceAccess: mockCheckWorkspaceAccess, })) @@ -109,6 +123,16 @@ describe('GET /api/credentials', () => { canWrite: true, canAdmin: false, }) + mockGetBlockVisibility.mockResolvedValue({ + revealed: new Set(), + disabled: new Set(), + previewTagged: new Set(), + }) + mockIsCredentialVisible.mockReturnValue(true) + mockCreateIntegrationCredentialVisibility.mockReturnValue({ + isCredentialVisible: mockIsCredentialVisible, + isOAuthServiceVisible: vi.fn(), + }) }) it('reports an owned personal secret as raw-view admin without a membership row', async () => { @@ -150,6 +174,70 @@ describe('GET /api/credentials', () => { role: 'admin', }), ]) + expect(mockGetBlockVisibility).not.toHaveBeenCalled() + }) + + it('hides a service-account credential when its gating block is preview-hidden', async () => { + mockIsCredentialVisible.mockReturnValue(false) + queueTableRows(credential, [ + { + id: 'slack-credential', + workspaceId: WORKSPACE_ID, + type: 'service_account', + displayName: 'Slack custom bot', + description: null, + providerId: 'slack-custom-bot', + accountId: null, + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + memberRole: 'admin', + }, + { + id: 'google-credential', + workspaceId: WORKSPACE_ID, + type: 'oauth', + displayName: 'Google account', + description: null, + providerId: 'google-email', + accountId: 'google-account', + envKey: null, + envOwnerUserId: null, + createdBy: 'user-1', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), + memberRole: 'admin', + }, + ]) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + `http://localhost:3000/api/credentials?workspaceId=${WORKSPACE_ID}` + ) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + credentials: [expect.objectContaining({ id: 'google-credential' })], + }) + expect(mockGetBlockVisibility).toHaveBeenCalledWith({ userId: 'user-1', orgId: 'org-1' }) + expect(mockCreateIntegrationCredentialVisibility).toHaveBeenCalledWith({ + allowedIntegrationTypes: null, + blockVisibility: { + revealed: new Set(), + disabled: new Set(), + previewTagged: new Set(), + }, + }) + expect(mockIsCredentialVisible).toHaveBeenCalledExactlyOnceWith({ + providerId: 'slack-custom-bot', + type: 'service_account', + }) }) it('normalizes padded, blank, and duplicate legacy query values', async () => { @@ -197,6 +285,7 @@ describe('GET /api/credentials', () => { }) expect(mockSyncWorkspaceOAuthCredentials).not.toHaveBeenCalled() expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + expect(mockGetBlockVisibility).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/credentials/application/credential-crud.ts b/apps/sim/lib/credentials/application/credential-crud.ts index cd1d065d1e2..5f8b1643375 100644 --- a/apps/sim/lib/credentials/application/credential-crud.ts +++ b/apps/sim/lib/credentials/application/credential-crud.ts @@ -1,6 +1,7 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { getBlockVisibility } from '@/lib/core/config/block-visibility' import { OrchestrationError } from '@/lib/core/orchestration/types' import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access' import { @@ -25,6 +26,8 @@ import { type VisibleWorkspaceCredential, type WorkspaceCredentialLookup, } from '@/lib/credentials/queries' +import { getServiceAccountGatingBlockType } from '@/lib/credentials/service-account-provider-ids' +import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' import { captureServerEvent } from '@/lib/posthog/server' import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -77,6 +80,39 @@ export type ListInternalCredentialsResult = | { mode: 'list'; credentials: VisibleWorkspaceCredential[] } | { mode: 'lookup'; credential: WorkspaceCredentialLookup | null } +async function filterGatedServiceAccountCredentials( + credentials: VisibleWorkspaceCredential[], + viewer: { userId: string; organizationId: string | null } +): Promise { + const gatedProviderIds = new Set( + credentials.flatMap(({ providerId }) => { + if (!providerId || !getServiceAccountGatingBlockType(providerId)) return [] + return [providerId] + }) + ) + if (gatedProviderIds.size === 0) return credentials + + const blockVisibility = await getBlockVisibility({ + userId: viewer.userId, + ...(viewer.organizationId ? { orgId: viewer.organizationId } : {}), + }) + const visibility = createIntegrationCredentialVisibility({ + allowedIntegrationTypes: null, + blockVisibility, + }) + + return credentials.filter((credential) => { + const { providerId } = credential + if (!providerId || !gatedProviderIds.has(providerId)) return true + if (credential.type !== 'service_account') { + throw new Error( + `Gated service-account provider ${providerId} has credential type ${credential.type}` + ) + } + return visibility.isCredentialVisible({ providerId, type: credential.type }) + }) +} + export const listInternalCredentials = defineAuthorizedWorkspaceUseCase({ operation: credentialOperations.listInternal, resolveContext: async ({ input }: { input: ListInternalCredentialsInput }) => { @@ -108,7 +144,11 @@ export const listInternalCredentials = defineAuthorizedWorkspaceUseCase({ types: input.type ? [input.type] : undefined, providerId: input.providerId, }) - return { mode: 'list', credentials: page.data } + const credentials = await filterGatedServiceAccountCredentials(page.data, { + userId, + organizationId: context.workspaceOrganizationId, + }) + return { mode: 'list', credentials } }, })