diff --git a/apps/docs/content/docs/search/gitlab.mdx b/apps/docs/content/docs/search/gitlab.mdx index 92ccdbf49fd..7ed99eb6c4b 100644 --- a/apps/docs/content/docs/search/gitlab.mdx +++ b/apps/docs/content/docs/search/gitlab.mdx @@ -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//settings/integrations/sources/`. 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. diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts index 26ac84b84d8..afbafe19338 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts @@ -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 }) } diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/[userId]/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/[userId]/route.ts index 8fd8f472889..531dbda9fcf 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/[userId]/route.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/[userId]/route.ts @@ -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 { @@ -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. */ @@ -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 } } + ) + }, }) diff --git a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts index d91f113e2e3..3191d34e469 100644 --- a/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts +++ b/apps/sim/app/api/workflows/[id]/executions/[executionId]/route.ts @@ -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 }) } diff --git a/apps/sim/app/api/workflows/[id]/paused/route.ts b/apps/sim/app/api/workflows/[id]/paused/route.ts index 74fa1cd4b51..bd1491bdc33 100644 --- a/apps/sim/app/api/workflows/[id]/paused/route.ts +++ b/apps/sim/app/api/workflows/[id]/paused/route.ts @@ -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 }) } diff --git a/apps/sim/app/api/workflows/middleware.test.ts b/apps/sim/app/api/workflows/middleware.test.ts index 6f8ef626c36..d5d25e16dbc 100644 --- a/apps/sim/app/api/workflows/middleware.test.ts +++ b/apps/sim/app/api/workflows/middleware.test.ts @@ -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 { @@ -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' @@ -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', @@ -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, @@ -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 }) }) diff --git a/apps/sim/app/api/workflows/middleware.ts b/apps/sim/app/api/workflows/middleware.ts index 56caf31ae08..1d6becef3d1 100644 --- a/apps/sim/app/api/workflows/middleware.ts +++ b/apps/sim/app/api/workflows/middleware.ts @@ -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' @@ -20,8 +15,7 @@ export interface ValidationResult { export async function validateWorkflowAccess( request: NextRequest, - workflowId: string, - requireDeployment = true + workflowId: string ): Promise { try { const workflow = await getWorkflowById(workflowId) @@ -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 { diff --git a/apps/sim/lib/workspaces/api/route-policies.ts b/apps/sim/lib/workspaces/api/route-policies.ts index d333d04dc37..13d72e0d307 100644 --- a/apps/sim/lib/workspaces/api/route-policies.ts +++ b/apps/sim/lib/workspaces/api/route-policies.ts @@ -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', @@ -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) },