Skip to content

Commit a01a557

Browse files
feat(resource-policies): add statement evaluator
1 parent 4d5bb9c commit a01a557

39 files changed

Lines changed: 23839 additions & 1125 deletions

apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/access/route.test.ts

Lines changed: 47 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,19 @@ const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
3030
const GROUP_ID = 'group-1'
3131
const url = `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups/${GROUP_ID}/access`
3232
const context = { params: Promise.resolve({ id: WORKSPACE_ID, groupId: GROUP_ID }) }
33+
const document = {
34+
version: 1,
35+
resource: { type: 'credential_group', id: GROUP_ID },
36+
statements: [
37+
{
38+
sid: 'WorkflowAccess',
39+
effect: 'allow',
40+
actions: ['credential_groups.credentials.use'],
41+
principals: [{ type: 'workflow', workflowId: 'workflow-1' }],
42+
condition: { StringEquals: { 'sim:WorkflowMode': 'deployment' } },
43+
},
44+
],
45+
}
3346

3447
describe('Credential Group access route', () => {
3548
beforeEach(() => {
@@ -38,36 +51,25 @@ describe('Credential Group access route', () => {
3851
user: { id: 'admin-1' },
3952
session: { id: 'session-1' },
4053
})
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-
})
54+
mocks.read.mockResolvedValue({ revision: 1, document })
55+
mocks.update.mockResolvedValue({ revision: 2, document })
5156
})
5257

53-
it('reads the managed policy without exposing the built-in actor rule', async () => {
58+
it('reads the exact managed policy without exposing the built-in actor rule', async () => {
5459
const request = new NextRequest(url)
5560
const response = await GET(request, context)
5661

5762
expect(response.status).toBe(200)
58-
expect(await response.json()).toEqual({ revision: 0, grants: [] })
63+
expect(await response.json()).toEqual({ revision: 1, document })
5964
expect(mocks.read).toHaveBeenCalledWith({
6065
principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' },
6166
input: { assertedWorkspaceId: WORKSPACE_ID, credentialGroupId: GROUP_ID },
6267
request,
6368
})
6469
})
6570

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+
it('updates the full allow/deny policy document with optimistic revision input', async () => {
72+
const body = { expectedRevision: 1, document }
7173
const request = new NextRequest(url, {
7274
method: 'PUT',
7375
body: JSON.stringify(body),
@@ -101,18 +103,20 @@ describe('Credential Group access route', () => {
101103
expect(mocks.update).not.toHaveBeenCalled()
102104
})
103105

104-
it('rejects caller-supplied effects and actions at the HTTP boundary', async () => {
106+
it('rejects recursive conditions at the HTTP boundary', async () => {
105107
const request = new NextRequest(url, {
106108
method: 'PUT',
107109
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-
],
110+
expectedRevision: 1,
111+
document: {
112+
...document,
113+
statements: [
114+
{
115+
...document.statements[0],
116+
condition: { all: [{ StringEquals: { 'sim:WorkflowMode': 'deployment' } }] },
117+
},
118+
],
119+
},
116120
}),
117121
headers: { 'content-type': 'application/json' },
118122
})
@@ -122,4 +126,21 @@ describe('Credential Group access route', () => {
122126
expect(response.status).toBe(400)
123127
expect(mocks.update).not.toHaveBeenCalled()
124128
})
129+
130+
it('rejects an oversized policy before parsing it', async () => {
131+
const body = JSON.stringify({ expectedRevision: 1, document, padding: 'x'.repeat(300_000) })
132+
const request = new NextRequest(url, {
133+
method: 'PUT',
134+
body,
135+
headers: {
136+
'content-length': String(Buffer.byteLength(body)),
137+
'content-type': 'application/json',
138+
},
139+
})
140+
141+
const response = await PUT(request, context)
142+
143+
expect(response.status).toBe(413)
144+
expect(mocks.update).not.toHaveBeenCalled()
145+
})
125146
})

apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/access/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[
1717
const rateLimit = internalRateLimits.none({
1818
reason: 'Credential Group access changes are workspace-admin control-plane operations',
1919
})
20+
const MAX_RESOURCE_POLICY_BODY_BYTES = 256 * 1024
2021

2122
export const GET = defineInternalJsonRoute({
2223
contract: getCredentialGroupAccessContract,
@@ -37,11 +38,12 @@ export const PUT = defineInternalJsonRoute({
3738
operation: credentialGroupOperations.updateAccess,
3839
rateLimit,
3940
errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to update Credential Group access'),
41+
parseOptions: { maxBodyBytes: MAX_RESOURCE_POLICY_BODY_BYTES },
4042
mapInput: ({ params, body }) => ({
4143
assertedWorkspaceId: params.id,
4244
credentialGroupId: params.groupId,
4345
expectedRevision: body.expectedRevision,
44-
grants: body.grants,
46+
document: body.document,
4547
}),
4648
useCase: updateCredentialGroupAccess,
4749
})
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
import type { ResourcePolicyDocument } from '@/lib/resource-policies/types'
8+
9+
const mocks = vi.hoisted(() => ({
10+
mutationError: null as Error | null,
11+
mutateAsync: vi.fn(),
12+
reset: vi.fn(),
13+
toastError: vi.fn(),
14+
toastSuccess: vi.fn(),
15+
useAccess: vi.fn(),
16+
}))
17+
18+
vi.mock('@sim/emcn', () => ({
19+
ChipTextarea: () => null,
20+
Info: () => null,
21+
Label: () => null,
22+
toast: { error: mocks.toastError, success: mocks.toastSuccess },
23+
}))
24+
25+
vi.mock('@/hooks/queries/credential-groups', () => ({
26+
useCredentialGroupAccess: mocks.useAccess,
27+
useUpdateCredentialGroupAccess: () => ({
28+
error: mocks.mutationError,
29+
isPending: false,
30+
mutateAsync: mocks.mutateAsync,
31+
reset: mocks.reset,
32+
}),
33+
}))
34+
35+
import { useCredentialGroupAccessEditor } from '@/ee/credential-groups/components/credential-group-access'
36+
37+
const GROUP_ID = 'group-1'
38+
const EMPTY_DOCUMENT: ResourcePolicyDocument = {
39+
version: 1,
40+
resource: { type: 'credential_group', id: GROUP_ID },
41+
statements: [],
42+
}
43+
const EDITED_DOCUMENT: ResourcePolicyDocument = {
44+
...EMPTY_DOCUMENT,
45+
statements: [
46+
{
47+
sid: 'SupportWorkflow',
48+
effect: 'allow',
49+
actions: ['credential_groups.credentials.use'],
50+
principals: [{ type: 'workflow', workflowId: 'workflow-1' }],
51+
},
52+
],
53+
}
54+
55+
const mountedRoots: Root[] = []
56+
57+
function renderEditor() {
58+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
59+
const container = document.createElement('div')
60+
const root = createRoot(container)
61+
mountedRoots.push(root)
62+
let result: ReturnType<typeof useCredentialGroupAccessEditor> | undefined
63+
64+
function Probe() {
65+
result = useCredentialGroupAccessEditor({ workspaceId: 'workspace-1', groupId: GROUP_ID })
66+
return null
67+
}
68+
69+
const render = () => {
70+
act(() => root.render(<Probe />))
71+
}
72+
render()
73+
74+
return {
75+
getResult: () => {
76+
if (!result) throw new Error('Editor hook did not render')
77+
return result
78+
},
79+
rerender: render,
80+
}
81+
}
82+
83+
beforeEach(() => {
84+
vi.clearAllMocks()
85+
mocks.mutationError = null
86+
mocks.reset.mockImplementation(() => {
87+
mocks.mutationError = null
88+
})
89+
mocks.useAccess.mockReturnValue({
90+
data: { revision: 3, document: EMPTY_DOCUMENT },
91+
error: null,
92+
isPending: false,
93+
})
94+
mocks.mutateAsync.mockResolvedValue({ revision: 4, document: EDITED_DOCUMENT })
95+
})
96+
97+
afterEach(() => {
98+
act(() => {
99+
for (const root of mountedRoots.splice(0)) root.unmount()
100+
})
101+
})
102+
103+
describe('Credential Group access editor', () => {
104+
it('pretty-prints the policy and rejects invalid JSON before mutation', async () => {
105+
const editor = renderEditor()
106+
107+
expect(editor.getResult().value).toBe(JSON.stringify(EMPTY_DOCUMENT, null, 2))
108+
expect(editor.getResult().dirty).toBe(false)
109+
110+
act(() => editor.getResult().setValue('{'))
111+
112+
expect(editor.getResult().dirty).toBe(true)
113+
expect(editor.getResult().validationError).toBe('Policy must be valid JSON')
114+
await act(async () => editor.getResult().save())
115+
expect(mocks.mutateAsync).not.toHaveBeenCalled()
116+
})
117+
118+
it('pins the revision and preserves the draft when a concurrent update conflicts', async () => {
119+
const editor = renderEditor()
120+
const editedValue = JSON.stringify(EDITED_DOCUMENT, null, 2)
121+
act(() => editor.getResult().setValue(editedValue))
122+
123+
mocks.useAccess.mockReturnValue({
124+
data: { revision: 4, document: EMPTY_DOCUMENT },
125+
error: null,
126+
isPending: false,
127+
})
128+
const conflict = new Error('Resource policy changed while being edited')
129+
mocks.mutateAsync.mockImplementation(async () => {
130+
mocks.mutationError = conflict
131+
throw conflict
132+
})
133+
editor.rerender()
134+
135+
await act(async () => editor.getResult().save())
136+
editor.rerender()
137+
138+
expect(mocks.mutateAsync).toHaveBeenCalledWith({
139+
workspaceId: 'workspace-1',
140+
groupId: GROUP_ID,
141+
body: { expectedRevision: 3, document: EDITED_DOCUMENT },
142+
})
143+
expect(editor.getResult().value).toBe(editedValue)
144+
expect(editor.getResult().dirty).toBe(true)
145+
expect(editor.getResult().error).toBe('Resource policy changed while being edited')
146+
})
147+
148+
it('discards the local draft back to the query document', () => {
149+
const editor = renderEditor()
150+
act(() => editor.getResult().setValue(JSON.stringify(EDITED_DOCUMENT, null, 2)))
151+
152+
act(() => editor.getResult().discard())
153+
154+
expect(editor.getResult().dirty).toBe(false)
155+
expect(editor.getResult().value).toBe(JSON.stringify(EMPTY_DOCUMENT, null, 2))
156+
})
157+
})

0 commit comments

Comments
 (0)