feat(dataset): expose New RAG KnowledgeFS contracts (#39314)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Stephen Zhou
2026-07-23 01:48:36 +00:00
committed by GitHub
co-authored by autofix-ci[bot]
parent 018fe7a9d3
commit bd8f0104d6
16 changed files with 8522 additions and 229 deletions
@@ -7,9 +7,11 @@ from unittest.mock import MagicMock
import httpx
import pytest
from flask import Flask, Response
from pydantic import SecretStr
from werkzeug.exceptions import (
BadGateway,
Forbidden,
HTTPException,
NotFound,
RequestEntityTooLarge,
ServiceUnavailable,
@@ -26,12 +28,14 @@ from controllers.console.knowledge_fs_proxy import (
proxy_knowledge_fs_write,
)
from controllers.console.wraps import RBACPermission
from services.knowledge_fs_proxy import (
KnowledgeFSAccessDeniedError,
KnowledgeFSConfigurationError,
from services.knowledge_fs_operations import (
KnowledgeFSMethod,
KnowledgeFSOperation,
KnowledgeFSResponseKind,
)
from services.knowledge_fs_proxy import (
KnowledgeFSAccessDeniedError,
KnowledgeFSConfigurationError,
KnowledgeFSRouteNotAllowedError,
KnowledgeFSUpstreamResponse,
get_knowledge_fs_operation,
@@ -47,6 +51,7 @@ def _upstream(
response: httpx.Response,
kind: KnowledgeFSResponseKind = "buffered",
*,
error_status_map: tuple[tuple[int, int], ...] = ((401, 502), (403, 403)),
max_response_bytes: int | None = None,
) -> KnowledgeFSUpstreamResponse:
operation = KnowledgeFSOperation(
@@ -56,7 +61,7 @@ def _upstream(
response_kind=kind,
required_scope="knowledge-spaces:read",
rbac_permission=RBACPermission.DATASET_READONLY,
requires_dataset_editor=False,
legacy_role="reader",
max_response_bytes=max_response_bytes
or (64 * 1024 * 1024 if kind == "stream" else 25 * 1024 * 1024 if kind == "binary" else 1024 * 1024),
request_headers=(),
@@ -67,6 +72,7 @@ def _upstream(
"x-session-id",
),
response_media_types=(),
error_status_map=error_status_map,
)
return KnowledgeFSUpstreamResponse(response, kind, operation)
@@ -93,7 +99,7 @@ def _set_current_workspace(
def _bypass_policy_wrappers(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"controllers.console.knowledge_fs_proxy._proxy_knowledge_fs_non_get",
unwrap(_proxy_knowledge_fs_non_get),
lambda method, path: _proxy_request(method, path),
)
@@ -287,10 +293,8 @@ def test_read_post_applies_knowledge_rate_limit_once(
monkeypatch.setattr("controllers.console.knowledge_fs_proxy.current_account_with_tenant", current_workspace)
monkeypatch.setattr("controllers.console.wraps.current_account_with_tenant", current_workspace)
monkeypatch.setattr(
"services.knowledge_fs_proxy.RBACService.CheckAccess.check",
MagicMock(return_value=True),
)
check_access = MagicMock(return_value=True)
monkeypatch.setattr("services.knowledge_fs_proxy.RBACService.CheckAccess.check", check_access)
monkeypatch.setattr(
"controllers.console.wraps.FeatureService.get_knowledge_rate_limit",
MagicMock(return_value=MagicMock(enabled=True, limit=10)),
@@ -300,14 +304,19 @@ def test_read_post_applies_knowledge_rate_limit_once(
monkeypatch.setattr("controllers.console.wraps.redis_client.zremrangebyscore", MagicMock())
monkeypatch.setattr("controllers.console.wraps.redis_client.zcard", MagicMock(return_value=1))
proxy = MagicMock(return_value=Response(status=200))
monkeypatch.setattr("controllers.console.knowledge_fs_proxy._proxy_request", proxy)
monkeypatch.setattr("controllers.console.knowledge_fs_proxy._proxy_authorized_request", proxy)
with app.test_request_context("/console/api/knowledge-fs/knowledge-spaces", method="POST"):
response = _proxy_knowledge_fs_non_get("POST", "knowledge-spaces")
assert isinstance(response, Response)
zadd.assert_called_once()
proxy.assert_called_once_with("POST", "knowledge-spaces")
proxy.assert_called_once()
authorization = proxy.call_args.args[0]
assert authorization.account_id == "account-1"
assert authorization.tenant_id == "tenant-1"
assert authorization.operation.operation_id == "createKnowledgeSpace"
check_access.assert_called_once()
def test_denied_write_does_not_consume_the_workspace_rate_limit(
@@ -447,6 +456,65 @@ def test_generic_write_forwards_path_raw_body_and_current_tenant(
assert response.get_json()["tenantId"] == "tenant-1"
def test_generic_write_forwards_through_the_authorized_production_path(
app: Flask,
monkeypatch: pytest.MonkeyPatch,
) -> None:
account = MagicMock(id="account-1", is_dataset_editor=True)
def current_workspace() -> tuple[MagicMock, str]:
return account, "tenant-1"
monkeypatch.setattr("controllers.console.knowledge_fs_proxy.current_account_with_tenant", current_workspace)
monkeypatch.setattr("controllers.console.wraps.current_account_with_tenant", current_workspace)
check_access = MagicMock(return_value=True)
monkeypatch.setattr("services.knowledge_fs_proxy.RBACService.CheckAccess.check", check_access)
monkeypatch.setattr(
"controllers.console.wraps.FeatureService.get_knowledge_rate_limit",
MagicMock(return_value=MagicMock(enabled=False)),
)
monkeypatch.setattr(
"services.knowledge_fs_proxy.dify_config.KNOWLEDGE_FS_BASE_URL",
"http://knowledge-fs.test",
raising=False,
)
monkeypatch.setattr(
"services.knowledge_fs_proxy.dify_config.KNOWLEDGE_FS_JWT_SECRET",
SecretStr("production-secret-with-at-least-32-bytes"),
raising=False,
)
upstream_request = MagicMock(
return_value=httpx.Response(
201,
content=b'{"id":"space-1","tenantId":"tenant-1"}',
headers={"Content-Type": "application/json"},
)
)
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", upstream_request)
route = unwrap(proxy_knowledge_fs_write)
body = b'{"idempotencyKey":"create-product-docs","name":"Product docs"}'
with app.test_request_context(
"/console/api/knowledge-fs/knowledge-spaces",
method="POST",
query_string={"source": "console"},
data=body,
content_type="application/json",
headers={"X-Trace-Id": "trace-1"},
):
response = route("knowledge-spaces")
assert isinstance(response, Response)
assert response.status_code == 201
assert response.get_json() == {"id": "space-1", "tenantId": "tenant-1"}
check_access.assert_called_once()
assert upstream_request.call_args.kwargs["method"] == "POST"
assert upstream_request.call_args.kwargs["url"] == "http://knowledge-fs.test/knowledge-spaces"
assert upstream_request.call_args.kwargs["params"] == b"source=console"
assert upstream_request.call_args.kwargs["content"] == body
assert upstream_request.call_args.kwargs["headers"]["x-trace-id"] == "trace-1"
def test_generic_write_forwards_contract_declared_request_headers(
app: Flask,
monkeypatch: pytest.MonkeyPatch,
@@ -617,6 +685,43 @@ def test_resource_authorization_rejection_is_exposed_as_forbidden(
route("knowledge-spaces")
def test_proxy_response_applies_operation_specific_error_status_mapping() -> None:
upstream = httpx.Response(
429,
content=b'{"error":"rate limited"}',
headers={"Content-Type": "application/json"},
)
with pytest.raises(ServiceUnavailable):
_proxy_response(
_upstream(upstream, error_status_map=((429, 503),)),
tenant_id="tenant-1",
contract_response_headers=(),
max_response_bytes=1024 * 1024,
)
assert upstream.is_closed
def test_proxy_response_preserves_nonstandard_mapped_error_status() -> None:
upstream = httpx.Response(
429,
content=b'{"error":"rate limited"}',
headers={"Content-Type": "application/json"},
)
with pytest.raises(HTTPException) as exc_info:
_proxy_response(
_upstream(upstream, error_status_map=((429, 499),)),
tenant_id="tenant-1",
contract_response_headers=(),
max_response_bytes=1024 * 1024,
)
assert exc_info.value.code == 499
assert upstream.is_closed
def test_contract_response_headers_are_deduplicated_case_insensitively() -> None:
upstream = httpx.Response(
200,
@@ -671,6 +776,7 @@ def test_disallowed_non_get_route_is_hidden_as_not_found(
monkeypatch: pytest.MonkeyPatch,
method: KnowledgeFSMethod,
) -> None:
_set_current_workspace(monkeypatch)
route = unwrap(proxy_knowledge_fs_write)
with app.test_request_context("/console/api/knowledge-fs/not-a-route", method=method):
@@ -2,6 +2,7 @@
import json
import os
import re
import subprocess
import sys
from pathlib import Path
@@ -15,7 +16,7 @@ from dev.generate_knowledge_fs_contract import (
filter_openapi_document,
validate_declarations,
)
from services.knowledge_fs_proxy import KNOWLEDGE_FS_CONSOLE_OPERATIONS, KnowledgeFSOperation
from services.knowledge_fs_operations import KNOWLEDGE_FS_CONSOLE_OPERATIONS, KnowledgeFSOperation
def test_contract_cli_updates_checks_and_detects_openapi_drift(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -111,7 +112,7 @@ def test_contract_script_loads_runtime_registry_outside_api_directory(tmp_path:
text=True,
)
assert result.stdout.strip() == "2"
assert result.stdout.strip() == str(len(KNOWLEDGE_FS_CONSOLE_OPERATIONS))
def test_validate_declarations_accepts_matching_contract() -> None:
@@ -178,47 +179,115 @@ def test_filter_openapi_document_keeps_only_declared_operations_and_referenced_s
assert set(filtered["paths"]) == {"/knowledge-spaces"}
assert set(filtered["paths"]["/knowledge-spaces"]) == {"get"}
assert set(filtered["components"]["schemas"]) == {"KnowledgeSpaceList", "KnowledgeSpace"}
assert set(filtered["components"]["schemas"]) == {
"ConsoleProxyError",
"KnowledgeSpaceList",
"KnowledgeSpace",
}
assert filtered["components"]["securitySchemes"] == document["components"]["securitySchemes"]
def test_console_operation_registry_matches_contract() -> None:
list_route = operation("knowledge-spaces:read", "listKnowledgeSpaces")
create_route = operation("knowledge-spaces:write", "createKnowledgeSpace")
for route in (list_route, create_route):
route["parameters"] = [{"in": "header", "name": "X-Trace-Id"}]
route["responses"] = {
"200": {
"content": {"application/json": {}},
"headers": {"X-Trace-Id": {}},
}
}
def test_filter_openapi_document_keeps_sse_for_streaming_orpc_contracts() -> None:
json_declaration = declaration()
stream_declaration = declaration(
operation_id="streamTask",
path="tasks/{id}/events",
response_kind="stream",
response_media_types=("text/event-stream",),
)
json_operation = operation("knowledge-spaces:read", "listKnowledgeSpaces")
stream_operation = operation(
"knowledge-spaces:read",
"streamTask",
responses={"200": {"content": {"text/event-stream": {"schema": {"$ref": "#/components/schemas/TaskEvent"}}}}},
)
validate_declarations(
filtered = filter_openapi_document(
{
"paths": {
"/knowledge-spaces": {
"get": list_route,
"post": create_route,
}
}
"/knowledge-spaces": {"get": json_operation},
"/tasks/{id}/events": {"get": stream_operation},
},
"components": {"schemas": {"TaskEvent": {"type": "object"}}},
},
(json_declaration, stream_declaration),
)
assert set(filtered["paths"]) == {"/knowledge-spaces", "/tasks/{id}/events"}
assert filtered["components"]["schemas"]["TaskEvent"] == {"type": "object"}
def test_filter_openapi_document_rewrites_proxy_error_responses() -> None:
route = operation("knowledge-spaces:read", "listKnowledgeSpaces")
route["responses"] = {
"200": {"content": {"application/json": {}}},
"401": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}},
"403": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorResponse"}}}},
}
document = {
"paths": {"/knowledge-spaces": {"get": route}},
"components": {"schemas": {"ErrorResponse": {"type": "object"}}},
}
filtered = filter_openapi_document(
document,
(declaration(error_status_map=((401, 502), (403, 403))),),
)
responses = filtered["paths"]["/knowledge-spaces"]["get"]["responses"]
assert "401" not in responses
assert responses["403"]["content"]["application/json"]["schema"] == {
"$ref": "#/components/schemas/ConsoleProxyError"
}
assert responses["502"]["content"]["application/json"]["schema"] == {
"$ref": "#/components/schemas/ConsoleProxyError"
}
assert filtered["components"]["schemas"]["ConsoleProxyError"]["required"] == ["code", "message", "status"]
def test_console_operation_registry_matches_contract() -> None:
validate_declarations(
console_registry_document(),
tuple(_contract_declaration(operation) for operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS),
)
def test_generated_contract_metadata_matches_current_pin_and_registry() -> None:
metadata = (
contract_validator.WORKSPACE_ROOT / "packages/contracts/generated/knowledge-fs/metadata.gen.ts"
).read_text()
lock = json.loads(contract_validator.LOCK_PATH.read_text())
assert _metadata_string(metadata, "knowledgeFsSourceOpenapiSha256") == lock["openapiSha256"]
assert _metadata_string(
metadata,
"knowledgeFsConsoleDeclarationsSha256",
) == contract_validator.contract_declarations_sha256(contract_validator.console_contract_declarations())
def _metadata_string(source: str, export_name: str) -> str:
match = re.search(rf"export const {export_name}\s*=\s*'([0-9a-f]{{64}})'", source)
assert match is not None, f"missing generated metadata export: {export_name}"
return match.group(1)
def console_registry_document() -> dict[str, object]:
list_route = operation("knowledge-spaces:read", "listKnowledgeSpaces")
create_route = operation("knowledge-spaces:write", "createKnowledgeSpace")
for route in (list_route, create_route):
route["parameters"] = [{"in": "header", "name": "X-Trace-Id"}]
route["responses"] = {
"200": {
"content": {"application/json": {}},
"headers": {"X-Trace-Id": {}},
}
}
return {"paths": {"/knowledge-spaces": {"get": list_route, "post": create_route}}}
paths: dict[str, dict[str, object]] = {}
for console_operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS:
route = operation(
console_operation.required_scope,
console_operation.operation_id,
parameters=[{"in": "header", "name": name} for name in console_operation.request_headers],
responses={
"200": {
"content": {media_type: {} for media_type in console_operation.response_media_types},
"headers": {name: {} for name in console_operation.response_headers},
}
},
)
route["x-knowledge-fs-max-response-bytes"] = console_operation.max_response_bytes
paths.setdefault(f"/{console_operation.path}", {})[console_operation.method.lower()] = route
return {"paths": paths}
@pytest.mark.parametrize(
@@ -377,6 +446,7 @@ def declaration(**overrides: object) -> ContractDeclaration:
"request_headers": (),
"response_headers": (),
"response_media_types": ("application/json",),
"error_status_map": ((401, 502), (403, 403)),
}
value.update(overrides)
return cast(ContractDeclaration, value)
@@ -393,6 +463,7 @@ def _contract_declaration(operation: KnowledgeFSOperation) -> ContractDeclaratio
"request_headers": operation.request_headers,
"response_headers": operation.response_headers,
"response_media_types": operation.response_media_types,
"error_status_map": operation.error_status_map,
}
@@ -10,16 +10,21 @@ from pydantic import SecretStr
from core.helper import ssrf_proxy
from core.rbac import RBACPermission
from core.tools.errors import ToolSSRFError
from services.knowledge_fs_proxy import (
from services.knowledge_fs_operations import (
KNOWLEDGE_FS_CONSOLE_OPERATIONS,
KnowledgeFSAccessDeniedError,
KnowledgeFSConfigurationError,
KnowledgeFSMethod,
KnowledgeFSOperation,
)
from services.knowledge_fs_proxy import (
KnowledgeFSAccessDeniedError,
KnowledgeFSAuthorization,
KnowledgeFSConfigurationError,
KnowledgeFSRouteNotAllowedError,
KnowledgeFSTimeoutError,
KnowledgeFSTransportError,
authorize_knowledge_fs_request,
get_knowledge_fs_operation,
proxy_authorized_knowledge_fs_request,
proxy_knowledge_fs_request,
)
from services.knowledge_fs_proxy import (
@@ -28,16 +33,178 @@ from services.knowledge_fs_proxy import (
_JWT_SECRET = "production-secret-with-at-least-32-bytes"
_HAPPY_PATH_OPERATION_IDS = (
"listKnowledgeSpaces",
"createKnowledgeSpace",
"getKnowledgeSpacesById",
"getKnowledgeSpacesByIdAccessPolicy",
"patchKnowledgeSpacesByIdAccessPolicy",
"getSourceProviders",
"getKnowledgeSpacesByIdSourceConnections",
"postKnowledgeSpacesByIdSourceConnections",
"postKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefresh",
"getKnowledgeSpacesByIdSources",
"postKnowledgeSpacesByIdSources",
"postKnowledgeSpacesByIdSourcesBySourceIdCrawlPreview",
"getKnowledgeSpacesByIdSourceWorkflowsByRunId",
"getKnowledgeSpacesByIdSourceWorkflowsByRunIdPages",
"postKnowledgeSpacesByIdSourceWorkflowsByRunIdCancel",
"postKnowledgeSpacesByIdSourceWorkflowsByRunIdRetry",
"postKnowledgeSpacesByIdSourceWorkflowsByRunIdSelection",
"getKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy",
"putKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy",
"getKnowledgeSpacesByIdLogicalDocuments",
"getKnowledgeSpacesByIdLogicalDocumentsByDocumentId",
"getKnowledgeSpacesByIdDocumentsByDocumentIdRevisions",
"getKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunks",
"getKnowledgeSpacesByIdProcessingTasks",
"getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEvents",
"deleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId",
"postKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetry",
)
_REQUIRED_EXPANDED_HAPPY_PATH_OPERATION_IDS = {
"patchKnowledgeSpacesById",
"deleteKnowledgeSpacesById",
"getKnowledgeSpacesByIdStats",
"postKnowledgeSpacesByIdSourceConnectionsOauth",
"postSourceOauthCallback",
"getKnowledgeSpacesByIdSourceConnectionsByConnectionId",
"deleteKnowledgeSpacesByIdSourceConnectionsByConnectionId",
"getKnowledgeSpacesByIdSourcesBySourceId",
"patchKnowledgeSpacesByIdSourcesBySourceId",
"deleteKnowledgeSpacesByIdSourcesBySourceId",
"putKnowledgeSpacesByIdSourcesBySourceIdCredentials",
"deleteKnowledgeSpacesByIdSourcesBySourceIdCredentials",
"postKnowledgeSpacesByIdSourcesBySourceIdSync",
"postKnowledgeSpacesByIdSourcesBySourceIdWorkflowImports",
"getKnowledgeSpacesByIdSourcesBySourceIdPages",
"getKnowledgeSpacesByIdSourcesBySourceIdFiles",
"postKnowledgeSpacesByIdSourcesBySourceIdCrawl",
"postKnowledgeSpacesByIdSourcesBySourceIdImport",
"postKnowledgeSpacesByIdSourcesBySourceIdTest",
"postKnowledgeSpacesByIdSourcesBySourceIdImportFiles",
"postKnowledgeSpacesByIdSourcesBulk",
"getKnowledgeSpacesByIdSourceWorkflows",
"getKnowledgeSpacesByIdSourceWorkflowsByRunIdBulkItems",
"getKnowledgeSpacesByIdDocuments",
"postKnowledgeSpacesByIdDocuments",
"deleteKnowledgeSpacesByIdDocumentsBulk",
"postKnowledgeSpacesByIdDocumentsBulk",
"postKnowledgeSpacesByIdDocumentsBulkReindex",
"getKnowledgeSpacesByIdDocumentsByDocumentId",
"deleteKnowledgeSpacesByIdDocumentsByDocumentId",
"deleteKnowledgeSpacesByIdLogicalDocumentsByDocumentId",
"getKnowledgeSpacesByIdDocumentsByDocumentIdOutline",
"postKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionRollback",
"patchKnowledgeSpacesByIdDocumentsByDocumentIdMetadata",
"getKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkId",
"postKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunksByChunkIdState",
"getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasks",
"getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId",
"getKnowledgeSpacesByIdDocumentsByDocumentIdSettings",
"putKnowledgeSpacesByIdDocumentsByDocumentIdSettings",
"getJobsById",
"deleteJobsById",
"postJobsByIdRetry",
"getDeletionJobsByJobId",
"postDeletionJobsByJobIdRetry",
"getBulkJobsById",
}
_EXPANDED_EXTERNAL_SOURCE_OPERATION_IDS = {
operation_id
for operation_id in _REQUIRED_EXPANDED_HAPPY_PATH_OPERATION_IDS
if "Source" in operation_id or operation_id == "postSourceOauthCallback"
}
_OPERATION_AUTHORIZATION_POLICIES = {
"listKnowledgeSpaces": (RBACPermission.DATASET_READONLY, "reader"),
"createKnowledgeSpace": (RBACPermission.DATASET_CREATE_AND_MANAGEMENT, "dataset_editor"),
"getKnowledgeSpacesById": (RBACPermission.DATASET_READONLY, "reader"),
"getKnowledgeSpacesByIdAccessPolicy": (RBACPermission.DATASET_READONLY, "reader"),
"patchKnowledgeSpacesByIdAccessPolicy": (RBACPermission.DATASET_ACCESS_CONFIG, "admin"),
"getSourceProviders": (RBACPermission.DATASET_EXTERNAL_CONNECT, "dataset_editor"),
"getKnowledgeSpacesByIdSourceConnections": (RBACPermission.DATASET_EXTERNAL_CONNECT, "dataset_editor"),
"postKnowledgeSpacesByIdSourceConnections": (RBACPermission.DATASET_EXTERNAL_CONNECT, "dataset_editor"),
"postKnowledgeSpacesByIdSourceConnectionsByConnectionIdRefresh": (
RBACPermission.DATASET_EXTERNAL_CONNECT,
"dataset_editor",
),
"getKnowledgeSpacesByIdSources": (RBACPermission.DATASET_READONLY, "reader"),
"postKnowledgeSpacesByIdSources": (RBACPermission.DATASET_EXTERNAL_CONNECT, "dataset_editor"),
"postKnowledgeSpacesByIdSourcesBySourceIdCrawlPreview": (
RBACPermission.DATASET_EXTERNAL_CONNECT,
"dataset_editor",
),
"getKnowledgeSpacesByIdSourceWorkflowsByRunId": (
RBACPermission.DATASET_EXTERNAL_CONNECT,
"dataset_editor",
),
"getKnowledgeSpacesByIdSourceWorkflowsByRunIdPages": (
RBACPermission.DATASET_EXTERNAL_CONNECT,
"dataset_editor",
),
"postKnowledgeSpacesByIdSourceWorkflowsByRunIdCancel": (
RBACPermission.DATASET_EXTERNAL_CONNECT,
"dataset_editor",
),
"postKnowledgeSpacesByIdSourceWorkflowsByRunIdRetry": (
RBACPermission.DATASET_EXTERNAL_CONNECT,
"dataset_editor",
),
"postKnowledgeSpacesByIdSourceWorkflowsByRunIdSelection": (
RBACPermission.DATASET_EXTERNAL_CONNECT,
"dataset_editor",
),
"getKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy": (RBACPermission.DATASET_READONLY, "reader"),
"putKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy": (RBACPermission.DATASET_EDIT, "dataset_editor"),
"getKnowledgeSpacesByIdLogicalDocuments": (RBACPermission.DATASET_READONLY, "reader"),
"getKnowledgeSpacesByIdLogicalDocumentsByDocumentId": (RBACPermission.DATASET_READONLY, "reader"),
"getKnowledgeSpacesByIdDocumentsByDocumentIdRevisions": (RBACPermission.DATASET_READONLY, "reader"),
"getKnowledgeSpacesByIdDocumentsByDocumentIdRevisionsByRevisionChunks": (
RBACPermission.DATASET_READONLY,
"reader",
),
"getKnowledgeSpacesByIdProcessingTasks": (RBACPermission.DATASET_READONLY, "reader"),
"getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEvents": (
RBACPermission.DATASET_READONLY,
"reader",
),
"deleteKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskId": (
RBACPermission.DATASET_EDIT,
"dataset_editor",
),
"postKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdRetry": (
RBACPermission.DATASET_EDIT,
"dataset_editor",
),
}
def _materialized_path(operation: KnowledgeFSOperation) -> str:
segments = []
for segment in operation.path.split("/"):
if segment == "{revision}":
segments.append("1")
elif segment.startswith("{"):
segments.append("00000000-0000-4000-8000-000000000001")
else:
segments.append(segment)
return "/".join(segments)
def _set_config(
monkeypatch: pytest.MonkeyPatch,
*,
base_url: str | None = "http://knowledge-fs.test",
sse_read_timeout_seconds: float = 90.0,
timeout_seconds: float = 7.5,
jwt_secret: str | None = _JWT_SECRET,
) -> None:
values = {
"KNOWLEDGE_FS_BASE_URL": base_url,
"KNOWLEDGE_FS_SSE_READ_TIMEOUT_SECONDS": sse_read_timeout_seconds,
"KNOWLEDGE_FS_TIMEOUT_SECONDS": timeout_seconds,
"KNOWLEDGE_FS_JWT_SECRET": SecretStr(jwt_secret) if jwt_secret is not None else None,
}
@@ -45,43 +212,68 @@ def _set_config(
monkeypatch.setattr(f"services.knowledge_fs_proxy.dify_config.{name}", value, raising=False)
def test_console_registry_starts_with_list_and_create_operations() -> None:
assert tuple(operation.operation_id for operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS) == (
"listKnowledgeSpaces",
"createKnowledgeSpace",
def _processing_task_events_path() -> str:
operation = next(
operation
for operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS
if operation.operation_id == "getKnowledgeSpacesByIdDocumentsByDocumentIdProcessingTasksByTaskIdEvents"
)
return _materialized_path(operation)
def test_console_registry_exposes_only_the_new_rag_happy_path_operations() -> None:
operation_ids = {operation.operation_id for operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS}
assert operation_ids == set(_HAPPY_PATH_OPERATION_IDS) | _REQUIRED_EXPANDED_HAPPY_PATH_OPERATION_IDS
def test_console_registry_exposes_existing_upstream_contracts_needed_by_all_happy_path_pages() -> None:
operation_ids = {operation.operation_id for operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS}
assert operation_ids >= _REQUIRED_EXPANDED_HAPPY_PATH_OPERATION_IDS
def test_console_registry_preserves_explicit_scope_and_authorization_policies() -> None:
for operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS:
is_read = operation.method == "GET"
assert operation.required_scope == f"knowledge-spaces:{'read' if is_read else 'write'}"
expected_policy = _OPERATION_AUTHORIZATION_POLICIES.get(operation.operation_id)
if expected_policy is None and operation.operation_id in _EXPANDED_EXTERNAL_SOURCE_OPERATION_IDS:
expected_policy = (RBACPermission.DATASET_EXTERNAL_CONNECT, "dataset_editor")
if expected_policy is None:
expected_policy = (
(RBACPermission.DATASET_READONLY, "reader")
if is_read
else (RBACPermission.DATASET_EDIT, "dataset_editor")
)
assert (operation.rbac_permission, operation.legacy_role) == expected_policy
assert operation.response_headers == ("x-trace-id",)
def test_console_registry_preserves_special_transport_contracts() -> None:
crawl_preview = get_knowledge_fs_operation(
"POST",
"knowledge-spaces/00000000-0000-4000-8000-000000000001/sources/"
"00000000-0000-4000-8000-000000000002/crawl-preview",
)
selection = get_knowledge_fs_operation(
"POST",
"knowledge-spaces/00000000-0000-4000-8000-000000000001/source-workflows/"
"00000000-0000-4000-8000-000000000002/selection",
)
events = get_knowledge_fs_operation(
"GET",
"knowledge-spaces/00000000-0000-4000-8000-000000000001/documents/"
"00000000-0000-4000-8000-000000000002/processing-tasks/"
"00000000-0000-4000-8000-000000000003/events",
)
@pytest.mark.parametrize(
("method", "operation_id", "scope", "permission", "requires_dataset_editor"),
[
("GET", "listKnowledgeSpaces", "knowledge-spaces:read", RBACPermission.DATASET_READONLY, False),
(
"POST",
"createKnowledgeSpace",
"knowledge-spaces:write",
RBACPermission.DATASET_CREATE_AND_MANAGEMENT,
True,
),
],
)
def test_console_registry_preserves_contract_and_policy(
method: KnowledgeFSMethod,
operation_id: str,
scope: str,
permission: RBACPermission,
requires_dataset_editor: bool,
) -> None:
operation = get_knowledge_fs_operation(method, "knowledge-spaces")
assert operation.operation_id == operation_id
assert operation.required_scope == scope
assert operation.rbac_permission == permission
assert operation.requires_dataset_editor is requires_dataset_editor
assert operation.max_response_bytes == 1_048_576
assert operation.request_headers == ("x-trace-id",)
assert operation.response_headers == ("x-trace-id",)
assert operation.response_media_types == ("application/json",)
assert crawl_preview.request_headers == ("idempotency-key", "x-trace-id")
assert selection.request_headers == ("idempotency-key", "x-trace-id")
assert events.response_kind == "stream"
assert events.max_response_bytes == 67_108_864
assert events.request_headers == ("last-event-id", "x-trace-id")
assert events.response_media_types == ("text/event-stream",)
def test_unconfigured_kfs_is_rejected_before_external_io(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -148,7 +340,112 @@ def test_proxy_forwards_only_registry_declared_headers(monkeypatch: pytest.Monke
assert forward.call_args.kwargs["request_headers"] == {"x-trace-id": "trace-1"}
def test_authorization_rejects_workspace_rbac_denial(monkeypatch: pytest.MonkeyPatch) -> None:
def test_authorized_proxy_does_not_repeat_workspace_rbac(monkeypatch: pytest.MonkeyPatch) -> None:
account = MagicMock(id="account-1", is_dataset_editor=True)
check_access = MagicMock(return_value=True)
forward = MagicMock(return_value=MagicMock())
monkeypatch.setattr("services.knowledge_fs_proxy.RBACService.CheckAccess.check", check_access)
monkeypatch.setattr("services.knowledge_fs_proxy._forward_knowledge_fs_request", forward)
operation = get_knowledge_fs_operation("POST", "knowledge-spaces")
authorization = authorize_knowledge_fs_request(
account=account,
tenant_id="tenant-1",
method=operation.method,
path=_materialized_path(operation),
)
proxy_authorized_knowledge_fs_request(authorization=authorization)
check_access.assert_called_once()
assert forward.call_args.kwargs["account_id"] == "account-1"
assert forward.call_args.kwargs["tenant_id"] == "tenant-1"
assert forward.call_args.kwargs["method"] == "POST"
assert forward.call_args.kwargs["path"] == "knowledge-spaces"
def test_authorization_capability_cannot_be_constructed_directly() -> None:
operation = get_knowledge_fs_operation("POST", "knowledge-spaces")
with pytest.raises(KnowledgeFSAccessDeniedError, match="must be created by workspace authorization"):
KnowledgeFSAuthorization("account-1", "tenant-1", operation)
def test_authorization_resolves_the_canonical_operation_policy(monkeypatch: pytest.MonkeyPatch) -> None:
account = MagicMock(id="account-1", is_dataset_editor=False, is_admin_or_owner=False)
check_access = MagicMock(return_value=True)
monkeypatch.setattr("services.knowledge_fs_proxy.RBACService.CheckAccess.check", check_access)
with pytest.raises(KnowledgeFSAccessDeniedError, match="dataset edit access"):
authorize_knowledge_fs_request(
account=account,
tenant_id="tenant-1",
method="POST",
path="knowledge-spaces",
)
check_access.assert_not_called()
@pytest.mark.parametrize(
("attribute", "value"),
[
("account_id", "account-2"),
("tenant_id", "tenant-2"),
("operation", get_knowledge_fs_operation("GET", "knowledge-spaces")),
],
)
def test_authorization_capability_binding_cannot_be_mutated(
monkeypatch: pytest.MonkeyPatch,
attribute: str,
value: object,
) -> None:
account = MagicMock(id="account-1", is_dataset_editor=True)
monkeypatch.setattr(
"services.knowledge_fs_proxy.RBACService.CheckAccess.check",
MagicMock(return_value=True),
)
authorization = authorize_knowledge_fs_request(
account=account,
tenant_id="tenant-1",
method="POST",
path="knowledge-spaces",
)
with pytest.raises(AttributeError):
setattr(authorization, attribute, value)
assert authorization.account_id == "account-1"
assert authorization.tenant_id == "tenant-1"
assert authorization.operation == get_knowledge_fs_operation("POST", "knowledge-spaces")
def test_authorization_capability_cannot_be_reused(monkeypatch: pytest.MonkeyPatch) -> None:
account = MagicMock(id="account-1", is_dataset_editor=True)
forward = MagicMock(return_value=MagicMock())
monkeypatch.setattr(
"services.knowledge_fs_proxy.RBACService.CheckAccess.check",
MagicMock(return_value=True),
)
monkeypatch.setattr("services.knowledge_fs_proxy._forward_knowledge_fs_request", forward)
authorization = authorize_knowledge_fs_request(
account=account,
tenant_id="tenant-1",
method="POST",
path="knowledge-spaces",
)
proxy_authorized_knowledge_fs_request(authorization=authorization)
with pytest.raises(KnowledgeFSAccessDeniedError, match="already been used"):
proxy_authorized_knowledge_fs_request(authorization=authorization)
forward.assert_called_once()
@pytest.mark.parametrize("operation", KNOWLEDGE_FS_CONSOLE_OPERATIONS, ids=lambda operation: operation.operation_id)
def test_authorization_rejects_workspace_rbac_denial(
monkeypatch: pytest.MonkeyPatch,
operation: KnowledgeFSOperation,
) -> None:
account = MagicMock(id="account-1", is_dataset_editor=True)
check_access = MagicMock(return_value=False)
monkeypatch.setattr("services.knowledge_fs_proxy.RBACService.CheckAccess.check", check_access)
@@ -157,19 +454,28 @@ def test_authorization_rejects_workspace_rbac_denial(monkeypatch: pytest.MonkeyP
authorize_knowledge_fs_request(
account=account,
tenant_id="tenant-1",
operation=get_knowledge_fs_operation("GET", "knowledge-spaces"),
method=operation.method,
path=_materialized_path(operation),
)
check_access.assert_called_once_with(
"tenant-1",
"account-1",
scene="dataset_readonly",
scene=operation.rbac_permission.value,
resource_type="dataset",
)
def test_create_rejects_non_dataset_editor_before_rbac(monkeypatch: pytest.MonkeyPatch) -> None:
account = MagicMock(id="account-1", is_dataset_editor=False)
@pytest.mark.parametrize(
"operation",
tuple(operation for operation in KNOWLEDGE_FS_CONSOLE_OPERATIONS if operation.legacy_role == "dataset_editor"),
ids=lambda operation: operation.operation_id,
)
def test_dataset_editor_operations_reject_legacy_viewers_before_rbac(
monkeypatch: pytest.MonkeyPatch,
operation: KnowledgeFSOperation,
) -> None:
account = MagicMock(id="account-1", is_dataset_editor=False, is_admin_or_owner=False)
check_access = MagicMock(return_value=True)
monkeypatch.setattr("services.knowledge_fs_proxy.RBACService.CheckAccess.check", check_access)
@@ -177,41 +483,66 @@ def test_create_rejects_non_dataset_editor_before_rbac(monkeypatch: pytest.Monke
authorize_knowledge_fs_request(
account=account,
tenant_id="tenant-1",
operation=get_knowledge_fs_operation("POST", "knowledge-spaces"),
method=operation.method,
path=_materialized_path(operation),
)
check_access.assert_not_called()
def test_authorization_uses_the_declared_editor_policy(monkeypatch: pytest.MonkeyPatch) -> None:
account = MagicMock(id="account-1", is_dataset_editor=False)
def test_admin_operation_rejects_legacy_editors_before_rbac(monkeypatch: pytest.MonkeyPatch) -> None:
account = MagicMock(id="account-1", is_dataset_editor=True, is_admin_or_owner=False)
check_access = MagicMock(return_value=True)
monkeypatch.setattr("services.knowledge_fs_proxy.RBACService.CheckAccess.check", check_access)
operation = get_knowledge_fs_operation("POST", "knowledge-spaces")._replace(requires_dataset_editor=False)
operation = get_knowledge_fs_operation(
"PATCH", "knowledge-spaces/00000000-0000-4000-8000-000000000001/access-policy"
)
authorize_knowledge_fs_request(account=account, tenant_id="tenant-1", operation=operation)
with pytest.raises(KnowledgeFSAccessDeniedError, match="administration access"):
authorize_knowledge_fs_request(
account=account,
tenant_id="tenant-1",
method=operation.method,
path=_materialized_path(operation),
)
check_access.assert_not_called()
def test_authorization_uses_the_declared_reader_policy(monkeypatch: pytest.MonkeyPatch) -> None:
account = MagicMock(id="account-1", is_dataset_editor=False, is_admin_or_owner=False)
check_access = MagicMock(return_value=True)
monkeypatch.setattr("services.knowledge_fs_proxy.RBACService.CheckAccess.check", check_access)
operation = get_knowledge_fs_operation("GET", "knowledge-spaces")
authorize_knowledge_fs_request(
account=account,
tenant_id="tenant-1",
method=operation.method,
path=_materialized_path(operation),
)
check_access.assert_called_once()
@pytest.mark.parametrize(
("method", "expected_scope"),
[("GET", "knowledge-spaces:read"), ("POST", "knowledge-spaces:write")],
)
@pytest.mark.parametrize("operation", KNOWLEDGE_FS_CONSOLE_OPERATIONS, ids=lambda operation: operation.operation_id)
def test_auth_signs_current_principals_and_declared_scope(
monkeypatch: pytest.MonkeyPatch,
method: KnowledgeFSMethod,
expected_scope: str,
operation: KnowledgeFSOperation,
) -> None:
_set_config(monkeypatch)
response = httpx.Response(200, content=b'{"items":[]}', headers={"Content-Type": "application/json"})
response = httpx.Response(
200,
content=b"data" if operation.response_kind == "stream" else b'{"items":[]}',
headers={"Content-Type": operation.response_media_types[0]},
)
request = MagicMock(return_value=response)
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", request)
forward_knowledge_fs_request(
account_id="account-1",
method=method,
path="knowledge-spaces",
method=operation.method,
path=_materialized_path(operation),
tenant_id="tenant-1",
)
@@ -226,7 +557,7 @@ def test_auth_signs_current_principals_and_declared_scope(
assert claims["dify_account_id"] == "dify-account:account-1"
assert claims["sub"] == "dify-workspace:tenant-1"
assert claims["tenant_id"] == "tenant-1"
assert claims["scopes"] == [expected_scope]
assert claims["scopes"] == [operation.required_scope]
assert claims["caller_kind"] == "interactive"
assert claims["exp"] - claims["iat"] == 60
@@ -244,6 +575,69 @@ def test_buffered_response_rejects_non_empty_body_without_content_type(monkeypat
assert response.is_closed
def test_sse_response_remains_streaming_and_uses_the_dedicated_read_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_set_config(monkeypatch, sse_read_timeout_seconds=120.0)
request = httpx.Request(
"GET",
"http://knowledge-fs.test/events",
extensions={"timeout": {"connect": 7.5, "read": 7.5}},
)
response = httpx.Response(
200,
headers={"Content-Type": "text/event-stream"},
request=request,
stream=httpx.ByteStream(b"event: progress\n\n"),
)
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", MagicMock(return_value=response))
buffer_response = MagicMock()
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.buffer_response", buffer_response)
result = forward_knowledge_fs_request(
account_id="account-dev",
method="GET",
path=_processing_task_events_path(),
tenant_id="tenant-dev",
)
assert result.response is response
assert result.response_kind == "stream"
assert request.extensions["timeout"]["read"] == 120.0
assert not response.is_closed
buffer_response.assert_not_called()
@pytest.mark.parametrize(
("headers", "message"),
[
({"Content-Type": "application/json"}, "unsupported media type"),
(
{"Content-Type": "text/event-stream", "Content-Encoding": "gzip"},
"unsupported encoding",
),
],
)
def test_sse_response_rejects_invalid_stream_headers_and_closes_upstream(
monkeypatch: pytest.MonkeyPatch,
headers: dict[str, str],
message: str,
) -> None:
_set_config(monkeypatch)
response = httpx.Response(200, headers=headers, stream=httpx.ByteStream(b"data"))
monkeypatch.setattr("services.knowledge_fs_proxy.ssrf_proxy.make_request", MagicMock(return_value=response))
with pytest.raises(KnowledgeFSTransportError, match=message):
forward_knowledge_fs_request(
account_id="account-dev",
method="GET",
path=_processing_task_events_path(),
tenant_id="tenant-dev",
)
assert response.is_closed
@pytest.mark.parametrize(
("error", "expected_exception"),
[
@@ -277,10 +671,10 @@ def test_transport_failures_are_normalized(
("method", "path"),
[
("GET", "openapi.json"),
("GET", "knowledge-spaces/space-1"),
("GET", "knowledge-spaces/space-1/manifest"),
("PATCH", "knowledge-spaces"),
("POST", "queries"),
("POST", "knowledge-spaces/space-1/documents"),
("POST", "knowledge-spaces/space-1/uploads"),
],
)
def test_unregistered_route_is_rejected_before_external_io(