Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions apps/sim/app/api/organizations/[id]/byok-keys/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {
deleteOrganizationByokKeyContract,
listOrganizationByokKeysContract,
upsertOrganizationByokKeyContract,
} from '@/lib/api/contracts/byok-keys'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { byokKeyOperations } from '@/lib/api-key/application/operations'
import {
deleteOrganizationByokKey,
listOrganizationByokKeys,
saveOrganizationByokKey,
} from '@/lib/api-key/application/organization-byok-keys'

export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'

const rateLimit = internalRateLimits.none({
reason: 'Preserve the existing authenticated BYOK settings admission policy.',
})

export const GET = defineInternalJsonRoute({
contract: listOrganizationByokKeysContract,
auth: internalSessionAuth,
operation: byokKeyOperations.listOrganization,
rateLimit,
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params }) => ({ organizationId: params.id }),
useCase: listOrganizationByokKeys,
present: ({ keys, entitled }) => ({
keys: keys.map((key) => ({
...key,
createdAt: key.createdAt.toISOString(),
updatedAt: key.updatedAt.toISOString(),
})),
entitled,
}),
})

export const POST = defineInternalJsonRoute({
contract: upsertOrganizationByokKeyContract,
auth: internalSessionAuth,
operation: byokKeyOperations.saveOrganization,
rateLimit,
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }),
useCase: saveOrganizationByokKey,
present: ({ key }) => ({
success: true as const,
key: {
id: key.id,
providerId: key.providerId,
name: key.name,
maskedKey: key.maskedKey,
createdAt: key.createdAt?.toISOString(),
updatedAt: key.updatedAt?.toISOString(),
},
}),
})

export const DELETE = defineInternalJsonRoute({
contract: deleteOrganizationByokKeyContract,
auth: internalSessionAuth,
operation: byokKeyOperations.deleteOrganization,
rateLimit,
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }),
useCase: deleteOrganizationByokKey,
present: () => ({ success: true as const }),
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { getInheritedByokStatusContract } from '@/lib/api/contracts/byok-keys'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { byokKeyOperations } from '@/lib/api-key/application/operations'
import { readInheritedByokStatus } from '@/lib/api-key/application/organization-byok-keys'

export const dynamic = 'force-dynamic'

export const GET = defineInternalJsonRoute({
contract: getInheritedByokStatusContract,
auth: internalSessionAuth,
operation: byokKeyOperations.readInheritedStatus,
rateLimit: internalRateLimits.none({
reason: 'Preserve the existing authenticated BYOK settings admission policy.',
}),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params }) => ({ workspaceId: params.id }),
useCase: readInheritedByokStatus,
present: (result) => result,
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import { parseAsString, parseAsStringLiteral } from 'nuqs/server'

/** Scope shown by the BYOK settings page. */
export const byokScopeParam = {
key: 'scope',
parser: parseAsStringLiteral(['workspace', 'organization'] as const).withDefault('workspace'),
} as const

/** Scope view-state: clean URLs, no back-stack churn. */
export const byokScopeUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const

/**
* Co-located, typed URL query-param definitions for the settings section pages.
* The client hook consumes this typed param definition as the single source of
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useMemo, useState } from 'react'
import { type ReactNode, useMemo, useState } from 'react'
import {
Button,
Chip,
Expand Down Expand Up @@ -36,6 +36,8 @@ export interface BYOKManagerProvider {
icon: React.ComponentType<{ className?: string }>
description: string
placeholder: string
/** Optional decorative status shown beside the provider row. */
badge?: ReactNode
}

/** A stored key as rendered by the manager in multi-key mode. */
Expand All @@ -55,17 +57,30 @@ export interface BYOKProviderSection {
ids: string[]
}

/** Independent key-management actions available to the current viewer. */
export interface BYOKManagerCapabilities {
add: boolean
update: boolean
delete: boolean
}

interface BYOKKeyManagerBaseProps {
/** Providers to render, in display order. */
providers: BYOKManagerProvider[]
isLoading: boolean
isSaving?: boolean
isDeleting?: boolean
readOnly?: boolean
capabilities?: BYOKManagerCapabilities
/** Labeled provider groups. When omitted, renders a single flat list. */
sections?: BYOKProviderSection[]
/** Optional subtitle shown above the provider list. */
description?: string
/** Human-readable scope used in key modal copy. */
scopeLabel?: string
/** Optional usage/security copy that replaces the add/update modal default. */
keyUsageDescription?: string
/** Consequence shown when deleting a provider's last stored key. */
lastKeyDeleteMessage?: string
/** Show the provider search box (hidden when there are only a couple). */
showSearch?: boolean
/**
Expand Down Expand Up @@ -125,6 +140,11 @@ interface DeleteConfirmState {
}

const NO_KEYS: BYOKManagerKey[] = []
const DEFAULT_CAPABILITIES: BYOKManagerCapabilities = {
add: true,
update: true,
delete: true,
}

/**
* Shared BYOK key list + add/update/delete modals. Used by both the workspace
Expand All @@ -141,9 +161,12 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
isLoading,
isSaving = false,
isDeleting = false,
readOnly = false,
capabilities = DEFAULT_CAPABILITIES,
sections,
description,
scopeLabel = 'this workspace',
keyUsageDescription,
lastKeyDeleteMessage = 'This workspace will revert to using platform hosted keys.',
showSearch = true,
} = props

Expand Down Expand Up @@ -188,13 +211,18 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
const isUpdatingExistingKey = props.multiKey
? !!editing?.keyId
: !!editing && hasStoredKey(editing.providerId)
const canSaveEditingKey = isUpdatingExistingKey ? capabilities.update : capabilities.add
const isDeletingLastKey =
!!deleteConfirm &&
(!props.multiKey ||
!deleteConfirm.keyId ||
getProviderKeys(deleteConfirm.providerId).length === 1)
const canManageKeys = capabilities.add || capabilities.update || capabilities.delete

const openEditModal = (providerId: string, key?: BYOKManagerKey) => {
const isUpdating = key !== undefined || (!props.multiKey && hasStoredKey(providerId))
if (isUpdating ? !capabilities.update : !capabilities.add) return

setManagingProviderId(null)
setEditing({ providerId, keyId: key?.id })
setApiKeyInput('')
Expand All @@ -212,12 +240,16 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
}

const openDeleteConfirm = (providerId: string, keyId?: string) => {
if (!capabilities.delete) return

setManagingProviderId(null)
setDeleteConfirm({ providerId, keyId })
}

const handleSave = async () => {
if (!editing || !apiKeyInput.trim() || isSaving) return
if (!editing || !apiKeyInput.trim() || isSaving || !canSaveEditingKey) {
return
}

setError(null)
try {
Expand All @@ -239,7 +271,7 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
}

const handleDelete = async () => {
if (!deleteConfirm) return
if (!deleteConfirm || !capabilities.delete) return

try {
if (props.multiKey) {
Expand All @@ -261,7 +293,7 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {

const renderActions = (provider: BYOKManagerProvider) => {
if (!hasStoredKey(provider.id)) {
if (readOnly) return null
if (!capabilities.add) return null
return (
<Chip variant='primary' onClick={() => openEditModal(provider.id)}>
Add Key
Expand All @@ -277,17 +309,17 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
{keyCount} {keyCount === 1 ? 'key' : 'keys'}
</span>
<Chip onClick={() => setManagingProviderId(provider.id)}>
{readOnly ? 'View' : 'Manage'}
{canManageKeys ? 'Manage' : 'View'}
</Chip>
</div>
)
}

if (readOnly) return null
if (!capabilities.update && !capabilities.delete) return null
return (
<div className='flex items-center gap-2'>
<Chip onClick={() => openEditModal(provider.id)}>Update</Chip>
<Chip onClick={() => openDeleteConfirm(provider.id)}>Delete</Chip>
{capabilities.update && <Chip onClick={() => openEditModal(provider.id)}>Update</Chip>}
{capabilities.delete && <Chip onClick={() => openDeleteConfirm(provider.id)}>Delete</Chip>}
</div>
)
}
Expand All @@ -301,6 +333,7 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
icon={<Icon />}
title={provider.name}
description={provider.description}
badge={provider.badge}
trailing={renderActions(provider)}
/>
)
Expand Down Expand Up @@ -356,7 +389,7 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
provider={managingMeta}
keys={managingProviderId ? getProviderKeys(managingProviderId) : NO_KEYS}
maxKeys={props.maxKeysPerProvider}
readOnly={readOnly}
capabilities={capabilities}
onAddKey={() => managingProviderId && openEditModal(managingProviderId)}
onUpdateKey={(key) => managingProviderId && openEditModal(managingProviderId, key)}
onDeleteKey={(key) => managingProviderId && openDeleteConfirm(managingProviderId, key.id)}
Expand All @@ -379,9 +412,10 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
</ChipModalHeader>
<ChipModalBody>
<p className='px-2 text-[var(--text-secondary)] text-sm'>
{props.multiKey
? `Requests are distributed evenly across all ${editingMeta?.name} keys in this workspace. Your key is encrypted and stored securely.`
: `This key will be used for all ${editingMeta?.name} requests in this workspace. Your key is encrypted and stored securely.`}
{keyUsageDescription ??
(props.multiKey
? `Requests are distributed evenly across all ${editingMeta?.name} keys in ${scopeLabel}. Your key is encrypted and stored securely.`
: `This key will be used for all ${editingMeta?.name} requests in ${scopeLabel}. Your key is encrypted and stored securely.`)}
</p>
<ChipModalField type='custom' title='API Key' required>
<input
Expand Down Expand Up @@ -449,7 +483,7 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
primaryAction={{
label: isSaving ? 'Saving...' : 'Save',
onClick: handleSave,
disabled: !apiKeyInput.trim() || isSaving,
disabled: !apiKeyInput.trim() || isSaving || !canSaveEditingKey,
}}
/>
</ChipModal>
Expand All @@ -466,13 +500,14 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
{ text: deleteMeta?.name ?? 'selected', bold: true },
' API key? ',
isDeletingLastKey
? { text: 'This workspace will revert to using platform hosted keys.', error: true }
? { text: lastKeyDeleteMessage, error: true }
: `Requests will continue using the remaining ${deleteMeta?.name ?? 'provider'} keys.`,
' This action cannot be undone.',
]}
confirm={{
label: 'Delete',
onClick: handleDelete,
disabled: !capabilities.delete,
pending: isDeleting,
pendingLabel: 'Deleting...',
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { Chip, ChipModal, ChipModalBody, ChipModalFooter, ChipModalHeader } from '@sim/emcn'
import type {
BYOKManagerCapabilities,
BYOKManagerKey,
BYOKManagerProvider,
} from '@/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager'
Expand All @@ -14,7 +15,7 @@ interface BYOKProviderKeysModalProps {
keys: BYOKManagerKey[]
/** Maximum keys allowed per provider; disables adding once reached. */
maxKeys: number
readOnly?: boolean
capabilities: BYOKManagerCapabilities
onAddKey: () => void
onUpdateKey: (key: BYOKManagerKey) => void
onDeleteKey: (key: BYOKManagerKey) => void
Expand All @@ -32,7 +33,7 @@ export function BYOKProviderKeysModal({
provider,
keys,
maxKeys,
readOnly = false,
capabilities,
onAddKey,
onUpdateKey,
onDeleteKey,
Expand All @@ -59,32 +60,35 @@ export function BYOKProviderKeysModal({
{key.maskedKey}
</span>
</div>
{!readOnly && (
{(capabilities.update || capabilities.delete) && (
<div className='flex flex-shrink-0 items-center gap-2'>
<Chip onClick={() => onUpdateKey(key)}>Update</Chip>
<Chip onClick={() => onDeleteKey(key)}>Delete</Chip>
{capabilities.update && <Chip onClick={() => onUpdateKey(key)}>Update</Chip>}
{capabilities.delete && <Chip onClick={() => onDeleteKey(key)}>Delete</Chip>}
</div>
)}
</div>
))}
</div>
{atCapacity && (
{capabilities.add && atCapacity && (
<p className='px-2 text-[var(--text-muted)] text-caption'>
Key limit reached ({maxKeys} keys per provider).
</p>
)}
</ChipModalBody>
<ChipModalFooter
onCancel={close}
hideCancel={readOnly}
hideCancel={!capabilities.add}
primaryAction={
readOnly
? { label: 'Close', onClick: close }
: {
capabilities.add
? {
label: 'Add Key',
onClick: onAddKey,
disabled: atCapacity,
}
: {
label: 'Close',
onClick: close,
}
}
/>
</ChipModal>
Expand Down
Loading
Loading