Skip to content

Commit 6b3cd82

Browse files
fix(credentials): keep the field-less GitHub App installation out of v2 provider discovery (#8632)
1 parent 2c8eeaf commit 6b3cd82

3 files changed

Lines changed: 166 additions & 1 deletion

File tree

‎apps/sim/lib/credentials/application/list-credential-providers.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type CredentialProviderCatalogEntry,
77
listCredentialProviderCatalog,
88
} from '@/lib/credentials/application/provider-catalog'
9+
import { GITHUB_INSTALLATION_PROVIDER_ID } from '@/lib/oauth/github-installation-types'
910
import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context'
1011

1112
export interface ListCredentialProvidersInput {
@@ -31,7 +32,13 @@ export const listCredentialProviders = defineAuthorizedWorkspaceUseCase({
3132
throw new OrchestrationError('validation', 'search cannot be empty')
3233
}
3334

34-
const providers = await listCredentialProviderCatalog(principal, context)
35+
// A GitHub App installation is connected through Search integrations, never credential
36+
// creation, so it has no create fields and stays out of public discovery.
37+
const providers = (await listCredentialProviderCatalog(principal, context)).filter(
38+
(provider) =>
39+
provider.type !== 'service_account' ||
40+
provider.providerId !== GITHUB_INSTALLATION_PROVIDER_ID
41+
)
3542
return {
3643
providers: search
3744
? providers.filter((provider) => provider.name.toLowerCase().includes(search))
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
2+
import { blockVisibilityMock } from '@sim/testing/mocks/block-visibility.mock'
3+
import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock'
4+
import {
5+
workspaceContextMock,
6+
workspaceContextMockFns,
7+
} from '@sim/testing/mocks/workspace-context.mock'
8+
import { beforeEach, describe, expect, it, vi } from 'vitest'
9+
10+
const hoisted = vi.hoisted(() => ({
11+
allowedIntegrationTypes: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/core/config/block-visibility', () => blockVisibilityMock)
15+
vi.mock('@/lib/workspaces/application/workspace-context', () => workspaceContextMock)
16+
vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock)
17+
vi.mock('@/lib/integrations/principal-scope.server', () => ({
18+
principalUserId: () => 'user-1',
19+
allowedIntegrationTypes: hoisted.allowedIntegrationTypes,
20+
allowedOrganizationIntegrationTypes: hoisted.allowedIntegrationTypes,
21+
}))
22+
23+
import { v2ListCredentialProvidersContract } from '@/lib/api/contracts/v2/credentials'
24+
import { listCredentialProviders } from '@/lib/credentials/application/list-credential-providers'
25+
import { listCredentialProviderCatalog } from '@/lib/credentials/application/provider-catalog'
26+
27+
const CLAUDE_PROVIDER_ID = 'claude-platform-service-account'
28+
const GITHUB_INSTALLATION_PROVIDER_ID = 'github-app-installation'
29+
const context = { workspaceId: 'workspace-1', workspaceOrganizationId: null }
30+
const responseSchema = v2ListCredentialProvidersContract.response.schema
31+
32+
async function listProviders(search?: string) {
33+
const { providers } = await listCredentialProviders.execute({
34+
principal: createSessionPrincipal(),
35+
input: { workspaceId: 'workspace-1', ...(search ? { search } : {}) },
36+
})
37+
return providers
38+
}
39+
40+
describe('v2 credential provider catalog contract', () => {
41+
beforeEach(() => {
42+
hoisted.allowedIntegrationTypes.mockResolvedValue(null)
43+
workspaceContextMockFns.mockLoadActiveWorkspaceApplicationContext.mockResolvedValue({
44+
...context,
45+
allowPersonalApiKeys: true,
46+
billedAccountUserId: 'billing-owner-1',
47+
})
48+
workspaceAuthzMockFns.mockResolveEffectiveWorkspacePermission.mockResolvedValue('read')
49+
})
50+
51+
it('presents the full unfiltered catalog as a valid response', async () => {
52+
const providers = await listProviders()
53+
54+
const parsed = responseSchema.safeParse({ data: providers, nextCursor: null })
55+
expect(parsed.success ? [] : parsed.error.issues).toEqual([])
56+
expect(
57+
providers.some(
58+
(provider) =>
59+
provider.type === 'service_account' &&
60+
provider.providerId === GITHUB_INSTALLATION_PROVIDER_ID
61+
)
62+
).toBe(false)
63+
})
64+
65+
it('keeps the field-less GitHub installation in the internal catalog for existing credentials', async () => {
66+
const catalog = await listCredentialProviderCatalog(createSessionPrincipal(), context)
67+
const installation = catalog.find(
68+
(provider) =>
69+
provider.type === 'service_account' &&
70+
provider.providerId === GITHUB_INSTALLATION_PROVIDER_ID
71+
)
72+
73+
expect(installation?.fields).toEqual([])
74+
expect(responseSchema.safeParse({ data: catalog, nextCursor: null }).success).toBe(false)
75+
})
76+
77+
it('lists the native Claude Platform provider when filtered', async () => {
78+
const providers = await listProviders('claude')
79+
80+
expect(responseSchema.safeParse({ data: providers, nextCursor: null }).success).toBe(true)
81+
expect(providers).toContainEqual(
82+
expect.objectContaining({
83+
type: 'service_account',
84+
serviceId: CLAUDE_PROVIDER_ID,
85+
providerId: CLAUDE_PROVIDER_ID,
86+
available: true,
87+
requiresClientGeneratedCredentialId: false,
88+
fields: [expect.objectContaining({ id: 'apiToken', required: true, secret: true })],
89+
})
90+
)
91+
})
92+
93+
it('reports Claude as unavailable, not missing, when integration policy disables it', async () => {
94+
hoisted.allowedIntegrationTypes.mockResolvedValue(new Set(['slack']))
95+
96+
const providers = await listProviders()
97+
98+
expect(responseSchema.safeParse({ data: providers, nextCursor: null }).success).toBe(true)
99+
expect(providers).toContainEqual(
100+
expect.objectContaining({ providerId: CLAUDE_PROVIDER_ID, available: false })
101+
)
102+
})
103+
})

‎packages/sim-cli/src/commands/credentials.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,61 @@ describe('credential connection commands', () => {
112112
).rejects.toThrow('unsupported field "extra" for zoom-service-account')
113113
expect(mockRequest).toHaveBeenCalledTimes(1)
114114
})
115+
116+
describe('native Claude Platform service account', () => {
117+
const claude = {
118+
type: 'service_account',
119+
serviceId: 'claude-platform-service-account',
120+
providerId: 'claude-platform-service-account',
121+
available: true,
122+
requiresClientGeneratedCredentialId: false,
123+
fields: [{ id: 'apiToken', required: true, secret: true }],
124+
}
125+
const createArgs = [
126+
'node',
127+
'sim',
128+
'credentials',
129+
'create',
130+
'claude-platform-service-account',
131+
'--name',
132+
'Code Fixes',
133+
'--credentials',
134+
'{"apiToken":"test-api-token"}',
135+
]
136+
137+
it('creates from the unfiltered catalog without requiring a client credential id', async () => {
138+
mockRequest
139+
.mockReset()
140+
.mockResolvedValueOnce({ data: [claude], nextCursor: null })
141+
.mockResolvedValueOnce({ data: { id: 'credential-1' } })
142+
143+
await program().parseAsync(createArgs)
144+
145+
expect(mockRequest).toHaveBeenNthCalledWith(1, '/api/v2/credentials/providers', {
146+
method: 'GET',
147+
query: { workspaceId: 'ws_local' },
148+
})
149+
const [, createRequest] = mockRequest.mock.calls[1]
150+
expect(createRequest.body).toEqual({
151+
workspaceId: 'ws_local',
152+
type: 'service_account',
153+
providerId: 'claude-platform-service-account',
154+
displayName: 'Code Fixes',
155+
credentials: '{"apiToken":"test-api-token"}',
156+
})
157+
})
158+
159+
it('refuses before creation when workspace policy disables the provider', async () => {
160+
mockRequest
161+
.mockReset()
162+
.mockResolvedValueOnce({ data: [{ ...claude, available: false }], nextCursor: null })
163+
164+
await expect(program().parseAsync(createArgs)).rejects.toThrow(
165+
'Service-account provider "claude-platform-service-account" is not available.'
166+
)
167+
expect(mockRequest).toHaveBeenCalledTimes(1)
168+
})
169+
})
115170
})
116171

117172
describe('credentials update --name', () => {

0 commit comments

Comments
 (0)