Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/docs/content/docs/search/gitlab.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ To rotate the PAT, supply its replacement in source settings. A blank token reta

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`).

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.
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.

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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export const GET = withRouteHandler(
}
) => {
const { workflowId: requestedWorkflowId } = await context.params
const access = await validateWorkflowAccess(request, requestedWorkflowId, false)
const access = await validateWorkflowAccess(request, requestedWorkflowId)
if (access.error) {
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { isUserCredentialPrincipal } from '@sim/auth/principal'
import {
v2RemoveWorkspaceMemberContract,
v2UpdateWorkspaceMemberContract,
} from '@/lib/api/contracts/v2/workspaces'
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
import { presentWorkspaceMember } from '@/lib/api/server/workspace-presenters'
import { captureServerEvent } from '@/lib/posthog/server'
import { v2WorkspaceErrorPolicies } from '@/lib/workspaces/api/route-policies'
import { updateWorkspaceMember } from '@/lib/workspaces/application/manage-permissions'
import {
Expand All @@ -22,6 +24,17 @@ export const PATCH = defineV2JsonRoute({
mapInput: ({ params, body }) => ({ ...params, ...body }),
useCase: updateWorkspaceMember,
present: ({ member }) => ({ data: presentWorkspaceMember(member) }),
onSuccess: ({ principal, input, result }) => {
if (!isUserCredentialPrincipal(principal)) return
for (const change of result.changes) {
captureServerEvent(
principal.userId,
'workspace_member_role_changed',
{ workspace_id: input.workspaceId, new_role: change.newRole },
{ groups: { workspace: input.workspaceId } }
)
}
},
})

/** DELETE /api/v2/workspaces/[workspaceId]/members/[userId] — Remove a member, or leave. */
Expand All @@ -40,4 +53,13 @@ export const DELETE = defineV2JsonRoute({
organizationMembershipRemoved: result.organizationRemoval,
},
}),
onSuccess: ({ principal, input, result }) => {
if (!isUserCredentialPrincipal(principal)) return
captureServerEvent(
principal.userId,
'workspace_member_removed',
{ workspace_id: input.workspaceId, is_self_removal: result.selfRemoval },
{ groups: { workspace: input.workspaceId } }
)
},
})
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const GET = withRouteHandler(
const { id: workflowId, executionId } = parsed.data.params
const { includeOutput, selectedOutputs } = parsed.data.query

const access = await validateWorkflowAccess(request, workflowId, false)
const access = await validateWorkflowAccess(request, workflowId)
if (access.error) {
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
}
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/workflows/[id]/paused/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const GET = withRouteHandler(
if (!parsed.success) return parsed.response
const { id: workflowId } = parsed.data.params

const access = await validateWorkflowAccess(request, workflowId, false)
const access = await validateWorkflowAccess(request, workflowId)
if (access.error) {
return NextResponse.json({ error: access.error.message }, { status: access.error.status })
}
Expand Down
12 changes: 4 additions & 8 deletions apps/sim/app/api/workflows/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Tests for workflow access middleware — focused on the workspace-scoped
* API key boundary check in the `requireDeployment=false` branch.
* API key boundary check.
*/

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

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

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

describe('validateWorkflowAccess (requireDeployment=false)', () => {
describe('validateWorkflowAccess', () => {
beforeEach(() => {
workflowsUtilsMockFns.mockGetWorkflowById.mockResolvedValue({
id: 'wf-1',
Expand All @@ -48,7 +44,7 @@ describe('validateWorkflowAccess (requireDeployment=false)', () => {
workspaceId: 'ws-B',
})

const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)
const result = await validateWorkflowAccess(makeRequest(), 'wf-1')

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

const result = await validateWorkflowAccess(makeRequest(), 'wf-1', false)
const result = await validateWorkflowAccess(makeRequest(), 'wf-1')

expect(result.error).toEqual({ message: 'Access denied', status: 403 })
})
Expand Down
117 changes: 28 additions & 89 deletions apps/sim/app/api/workflows/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,6 @@ import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import type { NextRequest } from 'next/server'
import { WORKSPACE_KEY_SCOPE_DENIED } from '@/lib/api-key/policy-messages'
import {
type ApiKeyAuthResult,
authenticateApiKeyFromHeader,
updateApiKeyLastUsed,
} from '@/lib/api-key/service'
import { type AuthResult, checkHybridAuth } from '@/lib/auth/hybrid'
import { getWorkflowById } from '@/lib/workflows/utils'

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

export async function validateWorkflowAccess(
request: NextRequest,
workflowId: string,
requireDeployment = true
workflowId: string
): Promise<ValidationResult> {
try {
const workflow = await getWorkflowById(workflowId)
Expand All @@ -44,95 +38,40 @@ export async function validateWorkflowAccess(
}
}

if (!requireDeployment) {
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return {
error: {
message: auth.error || 'Unauthorized',
status: 401,
},
}
}

if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflow.workspaceId) {
return {
error: {
message: WORKSPACE_KEY_SCOPE_DENIED,
status: 403,
},
}
}

const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId: auth.userId,
action: 'read',
})
if (!authorization.allowed) {
return {
error: {
message: authorization.message || 'Access denied',
status: authorization.status,
},
}
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
if (!auth.success || !auth.userId) {
return {
error: {
message: auth.error || 'Unauthorized',
status: 401,
},
}

return { workflow, auth }
}

if (requireDeployment) {
if (!workflow.isDeployed) {
return {
error: {
message: 'Workflow is not deployed',
status: 403,
},
}
}

let apiKeyHeader = null
for (const [key, value] of request.headers.entries()) {
if (key.toLowerCase() === 'x-api-key' && value) {
apiKeyHeader = value
break
}
}

if (!apiKeyHeader) {
return {
error: {
message: 'Unauthorized: API key required',
status: 401,
},
}
}

let validResult: ApiKeyAuthResult | null = null

const workspaceResult = await authenticateApiKeyFromHeader(apiKeyHeader, {
workspaceId: workflow.workspaceId as string,
keyTypes: ['workspace', 'personal'],
})

if (workspaceResult.success) {
validResult = workspaceResult
}

if (!validResult) {
return {
error: {
message: 'Unauthorized: Invalid API key',
status: 401,
},
}
if (auth.apiKeyType === 'workspace' && auth.workspaceId !== workflow.workspaceId) {
return {
error: {
message: WORKSPACE_KEY_SCOPE_DENIED,
status: 403,
},
}
}

if (validResult.keyId) {
updateApiKeyLastUsed(validResult.keyId)
const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId,
userId: auth.userId,
action: 'read',
})
if (!authorization.allowed) {
return {
error: {
message: authorization.message || 'Access denied',
status: authorization.status,
},
}
}
return { workflow }

return { workflow, auth }
} catch (error) {
logger.error('Validation error:', { error })
return {
Expand Down
12 changes: 7 additions & 5 deletions apps/sim/lib/workspaces/api/route-policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,6 @@ import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response'
* `get-public-workspace` and `list-public-workspace-members` already uses, so
* the two responses are byte-identical.
*/
function isWorkspaceBusyError(error: unknown): error is WorkspaceBusyError {
return error instanceof WorkspaceBusyError
}

export const v2WorkspaceErrorPolicies = {
concealWorkspaceAuthorization: createV2ResourceConcealmentPolicy({
notFoundMessage: 'Workspace not found',
Expand All @@ -35,7 +31,13 @@ export const v2WorkspaceErrorPolicies = {
render(error) {
if (error instanceof WorkspaceBillingAccountRemovalError)
return v2Error('CONFLICT', error.message)
if (isRetryableTransactionError(error) || findCause(error, isWorkspaceBusyError))
if (
isRetryableTransactionError(error) ||
findCause(
error,
(cause): cause is WorkspaceBusyError => cause instanceof WorkspaceBusyError
)
)
return v2Error('SERVICE_UNAVAILABLE', 'The workspace is busy; retry in a moment')
return v2CaughtOrchestrationError(error)
},
Expand Down
Loading