Skip to content

Commit 7049cec

Browse files
committed
Mship byok
1 parent cc87f61 commit 7049cec

19 files changed

Lines changed: 732 additions & 1752 deletions

File tree

apps/sim/app/api/admin/mothership/route.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,59 @@ export const GET = withRouteHandler(async (req: NextRequest) => {
172172
)
173173
}
174174
})
175+
176+
export const DELETE = withRouteHandler(async (req: NextRequest) => {
177+
const userId = await getAuthorizedAdminUserId()
178+
if (!userId) {
179+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
180+
}
181+
182+
const adminKey = env.MOTHERSHIP_API_ADMIN_KEY
183+
if (!adminKey) {
184+
return NextResponse.json({ error: 'MOTHERSHIP_API_ADMIN_KEY not configured' }, { status: 500 })
185+
}
186+
187+
const { searchParams } = new URL(req.url)
188+
const queryValidation = adminMothershipQuerySchema.safeParse(searchParamsToObject(searchParams))
189+
if (!queryValidation.success) return validationErrorResponse(queryValidation.error)
190+
const { env: environment, endpoint } = queryValidation.data
191+
192+
if (!isValidEndpoint(endpoint)) {
193+
return NextResponse.json({ error: 'invalid endpoint' }, { status: 400 })
194+
}
195+
196+
const baseUrl = await getMothershipUrl(environment, userId)
197+
if (!baseUrl) {
198+
return NextResponse.json(
199+
{ error: `No URL configured for environment: ${environment}` },
200+
{ status: 400 }
201+
)
202+
}
203+
204+
const forwardParams = new URLSearchParams()
205+
searchParams.forEach((value, key) => {
206+
if (key !== 'env' && key !== 'endpoint') {
207+
forwardParams.set(key, value)
208+
}
209+
})
210+
211+
const qs = forwardParams.toString()
212+
const targetUrl = `${baseUrl}/api/admin/${endpoint}${qs ? `?${qs}` : ''}`
213+
214+
try {
215+
const upstream = await fetch(targetUrl, {
216+
method: 'DELETE',
217+
headers: { 'x-api-key': adminKey },
218+
})
219+
220+
const data = await upstream.json()
221+
return NextResponse.json(data, { status: upstream.status })
222+
} catch (error) {
223+
return NextResponse.json(
224+
{
225+
error: `Failed to reach mothership (${environment}): ${getErrorMessage(error, 'Unknown error')}`,
226+
},
227+
{ status: 502 }
228+
)
229+
}
230+
})

apps/sim/app/api/copilot/auto-allowed-tools/route.ts

Lines changed: 0 additions & 168 deletions
This file was deleted.
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { db } from '@sim/db'
2+
import { settings, user } from '@sim/db/schema'
3+
import { getErrorMessage } from '@sim/utils/errors'
4+
import { eq } from 'drizzle-orm'
5+
import { type NextRequest, NextResponse } from 'next/server'
6+
import { getSession } from '@/lib/auth'
7+
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
8+
import { getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
9+
import { env } from '@/lib/core/config/env'
10+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
11+
12+
/**
13+
* Enterprise BYOK key management for the current workspace's mothership.
14+
*
15+
* Unlike the cross-environment admin inspector (`/api/admin/mothership`), this
16+
* talks to the SAME copilot the workspace's mothership actually runs on —
17+
* `SIM_AGENT_API_URL` (local in dev, prod copilot in prod) — and authenticates
18+
* with the hosted internal key (`COPILOT_API_KEY`), the exact credential
19+
* mothership chat uses. Copilot requires that key (`SIM_AGENT_API_KEY`) and
20+
* rejects self-hosted callers, so BYOK can only ever be written through our
21+
* hosted Sim. The route is superuser-gated; the workspace id rides in the
22+
* request and is resolved by the caller from the route.
23+
*/
24+
async function getAuthorizedSuperUserId(): Promise<string | null> {
25+
const session = await getSession()
26+
if (!session?.user?.id) return null
27+
28+
const [currentUser] = await db
29+
.select({ role: user.role, superUserModeEnabled: settings.superUserModeEnabled })
30+
.from(user)
31+
.leftJoin(settings, eq(settings.userId, user.id))
32+
.where(eq(user.id, session.user.id))
33+
.limit(1)
34+
35+
const authorized = currentUser?.role === 'admin' && (currentUser.superUserModeEnabled ?? false)
36+
return authorized ? session.user.id : null
37+
}
38+
39+
async function proxyToCopilot(req: NextRequest, method: 'GET' | 'POST' | 'DELETE') {
40+
const userId = await getAuthorizedSuperUserId()
41+
if (!userId) {
42+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
43+
}
44+
45+
const headers: Record<string, string> = { ...getMothershipSourceEnvHeaders() }
46+
if (env.COPILOT_API_KEY) headers['x-api-key'] = env.COPILOT_API_KEY
47+
48+
let body: string | undefined
49+
if (method === 'POST') {
50+
const raw = await req.text()
51+
// Bind the audit field to the authenticated superuser, ignoring any
52+
// client-supplied createdBy so provisioning is always attributable.
53+
try {
54+
const parsed = raw ? JSON.parse(raw) : {}
55+
body = JSON.stringify({ ...parsed, createdBy: userId })
56+
} catch {
57+
body = raw
58+
}
59+
headers['Content-Type'] = 'application/json'
60+
}
61+
62+
const { search } = new URL(req.url)
63+
const targetUrl = `${SIM_AGENT_API_URL}/api/admin/byok${search}`
64+
65+
try {
66+
const upstream = await fetch(targetUrl, { method, headers, ...(body ? { body } : {}) })
67+
const text = await upstream.text()
68+
// boundary-raw-fetch: copilot returns JSON; tolerate an empty body.
69+
const data = text ? JSON.parse(text) : {}
70+
return NextResponse.json(data, { status: upstream.status })
71+
} catch (error) {
72+
return NextResponse.json(
73+
{ error: `Failed to reach copilot: ${getErrorMessage(error, 'Unknown error')}` },
74+
{ status: 502 }
75+
)
76+
}
77+
}
78+
79+
export const GET = withRouteHandler((req: NextRequest) => proxyToCopilot(req, 'GET'))
80+
export const POST = withRouteHandler((req: NextRequest) => proxyToCopilot(req, 'POST'))
81+
export const DELETE = withRouteHandler((req: NextRequest) => proxyToCopilot(req, 'DELETE'))
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { isWorkspaceOnEnterprisePlan } from '@/lib/billing/core/subscription'
4+
import { checkInternalApiKey } from '@/lib/copilot/request/http'
5+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
6+
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
7+
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
8+
9+
const logger = createLogger('CopilotByokValidate')
10+
11+
/**
12+
* Authoritative entitlement gate for enterprise BYOK, called server-to-server by
13+
* the mothership (Go) before it uses a workspace's own provider key. Gated by
14+
* INTERNAL_API_SECRET — never exposed to the browser.
15+
*
16+
* Returns 200 when EITHER:
17+
* - the requesting user is a superuser admin (platform admin with superuser
18+
* mode on), who may use BYOK on any workspace for management/testing; OR
19+
* - the user is a member of the workspace (prevents one org from causing
20+
* another org's stored key to be used) AND the workspace is on an
21+
* enterprise plan.
22+
*
23+
* Any other case returns 403 (not entitled) or 401 (bad internal auth). The Go
24+
* caller fails closed to hosted keys on anything but a 200.
25+
*/
26+
export const POST = withRouteHandler(async (req: NextRequest) => {
27+
const auth = checkInternalApiKey(req)
28+
if (!auth.success) {
29+
return new NextResponse(null, { status: 401 })
30+
}
31+
32+
let body: { workspaceId?: unknown; userId?: unknown }
33+
try {
34+
body = await req.json()
35+
} catch {
36+
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
37+
}
38+
39+
const workspaceId = typeof body.workspaceId === 'string' ? body.workspaceId : ''
40+
const userId = typeof body.userId === 'string' ? body.userId : ''
41+
if (!workspaceId || !userId) {
42+
return NextResponse.json({ error: 'workspaceId and userId are required' }, { status: 400 })
43+
}
44+
45+
try {
46+
// Superuser admins may use BYOK on any workspace (management/testing).
47+
const { effectiveSuperUser } = await verifyEffectiveSuperUser(userId)
48+
if (effectiveSuperUser) {
49+
return new NextResponse(null, { status: 200 })
50+
}
51+
52+
// Everyone else must be a workspace member on an enterprise plan. The
53+
// membership check prevents one org from using another org's stored key.
54+
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
55+
if (!permission) {
56+
logger.warn('BYOK validate denied: user is not a member of the workspace', {
57+
workspaceId,
58+
userId,
59+
})
60+
return new NextResponse(null, { status: 403 })
61+
}
62+
63+
const eligible = await isWorkspaceOnEnterprisePlan(workspaceId)
64+
if (!eligible) {
65+
logger.warn('BYOK validate denied: workspace is not on an enterprise plan', { workspaceId })
66+
return new NextResponse(null, { status: 403 })
67+
}
68+
69+
return new NextResponse(null, { status: 200 })
70+
} catch (error) {
71+
logger.error('BYOK validation failed', { error, workspaceId })
72+
return NextResponse.json({ error: 'Failed to validate BYOK entitlement' }, { status: 500 })
73+
}
74+
})

0 commit comments

Comments
 (0)