Skip to content

Commit de7e736

Browse files
committed
improvement(api): release audit follow-ups for v2 member analytics, workflow access middleware and docs
1 parent 03bf275 commit de7e736

8 files changed

Lines changed: 65 additions & 106 deletions

File tree

‎apps/docs/content/docs/search/gitlab.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ To rotate the PAT, supply its replacement in source settings. A blank token reta
9191

9292
After an admin configures a source in **Non-admin token** mode, a job can [read its revision](/api-reference/organizations/getSearchSourcePermissions) and [replace both CSVs](/api-reference/organizations/replaceSearchSourcePermissions). Copy `SIM_ORG_ID` and `SIM_SOURCE_ID` from the source settings URL: `/o/<organizationId>/settings/integrations/sources/<sourceId>`. Set `SIM_BASE_URL` to your Sim origin (for hosted Sim, `https://www.sim.ai`).
9393

94-
Store an organization admin's [personal Sim API key](/api-reference/authentication) as a [masked, protected GitLab CI variable](https://docs.gitlab.com/ci/variables/) named `SIM_API_KEY`. A personal key inherits its owner's access. This endpoint can only read the revision or replace both CSVs; it cannot change the GitLab PAT, host, or project.
94+
Store an organization admin's [personal Sim API key](/api-reference/authentication) with [**Full access**](/api-reference/authentication#key-access) as a [masked, protected GitLab CI variable](https://docs.gitlab.com/ci/variables/) named `SIM_API_KEY`. A personal key inherits its owner's access; a read-only key can read the revision but not replace the CSVs. This endpoint can only read the revision or replace both CSVs; it cannot change the GitLab PAT, host, or project.
9595

9696
This example runs on a schedule against a protected branch. Replace `./ci/export-gitlab-permissions` with your existing export command. It must write a fresh, complete `users.csv` and `project-permissions.csv` and exit nonzero if it cannot finish.
9797

‎apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ export const GET = withRouteHandler(
160160
}
161161
) => {
162162
const { workflowId: requestedWorkflowId } = await context.params
163-
const access = await validateWorkflowAccess(request, requestedWorkflowId, false)
163+
const access = await validateWorkflowAccess(request, requestedWorkflowId)
164164
if (access.error) {
165165
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
166166
}

‎apps/sim/app/api/v2/workspaces/[workspaceId]/members/[userId]/route.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { isUserCredentialPrincipal } from '@sim/auth/principal'
12
import {
23
v2RemoveWorkspaceMemberContract,
34
v2UpdateWorkspaceMemberContract,
45
} from '@/lib/api/contracts/v2/workspaces'
56
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
67
import { presentWorkspaceMember } from '@/lib/api/server/workspace-presenters'
8+
import { captureServerEvent } from '@/lib/posthog/server'
79
import { v2WorkspaceErrorPolicies } from '@/lib/workspaces/api/route-policies'
810
import { updateWorkspaceMember } from '@/lib/workspaces/application/manage-permissions'
911
import {
@@ -22,6 +24,17 @@ export const PATCH = defineV2JsonRoute({
2224
mapInput: ({ params, body }) => ({ ...params, ...body }),
2325
useCase: updateWorkspaceMember,
2426
present: ({ member }) => ({ data: presentWorkspaceMember(member) }),
27+
onSuccess: ({ principal, input, result }) => {
28+
if (!isUserCredentialPrincipal(principal)) return
29+
for (const change of result.changes) {
30+
captureServerEvent(
31+
principal.userId,
32+
'workspace_member_role_changed',
33+
{ workspace_id: input.workspaceId, new_role: change.newRole },
34+
{ groups: { workspace: input.workspaceId } }
35+
)
36+
}
37+
},
2538
})
2639

2740
/** DELETE /api/v2/workspaces/[workspaceId]/members/[userId] — Remove a member, or leave. */
@@ -40,4 +53,13 @@ export const DELETE = defineV2JsonRoute({
4053
organizationMembershipRemoved: result.organizationRemoval,
4154
},
4255
}),
56+
onSuccess: ({ principal, input, result }) => {
57+
if (!isUserCredentialPrincipal(principal)) return
58+
captureServerEvent(
59+
principal.userId,
60+
'workspace_member_removed',
61+
{ workspace_id: input.workspaceId, is_self_removal: result.selfRemoval },
62+
{ groups: { workspace: input.workspaceId } }
63+
)
64+
},
4365
})

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export const GET = withRouteHandler(
2323
const { id: workflowId, executionId } = parsed.data.params
2424
const { includeOutput, selectedOutputs } = parsed.data.query
2525

26-
const access = await validateWorkflowAccess(request, workflowId, false)
26+
const access = await validateWorkflowAccess(request, workflowId)
2727
if (access.error) {
2828
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
2929
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const GET = withRouteHandler(
1414
if (!parsed.success) return parsed.response
1515
const { id: workflowId } = parsed.data.params
1616

17-
const access = await validateWorkflowAccess(request, workflowId, false)
17+
const access = await validateWorkflowAccess(request, workflowId)
1818
if (access.error) {
1919
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
2020
}

‎apps/sim/app/api/workflows/middleware.test.ts‎

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* Tests for workflow access middleware — focused on the workspace-scoped
3-
* API key boundary check in the `requireDeployment=false` branch.
3+
* API key boundary check.
44
*/
55

66
import {
@@ -13,10 +13,6 @@ import { NextRequest } from 'next/server'
1313
import { beforeEach, describe, expect, it, vi } from 'vitest'
1414

1515
vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
16-
vi.mock('@/lib/api-key/service', () => ({
17-
authenticateApiKeyFromHeader: vi.fn(),
18-
updateApiKeyLastUsed: vi.fn(),
19-
}))
2016

2117
import { WORKSPACE_KEY_SCOPE_DENIED } from '@/lib/api-key/policy-messages'
2218
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
@@ -25,7 +21,7 @@ function makeRequest() {
2521
return new NextRequest(new URL('https://example.com/api/workflows/wf-1'))
2622
}
2723

28-
describe('validateWorkflowAccess (requireDeployment=false)', () => {
24+
describe('validateWorkflowAccess', () => {
2925
beforeEach(() => {
3026
workflowsUtilsMockFns.mockGetWorkflowById.mockResolvedValue({
3127
id: 'wf-1',
@@ -48,7 +44,7 @@ describe('validateWorkflowAccess (requireDeployment=false)', () => {
4844
workspaceId: 'ws-B',
4945
})
5046

51-
const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)
47+
const result = await validateWorkflowAccess(makeRequest(), 'wf-1')
5248

5349
expect(result.error).toEqual({
5450
message: WORKSPACE_KEY_SCOPE_DENIED,
@@ -70,7 +66,7 @@ describe('validateWorkflowAccess (requireDeployment=false)', () => {
7066
message: 'Access denied',
7167
})
7268

73-
const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)
69+
const result = await validateWorkflowAccess(makeRequest(), 'wf-1')
7470

7571
expect(result.error).toEqual({ message: 'Access denied', status: 403 })
7672
})

‎apps/sim/app/api/workflows/middleware.ts‎

Lines changed: 28 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,6 @@ import { createLogger } from '@sim/logger'
22
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
33
import type { NextRequest } from 'next/server'
44
import { WORKSPACE_KEY_SCOPE_DENIED } from '@/lib/api-key/policy-messages'
5-
import {
6-
type ApiKeyAuthResult,
7-
authenticateApiKeyFromHeader,
8-
updateApiKeyLastUsed,
9-
} from '@/lib/api-key/service'
105
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
116
import { getWorkflowById } from '@/lib/workflows/utils'
127

@@ -20,8 +15,7 @@ export interface ValidationResult {
2015

2116
export async function validateWorkflowAccess(
2217
request: NextRequest,
23-
workflowId: string,
24-
requireDeployment = true
18+
workflowId: string
2519
): Promise<ValidationResult> {
2620
try {
2721
const workflow = await getWorkflowById(workflowId)
@@ -44,95 +38,40 @@ export async function validateWorkflowAccess(
4438
}
4539
}
4640

47-
if (!requireDeployment) {
48-
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
49-
if (!auth.success || !auth.userId) {
50-
return {
51-
error: {
52-
message: auth.error || 'Unauthorized',
53-
status: 401,
54-
},
55-
}
56-
}
57-
58-
if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflow.workspaceId) {
59-
return {
60-
error: {
61-
message: WORKSPACE_KEY_SCOPE_DENIED,
62-
status: 403,
63-
},
64-
}
65-
}
66-
67-
const authorization = await authorizeWorkflowByWorkspacePermission({
68-
workflowId,
69-
userId: auth.userId,
70-
action: 'read',
71-
})
72-
if (!authorization.allowed) {
73-
return {
74-
error: {
75-
message: authorization.message || 'Access denied',
76-
status: authorization.status,
77-
},
78-
}
41+
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
42+
if (!auth.success || !auth.userId) {
43+
return {
44+
error: {
45+
message: auth.error || 'Unauthorized',
46+
status: 401,
47+
},
7948
}
80-
81-
return { workflow, auth }
8249
}
8350

84-
if (requireDeployment) {
85-
if (!workflow.isDeployed) {
86-
return {
87-
error: {
88-
message: 'Workflow is not deployed',
89-
status: 403,
90-
},
91-
}
92-
}
93-
94-
let apiKeyHeader = null
95-
for (const [key, value] of request.headers.entries()) {
96-
if (key.toLowerCase() === 'x-api-key' && value) {
97-
apiKeyHeader = value
98-
break
99-
}
100-
}
101-
102-
if (!apiKeyHeader) {
103-
return {
104-
error: {
105-
message: 'Unauthorized: API key required',
106-
status: 401,
107-
},
108-
}
109-
}
110-
111-
let validResult: ApiKeyAuthResult | null = null
112-
113-
const workspaceResult = await authenticateApiKeyFromHeader(apiKeyHeader, {
114-
workspaceId: workflow.workspaceId as string,
115-
keyTypes: ['workspace', 'personal'],
116-
})
117-
118-
if (workspaceResult.success) {
119-
validResult = workspaceResult
120-
}
121-
122-
if (!validResult) {
123-
return {
124-
error: {
125-
message: 'Unauthorized: Invalid API key',
126-
status: 401,
127-
},
128-
}
51+
if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflow.workspaceId) {
52+
return {
53+
error: {
54+
message: WORKSPACE_KEY_SCOPE_DENIED,
55+
status: 403,
56+
},
12957
}
58+
}
13059

131-
if (validResult.keyId) {
132-
updateApiKeyLastUsed(validResult.keyId)
60+
const authorization = await authorizeWorkflowByWorkspacePermission({
61+
workflowId,
62+
userId: auth.userId,
63+
action: 'read',
64+
})
65+
if (!authorization.allowed) {
66+
return {
67+
error: {
68+
message: authorization.message || 'Access denied',
69+
status: authorization.status,
70+
},
13371
}
13472
}
135-
return { workflow }
73+
74+
return { workflow, auth }
13675
} catch (error) {
13776
logger.error('Validation error:', { error })
13877
return {

‎apps/sim/lib/workspaces/api/route-policies.ts‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,6 @@ import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response'
1616
* `get-public-workspace` and `list-public-workspace-members` already uses, so
1717
* the two responses are byte-identical.
1818
*/
19-
function isWorkspaceBusyError(error: unknown): error is WorkspaceBusyError {
20-
return error instanceof WorkspaceBusyError
21-
}
22-
2319
export const v2WorkspaceErrorPolicies = {
2420
concealWorkspaceAuthorization: createV2ResourceConcealmentPolicy({
2521
notFoundMessage: 'Workspace not found',
@@ -35,7 +31,13 @@ export const v2WorkspaceErrorPolicies = {
3531
render(error) {
3632
if (error instanceof WorkspaceBillingAccountRemovalError)
3733
return v2Error('CONFLICT', error.message)
38-
if (isRetryableTransactionError(error) || findCause(error, isWorkspaceBusyError))
34+
if (
35+
isRetryableTransactionError(error) ||
36+
findCause(
37+
error,
38+
(cause): cause is WorkspaceBusyError => cause instanceof WorkspaceBusyError
39+
)
40+
)
3941
return v2Error('SERVICE_UNAVAILABLE', 'The workspace is busy; retry in a moment')
4042
return v2CaughtOrchestrationError(error)
4143
},

0 commit comments

Comments
 (0)