mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
refactor(api): centralize API key management (#42489)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
1a09485013
commit
8b7f903cff
36 files changed
+1840
-1077
No files matched your search
@@ -18,7 +18,7 @@ from controllers.common.schema import (
|
||||
)
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.apikey import ApiKeyItem, ApiKeyList, BaseApiKeyListResource, BaseApiKeyResource
|
||||
from controllers.console.apikey import API_KEY_DELETE_ROLES, API_KEY_EDIT_ROLES, api_key_errors
|
||||
from controllers.console.app.app import (
|
||||
APP_LIST_QUERY_ARRAY_FIELDS,
|
||||
AppListQuery,
|
||||
@@ -35,6 +35,7 @@ from controllers.console.app.app import (
|
||||
from controllers.console.app.app import (
|
||||
UpdateAppPayload as GenericUpdateAppPayload,
|
||||
)
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
account_initialization_required,
|
||||
@@ -49,6 +50,7 @@ from controllers.console.wraps import (
|
||||
)
|
||||
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.agent_fields import (
|
||||
AgentConfigDraftSummaryResponse,
|
||||
AgentConfigSnapshotDetailResponse,
|
||||
@@ -63,10 +65,12 @@ from fields.agent_fields import (
|
||||
AgentRosterListResponse,
|
||||
AgentStatisticSummaryEnvelopeResponse,
|
||||
)
|
||||
from fields.api_key_fields import ApiKeyItem, ApiKeyList
|
||||
from fields.base import ResponseModel
|
||||
from libs.datetime_utils import parse_time_range
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from machinery.context import RequestContext
|
||||
from models import Account
|
||||
from models.agent import Agent, AgentStatus
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
@@ -1039,56 +1043,44 @@ class AgentApiStatusApi(Resource):
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/api-keys")
|
||||
class AgentApiKeyListApi(BaseApiKeyListResource):
|
||||
resource_type = ApiTokenType.APP
|
||||
resource_model = App
|
||||
resource_id_field = "app_id"
|
||||
token_prefix = "app-"
|
||||
|
||||
class AgentApiKeyListApi(Resource):
|
||||
@console_ns.response(200, "Agent service API keys", console_ns.models[ApiKeyList.__name__])
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_ACCESS_POINT_VIEW, AgentId()))
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@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)
|
||||
return dump_response(ApiKeyList, self._get_api_key_list(str(app_model.id), tenant_id, session=session))
|
||||
@console_account_admission(
|
||||
allowed_roles=API_KEY_EDIT_ROLES,
|
||||
rbac_checks=[RBACCheck(RBACPermission.AGENT_ACCESS_POINT_VIEW, AgentId())],
|
||||
)
|
||||
def get(self, request_context: RequestContext, agent_id: UUID) -> dict[str, object]:
|
||||
with api_key_errors():
|
||||
keys = application_services().app_api_keys.list_agent_keys(request_context, str(agent_id))
|
||||
return dump_response(ApiKeyList, {"data": keys})
|
||||
|
||||
@console_ns.response(201, "Agent service API key created", console_ns.models[ApiKeyItem.__name__])
|
||||
@console_ns.response(400, "Maximum keys exceeded")
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentId()))
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id: str, agent_id: UUID) -> tuple[dict[str, object], int]:
|
||||
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
return dump_response(
|
||||
ApiKeyItem,
|
||||
self._create_api_key(str(app_model.id), tenant_id, session=session),
|
||||
), 201
|
||||
@console_account_admission(
|
||||
allowed_roles=API_KEY_EDIT_ROLES,
|
||||
rbac_checks=[RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentId())],
|
||||
)
|
||||
def post(self, request_context: RequestContext, agent_id: UUID) -> tuple[dict[str, object], int]:
|
||||
with api_key_errors():
|
||||
key = application_services().app_api_keys.create_agent_key(request_context, str(agent_id))
|
||||
return dump_response(ApiKeyItem, key), 201
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/api-keys/<uuid:api_key_id>")
|
||||
class AgentApiKeyApi(BaseApiKeyResource):
|
||||
resource_type = ApiTokenType.APP
|
||||
resource_model = App
|
||||
resource_id_field = "app_id"
|
||||
|
||||
class AgentApiKeyApi(Resource):
|
||||
@console_ns.response(204, "Agent service API key deleted")
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentId()))
|
||||
@with_session
|
||||
@console_account_admission(
|
||||
allowed_roles=API_KEY_DELETE_ROLES,
|
||||
rbac_checks=[RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentId())],
|
||||
)
|
||||
def delete(
|
||||
self,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
current_user: Account,
|
||||
request_context: RequestContext,
|
||||
agent_id: UUID,
|
||||
api_key_id: UUID,
|
||||
) -> tuple[str, int]:
|
||||
app_model = _resolve_agent_app_model(session, tenant_id=tenant_id, agent_id=agent_id)
|
||||
self._delete_api_key(str(app_model.id), str(api_key_id), tenant_id, current_user, session=session)
|
||||
with api_key_errors():
|
||||
application_services().app_api_keys.delete_agent_key(request_context, str(agent_id), str(api_key_id))
|
||||
return "", 204
|
||||
|
||||
|
||||
|
||||
@@ -1,390 +1,151 @@
|
||||
from collections.abc import Iterable, Mapping
|
||||
from datetime import datetime
|
||||
"""Console transport adapters for resource-scoped API key management."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from uuid import UUID
|
||||
|
||||
import flask_restx
|
||||
from flask_restx import Resource
|
||||
from flask_restx._http import HTTPStatus
|
||||
from pydantic import field_validator
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.rbac import AgentBehindApp, DatasetId, PlainApp, RBACCheck
|
||||
from controllers.common.schema import register_response_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.dataset import Dataset
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, App
|
||||
from services.api_token_service import ApiTokenCache
|
||||
from services.app_service import AppService
|
||||
|
||||
from . import console_ns
|
||||
from .wraps import (
|
||||
RBACPermission,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import RBACPermission
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.api_key_fields import ApiKeyItem, ApiKeyList, build_masked_api_key_list
|
||||
from libs.helper import dump_response
|
||||
from machinery.context import RequestContext
|
||||
from models.account import TenantAccountRole
|
||||
from services.agent.errors import AgentAccessNotReadyError
|
||||
from services.app.api_key_service import AppApiKeyNotReadyError
|
||||
from services.auth.api_key_contracts import (
|
||||
ApiKeyLimitExceededError,
|
||||
ApiKeyNotFoundError,
|
||||
ApiKeyResourceNotFoundError,
|
||||
)
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.knowledge.api_key_service import UnknownDatasetIdsError
|
||||
|
||||
|
||||
class ApiKeyItem(ResponseModel):
|
||||
id: str
|
||||
type: str
|
||||
token: str
|
||||
# Dataset keys only: the knowledge bases this key is bound to. Empty = the key can
|
||||
# access every dataset in the tenant (default). App keys are always empty.
|
||||
dataset_ids: list[str] = []
|
||||
last_used_at: int | None = None
|
||||
created_at: int | None = None
|
||||
|
||||
@field_validator("last_used_at", "created_at", mode="before")
|
||||
@classmethod
|
||||
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||
return to_timestamp(value)
|
||||
|
||||
|
||||
class ApiKeyList(ResponseModel):
|
||||
data: list[ApiKeyItem]
|
||||
|
||||
API_KEY_EDIT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR})
|
||||
API_KEY_DELETE_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN})
|
||||
|
||||
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],
|
||||
bindings_by_token: Mapping[str, list[str]] | None = None,
|
||||
) -> ApiKeyList:
|
||||
"""Build an ApiKeyList from ORM tokens with their secrets masked.
|
||||
|
||||
``bindings_by_token`` maps an api_token id to the dataset ids it is bound to
|
||||
(from DatasetApiTokenBinding); tokens absent from the map are unbound (empty =
|
||||
access all). App-key lists omit it entirely.
|
||||
"""
|
||||
bindings_by_token = bindings_by_token or {}
|
||||
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)
|
||||
item.dataset_ids = bindings_by_token.get(str(api_token.id), [])
|
||||
items.append(item)
|
||||
return ApiKeyList(data=items)
|
||||
|
||||
|
||||
def _get_resource(resource_id, tenant_id, resource_model, *, session: Session):
|
||||
resource = session.execute(
|
||||
select(resource_model).filter_by(id=resource_id, tenant_id=tenant_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if resource is None:
|
||||
flask_restx.abort(HTTPStatus.NOT_FOUND, message=f"{resource_model.__name__} not found.")
|
||||
|
||||
return resource
|
||||
|
||||
|
||||
class BaseApiKeyListResource(Resource):
|
||||
method_decorators = [account_initialization_required, login_required, setup_required]
|
||||
|
||||
resource_type: ApiTokenType | None = None
|
||||
resource_model: type | None = None
|
||||
resource_id_field: str | None = None
|
||||
token_prefix: str | None = None
|
||||
max_keys = 10
|
||||
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, resource_id: str, current_tenant_id: str) -> dict[str, object]:
|
||||
return dump_response(
|
||||
ApiKeyList,
|
||||
self._get_api_key_list(resource_id, current_tenant_id, session=session),
|
||||
)
|
||||
|
||||
def _get_api_key_list(self, resource_id: str, current_tenant_id: str, *, session: Session) -> ApiKeyList:
|
||||
assert self.resource_id_field is not None, "resource_id_field must be set"
|
||||
|
||||
_get_resource(resource_id, current_tenant_id, self.resource_model, session=session)
|
||||
keys = session.scalars(
|
||||
select(ApiToken).where(
|
||||
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()
|
||||
# App and agent keys keep their existing (unmasked) list behavior; reveal-once
|
||||
# masking is scoped to dataset keys, which build their list in datasets.py.
|
||||
return ApiKeyList(data=[ApiKeyItem.model_validate(key, from_attributes=True) for key in keys])
|
||||
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
def post(self, session: Session, resource_id: str, current_tenant_id: str) -> tuple[dict[str, object], int]:
|
||||
return dump_response(
|
||||
ApiKeyItem,
|
||||
self._create_api_key(resource_id, current_tenant_id, session=session),
|
||||
), 201
|
||||
|
||||
def _create_api_key(self, resource_id: str, current_tenant_id: str, *, session: Session) -> ApiToken:
|
||||
assert self.resource_id_field is not None, "resource_id_field must be set"
|
||||
resource = _get_resource(resource_id, current_tenant_id, self.resource_model, session=session)
|
||||
if isinstance(resource, App):
|
||||
AppService.ensure_agent_app_access_ready(resource, session=session)
|
||||
current_key_count: int = (
|
||||
session.scalar(
|
||||
select(func.count(ApiToken.id)).where(
|
||||
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
|
||||
)
|
||||
|
||||
if current_key_count >= self.max_keys:
|
||||
flask_restx.abort(
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
message=f"Cannot create more than {self.max_keys} API keys for this resource type.",
|
||||
custom="max_keys_exceeded",
|
||||
)
|
||||
|
||||
key = ApiToken.generate_api_key(self.token_prefix or "", 24, session=session)
|
||||
assert self.resource_type is not None, "resource_type must be set"
|
||||
api_token = ApiToken()
|
||||
setattr(api_token, self.resource_id_field, resource_id)
|
||||
api_token.tenant_id = current_tenant_id
|
||||
api_token.token = key
|
||||
api_token.type = self.resource_type
|
||||
session.add(api_token)
|
||||
session.commit()
|
||||
return api_token
|
||||
|
||||
|
||||
class BaseApiKeyResource(Resource):
|
||||
method_decorators = [account_initialization_required, login_required, setup_required]
|
||||
|
||||
resource_type: ApiTokenType | None = None
|
||||
resource_model: type | None = None
|
||||
resource_id_field: str | None = None
|
||||
|
||||
@with_session
|
||||
def delete(
|
||||
self,
|
||||
session: Session,
|
||||
resource_id: str,
|
||||
api_key_id: str,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
) -> tuple[str, int]:
|
||||
self._delete_api_key(resource_id, api_key_id, current_tenant_id, current_user, session=session)
|
||||
return "", 204
|
||||
|
||||
def _delete_api_key(
|
||||
self,
|
||||
resource_id: str,
|
||||
api_key_id: str,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
*,
|
||||
session: Session,
|
||||
) -> None:
|
||||
assert self.resource_id_field is not None, "resource_id_field must be set"
|
||||
_get_resource(resource_id, current_tenant_id, self.resource_model, session=session)
|
||||
|
||||
if not dify_config.RBAC_ENABLED and not current_user.is_admin_or_owner:
|
||||
raise Forbidden()
|
||||
|
||||
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,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if key is None:
|
||||
flask_restx.abort(HTTPStatus.NOT_FOUND, message="API key not found")
|
||||
|
||||
# Invalidate cache before deleting from database
|
||||
# Type assertion: key is guaranteed to be non-None here because abort() raises
|
||||
assert key is not None # nosec - for type checker only
|
||||
ApiTokenCache.delete(key.token, key.type)
|
||||
|
||||
session.delete(key)
|
||||
session.commit()
|
||||
@contextmanager
|
||||
def api_key_errors() -> Generator[None]:
|
||||
"""Translate application failures without changing the Console error contract."""
|
||||
try:
|
||||
yield
|
||||
except (ApiKeyResourceNotFoundError, ApiKeyNotFoundError) as error:
|
||||
flask_restx.abort(404, message=str(error))
|
||||
except ApiKeyLimitExceededError as error:
|
||||
flask_restx.abort(400, message=str(error), custom="max_keys_exceeded")
|
||||
except UnknownDatasetIdsError as error:
|
||||
flask_restx.abort(400, message=str(error))
|
||||
except NoPermissionError as error:
|
||||
flask_restx.abort(403, message=str(error))
|
||||
except AppApiKeyNotReadyError as error:
|
||||
raise AgentAccessNotReadyError from error
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:resource_id>/api-keys")
|
||||
class AppApiKeyListResource(BaseApiKeyListResource):
|
||||
class AppApiKeyListResource(Resource):
|
||||
@console_ns.doc("get_app_api_keys")
|
||||
@console_ns.doc(description="Get all API keys for an app")
|
||||
@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(
|
||||
RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp("resource_id")),
|
||||
RBACCheck(RBACPermission.AGENT_ACCESS_POINT_VIEW, AgentBehindApp("resource_id")),
|
||||
@console_account_admission(
|
||||
allowed_roles=API_KEY_EDIT_ROLES,
|
||||
rbac_checks=[
|
||||
RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp("resource_id")),
|
||||
RBACCheck(RBACPermission.AGENT_ACCESS_POINT_VIEW, AgentBehindApp("resource_id")),
|
||||
],
|
||||
)
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, resource_id: UUID) -> dict[str, object]:
|
||||
"""Get all API keys for an app"""
|
||||
return dump_response(
|
||||
ApiKeyList,
|
||||
self._get_api_key_list(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
)
|
||||
def get(self, request_context: RequestContext, resource_id: UUID) -> dict[str, object]:
|
||||
with api_key_errors():
|
||||
keys = application_services().app_api_keys.list_keys(request_context, str(resource_id))
|
||||
return dump_response(ApiKeyList, {"data": keys})
|
||||
|
||||
@console_ns.doc("create_app_api_key")
|
||||
@console_ns.doc(description="Create a new API key for an app")
|
||||
@console_ns.doc(params={"resource_id": "App ID"})
|
||||
@console_ns.response(201, "API key created successfully", console_ns.models[ApiKeyItem.__name__])
|
||||
@console_ns.response(400, "Maximum keys exceeded")
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(
|
||||
RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp("resource_id")),
|
||||
RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentBehindApp("resource_id")),
|
||||
@console_account_admission(
|
||||
allowed_roles=API_KEY_EDIT_ROLES,
|
||||
rbac_checks=[
|
||||
RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp("resource_id")),
|
||||
RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentBehindApp("resource_id")),
|
||||
],
|
||||
)
|
||||
@with_session
|
||||
def post(self, session: Session, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]:
|
||||
"""Create a new API key for an app"""
|
||||
return dump_response(
|
||||
ApiKeyItem,
|
||||
self._create_api_key(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
), 201
|
||||
|
||||
resource_type = ApiTokenType.APP
|
||||
resource_model = App
|
||||
resource_id_field = "app_id"
|
||||
token_prefix = "app-"
|
||||
def post(self, request_context: RequestContext, resource_id: UUID) -> tuple[dict[str, object], int]:
|
||||
with api_key_errors():
|
||||
key = application_services().app_api_keys.create_key(request_context, str(resource_id))
|
||||
return dump_response(ApiKeyItem, key), 201
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:resource_id>/api-keys/<uuid:api_key_id>")
|
||||
class AppApiKeyResource(BaseApiKeyResource):
|
||||
class AppApiKeyResource(Resource):
|
||||
@console_ns.doc("delete_app_api_key")
|
||||
@console_ns.doc(description="Delete an API key for an app")
|
||||
@console_ns.doc(params={"resource_id": "App ID", "api_key_id": "API key ID"})
|
||||
@console_ns.response(204, "API key deleted successfully")
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(
|
||||
RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp("resource_id")),
|
||||
RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentBehindApp("resource_id")),
|
||||
@console_account_admission(
|
||||
allowed_roles=API_KEY_DELETE_ROLES,
|
||||
rbac_checks=[
|
||||
RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp("resource_id")),
|
||||
RBACCheck(RBACPermission.AGENT_ACCESS_POINT_MANAGE, AgentBehindApp("resource_id")),
|
||||
],
|
||||
)
|
||||
@with_session
|
||||
def delete(
|
||||
self,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
resource_id: UUID,
|
||||
api_key_id: UUID,
|
||||
) -> tuple[str, int]:
|
||||
"""Delete an API key for an app"""
|
||||
self._delete_api_key(
|
||||
str(resource_id), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
str(api_key_id),
|
||||
current_tenant_id,
|
||||
current_user,
|
||||
session=session,
|
||||
)
|
||||
def delete(self, request_context: RequestContext, resource_id: UUID, api_key_id: UUID) -> tuple[str, int]:
|
||||
with api_key_errors():
|
||||
application_services().app_api_keys.delete_key(request_context, str(resource_id), str(api_key_id))
|
||||
return "", 204
|
||||
|
||||
resource_type = ApiTokenType.APP
|
||||
resource_model = App
|
||||
resource_id_field = "app_id"
|
||||
|
||||
|
||||
# Dataset service-API keys are also managed at the workspace level (create with a set of
|
||||
# knowledge bases, list, delete) by DatasetApiKeyApi in
|
||||
# controllers/console/datasets/datasets.py, using DatasetApiTokenBinding for scoping.
|
||||
# The per-dataset routes below remain for callers that key an API token to a single dataset.
|
||||
@console_ns.route("/datasets/<uuid:resource_id>/api-keys")
|
||||
class DatasetApiKeyListResource(BaseApiKeyListResource):
|
||||
class DatasetApiKeyListResource(Resource):
|
||||
@console_ns.doc("get_dataset_api_keys")
|
||||
@console_ns.doc(description="Get all API keys for a dataset")
|
||||
@console_ns.doc(params={"resource_id": "Dataset ID"})
|
||||
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, DatasetId("resource_id")))
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str, resource_id: UUID) -> dict[str, object]:
|
||||
"""Get all API keys for a dataset"""
|
||||
return dump_response(
|
||||
ApiKeyList,
|
||||
self._get_api_key_list(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
)
|
||||
@console_account_admission(
|
||||
allowed_roles=API_KEY_EDIT_ROLES,
|
||||
rbac_checks=[RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, DatasetId("resource_id"))],
|
||||
)
|
||||
def get(self, request_context: RequestContext, resource_id: UUID) -> dict[str, object]:
|
||||
with api_key_errors():
|
||||
keys = application_services().dataset_api_keys.list_keys(request_context, str(resource_id))
|
||||
return dump_response(ApiKeyList, build_masked_api_key_list(keys))
|
||||
|
||||
@console_ns.doc("create_dataset_api_key")
|
||||
@console_ns.doc(description="Create a new API key for a dataset")
|
||||
@console_ns.doc(params={"resource_id": "Dataset ID"})
|
||||
@console_ns.response(201, "API key created successfully", console_ns.models[ApiKeyItem.__name__])
|
||||
@console_ns.response(400, "Maximum keys exceeded")
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, DatasetId("resource_id")))
|
||||
@with_session
|
||||
def post(self, session: Session, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]:
|
||||
"""Create a new API key for a dataset"""
|
||||
return dump_response(
|
||||
ApiKeyItem,
|
||||
self._create_api_key(str(resource_id), current_tenant_id, session=session), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
), 201
|
||||
|
||||
resource_type = ApiTokenType.DATASET
|
||||
resource_model = Dataset
|
||||
resource_id_field = "dataset_id"
|
||||
token_prefix = "ds-"
|
||||
@console_account_admission(
|
||||
allowed_roles=API_KEY_EDIT_ROLES,
|
||||
rbac_checks=[RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, DatasetId("resource_id"))],
|
||||
)
|
||||
def post(self, request_context: RequestContext, resource_id: UUID) -> tuple[dict[str, object], int]:
|
||||
with api_key_errors():
|
||||
key = application_services().dataset_api_keys.create_key(request_context, str(resource_id))
|
||||
return dump_response(ApiKeyItem, key), 201
|
||||
|
||||
|
||||
@console_ns.route("/datasets/<uuid:resource_id>/api-keys/<uuid:api_key_id>")
|
||||
class DatasetApiKeyResource(BaseApiKeyResource):
|
||||
class DatasetApiKeyResource(Resource):
|
||||
@console_ns.doc("delete_dataset_api_key")
|
||||
@console_ns.doc(description="Delete an API key for a dataset")
|
||||
@console_ns.doc(params={"resource_id": "Dataset ID", "api_key_id": "API key ID"})
|
||||
@console_ns.response(204, "API key deleted successfully")
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, DatasetId("resource_id")))
|
||||
@with_session
|
||||
def delete(
|
||||
self,
|
||||
session: Session,
|
||||
current_tenant_id: str,
|
||||
current_user: Account,
|
||||
resource_id: UUID,
|
||||
api_key_id: UUID,
|
||||
) -> tuple[str, int]:
|
||||
"""Delete an API key for a dataset"""
|
||||
self._delete_api_key(
|
||||
str(resource_id), # pyrefly: ignore[unnecessary-type-conversion]
|
||||
str(api_key_id),
|
||||
current_tenant_id,
|
||||
current_user,
|
||||
session=session,
|
||||
)
|
||||
@console_account_admission(
|
||||
allowed_roles=API_KEY_DELETE_ROLES,
|
||||
rbac_checks=[RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, DatasetId("resource_id"))],
|
||||
)
|
||||
def delete(self, request_context: RequestContext, resource_id: UUID, api_key_id: UUID) -> tuple[str, int]:
|
||||
with api_key_errors():
|
||||
application_services().dataset_api_keys.delete_key(request_context, str(resource_id), str(api_key_id))
|
||||
return "", 204
|
||||
|
||||
resource_type = ApiTokenType.DATASET
|
||||
resource_model = Dataset
|
||||
resource_id_field = "dataset_id"
|
||||
@@ -6,7 +6,7 @@ from uuid import UUID
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
@@ -17,15 +17,15 @@ from controllers.common.rbac import DatasetId, RBACCheck, Workspace, enforce_rba
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.apikey import ApiKeyItem, ApiKeyList, build_masked_api_key_list
|
||||
from controllers.console.apikey import API_KEY_DELETE_ROLES, api_key_errors
|
||||
from controllers.console.app.error import ProviderNotInitializeError
|
||||
from controllers.console.datasets.error import DatasetInUseError, DatasetNameDuplicateError, IndexingEstimateError
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import (
|
||||
RBACPermission,
|
||||
account_initialization_required,
|
||||
cloud_edition_billing_rate_limit_check,
|
||||
enterprise_license_required,
|
||||
is_admin_or_owner_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
@@ -41,6 +41,8 @@ from core.rag.extractor.entity.datasource_type import DatasourceType
|
||||
from core.rag.extractor.entity.extract_setting import ExtractSetting, NotionInfo, WebsiteInfo
|
||||
from core.rag.index_processor.constant.index_type import IndexTechniqueType
|
||||
from core.rag.retrieval.retrieval_methods import RetrievalMethod
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.api_key_fields import ApiKeyItem, ApiKeyList, build_masked_api_key_list
|
||||
from fields.base import ResponseModel
|
||||
from fields.dataset_fields import (
|
||||
DatasetDetailResponse,
|
||||
@@ -52,12 +54,10 @@ from libs.helper import build_icon_url, dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
from libs.pagination import clamp_pagination
|
||||
from libs.url_utils import normalize_api_base_url
|
||||
from models import Account, ApiToken, App, Dataset, Document, UploadFile
|
||||
from machinery.context import RequestContext
|
||||
from models import Account, App, Dataset, Document, UploadFile
|
||||
from models.dataset import DatasetPermission, DatasetPermissionEnum, DatasetQuery
|
||||
from models.enums import ApiTokenType
|
||||
from models.provider_ids import ModelProviderID
|
||||
from services import dataset_api_key_service
|
||||
from services.api_token_service import ApiTokenCache
|
||||
from services.app_service import AppService
|
||||
from services.dataset_ref_service import DatasetRefService
|
||||
from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService
|
||||
@@ -1113,121 +1113,49 @@ class DatasetIndexingStatusApi(Resource):
|
||||
|
||||
@console_ns.route("/datasets/api-keys")
|
||||
class DatasetApiKeyApi(Resource):
|
||||
max_keys = 10
|
||||
token_prefix = "dataset-"
|
||||
resource_type = ApiTokenType.DATASET
|
||||
|
||||
@console_ns.doc("get_dataset_api_keys")
|
||||
@console_ns.doc(description="Get dataset API keys")
|
||||
@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(RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, Workspace()))
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session(write=False)
|
||||
def get(self, session: Session, current_tenant_id: str):
|
||||
keys = session.scalars(
|
||||
select(ApiToken).where(ApiToken.type == self.resource_type, ApiToken.tenant_id == current_tenant_id)
|
||||
).all()
|
||||
token_ids = [str(key.id) for key in keys]
|
||||
bindings_by_token = dataset_api_key_service.list_bindings_by_token(session, token_ids)
|
||||
return dump_response(ApiKeyList, build_masked_api_key_list(keys, bindings_by_token))
|
||||
@console_account_admission(
|
||||
allowed_roles=API_KEY_DELETE_ROLES,
|
||||
rbac_checks=[RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, Workspace())],
|
||||
)
|
||||
def get(self, request_context: RequestContext):
|
||||
with api_key_errors():
|
||||
keys = application_services().dataset_api_keys.list_workspace_keys(request_context)
|
||||
return dump_response(ApiKeyList, build_masked_api_key_list(keys))
|
||||
|
||||
@console_ns.expect(console_ns.models[DatasetApiKeyCreatePayload.__name__])
|
||||
@console_ns.response(200, "API key created successfully", console_ns.models[ApiKeyItem.__name__])
|
||||
@console_ns.response(400, "Maximum keys exceeded")
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, Workspace()))
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def post(self, session: Session, current_tenant_id: str):
|
||||
# Optional list of knowledge bases to scope the key to. Absent/empty => the key
|
||||
# can access every dataset in the tenant (default). Duplicates are de-duplicated.
|
||||
@console_account_admission(
|
||||
allowed_roles=API_KEY_DELETE_ROLES,
|
||||
rbac_checks=[RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, Workspace())],
|
||||
)
|
||||
def post(self, request_context: RequestContext):
|
||||
# Preserve the optional scope and validation response of the workspace route.
|
||||
payload = request.get_json(silent=True) or {}
|
||||
raw_dataset_ids = payload.get("dataset_ids") or []
|
||||
if not isinstance(raw_dataset_ids, list) or any(not isinstance(item, str) for item in raw_dataset_ids):
|
||||
console_ns.abort(400, message="dataset_ids must be a list of strings.")
|
||||
dataset_ids = list(dict.fromkeys(raw_dataset_ids))
|
||||
|
||||
if dataset_ids:
|
||||
unknown = dataset_api_key_service.find_unknown_dataset_ids(session, dataset_ids, current_tenant_id)
|
||||
if unknown:
|
||||
console_ns.abort(400, message=f"Unknown knowledge base id(s): {', '.join(unknown)}")
|
||||
|
||||
current_key_count = (
|
||||
session.scalar(
|
||||
select(func.count(ApiToken.id)).where(
|
||||
ApiToken.type == self.resource_type, ApiToken.tenant_id == current_tenant_id
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
if current_key_count >= self.max_keys:
|
||||
console_ns.abort(
|
||||
400,
|
||||
message=f"Cannot create more than {self.max_keys} API keys for this resource type.",
|
||||
custom="max_keys_exceeded",
|
||||
)
|
||||
|
||||
key = ApiToken.generate_api_key(self.token_prefix, 24, session=session)
|
||||
api_token = ApiToken()
|
||||
api_token.tenant_id = current_tenant_id
|
||||
api_token.token = key
|
||||
api_token.type = self.resource_type
|
||||
session.add(api_token)
|
||||
session.flush()
|
||||
dataset_api_key_service.bind_datasets(session, api_token.id, dataset_ids)
|
||||
session.flush()
|
||||
|
||||
# Reveal-once: the create response carries the full secret and its bound scope.
|
||||
item = ApiKeyItem.model_validate(api_token, from_attributes=True)
|
||||
item.dataset_ids = dataset_ids
|
||||
return dump_response(ApiKeyItem, item), 200
|
||||
with api_key_errors():
|
||||
key = application_services().dataset_api_keys.create_workspace_key(request_context, tuple(raw_dataset_ids))
|
||||
return dump_response(ApiKeyItem, key), 200
|
||||
|
||||
|
||||
@console_ns.route("/datasets/api-keys/<uuid:api_key_id>")
|
||||
class DatasetApiDeleteApi(Resource):
|
||||
resource_type = ApiTokenType.DATASET
|
||||
|
||||
@console_ns.doc("delete_dataset_api_key")
|
||||
@console_ns.doc(description="Delete dataset API key")
|
||||
@console_ns.doc(params={"api_key_id": "API key ID"})
|
||||
@console_ns.response(204, "API key deleted successfully")
|
||||
@setup_required
|
||||
@login_required
|
||||
@is_admin_or_owner_required
|
||||
@rbac_permission_required(RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, Workspace()))
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def delete(self, session: Session, current_tenant_id: str, api_key_id: UUID):
|
||||
api_key_id_str = str(api_key_id)
|
||||
key = session.scalar(
|
||||
select(ApiToken)
|
||||
.where(
|
||||
ApiToken.tenant_id == current_tenant_id,
|
||||
ApiToken.type == self.resource_type,
|
||||
ApiToken.id == api_key_id_str,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if key is None:
|
||||
console_ns.abort(404, message="API key not found")
|
||||
|
||||
# Invalidate cache before deleting from database
|
||||
# Type assertion: key is guaranteed to be non-None here because abort() raises
|
||||
assert key is not None # nosec - for type checker only
|
||||
ApiTokenCache.delete(key.token, key.type)
|
||||
|
||||
session.delete(key)
|
||||
|
||||
@console_account_admission(
|
||||
allowed_roles=API_KEY_DELETE_ROLES,
|
||||
rbac_checks=[RBACCheck(RBACPermission.DATASET_API_KEY_MANAGE, Workspace())],
|
||||
)
|
||||
def delete(self, request_context: RequestContext, api_key_id: UUID):
|
||||
with api_key_errors():
|
||||
application_services().dataset_api_keys.delete_workspace_key(request_context, str(api_key_id))
|
||||
return "", 204
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ from libs.login import current_user
|
||||
from models import Account, Tenant, TenantAccountJoin, TenantStatus
|
||||
from models.dataset import Dataset, RateLimitLog
|
||||
from models.model import ApiToken, App
|
||||
from services import dataset_api_key_service
|
||||
from repositories.knowledge import dataset_api_key_bindings
|
||||
from services.api_token_service import ApiTokenCache, fetch_token_with_single_flight, record_token_usage
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
@@ -343,7 +343,7 @@ def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
|
||||
# per request (not cached) so scope changes take effect immediately.
|
||||
# db.session is Flask-SQLAlchemy's scoped_session proxy; cast so the plain-Session
|
||||
# typed helper accepts it (runtime proxies every Session method through unchanged).
|
||||
bound_dataset_ids = dataset_api_key_service.get_bound_dataset_ids(cast(Session, db.session), api_token.id)
|
||||
bound_dataset_ids = dataset_api_key_bindings.get_bound_dataset_ids(cast(Session, db.session), api_token.id)
|
||||
if bound_dataset_ids and (not dataset_id or str(dataset_id) not in bound_dataset_ids):
|
||||
raise Forbidden("The API key is not authorized to access this knowledge base.")
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Compose App API access dependencies."""
|
||||
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from repositories.app.api_key_repository import AppApiKeyRepository
|
||||
from services.api_token_service import ApiTokenCache
|
||||
from services.app.api_key_service import AppApiKeyService
|
||||
|
||||
|
||||
def build_app_api_key_service(*, database_client: sessionmaker[Session]) -> AppApiKeyService:
|
||||
return AppApiKeyService(
|
||||
keys=AppApiKeyRepository(session_factory=database_client),
|
||||
cache=ApiTokenCache,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Composition of knowledge base application services."""
|
||||
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from configs import dify_config
|
||||
from repositories.knowledge.dataset_api_key_repository import DatasetApiKeyRepository
|
||||
from services.api_token_service import ApiTokenCache
|
||||
from services.knowledge.api_key_service import DatasetApiKeyService
|
||||
|
||||
|
||||
def build_dataset_api_key_service(*, database_client: sessionmaker[Session]) -> DatasetApiKeyService:
|
||||
return DatasetApiKeyService(
|
||||
keys=DatasetApiKeyRepository(session_factory=database_client),
|
||||
cache=ApiTokenCache,
|
||||
rbac_enabled=lambda: dify_config.RBAC_ENABLED,
|
||||
)
|
||||
@@ -23,6 +23,8 @@ from core.schemas.schema_manager import SchemaManager
|
||||
from core.tools.tool_file_manager import ToolFileManager
|
||||
from enums import DeploymentEdition, WebAppAccessMode
|
||||
from extensions.application_services.agent import AgentAppServices, build_agent_app_services
|
||||
from extensions.application_services.app import build_app_api_key_service
|
||||
from extensions.application_services.knowledge import build_dataset_api_key_service
|
||||
from extensions.ext_redis import RedisClientWrapper, redis_client
|
||||
from extensions.ext_storage import storage
|
||||
from libs.datetime_utils import naive_utc_now, utc_now
|
||||
@@ -143,6 +145,7 @@ from services.account_password_hasher import DefaultAccountPasswordHasher
|
||||
from services.account_password_service import AccountPasswordService
|
||||
from services.account_profile_service import AccountProfileService
|
||||
from services.app.advanced_prompt_template_service import AdvancedPromptTemplateService
|
||||
from services.app.api_key_service import AppApiKeyService
|
||||
from services.app_audio_adapters import AppAudioRuntime
|
||||
from services.app_audio_service import AppAudio
|
||||
from services.app_definition_query_service import AppDefinitionQueryService
|
||||
@@ -180,6 +183,7 @@ from services.inner_mail_service import InnerMailService
|
||||
from services.installed_app_access_service import InstalledAppAccessService
|
||||
from services.installed_app_generation_adapters import AppGenerateServiceRuntime as InstalledAppGenerateServiceRuntime
|
||||
from services.installed_app_generation_service import InstalledAppGenerationService
|
||||
from services.knowledge.api_key_service import DatasetApiKeyService
|
||||
from services.message_file_preview_service import MessageFilePreviewService
|
||||
from services.message_suggested_questions_adapters import MessageSuggestedQuestionsRuntime
|
||||
from services.message_suggested_questions_service import MessageSuggestedQuestions
|
||||
@@ -298,6 +302,8 @@ class ApplicationServices:
|
||||
agent_apps: AgentAppServices
|
||||
advanced_prompt_templates: AdvancedPromptTemplateService
|
||||
accounts: AccountServices
|
||||
app_api_keys: AppApiKeyService
|
||||
dataset_api_keys: DatasetApiKeyService
|
||||
account_activation: AccountActivationService
|
||||
app_definitions: AppDefinitionQueryService
|
||||
app_preview_details: AppPreviewDetails
|
||||
@@ -717,6 +723,8 @@ def build_application_services(
|
||||
app_sites=AppSiteService(
|
||||
sites=AppSiteCommandRepository(session_factory=database_client),
|
||||
),
|
||||
app_api_keys=build_app_api_key_service(database_client=database_client),
|
||||
dataset_api_keys=build_dataset_api_key_service(database_client=database_client),
|
||||
app_statistics=AppStatisticQueryRepository(session_factory=database_client),
|
||||
app_tracing_configs=AppTracingConfigService(
|
||||
configs=SQLAlchemyAppTracingConfigRepository(session_factory=database_client),
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Response contracts shared by Console API key endpoints."""
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import field_validator
|
||||
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import to_timestamp
|
||||
|
||||
|
||||
class ApiKeyItem(ResponseModel):
|
||||
id: str
|
||||
type: str
|
||||
token: str
|
||||
# Dataset keys only: the knowledge bases this key is bound to. Empty = the key can
|
||||
# access every dataset in the tenant (default). App keys are always empty.
|
||||
dataset_ids: list[str] = []
|
||||
last_used_at: int | None = None
|
||||
created_at: int | None = None
|
||||
|
||||
@field_validator("last_used_at", "created_at", mode="before")
|
||||
@classmethod
|
||||
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||
return to_timestamp(value)
|
||||
|
||||
|
||||
class ApiKeyList(ResponseModel):
|
||||
data: list[ApiKeyItem]
|
||||
|
||||
|
||||
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[object],
|
||||
bindings_by_token: Mapping[str, list[str]] | None = None,
|
||||
) -> ApiKeyList:
|
||||
"""Build an ApiKeyList from token records with their secrets masked.
|
||||
|
||||
``bindings_by_token`` maps an api_token id to the dataset ids it is bound to
|
||||
(from DatasetApiTokenBinding); tokens absent from the map are unbound (empty =
|
||||
access all). When omitted, preserve the scope carried by the records.
|
||||
"""
|
||||
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)
|
||||
if bindings_by_token is not None:
|
||||
item.dataset_ids = bindings_by_token.get(item.id, [])
|
||||
items.append(item)
|
||||
return ApiKeyList(data=items)
|
||||
@@ -2236,9 +2236,6 @@ class ApiToken(Base):
|
||||
key with binding rows is limited to exactly those datasets). Enforcement lives in
|
||||
``validate_dataset_token`` (controllers/service_api/wraps.py).
|
||||
|
||||
Note: controllers/console/apikey.py assigns the ``*_id`` columns via ``setattr``
|
||||
keyed on ``resource_id_field``, so renaming ``app_id`` requires updating those
|
||||
controllers too.
|
||||
"""
|
||||
|
||||
__tablename__ = "api_tokens"
|
||||
|
||||
@@ -4807,7 +4807,7 @@ Restore a published workflow version into the draft workflow
|
||||
| 404 | Workflow not found | |
|
||||
|
||||
### [GET] /apps/{resource_id}/api-keys
|
||||
**Get all API keys for an app**
|
||||
Get all API keys for an app
|
||||
|
||||
#### Parameters
|
||||
|
||||
@@ -4822,7 +4822,7 @@ Restore a published workflow version into the draft workflow
|
||||
| 200 | API keys retrieved successfully | **application/json**: [ApiKeyList](#apikeylist)<br> |
|
||||
|
||||
### [POST] /apps/{resource_id}/api-keys
|
||||
**Create a new API key for an app**
|
||||
Create a new API key for an app
|
||||
|
||||
#### Parameters
|
||||
|
||||
@@ -4838,7 +4838,7 @@ Restore a published workflow version into the draft workflow
|
||||
| 400 | Maximum keys exceeded | |
|
||||
|
||||
### [DELETE] /apps/{resource_id}/api-keys/{api_key_id}
|
||||
**Delete an API key for an app**
|
||||
Delete an API key for an app
|
||||
|
||||
#### Parameters
|
||||
|
||||
@@ -6447,7 +6447,7 @@ Check if dataset is in use
|
||||
| 200 | Dataset use status retrieved successfully | **application/json**: [UsageCheckResponse](#usagecheckresponse)<br> |
|
||||
|
||||
### [GET] /datasets/{resource_id}/api-keys
|
||||
**Get all API keys for a dataset**
|
||||
Get all API keys for a dataset
|
||||
|
||||
#### Parameters
|
||||
|
||||
@@ -6462,7 +6462,7 @@ Check if dataset is in use
|
||||
| 200 | API keys retrieved successfully | **application/json**: [ApiKeyList](#apikeylist)<br> |
|
||||
|
||||
### [POST] /datasets/{resource_id}/api-keys
|
||||
**Create a new API key for a dataset**
|
||||
Create a new API key for a dataset
|
||||
|
||||
#### Parameters
|
||||
|
||||
@@ -6478,7 +6478,7 @@ Check if dataset is in use
|
||||
| 400 | Maximum keys exceeded | |
|
||||
|
||||
### [DELETE] /datasets/{resource_id}/api-keys/{api_key_id}
|
||||
**Delete an API key for a dataset**
|
||||
Delete an API key for a dataset
|
||||
|
||||
#### Parameters
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""App API keys with tenant scoping and repository-owned transactions."""
|
||||
|
||||
from typing import override
|
||||
|
||||
from sqlalchemy import Select, func, or_, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.agent.publish_visibility import workflow_callable_active_snapshot_filter
|
||||
from models.agent import APP_BACKED_AGENT_SOURCES, Agent, AgentScope, AgentStatus
|
||||
from models.enums import ApiTokenType, AppStatus
|
||||
from models.model import ApiToken, App, AppMode
|
||||
from services.app.api_key_service import AppApiKeyAccessState, AppApiKeyStore
|
||||
from services.auth.api_key_contracts import (
|
||||
ApiKeyLimitExceededError,
|
||||
ApiKeyNotFoundError,
|
||||
ApiKeyRecord,
|
||||
ApiKeyResourceNotFoundError,
|
||||
)
|
||||
|
||||
|
||||
class AppApiKeyRepository(AppApiKeyStore):
|
||||
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
@override
|
||||
def resolve_agent_app_id(self, workspace_id: str, agent_id: str) -> str:
|
||||
with self._session_factory() as session:
|
||||
app_id = session.scalar(
|
||||
select(App.id)
|
||||
.join(Agent, Agent.app_id == App.id)
|
||||
.where(
|
||||
App.tenant_id == workspace_id,
|
||||
App.mode == AppMode.AGENT,
|
||||
App.status == AppStatus.NORMAL,
|
||||
Agent.tenant_id == workspace_id,
|
||||
Agent.id == agent_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
if app_id is None:
|
||||
raise ApiKeyResourceNotFoundError("Agent not found.")
|
||||
return app_id
|
||||
|
||||
@override
|
||||
def get_access_state(self, workspace_id: str, app_id: str) -> AppApiKeyAccessState:
|
||||
with self._session_factory() as session:
|
||||
app = self._get_app(session, workspace_id, app_id)
|
||||
if app.mode != AppMode.AGENT:
|
||||
return AppApiKeyAccessState(is_agent_app=False, has_published_snapshot=False)
|
||||
published = session.scalar(
|
||||
select(Agent.id)
|
||||
.where(
|
||||
Agent.tenant_id == workspace_id,
|
||||
Agent.app_id == app_id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
workflow_callable_active_snapshot_filter(),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
return AppApiKeyAccessState(is_agent_app=True, has_published_snapshot=published is not None)
|
||||
|
||||
@override
|
||||
def list_keys(self, workspace_id: str, app_id: str) -> tuple[ApiKeyRecord, ...]:
|
||||
with self._session_factory() as session:
|
||||
self._get_app(session, workspace_id, app_id)
|
||||
return tuple(self._record(key) for key in session.scalars(self._key_query(workspace_id, app_id)))
|
||||
|
||||
@override
|
||||
def create_key(self, workspace_id: str, app_id: str, *, max_keys: int, prefix: str) -> ApiKeyRecord:
|
||||
with self._session_factory.begin() as session:
|
||||
# Serialize count-and-insert for the App, including historical keys.
|
||||
self._get_app(session, workspace_id, app_id, for_update=True)
|
||||
count = session.scalar(select(func.count()).select_from(self._key_query(workspace_id, app_id).subquery()))
|
||||
if count is not None and count >= max_keys:
|
||||
raise ApiKeyLimitExceededError(max_keys)
|
||||
key = ApiToken(
|
||||
tenant_id=workspace_id,
|
||||
app_id=app_id,
|
||||
type=ApiTokenType.APP,
|
||||
token=ApiToken.generate_api_key(prefix, 24, session=session),
|
||||
)
|
||||
session.add(key)
|
||||
session.flush()
|
||||
return self._record(key)
|
||||
|
||||
@override
|
||||
def delete_key(self, workspace_id: str, app_id: str, key_id: str) -> ApiKeyRecord:
|
||||
with self._session_factory.begin() as session:
|
||||
self._get_app(session, workspace_id, app_id)
|
||||
key = session.scalar(self._key_query(workspace_id, app_id).where(ApiToken.id == key_id))
|
||||
if key is None:
|
||||
raise ApiKeyNotFoundError
|
||||
record = self._record(key)
|
||||
session.delete(key)
|
||||
return record
|
||||
|
||||
@staticmethod
|
||||
def _get_app(session: Session, workspace_id: str, app_id: str, *, for_update: bool = False) -> App:
|
||||
query = select(App).where(App.id == app_id, App.tenant_id == workspace_id)
|
||||
if for_update:
|
||||
query = query.with_for_update()
|
||||
app = session.scalar(query)
|
||||
if app is None:
|
||||
raise ApiKeyResourceNotFoundError("App not found.")
|
||||
return app
|
||||
|
||||
@staticmethod
|
||||
def _key_query(workspace_id: str, app_id: str) -> Select[tuple[ApiToken]]:
|
||||
# Historical keys have no tenant_id; the owning App is validated first.
|
||||
return select(ApiToken).where(
|
||||
or_(ApiToken.tenant_id == workspace_id, ApiToken.tenant_id.is_(None)),
|
||||
ApiToken.type == ApiTokenType.APP,
|
||||
ApiToken.app_id == app_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _record(key: ApiToken) -> ApiKeyRecord:
|
||||
return ApiKeyRecord(
|
||||
id=key.id, type=key.type, token=key.token, last_used_at=key.last_used_at, created_at=key.created_at
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""Knowledge base persistence and queries."""
|
||||
+13
-15
@@ -6,14 +6,13 @@ A dataset API key is scoped to specific knowledge bases through
|
||||
- no rows -> the key can access every dataset in its tenant (default / back-compat)
|
||||
- N rows -> the key is restricted to exactly those datasets
|
||||
|
||||
These helpers live in the service layer so controllers and the service-API auth
|
||||
decorator can share the queries without embedding SQLAlchemy in controller code.
|
||||
Shared by key management, dataset deletion, and service-API authentication.
|
||||
The caller owns the transaction boundary (commit/flush).
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import Select, delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.dataset import Dataset
|
||||
@@ -77,18 +76,17 @@ def delete_keys_scoped_only_to(session: Session, dataset_id: str) -> list[str]:
|
||||
Must run before the dataset row is deleted (so the bindings still exist).
|
||||
Returns the deleted api_token ids; the caller controls the transaction.
|
||||
"""
|
||||
orphan_ids = [
|
||||
str(token_id)
|
||||
for token_id in session.scalars(
|
||||
select(DatasetApiTokenBinding.api_token_id)
|
||||
.where(DatasetApiTokenBinding.dataset_id == dataset_id)
|
||||
.where(
|
||||
DatasetApiTokenBinding.api_token_id.notin_(
|
||||
select(DatasetApiTokenBinding.api_token_id).where(DatasetApiTokenBinding.dataset_id != dataset_id)
|
||||
)
|
||||
)
|
||||
).all()
|
||||
]
|
||||
orphan_ids = [str(token_id) for token_id in session.scalars(token_ids_scoped_only_to(dataset_id)).all()]
|
||||
if orphan_ids:
|
||||
session.execute(delete(ApiToken).where(ApiToken.id.in_(orphan_ids)))
|
||||
return orphan_ids
|
||||
|
||||
|
||||
def token_ids_scoped_only_to(dataset_id: str) -> Select[tuple[str]]:
|
||||
"""Select keys bound only to one dataset, for management and deletion."""
|
||||
return select(DatasetApiTokenBinding.api_token_id).where(
|
||||
DatasetApiTokenBinding.dataset_id == dataset_id,
|
||||
DatasetApiTokenBinding.api_token_id.notin_(
|
||||
select(DatasetApiTokenBinding.api_token_id).where(DatasetApiTokenBinding.dataset_id != dataset_id)
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Knowledge base API keys with repository-owned sessions and transactions."""
|
||||
|
||||
from typing import override
|
||||
|
||||
from sqlalchemy import Select, func, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.account import Tenant, TenantAccountJoin
|
||||
from models.dataset import Dataset, DatasetPermission
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken
|
||||
from repositories.knowledge import dataset_api_key_bindings
|
||||
from services.auth.api_key_contracts import (
|
||||
ApiKeyLimitExceededError,
|
||||
ApiKeyNotFoundError,
|
||||
ApiKeyRecord,
|
||||
ApiKeyResourceNotFoundError,
|
||||
)
|
||||
from services.knowledge.api_key_service import DatasetApiKeyStore, UnknownDatasetIdsError
|
||||
from services.knowledge.dataset_access import DatasetAccess
|
||||
|
||||
|
||||
class DatasetApiKeyRepository(DatasetApiKeyStore):
|
||||
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
@override
|
||||
def get_dataset_access(self, workspace_id: str, dataset_id: str, account_id: str) -> DatasetAccess:
|
||||
with self._session_factory() as session:
|
||||
dataset = self._get_dataset(session, workspace_id, dataset_id)
|
||||
role = session.scalar(
|
||||
select(TenantAccountJoin.role).where(
|
||||
TenantAccountJoin.tenant_id == workspace_id, TenantAccountJoin.account_id == account_id
|
||||
)
|
||||
)
|
||||
has_permission = (
|
||||
session.scalar(
|
||||
select(DatasetPermission.id)
|
||||
.where(
|
||||
DatasetPermission.tenant_id == workspace_id,
|
||||
DatasetPermission.dataset_id == dataset_id,
|
||||
DatasetPermission.account_id == account_id,
|
||||
DatasetPermission.has_permission.is_(True),
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
return DatasetAccess(dataset.permission, dataset.maintainer, role, has_permission)
|
||||
|
||||
@override
|
||||
def list_keys(self, workspace_id: str, dataset_id: str) -> tuple[ApiKeyRecord, ...]:
|
||||
with self._session_factory() as session:
|
||||
self._get_dataset(session, workspace_id, dataset_id)
|
||||
return tuple(
|
||||
self._record(key, (dataset_id,)) for key in session.scalars(self._key_query(workspace_id, dataset_id))
|
||||
)
|
||||
|
||||
@override
|
||||
def create_key(self, workspace_id: str, dataset_id: str, *, max_keys: int, prefix: str) -> ApiKeyRecord:
|
||||
with self._session_factory.begin() as session:
|
||||
self._lock_workspace(session, workspace_id)
|
||||
self._get_dataset(session, workspace_id, dataset_id)
|
||||
return self._create_key(session, workspace_id, (dataset_id,), max_keys=max_keys, prefix=prefix)
|
||||
|
||||
@override
|
||||
def list_workspace_keys(self, workspace_id: str) -> tuple[ApiKeyRecord, ...]:
|
||||
with self._session_factory() as session:
|
||||
keys = session.scalars(self._workspace_key_query(workspace_id)).all()
|
||||
bindings = dataset_api_key_bindings.list_bindings_by_token(session, (key.id for key in keys))
|
||||
return tuple(self._record(key, tuple(bindings.get(key.id, ()))) for key in keys)
|
||||
|
||||
@override
|
||||
def create_workspace_key(
|
||||
self, workspace_id: str, dataset_ids: tuple[str, ...], *, max_keys: int, prefix: str
|
||||
) -> ApiKeyRecord:
|
||||
with self._session_factory.begin() as session:
|
||||
self._lock_workspace(session, workspace_id)
|
||||
unknown = dataset_api_key_bindings.find_unknown_dataset_ids(session, list(dataset_ids), workspace_id)
|
||||
if unknown:
|
||||
raise UnknownDatasetIdsError(unknown)
|
||||
return self._create_key(session, workspace_id, dataset_ids, max_keys=max_keys, prefix=prefix)
|
||||
|
||||
@override
|
||||
def delete_workspace_key(self, workspace_id: str, key_id: str) -> ApiKeyRecord:
|
||||
with self._session_factory.begin() as session:
|
||||
key = session.scalar(self._workspace_key_query(workspace_id).where(ApiToken.id == key_id))
|
||||
if key is None:
|
||||
raise ApiKeyNotFoundError
|
||||
bindings = dataset_api_key_bindings.get_bound_dataset_ids(session, key.id)
|
||||
record = self._record(key, tuple(sorted(bindings)))
|
||||
session.delete(key)
|
||||
return record
|
||||
|
||||
def _create_key(
|
||||
self, session: Session, workspace_id: str, dataset_ids: tuple[str, ...], *, max_keys: int, prefix: str
|
||||
) -> ApiKeyRecord:
|
||||
count = session.scalar(select(func.count()).select_from(self._workspace_key_query(workspace_id).subquery()))
|
||||
if count is not None and count >= max_keys:
|
||||
raise ApiKeyLimitExceededError(max_keys)
|
||||
key = ApiToken(
|
||||
tenant_id=workspace_id,
|
||||
type=ApiTokenType.DATASET,
|
||||
token=ApiToken.generate_api_key(prefix, 24, session=session),
|
||||
)
|
||||
session.add(key)
|
||||
session.flush()
|
||||
dataset_api_key_bindings.bind_datasets(session, key.id, dataset_ids)
|
||||
return self._record(key, dataset_ids)
|
||||
|
||||
@staticmethod
|
||||
def _lock_workspace(session: Session, workspace_id: str) -> None:
|
||||
# Both creation routes lock the same row before counting all workspace dataset keys.
|
||||
if session.scalar(select(Tenant.id).where(Tenant.id == workspace_id).with_for_update()) is None:
|
||||
raise ApiKeyResourceNotFoundError("Workspace not found.")
|
||||
|
||||
@override
|
||||
def delete_key(self, workspace_id: str, dataset_id: str, key_id: str) -> ApiKeyRecord:
|
||||
with self._session_factory.begin() as session:
|
||||
self._get_dataset(session, workspace_id, dataset_id)
|
||||
key = session.scalar(self._key_query(workspace_id, dataset_id).where(ApiToken.id == key_id))
|
||||
if key is None:
|
||||
raise ApiKeyNotFoundError
|
||||
record = self._record(key, (dataset_id,))
|
||||
session.delete(key)
|
||||
return record
|
||||
|
||||
@staticmethod
|
||||
def _get_dataset(session: Session, workspace_id: str, dataset_id: str) -> Dataset:
|
||||
query = select(Dataset).where(Dataset.id == dataset_id, Dataset.tenant_id == workspace_id)
|
||||
dataset = session.scalar(query)
|
||||
if dataset is None:
|
||||
raise ApiKeyResourceNotFoundError("Dataset not found.")
|
||||
return dataset
|
||||
|
||||
@staticmethod
|
||||
def _workspace_key_query(workspace_id: str) -> Select[tuple[ApiToken]]:
|
||||
return select(ApiToken).where(ApiToken.tenant_id == workspace_id, ApiToken.type == ApiTokenType.DATASET)
|
||||
|
||||
@classmethod
|
||||
def _key_query(cls, workspace_id: str, dataset_id: str) -> Select[tuple[ApiToken]]:
|
||||
# A per-dataset permission cannot expose or revoke broader workspace keys.
|
||||
return cls._workspace_key_query(workspace_id).where(
|
||||
ApiToken.id.in_(dataset_api_key_bindings.token_ids_scoped_only_to(dataset_id)),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _record(key: ApiToken, dataset_ids: tuple[str, ...]) -> ApiKeyRecord:
|
||||
return ApiKeyRecord(
|
||||
id=key.id,
|
||||
type=key.type,
|
||||
token=key.token,
|
||||
last_used_at=key.last_used_at,
|
||||
created_at=key.created_at,
|
||||
dataset_ids=dataset_ids,
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Manage App API access, including Agent routes backed by an App."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from machinery.context import RequestContext
|
||||
from services.auth.api_key_contracts import ApiKeyCache, ApiKeyRecord
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AppApiKeyAccessState:
|
||||
is_agent_app: bool
|
||||
has_published_snapshot: bool
|
||||
|
||||
|
||||
class AppApiKeyStore(Protocol):
|
||||
def resolve_agent_app_id(self, workspace_id: str, agent_id: str) -> str: ...
|
||||
|
||||
def get_access_state(self, workspace_id: str, app_id: str) -> AppApiKeyAccessState: ...
|
||||
|
||||
def list_keys(self, workspace_id: str, app_id: str) -> tuple[ApiKeyRecord, ...]: ...
|
||||
|
||||
def create_key(self, workspace_id: str, app_id: str, *, max_keys: int, prefix: str) -> ApiKeyRecord: ...
|
||||
|
||||
def delete_key(self, workspace_id: str, app_id: str, key_id: str) -> ApiKeyRecord: ...
|
||||
|
||||
|
||||
class AppApiKeyNotReadyError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AppApiKeyService:
|
||||
def __init__(self, *, keys: AppApiKeyStore, cache: ApiKeyCache) -> None:
|
||||
self._keys = keys
|
||||
self._cache = cache
|
||||
|
||||
def list_keys(self, context: RequestContext, app_id: str) -> tuple[ApiKeyRecord, ...]:
|
||||
return self._keys.list_keys(context.active_workspace_id, app_id)
|
||||
|
||||
def create_key(self, context: RequestContext, app_id: str) -> ApiKeyRecord:
|
||||
access = self._keys.get_access_state(context.active_workspace_id, app_id)
|
||||
if access.is_agent_app and not access.has_published_snapshot:
|
||||
raise AppApiKeyNotReadyError
|
||||
return self._keys.create_key(context.active_workspace_id, app_id, max_keys=10, prefix="app-")
|
||||
|
||||
def delete_key(self, context: RequestContext, app_id: str, key_id: str) -> None:
|
||||
key = self._keys.delete_key(context.active_workspace_id, app_id, key_id)
|
||||
# External cache I/O follows the repository's committed transaction.
|
||||
self._cache.delete(key.token, key.type)
|
||||
|
||||
def list_agent_keys(self, context: RequestContext, agent_id: str) -> tuple[ApiKeyRecord, ...]:
|
||||
app_id = self._keys.resolve_agent_app_id(context.active_workspace_id, agent_id)
|
||||
return self.list_keys(context, app_id)
|
||||
|
||||
def create_agent_key(self, context: RequestContext, agent_id: str) -> ApiKeyRecord:
|
||||
app_id = self._keys.resolve_agent_app_id(context.active_workspace_id, agent_id)
|
||||
return self.create_key(context, app_id)
|
||||
|
||||
def delete_agent_key(self, context: RequestContext, agent_id: str, key_id: str) -> None:
|
||||
app_id = self._keys.resolve_agent_app_id(context.active_workspace_id, agent_id)
|
||||
self.delete_key(context, app_id, key_id)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Framework-neutral data contracts for API key management."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApiKeyRecord:
|
||||
id: str
|
||||
type: str
|
||||
token: str
|
||||
last_used_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
dataset_ids: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class ApiKeyCache(Protocol):
|
||||
def delete(self, token: str, scope: str | None = None) -> bool | None: ...
|
||||
|
||||
|
||||
class ApiKeyResourceNotFoundError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ApiKeyNotFoundError(Exception):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("API key not found")
|
||||
|
||||
|
||||
class ApiKeyLimitExceededError(Exception):
|
||||
def __init__(self, max_keys: int) -> None:
|
||||
super().__init__(f"Cannot create more than {max_keys} API keys for this resource type.")
|
||||
@@ -64,7 +64,7 @@ from models.model import UploadFile
|
||||
from models.provider_ids import ModelProviderID
|
||||
from models.source import DataSourceOauthBinding
|
||||
from models.workflow import Workflow
|
||||
from services import dataset_api_key_service
|
||||
from repositories.knowledge import dataset_api_key_bindings
|
||||
from services.dataset_ref_service import DatasetRef, DatasetRefService, SegmentRef
|
||||
from services.document_indexing_proxy.document_indexing_task_proxy import DocumentIndexingTaskProxy
|
||||
from services.document_indexing_proxy.duplicate_document_indexing_task_proxy import DuplicateDocumentIndexingTaskProxy
|
||||
@@ -89,6 +89,7 @@ from services.errors.file import FileNotExistsError
|
||||
from services.external_knowledge_service import ExternalDatasetService
|
||||
from services.feature_service import FeatureService
|
||||
from services.file_service import FileService
|
||||
from services.knowledge.dataset_access import DatasetAccess
|
||||
from services.rag_pipeline.rag_pipeline import RagPipelineService
|
||||
from services.tag_service import TagService
|
||||
from services.vector_service import VectorService
|
||||
@@ -1363,7 +1364,7 @@ class DatasetService:
|
||||
|
||||
# Remove any dataset API key scoped only to this knowledge base, so it cannot
|
||||
# silently degrade to unrestricted (access-all) once its last binding is gone.
|
||||
dataset_api_key_service.delete_keys_scoped_only_to(session, str(dataset.id))
|
||||
dataset_api_key_bindings.delete_keys_scoped_only_to(session, str(dataset.id))
|
||||
|
||||
session.delete(dataset)
|
||||
session.commit()
|
||||
@@ -1380,25 +1381,29 @@ class DatasetService:
|
||||
if dataset.tenant_id != user.current_tenant_id:
|
||||
logger.debug("User %s does not have permission to access dataset %s", user.id, dataset.id)
|
||||
raise NoPermissionError("You do not have permission to access this dataset.")
|
||||
if user.current_role != TenantAccountRole.OWNER:
|
||||
if dataset.permission == DatasetPermissionEnum.ONLY_ME and dataset.maintainer != user.id:
|
||||
logger.debug("User %s does not have permission to access dataset %s", user.id, dataset.id)
|
||||
raise NoPermissionError("You do not have permission to access this dataset.")
|
||||
if dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM:
|
||||
# For partial team permission, user needs explicit permission or be the maintainer.
|
||||
if dataset.maintainer != user.id:
|
||||
user_permission = session.scalar(
|
||||
select(DatasetPermission)
|
||||
.where(
|
||||
DatasetPermission.dataset_id == dataset.id,
|
||||
DatasetPermission.account_id == user.id,
|
||||
DatasetPermission.tenant_id == dataset.tenant_id,
|
||||
)
|
||||
.limit(1)
|
||||
has_permission = False
|
||||
if (
|
||||
user.current_role != TenantAccountRole.OWNER
|
||||
and dataset.permission == DatasetPermissionEnum.PARTIAL_TEAM
|
||||
and dataset.maintainer != user.id
|
||||
):
|
||||
has_permission = (
|
||||
session.scalar(
|
||||
select(DatasetPermission.id)
|
||||
.where(
|
||||
DatasetPermission.dataset_id == dataset.id,
|
||||
DatasetPermission.account_id == user.id,
|
||||
DatasetPermission.tenant_id == dataset.tenant_id,
|
||||
DatasetPermission.has_permission.is_(True),
|
||||
)
|
||||
if not user_permission:
|
||||
logger.debug("User %s does not have permission to access dataset %s", user.id, dataset.id)
|
||||
raise NoPermissionError("You do not have permission to access this dataset.")
|
||||
.limit(1)
|
||||
)
|
||||
is not None
|
||||
)
|
||||
access = DatasetAccess(dataset.permission, dataset.maintainer, user.current_role, has_permission)
|
||||
if not access.allows(user.id):
|
||||
logger.debug("User %s does not have permission to access dataset %s", user.id, dataset.id)
|
||||
raise NoPermissionError("You do not have permission to access this dataset.")
|
||||
|
||||
@staticmethod
|
||||
def check_dataset_operator_permission(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Knowledge base application services."""
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Manage workspace knowledge API keys and keys restricted to one knowledge base."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol
|
||||
|
||||
from machinery.context import RequestContext
|
||||
from services.auth.api_key_contracts import ApiKeyCache, ApiKeyRecord
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.knowledge.dataset_access import DatasetAccess
|
||||
|
||||
|
||||
class DatasetApiKeyStore(Protocol):
|
||||
def get_dataset_access(self, workspace_id: str, dataset_id: str, account_id: str) -> DatasetAccess: ...
|
||||
|
||||
def list_keys(self, workspace_id: str, dataset_id: str) -> tuple[ApiKeyRecord, ...]: ...
|
||||
|
||||
def create_key(self, workspace_id: str, dataset_id: str, *, max_keys: int, prefix: str) -> ApiKeyRecord: ...
|
||||
|
||||
def delete_key(self, workspace_id: str, dataset_id: str, key_id: str) -> ApiKeyRecord: ...
|
||||
|
||||
def list_workspace_keys(self, workspace_id: str) -> tuple[ApiKeyRecord, ...]: ...
|
||||
|
||||
def create_workspace_key(
|
||||
self, workspace_id: str, dataset_ids: tuple[str, ...], *, max_keys: int, prefix: str
|
||||
) -> ApiKeyRecord: ...
|
||||
|
||||
def delete_workspace_key(self, workspace_id: str, key_id: str) -> ApiKeyRecord: ...
|
||||
|
||||
|
||||
class UnknownDatasetIdsError(Exception):
|
||||
def __init__(self, dataset_ids: list[str]) -> None:
|
||||
super().__init__(f"Unknown knowledge base id(s): {', '.join(dataset_ids)}")
|
||||
|
||||
|
||||
class DatasetApiKeyService:
|
||||
MAX_KEYS = 10
|
||||
|
||||
def __init__(self, *, keys: DatasetApiKeyStore, cache: ApiKeyCache, rbac_enabled: Callable[[], bool]) -> None:
|
||||
self._keys = keys
|
||||
self._cache = cache
|
||||
self._rbac_enabled = rbac_enabled
|
||||
|
||||
def list_keys(self, context: RequestContext, dataset_id: str) -> tuple[ApiKeyRecord, ...]:
|
||||
self._check_access(context, dataset_id)
|
||||
return self._keys.list_keys(context.active_workspace_id, dataset_id)
|
||||
|
||||
def create_key(self, context: RequestContext, dataset_id: str) -> ApiKeyRecord:
|
||||
self._check_access(context, dataset_id)
|
||||
return self._keys.create_key(context.active_workspace_id, dataset_id, max_keys=self.MAX_KEYS, prefix="ds-")
|
||||
|
||||
def delete_key(self, context: RequestContext, dataset_id: str, key_id: str) -> None:
|
||||
self._check_access(context, dataset_id)
|
||||
key = self._keys.delete_key(context.active_workspace_id, dataset_id, key_id)
|
||||
self._cache.delete(key.token, key.type)
|
||||
|
||||
def _check_access(self, context: RequestContext, dataset_id: str) -> None:
|
||||
# Enterprise RBAC is enforced at admission; community deployments use the dataset ACL.
|
||||
if not self._rbac_enabled():
|
||||
access = self._keys.get_dataset_access(context.active_workspace_id, dataset_id, context.account_id)
|
||||
if not access.allows(context.account_id):
|
||||
raise NoPermissionError("You do not have permission to access this dataset.")
|
||||
|
||||
def list_workspace_keys(self, context: RequestContext) -> tuple[ApiKeyRecord, ...]:
|
||||
return self._keys.list_workspace_keys(context.active_workspace_id)
|
||||
|
||||
def create_workspace_key(self, context: RequestContext, dataset_ids: tuple[str, ...]) -> ApiKeyRecord:
|
||||
return self._keys.create_workspace_key(
|
||||
context.active_workspace_id, tuple(dict.fromkeys(dataset_ids)), max_keys=self.MAX_KEYS, prefix="dataset-"
|
||||
)
|
||||
|
||||
def delete_workspace_key(self, context: RequestContext, key_id: str) -> None:
|
||||
key = self._keys.delete_workspace_key(context.active_workspace_id, key_id)
|
||||
self._cache.delete(key.token, key.type)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Dataset ACL policy independent of persistence and request globals."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DatasetAccess:
|
||||
permission: str
|
||||
maintainer_id: str | None
|
||||
role: str | None
|
||||
has_explicit_permission: bool
|
||||
|
||||
def allows(self, account_id: str) -> bool:
|
||||
if self.role is None:
|
||||
return False
|
||||
if self.role == "owner" or self.maintainer_id == account_id:
|
||||
return True
|
||||
if self.permission == "only_me":
|
||||
return False
|
||||
if self.permission == "partial_members":
|
||||
return self.has_explicit_permission
|
||||
return self.permission == "all_team_members"
|
||||
@@ -2,20 +2,22 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from collections.abc import Iterator
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError
|
||||
from threading import Event
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskClient
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models import Account
|
||||
from models.account import AccountStatus, TenantAccountRole
|
||||
from models.account import TenantAccountJoin, TenantAccountRole
|
||||
from models.dataset import Dataset
|
||||
from models.enums import ApiTokenType, DataSourceType
|
||||
from models.model import ApiToken, App, AppMode, DatasetApiTokenBinding
|
||||
from repositories.knowledge.dataset_api_key_repository import DatasetApiKeyRepository
|
||||
from services.auth.api_key_contracts import ApiKeyLimitExceededError
|
||||
from tests.test_containers_integration_tests.controllers.console.helpers import (
|
||||
authenticate_console_client,
|
||||
create_console_account_and_tenant,
|
||||
@@ -58,7 +60,7 @@ def setup_dataset(
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_api_tokens(db_session_with_containers: Session):
|
||||
def cleanup_api_tokens(db_session_with_containers: Session) -> Iterator[None]:
|
||||
"""Remove API tokens created during each test."""
|
||||
yield
|
||||
db_session_with_containers.execute(delete(ApiToken))
|
||||
@@ -186,28 +188,25 @@ class TestAppApiKeyResource:
|
||||
|
||||
def test_delete_forbidden_for_non_admin(
|
||||
self,
|
||||
flask_app_with_containers: Flask,
|
||||
db_session_with_containers: Session,
|
||||
test_client_with_containers: FlaskClient,
|
||||
) -> None:
|
||||
"""A non-admin member cannot delete API keys via the controller permission check."""
|
||||
from werkzeug.exceptions import Forbidden
|
||||
"""A non-admin member cannot delete API keys through Console admission."""
|
||||
account, tenant = create_console_account_and_tenant(db_session_with_containers)
|
||||
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
|
||||
membership = db_session_with_containers.scalar(
|
||||
select(TenantAccountJoin).where(
|
||||
TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == account.id
|
||||
)
|
||||
)
|
||||
assert membership is not None
|
||||
membership.role = TenantAccountRole.NORMAL
|
||||
db_session_with_containers.commit()
|
||||
headers = authenticate_console_client(test_client_with_containers, account)
|
||||
|
||||
from controllers.console.apikey import BaseApiKeyResource
|
||||
response = test_client_with_containers.delete(f"/console/api/apps/{app.id}/api-keys/{uuid4()}", headers=headers)
|
||||
|
||||
resource = BaseApiKeyResource()
|
||||
resource.resource_type = ApiTokenType.APP
|
||||
resource.resource_model = MagicMock()
|
||||
resource.resource_id_field = "app_id"
|
||||
|
||||
non_admin = Account(name="Normal User", email="normal@example.com", status=AccountStatus.ACTIVE)
|
||||
non_admin.id = "normal-user"
|
||||
non_admin.role = TenantAccountRole.NORMAL
|
||||
|
||||
with (
|
||||
flask_app_with_containers.test_request_context("/"),
|
||||
patch("controllers.console.apikey._get_resource"),
|
||||
):
|
||||
with pytest.raises(Forbidden):
|
||||
BaseApiKeyResource.delete(resource, "rid", "kid", "tenant-id", non_admin)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestDatasetApiKeyListResource:
|
||||
@@ -311,6 +310,49 @@ class TestDatasetApiKeyListResource:
|
||||
assert listed[scoped_resp.json["id"]] != scoped_resp.json["token"]
|
||||
|
||||
|
||||
def test_dataset_creation_routes_serialize_on_the_same_workspace(
|
||||
setup_dataset: tuple[FlaskClient, dict[str, str], Dataset],
|
||||
db_session_with_containers: Session,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The workspace request must wait until the per-dataset count/insert commits."""
|
||||
_, _, dataset = setup_dataset
|
||||
workspace_id, dataset_id = dataset.tenant_id, dataset.id
|
||||
db_session_with_containers.add_all(
|
||||
ApiToken(tenant_id=workspace_id, type=ApiTokenType.DATASET, token=f"dataset-{uuid4()}") for _ in range(9)
|
||||
)
|
||||
db_session_with_containers.commit()
|
||||
repository = DatasetApiKeyRepository(session_factory=sessionmaker(bind=db_session_with_containers.get_bind()))
|
||||
first_counted, release_first, second_started = Event(), Event(), Event()
|
||||
generate_key = ApiToken.generate_api_key
|
||||
|
||||
def pause_first_insert(prefix: str, n: int, *, session: Session) -> str:
|
||||
if prefix == "ds-":
|
||||
first_counted.set()
|
||||
assert release_first.wait(timeout=10), "Timed out waiting to release the first insertion"
|
||||
return generate_key(prefix, n, session=session)
|
||||
|
||||
def create_workspace_key() -> None:
|
||||
second_started.set()
|
||||
repository.create_workspace_key(workspace_id, (), max_keys=10, prefix="dataset-")
|
||||
|
||||
monkeypatch.setattr(ApiToken, "generate_api_key", pause_first_insert)
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
first = pool.submit(repository.create_key, workspace_id, dataset_id, max_keys=10, prefix="ds-")
|
||||
try:
|
||||
assert first_counted.wait(timeout=5)
|
||||
second = pool.submit(create_workspace_key)
|
||||
assert second_started.wait(timeout=5)
|
||||
with pytest.raises(TimeoutError):
|
||||
second.result(timeout=0.5)
|
||||
finally:
|
||||
release_first.set()
|
||||
first.result(timeout=5)
|
||||
with pytest.raises(ApiKeyLimitExceededError):
|
||||
second.result(timeout=5)
|
||||
assert len(repository.list_workspace_keys(workspace_id)) == 10
|
||||
|
||||
|
||||
class TestDatasetApiKeyResource:
|
||||
"""Tests for DELETE /datasets/api-keys/<api_key_id>."""
|
||||
|
||||
|
||||
+2
-1
@@ -12,6 +12,7 @@ from werkzeug.exceptions import Forbidden, NotFound
|
||||
from core.rag.index_processor.constant.index_type import IndexStructureType
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from models import Account
|
||||
from models.account import TenantAccountRole
|
||||
from models.dataset import Dataset, Document
|
||||
from models.enums import CreatorUserRole, DataSourceType, DocumentCreatedFrom, IndexingStatus
|
||||
from models.model import UploadFile
|
||||
@@ -119,7 +120,7 @@ def current_user_mock():
|
||||
with patch("services.dataset_service.current_user", create_autospec(Account, instance=True)) as current_user:
|
||||
current_user.id = str(uuid4())
|
||||
current_user.current_tenant_id = str(uuid4())
|
||||
current_user.current_role = None
|
||||
current_user.current_role = TenantAccountRole.EDITOR
|
||||
yield current_user
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
from inspect import getsource, unwrap
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock, Mock, call
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
@@ -24,8 +24,6 @@ from controllers.console.agent.composer import (
|
||||
)
|
||||
from controllers.console.agent.roster import (
|
||||
AgentApiAccessApi,
|
||||
AgentApiKeyApi,
|
||||
AgentApiKeyListApi,
|
||||
AgentApiStatusApi,
|
||||
AgentApiStatusPayload,
|
||||
AgentAppApi,
|
||||
@@ -953,11 +951,10 @@ def test_agent_api_key_count_scopes_tenant_and_keeps_legacy_tokens(sqlite_sessio
|
||||
assert roster_controller._agent_api_key_count(sqlite_session, app_model) == 2
|
||||
|
||||
|
||||
def test_agent_api_status_and_key_routes_resolve_backing_app(
|
||||
def test_agent_api_status_resolves_backing_app(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
|
||||
) -> None:
|
||||
agent_id = "00000000-0000-0000-0000-000000000001"
|
||||
api_key_id = "00000000-0000-0000-0000-000000000002"
|
||||
app_model = _app_detail_obj(
|
||||
id="app-1",
|
||||
tenant_id="tenant-1",
|
||||
@@ -979,34 +976,6 @@ def test_agent_api_status_and_key_routes_resolve_backing_app(
|
||||
|
||||
monkeypatch.setattr(roster_controller, "AppService", FakeAppService)
|
||||
|
||||
def fake_get_api_key_list(self, resource_id: str, tenant_id: str, *, session: object):
|
||||
captured["list_keys"] = {"session": session, "resource_id": resource_id, "tenant_id": tenant_id}
|
||||
return roster_controller.ApiKeyList(data=[])
|
||||
|
||||
def fake_create_api_key(self, resource_id: str, tenant_id: str, *, session: object):
|
||||
captured["create_key"] = {"session": session, "resource_id": resource_id, "tenant_id": tenant_id}
|
||||
return ApiToken(id=api_key_id, type="app", token="app-test-token", last_used_at=None, created_at=None)
|
||||
|
||||
def fake_delete_api_key(
|
||||
self,
|
||||
resource_id: str,
|
||||
key_id: str,
|
||||
tenant_id: str,
|
||||
current_user: object,
|
||||
*,
|
||||
session: object,
|
||||
) -> None:
|
||||
captured["delete_key"] = {
|
||||
"session": session,
|
||||
"resource_id": resource_id,
|
||||
"api_key_id": key_id,
|
||||
"tenant_id": tenant_id,
|
||||
"current_user": current_user,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(AgentApiKeyListApi, "_get_api_key_list", fake_get_api_key_list)
|
||||
monkeypatch.setattr(AgentApiKeyListApi, "_create_api_key", fake_create_api_key)
|
||||
monkeypatch.setattr(AgentApiKeyApi, "_delete_api_key", fake_delete_api_key)
|
||||
with app.test_request_context(
|
||||
"/console/api/agent/00000000-0000-0000-0000-000000000001/api-enable", json={"enable_api": True}
|
||||
):
|
||||
@@ -1015,40 +984,7 @@ def test_agent_api_status_and_key_routes_resolve_backing_app(
|
||||
)
|
||||
assert enabled["enabled"] is True
|
||||
assert captured["enable"] == {"app": app_model, "enable_api": True}
|
||||
keys = unwrap(AgentApiKeyListApi.get)(AgentApiKeyListApi(), unbound_session, "tenant-1", agent_id)
|
||||
assert keys == {"data": []}
|
||||
assert captured["list_keys"] == {
|
||||
"session": unbound_session,
|
||||
"resource_id": "app-1",
|
||||
"tenant_id": "tenant-1",
|
||||
}
|
||||
created, status = unwrap(AgentApiKeyListApi.post)(AgentApiKeyListApi(), unbound_session, "tenant-1", agent_id)
|
||||
assert status == 201
|
||||
assert created["id"] == api_key_id
|
||||
assert created["token"] == "app-test-token"
|
||||
assert captured["create_key"] == {
|
||||
"session": unbound_session,
|
||||
"resource_id": "app-1",
|
||||
"tenant_id": "tenant-1",
|
||||
}
|
||||
current_user = _account(privileged=True)
|
||||
deleted, delete_status = unwrap(AgentApiKeyApi.delete)(
|
||||
AgentApiKeyApi(), unbound_session, "tenant-1", current_user, agent_id, api_key_id
|
||||
)
|
||||
assert (deleted, delete_status) == ("", 204)
|
||||
assert captured["delete_key"] == {
|
||||
"session": unbound_session,
|
||||
"resource_id": "app-1",
|
||||
"api_key_id": api_key_id,
|
||||
"tenant_id": "tenant-1",
|
||||
"current_user": current_user,
|
||||
}
|
||||
assert resolve_app.call_args_list == [
|
||||
call(unbound_session, tenant_id="tenant-1", agent_id=agent_id),
|
||||
call(unbound_session, tenant_id="tenant-1", agent_id=agent_id),
|
||||
call(unbound_session, tenant_id="tenant-1", agent_id=agent_id),
|
||||
call(unbound_session, tenant_id="tenant-1", agent_id=agent_id),
|
||||
]
|
||||
resolve_app.assert_called_once_with(unbound_session, tenant_id="tenant-1", agent_id=agent_id)
|
||||
|
||||
|
||||
def test_agent_app_update_allows_empty_role(
|
||||
|
||||
@@ -8,14 +8,15 @@ from unittest.mock import ANY, MagicMock, PropertyMock, call, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import event, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
|
||||
|
||||
import services
|
||||
from controllers.common.rbac import DatasetId, Workspace
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import ProviderNotInitializeError
|
||||
from controllers.console.datasets import datasets as datasets_controller
|
||||
from controllers.console.datasets.datasets import (
|
||||
DatasetApi,
|
||||
DatasetApiBaseUrlApi,
|
||||
@@ -46,12 +47,14 @@ from core.provider_manager import ProviderManager
|
||||
from core.rag.datasource.vdb.vector_type import VectorType
|
||||
from core.rag.index_processor.constant.index_type import IndexStructureType
|
||||
from core.rag.retrieval.retrieval_methods import RetrievalMethod
|
||||
from extensions.application_services.knowledge import build_dataset_api_key_service
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from fields.dataset_fields import build_dataset_detail_prefetch
|
||||
from models.account import Account, TenantAccountRole
|
||||
from machinery.context import RequestContext
|
||||
from models.account import Account, Tenant, TenantAccountRole
|
||||
from models.dataset import AppDatasetJoin, Dataset, DatasetPermission, DatasetQuery, Document, DocumentSegment
|
||||
from models.enums import CreatorUserRole, DataSourceType, DocumentCreatedFrom, IndexingStatus, SegmentStatus
|
||||
from models.model import ApiToken, App, AppMode, IconType, UploadFile
|
||||
from models.model import ApiToken, App, AppMode, DatasetApiTokenBinding, IconType, UploadFile
|
||||
from services.dataset_ref_service import DatasetRef
|
||||
from services.dataset_service import DatasetPermissionService, DatasetService
|
||||
from services.enterprise import rbac_service as enterprise_rbac_service
|
||||
@@ -1528,168 +1531,112 @@ class TestDatasetIndexingStatusApi(_UsesSQLiteSession):
|
||||
|
||||
|
||||
class TestDatasetApiKeyApi(_UsesSQLiteSession):
|
||||
context = RequestContext("request", None, "actor", "tenant-1")
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def key_services(self, sqlite_session: Session, sqlite_session_factory: sessionmaker[Session], monkeypatch):
|
||||
tenant = Tenant(name="Workspace")
|
||||
tenant.id = "tenant-1"
|
||||
sqlite_session.add(tenant)
|
||||
sqlite_session.commit()
|
||||
service = build_dataset_api_key_service(database_client=sqlite_session_factory)
|
||||
monkeypatch.setattr(
|
||||
datasets_controller,
|
||||
"application_services",
|
||||
lambda: SimpleNamespace(dataset_api_keys=service),
|
||||
)
|
||||
|
||||
def test_get_api_keys_success(self, app: Flask):
|
||||
api = DatasetApiKeyApi()
|
||||
method = unwrap(api.get)
|
||||
mock_key_1 = ApiToken(
|
||||
id="key-1",
|
||||
type="dataset",
|
||||
token="dataset-aaaa1111bbbb",
|
||||
last_used_at=None,
|
||||
created_at=None,
|
||||
self.session.add_all(
|
||||
[
|
||||
ApiToken(id="key-1", tenant_id="tenant-1", type="dataset", token="dataset-aaaa1111bbbb"),
|
||||
ApiToken(id="key-2", tenant_id="tenant-1", type="dataset", token="dataset-cccc2222dddd"),
|
||||
]
|
||||
)
|
||||
mock_key_2 = ApiToken(
|
||||
id="key-2",
|
||||
type="dataset",
|
||||
token="dataset-cccc2222dddd",
|
||||
last_used_at=None,
|
||||
created_at=None,
|
||||
)
|
||||
session = self.session
|
||||
mock_key_1.tenant_id = "tenant-1"
|
||||
mock_key_2.tenant_id = "tenant-1"
|
||||
session.add_all([mock_key_1, mock_key_2])
|
||||
session.flush()
|
||||
self.session.commit()
|
||||
with app.test_request_context("/"):
|
||||
response = method(api, session, "tenant-1")
|
||||
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"] == "datas...bbbb"
|
||||
assert response["data"][1]["id"] == "key-2"
|
||||
assert response["data"][1]["token"] == "datas...dddd"
|
||||
response = unwrap(api.get)(api, self.context)
|
||||
assert [key["token"] for key in response["data"]] == ["datas...bbbb", "datas...dddd"]
|
||||
|
||||
def test_post_create_api_key_success(self, app: Flask):
|
||||
api = DatasetApiKeyApi()
|
||||
method = unwrap(api.post)
|
||||
session = self.session
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch.object(ApiToken, "generate_api_key", return_value="dataset-abc123") as generate_api_key,
|
||||
):
|
||||
response, status = method(api, session, "tenant-1")
|
||||
with app.test_request_context("/"):
|
||||
response, status = unwrap(api.post)(api, self.context)
|
||||
assert status == 200
|
||||
assert isinstance(response, dict)
|
||||
assert response["token"] == "dataset-abc123"
|
||||
assert response["token"].startswith("dataset-")
|
||||
assert response["type"] == "dataset"
|
||||
assert response["dataset_ids"] == []
|
||||
assert response["created_at"] is not None
|
||||
generate_api_key.assert_called_once_with("dataset-", 24, session=session)
|
||||
assert session.get(ApiToken, response["id"]).token == "dataset-abc123"
|
||||
stored = self.session.get(ApiToken, response["id"])
|
||||
assert stored is not None
|
||||
assert stored.token == response["token"]
|
||||
|
||||
def test_post_exceed_max_keys(self, app: Flask):
|
||||
api = DatasetApiKeyApi()
|
||||
method = unwrap(api.post)
|
||||
session = self.session
|
||||
session.add_all(
|
||||
[
|
||||
ApiToken(id=f"key-{index}", tenant_id="tenant-1", type="dataset", token=f"ds-{index}")
|
||||
for index in range(10)
|
||||
]
|
||||
self.session.add_all(
|
||||
[ApiToken(id=f"key-{i}", tenant_id="tenant-1", type="dataset", token=f"ds-{i}") for i in range(10)]
|
||||
)
|
||||
session.flush()
|
||||
with app.test_request_context("/"):
|
||||
with pytest.raises(BadRequest) as exc_info:
|
||||
method(api, session, "tenant-1")
|
||||
assert exc_info.value.code == 400
|
||||
self.session.commit()
|
||||
with app.test_request_context("/"), pytest.raises(BadRequest) as exc_info:
|
||||
unwrap(api.post)(api, self.context)
|
||||
assert vars(exc_info.value)["data"] == {
|
||||
"message": "Cannot create more than 10 API keys for this resource type.",
|
||||
"custom": "max_keys_exceeded",
|
||||
}
|
||||
|
||||
def test_get_api_keys_include_bound_dataset_ids(self, app: Flask):
|
||||
def test_scoped_key_persists_bindings_and_lists_masked_scope(self, app: Flask):
|
||||
api = DatasetApiKeyApi()
|
||||
method = unwrap(api.get)
|
||||
mock_key = MagicMock(spec=ApiToken)
|
||||
mock_key.id = "key-1"
|
||||
mock_key.type = "dataset"
|
||||
mock_key.token = "dataset-aaaa1111bbbb"
|
||||
mock_key.last_used_at = None
|
||||
mock_key.created_at = None
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = [mock_key]
|
||||
# Two binding rows for the same key -> the masked list surfaces both dataset ids.
|
||||
session.execute.return_value.all.return_value = [("key-1", "ds-1"), ("key-1", "ds-2")]
|
||||
with app.test_request_context("/"):
|
||||
response = method(api, session, "tenant-1")
|
||||
assert response["data"][0]["dataset_ids"] == ["ds-1", "ds-2"]
|
||||
|
||||
def test_post_create_scoped_key_persists_bindings(self, app: Flask):
|
||||
api = DatasetApiKeyApi()
|
||||
method = unwrap(api.post)
|
||||
mock_token = MagicMock()
|
||||
mock_token.id = "new-key-id"
|
||||
mock_token.last_used_at = None
|
||||
mock_token.created_at = datetime.datetime(2024, 1, 1, 0, 0, 0, tzinfo=datetime.UTC)
|
||||
mock_api_token_cls = MagicMock()
|
||||
mock_api_token_cls.return_value = mock_token
|
||||
mock_api_token_cls.generate_api_key.return_value = "dataset-abc123"
|
||||
session = MagicMock()
|
||||
# Dataset-ownership validation returns exactly the requested ids (all belong to tenant).
|
||||
session.scalars.return_value.all.return_value = ["ds-1", "ds-2"]
|
||||
session.scalar.return_value = 1
|
||||
with (
|
||||
app.test_request_context("/", json={"dataset_ids": ["ds-1", "ds-2", "ds-1"]}),
|
||||
patch("controllers.console.datasets.datasets.ApiToken", mock_api_token_cls),
|
||||
):
|
||||
response, status = method(api, session, "tenant-1")
|
||||
self.session.add_all(
|
||||
[
|
||||
make_dataset(id="ds-1", tenant_id="tenant-1"),
|
||||
make_dataset(id="ds-2", tenant_id="tenant-1"),
|
||||
]
|
||||
)
|
||||
self.session.commit()
|
||||
with app.test_request_context("/", json={"dataset_ids": ["ds-1", "ds-2", "ds-1"]}):
|
||||
response, status = unwrap(api.post)(api, self.context)
|
||||
listed = unwrap(api.get)(api, self.context)
|
||||
assert status == 200
|
||||
# Duplicates are collapsed and returned in the reveal-once response.
|
||||
assert response["dataset_ids"] == ["ds-1", "ds-2"]
|
||||
# One binding row is added per unique dataset id (plus the token itself).
|
||||
added = [call.args[0] for call in session.add.call_args_list]
|
||||
binding_dataset_ids = [obj.dataset_id for obj in added if obj.__class__.__name__ == "DatasetApiTokenBinding"]
|
||||
assert binding_dataset_ids == ["ds-1", "ds-2"]
|
||||
bindings = self.session.scalars(
|
||||
select(DatasetApiTokenBinding).where(DatasetApiTokenBinding.api_token_id == response["id"])
|
||||
).all()
|
||||
assert {binding.dataset_id for binding in bindings} == {"ds-1", "ds-2"}
|
||||
assert set(listed["data"][0]["dataset_ids"]) == {"ds-1", "ds-2"}
|
||||
assert listed["data"][0]["token"] != response["token"]
|
||||
|
||||
def test_post_rejects_dataset_ids_from_another_tenant(self, app: Flask):
|
||||
api = DatasetApiKeyApi()
|
||||
method = unwrap(api.post)
|
||||
session = MagicMock()
|
||||
# None of the requested ids belong to this tenant.
|
||||
session.scalars.return_value.all.return_value = []
|
||||
with app.test_request_context("/", json={"dataset_ids": ["foreign-ds"]}):
|
||||
with pytest.raises(BadRequest) as exc_info:
|
||||
method(api, session, "tenant-1")
|
||||
assert exc_info.value.code == 400
|
||||
self.session.add(make_dataset(id="foreign-ds", tenant_id="foreign"))
|
||||
self.session.commit()
|
||||
with app.test_request_context("/", json={"dataset_ids": ["foreign-ds"]}), pytest.raises(BadRequest) as exc_info:
|
||||
unwrap(api.post)(api, self.context)
|
||||
assert "Unknown knowledge base id(s)" in vars(exc_info.value)["data"]["message"]
|
||||
assert self.session.scalar(select(ApiToken)) is None
|
||||
|
||||
def test_post_rejects_non_list_dataset_ids(self, app: Flask):
|
||||
@pytest.mark.parametrize("dataset_ids", ["not-a-list", [1]])
|
||||
def test_post_rejects_invalid_dataset_ids(self, app: Flask, dataset_ids):
|
||||
api = DatasetApiKeyApi()
|
||||
method = unwrap(api.post)
|
||||
session = MagicMock()
|
||||
with app.test_request_context("/", json={"dataset_ids": "not-a-list"}):
|
||||
with pytest.raises(BadRequest) as exc_info:
|
||||
method(api, session, "tenant-1")
|
||||
assert exc_info.value.code == 400
|
||||
with app.test_request_context("/", json={"dataset_ids": dataset_ids}), pytest.raises(BadRequest) as exc_info:
|
||||
unwrap(api.post)(api, self.context)
|
||||
assert vars(exc_info.value)["data"]["message"] == "dataset_ids must be a list of strings."
|
||||
|
||||
|
||||
class TestDatasetApiDeleteApi(_UsesSQLiteSession):
|
||||
def test_delete_success(self, app: Flask):
|
||||
api = DatasetApiDeleteApi()
|
||||
method = unwrap(api.delete)
|
||||
session = self.session
|
||||
key = ApiToken(id="api-key-id", tenant_id="tenant-1", type="dataset", token="dataset-secret")
|
||||
session.add(key)
|
||||
session.flush()
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
patch("controllers.console.datasets.datasets.ApiTokenCache.delete") as delete_cache,
|
||||
):
|
||||
response, status = method(api, session, "tenant-1", "api-key-id")
|
||||
assert status == 204
|
||||
assert response == ""
|
||||
delete_cache.assert_called_once()
|
||||
session.flush()
|
||||
assert session.get(ApiToken, "api-key-id") is None
|
||||
self.session.add(ApiToken(id="api-key-id", tenant_id="tenant-1", type="dataset", token="dataset-secret"))
|
||||
self.session.commit()
|
||||
with app.test_request_context("/"), patch("services.api_token_service.ApiTokenCache.delete") as invalidate:
|
||||
result = unwrap(api.delete)(api, self.context, "api-key-id")
|
||||
assert result == ("", 204)
|
||||
self.session.expire_all()
|
||||
assert self.session.get(ApiToken, "api-key-id") is None
|
||||
invalidate.assert_called_once_with("dataset-secret", "dataset")
|
||||
|
||||
def test_delete_key_not_found(self, app: Flask):
|
||||
api = DatasetApiDeleteApi()
|
||||
method = unwrap(api.delete)
|
||||
session = self.session
|
||||
with app.test_request_context("/"):
|
||||
with pytest.raises(NotFound):
|
||||
method(api, session, "tenant-1", "api-key-id")
|
||||
with app.test_request_context("/"), pytest.raises(NotFound):
|
||||
unwrap(api.delete)(api, self.context, "api-key-id")
|
||||
|
||||
|
||||
class TestDatasetEnableApiApi(_UsesSQLiteSession):
|
||||
|
||||
@@ -1,338 +1,317 @@
|
||||
from __future__ import annotations
|
||||
"""API key admission, delegation, error and response contracts."""
|
||||
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import Mock
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import event, select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
|
||||
from flask_restx import Api
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from controllers.console.agent.roster import AgentApiKeyListApi
|
||||
from controllers.console.apikey import (
|
||||
AppApiKeyListResource,
|
||||
BaseApiKeyListResource,
|
||||
BaseApiKeyResource,
|
||||
DatasetApiKeyListResource,
|
||||
)
|
||||
from controllers.console.datasets.datasets import DatasetApiKeyApi
|
||||
from core.rbac import RBACPermission, RBACResourceScope
|
||||
from controllers.console import apikey, flask_admission, wraps
|
||||
from controllers.console.agent import roster
|
||||
from controllers.console.datasets import datasets
|
||||
from core.rbac import RBACPermission
|
||||
from enums import DeploymentEdition
|
||||
from models import Account
|
||||
from models.account import AccountStatus, TenantAccountRole
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, App, AppMode, IconType
|
||||
from services.agent.errors import AgentAccessNotReadyError
|
||||
from extensions.application_services.knowledge import build_dataset_api_key_service
|
||||
from extensions.ext_application_services import ApplicationServices
|
||||
from libs.login import AccountWithTenant
|
||||
from machinery.context import RequestContext
|
||||
from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.dataset import Dataset
|
||||
from models.model import ApiToken
|
||||
from services.app.api_key_service import AppApiKeyNotReadyError
|
||||
from services.auth.api_key_contracts import (
|
||||
ApiKeyLimitExceededError,
|
||||
ApiKeyNotFoundError,
|
||||
ApiKeyRecord,
|
||||
ApiKeyResourceNotFoundError,
|
||||
)
|
||||
|
||||
RESOURCE_ID = UUID("00000000-0000-0000-0000-000000000001")
|
||||
KEY_ID = UUID("00000000-0000-0000-0000-000000000002")
|
||||
|
||||
|
||||
def _make_list_resource() -> BaseApiKeyListResource:
|
||||
resource = BaseApiKeyListResource()
|
||||
resource.resource_type = ApiTokenType.APP
|
||||
resource.resource_model = App
|
||||
resource.resource_id_field = "app_id"
|
||||
resource.token_prefix = "app-"
|
||||
return resource
|
||||
@dataclass
|
||||
class RecordingKeys:
|
||||
calls: list[tuple[str, RequestContext, str, str, str | None]] = field(default_factory=list)
|
||||
error: Exception | None = None
|
||||
|
||||
|
||||
def _make_key_resource() -> BaseApiKeyResource:
|
||||
resource = BaseApiKeyResource()
|
||||
resource.resource_type = ApiTokenType.APP
|
||||
resource.resource_model = App
|
||||
resource.resource_id_field = "app_id"
|
||||
return resource
|
||||
|
||||
|
||||
def _make_account(role: TenantAccountRole) -> Account:
|
||||
account = Account(
|
||||
name="Test User",
|
||||
email=f"{role.value}@example.com",
|
||||
status=AccountStatus.ACTIVE,
|
||||
)
|
||||
account.id = f"{role.value}-user"
|
||||
account.role = role
|
||||
return account
|
||||
|
||||
|
||||
def _persist_app(session: Session, *, mode: AppMode = AppMode.CHAT, app_id: str = "app-1") -> App:
|
||||
app = App(
|
||||
id=app_id,
|
||||
tenant_id="tenant-1",
|
||||
name="API key app",
|
||||
mode=mode,
|
||||
icon_type=IconType.EMOJI,
|
||||
icon="chat",
|
||||
icon_background="#ffffff",
|
||||
enable_site=False,
|
||||
enable_api=True,
|
||||
)
|
||||
session.add(app)
|
||||
session.flush()
|
||||
return app
|
||||
|
||||
|
||||
def test_list_api_keys_uses_injected_session_and_tenant_id(sqlite_session: Session) -> None:
|
||||
resource = _make_list_resource()
|
||||
raw_get = cast(
|
||||
Callable[[BaseApiKeyListResource, object, str, str], dict[str, object]],
|
||||
inspect.unwrap(BaseApiKeyListResource.get),
|
||||
)
|
||||
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.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",
|
||||
def _record(
|
||||
self,
|
||||
operation: str,
|
||||
context: RequestContext,
|
||||
kind: str,
|
||||
resource_id: str,
|
||||
key_id: str | None = None,
|
||||
) -> ApiKeyRecord:
|
||||
self.calls.append((operation, context, kind, resource_id, key_id))
|
||||
if self.error:
|
||||
raise self.error
|
||||
return ApiKeyRecord(
|
||||
id=str(KEY_ID),
|
||||
type="dataset" if kind == "dataset" else "app",
|
||||
token="app-secret-token",
|
||||
created_at=datetime(2026, 1, 1, tzinfo=UTC),
|
||||
dataset_ids=(resource_id,) if kind == "dataset" else (),
|
||||
)
|
||||
)
|
||||
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"])
|
||||
def list_keys(self, context: RequestContext, app_id: str) -> tuple[ApiKeyRecord, ...]:
|
||||
return (self._record("list", context, "app", app_id),)
|
||||
|
||||
assert {item["token"] for item in data} == {"app-token", "legacy-app-token"}
|
||||
def create_key(self, context: RequestContext, app_id: str) -> ApiKeyRecord:
|
||||
return self._record("create", context, "app", app_id)
|
||||
|
||||
def delete_key(self, context: RequestContext, app_id: str, key_id: str) -> None:
|
||||
self._record("delete", context, "app", app_id, key_id)
|
||||
|
||||
def list_agent_keys(self, context: RequestContext, agent_id: str) -> tuple[ApiKeyRecord, ...]:
|
||||
return (self._record("list", context, "agent", agent_id),)
|
||||
|
||||
def create_agent_key(self, context: RequestContext, agent_id: str) -> ApiKeyRecord:
|
||||
return self._record("create", context, "agent", agent_id)
|
||||
|
||||
def delete_agent_key(self, context: RequestContext, agent_id: str, key_id: str) -> None:
|
||||
self._record("delete", context, "agent", agent_id, key_id)
|
||||
|
||||
|
||||
def test_create_api_key_uses_injected_session_and_tenant_id(sqlite_session: Session) -> None:
|
||||
resource = _make_list_resource()
|
||||
raw_post = cast(
|
||||
Callable[[BaseApiKeyListResource, object, str, str], tuple[dict[str, object], int]],
|
||||
inspect.unwrap(BaseApiKeyListResource.post),
|
||||
)
|
||||
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"))
|
||||
@dataclass
|
||||
class RecordingDatasetKeys:
|
||||
recorder: RecordingKeys
|
||||
|
||||
with patch(
|
||||
"controllers.console.apikey.ApiToken.generate_api_key", return_value="app-generated-token"
|
||||
) as generate_api_key:
|
||||
result, status = raw_post(resource, session, "app-1", "tenant-1")
|
||||
def list_keys(self, context: RequestContext, dataset_id: str) -> tuple[ApiKeyRecord, ...]:
|
||||
return (self.recorder._record("list", context, "dataset", dataset_id),)
|
||||
|
||||
assert status == 201
|
||||
assert result["token"] == "app-generated-token"
|
||||
api_token = session.scalar(select(ApiToken).where(ApiToken.token == "app-generated-token"))
|
||||
assert api_token is not None
|
||||
assert api_token.app_id == "app-1"
|
||||
assert api_token.tenant_id == "tenant-1"
|
||||
assert api_token.type == ApiTokenType.APP
|
||||
generate_api_key.assert_called_once_with("app-", 24, session=session)
|
||||
assert commits == ["commit"]
|
||||
def create_key(self, context: RequestContext, dataset_id: str) -> ApiKeyRecord:
|
||||
return self.recorder._record("create", context, "dataset", dataset_id)
|
||||
|
||||
def delete_key(self, context: RequestContext, dataset_id: str, key_id: str) -> None:
|
||||
self.recorder._record("delete", context, "dataset", dataset_id, key_id)
|
||||
|
||||
def list_workspace_keys(self, context: RequestContext) -> tuple[ApiKeyRecord, ...]:
|
||||
return (self.recorder._record("list", context, "dataset", "workspace"),)
|
||||
|
||||
def create_workspace_key(self, context: RequestContext, _dataset_ids: tuple[str, ...]) -> ApiKeyRecord:
|
||||
return self.recorder._record("create", context, "dataset", "workspace")
|
||||
|
||||
def delete_workspace_key(self, context: RequestContext, key_id: str) -> None:
|
||||
self.recorder._record("delete", context, "dataset", "workspace", key_id)
|
||||
|
||||
|
||||
def test_create_api_key_counts_legacy_tokens(sqlite_session: Session) -> None:
|
||||
resource = _make_list_resource()
|
||||
_persist_app(sqlite_session)
|
||||
@dataclass
|
||||
class ApiKeyTestServices:
|
||||
app_api_keys: RecordingKeys
|
||||
dataset_api_keys: RecordingDatasetKeys
|
||||
|
||||
|
||||
type KeysApp = tuple[Flask, RecordingKeys, Account]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def keys_app(monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]) -> KeysApp:
|
||||
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, LOGIN_DISABLED=True, RBAC_ENABLED=False)
|
||||
account = Account(name="Owner", email="owner@example.com", status=AccountStatus.ACTIVE)
|
||||
account.id = "actor"
|
||||
account.role = TenantAccountRole.OWNER
|
||||
|
||||
def current_account() -> AccountWithTenant:
|
||||
return AccountWithTenant(account, "workspace")
|
||||
|
||||
monkeypatch.setattr(flask_admission, "current_account_with_tenant", current_account)
|
||||
monkeypatch.setattr(wraps, "current_account_with_tenant", current_account)
|
||||
monkeypatch.setattr(flask_admission, "get_request_id", lambda: "request-id")
|
||||
monkeypatch.setattr(flask_admission, "get_trace_id", lambda: None)
|
||||
keys = RecordingKeys()
|
||||
services = cast(ApplicationServices, ApiKeyTestServices(keys, RecordingDatasetKeys(keys)))
|
||||
monkeypatch.setattr(apikey, "application_services", lambda: services)
|
||||
monkeypatch.setattr(roster, "application_services", lambda: services)
|
||||
monkeypatch.setattr(datasets, "application_services", lambda: services)
|
||||
app = Flask(__name__)
|
||||
api = Api(app)
|
||||
api.add_resource(apikey.AppApiKeyListResource, "/app/<uuid:resource_id>")
|
||||
api.add_resource(apikey.AppApiKeyResource, "/app/<uuid:resource_id>/<uuid:api_key_id>")
|
||||
api.add_resource(apikey.DatasetApiKeyListResource, "/dataset/<uuid:resource_id>")
|
||||
api.add_resource(apikey.DatasetApiKeyResource, "/dataset/<uuid:resource_id>/<uuid:api_key_id>")
|
||||
api.add_resource(roster.AgentApiKeyListApi, "/agent/<uuid:agent_id>")
|
||||
api.add_resource(roster.AgentApiKeyApi, "/agent/<uuid:agent_id>/<uuid:api_key_id>")
|
||||
api.add_resource(datasets.DatasetApiKeyApi, "/workspace-keys")
|
||||
api.add_resource(datasets.DatasetApiDeleteApi, "/workspace-keys/<uuid:api_key_id>")
|
||||
return app, keys, account
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["app", "dataset", "agent"])
|
||||
def test_key_routes_forward_stable_context_and_serialize(keys_app: KeysApp, kind: str) -> None:
|
||||
app, keys, _ = keys_app
|
||||
client = app.test_client()
|
||||
path = f"/{kind}/{RESOURCE_ID}"
|
||||
headers = {"X-Trace-Id": "trace-id"}
|
||||
|
||||
listed = client.get(path, headers=headers)
|
||||
created = client.post(path, headers=headers)
|
||||
deleted = client.delete(f"{path}/{KEY_ID}", headers=headers)
|
||||
|
||||
assert listed.status_code == 200
|
||||
assert listed.json is not None
|
||||
assert listed.json["data"][0]["token"] == ("app-s...oken" if kind == "dataset" else "app-secret-token")
|
||||
assert listed.json["data"][0]["dataset_ids"] == ([str(RESOURCE_ID)] if kind == "dataset" else [])
|
||||
assert created.status_code == 201
|
||||
assert created.json is not None
|
||||
assert created.json["token"] == "app-secret-token"
|
||||
assert created.json["created_at"] == 1767225600
|
||||
assert deleted.status_code == 204
|
||||
assert deleted.data == b""
|
||||
context = RequestContext("request-id", "trace-id", "actor", "workspace")
|
||||
assert keys.calls == [
|
||||
("list", context, kind, str(RESOURCE_ID), None),
|
||||
("create", context, kind, str(RESOURCE_ID), None),
|
||||
("delete", context, kind, str(RESOURCE_ID), str(KEY_ID)),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["app", "dataset", "agent"])
|
||||
@pytest.mark.parametrize("role", list(TenantAccountRole))
|
||||
@pytest.mark.parametrize("method", ["GET", "POST", "DELETE"])
|
||||
def test_admission_preserves_role_policy(keys_app: KeysApp, kind: str, role: TenantAccountRole, method: str) -> None:
|
||||
app, keys, account = keys_app
|
||||
account.role = role
|
||||
path = f"/{kind}/{RESOURCE_ID}" + (f"/{KEY_ID}" if method == "DELETE" else "")
|
||||
response = app.test_client().open(path, method=method)
|
||||
allowed = role in (apikey.API_KEY_DELETE_ROLES if method == "DELETE" else apikey.API_KEY_EDIT_ROLES)
|
||||
assert response.status_code == ({"GET": 200, "POST": 201, "DELETE": 204}[method] if allowed else 403)
|
||||
assert bool(keys.calls) is allowed
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("error", "status", "message"),
|
||||
[
|
||||
(ApiKeyResourceNotFoundError("App not found."), 404, "App not found."),
|
||||
(ApiKeyNotFoundError(), 404, "API key not found"),
|
||||
(ApiKeyLimitExceededError(10), 400, "Cannot create more than 10 API keys for this resource type."),
|
||||
(AppApiKeyNotReadyError(), 409, "Publish the Agent before enabling Web App or API access."),
|
||||
],
|
||||
)
|
||||
def test_errors_keep_http_contract(keys_app: KeysApp, error: Exception, status: int, message: str) -> None:
|
||||
app, keys, _ = keys_app
|
||||
keys.error = error
|
||||
response = app.test_client().post(f"/app/{RESOURCE_ID}")
|
||||
assert response.status_code == status
|
||||
assert response.json is not None
|
||||
assert response.json["message"].startswith(message)
|
||||
if isinstance(error, ApiKeyLimitExceededError):
|
||||
assert response.json["custom"] == "max_keys_exceeded"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kind", "permission"),
|
||||
[
|
||||
("app", RBACPermission.APP_RELEASE_AND_VERSION),
|
||||
("dataset", RBACPermission.DATASET_API_KEY_MANAGE),
|
||||
("agent", RBACPermission.AGENT_ACCESS_POINT_VIEW),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("allowed", [False, True])
|
||||
def test_rbac_admission_controls_service_access(
|
||||
keys_app: KeysApp,
|
||||
config_overrides: Callable[..., None],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
kind: str,
|
||||
permission: RBACPermission,
|
||||
allowed: bool,
|
||||
) -> None:
|
||||
from controllers.common.rbac import checks, locators
|
||||
|
||||
app, keys, account = keys_app
|
||||
account.role = TenantAccountRole.NORMAL
|
||||
config_overrides(RBAC_ENABLED=True)
|
||||
monkeypatch.setattr(locators, "agent_binding", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(locators.PlainApp, "owner_id", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(locators.DatasetId, "owner_id", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(locators.AgentId, "owner_id", lambda *_args, **_kwargs: None)
|
||||
check = Mock(return_value=allowed)
|
||||
monkeypatch.setattr(checks.RBACService.CheckAccess, "check", check)
|
||||
|
||||
response = app.test_client().get(f"/{kind}/{RESOURCE_ID}")
|
||||
|
||||
assert response.status_code == (200 if allowed else 403)
|
||||
assert check.call_args.kwargs["scene"] == permission
|
||||
assert bool(keys.calls) is allowed
|
||||
|
||||
|
||||
def test_uninitialized_account_is_rejected(keys_app: KeysApp) -> None:
|
||||
app, keys, account = keys_app
|
||||
account.status = AccountStatus.UNINITIALIZED
|
||||
assert app.test_client().post(f"/app/{RESOURCE_ID}").status_code == 400
|
||||
assert keys.calls == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", list(TenantAccountRole))
|
||||
@pytest.mark.parametrize("method", ["GET", "POST", "DELETE"])
|
||||
def test_workspace_key_admission_preserves_admin_role_policy(
|
||||
keys_app: KeysApp, role: TenantAccountRole, method: str
|
||||
) -> None:
|
||||
app, keys, account = keys_app
|
||||
account.role = role
|
||||
path = "/workspace-keys" + (f"/{KEY_ID}" if method == "DELETE" else "")
|
||||
response = app.test_client().open(path, method=method)
|
||||
allowed = role in apikey.API_KEY_DELETE_ROLES
|
||||
assert response.status_code == ({"GET": 200, "POST": 200, "DELETE": 204}[method] if allowed else 403)
|
||||
assert bool(keys.calls) is allowed
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def persisted_keys_app(
|
||||
keys_app: KeysApp,
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> KeysApp:
|
||||
app, keys, account = keys_app
|
||||
assert account.role is not None
|
||||
tenant = Tenant(name="Workspace")
|
||||
tenant.id = "workspace"
|
||||
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)
|
||||
tenant,
|
||||
TenantAccountJoin(tenant_id="workspace", account_id=account.id, role=account.role),
|
||||
Dataset(id=str(RESOURCE_ID), tenant_id="workspace", name="Private", created_by="other", maintainer="other"),
|
||||
]
|
||||
)
|
||||
sqlite_session.commit()
|
||||
|
||||
with pytest.raises(BadRequest):
|
||||
resource._create_api_key("app-1", "tenant-1", session=sqlite_session)
|
||||
service = build_dataset_api_key_service(database_client=sqlite_session_factory)
|
||||
services = Mock(dataset_api_keys=service)
|
||||
monkeypatch.setattr(apikey, "application_services", lambda: services)
|
||||
monkeypatch.setattr(datasets, "application_services", lambda: services)
|
||||
return app, keys, account
|
||||
|
||||
|
||||
def test_create_agent_api_key_requires_published_access(sqlite_session: Session) -> None:
|
||||
resource = _make_list_resource()
|
||||
session = sqlite_session
|
||||
app = _persist_app(session, mode=AppMode.AGENT)
|
||||
|
||||
with patch(
|
||||
"controllers.console.apikey.AppService.ensure_agent_app_access_ready",
|
||||
side_effect=AgentAccessNotReadyError(),
|
||||
) as ensure_access_ready:
|
||||
with pytest.raises(AgentAccessNotReadyError):
|
||||
resource._create_api_key("app-1", "tenant-1", session=session)
|
||||
|
||||
ensure_access_ready.assert_called_once_with(app, session=session)
|
||||
assert session.scalar(select(ApiToken)) is None
|
||||
def test_private_dataset_rejects_editor_without_creating_token(
|
||||
persisted_keys_app: KeysApp, sqlite_session: Session
|
||||
) -> None:
|
||||
app, _, account = persisted_keys_app
|
||||
account.role = TenantAccountRole.EDITOR
|
||||
member = sqlite_session.scalar(select(TenantAccountJoin))
|
||||
assert member is not None
|
||||
member.role = account.role
|
||||
sqlite_session.commit()
|
||||
response = app.test_client().post(f"/dataset/{RESOURCE_ID}")
|
||||
assert response.status_code == 403
|
||||
assert sqlite_session.scalar(select(ApiToken)) is None
|
||||
|
||||
|
||||
def test_delete_api_key_rejects_non_admin_account(sqlite_session: Session) -> None:
|
||||
resource = _make_key_resource()
|
||||
raw_delete = cast(
|
||||
Callable[[BaseApiKeyResource, object, str, str, str, Account], tuple[str, int]],
|
||||
inspect.unwrap(BaseApiKeyResource.delete),
|
||||
)
|
||||
session = sqlite_session
|
||||
_persist_app(session)
|
||||
|
||||
with pytest.raises(Forbidden):
|
||||
raw_delete(
|
||||
resource,
|
||||
session,
|
||||
"app-1",
|
||||
"key-1",
|
||||
"tenant-1",
|
||||
_make_account(TenantAccountRole.NORMAL),
|
||||
)
|
||||
|
||||
|
||||
def test_delete_api_key_uses_injected_session_user_and_tenant(sqlite_session: Session) -> None:
|
||||
resource = _make_key_resource()
|
||||
raw_delete = cast(
|
||||
Callable[[BaseApiKeyResource, object, str, str, str, Account], tuple[str, int]],
|
||||
inspect.unwrap(BaseApiKeyResource.delete),
|
||||
)
|
||||
session = sqlite_session
|
||||
_persist_app(session)
|
||||
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()
|
||||
commits: list[str] = []
|
||||
event.listen(session, "after_commit", lambda _session: commits.append("commit"))
|
||||
|
||||
with patch("controllers.console.apikey.ApiTokenCache.delete") as delete_cache:
|
||||
result, status = raw_delete(
|
||||
resource,
|
||||
session,
|
||||
"app-1",
|
||||
"key-1",
|
||||
"tenant-1",
|
||||
_make_account(TenantAccountRole.OWNER),
|
||||
)
|
||||
|
||||
delete_cache.assert_called_once_with("app-token", ApiTokenType.APP)
|
||||
assert session.get(ApiToken, "key-1") is None
|
||||
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(config_overrides: Callable[..., None]) -> None:
|
||||
config_overrides(
|
||||
DEPLOYMENT_EDITION=DeploymentEdition.CLOUD,
|
||||
LOGIN_DISABLED=True,
|
||||
RBAC_ENABLED=True,
|
||||
)
|
||||
app = Flask(__name__)
|
||||
account = _make_account(TenantAccountRole.OWNER)
|
||||
api_id = UUID("00000000-0000-0000-0000-000000000001")
|
||||
cases = [
|
||||
(
|
||||
lambda: AppApiKeyListResource().get(resource_id=api_id),
|
||||
{
|
||||
"scene": RBACPermission.APP_RELEASE_AND_VERSION,
|
||||
"resource_type": RBACResourceScope.APP,
|
||||
"resource_id": str(api_id),
|
||||
},
|
||||
),
|
||||
(
|
||||
lambda: DatasetApiKeyApi().get(),
|
||||
{"scene": RBACPermission.DATASET_API_KEY_MANAGE, "resource_type": None, "resource_id": None},
|
||||
),
|
||||
(
|
||||
lambda: DatasetApiKeyListResource().get(resource_id=api_id),
|
||||
{
|
||||
"scene": RBACPermission.DATASET_API_KEY_MANAGE,
|
||||
"resource_type": RBACResourceScope.DATASET,
|
||||
"resource_id": str(api_id),
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
with (
|
||||
app.test_request_context("/"),
|
||||
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("controllers.common.rbac.locators.agent_binding", return_value=None),
|
||||
patch("controllers.common.rbac.locators.PlainApp.owner_id", return_value=None),
|
||||
patch("controllers.common.rbac.locators.DatasetId.owner_id", return_value=None),
|
||||
patch.object(BaseApiKeyListResource, "_get_api_key_list") as get_api_key_list,
|
||||
):
|
||||
for invoke, expected_kwargs in cases:
|
||||
with patch(
|
||||
"controllers.common.rbac.checks.RBACService.CheckAccess.check", return_value=False
|
||||
) as check_access:
|
||||
with pytest.raises(Forbidden):
|
||||
invoke()
|
||||
|
||||
check_access.assert_called_once_with(
|
||||
"tenant-1",
|
||||
account.id,
|
||||
scene=expected_kwargs["scene"],
|
||||
resource_type=expected_kwargs["resource_type"],
|
||||
resource_id=expected_kwargs["resource_id"],
|
||||
)
|
||||
|
||||
get_api_key_list.assert_not_called()
|
||||
|
||||
|
||||
def test_api_key_lists_reject_legacy_read_only_members(config_overrides: Callable[..., None]) -> None:
|
||||
config_overrides(
|
||||
DEPLOYMENT_EDITION=DeploymentEdition.CLOUD,
|
||||
LOGIN_DISABLED=True,
|
||||
RBAC_ENABLED=False,
|
||||
)
|
||||
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("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(),
|
||||
lambda: DatasetApiKeyListResource().get(resource_id=api_id),
|
||||
):
|
||||
with pytest.raises(Forbidden):
|
||||
invoke()
|
||||
|
||||
get_api_key_list.assert_not_called()
|
||||
def test_both_http_routes_enforce_the_same_key_limit(persisted_keys_app: KeysApp, sqlite_session: Session) -> None:
|
||||
app, _, _ = persisted_keys_app
|
||||
client = app.test_client()
|
||||
for _ in range(5):
|
||||
assert client.post("/workspace-keys").status_code == 200
|
||||
assert client.post(f"/dataset/{RESOURCE_ID}").status_code == 201
|
||||
for path in ("/workspace-keys", f"/dataset/{RESOURCE_ID}"):
|
||||
response = client.post(path)
|
||||
assert response.status_code == 400
|
||||
assert response.json is not None
|
||||
assert response.json["custom"] == "max_keys_exceeded"
|
||||
assert len(sqlite_session.scalars(select(ApiToken)).all()) == 10
|
||||
@@ -36,6 +36,7 @@ from repositories.app_site_command_repository import AppSiteCommandRepository
|
||||
from repositories.app_statistic_query_repository import AppStatisticQueryRepository
|
||||
from repositories.app_tracing_config_repository import SQLAlchemyAppTracingConfigRepository
|
||||
from repositories.human_input_file_upload_repository import SQLAlchemyHumanInputFileUploadRepository
|
||||
from repositories.installed_app_repository import SQLAlchemyInstalledAppRepository
|
||||
from repositories.message_file_preview_repository import MessageFilePreviewQueryRepository
|
||||
from repositories.plugin_file_upload_repository import SQLAlchemyPluginFileUploadOwnerRepository
|
||||
from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository
|
||||
@@ -66,6 +67,7 @@ from services.account_oauth_adapters import (
|
||||
DeploymentOAuthPolicyGateway,
|
||||
RedisOAuthAccountClaimLock,
|
||||
)
|
||||
from services.app.api_key_service import AppApiKeyService
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.app_preview_query_service import AppPreviewRef, AppPreviewUnavailableError
|
||||
from services.app_scoped_end_user_query_service import AppScopedEndUserQueryService
|
||||
@@ -84,7 +86,11 @@ from services.file_service import FileService
|
||||
from services.human_input_file_upload_service import HumanInputFileUploadService
|
||||
from services.init_validation_service import InvalidInitializationPasswordError
|
||||
from services.installed_app_access_service import InstalledAppAccessDeniedError, InstalledAppRef
|
||||
from services.installed_app_generation_adapters import AppGenerateServiceRuntime as InstalledAppGenerateServiceRuntime
|
||||
from services.installed_app_generation_service import InstalledAppGenerationService
|
||||
from services.knowledge.api_key_service import DatasetApiKeyService
|
||||
from services.message_file_preview_service import MessageFilePreviewService
|
||||
from services.oauth_device_application_service import OAuthDeviceApplicationService
|
||||
from services.partner_tenant_binding_service import PartnerTenantBindingService
|
||||
from services.plugin_file_upload_gateway import ToolFilePluginUploadGateway
|
||||
from services.plugin_file_upload_service import PluginFileUploadService
|
||||
@@ -177,6 +183,33 @@ def test_init_app_registers_services_for_the_current_app(
|
||||
assert isinstance(services.workflow_statistics, WorkflowStatisticQueryService)
|
||||
|
||||
|
||||
def test_build_application_services_preserves_composed_boundaries(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
redis = MagicMock(spec=RedisClientWrapper)
|
||||
|
||||
services = ext_application_services.build_application_services(
|
||||
database_client=sqlite_session_factory,
|
||||
deployment_edition=DeploymentEdition.COMMUNITY,
|
||||
initialization_password="",
|
||||
redis=redis,
|
||||
)
|
||||
|
||||
assert isinstance(services.app_api_keys, AppApiKeyService)
|
||||
assert isinstance(services.dataset_api_keys, DatasetApiKeyService)
|
||||
assert isinstance(services.oauth_device, OAuthDeviceApplicationService)
|
||||
assert redis.register_script.call_count == 3
|
||||
|
||||
assert isinstance(services.installed_app_generation, InstalledAppGenerationService)
|
||||
installed_apps = services.installed_app_access._installed_apps
|
||||
assert isinstance(installed_apps, SQLAlchemyInstalledAppRepository)
|
||||
assert services.installed_app_generation._usage is installed_apps
|
||||
assert services.installed_app_generation._app_definitions is services.app_definitions
|
||||
runtime = services.installed_app_generation._runtime
|
||||
assert isinstance(runtime, InstalledAppGenerateServiceRuntime)
|
||||
assert runtime._session_factory is sqlite_session_factory
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("deployment_edition", "setup_completed"),
|
||||
[
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Real persistence checks for the resource API key boundary."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, App, AppMode
|
||||
from repositories.app.api_key_repository import AppApiKeyRepository
|
||||
from services.auth.api_key_contracts import (
|
||||
ApiKeyLimitExceededError,
|
||||
ApiKeyNotFoundError,
|
||||
ApiKeyResourceNotFoundError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repository(sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]) -> AppApiKeyRepository:
|
||||
sqlite_session.add_all(
|
||||
[
|
||||
App(id="app", tenant_id="tenant", name="App", mode=AppMode.CHAT, enable_site=False, enable_api=True),
|
||||
App(
|
||||
id="other-app", tenant_id="tenant", name="Other", mode=AppMode.CHAT, enable_site=False, enable_api=True
|
||||
),
|
||||
]
|
||||
)
|
||||
sqlite_session.commit()
|
||||
return AppApiKeyRepository(session_factory=sqlite_session_factory)
|
||||
|
||||
|
||||
def test_app_key_list_and_delete_preserve_owner_chain(repository: AppApiKeyRepository, sqlite_session: Session) -> None:
|
||||
sqlite_session.add_all(
|
||||
[
|
||||
ApiToken(id="current", type=ApiTokenType.APP, token="current", tenant_id="tenant", app_id="app"),
|
||||
ApiToken(id="historical", type=ApiTokenType.APP, token="historical", tenant_id=None, app_id="app"),
|
||||
ApiToken(id="foreign", type=ApiTokenType.APP, token="foreign", tenant_id="foreign", app_id="app"),
|
||||
ApiToken(id="other", type=ApiTokenType.APP, token="other", tenant_id="tenant", app_id="other-app"),
|
||||
ApiToken(id="wrong-type", type=ApiTokenType.DATASET, token="dataset", tenant_id="tenant", app_id="app"),
|
||||
]
|
||||
)
|
||||
sqlite_session.commit()
|
||||
assert {key.id for key in repository.list_keys("tenant", "app")} == {"current", "historical"}
|
||||
for key_id in ["foreign", "other", "wrong-type", "missing"]:
|
||||
with pytest.raises(ApiKeyNotFoundError):
|
||||
repository.delete_key("tenant", "app", key_id)
|
||||
deleted = repository.delete_key("tenant", "app", "historical")
|
||||
assert deleted.token == "historical"
|
||||
assert sqlite_session.get(ApiToken, "historical") is None
|
||||
assert sqlite_session.get(ApiToken, "foreign") is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["list", "create", "delete"])
|
||||
def test_resource_must_belong_to_workspace(repository: AppApiKeyRepository, operation: str) -> None:
|
||||
def invoke() -> object:
|
||||
if operation == "list":
|
||||
return repository.list_keys("foreign", "app")
|
||||
if operation == "create":
|
||||
return repository.create_key("foreign", "app", max_keys=10, prefix="test-")
|
||||
return repository.delete_key("foreign", "app", "missing")
|
||||
|
||||
with pytest.raises(ApiKeyResourceNotFoundError):
|
||||
invoke()
|
||||
|
||||
|
||||
def test_creation_counts_historical_keys_but_not_foreign_keys(
|
||||
repository: AppApiKeyRepository,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
sqlite_session.add_all(
|
||||
[ApiToken(type=ApiTokenType.APP, token=f"foreign-{i}", tenant_id="foreign", app_id="app") for i in range(10)]
|
||||
)
|
||||
sqlite_session.add_all(
|
||||
[ApiToken(type=ApiTokenType.APP, token=f"historical-{i}", tenant_id=None, app_id="app") for i in range(9)]
|
||||
)
|
||||
sqlite_session.commit()
|
||||
|
||||
created = repository.create_key("tenant", "app", max_keys=10, prefix="app-")
|
||||
assert created.token.startswith("app-")
|
||||
assert len(created.token) == 28
|
||||
assert created.created_at is not None
|
||||
with pytest.raises(ApiKeyLimitExceededError):
|
||||
repository.create_key("tenant", "app", max_keys=10, prefix="app-")
|
||||
stored = sqlite_session.get(ApiToken, created.id)
|
||||
assert stored is not None
|
||||
assert (stored.tenant_id, stored.app_id, stored.type) == ("tenant", "app", ApiTokenType.APP)
|
||||
assert len(repository.list_keys("tenant", "app")) == 10
|
||||
Whitespace-only changes.
+5
-5
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for dataset API key scoping helpers."""
|
||||
"""Persistence tests for dataset API key scoping and deletion."""
|
||||
|
||||
import uuid
|
||||
|
||||
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, DatasetApiTokenBinding
|
||||
from services import dataset_api_key_service
|
||||
from repositories.knowledge import dataset_api_key_bindings
|
||||
|
||||
|
||||
def _dataset_token(session: Session) -> ApiToken:
|
||||
@@ -28,7 +28,7 @@ class TestDeleteKeysScopedOnlyTo:
|
||||
sqlite_session.add(DatasetApiTokenBinding(api_token_id=token.id, dataset_id=dataset_id))
|
||||
sqlite_session.commit()
|
||||
|
||||
deleted = dataset_api_key_service.delete_keys_scoped_only_to(sqlite_session, dataset_id)
|
||||
deleted = dataset_api_key_bindings.delete_keys_scoped_only_to(sqlite_session, dataset_id)
|
||||
sqlite_session.commit()
|
||||
|
||||
assert deleted == [token.id]
|
||||
@@ -42,7 +42,7 @@ class TestDeleteKeysScopedOnlyTo:
|
||||
sqlite_session.add(DatasetApiTokenBinding(api_token_id=token.id, dataset_id=other_dataset_id))
|
||||
sqlite_session.commit()
|
||||
|
||||
deleted = dataset_api_key_service.delete_keys_scoped_only_to(sqlite_session, dataset_id)
|
||||
deleted = dataset_api_key_bindings.delete_keys_scoped_only_to(sqlite_session, dataset_id)
|
||||
sqlite_session.commit()
|
||||
|
||||
# The key is bound elsewhere, so it survives and stays scoped.
|
||||
@@ -55,7 +55,7 @@ class TestDeleteKeysScopedOnlyTo:
|
||||
unbound = _dataset_token(sqlite_session)
|
||||
sqlite_session.commit()
|
||||
|
||||
deleted = dataset_api_key_service.delete_keys_scoped_only_to(sqlite_session, dataset_id)
|
||||
deleted = dataset_api_key_bindings.delete_keys_scoped_only_to(sqlite_session, dataset_id)
|
||||
sqlite_session.commit()
|
||||
|
||||
assert deleted == []
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Real persistence checks for the resource API key boundary."""
|
||||
|
||||
from functools import partial
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.account import Tenant
|
||||
from models.dataset import Dataset
|
||||
from models.enums import ApiTokenType
|
||||
from models.model import ApiToken, DatasetApiTokenBinding
|
||||
from repositories.knowledge import dataset_api_key_bindings
|
||||
from repositories.knowledge.dataset_api_key_repository import DatasetApiKeyRepository
|
||||
from services.auth.api_key_contracts import (
|
||||
ApiKeyLimitExceededError,
|
||||
ApiKeyNotFoundError,
|
||||
ApiKeyResourceNotFoundError,
|
||||
)
|
||||
from services.knowledge.api_key_service import UnknownDatasetIdsError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repository(sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]) -> DatasetApiKeyRepository:
|
||||
tenant = Tenant(name="Workspace")
|
||||
tenant.id = "tenant"
|
||||
sqlite_session.add(tenant)
|
||||
sqlite_session.add_all(
|
||||
[
|
||||
Dataset(id="dataset", tenant_id="tenant", name="Dataset", created_by="actor"),
|
||||
Dataset(id="other-dataset", tenant_id="tenant", name="Other dataset", created_by="actor"),
|
||||
]
|
||||
)
|
||||
sqlite_session.commit()
|
||||
return DatasetApiKeyRepository(session_factory=sqlite_session_factory)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["list", "create", "delete"])
|
||||
def test_resource_must_belong_to_workspace(repository: DatasetApiKeyRepository, operation: str) -> None:
|
||||
def invoke() -> object:
|
||||
if operation == "list":
|
||||
return repository.list_keys("foreign", "dataset")
|
||||
if operation == "create":
|
||||
return repository.create_key("foreign", "dataset", max_keys=10, prefix="test-")
|
||||
return repository.delete_key("foreign", "dataset", "missing")
|
||||
|
||||
with pytest.raises(ApiKeyResourceNotFoundError):
|
||||
invoke()
|
||||
|
||||
|
||||
def test_dataset_keys_use_persisted_bindings_and_cannot_manage_broader_scopes(
|
||||
repository: DatasetApiKeyRepository,
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
created = repository.create_key("tenant", "dataset", max_keys=10, prefix="ds-")
|
||||
assert created.dataset_ids == ("dataset",)
|
||||
assert created.token.startswith("ds-")
|
||||
assert (
|
||||
sqlite_session.scalar(
|
||||
select(DatasetApiTokenBinding.dataset_id).where(DatasetApiTokenBinding.api_token_id == created.id)
|
||||
)
|
||||
== "dataset"
|
||||
)
|
||||
scopes: list[tuple[str, list[str], str]] = [
|
||||
("unrestricted", [], "tenant"),
|
||||
("multiple", ["dataset", "other-dataset"], "tenant"),
|
||||
("foreign", ["dataset"], "foreign"),
|
||||
("other", ["other-dataset"], "tenant"),
|
||||
]
|
||||
for key_id, dataset_ids, tenant_id in scopes:
|
||||
sqlite_session.add(ApiToken(id=key_id, type=ApiTokenType.DATASET, token=key_id, tenant_id=tenant_id))
|
||||
sqlite_session.add_all(DatasetApiTokenBinding(api_token_id=key_id, dataset_id=value) for value in dataset_ids)
|
||||
sqlite_session.commit()
|
||||
|
||||
assert repository.list_keys("tenant", "dataset") == (created,)
|
||||
for key_id in ["unrestricted", "multiple", "foreign", "other"]:
|
||||
with pytest.raises(ApiKeyNotFoundError):
|
||||
repository.delete_key("tenant", "dataset", key_id)
|
||||
with pytest.raises(ApiKeyLimitExceededError):
|
||||
repository.create_key("tenant", "dataset", max_keys=1, prefix="ds-")
|
||||
repository.delete_key("tenant", "dataset", created.id)
|
||||
assert repository.list_keys("tenant", "dataset") == ()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("workspace", [False, True])
|
||||
def test_failed_binding_rolls_back_the_new_token(
|
||||
repository: DatasetApiKeyRepository, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch, workspace: bool
|
||||
) -> None:
|
||||
def fail_binding(_session: Session, _api_token_id: str, _dataset_ids: object) -> None:
|
||||
raise RuntimeError("binding write failed")
|
||||
|
||||
monkeypatch.setattr(dataset_api_key_bindings, "bind_datasets", fail_binding)
|
||||
create_key = (
|
||||
partial(repository.create_workspace_key, "tenant", ("dataset",), max_keys=10, prefix="dataset-")
|
||||
if workspace
|
||||
else partial(repository.create_key, "tenant", "dataset", max_keys=10, prefix="ds-")
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="binding write failed"):
|
||||
create_key()
|
||||
|
||||
assert sqlite_session.scalar(select(ApiToken)) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scope", [(), ("other-dataset",), ("dataset", "other-dataset")])
|
||||
@pytest.mark.parametrize("workspace_creates_last", [False, True])
|
||||
def test_both_scopes_share_the_workspace_quota(
|
||||
repository: DatasetApiKeyRepository,
|
||||
sqlite_session: Session,
|
||||
scope: tuple[str, ...],
|
||||
workspace_creates_last: bool,
|
||||
) -> None:
|
||||
# Neither another tenant's keys nor app keys consume the dataset quota.
|
||||
sqlite_session.add_all(
|
||||
[ApiToken(tenant_id="foreign", type=ApiTokenType.DATASET, token=f"foreign-{i}") for i in range(10)]
|
||||
+ [ApiToken(tenant_id="tenant", type=ApiTokenType.APP, token=f"app-{i}") for i in range(10)]
|
||||
)
|
||||
sqlite_session.commit()
|
||||
for _ in range(9):
|
||||
repository.create_workspace_key("tenant", scope, max_keys=10, prefix="dataset-")
|
||||
|
||||
if workspace_creates_last:
|
||||
repository.create_workspace_key("tenant", scope, max_keys=10, prefix="dataset-")
|
||||
else:
|
||||
repository.create_key("tenant", "dataset", max_keys=10, prefix="ds-")
|
||||
|
||||
with pytest.raises(ApiKeyLimitExceededError):
|
||||
repository.create_key("tenant", "dataset", max_keys=10, prefix="ds-")
|
||||
with pytest.raises(ApiKeyLimitExceededError):
|
||||
repository.create_workspace_key("tenant", (), max_keys=10, prefix="dataset-")
|
||||
assert len(repository.list_workspace_keys("tenant")) == 10
|
||||
|
||||
|
||||
def test_workspace_operations_preserve_scope_and_tenant(
|
||||
repository: DatasetApiKeyRepository, sqlite_session: Session
|
||||
) -> None:
|
||||
sqlite_session.add(Dataset(id="foreign-dataset", tenant_id="foreign", name="Foreign", created_by="actor"))
|
||||
sqlite_session.commit()
|
||||
with pytest.raises(UnknownDatasetIdsError):
|
||||
repository.create_workspace_key("tenant", ("dataset", "foreign-dataset"), max_keys=10, prefix="dataset-")
|
||||
assert sqlite_session.scalar(select(ApiToken)) is None
|
||||
|
||||
key = repository.create_workspace_key("tenant", ("dataset", "other-dataset"), max_keys=10, prefix="dataset-")
|
||||
listed = repository.list_workspace_keys("tenant")
|
||||
assert len(listed) == 1
|
||||
assert set(listed[0].dataset_ids) == {"dataset", "other-dataset"}
|
||||
assert repository.list_workspace_keys("foreign") == ()
|
||||
with pytest.raises(ApiKeyNotFoundError):
|
||||
repository.delete_workspace_key("foreign", key.id)
|
||||
repository.delete_workspace_key("tenant", key.id)
|
||||
assert repository.list_workspace_keys("tenant") == ()
|
||||
@@ -0,0 +1,204 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from extensions.application_services.app import build_app_api_key_service
|
||||
from machinery.context import RequestContext
|
||||
from models.agent import Agent, AgentConfigRevision, AgentConfigRevisionOperation, AgentScope, AgentSource, AgentStatus
|
||||
from models.model import ApiToken, App, AppMode
|
||||
from repositories.app.api_key_repository import AppApiKeyRepository
|
||||
from services.app.api_key_service import AppApiKeyNotReadyError, AppApiKeyService
|
||||
from services.auth.api_key_contracts import ApiKeyNotFoundError, ApiKeyResourceNotFoundError
|
||||
|
||||
|
||||
@dataclass
|
||||
class ObservingCache:
|
||||
session_factory: sessionmaker[Session]
|
||||
calls: list[tuple[str, str | None]] = field(default_factory=list)
|
||||
|
||||
def delete(self, token: str, scope: str | None = None) -> bool:
|
||||
# A fresh connection must see the deletion before any external cache I/O.
|
||||
with self.session_factory() as session:
|
||||
assert session.scalar(select(ApiToken).where(ApiToken.token == token)) is None
|
||||
self.calls.append((token, scope))
|
||||
return True
|
||||
|
||||
|
||||
def test_deletion_invalidates_cache_after_commit_and_not_on_failure(
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
sqlite_session.add(
|
||||
App(id="app", tenant_id="tenant", name="App", mode=AppMode.CHAT, enable_site=False, enable_api=True)
|
||||
)
|
||||
sqlite_session.commit()
|
||||
context = RequestContext("request", None, "actor", "tenant")
|
||||
cache = ObservingCache(sqlite_session_factory)
|
||||
service = AppApiKeyService(
|
||||
keys=AppApiKeyRepository(session_factory=sqlite_session_factory),
|
||||
cache=cache,
|
||||
)
|
||||
key = service.create_key(context, "app")
|
||||
|
||||
with pytest.raises(ApiKeyNotFoundError):
|
||||
service.delete_key(context, "app", "missing")
|
||||
assert cache.calls == []
|
||||
|
||||
service.delete_key(context, "app", key.id)
|
||||
assert cache.calls == [(key.token, key.type)]
|
||||
assert service.list_keys(context, "app") == ()
|
||||
|
||||
|
||||
def test_composition_builds_working_api_key_service(
|
||||
sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]
|
||||
) -> None:
|
||||
sqlite_session.add(
|
||||
App(id="app", tenant_id="tenant", name="App", mode=AppMode.CHAT, enable_site=False, enable_api=True)
|
||||
)
|
||||
sqlite_session.commit()
|
||||
service = build_app_api_key_service(database_client=sqlite_session_factory)
|
||||
context = RequestContext("request", None, "actor", "tenant")
|
||||
|
||||
key = service.create_key(context, "app")
|
||||
assert service.list_keys(context, "app") == (key,)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]) -> AppApiKeyService:
|
||||
sqlite_session.add(
|
||||
App(id="app", tenant_id="tenant", name="App", mode=AppMode.CHAT, enable_site=False, enable_api=True)
|
||||
)
|
||||
sqlite_session.commit()
|
||||
return build_app_api_key_service(database_client=sqlite_session_factory)
|
||||
|
||||
|
||||
def _persist_agent(session: Session) -> Agent:
|
||||
app = session.get(App, "app")
|
||||
assert app is not None
|
||||
app.mode = AppMode.AGENT
|
||||
agent = Agent(
|
||||
id="agent",
|
||||
tenant_id="tenant",
|
||||
name="Agent",
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
app_id="app",
|
||||
)
|
||||
session.add(agent)
|
||||
session.commit()
|
||||
return agent
|
||||
|
||||
|
||||
def test_app_access_check_rejects_foreign_workspace(service: AppApiKeyService, sqlite_session: Session) -> None:
|
||||
context = RequestContext("request", None, "actor", "foreign")
|
||||
|
||||
with pytest.raises(ApiKeyResourceNotFoundError, match="App not found"):
|
||||
service.create_key(context, "app")
|
||||
|
||||
assert sqlite_session.scalar(select(ApiToken)) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", [AgentSource.AGENT_APP, AgentSource.IMPORTED])
|
||||
def test_agent_requires_publish_visible_version_for_creation(
|
||||
service: AppApiKeyService,
|
||||
sqlite_session: Session,
|
||||
source: AgentSource,
|
||||
) -> None:
|
||||
context = RequestContext("request", None, "actor", "tenant")
|
||||
agent = _persist_agent(sqlite_session)
|
||||
agent.source = source
|
||||
sqlite_session.commit()
|
||||
with pytest.raises(AppApiKeyNotReadyError):
|
||||
service.create_key(context, "app")
|
||||
with pytest.raises(AppApiKeyNotReadyError):
|
||||
service.create_agent_key(context, "agent")
|
||||
assert sqlite_session.scalar(select(ApiToken)) is None
|
||||
|
||||
agent.active_config_snapshot_id = "snapshot"
|
||||
agent.active_config_is_published = False # New draft edits must not revoke a published version.
|
||||
sqlite_session.add(
|
||||
AgentConfigRevision(
|
||||
tenant_id="tenant",
|
||||
agent_id=agent.id,
|
||||
current_snapshot_id="snapshot",
|
||||
revision=1,
|
||||
operation=AgentConfigRevisionOperation.PUBLISH_DRAFT,
|
||||
)
|
||||
)
|
||||
sqlite_session.commit()
|
||||
created = service.create_agent_key(context, "agent")
|
||||
assert service.list_agent_keys(context, "agent") == (created,)
|
||||
assert service.list_keys(context, "app") == (created,)
|
||||
|
||||
agent.active_config_snapshot_id = None
|
||||
sqlite_session.commit()
|
||||
assert service.list_agent_keys(context, "agent") == (created,)
|
||||
service.delete_agent_key(context, "agent", created.id)
|
||||
assert service.list_agent_keys(context, "agent") == ()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_revision", ["tenant", "agent", "snapshot", "unpublished"])
|
||||
def test_creation_requires_a_publish_revision_for_the_owned_active_snapshot(
|
||||
service: AppApiKeyService, sqlite_session: Session, invalid_revision: str
|
||||
) -> None:
|
||||
context = RequestContext("request", None, "actor", "tenant")
|
||||
agent = _persist_agent(sqlite_session)
|
||||
agent.active_config_snapshot_id = "snapshot"
|
||||
agent.active_config_is_published = True
|
||||
revision = AgentConfigRevision(
|
||||
tenant_id="tenant",
|
||||
agent_id="agent",
|
||||
current_snapshot_id="snapshot",
|
||||
revision=1,
|
||||
operation=AgentConfigRevisionOperation.PUBLISH_DRAFT,
|
||||
)
|
||||
match invalid_revision:
|
||||
case "tenant":
|
||||
revision.tenant_id = "foreign"
|
||||
case "agent":
|
||||
revision.agent_id = "other-agent"
|
||||
case "snapshot":
|
||||
revision.current_snapshot_id = "old-snapshot"
|
||||
case "unpublished":
|
||||
revision.operation = AgentConfigRevisionOperation.IMPORT_PACKAGE
|
||||
sqlite_session.add(revision)
|
||||
sqlite_session.commit()
|
||||
|
||||
with pytest.raises(AppApiKeyNotReadyError):
|
||||
service.create_key(context, "app")
|
||||
with pytest.raises(AppApiKeyNotReadyError):
|
||||
service.create_agent_key(context, "agent")
|
||||
assert sqlite_session.scalar(select(ApiToken)) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_state", ["tenant", "archived", "scope", "source", "app-tenant", "missing-app", "app-mode"]
|
||||
)
|
||||
def test_agent_resolution_checks_entire_owner_chain(
|
||||
service: AppApiKeyService, sqlite_session: Session, invalid_state: str
|
||||
) -> None:
|
||||
context = RequestContext("request", None, "actor", "tenant")
|
||||
agent = _persist_agent(sqlite_session)
|
||||
app = sqlite_session.get(App, "app")
|
||||
assert app is not None
|
||||
match invalid_state:
|
||||
case "tenant":
|
||||
agent.tenant_id = "foreign"
|
||||
case "archived":
|
||||
agent.status = AgentStatus.ARCHIVED
|
||||
case "scope":
|
||||
agent.scope = AgentScope.WORKFLOW_ONLY
|
||||
case "source":
|
||||
agent.source = AgentSource.ROSTER
|
||||
case "app-tenant":
|
||||
app.tenant_id = "foreign"
|
||||
case "missing-app":
|
||||
agent.app_id = "missing"
|
||||
case "app-mode":
|
||||
app.mode = AppMode.CHAT
|
||||
sqlite_session.commit()
|
||||
with pytest.raises(ApiKeyResourceNotFoundError, match="Agent not found"):
|
||||
service.list_agent_keys(context, "agent")
|
||||
Whitespace-only changes.
@@ -0,0 +1,154 @@
|
||||
"""Knowledge key composition and post-commit cache invalidation."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from extensions.application_services.knowledge import build_dataset_api_key_service
|
||||
from machinery.context import RequestContext
|
||||
from models.account import Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.dataset import Dataset, DatasetPermission
|
||||
from models.enums import PermissionEnum
|
||||
from models.model import ApiToken
|
||||
from services.api_token_service import ApiTokenCache
|
||||
from services.auth.api_key_contracts import ApiKeyNotFoundError
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.knowledge.api_key_service import DatasetApiKeyService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(
|
||||
sqlite_session: Session,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
config_overrides: Callable[..., None],
|
||||
) -> DatasetApiKeyService:
|
||||
config_overrides(RBAC_ENABLED=False)
|
||||
tenant = Tenant(name="Workspace")
|
||||
tenant.id = "tenant"
|
||||
sqlite_session.add_all(
|
||||
[
|
||||
tenant,
|
||||
TenantAccountJoin(tenant_id="tenant", account_id="actor", role=TenantAccountRole.OWNER),
|
||||
Dataset(id="dataset", tenant_id="tenant", name="Dataset", created_by="actor", maintainer="maintainer"),
|
||||
]
|
||||
)
|
||||
sqlite_session.commit()
|
||||
return build_dataset_api_key_service(database_client=sqlite_session_factory)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("workspace", [False, True])
|
||||
def test_dataset_keys_commit_before_invalidating_cache(
|
||||
service: DatasetApiKeyService,
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
workspace: bool,
|
||||
) -> None:
|
||||
calls: list[tuple[str, str | None]] = []
|
||||
|
||||
def invalidate(token: str, scope: str | None = None) -> bool:
|
||||
with sqlite_session_factory() as session:
|
||||
assert session.scalar(select(ApiToken).where(ApiToken.token == token)) is None
|
||||
calls.append((token, scope))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(ApiTokenCache, "delete", invalidate)
|
||||
context = RequestContext("request", None, "actor", "tenant")
|
||||
|
||||
key = (
|
||||
service.create_workspace_key(context, ("dataset", "dataset"))
|
||||
if workspace
|
||||
else service.create_key(context, "dataset")
|
||||
)
|
||||
assert key.token.startswith("dataset-" if workspace else "ds-")
|
||||
assert key.dataset_ids == ("dataset",)
|
||||
assert service.list_keys(context, "dataset") == (key,)
|
||||
|
||||
delete_key = (
|
||||
partial(service.delete_workspace_key, context) if workspace else partial(service.delete_key, context, "dataset")
|
||||
)
|
||||
with pytest.raises(ApiKeyNotFoundError):
|
||||
delete_key("missing")
|
||||
assert calls == []
|
||||
|
||||
delete_key(key.id)
|
||||
assert calls == [(key.token, key.type)]
|
||||
assert service.list_keys(context, "dataset") == ()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("permission", "role", "maintainer", "grant", "allowed"),
|
||||
[
|
||||
(PermissionEnum.ONLY_ME, TenantAccountRole.EDITOR, "other", None, False),
|
||||
(PermissionEnum.ONLY_ME, TenantAccountRole.ADMIN, "other", None, False),
|
||||
(PermissionEnum.ONLY_ME, TenantAccountRole.OWNER, "other", None, True),
|
||||
(PermissionEnum.ONLY_ME, TenantAccountRole.EDITOR, "actor", None, True),
|
||||
(PermissionEnum.PARTIAL_TEAM, TenantAccountRole.EDITOR, "other", None, False),
|
||||
(PermissionEnum.PARTIAL_TEAM, TenantAccountRole.EDITOR, "other", "allowed", True),
|
||||
(PermissionEnum.PARTIAL_TEAM, TenantAccountRole.EDITOR, "actor", None, True),
|
||||
(PermissionEnum.PARTIAL_TEAM, TenantAccountRole.EDITOR, "other", "revoked", False),
|
||||
(PermissionEnum.PARTIAL_TEAM, TenantAccountRole.EDITOR, "other", "foreign", False),
|
||||
(PermissionEnum.ALL_TEAM, TenantAccountRole.EDITOR, "other", None, True),
|
||||
],
|
||||
)
|
||||
def test_dataset_acl_controls_key_management(
|
||||
service: DatasetApiKeyService,
|
||||
sqlite_session: Session,
|
||||
permission: PermissionEnum,
|
||||
role: TenantAccountRole,
|
||||
maintainer: str,
|
||||
grant: str | None,
|
||||
allowed: bool,
|
||||
) -> None:
|
||||
dataset = sqlite_session.get(Dataset, "dataset")
|
||||
member = sqlite_session.scalar(select(TenantAccountJoin))
|
||||
assert dataset is not None
|
||||
assert member is not None
|
||||
dataset.permission = permission
|
||||
dataset.maintainer = maintainer
|
||||
member.role = role
|
||||
if grant:
|
||||
sqlite_session.add(
|
||||
DatasetPermission(
|
||||
dataset_id="dataset",
|
||||
account_id="actor",
|
||||
tenant_id="foreign" if grant == "foreign" else "tenant",
|
||||
has_permission=grant != "revoked",
|
||||
)
|
||||
)
|
||||
sqlite_session.commit()
|
||||
context = RequestContext("request", None, "actor", "tenant")
|
||||
|
||||
if allowed:
|
||||
key = service.create_key(context, "dataset")
|
||||
assert service.list_keys(context, "dataset") == (key,)
|
||||
else:
|
||||
with pytest.raises(NoPermissionError):
|
||||
service.create_key(context, "dataset")
|
||||
with pytest.raises(NoPermissionError):
|
||||
service.list_keys(context, "dataset")
|
||||
with pytest.raises(NoPermissionError):
|
||||
service.delete_key(context, "dataset", "missing")
|
||||
assert sqlite_session.scalar(select(ApiToken)) is None
|
||||
|
||||
|
||||
def test_acl_requires_membership_in_the_active_workspace(
|
||||
service: DatasetApiKeyService, sqlite_session: Session
|
||||
) -> None:
|
||||
member = sqlite_session.scalar(select(TenantAccountJoin))
|
||||
assert member is not None
|
||||
member.tenant_id = "foreign"
|
||||
sqlite_session.commit()
|
||||
with pytest.raises(NoPermissionError):
|
||||
service.create_key(RequestContext("request", None, "actor", "tenant"), "dataset")
|
||||
assert sqlite_session.scalar(select(ApiToken)) is None
|
||||
|
||||
|
||||
def test_rbac_admitted_request_uses_rbac_instead_of_community_acl(
|
||||
service: DatasetApiKeyService, config_overrides: Callable[..., None]
|
||||
) -> None:
|
||||
config_overrides(RBAC_ENABLED=True)
|
||||
key = service.create_key(RequestContext("request", None, "rbac-actor", "tenant"), "dataset")
|
||||
assert key.dataset_ids == ("dataset",)
|
||||
@@ -418,9 +418,8 @@ def test_unpublished_agent_app_access_cannot_be_enabled(
|
||||
commits: list[str] = []
|
||||
event.listen(sqlite_session, "after_commit", lambda _session: commits.append("commit"))
|
||||
|
||||
with patch("services.app_service.agent_has_workflow_callable_active_snapshot", return_value=False):
|
||||
with pytest.raises(AgentAccessNotReadyError):
|
||||
update_status(AppService(), app, True, session=sqlite_session)
|
||||
with pytest.raises(AgentAccessNotReadyError):
|
||||
update_status(AppService(), app, True, session=sqlite_session)
|
||||
|
||||
assert app.enable_site is False
|
||||
assert app.enable_api is False
|
||||
|
||||
@@ -4498,8 +4498,6 @@ export const byAppId2 = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an API key for an app
|
||||
*
|
||||
* Delete an API key for an app
|
||||
*/
|
||||
export const delete16 = oc
|
||||
@@ -4510,7 +4508,6 @@ export const delete16 = oc
|
||||
operationId: 'deleteAppsByResourceIdApiKeysByApiKeyId',
|
||||
path: '/apps/{resource_id}/api-keys/{api_key_id}',
|
||||
successStatus: 204,
|
||||
summary: 'Delete an API key for an app',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zDeleteAppsByResourceIdApiKeysByApiKeyIdPath }))
|
||||
@@ -4521,8 +4518,6 @@ export const byApiKeyId = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all API keys for an app
|
||||
*
|
||||
* Get all API keys for an app
|
||||
*/
|
||||
export const get85 = oc
|
||||
@@ -4532,15 +4527,12 @@ export const get85 = oc
|
||||
method: 'GET',
|
||||
operationId: 'getAppsByResourceIdApiKeys',
|
||||
path: '/apps/{resource_id}/api-keys',
|
||||
summary: 'Get all API keys for an app',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zGetAppsByResourceIdApiKeysPath }))
|
||||
.output(zGetAppsByResourceIdApiKeysResponse)
|
||||
|
||||
/**
|
||||
* Create a new API key for an app
|
||||
*
|
||||
* Create a new API key for an app
|
||||
*/
|
||||
export const post63 = oc
|
||||
@@ -4551,7 +4543,6 @@ export const post63 = oc
|
||||
operationId: 'postAppsByResourceIdApiKeys',
|
||||
path: '/apps/{resource_id}/api-keys',
|
||||
successStatus: 201,
|
||||
summary: 'Create a new API key for an app',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zPostAppsByResourceIdApiKeysPath }))
|
||||
|
||||
@@ -1698,8 +1698,6 @@ export const byDatasetId = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an API key for a dataset
|
||||
*
|
||||
* Delete an API key for a dataset
|
||||
*/
|
||||
export const delete10 = oc
|
||||
@@ -1710,7 +1708,6 @@ export const delete10 = oc
|
||||
operationId: 'deleteDatasetsByResourceIdApiKeysByApiKeyId',
|
||||
path: '/datasets/{resource_id}/api-keys/{api_key_id}',
|
||||
successStatus: 204,
|
||||
summary: 'Delete an API key for a dataset',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zDeleteDatasetsByResourceIdApiKeysByApiKeyIdPath }))
|
||||
@@ -1721,8 +1718,6 @@ export const byApiKeyId2 = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all API keys for a dataset
|
||||
*
|
||||
* Get all API keys for a dataset
|
||||
*/
|
||||
export const get35 = oc
|
||||
@@ -1732,15 +1727,12 @@ export const get35 = oc
|
||||
method: 'GET',
|
||||
operationId: 'getDatasetsByResourceIdApiKeys',
|
||||
path: '/datasets/{resource_id}/api-keys',
|
||||
summary: 'Get all API keys for a dataset',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zGetDatasetsByResourceIdApiKeysPath }))
|
||||
.output(zGetDatasetsByResourceIdApiKeysResponse)
|
||||
|
||||
/**
|
||||
* Create a new API key for a dataset
|
||||
*
|
||||
* Create a new API key for a dataset
|
||||
*/
|
||||
export const post22 = oc
|
||||
@@ -1751,7 +1743,6 @@ export const post22 = oc
|
||||
operationId: 'postDatasetsByResourceIdApiKeys',
|
||||
path: '/datasets/{resource_id}/api-keys',
|
||||
successStatus: 201,
|
||||
summary: 'Create a new API key for a dataset',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ params: zPostDatasetsByResourceIdApiKeysPath }))
|
||||
|
||||
Reference in new issue
Block a user