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

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

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

127 lines
4.9 KiB
Python

"""Workspace Dataset API key authorization for KnowledgeFS Service API calls."""
from __future__ import annotations
from typing import NamedTuple
import sqlalchemy as sa
from sqlalchemy.orm import Session, sessionmaker
from models.enums import ApiTokenType
from models.knowledge_fs import (
KnowledgeFSAuthorizationRevision,
KnowledgeFSControlSpace,
KnowledgeFSControlSpaceState,
KnowledgeFSExternalAccessPolicy,
)
from models.model import ApiToken
from services import dataset_api_key_service
class KnowledgeFSServiceApiAuthorizationError(RuntimeError):
"""A Dataset API key cannot access the requested KnowledgeFS control space."""
class KnowledgeFSServiceApiScopeError(KnowledgeFSServiceApiAuthorizationError):
"""A valid Dataset API key is bound to other knowledge bases than the requested space.
Distinct from the base error so the Service API can answer 403 (the credential is
real but out of scope) instead of 401 (unknown credential).
"""
class KnowledgeFSServiceApiProfile(NamedTuple):
tenant_id: str
control_space_id: str
api_token_id: str
principal_id: str
knowledge_space_id: str
knowledge_space_revision: int
membership_epoch: int
space_acl_epoch: int
external_access_epoch: int
content_policy_revision: int
class KnowledgeFSServiceApiAuthorizationService:
def __init__(self, session_maker: sessionmaker[Session]) -> None:
self._session_maker = session_maker
def authorize(
self,
*,
api_token_id: str,
tenant_id: str,
control_space_id: str,
) -> KnowledgeFSServiceApiProfile:
with self._session_maker() as session:
row = session.execute(
sa.select(
ApiToken,
KnowledgeFSControlSpace,
KnowledgeFSExternalAccessPolicy,
KnowledgeFSAuthorizationRevision,
)
.join(
KnowledgeFSControlSpace,
KnowledgeFSControlSpace.tenant_id == ApiToken.tenant_id,
)
.outerjoin(
KnowledgeFSExternalAccessPolicy,
sa.and_(
KnowledgeFSExternalAccessPolicy.tenant_id == KnowledgeFSControlSpace.tenant_id,
KnowledgeFSExternalAccessPolicy.control_space_id == KnowledgeFSControlSpace.id,
),
)
.join(
KnowledgeFSAuthorizationRevision,
sa.and_(
KnowledgeFSAuthorizationRevision.tenant_id == KnowledgeFSControlSpace.tenant_id,
KnowledgeFSAuthorizationRevision.control_space_id == KnowledgeFSControlSpace.id,
),
)
.where(
ApiToken.id == api_token_id,
ApiToken.tenant_id == tenant_id,
ApiToken.type == ApiTokenType.DATASET,
KnowledgeFSControlSpace.id == control_space_id,
)
).one_or_none()
if row is None:
raise KnowledgeFSServiceApiAuthorizationError("Invalid Dataset API key or KnowledgeFS space")
# Per-knowledge-base scoping (DatasetApiTokenBinding): an unbound key reaches every
# space in its tenant; a bound key only the KnowledgeFS spaces it is bound to.
# Legacy dataset bindings never grant access here because the two knowledge base
# kinds live in different tables.
scope = dataset_api_key_service.get_key_scope(session, api_token_id)
if not scope.allows_knowledge_space(control_space_id):
raise KnowledgeFSServiceApiScopeError("Dataset API key is not authorized for this KnowledgeFS space")
api_token, control_space, policy, revision = row._t
if (
control_space.state is not KnowledgeFSControlSpaceState.ACTIVE
or control_space.knowledge_space_id is None
or policy is None
or not policy.service_api_enabled
):
raise KnowledgeFSServiceApiAuthorizationError("KnowledgeFS Service API access is not enabled")
return KnowledgeFSServiceApiProfile(
tenant_id=tenant_id,
control_space_id=control_space_id,
api_token_id=api_token.id,
principal_id=api_token.id,
knowledge_space_id=control_space.knowledge_space_id,
knowledge_space_revision=control_space.knowledge_space_revision,
membership_epoch=revision.membership_epoch,
space_acl_epoch=revision.space_acl_epoch,
external_access_epoch=revision.external_access_epoch,
content_policy_revision=revision.content_policy_revision,
)
__all__ = [
"KnowledgeFSServiceApiAuthorizationError",
"KnowledgeFSServiceApiAuthorizationService",
"KnowledgeFSServiceApiProfile",
"KnowledgeFSServiceApiScopeError",
]