Skip to content

Commit 2db10c1

Browse files
Merge remote-tracking branch 'origin/staging' into feat/sim-cli
# Conflicts: # apps/docs/openapi-v2-resources.json # apps/sim/app/api/auth/oauth2/authorize/route.test.ts # apps/sim/app/api/auth/oauth2/authorize/route.ts # apps/sim/app/api/credentials/route.test.ts # apps/sim/app/api/v2/credentials/providers/route.test.ts # apps/sim/app/oauth/credential-connected/page.tsx # apps/sim/lib/api/contracts/v2/credentials.ts # apps/sim/lib/api/contracts/v2/openapi/resources.ts # apps/sim/lib/credentials/application/authorization.ts # apps/sim/lib/credentials/application/list-credential-providers.test.ts # apps/sim/lib/credentials/application/list-credential-providers.ts # apps/sim/lib/credentials/application/operations.ts # apps/sim/lib/credentials/application/presentation.ts # apps/sim/lib/credentials/application/save-credential-draft.test.ts # apps/sim/lib/credentials/application/save-credential-draft.ts # apps/sim/lib/credentials/members.ts # apps/sim/lib/credentials/orchestration/credential-create.ts # apps/sim/lib/credentials/orchestration/index.ts # apps/sim/lib/credentials/queries.ts # scripts/check-api-validation-contracts.ts # scripts/check-tool-registry-boundary.baseline.json
2 parents d7ff17a + 6006870 commit 2db10c1

211 files changed

Lines changed: 36031 additions & 425 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/docs/content/docs/en/integrations/table.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ Tables are created from the **Tables** section in the sidebar. Each table requir
5454

5555
## Usage Instructions
5656

57-
Create and manage custom data tables. Store, query, and manipulate structured data within workflows.
57+
Create and manage custom data tables. Store, query, and manipulate structured data within workflows. Query Rows returns every matching row when Limit is omitted and fails if the result exceeds 5MB.
5858

5959

6060

@@ -213,7 +213,7 @@ Query rows from a table with filtering, sorting, and pagination
213213
| `tableId` | string | Yes | Table ID |
214214
| `filter` | object | No | Filter conditions \(MongoDB-style operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $contains, $ncontains, $startsWith, $endsWith, $empty\) |
215215
| `sort` | object | No | Sort order as \{field: "asc"\|"desc"\} |
216-
| `limit` | number | No | Maximum rows to return \(default: $\{TABLE_LIMITS.DEFAULT_QUERY_LIMIT\}, max: $\{TABLE_LIMITS.MAX_QUERY_LIMIT\}\) |
216+
| `limit` | number | No | Maximum rows to return. Omit to return every matching row; the query fails if the result exceeds the 5MB response budget. |
217217
| `offset` | number | No | Number of rows to skip \(default: 0\) |
218218

219219
#### Output

apps/docs/openapi-v2-resources.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1810,7 +1810,7 @@
18101810
"get": {
18111811
"operationId": "listCredentialProviders",
18121812
"summary": "List Credential Providers",
1813-
"description": "List catalogued OAuth and service-account connection methods and whether each is available to the caller in this workspace and deployment. OAuth authorization options contain the exact provider IDs accepted by the browser connection endpoint; service-account methods list the exact create-body fields and mark secret fields write-only. The bounded set is returned in one page; `nextCursor` is always null.",
1813+
"description": "List catalogued OAuth and service-account connection methods and whether each is available to the caller in this workspace and deployment. Optionally search provider names with a case-insensitive substring match. OAuth authorization options contain the exact provider IDs accepted by the browser connection endpoint; service-account methods list the exact create-body fields and mark secret fields write-only. The bounded set is returned in one page; `nextCursor` is always null.",
18141814
"tags": ["Credentials"],
18151815
"parameters": [
18161816
{
@@ -1824,6 +1824,18 @@
18241824
"maxLength": 128,
18251825
"description": "Workspace used to evaluate credential-provider availability and integration policy."
18261826
}
1827+
},
1828+
{
1829+
"name": "search",
1830+
"in": "query",
1831+
"required": false,
1832+
"description": "Case-insensitive substring match against the credential provider name.",
1833+
"schema": {
1834+
"description": "Case-insensitive substring match against the credential provider name.",
1835+
"type": "string",
1836+
"minLength": 1,
1837+
"maxLength": 200
1838+
}
18271839
}
18281840
],
18291841
"responses": {

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)