mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user