Skip to content

Commit 2d4dc85

Browse files
waleedlatif1claude
andcommitted
feat(public-api): add env var and permission group controls to disable public API access
Add DISABLE_PUBLIC_API / NEXT_PUBLIC_DISABLE_PUBLIC_API environment variables and disablePublicApi permission group config option to allow self-hosted deployments and enterprise admins to globally disable the public API toggle. When disabled: the Access toggle is hidden in the Edit API Info modal, the execute route blocks unauthenticated public access (401), and the public-api PATCH route rejects enabling public API (403). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fe34d23 commit 2d4dc85

File tree

17 files changed

+12668
-30
lines changed

17 files changed

+12668
-30
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
5151
deployedAt: null,
5252
apiKey: null,
5353
needsRedeployment: false,
54+
isPublicApi: workflowData.isPublicApi ?? false,
5455
})
5556
}
5657

@@ -98,6 +99,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
9899
isDeployed: workflowData.isDeployed,
99100
deployedAt: workflowData.deployedAt,
100101
needsRedeployment,
102+
isPublicApi: workflowData.isPublicApi ?? false,
101103
})
102104
} catch (error: any) {
103105
logger.error(`[${requestId}] Error fetching deployment info: ${id}`, error)

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

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -254,10 +254,33 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
254254

255255
try {
256256
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
257+
258+
let userId: string
259+
let isPublicApiAccess = false
260+
257261
if (!auth.success || !auth.userId) {
258-
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
262+
const { db: dbClient, workflow: workflowTable } = await import('@sim/db')
263+
const { eq } = await import('drizzle-orm')
264+
const [wf] = await dbClient
265+
.select({ isPublicApi: workflowTable.isPublicApi, userId: workflowTable.userId })
266+
.from(workflowTable)
267+
.where(eq(workflowTable.id, workflowId))
268+
.limit(1)
269+
270+
if (!wf?.isPublicApi) {
271+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
272+
}
273+
274+
const { isPublicApiDisabled } = await import('@/lib/core/config/feature-flags')
275+
if (isPublicApiDisabled) {
276+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
277+
}
278+
279+
userId = wf.userId
280+
isPublicApiAccess = true
281+
} else {
282+
userId = auth.userId
259283
}
260-
const userId = auth.userId
261284

262285
let body: any = {}
263286
try {
@@ -284,7 +307,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
284307
)
285308
}
286309

287-
const defaultTriggerType = auth.authType === 'api_key' ? 'api' : 'manual'
310+
const defaultTriggerType = isPublicApiAccess || auth.authType === 'api_key' ? 'api' : 'manual'
288311

289312
const {
290313
selectedOutputs,
@@ -341,7 +364,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
341364
// For API key and internal JWT auth, the entire body is the input (except for our control fields)
342365
// For session auth, the input is explicitly provided in the input field
343366
const input =
344-
auth.authType === 'api_key' || auth.authType === 'internal_jwt'
367+
isPublicApiAccess || auth.authType === 'api_key' || auth.authType === 'internal_jwt'
345368
? (() => {
346369
const {
347370
selectedOutputs,
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { db, workflow } from '@sim/db'
2+
import { createLogger } from '@sim/logger'
3+
import { eq } from 'drizzle-orm'
4+
import type { NextRequest } from 'next/server'
5+
import { z } from 'zod'
6+
import { generateRequestId } from '@/lib/core/utils/request'
7+
import { validateWorkflowPermissions } from '@/lib/workflows/utils'
8+
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
9+
10+
const logger = createLogger('WorkflowPublicApiRoute')
11+
12+
export const dynamic = 'force-dynamic'
13+
export const runtime = 'nodejs'
14+
15+
const UpdatePublicApiSchema = z.object({
16+
isPublicApi: z.boolean(),
17+
})
18+
19+
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
20+
const requestId = generateRequestId()
21+
const { id } = await params
22+
23+
try {
24+
const { error } = await validateWorkflowPermissions(id, requestId, 'admin')
25+
if (error) {
26+
return createErrorResponse(error.message, error.status)
27+
}
28+
29+
const body = await request.json()
30+
const validation = UpdatePublicApiSchema.safeParse(body)
31+
if (!validation.success) {
32+
return createErrorResponse('Invalid request body', 400)
33+
}
34+
35+
const { isPublicApi } = validation.data
36+
37+
if (isPublicApi) {
38+
const { isPublicApiDisabled } = await import('@/lib/core/config/feature-flags')
39+
if (isPublicApiDisabled) {
40+
return createErrorResponse('Public API access is disabled', 403)
41+
}
42+
}
43+
44+
await db.update(workflow).set({ isPublicApi }).where(eq(workflow.id, id))
45+
46+
logger.info(`[${requestId}] Updated isPublicApi for workflow ${id} to ${isPublicApi}`)
47+
48+
return createSuccessResponse({ isPublicApi })
49+
} catch (error: unknown) {
50+
const message = error instanceof Error ? error.message : 'Failed to update public API setting'
51+
logger.error(`[${requestId}] Error updating public API setting: ${id}`, { error })
52+
return createErrorResponse(message, 500)
53+
}
54+
}

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/api/api.tsx

Lines changed: 16 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface WorkflowDeploymentInfo {
2121
endpoint: string
2222
exampleCommand: string
2323
needsRedeployment: boolean
24+
isPublicApi?: boolean
2425
}
2526

2627
interface ApiDeployProps {
@@ -107,12 +108,12 @@ export function ApiDeploy({
107108
if (!info) return ''
108109
const endpoint = getBaseEndpoint()
109110
const payload = getPayloadObject()
111+
const isPublic = info.isPublicApi
110112

111113
switch (language) {
112114
case 'curl':
113115
return `curl -X POST \\
114-
-H "X-API-Key: $SIM_API_KEY" \\
115-
-H "Content-Type: application/json" \\
116+
${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\
116117
-d '${JSON.stringify(payload)}' \\
117118
${endpoint}`
118119

@@ -123,8 +124,7 @@ import requests
123124
response = requests.post(
124125
"${endpoint}",
125126
headers={
126-
"X-API-Key": os.environ.get("SIM_API_KEY"),
127-
"Content-Type": "application/json"
127+
${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json"
128128
},
129129
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
130130
)
@@ -135,8 +135,7 @@ print(response.json())`
135135
return `const response = await fetch("${endpoint}", {
136136
method: "POST",
137137
headers: {
138-
"X-API-Key": process.env.SIM_API_KEY,
139-
"Content-Type": "application/json"
138+
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json"
140139
},
141140
body: JSON.stringify(${JSON.stringify(payload)})
142141
});
@@ -148,8 +147,7 @@ console.log(data);`
148147
return `const response = await fetch("${endpoint}", {
149148
method: "POST",
150149
headers: {
151-
"X-API-Key": process.env.SIM_API_KEY,
152-
"Content-Type": "application/json"
150+
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json"
153151
},
154152
body: JSON.stringify(${JSON.stringify(payload)})
155153
});
@@ -166,12 +164,12 @@ console.log(data);`
166164
if (!info) return ''
167165
const endpoint = getBaseEndpoint()
168166
const payload = getStreamPayloadObject()
167+
const isPublic = info.isPublicApi
169168

170169
switch (language) {
171170
case 'curl':
172171
return `curl -X POST \\
173-
-H "X-API-Key: $SIM_API_KEY" \\
174-
-H "Content-Type: application/json" \\
172+
${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\
175173
-d '${JSON.stringify(payload)}' \\
176174
${endpoint}`
177175

@@ -182,8 +180,7 @@ import requests
182180
response = requests.post(
183181
"${endpoint}",
184182
headers={
185-
"X-API-Key": os.environ.get("SIM_API_KEY"),
186-
"Content-Type": "application/json"
183+
${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json"
187184
},
188185
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')},
189186
stream=True
@@ -197,8 +194,7 @@ for line in response.iter_lines():
197194
return `const response = await fetch("${endpoint}", {
198195
method: "POST",
199196
headers: {
200-
"X-API-Key": process.env.SIM_API_KEY,
201-
"Content-Type": "application/json"
197+
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json"
202198
},
203199
body: JSON.stringify(${JSON.stringify(payload)})
204200
});
@@ -216,8 +212,7 @@ while (true) {
216212
return `const response = await fetch("${endpoint}", {
217213
method: "POST",
218214
headers: {
219-
"X-API-Key": process.env.SIM_API_KEY,
220-
"Content-Type": "application/json"
215+
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json"
221216
},
222217
body: JSON.stringify(${JSON.stringify(payload)})
223218
});
@@ -241,14 +236,14 @@ while (true) {
241236
const endpoint = getBaseEndpoint()
242237
const baseUrl = endpoint.split('/api/workflows/')[0]
243238
const payload = getPayloadObject()
239+
const isPublic = info.isPublicApi
244240

245241
switch (asyncExampleType) {
246242
case 'execute':
247243
switch (language) {
248244
case 'curl':
249245
return `curl -X POST \\
250-
-H "X-API-Key: $SIM_API_KEY" \\
251-
-H "Content-Type: application/json" \\
246+
${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\
252247
-H "X-Execution-Mode: async" \\
253248
-d '${JSON.stringify(payload)}' \\
254249
${endpoint}`
@@ -260,8 +255,7 @@ import requests
260255
response = requests.post(
261256
"${endpoint}",
262257
headers={
263-
"X-API-Key": os.environ.get("SIM_API_KEY"),
264-
"Content-Type": "application/json",
258+
${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json",
265259
"X-Execution-Mode": "async"
266260
},
267261
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
@@ -274,8 +268,7 @@ print(job) # Contains jobId and executionId`
274268
return `const response = await fetch("${endpoint}", {
275269
method: "POST",
276270
headers: {
277-
"X-API-Key": process.env.SIM_API_KEY,
278-
"Content-Type": "application/json",
271+
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json",
279272
"X-Execution-Mode": "async"
280273
},
281274
body: JSON.stringify(${JSON.stringify(payload)})
@@ -288,8 +281,7 @@ console.log(job); // Contains jobId and executionId`
288281
return `const response = await fetch("${endpoint}", {
289282
method: "POST",
290283
headers: {
291-
"X-API-Key": process.env.SIM_API_KEY,
292-
"Content-Type": "application/json",
284+
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json",
293285
"X-Execution-Mode": "async"
294286
},
295287
body: JSON.stringify(${JSON.stringify(payload)})

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/general/components/api-info-modal.tsx

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import {
55
Badge,
66
Button,
7+
ButtonGroup,
8+
ButtonGroupItem,
79
Input,
810
Label,
911
Modal,
@@ -16,6 +18,8 @@ import {
1618
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
1719
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
1820
import type { InputFormatField } from '@/lib/workflows/types'
21+
import { useDeploymentInfo, useUpdatePublicApi } from '@/hooks/queries/deployments'
22+
import { usePermissionConfig } from '@/hooks/use-permission-config'
1923
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
2024
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
2125
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -40,13 +44,19 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
4044
)
4145
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
4246

47+
const { data: deploymentData } = useDeploymentInfo(workflowId, { enabled: open })
48+
const updatePublicApiMutation = useUpdatePublicApi()
49+
const { isPublicApiDisabled } = usePermissionConfig()
50+
4351
const [description, setDescription] = useState('')
4452
const [paramDescriptions, setParamDescriptions] = useState<Record<string, string>>({})
53+
const [accessMode, setAccessMode] = useState<'api_key' | 'public'>('api_key')
4554
const [isSaving, setIsSaving] = useState(false)
4655
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
4756

4857
const initialDescriptionRef = useRef('')
4958
const initialParamDescriptionsRef = useRef<Record<string, string>>({})
59+
const initialAccessModeRef = useRef<'api_key' | 'public'>('api_key')
5060

5161
const starterBlockId = useMemo(() => {
5262
for (const [blockId, block] of Object.entries(blocks)) {
@@ -92,11 +102,16 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
92102
}
93103
setParamDescriptions(descriptions)
94104
initialParamDescriptionsRef.current = { ...descriptions }
105+
106+
const initialAccess = deploymentData?.isPublicApi ? 'public' : 'api_key'
107+
setAccessMode(initialAccess)
108+
initialAccessModeRef.current = initialAccess
95109
}
96-
}, [open, workflowMetadata, inputFormat])
110+
}, [open, workflowMetadata, inputFormat, deploymentData])
97111

98112
const hasChanges = useMemo(() => {
99113
if (description.trim() !== initialDescriptionRef.current.trim()) return true
114+
if (accessMode !== initialAccessModeRef.current) return true
100115

101116
for (const field of inputFormat) {
102117
const currentValue = (paramDescriptions[field.name] || '').trim()
@@ -105,7 +120,7 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
105120
}
106121

107122
return false
108-
}, [description, paramDescriptions, inputFormat])
123+
}, [description, paramDescriptions, inputFormat, accessMode])
109124

110125
const handleParamDescriptionChange = (fieldName: string, value: string) => {
111126
setParamDescriptions((prev) => ({
@@ -126,6 +141,7 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
126141
setShowUnsavedChangesAlert(false)
127142
setDescription(initialDescriptionRef.current)
128143
setParamDescriptions({ ...initialParamDescriptionsRef.current })
144+
setAccessMode(initialAccessModeRef.current)
129145
onOpenChange(false)
130146
}, [onOpenChange])
131147

@@ -151,6 +167,13 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
151167
setValue(starterBlockId, 'inputFormat', updatedValue)
152168
}
153169

170+
if (accessMode !== initialAccessModeRef.current) {
171+
await updatePublicApiMutation.mutateAsync({
172+
workflowId,
173+
isPublicApi: accessMode === 'public',
174+
})
175+
}
176+
154177
onOpenChange(false)
155178
} finally {
156179
setIsSaving(false)
@@ -165,6 +188,8 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
165188
paramDescriptions,
166189
setValue,
167190
onOpenChange,
191+
accessMode,
192+
updatePublicApiMutation,
168193
])
169194

170195
return (
@@ -187,6 +212,26 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
187212
/>
188213
</div>
189214

215+
{!isPublicApiDisabled && (
216+
<div>
217+
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
218+
Access
219+
</Label>
220+
<ButtonGroup
221+
value={accessMode}
222+
onValueChange={(val) => setAccessMode(val as 'api_key' | 'public')}
223+
>
224+
<ButtonGroupItem value='api_key'>API Key</ButtonGroupItem>
225+
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
226+
</ButtonGroup>
227+
<p className='mt-1 text-[12px] text-[var(--text-secondary)]'>
228+
{accessMode === 'public'
229+
? 'Anyone can call this API without authentication. You will be billed for all usage.'
230+
: 'Requires a valid API key to call this endpoint.'}
231+
</p>
232+
</div>
233+
)}
234+
190235
{inputFormat.length > 0 && (
191236
<div>
192237
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>

0 commit comments

Comments
 (0)