diff --git a/apps/docs/content/docs/en/enterprise/index.mdx b/apps/docs/content/docs/en/enterprise/index.mdx
new file mode 100644
index 0000000000..c5b451d83d
--- /dev/null
+++ b/apps/docs/content/docs/en/enterprise/index.mdx
@@ -0,0 +1,75 @@
+---
+title: Enterprise
+description: Enterprise features for organizations with advanced security and compliance requirements
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+
+Sim Studio Enterprise provides advanced features for organizations with enhanced security, compliance, and management requirements.
+
+---
+
+## Bring Your Own Key (BYOK)
+
+Use your own API keys for AI model providers instead of Sim Studio's hosted keys.
+
+### Supported Providers
+
+| Provider | Usage |
+|----------|-------|
+| OpenAI | Knowledge Base embeddings, Agent block |
+| Anthropic | Agent block |
+| Google | Agent block |
+| Mistral | Knowledge Base OCR |
+
+### Setup
+
+1. Navigate to **Settings** → **BYOK** in your workspace
+2. Click **Add Key** for your provider
+3. Enter your API key and save
+
+
+ BYOK keys are encrypted at rest. Only organization admins and owners can manage keys.
+
+
+When configured, workflows use your key instead of Sim Studio's hosted keys. If removed, workflows automatically fall back to hosted keys.
+
+---
+
+## Single Sign-On (SSO)
+
+Enterprise authentication with SAML 2.0 and OIDC support for centralized identity management.
+
+### Supported Providers
+
+- Okta
+- Azure AD / Entra ID
+- Google Workspace
+- OneLogin
+- Any SAML 2.0 or OIDC provider
+
+### Setup
+
+1. Navigate to **Settings** → **SSO** in your workspace
+2. Choose your identity provider
+3. Configure the connection using your IdP's metadata
+4. Enable SSO for your organization
+
+
+ Once SSO is enabled, team members authenticate through your identity provider instead of email/password.
+
+
+---
+
+## Self-Hosted
+
+For self-hosted deployments, enterprise features can be enabled via environment variables:
+
+| Variable | Description |
+|----------|-------------|
+| `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Single Sign-On with SAML/OIDC |
+| `CREDENTIAL_SETS_ENABLED`, `NEXT_PUBLIC_CREDENTIAL_SETS_ENABLED` | Polling Groups for email triggers |
+
+
+ BYOK is only available on hosted Sim Studio. Self-hosted deployments configure AI provider keys directly via environment variables.
+
diff --git a/apps/docs/content/docs/en/meta.json b/apps/docs/content/docs/en/meta.json
index ddef012c76..52213b66a3 100644
--- a/apps/docs/content/docs/en/meta.json
+++ b/apps/docs/content/docs/en/meta.json
@@ -15,6 +15,7 @@
"permissions",
"sdks",
"self-hosting",
+ "./enterprise/index",
"./keyboard-shortcuts/index"
],
"defaultOpen": false
diff --git a/apps/sim/app/api/auth/sso/register/route.ts b/apps/sim/app/api/auth/sso/register/route.ts
index 2743842136..b53d83eae6 100644
--- a/apps/sim/app/api/auth/sso/register/route.ts
+++ b/apps/sim/app/api/auth/sso/register/route.ts
@@ -1,7 +1,8 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
-import { auth } from '@/lib/auth'
+import { auth, getSession } from '@/lib/auth'
+import { hasSSOAccess } from '@/lib/billing'
import { env } from '@/lib/core/config/env'
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
@@ -63,10 +64,22 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [
export async function POST(request: NextRequest) {
try {
+ // SSO plugin must be enabled in Better Auth
if (!env.SSO_ENABLED) {
return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 })
}
+ // Check plan access (enterprise) or env var override
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
+ }
+
+ const hasAccess = await hasSSOAccess(session.user.id)
+ if (!hasAccess) {
+ return NextResponse.json({ error: 'SSO requires an Enterprise plan' }, { status: 403 })
+ }
+
const rawBody = await request.json()
const parseResult = ssoRegistrationSchema.safeParse(rawBody)
diff --git a/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts
index 839538ff7c..2e7a5a7dc5 100644
--- a/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts
+++ b/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts
@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
+import { hasCredentialSetsAccess } from '@/lib/billing'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -45,6 +46,15 @@ export async function POST(
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ // Check plan access (team/enterprise) or env var override
+ const hasAccess = await hasCredentialSetsAccess(session.user.id)
+ if (!hasAccess) {
+ return NextResponse.json(
+ { error: 'Credential sets require a Team or Enterprise plan' },
+ { status: 403 }
+ )
+ }
+
const { id, invitationId } = await params
try {
diff --git a/apps/sim/app/api/credential-sets/[id]/invite/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/route.ts
index 59b34a8268..3a2e59df5e 100644
--- a/apps/sim/app/api/credential-sets/[id]/invite/route.ts
+++ b/apps/sim/app/api/credential-sets/[id]/invite/route.ts
@@ -6,6 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
import { getSession } from '@/lib/auth'
+import { hasCredentialSetsAccess } from '@/lib/billing'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -47,6 +48,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ // Check plan access (team/enterprise) or env var override
+ const hasAccess = await hasCredentialSetsAccess(session.user.id)
+ if (!hasAccess) {
+ return NextResponse.json(
+ { error: 'Credential sets require a Team or Enterprise plan' },
+ { status: 403 }
+ )
+ }
+
const { id } = await params
const result = await getCredentialSetWithAccess(id, session.user.id)
@@ -69,6 +79,15 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ // Check plan access (team/enterprise) or env var override
+ const hasAccess = await hasCredentialSetsAccess(session.user.id)
+ if (!hasAccess) {
+ return NextResponse.json(
+ { error: 'Credential sets require a Team or Enterprise plan' },
+ { status: 403 }
+ )
+ }
+
const { id } = await params
try {
@@ -178,6 +197,15 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ // Check plan access (team/enterprise) or env var override
+ const hasAccess = await hasCredentialSetsAccess(session.user.id)
+ if (!hasAccess) {
+ return NextResponse.json(
+ { error: 'Credential sets require a Team or Enterprise plan' },
+ { status: 403 }
+ )
+ }
+
const { id } = await params
const { searchParams } = new URL(req.url)
const invitationId = searchParams.get('invitationId')
diff --git a/apps/sim/app/api/credential-sets/[id]/members/route.ts b/apps/sim/app/api/credential-sets/[id]/members/route.ts
index f8fb0b2f08..c09d39f868 100644
--- a/apps/sim/app/api/credential-sets/[id]/members/route.ts
+++ b/apps/sim/app/api/credential-sets/[id]/members/route.ts
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
+import { hasCredentialSetsAccess } from '@/lib/billing'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
const logger = createLogger('CredentialSetMembers')
@@ -39,6 +40,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ // Check plan access (team/enterprise) or env var override
+ const hasAccess = await hasCredentialSetsAccess(session.user.id)
+ if (!hasAccess) {
+ return NextResponse.json(
+ { error: 'Credential sets require a Team or Enterprise plan' },
+ { status: 403 }
+ )
+ }
+
const { id } = await params
const result = await getCredentialSetWithAccess(id, session.user.id)
@@ -110,6 +120,15 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ // Check plan access (team/enterprise) or env var override
+ const hasAccess = await hasCredentialSetsAccess(session.user.id)
+ if (!hasAccess) {
+ return NextResponse.json(
+ { error: 'Credential sets require a Team or Enterprise plan' },
+ { status: 403 }
+ )
+ }
+
const { id } = await params
const { searchParams } = new URL(req.url)
const memberId = searchParams.get('memberId')
diff --git a/apps/sim/app/api/credential-sets/[id]/route.ts b/apps/sim/app/api/credential-sets/[id]/route.ts
index 26af42e705..fb40336fd4 100644
--- a/apps/sim/app/api/credential-sets/[id]/route.ts
+++ b/apps/sim/app/api/credential-sets/[id]/route.ts
@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
+import { hasCredentialSetsAccess } from '@/lib/billing'
const logger = createLogger('CredentialSet')
@@ -49,6 +50,15 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ // Check plan access (team/enterprise) or env var override
+ const hasAccess = await hasCredentialSetsAccess(session.user.id)
+ if (!hasAccess) {
+ return NextResponse.json(
+ { error: 'Credential sets require a Team or Enterprise plan' },
+ { status: 403 }
+ )
+ }
+
const { id } = await params
const result = await getCredentialSetWithAccess(id, session.user.id)
@@ -66,6 +76,15 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ // Check plan access (team/enterprise) or env var override
+ const hasAccess = await hasCredentialSetsAccess(session.user.id)
+ if (!hasAccess) {
+ return NextResponse.json(
+ { error: 'Credential sets require a Team or Enterprise plan' },
+ { status: 403 }
+ )
+ }
+
const { id } = await params
try {
@@ -129,6 +148,15 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ // Check plan access (team/enterprise) or env var override
+ const hasAccess = await hasCredentialSetsAccess(session.user.id)
+ if (!hasAccess) {
+ return NextResponse.json(
+ { error: 'Credential sets require a Team or Enterprise plan' },
+ { status: 403 }
+ )
+ }
+
const { id } = await params
try {
diff --git a/apps/sim/app/api/credential-sets/route.ts b/apps/sim/app/api/credential-sets/route.ts
index 51d80293d8..68a5e5b9df 100644
--- a/apps/sim/app/api/credential-sets/route.ts
+++ b/apps/sim/app/api/credential-sets/route.ts
@@ -5,6 +5,7 @@ import { and, count, desc, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
+import { hasCredentialSetsAccess } from '@/lib/billing'
const logger = createLogger('CredentialSets')
@@ -22,6 +23,15 @@ export async function GET(req: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ // Check plan access (team/enterprise) or env var override
+ const hasAccess = await hasCredentialSetsAccess(session.user.id)
+ if (!hasAccess) {
+ return NextResponse.json(
+ { error: 'Credential sets require a Team or Enterprise plan' },
+ { status: 403 }
+ )
+ }
+
const { searchParams } = new URL(req.url)
const organizationId = searchParams.get('organizationId')
@@ -85,6 +95,15 @@ export async function POST(req: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ // Check plan access (team/enterprise) or env var override
+ const hasAccess = await hasCredentialSetsAccess(session.user.id)
+ if (!hasAccess) {
+ return NextResponse.json(
+ { error: 'Credential sets require a Team or Enterprise plan' },
+ { status: 403 }
+ )
+ }
+
try {
const body = await req.json()
const { organizationId, name, description, providerId } = createCredentialSetSchema.parse(body)
diff --git a/apps/sim/app/api/v1/admin/byok/route.ts b/apps/sim/app/api/v1/admin/byok/route.ts
new file mode 100644
index 0000000000..8144993122
--- /dev/null
+++ b/apps/sim/app/api/v1/admin/byok/route.ts
@@ -0,0 +1,199 @@
+/**
+ * Admin BYOK Keys API
+ *
+ * GET /api/v1/admin/byok
+ * List all BYOK keys with optional filtering.
+ *
+ * Query Parameters:
+ * - organizationId?: string - Filter by organization ID (finds all workspaces billed to this org)
+ * - workspaceId?: string - Filter by specific workspace ID
+ *
+ * Response: { data: AdminBYOKKey[], pagination: PaginationMeta }
+ *
+ * DELETE /api/v1/admin/byok
+ * Delete BYOK keys for an organization or workspace.
+ * Used when an enterprise plan churns to clean up BYOK keys.
+ *
+ * Query Parameters:
+ * - organizationId: string - Delete all BYOK keys for workspaces billed to this org
+ * - workspaceId?: string - Delete keys for a specific workspace only (optional)
+ *
+ * Response: { success: true, deletedCount: number, workspacesAffected: string[] }
+ */
+
+import { db } from '@sim/db'
+import { user, workspace, workspaceBYOKKeys } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { eq, inArray, sql } from 'drizzle-orm'
+import { withAdminAuth } from '@/app/api/v1/admin/middleware'
+import {
+ badRequestResponse,
+ internalErrorResponse,
+ singleResponse,
+} from '@/app/api/v1/admin/responses'
+
+const logger = createLogger('AdminBYOKAPI')
+
+export interface AdminBYOKKey {
+ id: string
+ workspaceId: string
+ workspaceName: string
+ organizationId: string
+ providerId: string
+ createdAt: string
+ createdByUserId: string | null
+ createdByEmail: string | null
+}
+
+export const GET = withAdminAuth(async (request) => {
+ const url = new URL(request.url)
+ const organizationId = url.searchParams.get('organizationId')
+ const workspaceId = url.searchParams.get('workspaceId')
+
+ try {
+ let workspaceIds: string[] = []
+
+ if (workspaceId) {
+ workspaceIds = [workspaceId]
+ } else if (organizationId) {
+ const workspaces = await db
+ .select({ id: workspace.id })
+ .from(workspace)
+ .where(eq(workspace.billedAccountUserId, organizationId))
+
+ workspaceIds = workspaces.map((w) => w.id)
+ }
+
+ const query = db
+ .select({
+ id: workspaceBYOKKeys.id,
+ workspaceId: workspaceBYOKKeys.workspaceId,
+ workspaceName: workspace.name,
+ organizationId: workspace.billedAccountUserId,
+ providerId: workspaceBYOKKeys.providerId,
+ createdAt: workspaceBYOKKeys.createdAt,
+ createdByUserId: workspaceBYOKKeys.createdBy,
+ createdByEmail: user.email,
+ })
+ .from(workspaceBYOKKeys)
+ .innerJoin(workspace, eq(workspaceBYOKKeys.workspaceId, workspace.id))
+ .leftJoin(user, eq(workspaceBYOKKeys.createdBy, user.id))
+
+ let keys
+ if (workspaceIds.length > 0) {
+ keys = await query.where(inArray(workspaceBYOKKeys.workspaceId, workspaceIds))
+ } else {
+ keys = await query
+ }
+
+ const formattedKeys: AdminBYOKKey[] = keys.map((k) => ({
+ id: k.id,
+ workspaceId: k.workspaceId,
+ workspaceName: k.workspaceName,
+ organizationId: k.organizationId,
+ providerId: k.providerId,
+ createdAt: k.createdAt.toISOString(),
+ createdByUserId: k.createdByUserId,
+ createdByEmail: k.createdByEmail,
+ }))
+
+ logger.info('Admin API: Listed BYOK keys', {
+ organizationId,
+ workspaceId,
+ count: formattedKeys.length,
+ })
+
+ return singleResponse({
+ data: formattedKeys,
+ pagination: {
+ total: formattedKeys.length,
+ limit: formattedKeys.length,
+ offset: 0,
+ hasMore: false,
+ },
+ })
+ } catch (error) {
+ logger.error('Admin API: Failed to list BYOK keys', { error, organizationId, workspaceId })
+ return internalErrorResponse('Failed to list BYOK keys')
+ }
+})
+
+export const DELETE = withAdminAuth(async (request) => {
+ const url = new URL(request.url)
+ const organizationId = url.searchParams.get('organizationId')
+ const workspaceId = url.searchParams.get('workspaceId')
+ const reason = url.searchParams.get('reason') || 'Enterprise plan churn cleanup'
+
+ if (!organizationId && !workspaceId) {
+ return badRequestResponse('Either organizationId or workspaceId is required')
+ }
+
+ try {
+ let workspaceIds: string[] = []
+
+ if (workspaceId) {
+ workspaceIds = [workspaceId]
+ } else if (organizationId) {
+ const workspaces = await db
+ .select({ id: workspace.id })
+ .from(workspace)
+ .where(eq(workspace.billedAccountUserId, organizationId))
+
+ workspaceIds = workspaces.map((w) => w.id)
+ }
+
+ if (workspaceIds.length === 0) {
+ logger.info('Admin API: No workspaces found for BYOK cleanup', {
+ organizationId,
+ workspaceId,
+ })
+ return singleResponse({
+ success: true,
+ deletedCount: 0,
+ workspacesAffected: [],
+ message: 'No workspaces found for the given organization/workspace ID',
+ })
+ }
+
+ const countResult = await db
+ .select({ count: sql`count(*)` })
+ .from(workspaceBYOKKeys)
+ .where(inArray(workspaceBYOKKeys.workspaceId, workspaceIds))
+
+ const totalToDelete = Number(countResult[0]?.count ?? 0)
+
+ if (totalToDelete === 0) {
+ logger.info('Admin API: No BYOK keys to delete', {
+ organizationId,
+ workspaceId,
+ workspaceIds,
+ })
+ return singleResponse({
+ success: true,
+ deletedCount: 0,
+ workspacesAffected: [],
+ message: 'No BYOK keys found for the specified workspaces',
+ })
+ }
+
+ await db.delete(workspaceBYOKKeys).where(inArray(workspaceBYOKKeys.workspaceId, workspaceIds))
+
+ logger.info('Admin API: Deleted BYOK keys', {
+ organizationId,
+ workspaceId,
+ workspaceIds,
+ deletedCount: totalToDelete,
+ reason,
+ })
+
+ return singleResponse({
+ success: true,
+ deletedCount: totalToDelete,
+ workspacesAffected: workspaceIds,
+ reason,
+ })
+ } catch (error) {
+ logger.error('Admin API: Failed to delete BYOK keys', { error, organizationId, workspaceId })
+ return internalErrorResponse('Failed to delete BYOK keys')
+ }
+})
diff --git a/apps/sim/app/api/v1/admin/index.ts b/apps/sim/app/api/v1/admin/index.ts
index 2d0afcce02..f41409bf90 100644
--- a/apps/sim/app/api/v1/admin/index.ts
+++ b/apps/sim/app/api/v1/admin/index.ts
@@ -51,6 +51,10 @@
* GET /api/v1/admin/subscriptions - List all subscriptions
* GET /api/v1/admin/subscriptions/:id - Get subscription details
* DELETE /api/v1/admin/subscriptions/:id - Cancel subscription (?atPeriodEnd=true for scheduled)
+ *
+ * BYOK Keys:
+ * GET /api/v1/admin/byok - List BYOK keys (?organizationId=X or ?workspaceId=X)
+ * DELETE /api/v1/admin/byok - Delete BYOK keys for org/workspace
*/
export type { AdminAuthFailure, AdminAuthResult, AdminAuthSuccess } from '@/app/api/v1/admin/auth'
diff --git a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts
index 246cc6b245..84be273d12 100644
--- a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts
@@ -6,6 +6,8 @@ import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getSession } from '@/lib/auth'
+import { isEnterpriseOrgAdminOrOwner } from '@/lib/billing/core/subscription'
+import { isHosted } from '@/lib/core/config/feature-flags'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -56,6 +58,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
+ let byokEnabled = true
+ if (isHosted) {
+ byokEnabled = await isEnterpriseOrgAdminOrOwner(userId)
+ }
+
+ if (!byokEnabled) {
+ return NextResponse.json({ keys: [], byokEnabled: false })
+ }
+
const byokKeys = await db
.select({
id: workspaceBYOKKeys.id,
@@ -97,7 +108,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
})
)
- return NextResponse.json({ keys: formattedKeys })
+ return NextResponse.json({ keys: formattedKeys, byokEnabled: true })
} catch (error: unknown) {
logger.error(`[${requestId}] BYOK keys GET error`, error)
return NextResponse.json(
@@ -120,6 +131,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const userId = session.user.id
+ if (isHosted) {
+ const canManageBYOK = await isEnterpriseOrgAdminOrOwner(userId)
+ if (!canManageBYOK) {
+ logger.warn(`[${requestId}] User not authorized to manage BYOK keys`, { userId })
+ return NextResponse.json(
+ {
+ error:
+ 'BYOK is an Enterprise-only feature. Only organization admins and owners can manage API keys.',
+ },
+ { status: 403 }
+ )
+ }
+ }
+
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission !== 'admin') {
return NextResponse.json(
@@ -220,6 +245,20 @@ export async function DELETE(
const userId = session.user.id
+ if (isHosted) {
+ const canManageBYOK = await isEnterpriseOrgAdminOrOwner(userId)
+ if (!canManageBYOK) {
+ logger.warn(`[${requestId}] User not authorized to manage BYOK keys`, { userId })
+ return NextResponse.json(
+ {
+ error:
+ 'BYOK is an Enterprise-only feature. Only organization admins and owners can manage API keys.',
+ },
+ { status: 403 }
+ )
+ }
+ }
+
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission !== 'admin') {
return NextResponse.json(
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/byok/byok.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/byok/byok.tsx
index 956c41365d..357af5a67b 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/byok/byok.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/byok/byok.tsx
@@ -2,7 +2,7 @@
import { useState } from 'react'
import { createLogger } from '@sim/logger'
-import { Eye, EyeOff } from 'lucide-react'
+import { Crown, Eye, EyeOff } from 'lucide-react'
import { useParams } from 'next/navigation'
import {
Button,
@@ -81,7 +81,9 @@ export function BYOK() {
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
- const { data: keys = [], isLoading } = useBYOKKeys(workspaceId)
+ const { data, isLoading } = useBYOKKeys(workspaceId)
+ const keys = data?.keys ?? []
+ const byokEnabled = data?.byokEnabled ?? true
const upsertKey = useUpsertBYOKKey()
const deleteKey = useDeleteBYOKKey()
@@ -96,6 +98,31 @@ export function BYOK() {
return keys.find((k) => k.providerId === providerId)
}
+ // Show enterprise-only gate if BYOK is not enabled
+ if (!isLoading && !byokEnabled) {
+ return (
+
+
+
+
+
+
Enterprise Feature
+
+ Bring Your Own Key (BYOK) is available exclusively on the Enterprise plan. Upgrade to
+ use your own API keys and eliminate the 2x cost multiplier.
+