Skip to content

Commit 5b384d7

Browse files
committed
Merge branch 'pgx/p1' into feat/permission-groups-coverage
2 parents a51592a + eb5a07e commit 5b384d7

12 files changed

Lines changed: 848 additions & 6 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* The internal run-detail door. `logs.cost` and `logs.trace_spans` withhold
5+
* fields inside a run, and the shared read applies them — but only for the
6+
* subject this route names. `auth.userId` is populated for every credential the
7+
* route accepts, so naming it unconditionally would apply a workspace key
8+
* creator's group to every caller of a shared credential, and the executor's
9+
* actor's group to a delegation that carries no capabilities at all.
10+
*/
11+
import { NextRequest } from 'next/server'
12+
import { beforeEach, describe, expect, it, vi } from 'vitest'
13+
14+
const { mockValidateWorkflowAccess, mockGetStatus } = vi.hoisted(() => ({
15+
mockValidateWorkflowAccess: vi.fn(),
16+
mockGetStatus: vi.fn(),
17+
}))
18+
19+
vi.mock('@/app/api/workflows/middleware', () => ({
20+
validateWorkflowAccess: mockValidateWorkflowAccess,
21+
}))
22+
23+
vi.mock('@/lib/workflows/executor/execution-status', () => ({
24+
getWorkflowExecutionStatus: mockGetStatus,
25+
}))
26+
27+
import { GET } from './route'
28+
29+
const WORKFLOW_ID = 'b1f0c7e2-0000-4000-8000-00000000000a'
30+
const EXECUTION_ID = 'b1f0c7e2-0000-4000-8000-00000000000b'
31+
32+
function request() {
33+
return new NextRequest(`https://sim.test/api/workflows/${WORKFLOW_ID}/executions/${EXECUTION_ID}`)
34+
}
35+
36+
function context() {
37+
return { params: Promise.resolve({ id: WORKFLOW_ID, executionId: EXECUTION_ID }) }
38+
}
39+
40+
function grantAccess(auth: Record<string, unknown>) {
41+
mockValidateWorkflowAccess.mockResolvedValue({
42+
workflow: { id: WORKFLOW_ID, workspaceId: 'workspace-1' },
43+
auth,
44+
})
45+
}
46+
47+
describe('internal execution status route projection subject', () => {
48+
beforeEach(() => {
49+
vi.clearAllMocks()
50+
mockGetStatus.mockResolvedValue({
51+
executionId: EXECUTION_ID,
52+
workflowId: WORKFLOW_ID,
53+
status: 'completed',
54+
trigger: 'api',
55+
level: 'info',
56+
startedAt: '2026-08-05T12:00:00.000Z',
57+
endedAt: null,
58+
totalDurationMs: null,
59+
paused: null,
60+
cost: null,
61+
error: null,
62+
finalOutput: null,
63+
blockOutputs: null,
64+
})
65+
})
66+
67+
it('names the session user as the projection subject', async () => {
68+
grantAccess({ success: true, userId: 'user-1', authType: 'session' })
69+
70+
const response = await GET(request(), context())
71+
72+
expect(response.status).toBe(200)
73+
expect(mockGetStatus).toHaveBeenCalledWith(
74+
expect.objectContaining({ workspaceId: 'workspace-1', viewerUserId: 'user-1' })
75+
)
76+
})
77+
78+
it('names the personal API key owner as the projection subject', async () => {
79+
grantAccess({ success: true, userId: 'user-1', authType: 'api_key', apiKeyType: 'personal' })
80+
81+
await GET(request(), context())
82+
83+
expect(mockGetStatus).toHaveBeenCalledWith(expect.objectContaining({ viewerUserId: 'user-1' }))
84+
})
85+
86+
it('names no subject for a workspace API key', async () => {
87+
grantAccess({
88+
success: true,
89+
userId: 'key-creator-1',
90+
authType: 'api_key',
91+
apiKeyType: 'workspace',
92+
})
93+
94+
await GET(request(), context())
95+
96+
expect(mockGetStatus).toHaveBeenCalledWith(expect.objectContaining({ viewerUserId: null }))
97+
})
98+
99+
it('names no subject for an executor delegation', async () => {
100+
grantAccess({ success: true, userId: 'run-actor-1', authType: 'internal_jwt' })
101+
102+
await GET(request(), context())
103+
104+
expect(mockGetStatus).toHaveBeenCalledWith(expect.objectContaining({ viewerUserId: null }))
105+
})
106+
})

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { type NextRequest, NextResponse } from 'next/server'
33
import { getWorkflowExecutionContract } from '@/lib/api/contracts/workflows'
44
import { parseRequest } from '@/lib/api/server'
5+
import { type AuthResult, AuthType } from '@/lib/auth/hybrid'
56
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
67
import {
78
FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE,
@@ -11,6 +12,22 @@ import { getWorkflowExecutionStatus } from '@/lib/workflows/executor/execution-s
1112
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
1213

1314
const logger = createLogger('WorkflowExecutionStatusAPI')
15+
16+
/**
17+
* The user whose permission group governs this read, or `null` when none does.
18+
*
19+
* `auth.userId` is populated for every credential this route accepts, and for a
20+
* workspace API key it is the key's *creator* — a bystander who may not be the
21+
* caller — while an internal JWT is the executor, which carries a role but no
22+
* capabilities. Keying on the presence of a user id would apply a group to both.
23+
* `authType` and `apiKeyType` are the authoritative signals, the same pair
24+
* `capabilityGovernedUserId` reads on the v1 surface.
25+
*/
26+
function capabilityGovernedUserId(auth: AuthResult | undefined): string | null {
27+
if (!auth?.userId) return null
28+
if (auth.authType === AuthType.SESSION) return auth.userId
29+
return auth.authType === AuthType.API_KEY && auth.apiKeyType === 'personal' ? auth.userId : null
30+
}
1431
export const GET = withRouteHandler(
1532
async (
1633
request: NextRequest,
@@ -33,6 +50,8 @@ export const GET = withRouteHandler(
3350
executionId,
3451
includeOutput,
3552
selectedOutputs,
53+
workspaceId: access.workflow.workspaceId,
54+
viewerUserId: capabilityGovernedUserId(access.auth),
3655
})
3756
} catch (error) {
3857
if (error instanceof FunctionalOutputsUnavailableError) {
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Copilot's `@log` mention context, projected for the chatting user.
5+
*
6+
* `logs.cost` and `logs.trace_spans` are PROJECTIONS, not gates, and Copilot is
7+
* deliberately not exempt from them: it acts as the person, so a run inlined as
8+
* mention context must be withheld exactly as the person's own log surfaces
9+
* withhold it. This path resolved the run row directly with a role-only
10+
* authorization and inlined the run total, every span's own cost, and the whole
11+
* block overview — so a member withheld all three on `/api/logs/**` read them by
12+
* typing `@` in chat.
13+
*
14+
* Kept in its own file because it mocks `config-scope.server`, which the sibling
15+
* `process-contents.test.ts` deliberately leaves real so its integration-allowlist
16+
* tests exercise `getUserPermissionConfig`.
17+
*/
18+
import {
19+
dbChainMockFns,
20+
permissionGroupScopeMock,
21+
permissionGroupScopeMockFns,
22+
resetPermissionGroupScopeMock,
23+
workflowAuthzMockFns,
24+
} from '@sim/testing'
25+
import { beforeEach, describe, expect, it, vi } from 'vitest'
26+
import type { ChatContext } from '@/stores/panel'
27+
28+
vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock)
29+
30+
import { processContextsServer } from '@/lib/copilot/chat/process-contents'
31+
import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields'
32+
33+
function queueRun(): void {
34+
dbChainMockFns.limit.mockResolvedValueOnce([
35+
{
36+
id: 'log-1',
37+
workflowId: 'wf-1',
38+
workspaceId: 'ws-1',
39+
executionId: 'exec-1',
40+
level: 'error',
41+
trigger: 'manual',
42+
startedAt: new Date('2026-01-01T00:00:00.000Z'),
43+
endedAt: new Date('2026-01-01T00:00:01.000Z'),
44+
totalDurationMs: 1000,
45+
executionData: {
46+
traceSpans: [
47+
{
48+
id: 'span-1',
49+
blockId: 'block-1',
50+
name: 'Agent 1',
51+
type: 'agent',
52+
status: 'failed',
53+
duration: 500,
54+
cost: { total: 0.04 },
55+
children: [
56+
{ id: 'span-2', name: 'tool', type: 'tool', duration: 10, cost: { total: 0.01 } },
57+
],
58+
},
59+
],
60+
},
61+
costTotal: '0.05',
62+
workflowName: 'My Flow',
63+
},
64+
])
65+
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
66+
allowed: true,
67+
workflow: { workspaceId: 'ws-1' },
68+
})
69+
}
70+
71+
async function mentionSummary(userId?: string) {
72+
const result = await processContextsServer(
73+
[{ kind: 'logs', executionId: 'exec-1', label: 'My Flow' } as ChatContext],
74+
userId as string,
75+
'hello',
76+
'ws-1'
77+
)
78+
expect(result).toHaveLength(1)
79+
return JSON.parse(result[0].content)
80+
}
81+
82+
describe('@log mention context projection', () => {
83+
beforeEach(() => {
84+
vi.clearAllMocks()
85+
resetPermissionGroupScopeMock()
86+
})
87+
88+
it('inlines spend whole for a member no group governs', async () => {
89+
queueRun()
90+
91+
const summary = await mentionSummary('user-1')
92+
93+
expect(summary.cost).toEqual({ total: 0.05 })
94+
expect(summary.overview[0].cost).toEqual({ total: 0.04 })
95+
expect(summary.overview[0].children[0].cost).toEqual({ total: 0.01 })
96+
})
97+
98+
it('withholds the run total and every span cost when the group hides spend', async () => {
99+
permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({
100+
...DEFAULT_PERMISSION_GROUP_CONFIG,
101+
hideCostInfo: true,
102+
})
103+
queueRun()
104+
105+
const summary = await mentionSummary('user-1')
106+
107+
expect(summary.cost).toBeUndefined()
108+
expect(summary.overview).toHaveLength(1)
109+
expect(summary.overview[0].name).toBe('Agent 1')
110+
expect(summary.overview[0].cost).toBeUndefined()
111+
expect(summary.overview[0].children[0].cost).toBeUndefined()
112+
})
113+
114+
/**
115+
* The overview is derived from `traceSpans`, which is on the withheld list the
116+
* log-detail path strips outright — so it is withheld entirely rather than
117+
* merely thinned. The run's identity, level and timings stay: these are
118+
* projections, not gates.
119+
*/
120+
it('withholds the whole block overview when the group hides trace spans', async () => {
121+
permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({
122+
...DEFAULT_PERMISSION_GROUP_CONFIG,
123+
hideTraceSpans: true,
124+
})
125+
queueRun()
126+
127+
const summary = await mentionSummary('user-1')
128+
129+
expect(summary.overview).toBeUndefined()
130+
expect(summary.cost).toEqual({ total: 0.05 })
131+
expect(summary.executionId).toBe('exec-1')
132+
expect(JSON.stringify(summary)).not.toContain('Agent 1')
133+
})
134+
135+
/** No subject, no group — never a bystander's. */
136+
it('resolves no group when the mention carries no subject', async () => {
137+
permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue({
138+
...DEFAULT_PERMISSION_GROUP_CONFIG,
139+
hideCostInfo: true,
140+
hideTraceSpans: true,
141+
})
142+
queueRun()
143+
144+
const summary = await mentionSummary(undefined)
145+
146+
expect(summary.cost).toEqual({ total: 0.05 })
147+
expect(summary.overview).toHaveLength(1)
148+
expect(permissionGroupScopeMockFns.mockResolvePermissionGroupConfig).not.toHaveBeenCalled()
149+
})
150+
})

apps/sim/lib/copilot/chat/process-contents.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilit
3232
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags'
3333
import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server'
3434
import { readKnowledgeBase } from '@/lib/knowledge/application/knowledge-bases'
35+
import {
36+
projectCostTotal,
37+
projectExecutionData,
38+
resolveLogFieldProjection,
39+
} from '@/lib/logs/log-projection'
3540
import { toOverview } from '@/lib/logs/log-views'
3641
import type { TraceSpan } from '@/lib/logs/types'
3742
import { mcpService } from '@/lib/mcp/service'
@@ -753,11 +758,35 @@ async function processExecutionLogFromDb(
753758
}
754759
}
755760

761+
/**
762+
* Copilot is deliberately not exempt: it acts as the person, so the run it
763+
* inlines is withheld exactly as the person's own log surfaces withhold it.
764+
* `userId` here is the chatting user — both callers of
765+
* `processContextsServer` pass the request's authenticated subject, and this
766+
* is session context rather than an executor delegation — so it is the right
767+
* subject for the projection, and an absent one reads whole.
768+
*
769+
* `logs.trace_spans` withholds the overview entirely rather than merely
770+
* thinning it: the tree is derived from `traceSpans`, which is on the
771+
* withheld list that the log-detail route strips outright.
772+
* `logs.cost` blanks the run total AND every span's own `cost`, through the
773+
* shared projector — a viewer who can sum the spans has been withheld
774+
* nothing.
775+
*
776+
* permission-group-enforced: logs.trace_spans
777+
* permission-group-enforced: logs.cost
778+
*/
779+
const projection = await resolveLogFieldProjection(userId, log.workspaceId)
780+
756781
const { materializeExecutionData } = await import('@/lib/logs/execution/trace-store')
757-
const executionData = (await materializeExecutionData(
782+
const materialized = (await materializeExecutionData(
758783
log.executionData as Record<string, unknown> | null,
759784
{ workspaceId: log.workspaceId, workflowId: log.workflowId, executionId: log.executionId }
760-
)) as { traceSpans?: TraceSpan[] } | undefined
785+
)) as Record<string, unknown> | null | undefined
786+
const executionData = projectExecutionData(materialized ?? null, projection) as
787+
| { traceSpans?: TraceSpan[] }
788+
| null
789+
| undefined
761790
const overview = executionData?.traceSpans?.length
762791
? toOverview(executionData.traceSpans)
763792
: undefined
@@ -772,7 +801,7 @@ async function processExecutionLogFromDb(
772801
endedAt: log.endedAt?.toISOString?.() || (log.endedAt ? String(log.endedAt) : null),
773802
totalDurationMs: log.totalDurationMs ?? null,
774803
workflowName: log.workflowName || '',
775-
cost: log.costTotal != null ? { total: Number(log.costTotal) } : undefined,
804+
cost: projectCostTotal(log.costTotal, projection) ?? undefined,
776805
overview,
777806
note: `For a block's input/output/error, or to grep the trace, call ${QueryLogs.id} with executionId: '${log.executionId}' — view: 'full' (scope with blockId or blockName), or pattern to grep.`,
778807
}

0 commit comments

Comments
 (0)