mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
feat(public-api): add env var and permission group controls to disable public API access (#3317)
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>
This commit is contained in:
@@ -37,8 +37,8 @@ import {
|
||||
EyeIcon,
|
||||
FirecrawlIcon,
|
||||
FirefliesIcon,
|
||||
GitLabIcon,
|
||||
GithubIcon,
|
||||
GitLabIcon,
|
||||
GmailIcon,
|
||||
GongIcon,
|
||||
GoogleBooksIcon,
|
||||
@@ -71,9 +71,9 @@ import {
|
||||
LinearIcon,
|
||||
LinkedInIcon,
|
||||
LinkupIcon,
|
||||
MailServerIcon,
|
||||
MailchimpIcon,
|
||||
MailgunIcon,
|
||||
MailServerIcon,
|
||||
Mem0Icon,
|
||||
MicrosoftDataverseIcon,
|
||||
MicrosoftExcelIcon,
|
||||
@@ -106,8 +106,6 @@ import {
|
||||
ResendIcon,
|
||||
RevenueCatIcon,
|
||||
S3Icon,
|
||||
SQSIcon,
|
||||
STTIcon,
|
||||
SalesforceIcon,
|
||||
SearchIcon,
|
||||
SendgridIcon,
|
||||
@@ -119,17 +117,19 @@ import {
|
||||
SimilarwebIcon,
|
||||
SlackIcon,
|
||||
SmtpIcon,
|
||||
SQSIcon,
|
||||
SshIcon,
|
||||
STTIcon,
|
||||
StagehandIcon,
|
||||
StripeIcon,
|
||||
SupabaseIcon,
|
||||
TTSIcon,
|
||||
TavilyIcon,
|
||||
TelegramIcon,
|
||||
TextractIcon,
|
||||
TinybirdIcon,
|
||||
TranslateIcon,
|
||||
TrelloIcon,
|
||||
TTSIcon,
|
||||
TwilioIcon,
|
||||
TypeformIcon,
|
||||
UpstashIcon,
|
||||
@@ -140,11 +140,11 @@ import {
|
||||
WhatsAppIcon,
|
||||
WikipediaIcon,
|
||||
WordpressIcon,
|
||||
xIcon,
|
||||
YouTubeIcon,
|
||||
ZendeskIcon,
|
||||
ZepIcon,
|
||||
ZoomIcon,
|
||||
xIcon,
|
||||
} from '@/components/icons'
|
||||
|
||||
type IconComponent = ComponentType<SVGProps<SVGSVGElement>>
|
||||
|
||||
@@ -145,4 +145,4 @@
|
||||
"zep",
|
||||
"zoom"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
deployedAt: null,
|
||||
apiKey: null,
|
||||
needsRedeployment: false,
|
||||
isPublicApi: workflowData.isPublicApi ?? false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -98,6 +99,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
isDeployed: workflowData.isDeployed,
|
||||
deployedAt: workflowData.deployedAt,
|
||||
needsRedeployment,
|
||||
isPublicApi: workflowData.isPublicApi ?? false,
|
||||
})
|
||||
} catch (error: any) {
|
||||
logger.error(`[${requestId}] Error fetching deployment info: ${id}`, error)
|
||||
@@ -301,6 +303,49 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const requestId = generateRequestId()
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
const { error, session } = await validateWorkflowPermissions(id, requestId, 'admin')
|
||||
if (error) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { isPublicApi } = body
|
||||
|
||||
if (typeof isPublicApi !== 'boolean') {
|
||||
return createErrorResponse('Invalid request body: isPublicApi must be a boolean', 400)
|
||||
}
|
||||
|
||||
if (isPublicApi) {
|
||||
const { validatePublicApiAllowed, PublicApiNotAllowedError } = await import(
|
||||
'@/ee/access-control/utils/permission-check'
|
||||
)
|
||||
try {
|
||||
await validatePublicApiAllowed(session?.user?.id)
|
||||
} catch (err) {
|
||||
if (err instanceof PublicApiNotAllowedError) {
|
||||
return createErrorResponse('Public API access is disabled', 403)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
await db.update(workflow).set({ isPublicApi }).where(eq(workflow.id, id))
|
||||
|
||||
logger.info(`[${requestId}] Updated isPublicApi for workflow ${id} to ${isPublicApi}`)
|
||||
|
||||
return createSuccessResponse({ isPublicApi })
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to update deployment settings'
|
||||
logger.error(`[${requestId}] Error updating deployment settings: ${id}`, { error })
|
||||
return createErrorResponse(message, 500)
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
|
||||
@@ -254,10 +254,49 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
|
||||
try {
|
||||
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
|
||||
|
||||
let userId: string
|
||||
let isPublicApiAccess = false
|
||||
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
const hasExplicitCredentials =
|
||||
req.headers.has('x-api-key') || req.headers.get('authorization')?.startsWith('Bearer ')
|
||||
if (hasExplicitCredentials) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { db: dbClient, workflow: workflowTable } = await import('@sim/db')
|
||||
const { eq } = await import('drizzle-orm')
|
||||
const [wf] = await dbClient
|
||||
.select({
|
||||
isPublicApi: workflowTable.isPublicApi,
|
||||
isDeployed: workflowTable.isDeployed,
|
||||
userId: workflowTable.userId,
|
||||
})
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!wf?.isPublicApi || !wf.isDeployed) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { isPublicApiDisabled } = await import('@/lib/core/config/feature-flags')
|
||||
if (isPublicApiDisabled) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const { getUserPermissionConfig } = await import('@/ee/access-control/utils/permission-check')
|
||||
const ownerConfig = await getUserPermissionConfig(wf.userId)
|
||||
if (ownerConfig?.disablePublicApi) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
userId = wf.userId
|
||||
isPublicApiAccess = true
|
||||
} else {
|
||||
userId = auth.userId
|
||||
}
|
||||
const userId = auth.userId
|
||||
|
||||
let body: any = {}
|
||||
try {
|
||||
@@ -284,7 +323,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
)
|
||||
}
|
||||
|
||||
const defaultTriggerType = auth.authType === 'api_key' ? 'api' : 'manual'
|
||||
const defaultTriggerType = isPublicApiAccess || auth.authType === 'api_key' ? 'api' : 'manual'
|
||||
|
||||
const {
|
||||
selectedOutputs,
|
||||
@@ -305,7 +344,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
| { startBlockId: string; sourceSnapshot: SerializableExecutionState }
|
||||
| undefined
|
||||
if (rawRunFromBlock) {
|
||||
if (rawRunFromBlock.sourceSnapshot) {
|
||||
if (rawRunFromBlock.sourceSnapshot && !isPublicApiAccess) {
|
||||
// Public API callers cannot inject arbitrary block state via sourceSnapshot.
|
||||
// They must use executionId to resume from a server-stored execution state.
|
||||
resolvedRunFromBlock = {
|
||||
startBlockId: rawRunFromBlock.startBlockId,
|
||||
sourceSnapshot: rawRunFromBlock.sourceSnapshot as SerializableExecutionState,
|
||||
@@ -341,7 +382,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
// For API key and internal JWT auth, the entire body is the input (except for our control fields)
|
||||
// For session auth, the input is explicitly provided in the input field
|
||||
const input =
|
||||
auth.authType === 'api_key' || auth.authType === 'internal_jwt'
|
||||
isPublicApiAccess || auth.authType === 'api_key' || auth.authType === 'internal_jwt'
|
||||
? (() => {
|
||||
const {
|
||||
selectedOutputs,
|
||||
@@ -360,7 +401,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
})()
|
||||
: validatedInput
|
||||
|
||||
const shouldUseDraftState = useDraftState ?? auth.authType === 'session'
|
||||
// Public API callers must not inject arbitrary workflow state overrides (code injection risk).
|
||||
// stopAfterBlockId and runFromBlock are safe — they control execution flow within the deployed state.
|
||||
const sanitizedWorkflowStateOverride = isPublicApiAccess ? undefined : workflowStateOverride
|
||||
|
||||
// Public API callers always execute the deployed state, never the draft.
|
||||
const shouldUseDraftState = isPublicApiAccess
|
||||
? false
|
||||
: (useDraftState ?? auth.authType === 'session')
|
||||
const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
|
||||
workflowId,
|
||||
userId,
|
||||
@@ -533,7 +581,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
)
|
||||
}
|
||||
|
||||
const effectiveWorkflowStateOverride = workflowStateOverride || cachedWorkflowData || undefined
|
||||
const effectiveWorkflowStateOverride =
|
||||
sanitizedWorkflowStateOverride || cachedWorkflowData || undefined
|
||||
|
||||
if (!enableSSE) {
|
||||
logger.info(`[${requestId}] Using non-SSE execution (direct JSON response)`)
|
||||
|
||||
@@ -21,6 +21,7 @@ interface WorkflowDeploymentInfo {
|
||||
endpoint: string
|
||||
exampleCommand: string
|
||||
needsRedeployment: boolean
|
||||
isPublicApi?: boolean
|
||||
}
|
||||
|
||||
interface ApiDeployProps {
|
||||
@@ -107,12 +108,12 @@ export function ApiDeploy({
|
||||
if (!info) return ''
|
||||
const endpoint = getBaseEndpoint()
|
||||
const payload = getPayloadObject()
|
||||
const isPublic = info.isPublicApi
|
||||
|
||||
switch (language) {
|
||||
case 'curl':
|
||||
return `curl -X POST \\
|
||||
-H "X-API-Key: $SIM_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\
|
||||
-d '${JSON.stringify(payload)}' \\
|
||||
${endpoint}`
|
||||
|
||||
@@ -123,8 +124,7 @@ import requests
|
||||
response = requests.post(
|
||||
"${endpoint}",
|
||||
headers={
|
||||
"X-API-Key": os.environ.get("SIM_API_KEY"),
|
||||
"Content-Type": "application/json"
|
||||
${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json"
|
||||
},
|
||||
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
|
||||
)
|
||||
@@ -135,8 +135,7 @@ print(response.json())`
|
||||
return `const response = await fetch("${endpoint}", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-API-Key": process.env.SIM_API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(${JSON.stringify(payload)})
|
||||
});
|
||||
@@ -148,8 +147,7 @@ console.log(data);`
|
||||
return `const response = await fetch("${endpoint}", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-API-Key": process.env.SIM_API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(${JSON.stringify(payload)})
|
||||
});
|
||||
@@ -166,12 +164,12 @@ console.log(data);`
|
||||
if (!info) return ''
|
||||
const endpoint = getBaseEndpoint()
|
||||
const payload = getStreamPayloadObject()
|
||||
const isPublic = info.isPublicApi
|
||||
|
||||
switch (language) {
|
||||
case 'curl':
|
||||
return `curl -X POST \\
|
||||
-H "X-API-Key: $SIM_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\
|
||||
-d '${JSON.stringify(payload)}' \\
|
||||
${endpoint}`
|
||||
|
||||
@@ -182,8 +180,7 @@ import requests
|
||||
response = requests.post(
|
||||
"${endpoint}",
|
||||
headers={
|
||||
"X-API-Key": os.environ.get("SIM_API_KEY"),
|
||||
"Content-Type": "application/json"
|
||||
${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json"
|
||||
},
|
||||
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')},
|
||||
stream=True
|
||||
@@ -197,8 +194,7 @@ for line in response.iter_lines():
|
||||
return `const response = await fetch("${endpoint}", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-API-Key": process.env.SIM_API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(${JSON.stringify(payload)})
|
||||
});
|
||||
@@ -216,8 +212,7 @@ while (true) {
|
||||
return `const response = await fetch("${endpoint}", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-API-Key": process.env.SIM_API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(${JSON.stringify(payload)})
|
||||
});
|
||||
@@ -241,14 +236,14 @@ while (true) {
|
||||
const endpoint = getBaseEndpoint()
|
||||
const baseUrl = endpoint.split('/api/workflows/')[0]
|
||||
const payload = getPayloadObject()
|
||||
const isPublic = info.isPublicApi
|
||||
|
||||
switch (asyncExampleType) {
|
||||
case 'execute':
|
||||
switch (language) {
|
||||
case 'curl':
|
||||
return `curl -X POST \\
|
||||
-H "X-API-Key: $SIM_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
${isPublic ? '' : ' -H "X-API-Key: $SIM_API_KEY" \\\n'} -H "Content-Type: application/json" \\
|
||||
-H "X-Execution-Mode: async" \\
|
||||
-d '${JSON.stringify(payload)}' \\
|
||||
${endpoint}`
|
||||
@@ -260,8 +255,7 @@ import requests
|
||||
response = requests.post(
|
||||
"${endpoint}",
|
||||
headers={
|
||||
"X-API-Key": os.environ.get("SIM_API_KEY"),
|
||||
"Content-Type": "application/json",
|
||||
${isPublic ? '' : ' "X-API-Key": os.environ.get("SIM_API_KEY"),\n'} "Content-Type": "application/json",
|
||||
"X-Execution-Mode": "async"
|
||||
},
|
||||
json=${JSON.stringify(payload, null, 4).replace(/\n/g, '\n ')}
|
||||
@@ -274,8 +268,7 @@ print(job) # Contains jobId and executionId`
|
||||
return `const response = await fetch("${endpoint}", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-API-Key": process.env.SIM_API_KEY,
|
||||
"Content-Type": "application/json",
|
||||
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json",
|
||||
"X-Execution-Mode": "async"
|
||||
},
|
||||
body: JSON.stringify(${JSON.stringify(payload)})
|
||||
@@ -288,8 +281,7 @@ console.log(job); // Contains jobId and executionId`
|
||||
return `const response = await fetch("${endpoint}", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-API-Key": process.env.SIM_API_KEY,
|
||||
"Content-Type": "application/json",
|
||||
${isPublic ? '' : ' "X-API-Key": process.env.SIM_API_KEY,\n'} "Content-Type": "application/json",
|
||||
"X-Execution-Mode": "async"
|
||||
},
|
||||
body: JSON.stringify(${JSON.stringify(payload)})
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
ButtonGroupItem,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
@@ -16,6 +18,8 @@ import {
|
||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
||||
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||
import type { InputFormatField } from '@/lib/workflows/types'
|
||||
import { useDeploymentInfo, useUpdatePublicApi } from '@/hooks/queries/deployments'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
@@ -40,13 +44,20 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
|
||||
)
|
||||
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
|
||||
|
||||
const { data: deploymentData } = useDeploymentInfo(workflowId, { enabled: open })
|
||||
const updatePublicApiMutation = useUpdatePublicApi()
|
||||
const { isPublicApiDisabled } = usePermissionConfig()
|
||||
|
||||
const [description, setDescription] = useState('')
|
||||
const [paramDescriptions, setParamDescriptions] = useState<Record<string, string>>({})
|
||||
const [accessMode, setAccessMode] = useState<'api_key' | 'public'>('api_key')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState<string | null>(null)
|
||||
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
||||
|
||||
const initialDescriptionRef = useRef('')
|
||||
const initialParamDescriptionsRef = useRef<Record<string, string>>({})
|
||||
const initialAccessModeRef = useRef<'api_key' | 'public'>('api_key')
|
||||
|
||||
const starterBlockId = useMemo(() => {
|
||||
for (const [blockId, block] of Object.entries(blocks)) {
|
||||
@@ -71,6 +82,8 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
|
||||
return normalizeInputFormatValue(blockValue) as NormalizedField[]
|
||||
}, [starterBlockId, subBlockValues, blocks])
|
||||
|
||||
const accessModeInitializedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const normalizedDesc = workflowMetadata?.description?.toLowerCase().trim()
|
||||
@@ -92,11 +105,24 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
|
||||
}
|
||||
setParamDescriptions(descriptions)
|
||||
initialParamDescriptionsRef.current = { ...descriptions }
|
||||
|
||||
setSaveError(null)
|
||||
accessModeInitializedRef.current = false
|
||||
}
|
||||
}, [open, workflowMetadata, inputFormat])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && deploymentData && !accessModeInitializedRef.current) {
|
||||
const initialAccess = deploymentData.isPublicApi ? 'public' : 'api_key'
|
||||
setAccessMode(initialAccess)
|
||||
initialAccessModeRef.current = initialAccess
|
||||
accessModeInitializedRef.current = true
|
||||
}
|
||||
}, [open, deploymentData])
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (description.trim() !== initialDescriptionRef.current.trim()) return true
|
||||
if (accessMode !== initialAccessModeRef.current) return true
|
||||
|
||||
for (const field of inputFormat) {
|
||||
const currentValue = (paramDescriptions[field.name] || '').trim()
|
||||
@@ -105,7 +131,7 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
|
||||
}
|
||||
|
||||
return false
|
||||
}, [description, paramDescriptions, inputFormat])
|
||||
}, [description, paramDescriptions, inputFormat, accessMode])
|
||||
|
||||
const handleParamDescriptionChange = (fieldName: string, value: string) => {
|
||||
setParamDescriptions((prev) => ({
|
||||
@@ -126,6 +152,7 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
|
||||
setShowUnsavedChangesAlert(false)
|
||||
setDescription(initialDescriptionRef.current)
|
||||
setParamDescriptions({ ...initialParamDescriptionsRef.current })
|
||||
setAccessMode(initialAccessModeRef.current)
|
||||
onOpenChange(false)
|
||||
}, [onOpenChange])
|
||||
|
||||
@@ -138,7 +165,15 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
setSaveError(null)
|
||||
try {
|
||||
if (accessMode !== initialAccessModeRef.current) {
|
||||
await updatePublicApiMutation.mutateAsync({
|
||||
workflowId,
|
||||
isPublicApi: accessMode === 'public',
|
||||
})
|
||||
}
|
||||
|
||||
if (description.trim() !== (workflowMetadata?.description || '')) {
|
||||
updateWorkflow(workflowId, { description: description.trim() || 'New workflow' })
|
||||
}
|
||||
@@ -152,6 +187,9 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
|
||||
}
|
||||
|
||||
onOpenChange(false)
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update access settings'
|
||||
setSaveError(message)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
@@ -165,6 +203,8 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
|
||||
paramDescriptions,
|
||||
setValue,
|
||||
onOpenChange,
|
||||
accessMode,
|
||||
updatePublicApiMutation,
|
||||
])
|
||||
|
||||
return (
|
||||
@@ -187,6 +227,26 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isPublicApiDisabled && (
|
||||
<div>
|
||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
Access
|
||||
</Label>
|
||||
<ButtonGroup
|
||||
value={accessMode}
|
||||
onValueChange={(val) => setAccessMode(val as 'api_key' | 'public')}
|
||||
>
|
||||
<ButtonGroupItem value='api_key'>API Key</ButtonGroupItem>
|
||||
<ButtonGroupItem value='public'>Public</ButtonGroupItem>
|
||||
</ButtonGroup>
|
||||
<p className='mt-1 text-[12px] text-[var(--text-secondary)]'>
|
||||
{accessMode === 'public'
|
||||
? 'Anyone can call this API without authentication. You will be billed for all usage.'
|
||||
: 'Requires a valid API key to call this endpoint.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inputFormat.length > 0 && (
|
||||
<div>
|
||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||
@@ -227,6 +287,9 @@ export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalPro
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
{saveError && (
|
||||
<p className='mr-auto text-[12px] text-[var(--text-error)]'>{saveError}</p>
|
||||
)}
|
||||
<Button variant='default' onClick={handleCloseAttempt} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -70,6 +70,7 @@ interface WorkflowDeploymentInfoUI {
|
||||
endpoint: string
|
||||
exampleCommand: string
|
||||
needsRedeployment: boolean
|
||||
isPublicApi: boolean
|
||||
}
|
||||
|
||||
type TabView = 'general' | 'api' | 'chat' | 'template' | 'mcp' | 'form' | 'a2a'
|
||||
@@ -117,7 +118,7 @@ export function DeployModal({
|
||||
const [isApiInfoModalOpen, setIsApiInfoModalOpen] = useState(false)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const canManageWorkspaceKeys = userPermissions.canAdmin
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
const { config: permissionConfig, isPublicApiDisabled } = usePermissionConfig()
|
||||
const { data: apiKeysData, isLoading: isLoadingKeys } = useApiKeys(workflowWorkspaceId || '')
|
||||
const { data: workspaceSettingsData, isLoading: isLoadingSettings } = useWorkspaceSettings(
|
||||
workflowWorkspaceId || ''
|
||||
@@ -214,9 +215,11 @@ export function DeployModal({
|
||||
endpoint,
|
||||
exampleCommand: `curl -X POST -H "X-API-Key: ${placeholderKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
|
||||
needsRedeployment: deploymentInfoData.needsRedeployment,
|
||||
isPublicApi: isPublicApiDisabled ? false : (deploymentInfoData.isPublicApi ?? false),
|
||||
}
|
||||
}, [
|
||||
deploymentInfoData,
|
||||
isPublicApiDisabled,
|
||||
workflowId,
|
||||
selectedStreamingOutputs,
|
||||
getInputFormatExample,
|
||||
|
||||
@@ -391,6 +391,12 @@ export function AccessControl() {
|
||||
category: 'Collaboration',
|
||||
configKey: 'disableInvitations' as const,
|
||||
},
|
||||
{
|
||||
id: 'disable-public-api',
|
||||
label: 'Public API',
|
||||
category: 'Features',
|
||||
configKey: 'disablePublicApi' as const,
|
||||
},
|
||||
],
|
||||
[]
|
||||
)
|
||||
@@ -966,6 +972,7 @@ export function AccessControl() {
|
||||
!editingConfig?.disableSkills &&
|
||||
!editingConfig?.hideTraceSpans &&
|
||||
!editingConfig?.disableInvitations &&
|
||||
!editingConfig?.disablePublicApi &&
|
||||
!editingConfig?.hideDeployApi &&
|
||||
!editingConfig?.hideDeployMcp &&
|
||||
!editingConfig?.hideDeployA2a &&
|
||||
@@ -987,6 +994,7 @@ export function AccessControl() {
|
||||
disableSkills: allVisible,
|
||||
hideTraceSpans: allVisible,
|
||||
disableInvitations: allVisible,
|
||||
disablePublicApi: allVisible,
|
||||
hideDeployApi: allVisible,
|
||||
hideDeployMcp: allVisible,
|
||||
hideDeployA2a: allVisible,
|
||||
@@ -1009,6 +1017,7 @@ export function AccessControl() {
|
||||
!editingConfig?.disableSkills &&
|
||||
!editingConfig?.hideTraceSpans &&
|
||||
!editingConfig?.disableInvitations &&
|
||||
!editingConfig?.disablePublicApi &&
|
||||
!editingConfig?.hideDeployApi &&
|
||||
!editingConfig?.hideDeployMcp &&
|
||||
!editingConfig?.hideDeployA2a &&
|
||||
|
||||
@@ -66,6 +66,13 @@ export class InvitationsNotAllowedError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class PublicApiNotAllowedError extends Error {
|
||||
constructor() {
|
||||
super('Public API access is not allowed based on your permission group settings')
|
||||
this.name = 'PublicApiNotAllowedError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the env allowlist into a permission config.
|
||||
* If `config` is null and no env allowlist is set, returns null.
|
||||
@@ -296,3 +303,30 @@ export async function validateInvitationsAllowed(userId: string | undefined): Pr
|
||||
throw new InvitationsNotAllowedError()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if the user is allowed to enable public API access.
|
||||
* Also checks the global feature flag.
|
||||
*/
|
||||
export async function validatePublicApiAllowed(userId: string | undefined): Promise<void> {
|
||||
const { isPublicApiDisabled } = await import('@/lib/core/config/feature-flags')
|
||||
if (isPublicApiDisabled) {
|
||||
logger.warn('Public API blocked by feature flag')
|
||||
throw new PublicApiNotAllowedError()
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = await getUserPermissionConfig(userId)
|
||||
|
||||
if (!config) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.disablePublicApi) {
|
||||
logger.warn('Public API blocked by permission group', { userId })
|
||||
throw new PublicApiNotAllowedError()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface WorkflowDeploymentInfo {
|
||||
deployedAt: string | null
|
||||
apiKey: string | null
|
||||
needsRedeployment: boolean
|
||||
isPublicApi: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +51,7 @@ async function fetchDeploymentInfo(workflowId: string): Promise<WorkflowDeployme
|
||||
deployedAt: data.deployedAt ?? null,
|
||||
apiKey: data.apiKey ?? null,
|
||||
needsRedeployment: data.needsRedeployment ?? false,
|
||||
isPublicApi: data.isPublicApi ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,3 +616,49 @@ export function useActivateDeploymentVersion() {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables for updating public API access
|
||||
*/
|
||||
interface UpdatePublicApiVariables {
|
||||
workflowId: string
|
||||
isPublicApi: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation hook for toggling a workflow's public API access.
|
||||
* Invalidates deployment info query on success.
|
||||
*/
|
||||
export function useUpdatePublicApi() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ workflowId, isPublicApi }: UpdatePublicApiVariables) => {
|
||||
const response = await fetch(`/api/workflows/${workflowId}/deploy`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isPublicApi }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to update public API setting')
|
||||
}
|
||||
|
||||
return response.json()
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
logger.info('Public API setting updated', {
|
||||
workflowId: variables.workflowId,
|
||||
isPublicApi: variables.isPublicApi,
|
||||
})
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentKeys.info(variables.workflowId),
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to update public API setting', { error })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface PermissionConfigResult {
|
||||
isBlockAllowed: (blockType: string) => boolean
|
||||
isProviderAllowed: (providerId: string) => boolean
|
||||
isInvitationsDisabled: boolean
|
||||
isPublicApiDisabled: boolean
|
||||
}
|
||||
|
||||
interface AllowedIntegrationsResponse {
|
||||
@@ -116,6 +117,11 @@ export function usePermissionConfig(): PermissionConfigResult {
|
||||
return featureFlagDisabled || config.disableInvitations
|
||||
}, [config.disableInvitations])
|
||||
|
||||
const isPublicApiDisabled = useMemo(() => {
|
||||
const featureFlagDisabled = isTruthy(getEnv('NEXT_PUBLIC_DISABLE_PUBLIC_API'))
|
||||
return featureFlagDisabled || config.disablePublicApi
|
||||
}, [config.disablePublicApi])
|
||||
|
||||
const mergedConfig = useMemo(
|
||||
() => ({ ...config, allowedIntegrations: mergedAllowedIntegrations }),
|
||||
[config, mergedAllowedIntegrations]
|
||||
@@ -131,6 +137,7 @@ export function usePermissionConfig(): PermissionConfigResult {
|
||||
isBlockAllowed,
|
||||
isProviderAllowed,
|
||||
isInvitationsDisabled,
|
||||
isPublicApiDisabled,
|
||||
}),
|
||||
[
|
||||
mergedConfig,
|
||||
@@ -141,6 +148,7 @@ export function usePermissionConfig(): PermissionConfigResult {
|
||||
isBlockAllowed,
|
||||
isProviderAllowed,
|
||||
isInvitationsDisabled,
|
||||
isPublicApiDisabled,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -297,6 +297,7 @@ export const env = createEnv({
|
||||
|
||||
// Invitations - for self-hosted deployments
|
||||
DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments)
|
||||
DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access globally (for self-hosted deployments)
|
||||
|
||||
// Development Tools
|
||||
REACT_GRAB_ENABLED: z.boolean().optional(), // Enable React Grab for UI element debugging in Cursor/AI agents (dev only)
|
||||
@@ -382,6 +383,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: z.boolean().optional(), // Enable access control (permission groups) on self-hosted
|
||||
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: z.boolean().optional(), // Enable organizations on self-hosted (bypasses plan requirements)
|
||||
NEXT_PUBLIC_DISABLE_INVITATIONS: z.boolean().optional(), // Disable workspace invitations globally (for self-hosted deployments)
|
||||
NEXT_PUBLIC_DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access UI toggle globally
|
||||
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms
|
||||
},
|
||||
|
||||
@@ -413,6 +415,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: process.env.NEXT_PUBLIC_ACCESS_CONTROL_ENABLED,
|
||||
NEXT_PUBLIC_ORGANIZATIONS_ENABLED: process.env.NEXT_PUBLIC_ORGANIZATIONS_ENABLED,
|
||||
NEXT_PUBLIC_DISABLE_INVITATIONS: process.env.NEXT_PUBLIC_DISABLE_INVITATIONS,
|
||||
NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API,
|
||||
NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED,
|
||||
NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED,
|
||||
NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED,
|
||||
|
||||
@@ -111,6 +111,12 @@ export const isE2bEnabled = isTruthy(env.E2B_ENABLED)
|
||||
*/
|
||||
export const isInvitationsDisabled = isTruthy(env.DISABLE_INVITATIONS)
|
||||
|
||||
/**
|
||||
* Is public API access disabled globally
|
||||
* When true, the public API toggle is hidden and public API access is blocked
|
||||
*/
|
||||
export const isPublicApiDisabled = isTruthy(env.DISABLE_PUBLIC_API)
|
||||
|
||||
/**
|
||||
* Is React Grab enabled for UI element debugging
|
||||
* When true and in development mode, enables React Grab for copying UI element context to clipboard
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface PermissionGroupConfig {
|
||||
disableSkills: boolean
|
||||
hideTemplates: boolean
|
||||
disableInvitations: boolean
|
||||
disablePublicApi: boolean
|
||||
// Deploy Modal Tabs
|
||||
hideDeployApi: boolean
|
||||
hideDeployMcp: boolean
|
||||
@@ -37,6 +38,7 @@ export const DEFAULT_PERMISSION_GROUP_CONFIG: PermissionGroupConfig = {
|
||||
disableSkills: false,
|
||||
hideTemplates: false,
|
||||
disableInvitations: false,
|
||||
disablePublicApi: false,
|
||||
hideDeployApi: false,
|
||||
hideDeployMcp: false,
|
||||
hideDeployA2a: false,
|
||||
@@ -67,6 +69,7 @@ export function parsePermissionGroupConfig(config: unknown): PermissionGroupConf
|
||||
disableSkills: typeof c.disableSkills === 'boolean' ? c.disableSkills : false,
|
||||
hideTemplates: typeof c.hideTemplates === 'boolean' ? c.hideTemplates : false,
|
||||
disableInvitations: typeof c.disableInvitations === 'boolean' ? c.disableInvitations : false,
|
||||
disablePublicApi: typeof c.disablePublicApi === 'boolean' ? c.disablePublicApi : false,
|
||||
hideDeployApi: typeof c.hideDeployApi === 'boolean' ? c.hideDeployApi : false,
|
||||
hideDeployMcp: typeof c.hideDeployMcp === 'boolean' ? c.hideDeployMcp : false,
|
||||
hideDeployA2a: typeof c.hideDeployA2a === 'boolean' ? c.hideDeployA2a : false,
|
||||
|
||||
@@ -202,6 +202,10 @@ app:
|
||||
DISABLE_INVITATIONS: "" # Set to "true" to disable workspace invitations globally
|
||||
NEXT_PUBLIC_DISABLE_INVITATIONS: "" # Set to "true" to hide invitation UI elements
|
||||
|
||||
# Public API Access Control
|
||||
DISABLE_PUBLIC_API: "" # Set to "true" to disable public API toggle globally
|
||||
NEXT_PUBLIC_DISABLE_PUBLIC_API: "" # Set to "true" to hide public API toggle in UI
|
||||
|
||||
# SSO Configuration (Enterprise Single Sign-On)
|
||||
# Set to "true" AFTER running the SSO registration script
|
||||
SSO_ENABLED: "" # Enable SSO authentication ("true" to enable)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "workflow" ADD COLUMN "is_public_api" boolean DEFAULT false NOT NULL;
|
||||
12430
packages/db/migrations/meta/0159_snapshot.json
Normal file
12430
packages/db/migrations/meta/0159_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1107,6 +1107,13 @@
|
||||
"when": 1771830035621,
|
||||
"tag": "0158_amazing_hulk",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 159,
|
||||
"version": "7",
|
||||
"when": 1771897746619,
|
||||
"tag": "0159_magical_marten_broadcloak",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ export const workflow = pgTable(
|
||||
updatedAt: timestamp('updated_at').notNull(),
|
||||
isDeployed: boolean('is_deployed').notNull().default(false),
|
||||
deployedAt: timestamp('deployed_at'),
|
||||
isPublicApi: boolean('is_public_api').notNull().default(false),
|
||||
runCount: integer('run_count').notNull().default(0),
|
||||
lastRunAt: timestamp('last_run_at'),
|
||||
variables: json('variables').default('{}'),
|
||||
|
||||
Reference in New Issue
Block a user