mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-15 12:13:38 +00:00
Merge main (roles and access revamp) into connectors
Main's access model (team editors and viewers, edit_credentials, owner-only OAuth servers, per-user tool preferences, resource sponsors) now applies to connection-backed tools and sources. Our migrations are renumbered to 0040_connections and 0041_connection_account_name, after main's 0038_resource_access_settings and 0039_resource_sponsors. Where the two sides met: MCP tools save and load their connections as the tool owner, editors may add a key (a new connection on the owner's account) but never rewrite an existing connection's secret, fixed values stay owner-only, and a member's own connection is used in member mode.
This commit is contained in:
commit
a1789d2ab4
167 files changed
+16909
-2126
No files matched your search
@@ -502,6 +502,9 @@ class TestFkBoundToolsIsInSync:
|
||||
"notes": "notes",
|
||||
"todos": "todo_list",
|
||||
}
|
||||
# FK-bound tables that are not a tool's storage: per-user settings keyed
|
||||
# by a tool row, never written by a running tool.
|
||||
_NOT_TOOL_STORAGE = {"user_tool_preferences"}
|
||||
|
||||
def test_fk_bound_tools_matches_metadata(self):
|
||||
from docsgpt.storage.db.models import metadata
|
||||
@@ -509,7 +512,7 @@ class TestFkBoundToolsIsInSync:
|
||||
fk_bound_tables = set()
|
||||
for tbl in metadata.tables.values():
|
||||
tool_id_col = tbl.columns.get("tool_id")
|
||||
if tool_id_col is None:
|
||||
if tool_id_col is None or tbl.name in self._NOT_TOOL_STORAGE:
|
||||
continue
|
||||
for fk in tool_id_col.foreign_keys:
|
||||
if fk.target_fullname == "user_tools.id":
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""``run_agent_headless`` renders only a prompt the agent's owner may use.
|
||||
|
||||
Scheduled and webhook runs execute as the owner; a prompt the owner lost
|
||||
access to (a revoked team grant, a deleted prompt) falls back to the default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
PROMPT_ID = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
|
||||
def _rendered_prompt_id(monkeypatch, *, usable: bool) -> str:
|
||||
from docsgpt.agents import headless_runner as hr
|
||||
from docsgpt.api.user.resource_access import build
|
||||
|
||||
agent = MagicMock(name="agent")
|
||||
agent.gen.return_value = iter([{"answer": "ok"}])
|
||||
agent.llm.token_usage = {"prompt_tokens": 1, "generated_tokens": 1}
|
||||
retriever = MagicMock(name="retriever")
|
||||
retriever.search.return_value = []
|
||||
tool_executor = MagicMock(name="tool_executor")
|
||||
tool_executor.headless_denials = []
|
||||
rendered: list = []
|
||||
|
||||
def _get_prompt(pid):
|
||||
rendered.append(pid)
|
||||
return "system prompt"
|
||||
|
||||
seen: list = []
|
||||
|
||||
def _resolve(_conn, resource_type, resource_id, user_id):
|
||||
seen.append((resource_type, resource_id, user_id))
|
||||
return build(resource_type, resource_id, "viewer", "x", {}) if usable else None
|
||||
|
||||
@contextmanager
|
||||
def _conn():
|
||||
yield MagicMock()
|
||||
|
||||
monkeypatch.setattr(hr, "get_prompt", _get_prompt)
|
||||
monkeypatch.setattr(
|
||||
hr.RetrieverCreator, "create_retriever",
|
||||
classmethod(lambda cls, *a, **kw: retriever),
|
||||
)
|
||||
monkeypatch.setattr(hr, "ToolExecutor", lambda *a, **kw: tool_executor)
|
||||
monkeypatch.setattr(
|
||||
hr.AgentCreator, "create_agent", classmethod(lambda cls, *a, **kw: agent),
|
||||
)
|
||||
config = {
|
||||
"user_id": "owner-1", "id": "agent-1", "default_model_id": "m",
|
||||
"prompt_id": PROMPT_ID,
|
||||
}
|
||||
with patch("docsgpt.core.model_utils.validate_model_id", return_value=True), \
|
||||
patch("docsgpt.core.model_utils.get_default_model_id", return_value="m"), \
|
||||
patch("docsgpt.core.model_utils.get_provider_from_model_id", return_value="openai"), \
|
||||
patch("docsgpt.core.model_utils.get_api_key_for_provider", return_value="k"), \
|
||||
patch("docsgpt.utils.calculate_doc_token_budget", return_value=1000), \
|
||||
patch("docsgpt.api.user.resource_access.resolve", _resolve), \
|
||||
patch("docsgpt.api.answer.services.stream_processor.db_readonly", _conn):
|
||||
hr.run_agent_headless(config, "do the thing")
|
||||
# Checked as the agent's owner, not whoever scheduled the run.
|
||||
assert seen == [("prompt", PROMPT_ID, "owner-1")]
|
||||
return rendered[0]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestHeadlessRunnerPromptAccess:
|
||||
def test_usable_prompt_is_rendered(self, monkeypatch):
|
||||
assert _rendered_prompt_id(monkeypatch, usable=True) == PROMPT_ID
|
||||
|
||||
def test_unusable_prompt_falls_back_to_default(self, monkeypatch):
|
||||
assert _rendered_prompt_id(monkeypatch, usable=False) == "default"
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Runtime resolution of team-shared tools (owner context, live grant checks)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from docsgpt.agents.default_tools import resolve_tool_by_id
|
||||
from docsgpt.agents.tool_executor import ToolExecutor
|
||||
from docsgpt.api.user.resource_access import set_settings
|
||||
from docsgpt.security.encryption import encrypt_credentials
|
||||
from docsgpt.storage.db.repositories.agents import AgentsRepository
|
||||
from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
|
||||
from docsgpt.storage.db.repositories.team_resource_grants import (
|
||||
TeamResourceGrantsRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.teams import TeamsRepository
|
||||
from docsgpt.storage.db.repositories.user_tool_preferences import (
|
||||
UserToolPreferencesRepository,
|
||||
)
|
||||
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
|
||||
|
||||
OWNER = "alice"
|
||||
|
||||
|
||||
def _tool(conn, name="read_webpage", config=None, status=True):
|
||||
return UserToolsRepository(conn).create(
|
||||
OWNER, name, config=config or {}, display_name=name, description="",
|
||||
actions=[{"name": "act", "active": True}], status=status,
|
||||
)
|
||||
|
||||
|
||||
def _share(conn, tool_id, member, level="viewer"):
|
||||
team = TeamsRepository(conn).create("Acme", f"t-{uuid.uuid4().hex[:8]}", OWNER)
|
||||
TeamMembersRepository(conn).add_member(str(team["id"]), member)
|
||||
TeamResourceGrantsRepository(conn).grant(
|
||||
str(team["id"]), "tool", str(tool_id), OWNER, OWNER, access_level=level
|
||||
)
|
||||
return team
|
||||
|
||||
|
||||
def _revoke(conn, team, tool_id):
|
||||
TeamResourceGrantsRepository(conn).revoke(str(team["id"]), "tool", str(tool_id))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def use_conn(monkeypatch, pg_conn):
|
||||
@contextmanager
|
||||
def _yield():
|
||||
yield pg_conn
|
||||
|
||||
monkeypatch.setattr("docsgpt.agents.tool_executor.db_readonly", _yield)
|
||||
return pg_conn
|
||||
|
||||
|
||||
class TestResolveToolById:
|
||||
def test_owner_resolves(self, pg_conn):
|
||||
tool = _tool(pg_conn)
|
||||
row = resolve_tool_by_id(str(tool["id"]), OWNER, user_tools_repo=UserToolsRepository(pg_conn))
|
||||
assert row is not None and row["user_id"] == OWNER
|
||||
|
||||
def test_grantee_with_use_in_own_resolves_owner_row(self, pg_conn):
|
||||
tool = _tool(pg_conn)
|
||||
_share(pg_conn, tool["id"], "bob")
|
||||
row = resolve_tool_by_id(str(tool["id"]), "bob", user_tools_repo=UserToolsRepository(pg_conn))
|
||||
assert row is not None and row["user_id"] == OWNER
|
||||
|
||||
def test_viewer_without_use_in_own_does_not_resolve(self, pg_conn):
|
||||
tool = _tool(pg_conn)
|
||||
_share(pg_conn, tool["id"], "bob")
|
||||
set_settings(pg_conn, "tool", str(tool["id"]), {"viewers_can_use_in_agents": False}, OWNER)
|
||||
repo = UserToolsRepository(pg_conn)
|
||||
assert resolve_tool_by_id(str(tool["id"]), "bob", user_tools_repo=repo) is None
|
||||
# Editors keep use_in_own when the viewer switch is off.
|
||||
_share(pg_conn, tool["id"], "carol", "editor")
|
||||
assert resolve_tool_by_id(str(tool["id"]), "carol", user_tools_repo=repo) is not None
|
||||
|
||||
def test_stranger_does_not_resolve(self, pg_conn):
|
||||
tool = _tool(pg_conn)
|
||||
assert resolve_tool_by_id(str(tool["id"]), "eve", user_tools_repo=UserToolsRepository(pg_conn)) is None
|
||||
|
||||
|
||||
class TestAgentToolsForOwner:
|
||||
def _agent(self, conn, owner, tool_ids):
|
||||
key = f"k-{uuid.uuid4().hex[:8]}"
|
||||
AgentsRepository(conn).create(owner, "A", "published", key=key, tools=[str(t) for t in tool_ids])
|
||||
return key
|
||||
|
||||
def test_team_tool_on_grantees_agent_resolves_then_drops_on_revoke(self, use_conn):
|
||||
tool = _tool(use_conn)
|
||||
team = _share(use_conn, tool["id"], "bob")
|
||||
key = self._agent(use_conn, "bob", [tool["id"]])
|
||||
# Carol chats with Bob's agent: tools resolve under Bob, the agent owner.
|
||||
tools = ToolExecutor(user_api_key=key, user="carol")._get_tools_by_api_key(key)
|
||||
assert str(tool["id"]) in tools
|
||||
assert tools[str(tool["id"])]["user_id"] == OWNER
|
||||
_revoke(use_conn, team, tool["id"])
|
||||
assert ToolExecutor(user_api_key=key, user="carol")._get_tools_by_api_key(key) == {}
|
||||
|
||||
def test_explicit_ids_path_checks_the_caller(self, use_conn):
|
||||
tool = _tool(use_conn)
|
||||
executor = ToolExecutor(user="bob")
|
||||
assert executor._get_tools_by_ids([str(tool["id"])]) == {}
|
||||
_share(use_conn, tool["id"], "bob")
|
||||
assert str(tool["id"]) in executor._get_tools_by_ids([str(tool["id"])])
|
||||
|
||||
|
||||
class TestAgentlessChatTools:
|
||||
def test_includes_in_chat_team_tools(self, use_conn):
|
||||
own = _tool(use_conn, name="brave")
|
||||
shared = _tool(use_conn, name="read_webpage")
|
||||
team = _share(use_conn, shared["id"], "bob")
|
||||
UserToolsRepository(use_conn).create("bob", "cryptoprice", status=True)
|
||||
names = lambda: sorted( # noqa: E731
|
||||
t["name"] for t in ToolExecutor(user="bob")._get_user_tools("bob").values() if t.get("user_id")
|
||||
)
|
||||
assert names() == ["cryptoprice"] # sharing alone never adds to chats
|
||||
UserToolPreferencesRepository(use_conn).set_in_chat("bob", str(shared["id"]), True)
|
||||
assert names() == ["cryptoprice", "read_webpage"]
|
||||
set_settings(use_conn, "tool", str(shared["id"]), {"viewers_can_use_in_agents": False}, OWNER)
|
||||
assert names() == ["cryptoprice"]
|
||||
set_settings(use_conn, "tool", str(shared["id"]), {"viewers_can_use_in_agents": True}, OWNER)
|
||||
_revoke(use_conn, team, shared["id"])
|
||||
assert names() == ["cryptoprice"]
|
||||
assert own["id"] # the owner's own tool never leaks into bob's chat
|
||||
|
||||
|
||||
class TestRuntimeCredentials:
|
||||
def test_mcp_tool_loads_with_owner_identity(self, monkeypatch):
|
||||
executor = ToolExecutor(user="bob")
|
||||
mock_tm = Mock()
|
||||
monkeypatch.setattr("docsgpt.agents.tool_executor.ToolManager", lambda config: mock_tm)
|
||||
tool_data = {"id": str(uuid.uuid4()), "name": "mcp_tool", "user_id": OWNER, "config": {}}
|
||||
executor._get_or_load_tool(tool_data, "t1", "act")
|
||||
assert mock_tm.load_tool.call_args.kwargs["user_id"] == OWNER
|
||||
|
||||
def test_api_tool_secret_values_decrypt_with_owner(self):
|
||||
from docsgpt.agents.tool_executor import api_tool_action_with_secrets
|
||||
|
||||
blob = encrypt_credentials({"a": {"headers": {"X-Key": "sk"}}}, OWNER)
|
||||
tool_data = {
|
||||
"user_id": OWNER,
|
||||
"config": {
|
||||
"encrypted_action_secrets": blob,
|
||||
"actions": {"a": {"url": "https://x", "headers": {"properties": {
|
||||
"X-Key": {"value": "", "has_value": True, "filled_by_llm": False}}}}},
|
||||
},
|
||||
}
|
||||
action = api_tool_action_with_secrets(tool_data, "a", "bob")
|
||||
assert action["headers"]["properties"]["X-Key"]["value"] == "sk"
|
||||
# The stored row is not mutated.
|
||||
assert tool_data["config"]["actions"]["a"]["headers"]["properties"]["X-Key"]["value"] == ""
|
||||
@@ -148,6 +148,11 @@ class TestHeadlessSchedulerExclusion:
|
||||
"get": lambda _self, _u: None,
|
||||
})(),
|
||||
)
|
||||
# No team tools switched into chats either.
|
||||
monkeypatch.setattr(
|
||||
te_module.ToolExecutor, "_shared_in_chat_tools",
|
||||
staticmethod(lambda _conn, _user: []),
|
||||
)
|
||||
|
||||
sched_id = default_tool_id("scheduler")
|
||||
|
||||
|
||||
@@ -152,6 +152,17 @@ def patched_wiki(monkeypatch, reembed_mock, rebuild_mock):
|
||||
task = MagicMock()
|
||||
task.delay = reembed_mock
|
||||
monkeypatch.setattr("docsgpt.api.user.tasks.reembed_wiki_page", task)
|
||||
# Every write re-checks the caller's live grant; default to editor.
|
||||
monkeypatch.setattr(
|
||||
"docsgpt.api.user.resource_access.resolve",
|
||||
lambda conn, rt, rid, uid: _access("editor"),
|
||||
)
|
||||
|
||||
|
||||
def _access(level):
|
||||
from docsgpt.api.user.resource_access import build
|
||||
|
||||
return build("source", "src-1", level, "owner-sub", {})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -194,6 +205,54 @@ class TestBasics:
|
||||
assert wiki_tool.updated_by == "caller-sub"
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Live grant re-check on writes
|
||||
# =====================================================================
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLiveWriteCheck:
|
||||
@pytest.mark.parametrize("revoked", [None, "viewer"])
|
||||
def test_revoked_or_viewer_cannot_write(self, wiki_tool, monkeypatch, reembed_mock, revoked):
|
||||
wiki_tool.execute_action("create", path="/a.md", content="one")
|
||||
monkeypatch.setattr(
|
||||
"docsgpt.api.user.resource_access.resolve",
|
||||
lambda conn, rt, rid, uid: _access(revoked) if revoked else None,
|
||||
)
|
||||
reembed_mock.reset_mock()
|
||||
for action, kwargs in (
|
||||
("create", {"path": "/b.md", "content": "x"}),
|
||||
("str_replace", {"path": "/a.md", "old_str": "one", "new_str": "two"}),
|
||||
("insert", {"path": "/a.md", "insert_line": 1, "insert_text": "x"}),
|
||||
("delete", {"path": "/a.md"}),
|
||||
("rename", {"old_path": "/a.md", "new_path": "/c.md"}),
|
||||
):
|
||||
result = wiki_tool.execute_action(action, **kwargs)
|
||||
assert "no longer have edit access" in result, action
|
||||
reembed_mock.assert_not_called()
|
||||
# Reads still work.
|
||||
assert "one" in wiki_tool.execute_action("view", path="/a.md")
|
||||
|
||||
def test_checks_the_invoking_user(self, wiki_tool, monkeypatch):
|
||||
seen = []
|
||||
|
||||
def _resolve(conn, rt, rid, uid):
|
||||
seen.append((rt, rid, uid))
|
||||
return _access("editor")
|
||||
|
||||
monkeypatch.setattr("docsgpt.api.user.resource_access.resolve", _resolve)
|
||||
wiki_tool.execute_action("create", path="/a.md", content="x")
|
||||
assert seen == [("source", "src-1", "caller-sub")]
|
||||
|
||||
def test_no_caller_denied(self, patched_wiki):
|
||||
from docsgpt.agents.tools.wiki import WikiTool
|
||||
|
||||
tool = WikiTool({"source_id": "src-1", "source_owner_id": "owner-sub"})
|
||||
assert "no longer have edit access" in tool.execute_action(
|
||||
"create", path="/a.md", content="x"
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# create / view
|
||||
# =====================================================================
|
||||
@@ -498,8 +557,8 @@ class TestBuildAgentGating:
|
||||
_noop_conn,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"docsgpt.api.user.team_sharing.effective_write_owner",
|
||||
lambda conn, rt, rid, uid: "owner-x",
|
||||
"docsgpt.api.answer.services.stream_processor._wiki_write_owner",
|
||||
lambda conn, sid, uid: "owner-x",
|
||||
)
|
||||
cfg = proc._build_wiki_config()
|
||||
assert cfg is not None
|
||||
@@ -524,10 +583,10 @@ class TestBuildAgentGating:
|
||||
"docsgpt.api.answer.services.stream_processor.db_readonly",
|
||||
_noop_conn,
|
||||
)
|
||||
# Viewer: effective_write_owner returns None.
|
||||
# Viewer: no ``edit`` on the source, so no write owner.
|
||||
monkeypatch.setattr(
|
||||
"docsgpt.api.user.team_sharing.effective_write_owner",
|
||||
lambda conn, rt, rid, uid: None,
|
||||
"docsgpt.api.answer.services.stream_processor._wiki_write_owner",
|
||||
lambda conn, sid, uid: None,
|
||||
)
|
||||
assert proc._build_wiki_config() is None
|
||||
|
||||
@@ -550,8 +609,8 @@ class TestBuildAgentGating:
|
||||
_noop_conn,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"docsgpt.api.user.team_sharing.effective_write_owner",
|
||||
lambda conn, rt, rid, uid: "owner-x",
|
||||
"docsgpt.api.answer.services.stream_processor._wiki_write_owner",
|
||||
lambda conn, sid, uid: "owner-x",
|
||||
)
|
||||
assert proc._build_wiki_config() is None
|
||||
|
||||
@@ -574,8 +633,8 @@ class TestBuildAgentGating:
|
||||
_noop_conn,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"docsgpt.api.user.team_sharing.effective_write_owner",
|
||||
lambda conn, rt, rid, uid: "owner-x",
|
||||
"docsgpt.api.answer.services.stream_processor._wiki_write_owner",
|
||||
lambda conn, sid, uid: "owner-x",
|
||||
)
|
||||
cfg = proc._build_wiki_config()
|
||||
assert cfg is not None
|
||||
|
||||
Reference in new issue
Block a user