Skip to content

Commit 9995853

Browse files
feat(credential-groups): add resource access policies
1 parent 43e2364 commit 9995853

44 files changed

Lines changed: 22292 additions & 97 deletions

Some content is hidden

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

apps/sim/app/api/workflows/[id]/deployed/route.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const DEPLOYED_STATE = {
4949
loops: {},
5050
parallels: {},
5151
variables: {},
52+
deploymentVersionId: 'deployment-version-1',
5253
}
5354

5455
const SESSION = {

apps/sim/app/api/workflows/[id]/deployed/route.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,23 @@ export const GET = defineInternalJsonRoute({
2626
errorPolicy: internalOrchestrationErrorPolicy,
2727
mapInput: ({ params }) => ({ workflowId: params.id, state: 'deployed' as const }),
2828
useCase: readWorkflowDefinition,
29-
present: ({ state }) => ({
30-
deployedState: state
31-
? deployedWorkflowStateSchema.parse({
32-
blocks: state.blocks,
33-
edges: state.edges,
34-
loops: state.loops,
35-
parallels: state.parallels,
36-
variables: 'variables' in state ? (state.variables ?? {}) : {},
37-
})
38-
: null,
39-
}),
29+
present: ({ state }) => {
30+
if (state && (!('deploymentVersionId' in state) || !state.deploymentVersionId)) {
31+
throw new Error('Deployed workflow state is missing its deployment version')
32+
}
33+
return {
34+
deployedState: state
35+
? deployedWorkflowStateSchema.parse({
36+
blocks: state.blocks,
37+
edges: state.edges,
38+
loops: state.loops,
39+
parallels: state.parallels,
40+
variables: 'variables' in state ? (state.variables ?? {}) : {},
41+
deploymentVersionId: state.deploymentVersionId,
42+
})
43+
: null,
44+
}
45+
},
4046
onSuccess: ({ input, result }) => {
4147
if (!result.state) logger.warn('Workflow has no active deployed state', input)
4248
},
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const mocks = vi.hoisted(() => ({
9+
getSession: vi.fn(),
10+
read: vi.fn(),
11+
update: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession }))
15+
16+
vi.mock('@/lib/credential-groups/application/manage-access', () => ({
17+
readCredentialGroupAccess: {
18+
operation: { id: 'credential_groups.access.read' },
19+
execute: mocks.read,
20+
},
21+
updateCredentialGroupAccess: {
22+
operation: { id: 'credential_groups.access.update' },
23+
execute: mocks.update,
24+
},
25+
}))
26+
27+
import { GET, PUT } from '@/app/api/workspaces/[id]/credential-groups/[groupId]/access/route'
28+
29+
const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
30+
const GROUP_ID = 'group-1'
31+
const url = `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups/${GROUP_ID}/access`
32+
const context = { params: Promise.resolve({ id: WORKSPACE_ID, groupId: GROUP_ID }) }
33+
34+
describe('Credential Group access route', () => {
35+
beforeEach(() => {
36+
vi.clearAllMocks()
37+
mocks.getSession.mockResolvedValue({
38+
user: { id: 'admin-1' },
39+
session: { id: 'session-1' },
40+
})
41+
mocks.read.mockResolvedValue({ revision: 0, grants: [] })
42+
mocks.update.mockResolvedValue({
43+
revision: 1,
44+
grants: [
45+
{
46+
id: 'grant-1',
47+
subject: { type: 'workflow', workflowId: 'workflow-1' },
48+
},
49+
],
50+
})
51+
})
52+
53+
it('reads the managed policy without exposing the built-in actor rule', async () => {
54+
const request = new NextRequest(url)
55+
const response = await GET(request, context)
56+
57+
expect(response.status).toBe(200)
58+
expect(await response.json()).toEqual({ revision: 0, grants: [] })
59+
expect(mocks.read).toHaveBeenCalledWith({
60+
principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' },
61+
input: { assertedWorkspaceId: WORKSPACE_ID, credentialGroupId: GROUP_ID },
62+
request,
63+
})
64+
})
65+
66+
it('updates exact managed subjects with optimistic revision input', async () => {
67+
const body = {
68+
expectedRevision: 0,
69+
grants: [{ subject: { type: 'workflow', workflowId: 'workflow-1' } }],
70+
}
71+
const request = new NextRequest(url, {
72+
method: 'PUT',
73+
body: JSON.stringify(body),
74+
headers: { 'content-type': 'application/json' },
75+
})
76+
const response = await PUT(request, context)
77+
78+
expect(response.status).toBe(200)
79+
expect(mocks.update).toHaveBeenCalledWith({
80+
principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' },
81+
input: {
82+
assertedWorkspaceId: WORKSPACE_ID,
83+
credentialGroupId: GROUP_ID,
84+
...body,
85+
},
86+
request,
87+
})
88+
})
89+
90+
it('authenticates before parsing a malformed policy body', async () => {
91+
mocks.getSession.mockResolvedValue(null)
92+
const request = new NextRequest(url, {
93+
method: 'PUT',
94+
body: '{',
95+
headers: { 'content-type': 'application/json' },
96+
})
97+
98+
const response = await PUT(request, context)
99+
100+
expect(response.status).toBe(401)
101+
expect(mocks.update).not.toHaveBeenCalled()
102+
})
103+
104+
it('rejects caller-supplied effects and actions at the HTTP boundary', async () => {
105+
const request = new NextRequest(url, {
106+
method: 'PUT',
107+
body: JSON.stringify({
108+
expectedRevision: 0,
109+
grants: [
110+
{
111+
subject: { type: 'workflow', workflowId: 'workflow-1' },
112+
effect: 'allow',
113+
actions: ['credential_groups.credentials.use'],
114+
},
115+
],
116+
}),
117+
headers: { 'content-type': 'application/json' },
118+
})
119+
120+
const response = await PUT(request, context)
121+
122+
expect(response.status).toBe(400)
123+
expect(mocks.update).not.toHaveBeenCalled()
124+
})
125+
})
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import {
2+
getCredentialGroupAccessContract,
3+
updateCredentialGroupAccessContract,
4+
} from '@/lib/api/contracts/credential-groups'
5+
import {
6+
defineInternalJsonRoute,
7+
internalRateLimits,
8+
internalSessionAuth,
9+
} from '@/lib/api/server/routes'
10+
import {
11+
readCredentialGroupAccess,
12+
updateCredentialGroupAccess,
13+
} from '@/lib/credential-groups/application/manage-access'
14+
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
15+
import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'
16+
17+
const rateLimit = internalRateLimits.none({
18+
reason: 'Credential Group access changes are workspace-admin control-plane operations',
19+
})
20+
21+
export const GET = defineInternalJsonRoute({
22+
contract: getCredentialGroupAccessContract,
23+
auth: internalSessionAuth,
24+
operation: credentialGroupOperations.readAccess,
25+
rateLimit,
26+
errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to read Credential Group access'),
27+
mapInput: ({ params }) => ({
28+
assertedWorkspaceId: params.id,
29+
credentialGroupId: params.groupId,
30+
}),
31+
useCase: readCredentialGroupAccess,
32+
})
33+
34+
export const PUT = defineInternalJsonRoute({
35+
contract: updateCredentialGroupAccessContract,
36+
auth: internalSessionAuth,
37+
operation: credentialGroupOperations.updateAccess,
38+
rateLimit,
39+
errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to update Credential Group access'),
40+
mapInput: ({ params, body }) => ({
41+
assertedWorkspaceId: params.id,
42+
credentialGroupId: params.groupId,
43+
expectedRevision: body.expectedRevision,
44+
grants: body.grants,
45+
}),
46+
useCase: updateCredentialGroupAccess,
47+
})

apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export const credentialGroupIdUrlKeys = {
9595
/** Active view inside a credential-group detail page. */
9696
export const credentialGroupTabParam = {
9797
key: 'credential-group-tab',
98-
parser: parseAsStringLiteral(['details', 'people'] as const).withDefault('details'),
98+
parser: parseAsStringLiteral(['details', 'people', 'access'] as const).withDefault('details'),
9999
} as const
100100

101101
/** Tab view-state: clean URLs, no back-stack churn. */

0 commit comments

Comments
 (0)