dify/web/app/components/api-key/api-key-table.tsx
Jyong 79116d8c4b feat(knowledge-fs): scope dataset API keys by knowledge base type
Legacy knowledge bases (`datasets`) and KnowledgeFS spaces
(`knowledge_fs_control_spaces`) live in different tables, so a dataset API
key binding could only ever name a legacy dataset and the KnowledgeFS service
API never consulted bindings at all: a key scoped to specific knowledge bases
still reached every KnowledgeFS space in the workspace.

- Bindings carry a `resource_type` (`dataset` | `knowledge_fs_space`) and a
  nullable `control_space_id` (migration 9a4e7d1c2b60, existing rows stay
  `dataset`), with a CHECK that exactly one id column matches the type.
- `dataset_api_key_service` exposes the key scope by kind, validates and
  binds KnowledgeFS spaces (tenant-owned, not deleting/deleted), and cleans up
  keys scoped only to a space when its deletion is requested. The orphan-key
  cleanup is now NULL-safe so a space binding keeps a key alive.
- Legacy dataset routes only honour `dataset` bindings; the KnowledgeFS
  authorization service only honours `knowledge_fs_space` bindings and raises
  a scope error that the service API maps to 403 (unknown keys stay 401).
- Console key creation accepts `knowledge_space_ids`; list/create responses
  return them next to `dataset_ids`.
- The web scope picker lists KnowledgeFS spaces alongside legacy datasets
  when KnowledgeFS is enabled and submits the ids by kind; the scope column
  counts both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zw5G5SX3HmVfnZof6YWAc
2026-09-03 05:59:39 -04:00

102 lines
4.3 KiB
TypeScript

import type { ApiKeyItem } from '@dify/contracts/api/console/apps/types.gen'
import type { EnvironmentApiKey } from '@dify/contracts/enterprise-app-deploy/types.gen'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useTranslation } from 'react-i18next'
import { CopyFeedback } from '@/app/components/base/copy-feedback'
import useTimestamp from '@/hooks/use-timestamp'
type ApiKeyTableProps = {
apiKeys: Array<ApiKeyItem | EnvironmentApiKey>
canManage: boolean
// Dataset keys carry a knowledge-base scope; enable the SCOPE column to surface it.
showScope?: boolean
onDeleteRequest: (apiKeyId: string) => void
}
export function ApiKeyTable({
apiKeys,
canManage,
showScope = false,
onDeleteRequest,
}: ApiKeyTableProps) {
const { t } = useTranslation()
const { formatTime } = useTimestamp()
const maskToken = (token: string) => `${token.slice(0, 3)}...${token.slice(-20)}`
// Only dataset keys expose bound knowledge bases (legacy dataset_ids plus KnowledgeFS
// knowledge_space_ids); both empty/absent means the key can access every knowledge base
// in the workspace.
const scopeLabel = (apiKey: ApiKeyItem | EnvironmentApiKey) => {
const { dataset_ids: datasetIds, knowledge_space_ids: knowledgeSpaceIds } = apiKey as {
dataset_ids?: string[]
knowledge_space_ids?: string[]
}
const count = (datasetIds?.length ?? 0) + (knowledgeSpaceIds?.length ?? 0)
if (!count) return t(($) => $['apiKeyModal.scopeAllDatasets'], { ns: 'appApi' })
return t(($) => $['apiKeyModal.scopeCount'], { ns: 'appApi', count })
}
return (
<div className="min-h-0 overflow-auto border-y border-divider-subtle">
<table className="w-full table-fixed text-left system-sm-regular text-text-secondary">
<thead className="sticky top-0 bg-components-panel-bg system-xs-semibold-uppercase text-text-tertiary">
<tr className="border-b border-divider-regular">
<th className="w-64 px-6 py-2" scope="col">
{t(($) => $['apiKeyModal.secretKey'], { ns: 'appApi' })}
</th>
{showScope && (
<th className="w-40 px-3 py-2" scope="col">
{t(($) => $['apiKeyModal.scope'], { ns: 'appApi' })}
</th>
)}
<th className="w-48 px-3 py-2" scope="col">
{t(($) => $['apiKeyModal.created'], { ns: 'appApi' })}
</th>
<th className="w-48 px-3 py-2" scope="col">
{t(($) => $['apiKeyModal.lastUsed'], { ns: 'appApi' })}
</th>
<th className="w-24 px-6 py-2" scope="col">
<span className="sr-only">{t(($) => $['operation.settings'], { ns: 'common' })}</span>
</th>
</tr>
</thead>
<tbody>
{apiKeys.map((apiKey) => (
<tr className="border-b border-divider-regular last:border-b-0" key={apiKey.id}>
<td className="truncate px-6 py-2 font-mono">{maskToken(apiKey.token)}</td>
{showScope && <td className="truncate px-3 py-2">{scopeLabel(apiKey)}</td>}
<td className="truncate px-3 py-2">
{formatTime(
Number(apiKey.created_at),
t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string,
)}
</td>
<td className="truncate px-3 py-2">
{apiKey.last_used_at
? formatTime(
Number(apiKey.last_used_at),
t(($) => $.dateTimeFormat, { ns: 'appLog' }) as string,
)
: t(($) => $.never, { ns: 'appApi' })}
</td>
<td className="px-6 py-1">
<div className="flex justify-end gap-1">
<CopyFeedback content={apiKey.token} />
{canManage && (
<IconButton
aria-label={`${t(($) => $['operation.delete'], { ns: 'common' })} ${maskToken(apiKey.token)}`}
onClick={() => onDeleteRequest(apiKey.id)}
>
<span aria-hidden className="i-ri-delete-bin-line size-4" />
</IconButton>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}