Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/sim/app/api/workflows/[id]/deployed/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const DEPLOYED_STATE = {
loops: {},
parallels: {},
variables: {},
deploymentVersionId: 'deployment-version-1',
}

const SESSION = {
Expand Down
28 changes: 17 additions & 11 deletions apps/sim/app/api/workflows/[id]/deployed/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,23 @@ export const GET = defineInternalJsonRoute({
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params }) => ({ workflowId: params.id, state: 'deployed' as const }),
useCase: readWorkflowDefinition,
present: ({ state }) => ({
deployedState: state
? deployedWorkflowStateSchema.parse({
blocks: state.blocks,
edges: state.edges,
loops: state.loops,
parallels: state.parallels,
variables: 'variables' in state ? (state.variables ?? {}) : {},
})
: null,
}),
present: ({ state }) => {
if (state && (!('deploymentVersionId' in state) || !state.deploymentVersionId)) {
throw new Error('Deployed workflow state is missing its deployment version')
}
return {
deployedState: state
? deployedWorkflowStateSchema.parse({
blocks: state.blocks,
edges: state.edges,
loops: state.loops,
parallels: state.parallels,
variables: 'variables' in state ? (state.variables ?? {}) : {},
deploymentVersionId: state.deploymentVersionId,
})
: null,
}
},
onSuccess: ({ input, result }) => {
if (!result.state) logger.warn('Workflow has no active deployed state', input)
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* @vitest-environment node
*/

import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
getSession: vi.fn(),
read: vi.fn(),
update: vi.fn(),
}))

vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession }))

vi.mock('@/lib/credential-groups/application/manage-access', () => ({
readCredentialGroupAccess: {
operation: { id: 'credential_groups.access.read' },
execute: mocks.read,
},
updateCredentialGroupAccess: {
operation: { id: 'credential_groups.access.update' },
execute: mocks.update,
},
}))

import { GET, PUT } from '@/app/api/workspaces/[id]/credential-groups/[groupId]/access/route'

const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111'
const GROUP_ID = 'group-1'
const url = `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups/${GROUP_ID}/access`
const context = { params: Promise.resolve({ id: WORKSPACE_ID, groupId: GROUP_ID }) }

describe('Credential Group access route', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getSession.mockResolvedValue({
user: { id: 'admin-1' },
session: { id: 'session-1' },
})
mocks.read.mockResolvedValue({ revision: 0, grants: [] })
mocks.update.mockResolvedValue({
revision: 1,
grants: [
{
id: 'grant-1',
subject: { type: 'workflow', workflowId: 'workflow-1' },
},
],
})
})

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

expect(response.status).toBe(200)
expect(await response.json()).toEqual({ revision: 0, grants: [] })
expect(mocks.read).toHaveBeenCalledWith({
principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' },
input: { assertedWorkspaceId: WORKSPACE_ID, credentialGroupId: GROUP_ID },
request,
})
})

it('updates exact managed subjects with optimistic revision input', async () => {
const body = {
expectedRevision: 0,
grants: [{ subject: { type: 'workflow', workflowId: 'workflow-1' } }],
}
const request = new NextRequest(url, {
method: 'PUT',
body: JSON.stringify(body),
headers: { 'content-type': 'application/json' },
})
const response = await PUT(request, context)

expect(response.status).toBe(200)
expect(mocks.update).toHaveBeenCalledWith({
principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' },
input: {
assertedWorkspaceId: WORKSPACE_ID,
credentialGroupId: GROUP_ID,
...body,
},
request,
})
})

it('authenticates before parsing a malformed policy body', async () => {
mocks.getSession.mockResolvedValue(null)
const request = new NextRequest(url, {
method: 'PUT',
body: '{',
headers: { 'content-type': 'application/json' },
})

const response = await PUT(request, context)

expect(response.status).toBe(401)
expect(mocks.update).not.toHaveBeenCalled()
})

it('rejects caller-supplied effects and actions at the HTTP boundary', async () => {
const request = new NextRequest(url, {
method: 'PUT',
body: JSON.stringify({
expectedRevision: 0,
grants: [
{
subject: { type: 'workflow', workflowId: 'workflow-1' },
effect: 'allow',
actions: ['credential_groups.credentials.use'],
},
],
}),
headers: { 'content-type': 'application/json' },
})

const response = await PUT(request, context)

expect(response.status).toBe(400)
expect(mocks.update).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
getCredentialGroupAccessContract,
updateCredentialGroupAccessContract,
} from '@/lib/api/contracts/credential-groups'
import {
defineInternalJsonRoute,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import {
readCredentialGroupAccess,
updateCredentialGroupAccess,
} from '@/lib/credential-groups/application/manage-access'
import { credentialGroupOperations } from '@/lib/credential-groups/application/operations'
import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy'

const rateLimit = internalRateLimits.none({
reason: 'Credential Group access changes are workspace-admin control-plane operations',
})

export const GET = defineInternalJsonRoute({
contract: getCredentialGroupAccessContract,
auth: internalSessionAuth,
operation: credentialGroupOperations.readAccess,
rateLimit,
errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to read Credential Group access'),
mapInput: ({ params }) => ({
assertedWorkspaceId: params.id,
credentialGroupId: params.groupId,
}),
useCase: readCredentialGroupAccess,
})

export const PUT = defineInternalJsonRoute({
contract: updateCredentialGroupAccessContract,
auth: internalSessionAuth,
operation: credentialGroupOperations.updateAccess,
rateLimit,
errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to update Credential Group access'),
mapInput: ({ params, body }) => ({
assertedWorkspaceId: params.id,
credentialGroupId: params.groupId,
expectedRevision: body.expectedRevision,
grants: body.grants,
}),
useCase: updateCredentialGroupAccess,
})
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ export const credentialGroupIdUrlKeys = {
/** Active view inside a credential-group detail page. */
export const credentialGroupTabParam = {
key: 'credential-group-tab',
parser: parseAsStringLiteral(['details', 'people'] as const).withDefault('details'),
parser: parseAsStringLiteral(['details', 'people', 'access'] as const).withDefault('details'),
} as const

/** Tab view-state: clean URLs, no back-stack churn. */
Expand Down
Loading
Loading