Skip to content

Commit 8fb571a

Browse files
feat(credentials): add managed credential groups (#6697)
* feat(credentials): add managed credential groups * fix(audit): sync credential group mock * fix(credentials): serialize enrollment revocation * fix(credentials): isolate managed delegation * fix(credentials): serialize invitation lifecycle * fix(credentials): preserve enrollment lifecycle * refactor(credentials): migrate groups to application boundary * fix(credentials): serialize enrollment readiness * fix(credentials): preserve completed reconnect state * fix(credentials): revalidate policy before grant persistence * fix(credentials): prioritize expired invitations * fix(credentials): redirect unavailable oauth starts * fix(credentials): clarify managed oauth boundaries * fix(settings): complete feature flag test mocks * fix(credentials): clarify enrollment actions and entitlement errors * fix(credentials): preserve entitlement failure reasons * fix(credentials): refine managed oauth flow * fix(lint): use optional chain for pagination
1 parent 2a7abfd commit 8fb571a

156 files changed

Lines changed: 34020 additions & 223 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: 187 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

@@ -108,6 +128,38 @@ describe('OAuth Token API Routes', () => {
108128
expect(authOAuthUtilsMockFns.mockGetCredential).toHaveBeenCalled()
109129
})
110130

131+
it('does not authenticate managed delegation for an ordinary OAuth credential', async () => {
132+
mockAuthorizeCredentialUse.mockResolvedValueOnce({
133+
ok: true,
134+
authType: 'internal_jwt',
135+
requesterUserId: 'workflow-owner-id',
136+
credentialOwnerUserId: 'workflow-owner-id',
137+
})
138+
authOAuthUtilsMockFns.mockGetCredential.mockResolvedValueOnce({
139+
id: 'credential-id',
140+
accessToken: 'test-token',
141+
refreshToken: 'refresh-token',
142+
accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000),
143+
providerId: 'google',
144+
})
145+
authOAuthUtilsMockFns.mockRefreshTokenIfNeeded.mockResolvedValueOnce({
146+
accessToken: 'fresh-token',
147+
refreshed: false,
148+
})
149+
150+
const response = await POST(
151+
createMockRequest(
152+
'POST',
153+
{ credentialId: 'credential-id', workflowId: 'workflow-id' },
154+
{ 'x-sim-managed-oauth-delegation': 'Bearer stale-delegation' }
155+
)
156+
)
157+
158+
expect(response.status).toBe(200)
159+
await expect(response.json()).resolves.toMatchObject({ accessToken: 'fresh-token' })
160+
expect(mockAuthenticateManagedOAuthDelegation).not.toHaveBeenCalled()
161+
})
162+
111163
it('should handle missing credentialId', async () => {
112164
const req = createMockRequest('POST', {})
113165

@@ -332,6 +384,140 @@ describe('OAuth Token API Routes', () => {
332384
)
333385
})
334386

387+
describe('managed OAuth path', () => {
388+
const managedCredential = {
389+
accountId: '',
390+
credentialId: 'managed-credential-id',
391+
credentialType: 'managed_oauth',
392+
providerId: 'google-email',
393+
workspaceId: 'workspace-id',
394+
usedCredentialTable: true,
395+
}
396+
397+
beforeEach(() => {
398+
authOAuthUtilsMockFns.mockResolveOAuthAccountId.mockResolvedValueOnce(managedCredential)
399+
mockGetToolMetadata.mockReturnValue({
400+
oauth: {
401+
required: true,
402+
provider: 'google-email',
403+
requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'],
404+
},
405+
})
406+
})
407+
408+
it('fails closed when workflow delegation is missing', async () => {
409+
const response = await POST(
410+
createMockRequest('POST', {
411+
credentialId: 'managed-credential-id',
412+
toolId: 'gmail_read',
413+
})
414+
)
415+
416+
expect(response.status).toBe(403)
417+
await expect(response.json()).resolves.toMatchObject({
418+
code: 'MANAGED_CREDENTIAL_DELEGATION_REQUIRED',
419+
})
420+
expect(mockResolveManagedOAuthCredentialToken).not.toHaveBeenCalled()
421+
})
422+
423+
it('resolves a manually supplied managed credential ID with scoped delegation', async () => {
424+
const principal = {
425+
kind: 'delegated' as const,
426+
serviceId: 'executor' as const,
427+
subjectUserId: 'user-id',
428+
workspaceId: 'workspace-id',
429+
delegationId: 'delegation-id',
430+
audience: 'sim:managed-oauth-credentials',
431+
issuedAt: new Date(Date.now() - 1_000),
432+
expiresAt: new Date(Date.now() + 60_000),
433+
resourceScope: { credentialId: 'managed-credential-id' },
434+
delegationContext: {
435+
kind: 'workflow_execution' as const,
436+
workflowId: 'workflow-id',
437+
},
438+
}
439+
mockAuthenticateManagedOAuthDelegation.mockResolvedValueOnce(principal)
440+
mockResolveManagedOAuthCredentialToken.mockResolvedValueOnce({
441+
accessToken: 'managed-access-token',
442+
refreshed: false,
443+
})
444+
445+
const response = await POST(
446+
createMockRequest(
447+
'POST',
448+
{ credentialId: 'managed-credential-id', toolId: 'gmail_read' },
449+
{ 'x-sim-managed-oauth-delegation': 'Bearer delegated-token' }
450+
)
451+
)
452+
453+
expect(response.status).toBe(200)
454+
await expect(response.json()).resolves.toEqual({ accessToken: 'managed-access-token' })
455+
expect(mockResolveManagedOAuthCredentialToken).toHaveBeenCalledWith({
456+
principal,
457+
input: {
458+
credentialId: 'managed-credential-id',
459+
expectedProviderId: 'google-email',
460+
requiredScopes: ['https://www.googleapis.com/auth/gmail.readonly'],
461+
toolId: 'gmail_read',
462+
},
463+
request: expect.any(NextRequest),
464+
})
465+
})
466+
467+
it('uses the trusted provider scope policy when a Slack tool omits narrower scopes', async () => {
468+
mockGetToolMetadata.mockReturnValueOnce({
469+
oauth: {
470+
required: true,
471+
provider: 'slack',
472+
},
473+
})
474+
const principal = {
475+
kind: 'delegated' as const,
476+
serviceId: 'executor' as const,
477+
subjectUserId: 'user-id',
478+
workspaceId: 'workspace-id',
479+
delegationId: 'delegation-id',
480+
audience: 'sim:managed-oauth-credentials',
481+
issuedAt: new Date(Date.now() - 1_000),
482+
expiresAt: new Date(Date.now() + 60_000),
483+
resourceScope: { credentialId: 'managed-credential-id' },
484+
delegationContext: {
485+
kind: 'workflow_execution' as const,
486+
workflowId: 'workflow-id',
487+
},
488+
}
489+
mockAuthenticateManagedOAuthDelegation.mockResolvedValueOnce(principal)
490+
mockResolveManagedOAuthCredentialToken.mockResolvedValueOnce({
491+
accessToken: 'managed-slack-token',
492+
refreshed: false,
493+
})
494+
495+
const response = await POST(
496+
createMockRequest(
497+
'POST',
498+
{ credentialId: 'managed-credential-id', toolId: 'slack_message' },
499+
{ 'x-sim-managed-oauth-delegation': 'Bearer delegated-token' }
500+
)
501+
)
502+
503+
expect(response.status).toBe(200)
504+
expect(mockResolveManagedOAuthCredentialToken).toHaveBeenCalledWith({
505+
principal,
506+
input: {
507+
credentialId: 'managed-credential-id',
508+
expectedProviderId: 'slack',
509+
requiredScopes: expect.arrayContaining([
510+
'channels:read',
511+
'channels:history',
512+
'chat:write',
513+
]),
514+
toolId: 'slack_message',
515+
},
516+
request: expect.any(NextRequest),
517+
})
518+
})
519+
})
520+
335521
describe('credentialAccountUserId + providerId path', () => {
336522
it('should reject unauthenticated requests', async () => {
337523
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({

0 commit comments

Comments
 (0)