Skip to content

Commit c8576c6

Browse files
committed
fix: pre-landing review fixes
Specialist review of the deploy-permission change found six issues: - deployment-permission-matrix.test.ts was never staged. Mutation-proven: 12 of the 13 role changes had no guard without it. - performCreateWorkspaceApiKey's new 'forbidden' code was wired to 403 on the REST route but not on the copilot use case, so an authorization denial surfaced as an opaque 500. Confirmed independently by three reviewers. - The version-param coercion fix widened the union's z.input to unknown under Zod 4, letting a client pass anything at compile time — the exact drift the fix existed to prevent. Replaced with an explicit transform so the input stays number | string | 'active'. - Both chat authorization gates and the promote/undeploy tool routes had zero coverage of their required level; reverting them to admin passed the suite. Added assertions, each verified to fail under mutation. - Narrowed authorizeDeploymentWorkflow's action union, which retained an 'admin' arm with no callers. TODOS.md records three follow-ups, the notable one being that the v1 deployment surface does not honor the registry's workspaceApiKey: 'deny' — pre-existing, but this branch lowers the bar it sits behind.
1 parent e927607 commit c8576c6

7 files changed

Lines changed: 260 additions & 2 deletions

File tree

TODOS.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# TODOS
2+
3+
## API / Permissions
4+
5+
### Align the v1 deployment surface with the operation registry
6+
**Priority:** P1
7+
8+
`workflowOperations.deploy/undeploy/activateVersion/revertVersion` all declare
9+
`workspaceApiKey: 'deny'`, but `resolveV1DeploymentWorkflow`
10+
(`apps/sim/app/api/v1/workflows/utils.ts`) predates the registry and runs its own
11+
`validateWorkspaceAccess`, which resolves a workspace API key to its creator's
12+
permission. A workspace key therefore deploys through v1 regardless of the
13+
declared deny.
14+
15+
Pre-existing, not introduced by the deploy-requires-write branch — but that
16+
branch lowered the bar from `admin` to `write`, so a key created by an admin who
17+
was later demoted to editor now keeps working where it previously stopped.
18+
19+
Fixing it is a breaking change for anyone deploying via v1 with a workspace key,
20+
so it needs its own release note and deprecation window rather than riding along
21+
in a permissions PR.
22+
23+
Noted in: `apps/sim/lib/core/application/deployment-permission-matrix.test.ts`
24+
(scope note on the `workspaceApiKey` describe block).
25+
26+
### Route the deployment tool routes through the operation registry
27+
**Priority:** P3
28+
29+
`apps/sim/app/api/tools/deployments/{deploy,promote,undeploy}/route.ts` call
30+
`authorizeDeploymentWorkflow(..., 'write')` with a hardcoded role literal instead
31+
of consuming `workflowOperations.deploy.minimumRole`. A future change to the
32+
central `minimumRole` leaves these three routes at a stale value. They also sit
33+
outside the operation's `principalKinds` policy.
34+
35+
### Add `mship-tools:check` to CI
36+
**Priority:** P2
37+
38+
`apps/sim/lib/copilot/generated/tool-catalog-v1.ts` is generated from the
39+
mothership contract and enforced at runtime by
40+
`apps/sim/lib/copilot/tool-executor/executor.ts`. Nothing in CI verifies it is in
41+
sync, so a regeneration from a stale sibling checkout can silently revert tool
42+
permissions. The sim-side catalog currently carries schema bounds that
43+
mothership's committed contract does not, so regeneration is not byte-stable in
44+
either direction until the mothership side lands.
45+
46+
## Completed
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { workflowAuthzMockFns } from '@sim/testing'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { checkWorkflowAccessForChatCreation } from '@/app/api/chat/utils'
8+
9+
/**
10+
* Chat deployment dropped from `admin` to `write` alongside the rest of the
11+
* deployment lifecycle. These helpers call `authorizeWorkflowByWorkspacePermission`
12+
* directly rather than going through the operation registry, so the central
13+
* permission-matrix test does not cover them — every consumer suite mocks
14+
* `checkChatAccess` wholesale, which leaves the required level unobserved.
15+
* These assert the level itself, so reverting it to `admin` fails here.
16+
*/
17+
describe('chat deployment permission level', () => {
18+
const WORKFLOW = { id: 'wf-1', workspaceId: 'ws-1', name: 'Test Workflow' }
19+
20+
beforeEach(() => {
21+
vi.clearAllMocks()
22+
})
23+
24+
it('creating a chat deployment requires write, not admin', async () => {
25+
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
26+
allowed: true,
27+
status: 200,
28+
workflow: WORKFLOW,
29+
workspacePermission: 'write',
30+
})
31+
32+
const result = await checkWorkflowAccessForChatCreation('wf-1', 'user-1')
33+
34+
expect(result.hasAccess).toBe(true)
35+
expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({
36+
workflowId: 'wf-1',
37+
userId: 'user-1',
38+
action: 'write',
39+
})
40+
})
41+
42+
it('denies a member the authorizer rejects', async () => {
43+
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
44+
allowed: false,
45+
status: 403,
46+
message: 'Insufficient permissions',
47+
workflow: WORKFLOW,
48+
workspacePermission: 'read',
49+
})
50+
51+
const result = await checkWorkflowAccessForChatCreation('wf-1', 'user-1')
52+
53+
expect(result.hasAccess).toBe(false)
54+
expect(result.workflow).toBeUndefined()
55+
})
56+
57+
it('denies when the workflow does not resolve', async () => {
58+
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
59+
allowed: false,
60+
status: 404,
61+
message: 'Workflow not found',
62+
workflow: null,
63+
})
64+
65+
const result = await checkWorkflowAccessForChatCreation('wf-1', 'user-1')
66+
67+
expect(result.hasAccess).toBe(false)
68+
})
69+
})

apps/sim/app/api/tools/deployments/routes.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,28 @@ describe('POST /api/tools/deployments/undeploy', () => {
221221
expect(mockPerformFullUndeploy).not.toHaveBeenCalled()
222222
})
223223

224+
it('requires write permission on the workflow workspace', async () => {
225+
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
226+
allowed: false,
227+
status: 403,
228+
message: 'Insufficient permissions',
229+
workflow: WORKFLOW_RECORD,
230+
workspacePermission: 'read',
231+
})
232+
233+
const response = await undeployPost(
234+
makePost('undeploy', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1' })
235+
)
236+
237+
expect(response.status).toBe(403)
238+
expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({
239+
workflowId: WORKFLOW_ID,
240+
userId: 'user-1',
241+
action: 'write',
242+
})
243+
expect(mockPerformFullUndeploy).not.toHaveBeenCalled()
244+
})
245+
224246
it('undeploys a deployed workflow', async () => {
225247
const response = await undeployPost(
226248
makePost('undeploy', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1' })
@@ -254,6 +276,28 @@ describe('POST /api/tools/deployments/promote', () => {
254276
})
255277
})
256278

279+
it('requires write permission on the workflow workspace', async () => {
280+
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
281+
allowed: false,
282+
status: 403,
283+
message: 'Insufficient permissions',
284+
workflow: WORKFLOW_RECORD,
285+
workspacePermission: 'read',
286+
})
287+
288+
const response = await promotePost(
289+
makePost('promote', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1', version: 3 })
290+
)
291+
292+
expect(response.status).toBe(403)
293+
expect(workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({
294+
workflowId: WORKFLOW_ID,
295+
userId: 'user-1',
296+
action: 'write',
297+
})
298+
expect(mockPerformActivateVersion).not.toHaveBeenCalled()
299+
})
300+
257301
it('promotes the given version to live', async () => {
258302
const response = await promotePost(
259303
makePost('promote', { workflowId: WORKFLOW_ID, workspaceId: 'ws-1', version: 3 })

apps/sim/app/api/tools/deployments/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export async function authorizeDeploymentWorkflow(
5353
userId: string,
5454
workflowId: string,
5555
workspaceId: string,
56-
action: 'read' | 'write' | 'admin'
56+
action: 'read' | 'write'
5757
): Promise<
5858
{ ok: true; workflow: AuthorizedDeploymentWorkflow } | { ok: false; response: NextResponse }
5959
> {

apps/sim/lib/api-key/application/create-api-key.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ export const createCopilotWorkspaceApiKey = defineAuthorizedWorkspaceUseCase({
4040
if (result.errorCode === 'conflict') {
4141
throw new OrchestrationError('conflict', result.error ?? 'API key name already exists')
4242
}
43+
if (result.errorCode === 'forbidden') {
44+
throw new OrchestrationError(
45+
'forbidden',
46+
result.error ?? 'Admin permission is required to create a workspace API key'
47+
)
48+
}
4349
throw new Error('Failed to create workspace API key')
4450
}
4551
return { key: result.key, workspaceId: context.workspaceId }

apps/sim/lib/api/contracts/deployments.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,21 @@ export const deploymentVersionParamsSchema = z.object({
3131
* `z.number()` here makes every numeric version unreachable and leaves
3232
* `active` as the only satisfiable value. Coercion of `'active'` yields NaN,
3333
* fails `.int()`, and falls through to the literal branch, so ordering is safe.
34+
*
35+
* The numeric branch is `number | string` piped into the coercion rather than a
36+
* bare `z.coerce.number()`: a coerced member widens the union's `z.input` to
37+
* `unknown`, which would let a client pass anything at compile time and fail
38+
* only at runtime — the exact drift this schema exists to prevent.
3439
*/
3540
export const deploymentVersionOrActiveParamsSchema = z.object({
3641
id: z.string().min(1, 'Invalid workflow ID'),
37-
version: z.union([z.coerce.number().int().positive(), z.literal('active')]),
42+
version: z.union([
43+
z
44+
.union([z.number(), z.string()])
45+
.transform((value) => (typeof value === 'number' ? value : Number(value)))
46+
.pipe(z.number().int().positive()),
47+
z.literal('active'),
48+
]),
3849
})
3950

4051
export const deploymentVersionRouteParamsSchema = z.object({
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { apiKeyOperations } from '@/lib/api-key/application/operations'
6+
import { mcpServerOperations } from '@/lib/mcp/application/operations'
7+
import { workflowOperations } from '@/lib/workflows/application/operations'
8+
9+
/**
10+
* The deployment permission matrix, asserted in one place.
11+
*
12+
* `minimumRole` is declarative data, so a single-character edit silently widens
13+
* or narrows access with no failing test — the surrounding suites assert which
14+
* operation a handler invoked, not what role that operation demands. This pins
15+
* the boundary that matters: deploying a workflow needs `write`, while minting a
16+
* workspace API key (a credential that can invoke every deployed workflow in the
17+
* workspace) and installing a custom block (code that runs in every workflow)
18+
* stay `admin`.
19+
*/
20+
describe('deployment permission matrix', () => {
21+
describe('deployment lifecycle requires write', () => {
22+
it.each([
23+
['deploy', workflowOperations.deploy],
24+
['undeploy', workflowOperations.undeploy],
25+
['deployChat', workflowOperations.deployChat],
26+
['undeployChat', workflowOperations.undeployChat],
27+
['updatePublicApi', workflowOperations.updatePublicApi],
28+
['activateVersion', workflowOperations.activateVersion],
29+
['revertVersion', workflowOperations.revertVersion],
30+
['updateVersion', workflowOperations.updateVersion],
31+
])('workflowOperations.%s', (_name, operation) => {
32+
expect(operation.minimumRole).toBe('write')
33+
})
34+
35+
it.each([
36+
['createWorkflowServer', mcpServerOperations.createWorkflowDeploymentServer],
37+
['updateWorkflowServer', mcpServerOperations.updateWorkflowDeploymentServer],
38+
['deleteWorkflowServer', mcpServerOperations.deleteWorkflowDeploymentServer],
39+
['deployTool', mcpServerOperations.deployWorkflowTool],
40+
['undeployTool', mcpServerOperations.undeployWorkflowTool],
41+
])('mcpServerOperations.%s', (_name, operation) => {
42+
expect(operation.minimumRole).toBe('write')
43+
})
44+
})
45+
46+
describe('credential and code-injection surfaces stay admin', () => {
47+
it('minting a workspace API key requires admin', () => {
48+
expect(apiKeyOperations.createFromCopilot.minimumRole).toBe('admin')
49+
})
50+
51+
it('workflow policy (lock) requires admin', () => {
52+
expect(workflowOperations.updatePolicy.minimumRole).toBe('admin')
53+
})
54+
})
55+
56+
/**
57+
* Scope note: this asserts the operation *declarations*, which govern every
58+
* surface routed through the operation registry. The v1 REST API
59+
* (`resolveV1DeploymentWorkflow`) predates the registry and runs its own
60+
* `validateWorkspaceAccess`, resolving a workspace key to its creator — so a
61+
* workspace key can still deploy there. That gap is pre-existing and tracked
62+
* separately; do not read these assertions as covering v1.
63+
*/
64+
describe('deployment operations reject workspace API keys', () => {
65+
it.each([
66+
['deploy', workflowOperations.deploy],
67+
['undeploy', workflowOperations.undeploy],
68+
['updatePublicApi', workflowOperations.updatePublicApi],
69+
['activateVersion', workflowOperations.activateVersion],
70+
['revertVersion', workflowOperations.revertVersion],
71+
])(
72+
'workflowOperations.%s denies workspace_api_key so a long-lived key cannot deploy',
73+
(_name, operation) => {
74+
expect(operation.workspaceApiKey).toBe('deny')
75+
}
76+
)
77+
78+
it('creating an API key from copilot denies workspace API keys', () => {
79+
expect(apiKeyOperations.createFromCopilot.workspaceApiKey).toBe('deny')
80+
})
81+
})
82+
})

0 commit comments

Comments
 (0)