dify/api/services/dataset_api_key_service.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

262 lines
11 KiB
Python

"""Persistence helpers for dataset (knowledge base) API key scoping.
A dataset API key is scoped to specific knowledge bases through
``DatasetApiTokenBinding`` rows. Legacy knowledge bases (``datasets``) and
KnowledgeFS spaces (``knowledge_fs_control_spaces``) live in different tables, so
every row records which kind of resource it points at:
- ``resource_type = "dataset"`` -> ``dataset_id`` (legacy ``Dataset``)
- ``resource_type = "knowledge_fs_space"`` -> ``control_space_id`` (KnowledgeFS space)
Scope semantics:
- no rows -> the key can access every knowledge base in its tenant (default / back-compat)
- N rows -> the key is restricted to exactly those resources. Each service-API surface
only honours bindings of its own kind: the legacy dataset endpoints check
``dataset_id`` bindings and the KnowledgeFS endpoints check
``control_space_id`` bindings, so a key bound solely to one kind is
rejected by the other.
These helpers live in the service layer so controllers and the service-API auth
gates can share the queries without embedding SQLAlchemy in controller code.
The caller owns the transaction boundary (commit/flush).
"""
from collections.abc import Iterable
from dataclasses import dataclass, field
from sqlalchemy import delete, or_, select
from sqlalchemy.orm import Session
from models.dataset import Dataset
from models.enums import ApiTokenBindingResourceType
from models.knowledge_fs import KnowledgeFSControlSpace, KnowledgeFSControlSpaceState
from models.model import ApiToken, DatasetApiTokenBinding
_UNBINDABLE_SPACE_STATES = (KnowledgeFSControlSpaceState.DELETING, KnowledgeFSControlSpaceState.DELETED)
@dataclass(frozen=True)
class ApiKeyScope:
"""The knowledge bases a dataset API key may reach (empty = unrestricted)."""
dataset_ids: frozenset[str] = frozenset()
knowledge_space_ids: frozenset[str] = frozenset()
@property
def restricted(self) -> bool:
return bool(self.dataset_ids or self.knowledge_space_ids)
def allows_dataset(self, dataset_id: str | None) -> bool:
"""Legacy dataset endpoints: unrestricted keys pass; bound keys need a bound dataset id."""
if not self.restricted:
return True
return dataset_id is not None and str(dataset_id) in self.dataset_ids
def allows_knowledge_space(self, control_space_id: str | None) -> bool:
"""KnowledgeFS endpoints: unrestricted keys pass; bound keys need a bound space id."""
if not self.restricted:
return True
return control_space_id is not None and str(control_space_id) in self.knowledge_space_ids
@dataclass
class ApiKeyBindings:
"""Bound resource ids of one key, in insertion order, for console responses."""
dataset_ids: list[str] = field(default_factory=list)
knowledge_space_ids: list[str] = field(default_factory=list)
def _binding_columns():
return (
DatasetApiTokenBinding.api_token_id,
DatasetApiTokenBinding.resource_type,
DatasetApiTokenBinding.dataset_id,
DatasetApiTokenBinding.control_space_id,
)
def _classify(resource_type: object, dataset_id: object, control_space_id: object) -> tuple[str, str] | None:
"""Map one binding row to ``("dataset" | "knowledge_fs_space", resource_id)``."""
if resource_type == ApiTokenBindingResourceType.KNOWLEDGE_FS_SPACE:
if not control_space_id:
return None
return ApiTokenBindingResourceType.KNOWLEDGE_FS_SPACE.value, str(control_space_id)
if not dataset_id:
return None
return ApiTokenBindingResourceType.DATASET.value, str(dataset_id)
def get_key_scope(session: Session, api_token_id: str) -> ApiKeyScope:
"""Return the resources a key is scoped to (both empty means unrestricted)."""
dataset_ids: set[str] = set()
knowledge_space_ids: set[str] = set()
rows = session.execute(select(*_binding_columns()).where(DatasetApiTokenBinding.api_token_id == api_token_id)).all()
for _token_id, resource_type, dataset_id, control_space_id in rows:
classified = _classify(resource_type, dataset_id, control_space_id)
if classified is None:
continue
kind, resource_id = classified
if kind == ApiTokenBindingResourceType.KNOWLEDGE_FS_SPACE.value:
knowledge_space_ids.add(resource_id)
else:
dataset_ids.add(resource_id)
return ApiKeyScope(dataset_ids=frozenset(dataset_ids), knowledge_space_ids=frozenset(knowledge_space_ids))
def get_bound_dataset_ids(session: Session, api_token_id: str) -> set[str]:
"""Return the legacy dataset ids a key is scoped to.
Prefer :func:`get_key_scope`: an empty result here does not mean the key is
unrestricted, because it may still be bound to KnowledgeFS spaces.
"""
return set(get_key_scope(session, api_token_id).dataset_ids)
def list_bindings_by_token(session: Session, token_ids: Iterable[str]) -> dict[str, ApiKeyBindings]:
"""Group bound resource ids by api token id for the given tokens."""
ids = [str(token_id) for token_id in token_ids]
bindings_by_token: dict[str, ApiKeyBindings] = {}
if not ids:
return bindings_by_token
rows = session.execute(select(*_binding_columns()).where(DatasetApiTokenBinding.api_token_id.in_(ids))).all()
for token_id, resource_type, dataset_id, control_space_id in rows:
classified = _classify(resource_type, dataset_id, control_space_id)
if classified is None:
continue
kind, resource_id = classified
bindings = bindings_by_token.setdefault(str(token_id), ApiKeyBindings())
if kind == ApiTokenBindingResourceType.KNOWLEDGE_FS_SPACE.value:
bindings.knowledge_space_ids.append(resource_id)
else:
bindings.dataset_ids.append(resource_id)
return bindings_by_token
def find_unknown_dataset_ids(session: Session, dataset_ids: list[str], tenant_id: str) -> list[str]:
"""Return the dataset ids that do not belong to the tenant (preserving order)."""
if not dataset_ids:
return []
existing = {
str(dataset_id)
for dataset_id in session.scalars(
select(Dataset.id).where(Dataset.id.in_(dataset_ids), Dataset.tenant_id == tenant_id)
).all()
}
return [dataset_id for dataset_id in dataset_ids if dataset_id not in existing]
def find_unknown_knowledge_space_ids(session: Session, control_space_ids: list[str], tenant_id: str) -> list[str]:
"""Return the KnowledgeFS space ids that are not bindable in the tenant (preserving order).
A space that is being deleted or already deleted is treated as unknown so a new
key cannot be scoped to it.
"""
if not control_space_ids:
return []
existing = {
str(control_space_id)
for control_space_id in session.scalars(
select(KnowledgeFSControlSpace.id).where(
KnowledgeFSControlSpace.id.in_(control_space_ids),
KnowledgeFSControlSpace.tenant_id == tenant_id,
KnowledgeFSControlSpace.state.notin_(_UNBINDABLE_SPACE_STATES),
)
).all()
}
return [control_space_id for control_space_id in control_space_ids if control_space_id not in existing]
def bind_datasets(session: Session, api_token_id: str, dataset_ids: Iterable[str]) -> None:
"""Add one legacy-dataset binding row per dataset id. The caller controls the transaction."""
for dataset_id in dataset_ids:
session.add(
DatasetApiTokenBinding(
api_token_id=api_token_id,
resource_type=ApiTokenBindingResourceType.DATASET,
dataset_id=dataset_id,
)
)
def bind_knowledge_spaces(session: Session, api_token_id: str, control_space_ids: Iterable[str]) -> None:
"""Add one KnowledgeFS-space binding row per control space id. The caller controls the transaction."""
for control_space_id in control_space_ids:
session.add(
DatasetApiTokenBinding(
api_token_id=api_token_id,
resource_type=ApiTokenBindingResourceType.KNOWLEDGE_FS_SPACE,
control_space_id=control_space_id,
)
)
def _delete_keys_bound_only_to(session: Session, *, is_target_binding, is_other_binding) -> list[str]:
"""Delete keys whose every binding matches ``is_target_binding``.
``is_other_binding`` must select bindings that keep a key alive (any binding to a
different resource, of either kind). NULL-safe: a binding of the other kind has a
NULL id column for the target kind and must still count as "other".
"""
orphan_ids = [
str(token_id)
for token_id in session.scalars(
select(DatasetApiTokenBinding.api_token_id)
.where(is_target_binding)
.where(
DatasetApiTokenBinding.api_token_id.notin_(
select(DatasetApiTokenBinding.api_token_id).where(is_other_binding)
)
)
).all()
]
if orphan_ids:
session.execute(delete(ApiToken).where(ApiToken.id.in_(orphan_ids)))
return orphan_ids
def delete_keys_scoped_only_to(session: Session, dataset_id: str) -> list[str]:
"""Delete dataset API keys whose only binding is ``dataset_id``.
A deleted knowledge base cascades its bindings away. A key bound solely to it
would otherwise drop to zero bindings and silently become unrestricted
(access-all) under the "no bindings = access all" rule. To keep scope from
escalating, delete those keys (their remaining rows cascade with the token).
Keys also bound to other datasets or to KnowledgeFS spaces keep those bindings
and survive.
Must run before the dataset row is deleted (so the bindings still exist).
Returns the deleted api_token ids; the caller controls the transaction.
"""
return _delete_keys_bound_only_to(
session,
is_target_binding=DatasetApiTokenBinding.dataset_id == dataset_id,
is_other_binding=or_(
DatasetApiTokenBinding.dataset_id.is_(None),
DatasetApiTokenBinding.dataset_id != dataset_id,
),
)
def delete_keys_scoped_only_to_knowledge_space(session: Session, control_space_id: str) -> list[str]:
"""Delete dataset API keys whose only binding is the KnowledgeFS space ``control_space_id``.
KnowledgeFS spaces are soft-deleted (the control-space row survives in state
``deleted``), so their bindings never cascade away on their own. Keys bound
solely to the space are deleted for the same reason as
:func:`delete_keys_scoped_only_to`; the space's remaining bindings (on keys that
are also bound elsewhere) are removed so they stop counting toward the key's scope.
Returns the deleted api_token ids; the caller controls the transaction.
"""
orphan_ids = _delete_keys_bound_only_to(
session,
is_target_binding=DatasetApiTokenBinding.control_space_id == control_space_id,
is_other_binding=or_(
DatasetApiTokenBinding.control_space_id.is_(None),
DatasetApiTokenBinding.control_space_id != control_space_id,
),
)
session.execute(delete(DatasetApiTokenBinding).where(DatasetApiTokenBinding.control_space_id == control_space_id))
return orphan_ids