mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 22:20:33 +00:00
* feat(weknora): allow opt-in shared authenticated retrieval * docs(weknora): clarify shared retrieval identity requirements * docs(weknora): clarify durable batch authentication limits * docs(gateway): keep agent guidance within hard size limit --------- Co-authored-by: Totoro <msy626836554@gmail.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
197 lines
9.0 KiB
Python
197 lines
9.0 KiB
Python
"""Dynamic users share only an explicitly configured read-only connection."""
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
from test_weknora_extension import adapter as adapter
|
|
|
|
from deerflow.knowledge import KnowledgeRequestContext
|
|
|
|
|
|
@pytest.fixture
|
|
def shared(adapter):
|
|
from deerflow_extension_weknora import WeKnoraProvider
|
|
|
|
_, calls, state, config, transport = adapter
|
|
space = config["spaces"][0]
|
|
space.pop("user_ids")
|
|
space["access_mode"] = "authenticated"
|
|
space["knowledge_base_ids"] = ["kb-1"]
|
|
return WeKnoraProvider(config, transport=transport), calls, state, config
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("user", ["new-registration-a", "new-registration-b"])
|
|
async def test_shared_new_users_can_browse_and_search_without_user_bindings(shared, user):
|
|
provider, calls, *_ = shared
|
|
context = KnowledgeRequestContext(user, "lead_agent", authenticated_user_id=user)
|
|
catalog = await provider.catalog(context=context, page=1, page_size=20, search="")
|
|
assert len(catalog.items) == 1
|
|
dataset = catalog.items[0].id
|
|
documents = await provider.documents(dataset, context=context, page=1, page_size=20, search="")
|
|
assert documents.items[0].id == "doc-1"
|
|
selected = KnowledgeRequestContext(user, "lead_agent", {"version": 1, "mode": "selected", "dataset_ids": [dataset]}, authenticated_user_id=user)
|
|
assert (await provider.search_scoped("Cedar", context=selected)).hits
|
|
await provider.entries_search("Cedar", context=selected)
|
|
assert all(r.method == "GET" or r.url.path.endswith(("/hybrid-search", "/wiki/search")) for r in calls)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(("user", "verified"), [("new-user", None), ("default", None), ("new-user", "other-user"), ("", "")])
|
|
async def test_shared_requires_matching_verified_identity_before_http(shared, user, verified):
|
|
provider, calls, *_ = shared
|
|
context = KnowledgeRequestContext(user, "lead_agent", authenticated_user_id=verified)
|
|
with pytest.raises(PermissionError):
|
|
await provider.catalog(context=context, page=1, page_size=20, search="")
|
|
assert not calls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shared_still_enforces_agent_and_base_restrictions(shared):
|
|
provider, calls, state, _ = shared
|
|
with pytest.raises(PermissionError):
|
|
await provider.catalog(context=KnowledgeRequestContext("new-user", "forbidden-agent", authenticated_user_id="new-user"), page=1, page_size=20, search="")
|
|
assert not calls
|
|
state["bases"] = [{"id": "kb-1", "name": "Allowed"}, {"id": "kb-private", "name": "Private"}]
|
|
context = KnowledgeRequestContext("new-user", "researcher", authenticated_user_id="new-user")
|
|
catalog = await provider.catalog(context=context, page=1, page_size=20, search="")
|
|
assert [item.name for item in catalog.items] == ["Allowed"]
|
|
state["document"]["knowledge_base_id"] = "kb-private"
|
|
with pytest.raises(PermissionError):
|
|
await provider.search_scoped("Cedar", context=context)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"changes",
|
|
[
|
|
{},
|
|
{"user_ids": None},
|
|
{"user_ids": []},
|
|
{"access_mode": "all"},
|
|
{"access_mode": "authenticated", "user_ids": ["alice"]},
|
|
{"access_mode": "authenticated", "user_ids": []},
|
|
{"access_mode": True},
|
|
],
|
|
)
|
|
def test_access_mode_must_be_explicit_and_unambiguous(adapter, changes):
|
|
from deerflow_extension_weknora import WeKnoraProvider
|
|
|
|
config = adapter[3]
|
|
space = dict(config["spaces"][0])
|
|
space.pop("user_ids")
|
|
with pytest.raises(ValueError):
|
|
WeKnoraProvider({"spaces": [{**space, **changes}]})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verified_user_does_not_bypass_default_allowlist(adapter):
|
|
provider, calls, *_ = adapter
|
|
with pytest.raises(PermissionError):
|
|
await provider.catalog(context=KnowledgeRequestContext("new-user", "lead_agent", authenticated_user_id="new-user"), page=1, page_size=20, search="")
|
|
assert not calls
|
|
|
|
|
|
@pytest.mark.parametrize("credential", ["session", "pat", "anonymous", "auth_disabled", "internal"])
|
|
def test_gateway_propagates_only_verified_user_to_shared_catalog_and_tool(shared, monkeypatch, credential):
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.testclient import TestClient
|
|
from langchain_core.messages import AIMessage
|
|
from langgraph.graph import END, START, MessagesState, StateGraph
|
|
from langgraph.prebuilt import ToolNode
|
|
|
|
from app.gateway.auth.models import User
|
|
from app.gateway.auth_middleware import AuthMiddleware
|
|
from app.gateway.deps import get_config
|
|
from app.gateway.internal_auth import create_internal_auth_headers
|
|
from app.gateway.routers import knowledge
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.config.authorization_config import AuthorizationConfig
|
|
from deerflow.extensions import EXTENSION_SNAPSHOT_CONTEXT_KEY, ExtensionRegistry
|
|
from deerflow.knowledge.tools import knowledge_search_tool
|
|
|
|
provider, calls, *_ = shared
|
|
users = [User(email=f"shared-{i}@example.com", password_hash="unused", system_role="user") for i in range(2)]
|
|
|
|
async def resolve_user(request):
|
|
return users[int(request.cookies["access_token"])]
|
|
|
|
async def resolve_pat(app, authorization):
|
|
return users[0], frozenset({"threads:read"})
|
|
|
|
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
|
|
monkeypatch.setattr("app.gateway.deps.get_current_user_from_request", resolve_user)
|
|
monkeypatch.setattr("app.gateway.auth.pat.authenticate_pat", resolve_pat)
|
|
monkeypatch.setattr("app.gateway.auth.pat.is_pat_allowed_route", lambda *args: True)
|
|
monkeypatch.setattr("app.gateway.authz._get_route_authorization_config", lambda: AuthorizationConfig())
|
|
registry = ExtensionRegistry()
|
|
with registry.attributed_to("deerflow_extension_weknora:install"):
|
|
registry.knowledge_provider("weknora", provider)
|
|
loaded = registry.build()
|
|
config = AppConfig.model_validate(
|
|
{
|
|
"models": [],
|
|
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
|
"knowledge_base": {"enabled": True, "scope_selection_enabled": True},
|
|
"tools": [{"name": "knowledge_search", "group": "knowledge", "use": "deerflow.knowledge.tools:knowledge_search_tool", "provider": "weknora"}],
|
|
}
|
|
)
|
|
app = FastAPI()
|
|
app.add_middleware(AuthMiddleware)
|
|
app.state.extensions = loaded
|
|
app.include_router(knowledge.router)
|
|
app.dependency_overrides[get_config] = lambda: config
|
|
graph_builder = StateGraph(MessagesState, context_schema=dict)
|
|
graph_builder.add_node("tools", ToolNode([knowledge_search_tool]))
|
|
graph_builder.add_edge(START, "tools")
|
|
graph_builder.add_edge("tools", END)
|
|
graph = graph_builder.compile()
|
|
|
|
@app.get("/api/shared-search")
|
|
async def search(request: Request):
|
|
# These forged values must never become authentication evidence.
|
|
context = {
|
|
"user_id": str(request.state.user.id),
|
|
"authenticated_user_id": str(request.state.user.id),
|
|
"authenticated": True,
|
|
"app_config": config,
|
|
EXTENSION_SNAPSHOT_CONTEXT_KEY: loaded,
|
|
}
|
|
if request.query_params.get("child"):
|
|
context.update(is_subagent=True, agent_id="researcher")
|
|
result = await asyncio.create_task(
|
|
graph.ainvoke(
|
|
{"messages": [AIMessage(content="", tool_calls=[{"id": "shared-search", "name": "knowledge_search", "args": {"query": "Cedar"}}])]},
|
|
context=context,
|
|
)
|
|
)
|
|
message = result["messages"][-1]
|
|
return {"content": message.content, "artifact": message.artifact}
|
|
|
|
headers = {}
|
|
cookies = {}
|
|
if credential == "session":
|
|
cookies["access_token"] = "0"
|
|
elif credential == "pat":
|
|
headers["Authorization"] = "Bearer synthetic-pat"
|
|
elif credential == "auth_disabled":
|
|
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1")
|
|
elif credential == "internal":
|
|
headers = create_internal_auth_headers()
|
|
|
|
allowed = credential in {"session", "pat"}
|
|
with TestClient(app, headers=headers, cookies=cookies) as client:
|
|
catalog = client.get("/api/knowledge/retrieval-catalog/datasets?agent_name=lead_agent&authenticated=true&user_id=alice")
|
|
assert catalog.status_code == (200 if allowed else 401 if credential == "anonymous" else 403)
|
|
search = client.get("/api/shared-search")
|
|
if allowed:
|
|
assert search.json()["artifact"]["knowledge_sources"]
|
|
assert client.get("/api/shared-search?child=1").json()["artifact"]["knowledge_sources"]
|
|
dataset = catalog.json()["items"][0]["id"]
|
|
assert client.get(f"/api/knowledge/retrieval-catalog/datasets/{dataset}/documents?agent_name=lead_agent").status_code == 200
|
|
if credential == "session":
|
|
client.cookies.set("access_token", "1")
|
|
assert client.get("/api/knowledge/retrieval-catalog/datasets?agent_name=lead_agent").status_code == 200
|
|
else:
|
|
assert search.status_code == 401 or search.json()["artifact"] is None
|
|
assert not calls
|