mirror of
https://github.com/langgenius/dify.git
synced 2026-09-01 15:09:21 +08:00
fix: enforce API key list permissions (#40516)
This commit is contained in:
@@ -3,7 +3,7 @@ from uuid import UUID
|
||||
from flask import abort, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import AliasChoices, BaseModel, Field, field_validator
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import (
|
||||
@@ -484,12 +484,13 @@ def _resolve_agent_runtime_app_model(session: Session, *, tenant_id: str, agent_
|
||||
return _agent_roster_service(session).get_agent_runtime_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
|
||||
|
||||
def _agent_api_key_count(session: Session, app_id: str) -> int:
|
||||
def _agent_api_key_count(session: Session, app_model: App) -> int:
|
||||
return (
|
||||
session.scalar(
|
||||
select(func.count(ApiToken.id)).where(
|
||||
or_(ApiToken.tenant_id == app_model.tenant_id, ApiToken.tenant_id.is_(None)),
|
||||
ApiToken.type == ApiTokenType.APP,
|
||||
ApiToken.app_id == app_id,
|
||||
ApiToken.app_id == app_model.id,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
@@ -521,7 +522,7 @@ def _serialize_agent_api_access(session: Session, app_model: App) -> dict:
|
||||
meta_endpoint=f"{base_url}/meta",
|
||||
api_rpm=app_model.api_rpm or 0,
|
||||
api_rph=app_model.api_rph or 0,
|
||||
api_key_count=_agent_api_key_count(session, str(app_model.id)),
|
||||
api_key_count=_agent_api_key_count(session, app_model),
|
||||
)
|
||||
return response.model_dump(mode="json")
|
||||
|
||||
@@ -940,6 +941,8 @@ class AgentApiKeyListApi(BaseApiKeyListResource):
|
||||
@console_ns.response(200, "Agent service API keys", console_ns.models[ApiKeyList.__name__])
|
||||
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, resource_required=False)
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, tenant_id: str, agent_id: UUID) -> dict[str, object]:
|
||||
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
|
||||
@@ -5,7 +5,7 @@ import flask_restx
|
||||
from flask_restx import Resource
|
||||
from flask_restx._http import HTTPStatus
|
||||
from pydantic import field_validator
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
@@ -89,7 +89,9 @@ class BaseApiKeyListResource(Resource):
|
||||
_get_resource(resource_id, current_tenant_id, self.resource_model, session=session)
|
||||
keys = session.scalars(
|
||||
select(ApiToken).where(
|
||||
ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id
|
||||
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
|
||||
ApiToken.type == self.resource_type,
|
||||
getattr(ApiToken, self.resource_id_field) == resource_id,
|
||||
)
|
||||
).all()
|
||||
return ApiKeyList.model_validate({"data": keys}, from_attributes=True)
|
||||
@@ -110,7 +112,9 @@ class BaseApiKeyListResource(Resource):
|
||||
current_key_count: int = (
|
||||
session.scalar(
|
||||
select(func.count(ApiToken.id)).where(
|
||||
ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id
|
||||
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
|
||||
ApiToken.type == self.resource_type,
|
||||
getattr(ApiToken, self.resource_id_field) == resource_id,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
@@ -172,6 +176,7 @@ class BaseApiKeyResource(Resource):
|
||||
key = session.scalar(
|
||||
select(ApiToken)
|
||||
.where(
|
||||
or_(ApiToken.tenant_id == current_tenant_id, ApiToken.tenant_id.is_(None)),
|
||||
getattr(ApiToken, self.resource_id_field) == resource_id,
|
||||
ApiToken.type == self.resource_type,
|
||||
ApiToken.id == api_key_id,
|
||||
@@ -187,7 +192,7 @@ class BaseApiKeyResource(Resource):
|
||||
assert key is not None # nosec - for type checker only
|
||||
ApiTokenCache.delete(key.token, key.type)
|
||||
|
||||
session.execute(delete(ApiToken).where(ApiToken.id == api_key_id))
|
||||
session.delete(key)
|
||||
session.commit()
|
||||
|
||||
|
||||
@@ -198,6 +203,8 @@ class AppApiKeyListResource(BaseApiKeyListResource):
|
||||
@console_ns.doc(params={"resource_id": "App ID"})
|
||||
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
|
||||
@agent_manage_required_for_agent_app
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, resource_id: UUID) -> dict[str, object]:
|
||||
|
||||
@@ -1106,6 +1106,8 @@ class DatasetApiKeyApi(Resource):
|
||||
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE, resource_required=False)
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
|
||||
@@ -65,8 +65,8 @@ from controllers.console.app.message import (
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from models.agent import Agent, AgentConfigDraftType, AgentScope, AgentSource, AgentStatus
|
||||
from models.enums import ConversationFromSource
|
||||
from models.model import AppMode, Conversation, Message
|
||||
from models.enums import ApiTokenType, ConversationFromSource
|
||||
from models.model import ApiToken, App, AppMode, Conversation, Message
|
||||
from services.entities.agent_entities import (
|
||||
ComposerSavePayload,
|
||||
ComposerSaveStrategy,
|
||||
@@ -851,7 +851,7 @@ def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata(monkeyp
|
||||
api_rph=600,
|
||||
)
|
||||
monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda _session, **kwargs: app_model)
|
||||
monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda _session, app_id: 2)
|
||||
monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda _session, _app: 2)
|
||||
monkeypatch.setattr(roster_controller, "_agent_app_access_ready", lambda _session, _app: True)
|
||||
response = unwrap(AgentApiAccessApi.get)(AgentApiAccessApi(), MagicMock(), "tenant-1", agent_id)
|
||||
assert response == {
|
||||
@@ -873,6 +873,20 @@ def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata(monkeyp
|
||||
}
|
||||
|
||||
|
||||
def test_agent_api_key_count_scopes_tenant_and_keeps_legacy_tokens(sqlite_session: Session) -> None:
|
||||
app_model = cast(App, _app_detail_obj())
|
||||
sqlite_session.add_all(
|
||||
[
|
||||
ApiToken(type=ApiTokenType.APP, token="owned", app_id=app_model.id, tenant_id=app_model.tenant_id),
|
||||
ApiToken(type=ApiTokenType.APP, token="legacy", app_id=app_model.id, tenant_id=None),
|
||||
ApiToken(type=ApiTokenType.APP, token="foreign", app_id=app_model.id, tenant_id="tenant-2"),
|
||||
]
|
||||
)
|
||||
sqlite_session.commit()
|
||||
|
||||
assert roster_controller._agent_api_key_count(sqlite_session, app_model) == 2
|
||||
|
||||
|
||||
def test_agent_api_status_and_key_routes_resolve_backing_app(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
|
||||
) -> None:
|
||||
@@ -889,7 +903,7 @@ def test_agent_api_status_and_key_routes_resolve_backing_app(
|
||||
captured: dict[str, object] = {}
|
||||
resolve_app = Mock(return_value=app_model)
|
||||
monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", resolve_app)
|
||||
monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda _session, app_id: 1)
|
||||
monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda _session, _app: 1)
|
||||
monkeypatch.setattr(roster_controller, "_agent_app_access_ready", lambda _session, _app: True)
|
||||
|
||||
class FakeAppService:
|
||||
|
||||
@@ -3,14 +3,25 @@ from __future__ import annotations
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from typing import cast
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import event, select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
|
||||
|
||||
from controllers.console.apikey import BaseApiKeyListResource, BaseApiKeyResource
|
||||
from configs import dify_config
|
||||
from controllers.console.agent.roster import AgentApiKeyListApi
|
||||
from controllers.console.apikey import (
|
||||
AppApiKeyListResource,
|
||||
BaseApiKeyListResource,
|
||||
BaseApiKeyResource,
|
||||
)
|
||||
from controllers.console.datasets.datasets import DatasetApiKeyApi
|
||||
from core.rbac import RBACPermission, RBACResourceScope
|
||||
from enums import DeploymentEdition
|
||||
from models import Account
|
||||
from models.account import AccountStatus, TenantAccountRole
|
||||
from models.enums import ApiTokenType
|
||||
@@ -79,14 +90,22 @@ def test_list_api_keys_uses_injected_session_and_tenant_id(sqlite_session: Sessi
|
||||
)
|
||||
api_key.id = "key-1"
|
||||
session.add(api_key)
|
||||
session.add(
|
||||
ApiToken(
|
||||
type=ApiTokenType.APP,
|
||||
token="foreign-app-token",
|
||||
app_id="app-1",
|
||||
tenant_id="tenant-2",
|
||||
)
|
||||
)
|
||||
legacy_api_key = ApiToken(type=ApiTokenType.APP, token="legacy-app-token", app_id="app-1", tenant_id=None)
|
||||
session.add(legacy_api_key)
|
||||
session.commit()
|
||||
|
||||
result = raw_get(resource, session, "app-1", "tenant-1")
|
||||
data = cast(list[dict[str, object]], result["data"])
|
||||
|
||||
assert len(data) == 1
|
||||
assert data[0]["id"] == "key-1"
|
||||
assert data[0]["token"] == "app-token"
|
||||
assert {item["token"] for item in data} == {"app-token", "legacy-app-token"}
|
||||
|
||||
|
||||
def test_create_api_key_uses_injected_session_and_tenant_id(sqlite_session: Session) -> None:
|
||||
@@ -97,6 +116,13 @@ def test_create_api_key_uses_injected_session_and_tenant_id(sqlite_session: Sess
|
||||
)
|
||||
session = sqlite_session
|
||||
_persist_app(session)
|
||||
session.add_all(
|
||||
[
|
||||
ApiToken(type=ApiTokenType.APP, token=f"foreign-token-{index}", app_id="app-1", tenant_id="tenant-2")
|
||||
for index in range(resource.max_keys)
|
||||
]
|
||||
)
|
||||
session.commit()
|
||||
commits: list[str] = []
|
||||
event.listen(session, "after_commit", lambda _session: commits.append("commit"))
|
||||
|
||||
@@ -116,6 +142,21 @@ def test_create_api_key_uses_injected_session_and_tenant_id(sqlite_session: Sess
|
||||
assert commits == ["commit"]
|
||||
|
||||
|
||||
def test_create_api_key_counts_legacy_tokens(sqlite_session: Session) -> None:
|
||||
resource = _make_list_resource()
|
||||
_persist_app(sqlite_session)
|
||||
sqlite_session.add_all(
|
||||
[
|
||||
ApiToken(type=ApiTokenType.APP, token=f"legacy-token-{index}", app_id="app-1", tenant_id=None)
|
||||
for index in range(resource.max_keys)
|
||||
]
|
||||
)
|
||||
sqlite_session.commit()
|
||||
|
||||
with pytest.raises(BadRequest):
|
||||
resource._create_api_key("app-1", "tenant-1", session=sqlite_session)
|
||||
|
||||
|
||||
def test_create_agent_api_key_requires_published_access(sqlite_session: Session) -> None:
|
||||
resource = _make_list_resource()
|
||||
session = sqlite_session
|
||||
@@ -160,7 +201,7 @@ def test_delete_api_key_uses_injected_session_user_and_tenant(sqlite_session: Se
|
||||
)
|
||||
session = sqlite_session
|
||||
_persist_app(session)
|
||||
api_key = ApiToken(type=ApiTokenType.APP, token="app-token", app_id="app-1", tenant_id="tenant-1")
|
||||
api_key = ApiToken(type=ApiTokenType.APP, token="app-token", app_id="app-1", tenant_id=None)
|
||||
api_key.id = "key-1"
|
||||
session.add(api_key)
|
||||
session.commit()
|
||||
@@ -182,3 +223,101 @@ def test_delete_api_key_uses_injected_session_user_and_tenant(sqlite_session: Se
|
||||
assert commits == ["commit"]
|
||||
assert result == ""
|
||||
assert status == 204
|
||||
|
||||
|
||||
def test_delete_api_key_rejects_foreign_tenant_token(sqlite_session: Session) -> None:
|
||||
resource = _make_key_resource()
|
||||
session = sqlite_session
|
||||
_persist_app(session)
|
||||
api_key = ApiToken(type=ApiTokenType.APP, token="foreign-token", app_id="app-1", tenant_id="tenant-2")
|
||||
api_key.id = "key-1"
|
||||
session.add(api_key)
|
||||
session.commit()
|
||||
|
||||
with patch("controllers.console.apikey.ApiTokenCache.delete") as delete_cache:
|
||||
with pytest.raises(NotFound):
|
||||
resource._delete_api_key(
|
||||
"app-1",
|
||||
"key-1",
|
||||
"tenant-1",
|
||||
_make_account(TenantAccountRole.OWNER),
|
||||
session=session,
|
||||
)
|
||||
|
||||
delete_cache.assert_not_called()
|
||||
assert session.get(ApiToken, "key-1") is api_key
|
||||
|
||||
|
||||
def test_api_key_lists_require_matching_rbac_permission() -> None:
|
||||
app = Flask(__name__)
|
||||
account = _make_account(TenantAccountRole.OWNER)
|
||||
api_id = UUID("00000000-0000-0000-0000-000000000001")
|
||||
cases = [
|
||||
(
|
||||
lambda: AppApiKeyListResource().get(resource_id=api_id),
|
||||
[(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION, True)],
|
||||
),
|
||||
(
|
||||
lambda: AgentApiKeyListApi().get(agent_id=api_id),
|
||||
[
|
||||
(RBACResourceScope.WORKSPACE, RBACPermission.AGENT_MANAGE, False),
|
||||
(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION, True),
|
||||
],
|
||||
),
|
||||
(
|
||||
lambda: DatasetApiKeyApi().get(),
|
||||
[(RBACResourceScope.DATASET, RBACPermission.DATASET_API_KEY_MANAGE, False)],
|
||||
),
|
||||
]
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
|
||||
patch.object(dify_config, "LOGIN_DISABLED", True),
|
||||
patch.object(dify_config, "RBAC_ENABLED", True),
|
||||
patch("controllers.console.wraps.current_account_with_tenant", return_value=(account, "tenant-1")),
|
||||
patch("controllers.common.wraps.current_account_with_tenant", return_value=(account, "tenant-1")),
|
||||
patch.object(BaseApiKeyListResource, "_get_api_key_list") as get_api_key_list,
|
||||
):
|
||||
for invoke, expected_gates in cases:
|
||||
with patch(
|
||||
"controllers.common.wraps.enforce_rbac_access",
|
||||
side_effect=[None] * (len(expected_gates) - 1) + [Forbidden()],
|
||||
) as enforce_rbac_access:
|
||||
with pytest.raises(Forbidden):
|
||||
invoke()
|
||||
|
||||
assert [
|
||||
(kwargs["resource_type"], kwargs["scene"], kwargs["resource_required"])
|
||||
for _, kwargs in enforce_rbac_access.call_args_list
|
||||
] == expected_gates
|
||||
|
||||
get_api_key_list.assert_not_called()
|
||||
|
||||
|
||||
def test_api_key_lists_reject_legacy_read_only_members() -> None:
|
||||
app = Flask(__name__)
|
||||
account = _make_account(TenantAccountRole.NORMAL)
|
||||
api_id = UUID("00000000-0000-0000-0000-000000000001")
|
||||
current_user = MagicMock()
|
||||
current_user._get_current_object.return_value = account
|
||||
current_user.has_edit_permission = False
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD),
|
||||
patch.object(dify_config, "LOGIN_DISABLED", True),
|
||||
patch.object(dify_config, "RBAC_ENABLED", False),
|
||||
patch("libs.login.current_user", current_user),
|
||||
patch("controllers.console.wraps.current_account_with_tenant", return_value=(account, "tenant-1")),
|
||||
patch.object(BaseApiKeyListResource, "_get_api_key_list") as get_api_key_list,
|
||||
):
|
||||
for invoke in (
|
||||
lambda: AppApiKeyListResource().get(resource_id=api_id),
|
||||
lambda: AgentApiKeyListApi().get(agent_id=api_id),
|
||||
lambda: DatasetApiKeyApi().get(),
|
||||
):
|
||||
with pytest.raises(Forbidden):
|
||||
invoke()
|
||||
|
||||
get_api_key_list.assert_not_called()
|
||||
|
||||
@@ -86,12 +86,12 @@ describe('ApiSecretKeyButton', () => {
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
it('keeps the current count visible without management permission', () => {
|
||||
it('does not request API keys without management permission', () => {
|
||||
render(<ApiSecretKeyButton appId="app-1" canManage={false} />)
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'appApi.apiKeyModal.apiSecretKey 2',
|
||||
name: 'appApi.apiKeyModal.apiSecretKey 0',
|
||||
}),
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
@@ -16,6 +16,11 @@ const mocks = vi.hoisted(() => ({
|
||||
},
|
||||
webCard: vi.fn(),
|
||||
apiCard: vi.fn(),
|
||||
capabilities: {
|
||||
canEdit: false,
|
||||
canDeploy: true,
|
||||
canReleaseAndVersion: false,
|
||||
},
|
||||
mcpCard: vi.fn(),
|
||||
triggerCard: vi.fn(),
|
||||
}))
|
||||
@@ -59,11 +64,7 @@ vi.mock('@/service/use-workflow', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/permission', () => ({
|
||||
getAppACLCapabilities: () => ({
|
||||
canEdit: false,
|
||||
canDeploy: true,
|
||||
canReleaseAndVersion: false,
|
||||
}),
|
||||
getAppACLCapabilities: () => mocks.capabilities,
|
||||
}))
|
||||
|
||||
vi.mock('../shared/use-access-point-actions', () => ({
|
||||
@@ -119,6 +120,11 @@ describe('BuiltInAccessPoints', () => {
|
||||
data: null,
|
||||
isPending: false,
|
||||
}
|
||||
mocks.capabilities = {
|
||||
canEdit: false,
|
||||
canDeploy: true,
|
||||
canReleaseAndVersion: false,
|
||||
}
|
||||
})
|
||||
|
||||
it('renders the unpublished state across all access point cards', () => {
|
||||
@@ -129,7 +135,7 @@ describe('BuiltInAccessPoints', () => {
|
||||
expect.objectContaining({ availability: 'unavailable', canDeploy: true, canEdit: false }),
|
||||
)
|
||||
expect(mocks.apiCard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ availability: 'unavailable', canEdit: false }),
|
||||
expect.objectContaining({ availability: 'unavailable', canManage: false }),
|
||||
)
|
||||
expect(mocks.mcpCard).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.triggerCard).toHaveBeenCalledWith(
|
||||
@@ -163,6 +169,18 @@ describe('BuiltInAccessPoints', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not use edit permission to manage the Service API', () => {
|
||||
mocks.capabilities = {
|
||||
canEdit: true,
|
||||
canDeploy: true,
|
||||
canReleaseAndVersion: false,
|
||||
}
|
||||
|
||||
render(<BuiltInAccessPoints appId="app-1" />)
|
||||
|
||||
expect(mocks.apiCard).toHaveBeenCalledWith(expect.objectContaining({ canManage: false }))
|
||||
})
|
||||
|
||||
it('highlights only the targeted built-in access point card', () => {
|
||||
render(<BuiltInAccessPoints appId="app-1" highlightedAccessPoint="mcp" />)
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('ServiceApiAccessPointCard', () => {
|
||||
<ServiceApiAccessPointCard
|
||||
appInfo={createAppInfo(mode)}
|
||||
availability="available"
|
||||
canEdit
|
||||
canManage
|
||||
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
)
|
||||
@@ -69,7 +69,7 @@ describe('ServiceApiAccessPointCard', () => {
|
||||
<ServiceApiAccessPointCard
|
||||
appInfo={createAppInfo(AppModeEnum.WORKFLOW)}
|
||||
availability="loading"
|
||||
canEdit
|
||||
canManage
|
||||
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
)
|
||||
@@ -87,7 +87,7 @@ describe('ServiceApiAccessPointCard', () => {
|
||||
<ServiceApiAccessPointCard
|
||||
appInfo={createAppInfo(AppModeEnum.WORKFLOW, { enable_api: false })}
|
||||
availability="available"
|
||||
canEdit
|
||||
canManage
|
||||
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
)
|
||||
@@ -101,12 +101,26 @@ describe('ServiceApiAccessPointCard', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('disables API management without release permission', () => {
|
||||
render(
|
||||
<ServiceApiAccessPointCard
|
||||
appInfo={createAppInfo(AppModeEnum.WORKFLOW)}
|
||||
availability="available"
|
||||
canManage={false}
|
||||
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'api-secret-keys' })).toBeDisabled()
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
|
||||
it('disables API keys and external documentation when the access point is unavailable', () => {
|
||||
render(
|
||||
<ServiceApiAccessPointCard
|
||||
appInfo={createAppInfo(AppModeEnum.WORKFLOW)}
|
||||
availability="unavailable"
|
||||
canEdit
|
||||
canManage
|
||||
onChangeStatus={vi.fn().mockResolvedValue(undefined)}
|
||||
/>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { act } from '@testing-library/react'
|
||||
import { renderHookWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { useAccessPointActions } from '../shared/use-access-point-actions'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
onAppStateUpdate: vi.fn(() => vi.fn()),
|
||||
setAppDetail: vi.fn(),
|
||||
updateAppSiteStatus: vi.fn().mockResolvedValue({}),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({ toast: vi.fn() }))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: (selector: (state: { setAppDetail: typeof mocks.setAppDetail }) => unknown) =>
|
||||
selector({ setAppDetail: mocks.setAppDetail }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
|
||||
collaborationManager: { onAppStateUpdate: mocks.onAppStateUpdate },
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration/core/websocket-manager', () => ({
|
||||
webSocketClient: { getSocket: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
fetchAppDetail: vi.fn().mockResolvedValue({}),
|
||||
updateAppSiteAccessToken: vi.fn(),
|
||||
updateAppSiteConfig: vi.fn(),
|
||||
updateAppSiteStatus: mocks.updateAppSiteStatus,
|
||||
}))
|
||||
|
||||
describe('useAccessPointActions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('allows the API status to be changed independently of app editing', async () => {
|
||||
const { result } = renderHookWithConsoleQuery(() => useAccessPointActions('app-1', false))
|
||||
|
||||
await act(() => result.current.changeApiStatus(true))
|
||||
|
||||
expect(mocks.updateAppSiteStatus).toHaveBeenCalledWith({
|
||||
url: '/apps/app-1/api-enable',
|
||||
body: { enable_api: true },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -116,7 +116,7 @@ export function BuiltInAccessPoints({ appId, highlightedAccessPoint }: BuiltInAc
|
||||
<ServiceApiAccessPointCard
|
||||
appInfo={appInfo}
|
||||
availability={appCardAvailability}
|
||||
canEdit={capabilities.canEdit}
|
||||
canManage={capabilities.canReleaseAndVersion}
|
||||
onChangeStatus={actions.changeApiStatus}
|
||||
highlighted={highlightedAccessPoint === 'serviceApi'}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getBuiltInAccessUrls } from '../shared/utils'
|
||||
type ServiceApiAccessPointCardProps = {
|
||||
appInfo: AccessPointAppInfo
|
||||
availability: AccessPointAvailability
|
||||
canEdit: boolean
|
||||
canManage: boolean
|
||||
highlighted?: boolean
|
||||
onChangeStatus: (enabled: boolean) => Promise<void>
|
||||
}
|
||||
@@ -17,7 +17,7 @@ type ServiceApiAccessPointCardProps = {
|
||||
export function ServiceApiAccessPointCard({
|
||||
appInfo,
|
||||
availability,
|
||||
canEdit,
|
||||
canManage,
|
||||
highlighted,
|
||||
onChangeStatus,
|
||||
}: ServiceApiAccessPointCardProps) {
|
||||
@@ -29,7 +29,7 @@ export function ServiceApiAccessPointCard({
|
||||
<ServiceApiCardView
|
||||
apiKeyButtonProps={{
|
||||
appId: appInfo.id,
|
||||
canManage: canEdit,
|
||||
canManage,
|
||||
disabled: availability !== 'available',
|
||||
}}
|
||||
apiUrl={apiUrl}
|
||||
@@ -37,7 +37,7 @@ export function ServiceApiAccessPointCard({
|
||||
available={availability === 'available'}
|
||||
status={status}
|
||||
highlighted={highlighted}
|
||||
switchDisabled={!canEdit}
|
||||
switchDisabled={!canManage}
|
||||
onEnabledChange={availability === 'available' ? onChangeStatus : undefined}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -28,7 +28,7 @@ export function ApiSecretKeyButton({
|
||||
const isEnvironmentScope = Boolean(environmentId)
|
||||
const apiKeysQuery = useQuery(
|
||||
consoleQuery.apps.byResourceId.apiKeys.get.queryOptions({
|
||||
input: isEnvironmentScope ? skipToken : { params: { resource_id: appId } },
|
||||
input: isEnvironmentScope || !canManage ? skipToken : { params: { resource_id: appId } },
|
||||
}),
|
||||
)
|
||||
const apiKeyCount = isEnvironmentScope
|
||||
|
||||
@@ -81,7 +81,6 @@ export function useAccessPointActions(appId: string, canEdit: boolean) {
|
||||
|
||||
const changeApiStatus = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
if (!canEdit) return
|
||||
const [error] = await asyncRunSafe<App>(
|
||||
updateAppSiteStatus({
|
||||
url: `/apps/${appId}/api-enable`,
|
||||
@@ -90,7 +89,7 @@ export function useAccessPointActions(appId: string, canEdit: boolean) {
|
||||
)
|
||||
handleResult(error)
|
||||
},
|
||||
[appId, canEdit, handleResult],
|
||||
[appId, handleResult],
|
||||
)
|
||||
|
||||
const saveSiteConfig = useCallback(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { renderWithAccountProfile as render } from '@/test/console/account-profile'
|
||||
import { AppACLPermission } from '@/utils/permission'
|
||||
import DevelopMain from '../index'
|
||||
|
||||
const mockAppDetailValue: { current: unknown } = { current: undefined }
|
||||
@@ -24,8 +25,16 @@ vi.mock('@/app/components/develop/doc', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/develop/ApiServer', () => ({
|
||||
default: ({ apiBaseUrl, appId }: { apiBaseUrl: string; appId: string }) => (
|
||||
<div data-testid="api-server">
|
||||
default: ({
|
||||
apiBaseUrl,
|
||||
appId,
|
||||
canManageApiKey,
|
||||
}: {
|
||||
apiBaseUrl: string
|
||||
appId: string
|
||||
canManageApiKey: boolean
|
||||
}) => (
|
||||
<div data-testid="api-server" data-can-manage-api-key={canManageApiKey}>
|
||||
API Server -{apiBaseUrl} -{appId}
|
||||
</div>
|
||||
),
|
||||
@@ -104,6 +113,16 @@ describe('DevelopMain', () => {
|
||||
expect(screen.getByTestId('api-server')).toHaveTextContent('app-123')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[[AppACLPermission.ReleaseAndVersion], 'true'],
|
||||
[[AppACLPermission.Edit], 'false'],
|
||||
])('should gate API key management by release permission', (permissionKeys, canManage) => {
|
||||
mockAppDetailValue.current = { ...mockAppDetail, permission_keys: permissionKeys }
|
||||
render(<DevelopMain appId="app-123" />)
|
||||
|
||||
expect(screen.getByTestId('api-server')).toHaveAttribute('data-can-manage-api-key', canManage)
|
||||
})
|
||||
|
||||
it('should render Doc component', () => {
|
||||
render(<DevelopMain appId="app-123" />)
|
||||
expect(screen.getByTestId('doc-component')).toBeInTheDocument()
|
||||
|
||||
@@ -42,7 +42,7 @@ const DevelopMain = ({ appId }: IDevelopMainProps) => {
|
||||
<ApiServer
|
||||
apiBaseUrl={appDetail.api_base_url}
|
||||
appId={appId}
|
||||
canManageApiKey={appACLCapabilities.canEdit}
|
||||
canManageApiKey={appACLCapabilities.canReleaseAndVersion}
|
||||
/>
|
||||
</div>
|
||||
<div className="grow overflow-auto p-4 sm:px-10">
|
||||
|
||||
Reference in New Issue
Block a user