Skip to content

Commit 4a943cd

Browse files
author
waleed
committed
feat(permissions): allow admin workspace users to deploy workflows in workspaces they don't own
1 parent d1f5c69 commit 4a943cd

File tree

5 files changed

+139
-100
lines changed

5 files changed

+139
-100
lines changed

apps/sim/app/api/workflows/[id]/deploy/route.ts

Lines changed: 59 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@ import type { NextRequest } from 'next/server'
44
import { v4 as uuidv4 } from 'uuid'
55
import { generateApiKey } from '@/lib/api-key/service'
66
import { createLogger } from '@/lib/logs/console/logger'
7+
import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils'
78
import { generateRequestId } from '@/lib/utils'
89
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
9-
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
10+
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
1011
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
1112

1213
const logger = createLogger('WorkflowDeployAPI')
@@ -20,33 +21,16 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
2021

2122
try {
2223
logger.debug(`[${requestId}] Fetching deployment info for workflow: ${id}`)
23-
const validation = await validateWorkflowAccess(request, id, false)
2424

25-
if (validation.error) {
26-
logger.warn(`[${requestId}] Failed to fetch deployment info: ${validation.error.message}`)
27-
return createErrorResponse(validation.error.message, validation.error.status)
25+
const { error, workflow: workflowData } = await validateWorkflowPermissions(
26+
id,
27+
requestId,
28+
'read'
29+
)
30+
if (error) {
31+
return createErrorResponse(error.message, error.status)
2832
}
2933

30-
// Fetch the workflow information including deployment details
31-
const result = await db
32-
.select({
33-
isDeployed: workflow.isDeployed,
34-
deployedAt: workflow.deployedAt,
35-
userId: workflow.userId,
36-
pinnedApiKeyId: workflow.pinnedApiKeyId,
37-
})
38-
.from(workflow)
39-
.where(eq(workflow.id, id))
40-
.limit(1)
41-
42-
if (result.length === 0) {
43-
logger.warn(`[${requestId}] Workflow not found: ${id}`)
44-
return createErrorResponse('Workflow not found', 404)
45-
}
46-
47-
const workflowData = result[0]
48-
49-
// If the workflow is not deployed, return appropriate response
5034
if (!workflowData.isDeployed) {
5135
logger.info(`[${requestId}] Workflow is not deployed: ${id}`)
5236
return createSuccessResponse({
@@ -70,7 +54,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
7054
keyInfo = { name: pinnedKey[0].name, type: pinnedKey[0].type as 'personal' | 'workspace' }
7155
}
7256
} else {
73-
// Fetch the user's API key, preferring the most recently used
7457
const userApiKey = await db
7558
.select({
7659
key: apiKey.key,
@@ -82,7 +65,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
8265
.orderBy(desc(apiKey.lastUsed), desc(apiKey.createdAt))
8366
.limit(1)
8467

85-
// If no API key exists, create one automatically
8668
if (userApiKey.length === 0) {
8769
try {
8870
const newApiKeyVal = generateApiKey()
@@ -107,7 +89,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
10789
}
10890
}
10991

110-
// Check if the workflow has meaningful changes that would require redeployment
11192
let needsRedeployment = false
11293
const [active] = await db
11394
.select({ state: workflowDeploymentVersion.state })
@@ -158,42 +139,26 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
158139

159140
try {
160141
logger.debug(`[${requestId}] Deploying workflow: ${id}`)
161-
const validation = await validateWorkflowAccess(request, id, false)
162142

163-
if (validation.error) {
164-
logger.warn(`[${requestId}] Workflow deployment failed: ${validation.error.message}`)
165-
return createErrorResponse(validation.error.message, validation.error.status)
143+
const {
144+
error,
145+
session,
146+
workflow: workflowData,
147+
} = await validateWorkflowPermissions(id, requestId, 'write')
148+
if (error) {
149+
return createErrorResponse(error.message, error.status)
166150
}
167151

168-
// Get the workflow to find the user and existing pin (removed deprecated state column)
169-
const workflowData = await db
170-
.select({
171-
userId: workflow.userId,
172-
pinnedApiKeyId: workflow.pinnedApiKeyId,
173-
})
174-
.from(workflow)
175-
.where(eq(workflow.id, id))
176-
.limit(1)
177-
178-
if (workflowData.length === 0) {
179-
logger.warn(`[${requestId}] Workflow not found: ${id}`)
180-
return createErrorResponse('Workflow not found', 404)
181-
}
152+
const userId = workflowData!.userId
182153

183-
const userId = workflowData[0].userId
184-
185-
// Parse request body to capture selected API key (if provided)
186154
let providedApiKey: string | null = null
187155
try {
188156
const parsed = await request.json()
189157
if (parsed && typeof parsed.apiKey === 'string' && parsed.apiKey.trim().length > 0) {
190158
providedApiKey = parsed.apiKey.trim()
191159
}
192-
} catch (_err) {
193-
// Body may be empty; ignore
194-
}
160+
} catch (_err) {}
195161

196-
// Get the current live state from normalized tables using centralized helper
197162
logger.debug(`[${requestId}] Getting current workflow state for deployment`)
198163

199164
const normalizedData = await loadWorkflowFromNormalizedTables(id)
@@ -226,7 +191,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
226191
const deployedAt = new Date()
227192
logger.debug(`[${requestId}] Proceeding with deployment at ${deployedAt.toISOString()}`)
228193

229-
// Check if the user already has API keys
230194
const userApiKey = await db
231195
.select({
232196
key: apiKey.key,
@@ -236,23 +200,21 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
236200
.orderBy(desc(apiKey.lastUsed), desc(apiKey.createdAt))
237201
.limit(1)
238202

239-
// If no API key exists, create one
240203
if (userApiKey.length === 0) {
241204
try {
242205
const newApiKey = generateApiKey()
243206
await db.insert(apiKey).values({
244207
id: uuidv4(),
245208
userId,
246-
workspaceId: null, // Personal keys must have NULL workspaceId
209+
workspaceId: null,
247210
name: 'Default API Key',
248211
key: newApiKey,
249-
type: 'personal', // Explicitly set type
212+
type: 'personal',
250213
createdAt: new Date(),
251214
updatedAt: new Date(),
252215
})
253216
logger.info(`[${requestId}] Generated new API key for user: ${userId}`)
254217
} catch (keyError) {
255-
// If key generation fails, log the error but continue with the request
256218
logger.error(`[${requestId}] Failed to generate API key:`, keyError)
257219
}
258220
}
@@ -268,30 +230,49 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
268230
if (providedApiKey) {
269231
let isValidKey = false
270232

271-
const [personalKey] = await db
272-
.select({ id: apiKey.id, key: apiKey.key, name: apiKey.name, expiresAt: apiKey.expiresAt })
273-
.from(apiKey)
274-
.where(
275-
and(eq(apiKey.id, providedApiKey), eq(apiKey.userId, userId), eq(apiKey.type, 'personal'))
276-
)
277-
.limit(1)
233+
const currentUserId = session?.user?.id
278234

279-
if (personalKey) {
280-
if (!personalKey.expiresAt || personalKey.expiresAt >= new Date()) {
281-
matchedKey = { ...personalKey, type: 'personal' }
282-
isValidKey = true
283-
keyInfo = { name: personalKey.name, type: 'personal' }
284-
}
285-
}
286-
287-
if (!isValidKey) {
235+
if (currentUserId) {
288236
const [workflowData] = await db
289237
.select({ workspaceId: workflow.workspaceId })
290238
.from(workflow)
291239
.where(eq(workflow.id, id))
292240
.limit(1)
293241

294242
if (workflowData?.workspaceId) {
243+
const isAdmin = await hasWorkspaceAdminAccess(currentUserId, workflowData.workspaceId)
244+
245+
if (isAdmin) {
246+
const [personalKey] = await db
247+
.select({
248+
id: apiKey.id,
249+
key: apiKey.key,
250+
name: apiKey.name,
251+
expiresAt: apiKey.expiresAt,
252+
})
253+
.from(apiKey)
254+
.where(
255+
and(
256+
eq(apiKey.id, providedApiKey),
257+
eq(apiKey.userId, currentUserId),
258+
eq(apiKey.type, 'personal')
259+
)
260+
)
261+
.limit(1)
262+
263+
if (personalKey) {
264+
if (!personalKey.expiresAt || personalKey.expiresAt >= new Date()) {
265+
matchedKey = { ...personalKey, type: 'personal' }
266+
isValidKey = true
267+
keyInfo = { name: personalKey.name, type: 'personal' }
268+
}
269+
}
270+
}
271+
}
272+
}
273+
274+
if (!isValidKey) {
275+
if (workflowData!.workspaceId) {
295276
const [workspaceKey] = await db
296277
.select({
297278
id: apiKey.id,
@@ -303,7 +284,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
303284
.where(
304285
and(
305286
eq(apiKey.id, providedApiKey),
306-
eq(apiKey.workspaceId, workflowData.workspaceId),
287+
eq(apiKey.workspaceId, workflowData!.workspaceId),
307288
eq(apiKey.type, 'workspace')
308289
)
309290
)
@@ -325,7 +306,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
325306
}
326307
}
327308

328-
// In a transaction: create deployment version, update workflow flags and deployed state
329309
await db.transaction(async (tx) => {
330310
const [{ maxVersion }] = await tx
331311
.select({ maxVersion: sql`COALESCE(MAX("version"), 0)` })
@@ -366,7 +346,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
366346
await tx.update(workflow).set(updateData).where(eq(workflow.id, id))
367347
})
368348

369-
// Update lastUsed for the key we returned
370349
if (matchedKey) {
371350
try {
372351
await db
@@ -408,14 +387,12 @@ export async function DELETE(
408387

409388
try {
410389
logger.debug(`[${requestId}] Undeploying workflow: ${id}`)
411-
const validation = await validateWorkflowAccess(request, id, false)
412390

413-
if (validation.error) {
414-
logger.warn(`[${requestId}] Workflow undeployment failed: ${validation.error.message}`)
415-
return createErrorResponse(validation.error.message, validation.error.status)
391+
const { error } = await validateWorkflowPermissions(id, requestId, 'write')
392+
if (error) {
393+
return createErrorResponse(error.message, error.status)
416394
}
417395

418-
// Deactivate versions and clear deployment fields
419396
await db.transaction(async (tx) => {
420397
await tx
421398
.update(workflowDeploymentVersion)

apps/sim/app/api/workflows/[id]/deployed/route.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,14 @@ import { and, desc, eq } from 'drizzle-orm'
33
import type { NextRequest, NextResponse } from 'next/server'
44
import { createLogger } from '@/lib/logs/console/logger'
55
import { generateRequestId } from '@/lib/utils'
6-
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
6+
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
77
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
88

99
const logger = createLogger('WorkflowDeployedStateAPI')
1010

1111
export const dynamic = 'force-dynamic'
1212
export const runtime = 'nodejs'
1313

14-
// Helper function to add Cache-Control headers to NextResponse
1514
function addNoCacheHeaders(response: NextResponse): NextResponse {
1615
response.headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
1716
return response
@@ -23,15 +22,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
2322

2423
try {
2524
logger.debug(`[${requestId}] Fetching deployed state for workflow: ${id}`)
26-
const validation = await validateWorkflowAccess(request, id, false)
2725

28-
if (validation.error) {
29-
logger.warn(`[${requestId}] Failed to fetch deployed state: ${validation.error.message}`)
30-
const response = createErrorResponse(validation.error.message, validation.error.status)
26+
const { error } = await validateWorkflowPermissions(id, requestId, 'read')
27+
if (error) {
28+
const response = createErrorResponse(error.message, error.status)
3129
return addNoCacheHeaders(response)
3230
}
3331

34-
// Fetch active deployment version state
3532
const [active] = await db
3633
.select({ state: workflowDeploymentVersion.state })
3734
.from(workflowDeploymentVersion)

apps/sim/app/api/workflows/[id]/deployments/[version]/route.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { db, workflowDeploymentVersion } from '@sim/db'
22
import { and, eq } from 'drizzle-orm'
33
import type { NextRequest } from 'next/server'
44
import { createLogger } from '@/lib/logs/console/logger'
5-
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
5+
import { generateRequestId } from '@/lib/utils'
6+
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
67
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
78

89
const logger = createLogger('WorkflowDeploymentVersionAPI')
@@ -14,13 +15,14 @@ export async function GET(
1415
request: NextRequest,
1516
{ params }: { params: Promise<{ id: string; version: string }> }
1617
) {
17-
const requestId = crypto.randomUUID().slice(0, 8)
18+
const requestId = generateRequestId()
1819
const { id, version } = await params
1920

2021
try {
21-
const validation = await validateWorkflowAccess(request, id, false)
22-
if (validation.error) {
23-
return createErrorResponse(validation.error.message, validation.error.status)
22+
// Validate permissions and get workflow data
23+
const { error } = await validateWorkflowPermissions(id, requestId, 'read')
24+
if (error) {
25+
return createErrorResponse(error.message, error.status)
2426
}
2527

2628
const versionNum = Number(version)

apps/sim/app/api/workflows/[id]/deployments/route.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { db, user, workflowDeploymentVersion } from '@sim/db'
22
import { desc, eq } from 'drizzle-orm'
33
import type { NextRequest } from 'next/server'
44
import { createLogger } from '@/lib/logs/console/logger'
5-
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
5+
import { generateRequestId } from '@/lib/utils'
6+
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
67
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
78

89
const logger = createLogger('WorkflowDeploymentsListAPI')
@@ -11,14 +12,13 @@ export const dynamic = 'force-dynamic'
1112
export const runtime = 'nodejs'
1213

1314
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
14-
const requestId = crypto.randomUUID().slice(0, 8)
15+
const requestId = generateRequestId()
1516
const { id } = await params
1617

1718
try {
18-
const validation = await validateWorkflowAccess(request, id, false)
19-
if (validation.error) {
20-
logger.warn(`[${requestId}] Workflow access validation failed: ${validation.error.message}`)
21-
return createErrorResponse(validation.error.message, validation.error.status)
19+
const { error } = await validateWorkflowPermissions(id, requestId, 'read')
20+
if (error) {
21+
return createErrorResponse(error.message, error.status)
2222
}
2323

2424
const versions = await db

0 commit comments

Comments
 (0)