Skip to content

Commit e9e56ea

Browse files
feat(credentials): add managed credential groups
1 parent 0239db8 commit e9e56ea

160 files changed

Lines changed: 31663 additions & 64 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
197197
# DATA_RETENTION_ENABLED= / NEXT_PUBLIC_DATA_RETENTION_ENABLED= # Runs retention deletion — off by default
198198
# DATA_DRAINS_ENABLED= / NEXT_PUBLIC_DATA_DRAINS_ENABLED= # Export streams
199199
# FORKING_ENABLED= # Workspace forks
200+
# CREDENTIAL_GROUPS= # Enterprise managed OAuth collections
200201
# ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only
201202

202203
# Instance organization (Optional). Most enterprise features read their settings from the

apps/sim/app/api/auth/oauth/credentials/route.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@
44
* @vitest-environment node
55
*/
66

7-
import { hybridAuthMockFns, permissionsMock, workflowsUtilsMock } from '@sim/testing'
7+
import {
8+
dbChainMockFns,
9+
hybridAuthMockFns,
10+
permissionsMock,
11+
resetDbChainMock,
12+
workflowsUtilsMock,
13+
} from '@sim/testing'
814
import { NextRequest } from 'next/server'
915
import { beforeEach, describe, expect, it, vi } from 'vitest'
1016

@@ -26,6 +32,7 @@ describe('OAuth Credentials API Route', () => {
2632

2733
beforeEach(() => {
2834
vi.clearAllMocks()
35+
resetDbChainMock()
2936
})
3037

3138
it('should handle unauthenticated user', async () => {
@@ -90,4 +97,33 @@ describe('OAuth Credentials API Route', () => {
9097
expect(response.status).toBe(200)
9198
expect(data.credentials).toHaveLength(0)
9299
})
100+
101+
it('does not expose a managed credential requested by exact ID', async () => {
102+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
103+
success: true,
104+
userId: 'user-123',
105+
authType: 'session',
106+
})
107+
dbChainMockFns.limit.mockResolvedValueOnce([
108+
{
109+
id: 'managed-credential-1',
110+
workspaceId: 'workspace-1',
111+
type: 'managed_oauth',
112+
displayName: 'Managed Gmail',
113+
providerId: 'google-email',
114+
accountId: null,
115+
updatedAt: new Date('2026-01-01T00:00:00Z'),
116+
accountProviderId: null,
117+
accountScope: null,
118+
accountUpdatedAt: null,
119+
},
120+
])
121+
122+
const response = await GET(
123+
createMockRequestWithQuery('GET', '?credentialId=managed-credential-1')
124+
)
125+
126+
expect(response.status).toBe(200)
127+
await expect(response.json()).resolves.toEqual({ credentials: [] })
128+
})
93129
})

apps/sim/app/api/auth/oauth/token/route.test.ts

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,17 @@ import {
1212
import { NextRequest } from 'next/server'
1313
import { beforeEach, describe, expect, it, vi } from 'vitest'
1414

15-
const { mockAuthorizeCredentialUse, mockResolveServiceAccountToken } = vi.hoisted(() => ({
15+
const {
16+
mockAuthenticateManagedOAuthDelegation,
17+
mockAuthorizeCredentialUse,
18+
mockGetToolMetadata,
19+
mockResolveManagedOAuthCredentialToken,
20+
mockResolveServiceAccountToken,
21+
} = vi.hoisted(() => ({
22+
mockAuthenticateManagedOAuthDelegation: vi.fn(),
1623
mockAuthorizeCredentialUse: vi.fn(),
24+
mockGetToolMetadata: vi.fn(),
25+
mockResolveManagedOAuthCredentialToken: vi.fn(),
1726
mockResolveServiceAccountToken: vi.fn(),
1827
}))
1928

@@ -27,6 +36,17 @@ vi.mock('@/lib/auth/credential-access', () => ({
2736
authorizeCredentialUseForAuth: mockAuthorizeCredentialUse,
2837
}))
2938

39+
vi.mock('@/lib/credentials/application/managed-oauth-delegation', () => ({
40+
authenticateManagedOAuthDelegation: mockAuthenticateManagedOAuthDelegation,
41+
InvalidManagedOAuthDelegationError: class InvalidManagedOAuthDelegationError extends Error {},
42+
}))
43+
44+
vi.mock('@/lib/credentials/application/resolve-managed-oauth-token', () => ({
45+
resolveManagedOAuthCredentialToken: { execute: mockResolveManagedOAuthCredentialToken },
46+
}))
47+
48+
vi.mock('@/tools/metadata', () => ({ getToolMetadata: mockGetToolMetadata }))
49+
3050
import { TokenServiceAccountValidationError } from '@/lib/credentials/token-service-accounts/errors'
3151
import { GET, POST } from '@/app/api/auth/oauth/token/route'
3252

@@ -332,6 +352,140 @@ describe('OAuth Token API Routes', () => {
332352
)
333353
})
334354

355+
describe('managed OAuth path', () => {
356+
const managedCredential = {
357+
accountId: '',
358+
credentialId: 'managed-credential-id',
359+
credentialType: 'managed_oauth',
360+
providerId: 'google-email',
361+
workspaceId: 'workspace-id',
362+
usedCredentialTable: true,
363+
}
364+
365+
beforeEach(() => {
366+
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce(managedCredential)
367+
mockGetToolMetadata.mockReturnValue({
368+
oauth: {
369+
required: true,
370+
provider: 'google-email',
371+
requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'],
372+
},
373+
})
374+
})
375+
376+
it('fails closed when workflow delegation is missing', async () => {
377+
const response = await POST(
378+
createMockRequest('POST', {
379+
credentialId: 'managed-credential-id',
380+
toolId: 'gmail_read',
381+
})
382+
)
383+
384+
expect(response.status).toBe(403)
385+
await expect(response.json()).resolves.toMatchObject({
386+
code: 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED',
387+
})
388+
expect(mockResolveManagedOAuthCredentialToken).not.toHaveBeenCalled()
389+
})
390+
391+
it('resolves a manually supplied managed credential ID with scoped delegation', async () => {
392+
const principal = {
393+
kind: 'delegated' as const,
394+
serviceId: 'executor' as const,
395+
subjectUserId: 'user-id',
396+
workspaceId: 'workspace-id',
397+
delegationId: 'delegation-id',
398+
audience: 'sim:managed-oauth-credentials',
399+
issuedAt: new Date(Date.now() - 1_000),
400+
expiresAt: new Date(Date.now() + 60_000),
401+
resourceScope: { credentialId: 'managed-credential-id' },
402+
delegationContext: {
403+
kind: 'workflow_execution' as const,
404+
workflowId: 'workflow-id',
405+
},
406+
}
407+
mockAuthenticateManagedOAuthDelegation.mockResolvedValueOnce(principal)
408+
mockResolveManagedOAuthCredentialToken.mockResolvedValueOnce({
409+
accessToken: 'managed-access-token',
410+
refreshed: false,
411+
})
412+
413+
const response = await POST(
414+
createMockRequest(
415+
'POST',
416+
{ credentialId: 'managed-credential-id', toolId: 'gmail_read' },
417+
{ 'x-sim-managed-oauth-delegation': 'Bearer delegated-token' }
418+
)
419+
)
420+
421+
expect(response.status).toBe(200)
422+
await expect(response.json()).resolves.toEqual({ accessToken: 'managed-access-token' })
423+
expect(mockResolveManagedOAuthCredentialToken).toHaveBeenCalledWith({
424+
principal,
425+
input: {
426+
credentialId: 'managed-credential-id',
427+
expectedProviderId: 'google-email',
428+
requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'],
429+
toolId: 'gmail_read',
430+
},
431+
request: expect.any(NextRequest),
432+
})
433+
})
434+
435+
it('uses the trusted provider scope policy when a Slack tool omits narrower scopes', async () => {
436+
mockGetToolMetadata.mockReturnValueOnce({
437+
oauth: {
438+
required: true,
439+
provider: 'slack',
440+
},
441+
})
442+
const principal = {
443+
kind: 'delegated' as const,
444+
serviceId: 'executor' as const,
445+
subjectUserId: 'user-id',
446+
workspaceId: 'workspace-id',
447+
delegationId: 'delegation-id',
448+
audience: 'sim:managed-oauth-credentials',
449+
issuedAt: new Date(Date.now() - 1_000),
450+
expiresAt: new Date(Date.now() + 60_000),
451+
resourceScope: { credentialId: 'managed-credential-id' },
452+
delegationContext: {
453+
kind: 'workflow_execution' as const,
454+
workflowId: 'workflow-id',
455+
},
456+
}
457+
mockAuthenticateManagedOAuthDelegation.mockResolvedValueOnce(principal)
458+
mockResolveManagedOAuthCredentialToken.mockResolvedValueOnce({
459+
accessToken: 'managed-slack-token',
460+
refreshed: false,
461+
})
462+
463+
const response = await POST(
464+
createMockRequest(
465+
'POST',
466+
{ credentialId: 'managed-credential-id', toolId: 'slack_message' },
467+
{ 'x-sim-managed-oauth-delegation': 'Bearer delegated-token' }
468+
)
469+
)
470+
471+
expect(response.status).toBe(200)
472+
expect(mockResolveManagedOAuthCredentialToken).toHaveBeenCalledWith({
473+
principal,
474+
input: {
475+
credentialId: 'managed-credential-id',
476+
expectedProviderId: 'slack',
477+
requiredScopes: expect.arrayContaining([
478+
'channels:read',
479+
'channels:history',
480+
'chat:write',
481+
]),
482+
toolId: 'slack_message',
483+
},
484+
request: expect.any(NextRequest),
485+
})
486+
})
487+
})
488+
335489
describe('credentialAccountUserId + providerId path', () => {
336490
it('should reject unauthenticated requests', async () => {
337491
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({

0 commit comments

Comments
 (0)