feat(permissions): allow users to deploy workflows in all workspaces they are an admin in (#1463)

* feat(permissions): allow admin workspace users to deploy workflows in workspaces they don't own

* fixed failing test

* added additional routes

* remove overly complex, unecessary test and fixed docs formatting

* follow DRY
This commit is contained in:
Waleed
2025-09-26 19:58:11 -07:00
committed by GitHub
parent 448e9ea835
commit bcc75376e5
10 changed files with 143 additions and 332 deletions

View File

@@ -24,7 +24,7 @@ Si vous laissez le formulaire vide, le déclencheur n'a pas de sorties.
<div className='flex justify-center my-6'>
<Image
src='/static/triggers/input-form-panel-light.png'
alt='Panneau d'exécution du formulaire d'entrée'
alt="Panneau d'exécution du formulaire d'entrée"
width={400}
height={250}
className='rounded-xl border border-border shadow-sm'

View File

@@ -15,7 +15,7 @@ Le déclencheur manuel ajoute un simple bouton Exécuter en haut de votre workfl
<div className='flex justify-center my-6'>
<Image
src='/static/triggers/manual-run-light.png'
alt='Bouton d'exécution du déclencheur manuel'
alt="Bouton d'exécution du déclencheur manuel"
width={400}
height={250}
className='rounded-xl border border-border shadow-sm'

View File

@@ -1,217 +0,0 @@
/**
* Integration tests for workflow deployment API route
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest } from '@/app/api/__test-utils__/utils'
describe('Workflow Deployment API Route', () => {
beforeEach(() => {
vi.clearAllMocks()
// Set up environment to prevent @sim/db import errors
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test'
// Mock postgres dependencies
vi.doMock('drizzle-orm/postgres-js', () => ({
drizzle: vi.fn().mockReturnValue({}),
}))
vi.doMock('postgres', () => vi.fn().mockReturnValue({}))
vi.doMock('@/lib/utils', () => ({
generateApiKey: vi.fn().mockReturnValue('sim_testkeygenerated12345'),
generateRequestId: vi.fn(() => 'test-request-id'),
}))
vi.doMock('uuid', () => ({
v4: vi.fn().mockReturnValue('mock-uuid-1234'),
}))
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue('mock-request-id'),
})
vi.doMock('@/lib/logs/console/logger', () => ({
createLogger: vi.fn().mockReturnValue({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}))
// Mock serializer
vi.doMock('@/serializer', () => ({
serializeWorkflow: vi.fn().mockReturnValue({
version: '1.0',
blocks: [
{
id: 'block-1',
metadata: { id: 'starter', name: 'Start' },
position: { x: 100, y: 100 },
config: { tool: 'starter', params: {} },
inputs: {},
outputs: {},
enabled: true,
},
],
connections: [],
loops: {},
parallels: {},
}),
}))
vi.doMock('@/lib/workflows/db-helpers', () => ({
loadWorkflowFromNormalizedTables: vi.fn().mockResolvedValue({
blocks: {
'block-1': {
id: 'block-1',
type: 'starter',
name: 'Start',
position: { x: 100, y: 100 },
enabled: true,
subBlocks: {},
outputs: {},
data: {},
},
},
edges: [],
loops: {},
parallels: {},
isFromNormalizedTables: true,
}),
}))
vi.doMock('@/app/api/workflows/middleware', () => ({
validateWorkflowAccess: vi.fn().mockResolvedValue({
workflow: {
id: 'workflow-id',
userId: 'user-id',
},
}),
}))
vi.doMock('@/app/api/workflows/utils', () => ({
createSuccessResponse: vi.fn().mockImplementation((data) => {
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}),
createErrorResponse: vi.fn().mockImplementation((message, status = 500) => {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { 'Content-Type': 'application/json' },
})
}),
}))
// Mock the database schema module
// Mock drizzle-orm operators
vi.doMock('drizzle-orm', () => ({
eq: vi.fn((field, value) => ({ field, value, type: 'eq' })),
and: vi.fn((...conditions) => ({ conditions, type: 'and' })),
desc: vi.fn((field) => ({ field, type: 'desc' })),
sql: vi.fn((strings, ...values) => ({ strings, values, type: 'sql' })),
}))
// Mock the database module with proper chainable query builder
let selectCallCount = 0
vi.doMock('@sim/db', () => ({
workflow: {},
apiKey: {},
workflowBlocks: {},
workflowEdges: {},
workflowSubflows: {},
workflowDeploymentVersion: {
workflowId: 'workflowId',
state: 'state',
isActive: 'isActive',
version: 'version',
},
db: {
select: vi.fn().mockImplementation(() => {
selectCallCount++
const buildLimitResponse = () => ({
limit: vi.fn().mockImplementation(() => {
// First call: workflow lookup (should return workflow)
if (selectCallCount === 1) {
return Promise.resolve([{ userId: 'user-id', id: 'workflow-id' }])
}
// Second call: blocks lookup
if (selectCallCount === 2) {
return Promise.resolve([
{
id: 'block-1',
type: 'starter',
name: 'Start',
positionX: '100',
positionY: '100',
enabled: true,
subBlocks: {},
data: {},
},
])
}
// Third call: edges lookup
if (selectCallCount === 3) {
return Promise.resolve([])
}
// Fourth call: subflows lookup
if (selectCallCount === 4) {
return Promise.resolve([])
}
// Fifth call: API key lookup (should return empty for new key test)
if (selectCallCount === 5) {
return Promise.resolve([])
}
// Default: empty array
return Promise.resolve([])
}),
})
return {
from: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => ({
...buildLimitResponse(),
orderBy: vi.fn().mockReturnValue(buildLimitResponse()),
})),
})),
}
}),
insert: vi.fn().mockImplementation(() => ({
values: vi.fn().mockResolvedValue([{ id: 'mock-api-key-id' }]),
})),
update: vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation(() => ({
where: vi.fn().mockResolvedValue([]),
})),
})),
},
}))
})
afterEach(() => {
vi.clearAllMocks()
})
/**
* Test GET deployment status
*/
it('should fetch deployment info successfully', async () => {
// The global mock from mockExecutionDependencies() should handle this
const req = createMockRequest('GET')
const params = Promise.resolve({ id: 'workflow-id' })
const { GET } = await import('@/app/api/workflows/[id]/deploy/route')
const response = await GET(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('isDeployed')
})
})

View File

@@ -6,7 +6,7 @@ import { generateApiKey } from '@/lib/api-key/service'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('WorkflowDeployAPI')
@@ -20,33 +20,16 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
try {
logger.debug(`[${requestId}] Fetching deployment info for workflow: ${id}`)
const validation = await validateWorkflowAccess(request, id, false)
if (validation.error) {
logger.warn(`[${requestId}] Failed to fetch deployment info: ${validation.error.message}`)
return createErrorResponse(validation.error.message, validation.error.status)
const { error, workflow: workflowData } = await validateWorkflowPermissions(
id,
requestId,
'read'
)
if (error) {
return createErrorResponse(error.message, error.status)
}
// Fetch the workflow information including deployment details
const result = await db
.select({
isDeployed: workflow.isDeployed,
deployedAt: workflow.deployedAt,
userId: workflow.userId,
pinnedApiKeyId: workflow.pinnedApiKeyId,
})
.from(workflow)
.where(eq(workflow.id, id))
.limit(1)
if (result.length === 0) {
logger.warn(`[${requestId}] Workflow not found: ${id}`)
return createErrorResponse('Workflow not found', 404)
}
const workflowData = result[0]
// If the workflow is not deployed, return appropriate response
if (!workflowData.isDeployed) {
logger.info(`[${requestId}] Workflow is not deployed: ${id}`)
return createSuccessResponse({
@@ -70,7 +53,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
keyInfo = { name: pinnedKey[0].name, type: pinnedKey[0].type as 'personal' | 'workspace' }
}
} else {
// Fetch the user's API key, preferring the most recently used
const userApiKey = await db
.select({
key: apiKey.key,
@@ -82,7 +64,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
.orderBy(desc(apiKey.lastUsed), desc(apiKey.createdAt))
.limit(1)
// If no API key exists, create one automatically
if (userApiKey.length === 0) {
try {
const newApiKeyVal = generateApiKey()
@@ -107,7 +88,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
}
// Check if the workflow has meaningful changes that would require redeployment
let needsRedeployment = false
const [active] = await db
.select({ state: workflowDeploymentVersion.state })
@@ -158,42 +138,26 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
try {
logger.debug(`[${requestId}] Deploying workflow: ${id}`)
const validation = await validateWorkflowAccess(request, id, false)
if (validation.error) {
logger.warn(`[${requestId}] Workflow deployment failed: ${validation.error.message}`)
return createErrorResponse(validation.error.message, validation.error.status)
const {
error,
session,
workflow: workflowData,
} = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
// Get the workflow to find the user and existing pin (removed deprecated state column)
const workflowData = await db
.select({
userId: workflow.userId,
pinnedApiKeyId: workflow.pinnedApiKeyId,
})
.from(workflow)
.where(eq(workflow.id, id))
.limit(1)
const userId = workflowData!.userId
if (workflowData.length === 0) {
logger.warn(`[${requestId}] Workflow not found: ${id}`)
return createErrorResponse('Workflow not found', 404)
}
const userId = workflowData[0].userId
// Parse request body to capture selected API key (if provided)
let providedApiKey: string | null = null
try {
const parsed = await request.json()
if (parsed && typeof parsed.apiKey === 'string' && parsed.apiKey.trim().length > 0) {
providedApiKey = parsed.apiKey.trim()
}
} catch (_err) {
// Body may be empty; ignore
}
} catch (_err) {}
// Get the current live state from normalized tables using centralized helper
logger.debug(`[${requestId}] Getting current workflow state for deployment`)
const normalizedData = await loadWorkflowFromNormalizedTables(id)
@@ -226,7 +190,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const deployedAt = new Date()
logger.debug(`[${requestId}] Proceeding with deployment at ${deployedAt.toISOString()}`)
// Check if the user already has API keys
const userApiKey = await db
.select({
key: apiKey.key,
@@ -236,23 +199,21 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
.orderBy(desc(apiKey.lastUsed), desc(apiKey.createdAt))
.limit(1)
// If no API key exists, create one
if (userApiKey.length === 0) {
try {
const newApiKey = generateApiKey()
await db.insert(apiKey).values({
id: uuidv4(),
userId,
workspaceId: null, // Personal keys must have NULL workspaceId
workspaceId: null,
name: 'Default API Key',
key: newApiKey,
type: 'personal', // Explicitly set type
type: 'personal',
createdAt: new Date(),
updatedAt: new Date(),
})
logger.info(`[${requestId}] Generated new API key for user: ${userId}`)
} catch (keyError) {
// If key generation fails, log the error but continue with the request
logger.error(`[${requestId}] Failed to generate API key:`, keyError)
}
}
@@ -268,30 +229,37 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
if (providedApiKey) {
let isValidKey = false
const [personalKey] = await db
.select({ id: apiKey.id, key: apiKey.key, name: apiKey.name, expiresAt: apiKey.expiresAt })
.from(apiKey)
.where(
and(eq(apiKey.id, providedApiKey), eq(apiKey.userId, userId), eq(apiKey.type, 'personal'))
)
.limit(1)
const currentUserId = session?.user?.id
if (personalKey) {
if (!personalKey.expiresAt || personalKey.expiresAt >= new Date()) {
matchedKey = { ...personalKey, type: 'personal' }
isValidKey = true
keyInfo = { name: personalKey.name, type: 'personal' }
if (currentUserId) {
const [personalKey] = await db
.select({
id: apiKey.id,
key: apiKey.key,
name: apiKey.name,
expiresAt: apiKey.expiresAt,
})
.from(apiKey)
.where(
and(
eq(apiKey.id, providedApiKey),
eq(apiKey.userId, currentUserId),
eq(apiKey.type, 'personal')
)
)
.limit(1)
if (personalKey) {
if (!personalKey.expiresAt || personalKey.expiresAt >= new Date()) {
matchedKey = { ...personalKey, type: 'personal' }
isValidKey = true
keyInfo = { name: personalKey.name, type: 'personal' }
}
}
}
if (!isValidKey) {
const [workflowData] = await db
.select({ workspaceId: workflow.workspaceId })
.from(workflow)
.where(eq(workflow.id, id))
.limit(1)
if (workflowData?.workspaceId) {
if (workflowData!.workspaceId) {
const [workspaceKey] = await db
.select({
id: apiKey.id,
@@ -303,7 +271,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
.where(
and(
eq(apiKey.id, providedApiKey),
eq(apiKey.workspaceId, workflowData.workspaceId),
eq(apiKey.workspaceId, workflowData!.workspaceId),
eq(apiKey.type, 'workspace')
)
)
@@ -325,7 +293,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
}
// In a transaction: create deployment version, update workflow flags and deployed state
await db.transaction(async (tx) => {
const [{ maxVersion }] = await tx
.select({ maxVersion: sql`COALESCE(MAX("version"), 0)` })
@@ -366,7 +333,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
await tx.update(workflow).set(updateData).where(eq(workflow.id, id))
})
// Update lastUsed for the key we returned
if (matchedKey) {
try {
await db
@@ -408,14 +374,12 @@ export async function DELETE(
try {
logger.debug(`[${requestId}] Undeploying workflow: ${id}`)
const validation = await validateWorkflowAccess(request, id, false)
if (validation.error) {
logger.warn(`[${requestId}] Workflow undeployment failed: ${validation.error.message}`)
return createErrorResponse(validation.error.message, validation.error.status)
const { error } = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
// Deactivate versions and clear deployment fields
await db.transaction(async (tx) => {
await tx
.update(workflowDeploymentVersion)

View File

@@ -3,7 +3,7 @@ import { and, desc, eq } from 'drizzle-orm'
import type { NextRequest, NextResponse } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('WorkflowDeployedStateAPI')
@@ -11,7 +11,6 @@ const logger = createLogger('WorkflowDeployedStateAPI')
export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
// Helper function to add Cache-Control headers to NextResponse
function addNoCacheHeaders(response: NextResponse): NextResponse {
response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
return response
@@ -23,15 +22,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
try {
logger.debug(`[${requestId}] Fetching deployed state for workflow: ${id}`)
const validation = await validateWorkflowAccess(request, id, false)
if (validation.error) {
logger.warn(`[${requestId}] Failed to fetch deployed state: ${validation.error.message}`)
const response = createErrorResponse(validation.error.message, validation.error.status)
const { error } = await validateWorkflowPermissions(id, requestId, 'read')
if (error) {
const response = createErrorResponse(error.message, error.status)
return addNoCacheHeaders(response)
}
// Fetch active deployment version state
const [active] = await db
.select({ state: workflowDeploymentVersion.state })
.from(workflowDeploymentVersion)

View File

@@ -2,7 +2,8 @@ import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import { generateRequestId } from '@/lib/utils'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('WorkflowActivateDeploymentAPI')
@@ -14,14 +15,13 @@ export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string; version: string }> }
) {
const requestId = crypto.randomUUID().slice(0, 8)
const requestId = generateRequestId()
const { id, version } = await params
try {
const validation = await validateWorkflowAccess(request, id, false)
if (validation.error) {
logger.warn(`[${requestId}] Workflow access validation failed: ${validation.error.message}`)
return createErrorResponse(validation.error.message, validation.error.status)
const { error } = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
const versionNum = Number(version)

View File

@@ -3,8 +3,9 @@ import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { env } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { generateRequestId } from '@/lib/utils'
import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('RevertToDeploymentVersionAPI')
@@ -16,12 +17,13 @@ export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string; version: string }> }
) {
const requestId = generateRequestId()
const { id, version } = await params
try {
const validation = await validateWorkflowAccess(request, id, false)
if (validation.error) {
return createErrorResponse(validation.error.message, validation.error.status)
const { error } = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
const versionSelector = version === 'active' ? null : Number(version)

View File

@@ -2,7 +2,8 @@ import { db, workflowDeploymentVersion } from '@sim/db'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import { generateRequestId } from '@/lib/utils'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('WorkflowDeploymentVersionAPI')
@@ -14,13 +15,14 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string; version: string }> }
) {
const requestId = crypto.randomUUID().slice(0, 8)
const requestId = generateRequestId()
const { id, version } = await params
try {
const validation = await validateWorkflowAccess(request, id, false)
if (validation.error) {
return createErrorResponse(validation.error.message, validation.error.status)
// Validate permissions and get workflow data
const { error } = await validateWorkflowPermissions(id, requestId, 'read')
if (error) {
return createErrorResponse(error.message, error.status)
}
const versionNum = Number(version)

View File

@@ -2,7 +2,8 @@ import { db, user, workflowDeploymentVersion } from '@sim/db'
import { desc, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
import { generateRequestId } from '@/lib/utils'
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
const logger = createLogger('WorkflowDeploymentsListAPI')
@@ -11,14 +12,13 @@ export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const requestId = crypto.randomUUID().slice(0, 8)
const requestId = generateRequestId()
const { id } = await params
try {
const validation = await validateWorkflowAccess(request, id, false)
if (validation.error) {
logger.warn(`[${requestId}] Workflow access validation failed: ${validation.error.message}`)
return createErrorResponse(validation.error.message, validation.error.status)
const { error } = await validateWorkflowPermissions(id, requestId, 'read')
if (error) {
return createErrorResponse(error.message, error.status)
}
const versions = await db

View File

@@ -2,8 +2,10 @@ import { db } from '@sim/db'
import { apiKey, userStats, workflow as workflowTable } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getEnv } from '@/lib/env'
import { createLogger } from '@/lib/logs/console/logger'
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
import type { ExecutionResult } from '@/executor/types'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
@@ -399,3 +401,64 @@ export const createHttpResponseFromBlock = (executionResult: ExecutionResult): N
headers: responseHeaders,
})
}
/**
* Validates that the current user has permission to access/modify a workflow
* Returns session and workflow info if authorized, or error response if not
*/
export async function validateWorkflowPermissions(
workflowId: string,
requestId: string,
action: 'read' | 'write' | 'admin' = 'read'
) {
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] No authenticated user session for workflow ${action}`)
return {
error: { message: 'Unauthorized', status: 401 },
session: null,
workflow: null,
}
}
const workflow = await getWorkflowById(workflowId)
if (!workflow) {
logger.warn(`[${requestId}] Workflow ${workflowId} not found`)
return {
error: { message: 'Workflow not found', status: 404 },
session: null,
workflow: null,
}
}
if (workflow.workspaceId) {
const hasAccess = await hasWorkspaceAdminAccess(session.user.id, workflow.workspaceId)
if (!hasAccess) {
logger.warn(
`[${requestId}] User ${session.user.id} unauthorized to ${action} workflow ${workflowId} in workspace ${workflow.workspaceId}`
)
return {
error: { message: `Unauthorized: Access denied to ${action} this workflow`, status: 403 },
session: null,
workflow: null,
}
}
} else {
if (workflow.userId !== session.user.id) {
logger.warn(
`[${requestId}] User ${session.user.id} unauthorized to ${action} workflow ${workflowId} owned by ${workflow.userId}`
)
return {
error: { message: `Unauthorized: Access denied to ${action} this workflow`, status: 403 },
session: null,
workflow: null,
}
}
}
return {
error: null,
session,
workflow,
}
}