Skip to content

Commit 52680e2

Browse files
committed
Merge remote-tracking branch 'origin/staging' into fix/release-audit-hardening
2 parents dba6053 + b03f0c7 commit 52680e2

12 files changed

Lines changed: 77 additions & 112 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/cli/auth/poll/route.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { pollCliAuthContract } from '@/lib/api/contracts'
44
import { parseRequest } from '@/lib/api/server'
55
import {
6+
apiKeyCreationErrorStatus,
67
performCreatePersonalApiKey,
78
performCreateWorkspaceApiKey,
89
} from '@/lib/api-key/orchestration'
@@ -83,7 +84,7 @@ async function mintForGrant(
8384
if (!result.success || !result.key) {
8485
return {
8586
ok: false,
86-
status: result.errorCode === 'conflict' ? 409 : 500,
87+
status: apiKeyCreationErrorStatus(result.errorCode),
8788
message: result.error ?? 'Failed to generate API key',
8889
}
8990
}

‎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/background/workflow-execution.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,8 @@ export async function executeWorkflowJob(
286286
/**
287287
* The version this run executes, loaded once: the core runs this same version's
288288
* blocks, so a deploy landing mid-setup cannot pair one version's variables with
289-
* another's graph.
289+
* another's graph. A pinned version runs with only its own snapshot, as a sync run
290+
* of it does, never the editor's current variables.
290291
*/
291292
const pinnedDeploymentVersionId = payload.pinnedDeploymentVersionId
292293
const deployedState = await withDatabaseReadRetry(
@@ -328,7 +329,9 @@ export async function executeWorkflowJob(
328329
metadata,
329330
workflow,
330331
payload.input,
331-
resolveDeployedRunVariables(deployedState, workflow.variables),
332+
pinnedDeploymentVersionId
333+
? (deployedState.variables ?? {})
334+
: resolveDeployedRunVariables(deployedState, workflow.variables),
332335
[]
333336
)
334337

‎apps/sim/lib/integrations/credential-display.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { CANONICAL_SERVICE_ACCOUNT_SLUGS } from '@/lib/integrations/oauth-servic
2222
import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth'
2323
import type { OAuthProvider, OAuthServiceConfig } from '@/lib/oauth/types'
2424
import { getServiceConfigByProviderId, parseProvider } from '@/lib/oauth/utils'
25+
import { resolveAccessControlBlockType } from '@/lib/permission-groups/integration-allowlist'
2526

2627
/**
2728
* Above this many covered integrations the subtitle states a count instead of
@@ -226,7 +227,7 @@ export function resolveCredentialDisplay(credential: DisplayableCredential): Cre
226227
return {
227228
service: null,
228229
integration,
229-
blockType: 'gitlab',
230+
blockType: resolveAccessControlBlockType('gitlab'),
230231
icon: GitlabIcon,
231232
familyName: null,
232233
coveredIntegrations: integration ? [integration] : [],

0 commit comments

Comments
 (0)