chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { apiKey } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { and, eq, not } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { updateWorkspaceApiKeyContract } from '@/lib/api/contracts/api-keys'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceApiKeyAPI')
|
||||
|
||||
export const PUT = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string; keyId: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const { id: workspaceId, keyId } = await context.params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace API key update attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(updateWorkspaceApiKeyContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { name } = parsed.data.body
|
||||
|
||||
const existingKey = await db
|
||||
.select()
|
||||
.from(apiKey)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKey.workspaceId, workspaceId),
|
||||
eq(apiKey.id, keyId),
|
||||
eq(apiKey.type, 'workspace')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (existingKey.length === 0) {
|
||||
return NextResponse.json({ error: 'API key not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const conflictingKey = await db
|
||||
.select()
|
||||
.from(apiKey)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKey.workspaceId, workspaceId),
|
||||
eq(apiKey.name, name),
|
||||
eq(apiKey.type, 'workspace'),
|
||||
not(eq(apiKey.id, keyId))
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
if (conflictingKey.length > 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'A workspace API key with this name already exists' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const [updatedKey] = await db
|
||||
.update(apiKey)
|
||||
.set({
|
||||
name,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(apiKey.workspaceId, workspaceId),
|
||||
eq(apiKey.id, keyId),
|
||||
eq(apiKey.type, 'workspace')
|
||||
)
|
||||
)
|
||||
.returning({
|
||||
id: apiKey.id,
|
||||
name: apiKey.name,
|
||||
createdAt: apiKey.createdAt,
|
||||
updatedAt: apiKey.updatedAt,
|
||||
})
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.API_KEY_UPDATED,
|
||||
resourceType: AuditResourceType.API_KEY,
|
||||
resourceId: keyId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: name,
|
||||
description: `Renamed workspace API key from "${existingKey[0].name}" to "${name}"`,
|
||||
metadata: {
|
||||
keyType: 'workspace',
|
||||
previousName: existingKey[0].name,
|
||||
newName: name,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
logger.info(`[${requestId}] Updated workspace API key: ${keyId} in workspace ${workspaceId}`)
|
||||
return NextResponse.json({ key: updatedKey })
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Workspace API key PUT error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to update workspace API key') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string; keyId: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const { id: workspaceId, keyId } = await params
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace API key deletion attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const deletedRows = await db
|
||||
.delete(apiKey)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKey.workspaceId, workspaceId),
|
||||
eq(apiKey.id, keyId),
|
||||
eq(apiKey.type, 'workspace')
|
||||
)
|
||||
)
|
||||
.returning({ id: apiKey.id, name: apiKey.name, lastUsed: apiKey.lastUsed })
|
||||
|
||||
if (deletedRows.length === 0) {
|
||||
return NextResponse.json({ error: 'API key not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const deletedKey = deletedRows[0]
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'api_key_revoked',
|
||||
{ workspace_id: workspaceId, key_name: deletedKey.name },
|
||||
{ groups: { workspace: workspaceId } }
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
action: AuditAction.API_KEY_REVOKED,
|
||||
resourceType: AuditResourceType.API_KEY,
|
||||
resourceId: keyId,
|
||||
actorName: session.user.name ?? undefined,
|
||||
actorEmail: session.user.email ?? undefined,
|
||||
resourceName: deletedKey.name,
|
||||
description: `Revoked workspace API key: ${deletedKey.name}`,
|
||||
metadata: {
|
||||
keyType: 'workspace',
|
||||
keyName: deletedKey.name,
|
||||
lastUsed: deletedKey.lastUsed?.toISOString() ?? null,
|
||||
},
|
||||
request,
|
||||
})
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Deleted workspace API key: ${keyId} from workspace ${workspaceId}`
|
||||
)
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Workspace API key DELETE error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to delete workspace API key') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,216 @@
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
|
||||
import { db } from '@sim/db'
|
||||
import { apiKey } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import {
|
||||
createWorkspaceApiKeyContract,
|
||||
deleteWorkspaceApiKeysContract,
|
||||
} from '@/lib/api/contracts/api-keys'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { getApiKeyDisplayFormat } from '@/lib/api-key/auth'
|
||||
import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { PlatformEvents } from '@/lib/core/telemetry'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { captureServerEvent } from '@/lib/posthog/server'
|
||||
import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
|
||||
|
||||
const logger = createLogger('WorkspaceApiKeysAPI')
|
||||
|
||||
export const GET = withRouteHandler(
|
||||
async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const workspaceId = (await params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace API keys access attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const ws = await getWorkspaceById(workspaceId)
|
||||
if (!ws) {
|
||||
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (!permission) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const workspaceKeys = await db
|
||||
.select({
|
||||
id: apiKey.id,
|
||||
name: apiKey.name,
|
||||
key: apiKey.key,
|
||||
createdAt: apiKey.createdAt,
|
||||
lastUsed: apiKey.lastUsed,
|
||||
expiresAt: apiKey.expiresAt,
|
||||
createdBy: apiKey.createdBy,
|
||||
})
|
||||
.from(apiKey)
|
||||
.where(and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.type, 'workspace')))
|
||||
.orderBy(apiKey.createdAt)
|
||||
|
||||
const formattedWorkspaceKeys = await Promise.all(
|
||||
workspaceKeys.map(async (key) => {
|
||||
const displayFormat = await getApiKeyDisplayFormat(key.key)
|
||||
return {
|
||||
...key,
|
||||
key: key.key,
|
||||
displayKey: displayFormat,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
keys: formattedWorkspaceKeys,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Workspace API keys GET error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to load API keys') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const workspaceId = (await context.params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace API key creation attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(createWorkspaceApiKeyContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { name, source } = parsed.data.body
|
||||
|
||||
const result = await performCreateWorkspaceApiKey({
|
||||
workspaceId,
|
||||
userId,
|
||||
name,
|
||||
source,
|
||||
actorName: session.user.name,
|
||||
actorEmail: session.user.email,
|
||||
})
|
||||
if (!result.success || !result.key) {
|
||||
const status = result.errorCode === 'conflict' ? 409 : 500
|
||||
return NextResponse.json({ error: result.error }, { status })
|
||||
}
|
||||
|
||||
captureServerEvent(
|
||||
userId,
|
||||
'api_key_created',
|
||||
{ workspace_id: workspaceId, key_name: name, source },
|
||||
{
|
||||
groups: { workspace: workspaceId },
|
||||
setOnce: { first_api_key_created_at: new Date().toISOString() },
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`)
|
||||
|
||||
return NextResponse.json({
|
||||
key: result.key,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Workspace API key POST error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to create workspace API key') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const DELETE = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
const workspaceId = (await context.params).id
|
||||
|
||||
try {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthorized workspace API key deletion attempt`)
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const userId = session.user.id
|
||||
|
||||
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
|
||||
if (permission !== 'admin') {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(deleteWorkspaceApiKeysContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
const { keys } = parsed.data.body
|
||||
|
||||
const deletedCount = await db
|
||||
.delete(apiKey)
|
||||
.where(
|
||||
and(
|
||||
eq(apiKey.workspaceId, workspaceId),
|
||||
eq(apiKey.type, 'workspace'),
|
||||
inArray(apiKey.id, keys)
|
||||
)
|
||||
)
|
||||
|
||||
try {
|
||||
for (const keyId of keys) {
|
||||
PlatformEvents.apiKeyRevoked({
|
||||
userId: userId,
|
||||
keyId: keyId,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Telemetry should not fail the operation
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[${requestId}] Deleted ${deletedCount} workspace API keys from workspace ${workspaceId}`
|
||||
)
|
||||
|
||||
recordAudit({
|
||||
workspaceId,
|
||||
actorId: userId,
|
||||
actorName: session?.user?.name,
|
||||
actorEmail: session?.user?.email,
|
||||
action: AuditAction.API_KEY_REVOKED,
|
||||
resourceType: AuditResourceType.API_KEY,
|
||||
description: `Revoked ${deletedCount} workspace API key(s)`,
|
||||
metadata: { keyIds: keys, deletedCount, keyType: 'workspace' },
|
||||
request,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, deletedCount })
|
||||
} catch (error: unknown) {
|
||||
logger.error(`[${requestId}] Workspace API key DELETE error`, error)
|
||||
return NextResponse.json(
|
||||
{ error: getErrorMessage(error, 'Failed to delete workspace API keys') },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user