feat(api): mask secret tokens in api-key list responses (reveal-once)

Previously the console api-key list returned every key's full plaintext
token, so anyone with console access could retrieve the secret of an
already-created key (via the copy button or the raw API response). This
is contrary to the reveal-once norm.

- List endpoints (app keys, workspace dataset keys, per-dataset keys) now
  return a masked token (prefix + last 4); the full secret is only ever
  returned by the create endpoint, at creation time.
- Frontend secret-key modal displays the masked token as-is and drops the
  copy affordance for existing keys (copying a masked value is pointless).

Applies to both app and dataset keys since they share the modal and the
ApiKeyItem response model.
This commit is contained in:
yungle246 2026-06-25 14:21:38 +09:00 committed by YungLe
parent 30d5e4987c
commit a79bc7d074
6 changed files with 89 additions and 37 deletions

View File

@ -1,3 +1,4 @@
from collections.abc import Iterable
from datetime import datetime
from typing import override
from uuid import UUID
@ -58,6 +59,28 @@ class ApiKeyList(ResponseModel):
register_response_schema_models(console_ns, ApiKeyItem, ApiKeyList)
def mask_api_token(token: str) -> str:
"""Mask a secret token for list responses.
Reveal-once: the full secret is only returned by the create endpoint. List
endpoints expose just enough (prefix + last 4) to identify a key, never the
full value, so an existing key's secret cannot be retrieved after creation.
"""
if len(token) <= 8:
return "***"
return f"{token[:5]}...{token[-4:]}"
def build_masked_api_key_list(api_tokens: Iterable[ApiToken]) -> ApiKeyList:
"""Build an ApiKeyList from ORM tokens with their secrets masked."""
items: list[ApiKeyItem] = []
for api_token in api_tokens:
item = ApiKeyItem.model_validate(api_token, from_attributes=True)
item.token = mask_api_token(item.token)
items.append(item)
return ApiKeyList(data=items)
def _get_resource(resource_id, tenant_id, resource_model):
with sessionmaker(db.engine).begin() as session:
resource = session.execute(
@ -91,7 +114,7 @@ class BaseApiKeyListResource(Resource):
ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id
)
).all()
return ApiKeyList.model_validate({"data": keys}, from_attributes=True)
return build_masked_api_key_list(keys)
@edit_permission_required
def post(self, resource_id: str, current_tenant_id: str) -> tuple[dict[str, object], int]:
@ -258,7 +281,7 @@ class DatasetApiKeyListResource(BaseApiKeyListResource):
or_(ApiToken.dataset_id == resource_id, ApiToken.dataset_id.is_(None)),
)
).all()
return ApiKeyList.model_validate({"data": keys}, from_attributes=True)
return build_masked_api_key_list(keys)
@console_ns.doc("create_dataset_api_key")
@console_ns.doc(description="Create a new API key bound to a single dataset")

View File

@ -13,7 +13,7 @@ from configs import dify_config
from controllers.common.fields import ApiBaseUrlResponse, SimpleResultResponse, UsageCheckResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.apikey import ApiKeyItem, ApiKeyList
from controllers.console.apikey import ApiKeyItem, ApiKeyList, build_masked_api_key_list
from controllers.console.app.error import ProviderNotInitializeError
from controllers.console.datasets.error import DatasetInUseError, DatasetNameDuplicateError, IndexingEstimateError
from controllers.console.wraps import (
@ -1007,7 +1007,7 @@ class DatasetApiKeyApi(Resource):
keys = db.session.scalars(
select(ApiToken).where(ApiToken.type == self.resource_type, ApiToken.tenant_id == current_tenant_id)
).all()
return ApiKeyList.model_validate({"data": keys}, from_attributes=True).model_dump(mode="json")
return build_masked_api_key_list(keys).model_dump(mode="json")
@console_ns.response(200, "API key created successfully", console_ns.models[ApiKeyItem.__name__])
@console_ns.response(400, "Maximum keys exceeded")

View File

@ -1759,14 +1759,14 @@ class TestDatasetApiKeyApi:
mock_key_1.id = "key-1"
mock_key_1.type = "dataset"
mock_key_1.dataset_id = None
mock_key_1.token = "ds-abc"
mock_key_1.token = "dataset-aaaa1111bbbb"
mock_key_1.last_used_at = None
mock_key_1.created_at = None
mock_key_2 = MagicMock(spec=ApiToken)
mock_key_2.id = "key-2"
mock_key_2.type = "dataset"
mock_key_2.dataset_id = None
mock_key_2.token = "ds-def"
mock_key_2.token = "dataset-cccc2222dddd"
mock_key_2.last_used_at = None
mock_key_2.created_at = None
@ -1781,10 +1781,11 @@ class TestDatasetApiKeyApi:
assert "data" in response
assert len(response["data"]) == 2
# reveal-once: the list returns masked tokens, never the full secret
assert response["data"][0]["id"] == "key-1"
assert response["data"][0]["token"] == "ds-abc"
assert response["data"][0]["token"] == "datas...bbbb"
assert response["data"][1]["id"] == "key-2"
assert response["data"][1]["token"] == "ds-def"
assert response["data"][1]["token"] == "datas...dddd"
def test_post_create_api_key_success(self, app: Flask):
api = DatasetApiKeyApi()

View File

@ -9,7 +9,13 @@ from unittest.mock import patch
import pytest
from werkzeug.exceptions import Forbidden
from controllers.console.apikey import BaseApiKeyListResource, BaseApiKeyResource, DatasetApiKeyListResource
from controllers.console.apikey import (
BaseApiKeyListResource,
BaseApiKeyResource,
DatasetApiKeyListResource,
build_masked_api_key_list,
mask_api_token,
)
from models import Account
from models.account import AccountStatus, TenantAccountRole
from models.dataset import Dataset
@ -45,12 +51,37 @@ def _make_account(role: TenantAccountRole) -> Account:
return account
def test_mask_api_token_reveals_only_a_fragment() -> None:
# full secret must never be reproducible from the masked value
masked = mask_api_token("dataset-mqxAkpML14jRmgsb6Z7DBnVq")
assert masked == "datas...BnVq"
assert "mqxAkpML" not in masked
# very short tokens are fully hidden
assert mask_api_token("short") == "***"
def test_build_masked_api_key_list_masks_every_token() -> None:
keys = [
SimpleNamespace(
id="key-1",
type=ApiTokenType.DATASET,
token="dataset-aaaabbbbccccdddd",
dataset_id="ds-1",
last_used_at=None,
created_at=None,
),
]
result = build_masked_api_key_list(keys)
assert result.data[0].token == "datas...dddd"
assert result.data[0].dataset_id == "ds-1"
def test_list_api_keys_uses_injected_tenant_id() -> None:
resource = _make_list_resource()
api_key = SimpleNamespace(
id="key-1",
type=ApiTokenType.APP,
token="app-token",
token="app-1234567890abcdef",
last_used_at=None,
created_at=None,
)
@ -68,8 +99,9 @@ def test_list_api_keys_uses_injected_tenant_id() -> None:
"data": [
{
"id": "key-1",
# reveal-once: the list never returns the full secret, only a masked fragment
"type": "app",
"token": "app-token",
"token": "app-1...cdef",
"dataset_id": None,
"last_used_at": None,
"created_at": None,

View File

@ -177,7 +177,7 @@ describe('SecretKeyModal', () => {
it('should render API keys when available', async () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument()
expect(screen.getByText('sk-abc123def456ghi789')).toBeInTheDocument()
})
it('should render created time for keys', async () => {
@ -236,7 +236,7 @@ describe('SecretKeyModal', () => {
it('should render dataset API keys when no appId', async () => {
await renderModal(<SecretKeyModal {...defaultProps} />)
expect(screen.getByText('dk-...k-abc123def456ghi789')).toBeInTheDocument()
expect(screen.getByText('dk-abc123def456ghi789')).toBeInTheDocument()
})
})
@ -445,14 +445,14 @@ describe('SecretKeyModal', () => {
it('should render delete button for permitted users', async () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
const actionButtons = screen.getAllByRole('button')
expect(actionButtons.length).toBeGreaterThanOrEqual(3)
const actionButtons = document.body.querySelectorAll('button.action-btn')
expect(actionButtons.length).toBeGreaterThanOrEqual(1)
})
it('should render API key row with actions', async () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.getByText('sk-...k-abc123def456ghi789')).toBeInTheDocument()
expect(screen.getByText('sk-abc123def456ghi789')).toBeInTheDocument()
})
it('should have action buttons in the key row', async () => {
@ -475,7 +475,7 @@ describe('SecretKeyModal', () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
const deleteButton = actionButtons[0]
expect(deleteButton).toBeInTheDocument()
await act(async () => {
@ -495,7 +495,7 @@ describe('SecretKeyModal', () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
const deleteButton = actionButtons[0]
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
@ -525,7 +525,7 @@ describe('SecretKeyModal', () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
const deleteButton = actionButtons[0]
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
@ -552,7 +552,7 @@ describe('SecretKeyModal', () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
const deleteButton = actionButtons[0]
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
@ -581,7 +581,7 @@ describe('SecretKeyModal', () => {
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
const deleteButton = actionButtons[0]
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
@ -617,7 +617,7 @@ describe('SecretKeyModal', () => {
await renderModal(<SecretKeyModal {...defaultProps} />)
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
const deleteButton = actionButtons[0]
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
@ -652,7 +652,7 @@ describe('SecretKeyModal', () => {
await renderModal(<SecretKeyModal {...defaultProps} datasetId="dataset-123" />)
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
const deleteButton = actionButtons[0]
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
@ -697,7 +697,7 @@ describe('SecretKeyModal', () => {
await renderModal(<SecretKeyModal {...defaultProps} />)
const actionButtons = document.body.querySelectorAll('button.action-btn')
const deleteButton = actionButtons[1]
const deleteButton = actionButtons[0]
await act(async () => {
await user.click(deleteButton!)
vi.runAllTimers()
@ -720,16 +720,16 @@ describe('SecretKeyModal', () => {
})
})
describe('token truncation', () => {
it('should truncate token correctly', async () => {
describe('token display', () => {
it('should display the token exactly as provided by the API (no client-side reveal)', async () => {
const apiKeys = [
{ id: 'key-1', token: 'sk-abcdefghijklmnopqrstuvwxyz1234567890', created_at: 1700000000, last_used_at: null },
{ id: 'key-1', token: 'sk-...7890', created_at: 1700000000, last_used_at: null },
]
mockAppApiKeysData.mockReturnValue({ data: apiKeys })
await renderModal(<SecretKeyModal {...defaultProps} appId="app-123" />)
expect(screen.getByText('sk-...qrstuvwxyz1234567890')).toBeInTheDocument()
expect(screen.getByText('sk-...7890')).toBeInTheDocument()
})
})

View File

@ -19,7 +19,6 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import CopyFeedback from '@/app/components/base/copy-feedback'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import useTimestamp from '@/hooks/use-timestamp'
@ -122,10 +121,6 @@ const SecretKeyModal = ({
: t('apiKeyModal.scopeBoundDataset', { ns: 'appApi' })
}
const generateToken = (token: string) => {
return `${token.slice(0, 3)}...${token.slice(-20)}`
}
const handleDeleteConfirmOpenChange = (open: boolean) => {
if (open)
return
@ -154,7 +149,9 @@ const SecretKeyModal = ({
</DialogTitle>
<div className="-mt-6 -mr-2 mb-4 flex justify-end">
<span className="i-heroicons-x-mark-20-solid size-6 cursor-pointer text-text-tertiary" onClick={handleClose} />
<button type="button" aria-label={t('operation.cancel', { ns: 'common' })} className="border-none bg-transparent p-0 text-text-tertiary" onClick={handleClose}>
<span className="i-heroicons-x-mark-20-solid size-6 cursor-pointer" />
</button>
</div>
<p className="mt-1 shrink-0 text-[13px] leading-5 font-normal text-text-tertiary">{t('apiKeyModal.apiSecretKeyTips', { ns: 'appApi' })}</p>
{isApiKeysLoading && <div className="mt-4"><Loading /></div>}
@ -171,12 +168,11 @@ const SecretKeyModal = ({
<div className="grow overflow-x-hidden overflow-y-auto">
{apiKeysList.data.map(api => (
<div className="flex h-9 items-center border-b border-divider-regular text-sm font-normal text-text-secondary" key={api.id}>
<div className="min-w-0 flex-[1.8] truncate px-3 font-mono">{generateToken(api.token)}</div>
<div className="min-w-0 flex-[1.8] truncate px-3 font-mono">{api.token}</div>
{!appId && <div className="min-w-0 flex-[1.3] truncate px-3" title={getScopeLabel(api.dataset_id)}>{getScopeLabel(api.dataset_id)}</div>}
<div className="min-w-0 flex-[1.8] truncate px-3">{formatTime(Number(api.created_at), t('dateTimeFormat', { ns: 'appLog' }) as string)}</div>
<div className="min-w-0 flex-[1.8] truncate px-3">{api.last_used_at ? formatTime(Number(api.last_used_at), t('dateTimeFormat', { ns: 'appLog' }) as string) : t('never', { ns: 'appApi' })}</div>
<div className="flex w-20 shrink-0 space-x-2 px-3">
<CopyFeedback content={api.token} />
<div className="flex w-20 shrink-0 justify-end space-x-2 px-3">
{canManage && (
<ActionButton
onClick={() => {