Skip to content

Commit 0a78fde

Browse files
committed
feat(api-keys): add workspace-level api keys
1 parent 2f9c295 commit 0a78fde

10 files changed

Lines changed: 1411 additions & 258 deletions

File tree

apps/sim/app/api/users/me/api-keys/route.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { eq } from 'drizzle-orm'
1+
import { and, eq } from 'drizzle-orm'
22
import { nanoid } from 'nanoid'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { getSession } from '@/lib/auth'
@@ -57,11 +57,32 @@ export async function POST(request: NextRequest) {
5757
const body = await request.json()
5858

5959
// Validate the request
60-
const { name } = body
61-
if (!name || typeof name !== 'string') {
60+
const { name: rawName } = body
61+
if (!rawName || typeof rawName !== 'string') {
6262
return NextResponse.json({ error: 'Invalid request. Name is required.' }, { status: 400 })
6363
}
6464

65+
const name = rawName.trim()
66+
if (!name) {
67+
return NextResponse.json({ error: 'Name cannot be empty.' }, { status: 400 })
68+
}
69+
70+
// Check if a key with this name already exists for the user
71+
const existingKey = await db
72+
.select()
73+
.from(apiKey)
74+
.where(and(eq(apiKey.userId, userId), eq(apiKey.name, name)))
75+
.limit(1)
76+
77+
if (existingKey.length > 0) {
78+
return NextResponse.json(
79+
{
80+
error: `A personal API key named "${name}" already exists. Please choose a different name.`,
81+
},
82+
{ status: 409 }
83+
)
84+
}
85+
6586
const keyValue = generateApiKey()
6687

6788
// Insert the new API key

apps/sim/app/api/v1/auth.ts

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,22 @@ import { eq } from 'drizzle-orm'
22
import type { NextRequest } from 'next/server'
33
import { createLogger } from '@/lib/logs/console/logger'
44
import { db } from '@/db'
5-
import { apiKey as apiKeyTable } from '@/db/schema'
5+
import { apiKey as apiKeyTable, workflow, workspace, workspaceApiKey } from '@/db/schema'
66

77
const logger = createLogger('V1Auth')
88

99
export interface AuthResult {
1010
authenticated: boolean
1111
userId?: string
12+
workspaceId?: string
13+
keyType?: 'personal' | 'workspace'
1214
error?: string
1315
}
1416

15-
export async function authenticateApiKey(request: NextRequest): Promise<AuthResult> {
17+
export async function authenticateApiKey(
18+
request: NextRequest,
19+
workflowId?: string
20+
): Promise<AuthResult> {
1621
const apiKey = request.headers.get('x-api-key')
1722

1823
if (!apiKey) {
@@ -23,7 +28,8 @@ export async function authenticateApiKey(request: NextRequest): Promise<AuthResu
2328
}
2429

2530
try {
26-
const [keyRecord] = await db
31+
// First, check personal API keys
32+
const [personalKey] = await db
2733
.select({
2834
userId: apiKeyTable.userId,
2935
expiresAt: apiKeyTable.expiresAt,
@@ -32,27 +38,87 @@ export async function authenticateApiKey(request: NextRequest): Promise<AuthResu
3238
.where(eq(apiKeyTable.key, apiKey))
3339
.limit(1)
3440

35-
if (!keyRecord) {
36-
logger.warn('Invalid API key attempted', { keyPrefix: apiKey.slice(0, 8) })
41+
if (personalKey) {
42+
if (personalKey.expiresAt && personalKey.expiresAt < new Date()) {
43+
logger.warn('Expired personal API key attempted', { userId: personalKey.userId })
44+
return {
45+
authenticated: false,
46+
error: 'API key expired',
47+
}
48+
}
49+
50+
await db.update(apiKeyTable).set({ lastUsed: new Date() }).where(eq(apiKeyTable.key, apiKey))
51+
3752
return {
38-
authenticated: false,
39-
error: 'Invalid API key',
53+
authenticated: true,
54+
userId: personalKey.userId,
55+
keyType: 'personal',
4056
}
4157
}
4258

43-
if (keyRecord.expiresAt && keyRecord.expiresAt < new Date()) {
44-
logger.warn('Expired API key attempted', { userId: keyRecord.userId })
59+
// If not found in personal keys, check workspace API keys
60+
const [workspaceKey] = await db
61+
.select({
62+
workspaceId: workspaceApiKey.workspaceId,
63+
expiresAt: workspaceApiKey.expiresAt,
64+
workspaceOwnerId: workspace.ownerId,
65+
})
66+
.from(workspaceApiKey)
67+
.leftJoin(workspace, eq(workspaceApiKey.workspaceId, workspace.id))
68+
.where(eq(workspaceApiKey.key, apiKey))
69+
.limit(1)
70+
71+
if (workspaceKey) {
72+
if (workspaceKey.expiresAt && workspaceKey.expiresAt < new Date()) {
73+
logger.warn('Expired workspace API key attempted', {
74+
workspaceId: workspaceKey.workspaceId,
75+
})
76+
return {
77+
authenticated: false,
78+
error: 'API key expired',
79+
}
80+
}
81+
82+
// If a workflowId is provided, verify that the workflow belongs to this workspace
83+
if (workflowId) {
84+
const [workflowRecord] = await db
85+
.select({
86+
workspaceId: workflow.workspaceId,
87+
})
88+
.from(workflow)
89+
.where(eq(workflow.id, workflowId))
90+
.limit(1)
91+
92+
if (!workflowRecord || workflowRecord.workspaceId !== workspaceKey.workspaceId) {
93+
logger.warn('Workspace API key attempted to access workflow from different workspace', {
94+
workspaceId: workspaceKey.workspaceId,
95+
workflowId,
96+
workflowWorkspaceId: workflowRecord?.workspaceId,
97+
})
98+
return {
99+
authenticated: false,
100+
error: 'API key not authorized for this workflow',
101+
}
102+
}
103+
}
104+
105+
await db
106+
.update(workspaceApiKey)
107+
.set({ lastUsed: new Date() })
108+
.where(eq(workspaceApiKey.key, apiKey))
109+
45110
return {
46-
authenticated: false,
47-
error: 'API key expired',
111+
authenticated: true,
112+
userId: workspaceKey.workspaceOwnerId!, // Workspace owner is the effective user
113+
workspaceId: workspaceKey.workspaceId,
114+
keyType: 'workspace',
48115
}
49116
}
50117

51-
await db.update(apiKeyTable).set({ lastUsed: new Date() }).where(eq(apiKeyTable.key, apiKey))
52-
118+
logger.warn('Invalid API key attempted', { keyPrefix: apiKey.slice(0, 8) })
53119
return {
54-
authenticated: true,
55-
userId: keyRecord.userId,
120+
authenticated: false,
121+
error: 'Invalid API key',
56122
}
57123
} catch (error) {
58124
logger.error('API key authentication error', { error })

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

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ import { generateApiKey, generateRequestId } from '@/lib/utils'
66
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
77
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
88
import { db } from '@/db'
9-
import { apiKey, workflow, workflowBlocks, workflowEdges, workflowSubflows } from '@/db/schema'
9+
import {
10+
apiKey,
11+
workflow,
12+
workflowBlocks,
13+
workflowEdges,
14+
workflowSubflows,
15+
workspaceApiKey,
16+
} from '@/db/schema'
1017

1118
const logger = createLogger('WorkflowDeployAPI')
1219

@@ -313,15 +320,41 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
313320
userKey = userApiKey[0].key
314321
}
315322

316-
// If client provided a specific API key and it belongs to the user, prefer it
323+
// If client provided a specific API key, check if it's either personal or workspace key
317324
if (providedApiKey) {
318-
const [owned] = await db
325+
// First check personal API keys
326+
const [personalOwned] = await db
319327
.select({ key: apiKey.key })
320328
.from(apiKey)
321329
.where(and(eq(apiKey.userId, userId), eq(apiKey.key, providedApiKey)))
322330
.limit(1)
323-
if (owned) {
331+
332+
if (personalOwned) {
324333
userKey = providedApiKey
334+
} else {
335+
// Check workspace API keys - get the workflow's workspace ID
336+
const [workflowData] = await db
337+
.select({ workspaceId: workflow.workspaceId })
338+
.from(workflow)
339+
.where(eq(workflow.id, id))
340+
.limit(1)
341+
342+
if (workflowData) {
343+
const [workspaceOwned] = await db
344+
.select({ key: workspaceApiKey.key })
345+
.from(workspaceApiKey)
346+
.where(
347+
and(
348+
eq(workspaceApiKey.workspaceId, workflowData.workspaceId),
349+
eq(workspaceApiKey.key, providedApiKey)
350+
)
351+
)
352+
.limit(1)
353+
354+
if (workspaceOwned) {
355+
userKey = providedApiKey
356+
}
357+
}
325358
}
326359
}
327360

@@ -338,13 +371,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
338371

339372
await db.update(workflow).set(updateData).where(eq(workflow.id, id))
340373

341-
// Update lastUsed for the key we returned
374+
// Update lastUsed for the key we returned (try both personal and workspace keys)
342375
if (userKey) {
343376
try {
344-
await db
377+
// First try to update personal API key
378+
const personalResult = await db
345379
.update(apiKey)
346380
.set({ lastUsed: new Date(), updatedAt: new Date() })
347381
.where(eq(apiKey.key, userKey))
382+
383+
// If no personal key was updated, try workspace API key
384+
if (!personalResult || personalResult.rowCount === 0) {
385+
await db
386+
.update(workspaceApiKey)
387+
.set({ lastUsed: new Date(), updatedAt: new Date() })
388+
.where(eq(workspaceApiKey.key, userKey))
389+
}
348390
} catch (e) {
349391
logger.warn(`[${requestId}] Failed to update lastUsed for api key`)
350392
}

apps/sim/app/api/workflows/middleware.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { NextRequest } from 'next/server'
33
import { createLogger } from '@/lib/logs/console/logger'
44
import { getWorkflowById } from '@/lib/workflows/utils'
55
import { db } from '@/db'
6-
import { apiKey } from '@/db/schema'
6+
import { apiKey, workspace, workspaceApiKey } from '@/db/schema'
77

88
const logger = createLogger('WorkflowMiddleware')
99

@@ -67,21 +67,56 @@ export async function validateWorkflowAccess(
6767
}
6868
}
6969
} else {
70-
// Otherwise, verify the key belongs to the workflow owner
71-
const [owned] = await db
70+
// Check both personal API keys and workspace API keys
71+
72+
// First, check personal API keys belonging to the workflow owner
73+
const [personalKey] = await db
7274
.select({ key: apiKey.key })
7375
.from(apiKey)
7476
.where(and(eq(apiKey.userId, workflow.userId), eq(apiKey.key, apiKeyHeader)))
7577
.limit(1)
7678

77-
if (!owned) {
79+
// If not found in personal keys, check workspace API keys
80+
let workspaceKey = null
81+
if (!personalKey) {
82+
const [wsKey] = await db
83+
.select({
84+
key: workspaceApiKey.key,
85+
workspaceId: workspaceApiKey.workspaceId,
86+
workspaceOwnerId: workspace.ownerId,
87+
})
88+
.from(workspaceApiKey)
89+
.leftJoin(workspace, eq(workspaceApiKey.workspaceId, workspace.id))
90+
.where(
91+
and(
92+
eq(workspaceApiKey.key, apiKeyHeader),
93+
eq(workspace.id, workflow.workspaceId) // Key must belong to the same workspace as the workflow
94+
)
95+
)
96+
.limit(1)
97+
98+
workspaceKey = wsKey
99+
}
100+
101+
// If neither personal nor workspace key is valid, reject
102+
if (!personalKey && !workspaceKey) {
78103
return {
79104
error: {
80105
message: 'Unauthorized: Invalid API key',
81106
status: 401,
82107
},
83108
}
84109
}
110+
111+
// Update last used for the key that was found
112+
if (personalKey) {
113+
await db.update(apiKey).set({ lastUsed: new Date() }).where(eq(apiKey.key, apiKeyHeader))
114+
} else if (workspaceKey) {
115+
await db
116+
.update(workspaceApiKey)
117+
.set({ lastUsed: new Date() })
118+
.where(eq(workspaceApiKey.key, apiKeyHeader))
119+
}
85120
}
86121
}
87122
return { workflow }

0 commit comments

Comments
 (0)