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:
arc53-machine committed 2026-09-29 14:02:26 +01:00
commit a1789d2ab4
167 files changed
+16909 -2126

No files matched your search

+4 -1
View File
@@ -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"
+155
View File
@@ -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")
+68 -9
View File
@@ -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