mirror of
https://github.com/langgenius/dify.git
synced 2026-06-26 23:01:11 +08:00
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:
parent
30d5e4987c
commit
a79bc7d074
@ -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")
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user