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
65 changes: 43 additions & 22 deletions apps/sim/app/api/workflows/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
import { verifyInternalToken } from '@/lib/auth/internal'
import { createLogger } from '@/lib/logs/console-logger'
import { getUserEntityPermissions, hasAdminPermission } from '@/lib/permissions/utils'
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
Expand All @@ -28,14 +29,29 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const { id: workflowId } = await params

try {
// Get the session
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// Check for internal JWT token for server-side calls
const authHeader = request.headers.get('authorization')
let isInternalCall = false

if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.split(' ')[1]
isInternalCall = await verifyInternalToken(token)
}

const userId = session.user.id
let userId: string | null = null

if (isInternalCall) {
// For internal calls, we'll skip user-specific access checks
logger.info(`[${requestId}] Internal API call for workflow ${workflowId}`)
} else {
// Get the session for regular user calls
const session = await getSession()
if (!session?.user?.id) {
logger.warn(`[${requestId}] Unauthorized access attempt for workflow ${workflowId}`)
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
userId = session.user.id
}

// Fetch the workflow
const workflowData = await db
Expand All @@ -52,26 +68,31 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
// Check if user has access to this workflow
let hasAccess = false

// Case 1: User owns the workflow
if (workflowData.userId === userId) {
if (isInternalCall) {
// Internal calls have full access
hasAccess = true
}

// Case 2: Workflow belongs to a workspace the user has permissions for
if (!hasAccess && workflowData.workspaceId) {
const userPermission = await getUserEntityPermissions(
userId,
'workspace',
workflowData.workspaceId
)
if (userPermission !== null) {
} else {
// Case 1: User owns the workflow
if (workflowData.userId === userId) {
hasAccess = true
}
}

if (!hasAccess) {
logger.warn(`[${requestId}] User ${userId} denied access to workflow ${workflowId}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
// Case 2: Workflow belongs to a workspace the user has permissions for
if (!hasAccess && workflowData.workspaceId && userId) {
const userPermission = await getUserEntityPermissions(
userId,
'workspace',
workflowData.workspaceId
)
if (userPermission !== null) {
hasAccess = true
}
}

if (!hasAccess) {
logger.warn(`[${requestId}] User ${userId} denied access to workflow ${workflowId}`)
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
}

// Try to load from normalized tables first
Expand Down
18 changes: 16 additions & 2 deletions apps/sim/executor/handlers/workflow/workflow-handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { generateInternalToken } from '@/lib/auth/internal'
import { createLogger } from '@/lib/logs/console-logger'
import { getBaseUrl } from '@/lib/urls/utils'
import type { BlockOutput } from '@/blocks/types'
import { Serializer } from '@/serializer'
import type { SerializedBlock } from '@/serializer/types'
Expand Down Expand Up @@ -125,8 +127,20 @@ export class WorkflowBlockHandler implements BlockHandler {
*/
private async loadChildWorkflow(workflowId: string) {
try {
// Fetch workflow from API
const response = await fetch(`/api/workflows/${workflowId}`)
// Fetch workflow from API with internal authentication header
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}

// Add internal auth header for server-side calls
if (typeof window === 'undefined') {
const token = await generateInternalToken()
headers.Authorization = `Bearer ${token}`
}

const response = await fetch(`${getBaseUrl()}/api/workflows/${workflowId}`, {
headers,
})

if (!response.ok) {
if (response.status === 404) {
Expand Down
47 changes: 47 additions & 0 deletions apps/sim/lib/auth/internal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { jwtVerify, SignJWT } from 'jose'
import { env } from '@/lib/env'

// Create a secret key for JWT signing
const getJwtSecret = () => {
const secret = new TextEncoder().encode(env.INTERNAL_API_SECRET)
return secret
}

/**
* Generate an internal JWT token for server-side API calls
* Token expires in 5 minutes to keep it short-lived
*/
export async function generateInternalToken(): Promise<string> {
const secret = getJwtSecret()

const token = await new SignJWT({ type: 'internal' })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('5m')
.setIssuer('sim-internal')
.setAudience('sim-api')
.sign(secret)

return token
}

/**
* Verify an internal JWT token
* Returns true if valid, false otherwise
*/
export async function verifyInternalToken(token: string): Promise<boolean> {
try {
const secret = getJwtSecret()

const { payload } = await jwtVerify(token, secret, {
issuer: 'sim-internal',
audience: 'sim-api',
})

// Check that it's an internal token
return payload.type === 'internal'
} catch (error) {
// Token verification failed
return false
}
}
1 change: 1 addition & 0 deletions apps/sim/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const env = createEnv({
BETTER_AUTH_SECRET: z.string().min(32),
DISABLE_REGISTRATION: z.boolean().optional(),
ENCRYPTION_KEY: z.string().min(32),
INTERNAL_API_SECRET: z.string().min(32),

POSTGRES_URL: z.string().url().optional(),
STRIPE_SECRET_KEY: z.string().min(1).optional(),
Expand Down
1 change: 1 addition & 0 deletions apps/sim/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@
"groq-sdk": "^0.15.0",
"input-otp": "^1.4.2",
"ioredis": "^5.6.0",
"jose": "6.0.11",
"jwt-decode": "^4.0.0",
"lenis": "^1.2.3",
"lucide-react": "^0.479.0",
Expand Down
7 changes: 5 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.