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

@@ -0,0 +1,118 @@
"""Prompt and wiki-tool access in the answer pipeline (live grant checks)."""
from __future__ import annotations
import uuid
from contextlib import contextmanager
import pytest
from docsgpt.storage.db.repositories.prompts import PromptsRepository
from docsgpt.storage.db.repositories.sources import SourcesRepository
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
OWNER = "alice"
@pytest.fixture
def use_conn(monkeypatch, pg_conn):
@contextmanager
def _yield():
yield pg_conn
monkeypatch.setattr("docsgpt.api.answer.services.stream_processor.db_readonly", _yield)
return pg_conn
def _share(conn, rtype, rid, 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"]), rtype, str(rid), OWNER, OWNER, access_level=level)
return team
def _processor(data, caller):
from docsgpt.api.answer.services.stream_processor import StreamProcessor
return StreamProcessor(data, {"sub": caller})
class TestAgentlessPrompt:
def test_own_prompt_kept(self, use_conn):
pid = str(PromptsRepository(use_conn).create(OWNER, "P", "C")["id"])
proc = _processor({"question": "q", "prompt_id": pid}, OWNER)
proc._configure_agent()
assert proc.agent_config["prompt_id"] == pid
def test_shared_prompt_kept_then_falls_back_when_revoked(self, use_conn):
pid = str(PromptsRepository(use_conn).create(OWNER, "P", "C")["id"])
team = _share(use_conn, "prompt", pid, "bob")
proc = _processor({"question": "q", "prompt_id": pid}, "bob")
proc._configure_agent()
assert proc.agent_config["prompt_id"] == pid
TeamResourceGrantsRepository(use_conn).revoke(str(team["id"]), "prompt", pid)
proc = _processor({"question": "q", "prompt_id": pid}, "bob")
proc._configure_agent()
assert proc.agent_config["prompt_id"] == "default"
def test_strangers_prompt_falls_back_to_default(self, use_conn):
pid = str(PromptsRepository(use_conn).create(OWNER, "P", "secret")["id"])
proc = _processor({"question": "q", "prompt_id": pid}, "eve")
proc._configure_agent()
assert proc.agent_config["prompt_id"] == "default"
def test_presets_pass_through_without_db(self):
proc = _processor({"question": "q", "prompt_id": "creative"}, "eve")
proc._configure_agent()
assert proc.agent_config["prompt_id"] == "creative"
class TestAgentPrompt:
def _run(self, monkeypatch, agent_owner, prompt_id, caller="carol"):
from docsgpt.api.answer.services.stream_processor import StreamProcessor
monkeypatch.setattr(
StreamProcessor, "_get_data_from_api_key",
lambda self, key: {"prompt_id": prompt_id, "user": agent_owner, "_id": None},
)
proc = _processor({"question": "q", "api_key": "k"}, caller)
proc._configure_agent()
return proc.agent_config["prompt_id"]
def test_prompt_checked_against_agent_owner(self, use_conn, monkeypatch):
pid = str(PromptsRepository(use_conn).create(OWNER, "P", "C")["id"])
# Bob's agent uses Alice's prompt: allowed only while Bob has ``use``.
assert self._run(monkeypatch, "bob", pid) == "default"
_share(use_conn, "prompt", pid, "bob")
assert self._run(monkeypatch, "bob", pid) == pid
assert self._run(monkeypatch, OWNER, pid) == pid
def test_deleted_prompt_falls_back(self, use_conn, monkeypatch):
assert self._run(monkeypatch, OWNER, str(uuid.uuid4())) == "default"
class TestWikiConfigAccess:
def _wiki(self, conn):
return SourcesRepository(conn).create("W", user_id=OWNER, config={"kind": "wiki"})
def _cfg(self, conn, caller, sid):
from docsgpt.api.answer.services.stream_processor import StreamProcessor
proc = StreamProcessor.__new__(StreamProcessor)
proc.all_sources = [{"id": sid}]
proc.decoded_token = {"sub": caller}
return proc._build_wiki_config()
def test_owner_and_editor_get_tool_viewer_does_not(self, use_conn):
sid = str(self._wiki(use_conn)["id"])
assert self._cfg(use_conn, OWNER, sid)["source_owner_id"] == OWNER
_share(use_conn, "source", sid, "ed", "editor")
_share(use_conn, "source", sid, "vi", "viewer")
cfg = self._cfg(use_conn, "ed", sid)
assert cfg["source_owner_id"] == OWNER and cfg["user"] == "ed"
assert self._cfg(use_conn, "vi", sid) is None
assert self._cfg(use_conn, "eve", sid) is None
+36 -4
View File
@@ -27,6 +27,34 @@ def _cm(value):
yield value
def _access_patches(sub, repo, team_access):
"""Stand in for ``resource_access``: owner when the repo owns the row,
the team level when given, else not visible."""
from docsgpt.api.user.resource_access import AccessDenied, build
def _resolve(_conn, resource_type, resource_id, user_id):
if repo.get_any.return_value:
return build(resource_type, resource_id, "owner", user_id, {})
if team_access:
return build(resource_type, resource_id, team_access, "owner-x", {})
return None
def _require(conn, resource_type, resource_id, user_id, action):
ra = _resolve(conn, resource_type, resource_id, user_id)
if ra is None:
raise AccessDenied(404, "Agent not found")
if not ra.can(action):
raise AccessDenied(403, "Your access to this item doesn't allow that")
return ra
if repo.get_any.return_value and isinstance(repo.get_by_id.return_value, Mock):
repo.get_by_id.return_value = repo.get_any.return_value
return [
patch("docsgpt.api.user.agents.routes.resolve", _resolve),
patch("docsgpt.api.user.agents.routes.require", _require),
]
def _patches(sub, repo, team_access, *, prompt_name="Resolved Prompt", source_details=None):
if source_details is None:
source_details = []
@@ -35,7 +63,7 @@ def _patches(sub, repo, team_access, *, prompt_name="Resolved Prompt", source_de
patch("docsgpt.app.resolve_roles", return_value=["user"]),
patch("docsgpt.api.user.agents.routes.db_readonly", lambda: _cm(Mock())),
patch("docsgpt.api.user.agents.routes.AgentsRepository", return_value=repo),
patch("docsgpt.api.user.agents.routes.team_access_for", return_value=team_access),
*_access_patches(sub, repo, team_access),
# Resolve names by id (owner-agnostic) — patched so the test never
# touches the DB; the route is what we're asserting wires them in.
patch(
@@ -70,7 +98,7 @@ class TestGetAgentTeamFallback:
data = json.loads(resp.data)
assert data["ownership"] == "user"
assert data["team_access"] is None
repo.get_by_id.assert_not_called()
assert data["access"] == "owner"
def test_team_member_sees_shared_agent(self, client):
aid = str(uuid.uuid4())
@@ -92,7 +120,8 @@ class TestGetAgentTeamFallback:
# Owner's public share token / API key are blanked for grantees.
assert data["shared_token"] == ""
assert data.get("key", "") == ""
# get_by_id only reached AFTER the team grant check.
assert data["access"] == "viewer"
# get_by_id only reached AFTER the access check.
repo.get_by_id.assert_called_once_with(aid)
def test_no_access_returns_404(self, client):
@@ -164,8 +193,11 @@ def _update_patches(sub, repo, team_access, can_access_mock):
patch("docsgpt.app.resolve_roles", return_value=["user"]),
patch("docsgpt.api.user.agents.routes.db_session", lambda: _cm(Mock())),
patch("docsgpt.api.user.agents.routes.AgentsRepository", return_value=repo),
patch("docsgpt.api.user.agents.routes.team_access_for", return_value=team_access),
*_access_patches(sub, repo, team_access),
patch("docsgpt.api.user.agents.routes.can_access", can_access_mock),
# Sponsor bookkeeping queries the (mocked) connection; covered with a
# real database in tests/api/user/test_resource_sponsors.py.
patch("docsgpt.api.user.agents.routes.sponsors_after_save", return_value={}),
]
+20 -2
View File
@@ -490,19 +490,37 @@ class TestConnectorSync:
r = ConnectorSync().post()
assert r.status_code == 401
def test_returns_400_missing_fields(self, app):
def test_returns_400_missing_source_id(self, app):
from docsgpt.api.connector.routes import ConnectorSync
# The source's own connection is used when none is named, so only
# the source id is required.
with app.test_request_context(
"/api/connectors/sync", method="POST", json={}
"/api/connectors/sync", method="POST", json={"session_token": "y"}
):
from flask import request
request.decoded_token = {"sub": "u"}
r = ConnectorSync().post()
assert r.status_code == 400
def test_owner_without_a_connection_returns_400(self, app, pg_conn):
# The owner names a connection (or the source has its own); team
# editors don't send one (their sync uses the owner's connection).
from docsgpt.api.connector.routes import ConnectorSync
from docsgpt.storage.db.repositories.sources import SourcesRepository
user = "u-sync-notoken"
src = SourcesRepository(pg_conn).create(
"s", user_id=user, remote_data={"provider": "github"}
)
with _patch_db(pg_conn), app.test_request_context(
"/api/connectors/sync", method="POST", json={"source_id": str(src["id"])}
):
from flask import request
request.decoded_token = {"sub": user}
r = ConnectorSync().post()
assert r.status_code == 400
def test_returns_404_source_not_found(self, app, pg_conn):
from docsgpt.api.connector.routes import ConnectorSync
+24 -9
View File
@@ -56,13 +56,16 @@ _CONVERSATIONS = "docsgpt.api.user.conversations.routes"
@pytest.mark.unit
class TestSourceAudit:
def test_delete_records_source_deleted(self, client):
repo = Mock()
repo.get_any.return_value = {"id": "src-1", "name": "Handbook"}
from docsgpt.api.user.resource_access import build
doc = {"id": "src-1", "name": "Handbook"}
ra = build("source", "src-1", "owner", "u1", {})
storage = Mock()
storage.file_exists.return_value = False
with _authed(
_SOURCES,
SourcesRepository=Mock(return_value=repo),
load_source=Mock(return_value=(doc, ra)),
SourcesRepository=Mock(return_value=Mock()),
StorageCreator=Mock(get_storage=Mock(return_value=storage)),
) as recorded:
resp = client.get("/api/delete_old?source_id=src-1")
@@ -72,9 +75,12 @@ class TestSourceAudit:
assert recorded[0][3]["name"] == "Handbook"
def test_nothing_recorded_when_the_source_is_missing(self, client):
repo = Mock()
repo.get_any.return_value = None
with _authed(_SOURCES, SourcesRepository=Mock(return_value=repo)) as recorded:
from docsgpt.api.user.resource_access import AccessDenied
with _authed(
_SOURCES,
load_source=Mock(side_effect=AccessDenied(404, "Source not found")),
) as recorded:
resp = client.get("/api/delete_old?source_id=nope")
assert resp.status_code == 404
assert recorded == []
@@ -158,8 +164,10 @@ class TestRemoteSourceAudit:
@pytest.mark.unit
class TestAgentAudit:
def test_delete_records_agent_deleted(self, client):
from docsgpt.api.user.resource_access import build
repo = Mock()
repo.get_any.return_value = {
repo.get_by_id.return_value = {
"id": "agent-1",
"name": "Support bot",
"agent_type": "classic",
@@ -169,6 +177,8 @@ class TestAgentAudit:
AgentsRepository=Mock(return_value=repo),
WorkflowsRepository=Mock(),
UsersRepository=Mock(),
require=Mock(return_value=build("agent", "agent-1", "owner", "u1", {})),
delete_settings=Mock(),
) as recorded:
resp = client.delete("/api/delete_agent?id=agent-1")
assert resp.status_code == 200
@@ -177,9 +187,14 @@ class TestAgentAudit:
assert recorded[0][3]["name"] == "Support bot"
def test_missing_agent_records_nothing(self, client):
from docsgpt.api.user.resource_access import AccessDenied
repo = Mock()
repo.get_any.return_value = None
with _authed(_AGENTS, AgentsRepository=Mock(return_value=repo)) as recorded:
with _authed(
_AGENTS,
AgentsRepository=Mock(return_value=repo),
require=Mock(side_effect=AccessDenied(404, "Agent not found")),
) as recorded:
assert client.delete("/api/delete_agent?id=x").status_code == 404
assert recorded == []
+17 -9
View File
@@ -3,7 +3,7 @@
These drive the real app.py chokepoint + team_authz plane (only handle_auth /
resolve_roles and the leaf repos are mocked), so they catch regressions in the
authorization wiring: who can read/manage a team, that team_id comes from the
URL path (never the body), and that sharing requires resource ownership. Data
URL path (never the body), and that sharing requires the ``share`` action. Data
correctness lives in tests/storage/db/repositories/test_teams.py.
"""
@@ -15,6 +15,11 @@ from unittest.mock import Mock, patch
import pytest
from docsgpt.api.user.resource_access import AccessDenied, build
# What ``require(..., "share")`` returns for the resource owner.
_OWNER_RA = build("agent", "22222222-2222-2222-2222-222222222222", "owner", "alice", {})
@pytest.fixture
def client():
@@ -234,10 +239,13 @@ class TestTeamAccessControl:
@pytest.mark.unit
class TestSharingAuthz:
def test_share_requires_ownership(self, client):
def test_share_requires_share_action(self, client):
patches = _auth(sub="bob", team_role="team_member") + [
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("docsgpt.api.user.teams.routes.owns_resource", return_value=False),
patch(
"docsgpt.api.user.teams.routes.require",
side_effect=AccessDenied(403, "no share"),
),
]
_apply(patches)
try:
@@ -257,7 +265,7 @@ class TestSharingAuthz:
grants_repo.grant.return_value = {"id": "g1", "access_level": "viewer"}
patches = _auth(sub="alice", team_role="team_member") + [
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("docsgpt.api.user.teams.routes.owns_resource", return_value=True),
patch("docsgpt.api.user.teams.routes.require", return_value=_OWNER_RA),
patch(
"docsgpt.api.user.teams.routes.TeamResourceGrantsRepository",
return_value=grants_repo,
@@ -299,7 +307,7 @@ class TestSharingAuthz:
members_repo.is_member.return_value = False
patches = _auth(sub="alice", team_role="team_member") + [
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("docsgpt.api.user.teams.routes.owns_resource", return_value=True),
patch("docsgpt.api.user.teams.routes.require", return_value=_OWNER_RA),
patch(
"docsgpt.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
@@ -326,7 +334,7 @@ class TestSharingAuthz:
grants_repo.grant.return_value = {"id": "g1", "target_user_id": "bob"}
patches = _auth(sub="alice", team_role="team_member") + [
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("docsgpt.api.user.teams.routes.owns_resource", return_value=True),
patch("docsgpt.api.user.teams.routes.require", return_value=_OWNER_RA),
patch(
"docsgpt.api.user.teams.routes.TeamMembersRepository",
return_value=members_repo,
@@ -357,7 +365,7 @@ class TestSharingAuthz:
# rejected cleanly, not cast-and-poison the txn into a generic error.
patches = _auth(sub="alice", team_role="team_member") + [
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("docsgpt.api.user.teams.routes.owns_resource", return_value=True),
patch("docsgpt.api.user.teams.routes.require", return_value=_OWNER_RA),
]
_apply(patches)
try:
@@ -480,7 +488,7 @@ class TestTeamNotifications:
patches = _auth(sub="alice", team_role="team_member") + [
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("docsgpt.api.user.teams.routes.db_readonly", lambda: _cm(Mock())),
patch("docsgpt.api.user.teams.routes.owns_resource", return_value=True),
patch("docsgpt.api.user.teams.routes.require", return_value=_OWNER_RA),
patch(
"docsgpt.api.user.teams.routes.TeamResourceGrantsRepository",
return_value=grants_repo,
@@ -531,7 +539,7 @@ class TestTeamNotifications:
patches = _auth(sub="alice", team_role="team_member") + [
patch("docsgpt.api.user.teams.routes.db_session", lambda: _cm(Mock())),
patch("docsgpt.api.user.teams.routes.db_readonly", lambda: _cm(Mock())),
patch("docsgpt.api.user.teams.routes.owns_resource", return_value=True),
patch("docsgpt.api.user.teams.routes.require", return_value=_OWNER_RA),
patch(
"docsgpt.api.user.teams.routes.TeamResourceGrantsRepository",
return_value=grants_repo,
+715
View File
@@ -0,0 +1,715 @@
"""Role-based access (owner / editor / viewer / stranger) on the agent routes.
Every per-agent endpoint answers through ``resource_access.require``: 404 when
the caller can't see the agent, 403 when their role can't do the action, and
writes land as the agent's owner. Uses real repositories on ``pg_conn``.
"""
from __future__ import annotations
import uuid
from contextlib import ExitStack, contextmanager
from unittest.mock import patch
import pytest
from flask import Flask
from docsgpt.api.user.resource_access import set_settings, settings_for
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
OWNER, EDITOR, VIEWER, STRANGER = "r-owner", "r-editor", "r-viewer", "r-stranger"
_DB_MODULES = (
"docsgpt.api.user.agents.routes",
"docsgpt.api.user.agents.sharing",
"docsgpt.api.user.agents.webhooks",
"docsgpt.api.user.agents.guardrails",
"docsgpt.api.user.agents.folders",
"docsgpt.api.user.agents.portability",
"docsgpt.api.user.schedules.routes",
"docsgpt.api.user.workflows.routes",
"docsgpt.api.user.analytics.routes",
"docsgpt.api.user.base",
)
@pytest.fixture
def app():
return Flask(__name__)
@contextmanager
def _patch_db(conn):
import importlib
@contextmanager
def _yield():
yield conn
with ExitStack() as stack:
for mod_name in _DB_MODULES:
mod = importlib.import_module(mod_name)
for attr in ("db_session", "db_readonly"):
if hasattr(mod, attr):
stack.enter_context(patch(f"{mod_name}.{attr}", _yield))
yield
def _call(app, conn, resource_cls, method, path, user, *, json=None, args=()):
kwargs = {"method": method.upper()}
if json is not None:
kwargs["json"] = json
with _patch_db(conn), app.test_request_context(path, **kwargs):
from flask import request
request.decoded_token = {"sub": user} if user else None
return getattr(resource_cls(), method.lower())(*args)
def _status(resp) -> int:
return resp[1] if isinstance(resp, tuple) else resp.status_code
def _json(resp):
return resp[0] if isinstance(resp, tuple) else resp.get_json()
def _team_share(conn, resource_type, resource_id, owner=OWNER):
"""Share with a fresh team: EDITOR as editor, VIEWER as viewer."""
team = TeamsRepository(conn).create("T", f"t-{uuid.uuid4().hex[:8]}", owner)
tid = str(team["id"])
members = TeamMembersRepository(conn)
grants = TeamResourceGrantsRepository(conn)
for member, level in ((EDITOR, "editor"), (VIEWER, "viewer")):
members.add_member(tid, member)
grants.grant(tid, resource_type, resource_id, owner, owner,
access_level=level, target_user_id=member)
return tid
def _agent(conn, *, status="published", key="abcd1234wxyz", share=True, **extra):
extra.setdefault("agent_type", "classic")
extra.setdefault("chunks", 6)
row = AgentsRepository(conn).create(
OWNER, "Shared", status, description="d", key=key, **extra
)
agent_id = str(row["id"])
if share:
_team_share(conn, "agent", agent_id)
return agent_id
def _row(conn, agent_id):
return AgentsRepository(conn).get_by_id(agent_id)
# ---------------------------------------------------------------------------
# get_agent / get_agents
# ---------------------------------------------------------------------------
class TestGetAgent:
def _get(self, app, conn, agent_id, user):
from docsgpt.api.user.agents.routes import GetAgent
return _call(app, conn, GetAgent, "get", f"/api/get_agent?id={agent_id}", user)
def test_owner_gets_owner_access_and_masked_key(self, app, pg_conn):
agent_id = _agent(pg_conn)
data = _json(self._get(app, pg_conn, agent_id, OWNER))
assert data["access"] == "owner"
assert "delete" in data["allowed_actions"]
assert data["key"] == "abcd...wxyz"
def test_editor_gets_editor_access_and_masked_key(self, app, pg_conn):
agent_id = _agent(pg_conn, config={"guardrails": {"enabled": True}})
resp = self._get(app, pg_conn, agent_id, EDITOR)
assert _status(resp) == 200
data = _json(resp)
assert data["access"] == "editor"
assert data["allowed_actions"] == sorted(data["allowed_actions"])
assert "edit" in data["allowed_actions"]
assert "delete" not in data["allowed_actions"]
assert data["key"] == "abcd...wxyz"
assert data["config"] == {"guardrails": {"enabled": True}}
assert data["ownership"] == "team"
def test_editor_without_access_details_gets_no_key(self, app, pg_conn):
agent_id = _agent(pg_conn)
set_settings(pg_conn, "agent", agent_id,
{"editors_can_manage_access_details": False}, OWNER)
data = _json(self._get(app, pg_conn, agent_id, EDITOR))
assert "key" not in data or data["key"] == ""
assert data["shared_token"] == ""
def test_viewer_reads_for_chat_without_secrets_or_policy(self, app, pg_conn):
agent_id = _agent(pg_conn, config={"guardrails": {"enabled": True}})
resp = self._get(app, pg_conn, agent_id, VIEWER)
assert _status(resp) == 200
data = _json(resp)
assert data["access"] == "viewer"
assert data["allowed_actions"] == ["pin", "use"]
assert data.get("key", "") == ""
assert data["config"] == {}
def test_stranger_404(self, app, pg_conn):
agent_id = _agent(pg_conn)
assert _status(self._get(app, pg_conn, agent_id, STRANGER)) == 404
class TestGetAgents:
def test_rows_carry_access(self, app, pg_conn):
from docsgpt.api.user.agents.routes import GetAgents
shared_id = _agent(pg_conn)
own = AgentsRepository(pg_conn).create(EDITOR, "Mine", "draft")
resp = _call(app, pg_conn, GetAgents, "get", "/api/get_agents", EDITOR)
rows = {r["id"]: r for r in _json(resp)}
assert rows[str(own["id"])]["access"] == "owner"
assert "delete" in rows[str(own["id"])]["allowed_actions"]
assert rows[shared_id]["access"] == "editor"
assert "edit" in rows[shared_id]["allowed_actions"]
assert rows[shared_id]["team_access"] == "editor"
# ---------------------------------------------------------------------------
# update_agent
# ---------------------------------------------------------------------------
class TestUpdateAgent:
def _put(self, app, conn, agent_id, user, body):
from docsgpt.api.user.agents.routes import UpdateAgent
return _call(app, conn, UpdateAgent, "put", f"/api/update_agent/{agent_id}",
user, json=body, args=(agent_id,))
def test_editor_edits_as_owner(self, app, pg_conn):
agent_id = _agent(pg_conn)
resp = self._put(app, pg_conn, agent_id, EDITOR, {"name": "Renamed"})
assert _status(resp) == 200
row = _row(pg_conn, agent_id)
assert row["name"] == "Renamed"
assert row["user_id"] == OWNER
def test_viewer_403_stranger_404(self, app, pg_conn):
agent_id = _agent(pg_conn)
assert _status(self._put(app, pg_conn, agent_id, VIEWER, {"name": "x"})) == 403
assert _status(self._put(app, pg_conn, agent_id, STRANGER, {"name": "x"})) == 404
def test_editor_policy_change_is_applied_not_dropped(self, app, pg_conn):
agent_id = _agent(pg_conn)
body = {
"config": {"guardrails": {"enabled": True}},
"limited_request_mode": True,
"request_limit": 7,
}
assert _status(self._put(app, pg_conn, agent_id, EDITOR, body)) == 200
row = _row(pg_conn, agent_id)
assert row["config"]["guardrails"]["enabled"] is True
assert row["request_limit"] == 7
def test_editor_publish_mints_key_and_returns_it(self, app, pg_conn):
agent_id = _agent(pg_conn, status="draft", key="")
resp = self._put(app, pg_conn, agent_id, EDITOR, {"status": "published"})
assert _status(resp) == 200
assert _json(resp).get("key")
def test_key_withheld_when_access_details_switch_off(self, app, pg_conn):
agent_id = _agent(pg_conn, status="draft", key="")
set_settings(pg_conn, "agent", agent_id,
{"editors_can_manage_access_details": False}, OWNER)
resp = self._put(app, pg_conn, agent_id, EDITOR, {"status": "published"})
assert _status(resp) == 200
assert "key" not in _json(resp)
assert _row(pg_conn, agent_id)["key"]
def test_editor_cannot_move_folder(self, app, pg_conn):
from docsgpt.storage.db.repositories.agent_folders import AgentFoldersRepository
agent_id = _agent(pg_conn)
folder = AgentFoldersRepository(pg_conn).create(OWNER, "F")
resp = self._put(app, pg_conn, agent_id, EDITOR, {"folder_id": str(folder["id"])})
assert _status(resp) == 403
def test_editor_saving_unchanged_folder_is_fine(self, app, pg_conn):
from docsgpt.storage.db.repositories.agent_folders import AgentFoldersRepository
folder = AgentFoldersRepository(pg_conn).create(OWNER, "F")
agent_id = _agent(pg_conn, folder_id=str(folder["id"]))
resp = self._put(app, pg_conn, agent_id, EDITOR,
{"name": "n", "folder_id": str(folder["id"])})
assert _status(resp) == 200
def test_workflow_validated_against_owner_and_owner_only_to_change(self, app, pg_conn):
from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
wf = WorkflowsRepository(pg_conn).create(OWNER, "wf")
agent_id = _agent(pg_conn, agent_type="workflow")
# An editor can't point the agent at another of the owner's workflows.
resp = self._put(app, pg_conn, agent_id, EDITOR, {"workflow": str(wf["id"])})
assert _status(resp) == 403
assert _row(pg_conn, agent_id)["workflow_id"] is None
resp = self._put(app, pg_conn, agent_id, OWNER, {"workflow": str(wf["id"])})
assert _status(resp) == 200
assert str(_row(pg_conn, agent_id)["workflow_id"]) == str(wf["id"])
# Re-sending the current one is a plain save.
resp = self._put(app, pg_conn, agent_id, EDITOR, {"workflow": str(wf["id"])})
assert _status(resp) == 200
mine = WorkflowsRepository(pg_conn).create(EDITOR, "editor-wf")
resp = self._put(app, pg_conn, agent_id, EDITOR, {"workflow": str(mine["id"])})
assert _status(resp) == 404
def test_tool_gate(self, app, pg_conn):
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
tools = UserToolsRepository(pg_conn)
owner_tool = str(tools.create(OWNER, "api_tool")["id"])
foreign_tool = str(tools.create(STRANGER, "api_tool")["id"])
shared_tool = str(tools.create(STRANGER, "api_tool")["id"])
_team_share(pg_conn, "tool", shared_tool, owner=STRANGER)
agent_id = _agent(pg_conn)
# The owner owning a tool isn't enough: it must reach the editor.
denied = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [owner_tool]})
assert _status(denied) == 403
denied = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [foreign_tool]})
assert _status(denied) == 403
assert _status(self._put(app, pg_conn, agent_id, OWNER, {"tools": [owner_tool]})) == 200
ok = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [owner_tool, shared_tool]})
assert _status(ok) == 200
set_settings(pg_conn, "tool", shared_tool, {"viewers_can_use_in_agents": False}, STRANGER)
# Already attached: keeping it is fine.
assert _status(self._put(app, pg_conn, agent_id, EDITOR,
{"tools": [owner_tool, shared_tool]})) == 200
class TestCreateAgentToolGate:
def test_foreign_tool_rejected(self, app, pg_conn):
from docsgpt.api.user.agents.routes import CreateAgent
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
foreign_tool = str(UserToolsRepository(pg_conn).create(STRANGER, "api_tool")["id"])
body = {"name": "n", "status": "draft", "tools": [foreign_tool]}
resp = _call(app, pg_conn, CreateAgent, "post", "/api/create_agent", OWNER, json=body)
assert _status(resp) == 403
def test_own_tool_accepted(self, app, pg_conn):
from docsgpt.api.user.agents.routes import CreateAgent
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
tool = str(UserToolsRepository(pg_conn).create(OWNER, "api_tool")["id"])
body = {"name": "n", "status": "draft", "tools": [tool]}
resp = _call(app, pg_conn, CreateAgent, "post", "/api/create_agent", OWNER, json=body)
assert _status(resp) == 201
# ---------------------------------------------------------------------------
# access details: key, webhook, public link
# ---------------------------------------------------------------------------
class TestAccessDetails:
def _regen(self, app, conn, agent_id, user):
from docsgpt.api.user.agents.routes import RegenerateAgentKey
return _call(app, conn, RegenerateAgentKey, "post",
f"/api/regenerate_agent_key/{agent_id}", user, args=(agent_id,))
def test_regenerate_key_roles(self, app, pg_conn):
agent_id = _agent(pg_conn)
assert _status(self._regen(app, pg_conn, agent_id, STRANGER)) == 404
assert _status(self._regen(app, pg_conn, agent_id, VIEWER)) == 403
resp = self._regen(app, pg_conn, agent_id, EDITOR)
assert _status(resp) == 200
assert _row(pg_conn, agent_id)["key"] == _json(resp)["key"]
set_settings(pg_conn, "agent", agent_id,
{"editors_can_manage_access_details": False}, OWNER)
assert _status(self._regen(app, pg_conn, agent_id, EDITOR)) == 403
def test_webhook_roles(self, app, pg_conn):
from docsgpt.api.user.agents.webhooks import AgentWebhook
agent_id = _agent(pg_conn)
path = f"/api/agent_webhook?id={agent_id}"
assert _status(_call(app, pg_conn, AgentWebhook, "get", path, VIEWER)) == 403
assert _status(_call(app, pg_conn, AgentWebhook, "get", path, STRANGER)) == 404
resp = _call(app, pg_conn, AgentWebhook, "get", path, EDITOR)
assert _status(resp) == 200
assert _row(pg_conn, agent_id)["incoming_webhook_token"]
def test_share_link_roles(self, app, pg_conn):
from docsgpt.api.user.agents.sharing import ShareAgent
agent_id = _agent(pg_conn)
body = {"id": agent_id, "shared": True}
assert _status(_call(app, pg_conn, ShareAgent, "put", "/api/share_agent",
VIEWER, json=body)) == 403
assert _status(_call(app, pg_conn, ShareAgent, "put", "/api/share_agent",
STRANGER, json=body)) == 404
resp = _call(app, pg_conn, ShareAgent, "put", "/api/share_agent", EDITOR, json=body)
assert _status(resp) == 200
assert _row(pg_conn, agent_id)["shared"] is True
# ---------------------------------------------------------------------------
# delete / export / folders
# ---------------------------------------------------------------------------
class TestDeleteAgent:
def _delete(self, app, conn, agent_id, user):
from docsgpt.api.user.agents.routes import DeleteAgent
return _call(app, conn, DeleteAgent, "delete", f"/api/delete_agent?id={agent_id}", user)
def test_editor_403_by_default(self, app, pg_conn):
agent_id = _agent(pg_conn)
assert _status(self._delete(app, pg_conn, agent_id, EDITOR)) == 403
assert _status(self._delete(app, pg_conn, agent_id, STRANGER)) == 404
assert _row(pg_conn, agent_id) is not None
def test_editor_with_switch_deletes_and_settings_go(self, app, pg_conn):
from sqlalchemy import text
agent_id = _agent(pg_conn)
set_settings(pg_conn, "agent", agent_id, {"editors_can_delete": True}, OWNER)
assert _status(self._delete(app, pg_conn, agent_id, EDITOR)) == 200
assert _row(pg_conn, agent_id) is None
assert settings_for(pg_conn, "agent", agent_id)["editors_can_delete"] is False
left = pg_conn.execute(
text("SELECT count(*) FROM team_resource_grants WHERE resource_id = CAST(:id AS uuid)"),
{"id": agent_id},
).scalar()
assert left == 0
class TestExportAgent:
def test_roles(self, app, pg_conn):
from docsgpt.api.user.agents.portability import ExportAgent
agent_id = _agent(pg_conn)
path = f"/api/export_agent?id={agent_id}"
assert _status(_call(app, pg_conn, ExportAgent, "get", path, VIEWER)) == 403
assert _status(_call(app, pg_conn, ExportAgent, "get", path, STRANGER)) == 404
assert _status(_call(app, pg_conn, ExportAgent, "get", path, EDITOR)) == 200
class TestFolderMoves:
def test_move_agent_owner_only(self, app, pg_conn):
from docsgpt.api.user.agents.folders import MoveAgentToFolder
agent_id = _agent(pg_conn)
body = {"agent_id": agent_id, "folder_id": None}
assert _status(_call(app, pg_conn, MoveAgentToFolder, "post",
"/api/agents/folders/move_agent", EDITOR, json=body)) == 403
assert _status(_call(app, pg_conn, MoveAgentToFolder, "post",
"/api/agents/folders/move_agent", STRANGER, json=body)) == 404
assert _status(_call(app, pg_conn, MoveAgentToFolder, "post",
"/api/agents/folders/move_agent", OWNER, json=body)) == 200
def test_bulk_move_reports_skipped(self, app, pg_conn):
from docsgpt.api.user.agents.folders import BulkMoveAgents
from docsgpt.storage.db.repositories.agent_folders import AgentFoldersRepository
shared_id = _agent(pg_conn)
own = str(AgentsRepository(pg_conn).create(EDITOR, "Mine", "draft")["id"])
folder = AgentFoldersRepository(pg_conn).create(EDITOR, "F")
body = {"agent_ids": [own, shared_id], "folder_id": str(folder["id"])}
resp = _call(app, pg_conn, BulkMoveAgents, "post", "/api/agents/folders/bulk_move",
EDITOR, json=body)
assert _status(resp) == 200
assert _json(resp)["moved"] == [own]
assert _json(resp)["skipped"] == [shared_id]
assert _row(pg_conn, shared_id)["folder_id"] is None
# ---------------------------------------------------------------------------
# pinned / link-shared lists
# ---------------------------------------------------------------------------
class TestListsCarryAccess:
def test_pinned_agents(self, app, pg_conn):
from docsgpt.api.user.agents.routes import PinAgent, PinnedAgents
agent_id = _agent(pg_conn)
assert _status(_call(app, pg_conn, PinAgent, "post",
f"/api/pin_agent?id={agent_id}", VIEWER)) == 200
rows = _json(_call(app, pg_conn, PinnedAgents, "get", "/api/pinned_agents", VIEWER))
assert rows[0]["access"] == "viewer"
assert rows[0]["allowed_actions"] == ["pin", "use"]
def test_link_shared_agent_is_viewer(self, app, pg_conn):
from docsgpt.api.user.agents.sharing import SharedAgent, SharedAgents
agent_id = _agent(pg_conn, share=False)
AgentsRepository(pg_conn).update(agent_id, OWNER, {"shared": True, "shared_token": "tok-r"})
resp = _call(app, pg_conn, SharedAgent, "get", "/api/shared_agent?token=tok-r", STRANGER)
data = _json(resp)
assert data["access"] == "viewer"
assert data["allowed_actions"] == ["pin", "use"]
assert "user" not in data
rows = _json(_call(app, pg_conn, SharedAgents, "get", "/api/shared_agents", STRANGER))
assert rows[0]["access"] == "viewer"
assert rows[0]["allowed_actions"] == ["pin", "use"]
def test_link_shared_agent_with_team_grant_uses_grant(self, app, pg_conn):
from docsgpt.api.user.agents.sharing import SharedAgent
agent_id = _agent(pg_conn)
AgentsRepository(pg_conn).update(agent_id, OWNER, {"shared": True, "shared_token": "tok-e"})
data = _json(_call(app, pg_conn, SharedAgent, "get", "/api/shared_agent?token=tok-e", EDITOR))
assert data["access"] == "editor"
assert "edit" in data["allowed_actions"]
# ---------------------------------------------------------------------------
# schedules
# ---------------------------------------------------------------------------
_RUN_NOW_TASK = type("T", (), {"apply_async": staticmethod(lambda **k: None)})
class TestSchedules:
def _create(self, app, conn, agent_id, user):
from docsgpt.api.user.schedules.routes import AgentSchedules
body = {"instruction": "do it", "cron": "0 9 * * *"}
return _call(app, conn, AgentSchedules, "post", f"/api/agents/{agent_id}/schedules",
user, json=body, args=(agent_id,))
def test_editor_creates_as_owner(self, app, pg_conn):
agent_id = _agent(pg_conn)
resp = self._create(app, pg_conn, agent_id, EDITOR)
assert _status(resp) == 201
assert _json(resp)["schedule"]["user_id"] == OWNER
def test_viewer_403_stranger_404(self, app, pg_conn):
from docsgpt.api.user.schedules.routes import AgentSchedules, AgentScheduleStats
agent_id = _agent(pg_conn)
assert _status(self._create(app, pg_conn, agent_id, VIEWER)) == 403
assert _status(self._create(app, pg_conn, agent_id, STRANGER)) == 404
path = f"/api/agents/{agent_id}/schedules"
assert _status(_call(app, pg_conn, AgentSchedules, "get", path, VIEWER,
args=(agent_id,))) == 403
assert _status(_call(app, pg_conn, AgentScheduleStats, "get", path + "/stats",
VIEWER, args=(agent_id,))) == 403
def test_editor_manages_owner_schedule(self, app, pg_conn):
from docsgpt.api.user.schedules.routes import (
AgentSchedules,
ScheduleResource,
ScheduleRunList,
ScheduleRunNow,
)
agent_id = _agent(pg_conn)
sid = _json(self._create(app, pg_conn, agent_id, OWNER))["schedule"]["id"]
listed = _call(app, pg_conn, AgentSchedules, "get", f"/api/agents/{agent_id}/schedules",
EDITOR, args=(agent_id,))
assert [s["id"] for s in _json(listed)["schedules"]] == [sid]
path = f"/api/schedules/{sid}"
assert _status(_call(app, pg_conn, ScheduleResource, "get", path, EDITOR,
args=(sid,))) == 200
assert _status(_call(app, pg_conn, ScheduleResource, "get", path, VIEWER,
args=(sid,))) == 403
assert _status(_call(app, pg_conn, ScheduleResource, "get", path, STRANGER,
args=(sid,))) == 404
put = _call(app, pg_conn, ScheduleResource, "put", path, EDITOR,
json={"name": "renamed"}, args=(sid,))
assert _json(put)["schedule"]["name"] == "renamed"
patched = _call(app, pg_conn, ScheduleResource, "patch", path, EDITOR,
json={"action": "pause"}, args=(sid,))
assert _json(patched)["schedule"]["status"] == "paused"
with patch("docsgpt.api.user.tasks.execute_scheduled_run", _RUN_NOW_TASK):
run = _call(app, pg_conn, ScheduleRunNow, "post", path + "/run", EDITOR, args=(sid,))
assert _status(run) == 202
assert _json(run)["run"]["user_id"] == OWNER
runs = _call(app, pg_conn, ScheduleRunList, "get", path + "/runs", EDITOR, args=(sid,))
assert len(_json(runs)["runs"]) == 1
assert _status(_call(app, pg_conn, ScheduleResource, "delete", path, VIEWER,
args=(sid,))) == 403
assert _status(_call(app, pg_conn, ScheduleResource, "delete", path, EDITOR,
args=(sid,))) == 200
# ---------------------------------------------------------------------------
# workflows
# ---------------------------------------------------------------------------
def _wf_body(name="WF"):
return {
"name": name,
"description": "d",
"nodes": [
{"id": "start1", "type": "start", "position": {"x": 0, "y": 0}, "data": {}},
{"id": "end1", "type": "end", "position": {"x": 100, "y": 0}, "data": {}},
],
"edges": [{"id": "e1", "source": "start1", "target": "end1"}],
}
class TestWorkflows:
def _setup(self, pg_conn):
from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
wf = WorkflowsRepository(pg_conn).create(OWNER, "wf")
_agent(pg_conn, agent_type="workflow", workflow_id=str(wf["id"]))
return str(wf["id"])
def test_roles(self, app, pg_conn):
from docsgpt.api.user.workflows.routes import WorkflowDetail
from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
wid = self._setup(pg_conn)
path = f"/api/workflows/{wid}"
assert _status(_call(app, pg_conn, WorkflowDetail, "get", path, EDITOR, args=(wid,))) == 200
assert _status(_call(app, pg_conn, WorkflowDetail, "get", path, VIEWER, args=(wid,))) == 403
assert _status(_call(app, pg_conn, WorkflowDetail, "get", path, STRANGER, args=(wid,))) == 404
put = _call(app, pg_conn, WorkflowDetail, "put", path, EDITOR,
json=_wf_body("Edited"), args=(wid,))
assert _status(put) == 200
row = WorkflowsRepository(pg_conn).get_by_id(wid)
assert row["name"] == "Edited"
assert row["user_id"] == OWNER
assert _status(_call(app, pg_conn, WorkflowDetail, "put", path, VIEWER,
json=_wf_body(), args=(wid,))) == 403
assert _status(_call(app, pg_conn, WorkflowDetail, "delete", path, EDITOR,
args=(wid,))) == 403
assert WorkflowsRepository(pg_conn).get_by_id(wid) is not None
# ---------------------------------------------------------------------------
# analytics / logs / guardrail events
# ---------------------------------------------------------------------------
def _seed_conversation(conn, user_id, agent_id, count=2):
from docsgpt.storage.db.repositories.conversations import ConversationsRepository
repo = ConversationsRepository(conn)
conv = repo.create(user_id, name="t", agent_id=agent_id)
for i in range(count):
repo.append_message(str(conv["id"]), {"prompt": f"p{i}", "response": f"r{i}"})
class TestAnalytics:
def _messages(self, app, conn, agent_id, user):
from docsgpt.api.user.analytics.routes import GetMessageAnalytics
return _call(app, conn, GetMessageAnalytics, "post", "/api/get_message_analytics",
user, json={"api_key_id": agent_id})
def test_editor_sees_owner_view(self, app, pg_conn):
agent_id = _agent(pg_conn)
_seed_conversation(pg_conn, OWNER, agent_id, count=2)
_seed_conversation(pg_conn, "someone-else", agent_id, count=1)
resp = self._messages(app, pg_conn, agent_id, EDITOR)
assert _status(resp) == 200
assert sum(_json(resp)["messages"].values()) == 3
def test_viewer_403_unless_switch(self, app, pg_conn):
agent_id = _agent(pg_conn)
_seed_conversation(pg_conn, OWNER, agent_id, count=2)
assert _status(self._messages(app, pg_conn, agent_id, VIEWER)) == 403
set_settings(pg_conn, "agent", agent_id, {"viewers_can_see_logs": True}, OWNER)
resp = self._messages(app, pg_conn, agent_id, VIEWER)
assert _status(resp) == 200
assert sum(_json(resp)["messages"].values()) == 2
def test_stranger_gets_empty(self, app, pg_conn):
agent_id = _agent(pg_conn)
_seed_conversation(pg_conn, OWNER, agent_id, count=2)
resp = self._messages(app, pg_conn, agent_id, STRANGER)
assert _status(resp) == 200
assert sum(_json(resp)["messages"].values()) == 0
@pytest.mark.parametrize(
"cls_name,path",
[
("GetTokenAnalytics", "/api/get_token_analytics"),
("GetFeedbackAnalytics", "/api/get_feedback_analytics"),
("GetToolAnalytics", "/api/get_tool_analytics"),
("GetScheduleAnalytics", "/api/get_schedule_analytics"),
("GetUserLogs", "/api/get_user_logs"),
],
)
def test_other_endpoints_gate_viewer(self, app, pg_conn, cls_name, path):
from docsgpt.api.user.analytics import routes
agent_id = _agent(pg_conn)
cls = getattr(routes, cls_name)
body = {"api_key_id": agent_id}
assert _status(_call(app, pg_conn, cls, "post", path, VIEWER, json=body)) == 403
assert _status(_call(app, pg_conn, cls, "post", path, EDITOR, json=body)) == 200
def test_logs_editor_sees_owner_chats(self, app, pg_conn):
from docsgpt.api.user.analytics.routes import GetUserLogs
from docsgpt.storage.db.repositories.user_logs import UserLogsRepository
agent_id = _agent(pg_conn)
UserLogsRepository(pg_conn).insert(
user_id=OWNER, endpoint="stream",
data={"agent_id": agent_id, "question": "q", "level": "info"},
)
resp = _call(app, pg_conn, GetUserLogs, "post", "/api/get_user_logs", EDITOR,
json={"api_key_id": agent_id, "event_type": "chat"})
assert _status(resp) == 200
assert len(_json(resp)["logs"]) == 1
def test_traces_gate(self, app, pg_conn):
from docsgpt.api.user.analytics.routes import GetTraces
agent_id = _agent(pg_conn)
path = f"/api/traces?request_id=r1&api_key_id={agent_id}"
assert _status(_call(app, pg_conn, GetTraces, "get", path, VIEWER)) == 403
assert _status(_call(app, pg_conn, GetTraces, "get", path, EDITOR)) == 200
class TestGuardrailEvents:
def _seed(self, conn, agent_id):
from docsgpt.storage.db.repositories.guardrail_events import GuardrailEventsRepository
GuardrailEventsRepository(conn).record_many([
{
"user_id": OWNER, "agent_id": agent_id, "stage": "input",
"check_name": "pii", "detector_type": "regex", "action": "block",
"outcome": "triggered",
}
])
def test_events_and_summary(self, app, pg_conn):
from docsgpt.api.user.agents.guardrails import GuardrailEvents, GuardrailSummary
agent_id = _agent(pg_conn)
self._seed(pg_conn, agent_id)
path = f"/api/guardrails/events?agent_id={agent_id}"
resp = _call(app, pg_conn, GuardrailEvents, "get", path, EDITOR)
assert _status(resp) == 200
assert len(_json(resp)["events"]) == 1
assert _status(_call(app, pg_conn, GuardrailEvents, "get", path, VIEWER)) == 403
assert _status(_call(app, pg_conn, GuardrailEvents, "get", path, STRANGER)) == 404
spath = f"/api/guardrails/summary?agent_id={agent_id}"
summary = _call(app, pg_conn, GuardrailSummary, "get", spath, EDITOR)
assert _status(summary) == 200
assert _json(summary)["totals"]["blocked"] == 1
assert _status(_call(app, pg_conn, GuardrailSummary, "get", spath, VIEWER)) == 403
+25 -24
View File
@@ -30,15 +30,12 @@ def _seed_source(pg_conn, user="u", name="src"):
class TestResolveSource:
def test_returns_none_for_missing(self, pg_conn):
def test_missing_raises_404(self, pg_conn):
from docsgpt.api.user.resource_access import AccessDenied
from docsgpt.api.user.sources.chunks import _resolve_source
with _patch_db(pg_conn):
assert (
_resolve_source(
"00000000-0000-0000-0000-000000000000", "u"
)
is None
)
with _patch_db(pg_conn), pytest.raises(AccessDenied) as exc:
_resolve_source("00000000-0000-0000-0000-000000000000", "u")
assert exc.value.status == 404
def test_returns_source_when_found(self, pg_conn):
from docsgpt.api.user.sources.chunks import _resolve_source
@@ -49,7 +46,8 @@ class TestResolveSource:
assert got is not None
assert str(got["id"]) == str(src["id"])
def test_team_viewer_can_read(self, pg_conn):
def test_team_viewer_can_read_not_edit(self, pg_conn):
from docsgpt.api.user.resource_access import AccessDenied
from docsgpt.api.user.sources.chunks import _resolve_source
from docsgpt.storage.db.repositories.team_members import TeamMembersRepository
from docsgpt.storage.db.repositories.team_resource_grants import TeamResourceGrantsRepository
@@ -65,10 +63,13 @@ class TestResolveSource:
)
with _patch_db(pg_conn):
got = _resolve_source(str(src["id"]), viewer)
stranger = _resolve_source(str(src["id"]), "u-resolve-stranger")
assert got is not None
with pytest.raises(AccessDenied) as edit:
_resolve_source(str(src["id"]), viewer, "edit")
with pytest.raises(AccessDenied) as stranger:
_resolve_source(str(src["id"]), "u-resolve-stranger")
assert str(got["id"]) == str(src["id"])
assert stranger is None
assert edit.value.status == 403
assert stranger.value.status == 404
class TestChunkMatchesPath:
@@ -398,9 +399,9 @@ class TestAddChunk:
response = AddChunk().post()
assert response.status_code == 400
def test_returns_403_inaccessible_source(self, app, pg_conn):
# No ownership and no team editor grant resolves to None, which the
# owner-or-editor gate answers as 403 "Source not accessible".
def test_returns_404_inaccessible_source(self, app, pg_conn):
# No ownership and no team grant: the source isn't visible → 404
# (403 is reserved for a visible source the role can't change).
from docsgpt.api.user.sources.chunks import AddChunk
with _patch_db(pg_conn), app.test_request_context(
@@ -413,7 +414,7 @@ class TestAddChunk:
from flask import request
request.decoded_token = {"sub": "u"}
response = AddChunk().post()
assert response.status_code == 403
assert response.status_code == 404
def test_adds_chunk(self, app, pg_conn):
from docsgpt.api.user.sources.chunks import AddChunk
@@ -472,9 +473,9 @@ class TestDeleteChunk:
response = DeleteChunk().delete()
assert response.status_code == 401
def test_returns_403_inaccessible_source(self, app, pg_conn):
# No ownership and no team editor grant resolves to None, which the
# owner-or-editor gate answers as 403 "Source not accessible".
def test_returns_404_inaccessible_source(self, app, pg_conn):
# No ownership and no team grant: the source isn't visible → 404
# (403 is reserved for a visible source the role can't change).
from docsgpt.api.user.sources.chunks import DeleteChunk
with _patch_db(pg_conn), app.test_request_context(
@@ -484,7 +485,7 @@ class TestDeleteChunk:
from flask import request
request.decoded_token = {"sub": "u"}
response = DeleteChunk().delete()
assert response.status_code == 403
assert response.status_code == 404
def test_deletes_chunk(self, app, pg_conn):
from docsgpt.api.user.sources.chunks import DeleteChunk
@@ -551,9 +552,9 @@ class TestUpdateChunk:
response = UpdateChunk().put()
assert response.status_code == 400
def test_returns_403_inaccessible_source(self, app, pg_conn):
# No ownership and no team editor grant resolves to None, which the
# owner-or-editor gate answers as 403 "Source not accessible".
def test_returns_404_inaccessible_source(self, app, pg_conn):
# No ownership and no team grant: the source isn't visible → 404
# (403 is reserved for a visible source the role can't change).
from docsgpt.api.user.sources.chunks import UpdateChunk
with _patch_db(pg_conn), app.test_request_context(
@@ -566,7 +567,7 @@ class TestUpdateChunk:
from flask import request
request.decoded_token = {"sub": "u"}
response = UpdateChunk().put()
assert response.status_code == 403
assert response.status_code == 404
def test_returns_404_chunk_not_found(self, app, pg_conn):
from docsgpt.api.user.sources.chunks import UpdateChunk
@@ -53,6 +53,8 @@ def _run(sub, repo, team_shared, client):
"docsgpt.api.user.sources.routes.visible_with_access",
return_value=team_shared,
),
# Owner switches all at their defaults.
patch("docsgpt.api.user.sources.routes.settings_many", return_value={}),
]
for p in patches:
p.start()
@@ -80,6 +82,9 @@ class TestPaginatedSourcesTeamSharing:
assert by_id[owned]["team_access"] is None
assert by_id[shared]["ownership"] == "team"
assert by_id[shared]["team_access"] == "editor"
assert by_id[owned]["access"] == "owner"
assert by_id[shared]["access"] == "editor"
assert "edit" in by_id[shared]["allowed_actions"]
# The shared ids are unioned into the owner-scoped queries by id.
assert repo.list_for_user.call_args.kwargs["extra_ids"] == [shared]
assert repo.count_for_user.call_args.kwargs["extra_ids"] == [shared]
+8 -8
View File
@@ -526,9 +526,9 @@ class TestSyncSource:
response = SyncSource().post()
assert response.status_code == 400
def test_returns_403_inaccessible_source(self, app, pg_conn):
# No ownership and no team editor grant resolves to None, which the
# owner-or-editor gate answers as 403 "Source not accessible".
def test_returns_404_inaccessible_source(self, app, pg_conn):
# No ownership and no team grant: the source isn't visible → 404
# (403 is reserved for a visible source the role can't change).
from docsgpt.api.user.sources.routes import SyncSource
with _patch_db(pg_conn), app.test_request_context(
@@ -539,7 +539,7 @@ class TestSyncSource:
from flask import request
request.decoded_token = {"sub": "u"}
response = SyncSource().post()
assert response.status_code == 403
assert response.status_code == 404
def test_returns_400_for_connector_type(self, app, pg_conn):
from docsgpt.api.user.sources.routes import SyncSource
@@ -707,9 +707,9 @@ class TestReingestSource:
response = ReingestSource().post()
assert response.status_code == 400
def test_returns_403_inaccessible_source(self, app, pg_conn):
# No ownership and no team editor grant resolves to None, which the
# owner-or-editor gate answers as 403 "Source not accessible".
def test_returns_404_inaccessible_source(self, app, pg_conn):
# No ownership and no team grant: the source isn't visible → 404
# (403 is reserved for a visible source the role can't change).
from docsgpt.api.user.sources.routes import ReingestSource
with _patch_db(pg_conn), app.test_request_context(
@@ -720,7 +720,7 @@ class TestReingestSource:
from flask import request
request.decoded_token = {"sub": "u"}
response = ReingestSource().post()
assert response.status_code == 403
assert response.status_code == 404
def test_triggers_reingest_task(self, app, pg_conn):
from docsgpt.api.user.sources.routes import ReingestSource
+564
View File
@@ -0,0 +1,564 @@
"""Owner / editor / viewer / stranger matrix for the source endpoints.
Every source route resolves the caller's role through
``docsgpt.api.user.resource_access``: 404 when the source isn't visible, 403
when it is but the role can't perform the action, and writes land as the owner.
"""
from __future__ import annotations
import uuid
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
OWNER = "alice-roles"
EDITOR = "bob-roles-editor"
VIEWER = "carol-roles-viewer"
STRANGER = "dave-roles-stranger"
@pytest.fixture
def app():
return Flask(__name__)
@contextmanager
def _patch_db(conn, *modules):
@contextmanager
def _yield():
yield conn
patches = []
for mod in modules:
for name in ("db_session", "db_readonly"):
target = f"{mod}.{name}"
try:
p = patch(target, _yield)
p.start()
patches.append(p)
except AttributeError:
pass
try:
yield
finally:
for p in reversed(patches):
p.stop()
ROUTES = "docsgpt.api.user.sources.routes"
CHUNKS = "docsgpt.api.user.sources.chunks"
UPLOAD = "docsgpt.api.user.sources.upload"
CONNECTOR = "docsgpt.api.connector.routes"
CONNECTIONS = "docsgpt.connectors.service"
def _shared_source(pg_conn, **kwargs):
"""Seed a source owned by OWNER, shared to EDITOR (editor) and VIEWER (viewer)."""
from docsgpt.storage.db.repositories.sources import SourcesRepository
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
src = SourcesRepository(pg_conn).create(
kwargs.pop("name", "shared-src"), user_id=OWNER, **kwargs
)
sid = str(src["id"])
for member, level in ((EDITOR, "editor"), (VIEWER, "viewer")):
team = TeamsRepository(pg_conn).create(
f"T-{level}", f"t-{level}-{uuid.uuid4().hex[:8]}", OWNER
)
TeamMembersRepository(pg_conn).add_member(team["id"], member, role="team_member")
TeamResourceGrantsRepository(pg_conn).grant(
team["id"], "source", sid, owner_id=OWNER, granted_by=OWNER,
access_level=level,
)
return sid
def _set(pg_conn, sid, **switches):
from docsgpt.api.user.resource_access import set_settings
set_settings(pg_conn, "source", sid, switches, OWNER)
def _call(app, user, path, fn, method="GET", **ctx):
with app.test_request_context(path, method=method, **ctx):
from flask import request
request.decoded_token = {"sub": user}
return fn()
# ---------------------------------------------------------------------------
# Listing
# ---------------------------------------------------------------------------
class TestListPayload:
def test_sources_rows_carry_access(self, app, pg_conn):
from docsgpt.api.user.sources.routes import CombinedJson
sid = _shared_source(pg_conn)
with _patch_db(pg_conn, ROUTES):
owner = _call(app, OWNER, "/api/sources", CombinedJson().get)
editor = _call(app, EDITOR, "/api/sources", CombinedJson().get)
viewer = _call(app, VIEWER, "/api/sources", CombinedJson().get)
o = next(r for r in owner.json if r["id"] == sid)
e = next(r for r in editor.json if r["id"] == sid)
v = next(r for r in viewer.json if r["id"] == sid)
assert o["access"] == "owner"
assert "delete" in o["allowed_actions"]
assert o["ownership"] == "user"
assert e["access"] == "editor"
assert "edit" in e["allowed_actions"] and "delete" not in e["allowed_actions"]
assert e["allowed_actions"] == sorted(e["allowed_actions"])
assert e["team_access"] == "editor"
assert v["access"] == "viewer"
assert v["allowed_actions"] == ["use", "view_config"]
# viewers_can_see_config defaults on.
assert "config" in v
def test_viewer_config_hidden_when_switch_off(self, app, pg_conn):
from docsgpt.api.user.sources.routes import CombinedJson, PaginatedSources
sid = _shared_source(pg_conn)
_set(pg_conn, sid, viewers_can_see_config=False)
with _patch_db(pg_conn, ROUTES):
viewer = _call(app, VIEWER, "/api/sources", CombinedJson().get)
editor = _call(app, EDITOR, "/api/sources", CombinedJson().get)
vpage = _call(app, VIEWER, "/api/sources/paginated", PaginatedSources().get)
v = next(r for r in viewer.json if r["id"] == sid)
e = next(r for r in editor.json if r["id"] == sid)
vp = next(r for r in vpage.json["paginated"] if r["id"] == sid)
# Only the behaviour selector survives: the UI needs it to pick the view.
assert v["config"] == {"kind": "classic"}
assert v["allowed_actions"] == ["use"]
assert vp["config"] == {"kind": "classic"}
assert "retrieval" in e["config"]
def test_viewer_keeps_graphrag_kind_when_config_hidden(self, app, pg_conn):
from docsgpt.api.user.sources.routes import CombinedJson
sid = _shared_source(pg_conn, config={"kind": "graphrag"})
_set(pg_conn, sid, viewers_can_see_config=False)
with _patch_db(pg_conn, ROUTES):
viewer = _call(app, VIEWER, "/api/sources", CombinedJson().get)
v = next(r for r in viewer.json if r["id"] == sid)
assert v["config"] == {"kind": "graphrag"}
def test_paginated_rows_carry_access(self, app, pg_conn):
from docsgpt.api.user.sources.routes import PaginatedSources
sid = _shared_source(pg_conn)
_set(pg_conn, sid, editors_can_delete=True)
with _patch_db(pg_conn, ROUTES):
owner = _call(app, OWNER, "/api/sources/paginated", PaginatedSources().get)
editor = _call(app, EDITOR, "/api/sources/paginated", PaginatedSources().get)
o = next(r for r in owner.json["paginated"] if r["id"] == sid)
e = next(r for r in editor.json["paginated"] if r["id"] == sid)
assert o["access"] == "owner"
assert e["access"] == "editor"
assert "delete" in e["allowed_actions"]
# ---------------------------------------------------------------------------
# Delete
# ---------------------------------------------------------------------------
def _delete(app, pg_conn, user, sid):
from docsgpt.api.user.sources.routes import DeleteOldIndexes
storage = MagicMock()
storage.is_directory.return_value = False
with _patch_db(pg_conn, ROUTES), patch(
f"{ROUTES}.settings.VECTOR_STORE", "milvus"
), patch(f"{ROUTES}.StorageCreator.get_storage", return_value=storage), patch(
f"{ROUTES}.VectorCreator.create_vectorstore", return_value=MagicMock()
):
return _call(app, user, f"/api/delete_old?source_id={sid}", DeleteOldIndexes().get)
class TestDelete:
@pytest.mark.parametrize("user, status", [(EDITOR, 403), (VIEWER, 403), (STRANGER, 404)])
def test_non_owner_denied_by_default(self, app, pg_conn, user, status):
from docsgpt.storage.db.repositories.sources import SourcesRepository
sid = _shared_source(pg_conn)
assert _delete(app, pg_conn, user, sid).status_code == status
assert SourcesRepository(pg_conn).get_by_id(sid) is not None
def test_editor_deletes_when_switch_on(self, app, pg_conn):
from sqlalchemy import text
from docsgpt.storage.db.repositories.sources import SourcesRepository
sid = _shared_source(pg_conn)
_set(pg_conn, sid, editors_can_delete=True)
assert _delete(app, pg_conn, EDITOR, sid).status_code == 200
assert SourcesRepository(pg_conn).get_by_id(sid) is None
grants = pg_conn.execute(
text("SELECT count(*) FROM team_resource_grants WHERE resource_id = CAST(:i AS uuid)"),
{"i": sid},
).scalar()
settings_rows = pg_conn.execute(
text("SELECT count(*) FROM resource_share_settings WHERE resource_id = CAST(:i AS uuid)"),
{"i": sid},
).scalar()
assert grants == 0
assert settings_rows == 0
def test_viewer_still_denied_when_switch_on(self, app, pg_conn):
sid = _shared_source(pg_conn)
_set(pg_conn, sid, editors_can_delete=True)
assert _delete(app, pg_conn, VIEWER, sid).status_code == 403
# ---------------------------------------------------------------------------
# Sync frequency / sync / reingest / config
# ---------------------------------------------------------------------------
class TestManageSync:
@pytest.mark.parametrize("user, status", [(OWNER, 200), (EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
def test_matrix(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.routes import ManageSync
from docsgpt.storage.db.repositories.sources import SourcesRepository
sid = _shared_source(pg_conn)
with _patch_db(pg_conn, ROUTES):
resp = _call(
app, user, "/api/manage_sync", ManageSync().post, method="POST",
json={"source_id": sid, "sync_frequency": "weekly"},
)
assert resp.status_code == status
got = SourcesRepository(pg_conn).get_by_id(sid)["sync_frequency"]
assert (got == "weekly") == (status == 200)
class TestSyncSource:
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
def test_matrix_runs_as_owner(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.routes import SyncSource
sid = _shared_source(
pg_conn, type="url", remote_data={"url": "https://example.com"}
)
delay = MagicMock(return_value=MagicMock(id="t-1"))
with _patch_db(pg_conn, ROUTES), patch(f"{ROUTES}.sync_source.delay", delay):
resp = _call(
app, user, "/api/sync_source", SyncSource().post, method="POST",
json={"source_id": sid},
)
assert resp.status_code == status
if status == 200:
assert delay.call_args.kwargs["user"] == OWNER
else:
delay.assert_not_called()
class TestReingest:
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
def test_matrix(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.routes import ReingestSource
sid = _shared_source(pg_conn)
delay = MagicMock(return_value=MagicMock(id="t-2"))
with _patch_db(pg_conn, ROUTES), patch(f"{ROUTES}.reingest_source_task.delay", delay):
resp = _call(
app, user, "/api/sources/reingest", ReingestSource().post,
method="POST", json={"source_id": sid},
)
assert resp.status_code == status
if status == 200:
assert delay.call_args.kwargs["user"] == OWNER
class TestConfig:
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
def test_matrix(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.routes import SourceConfigResource
sid = _shared_source(pg_conn)
with _patch_db(pg_conn, ROUTES):
resp = _call(
app, user, f"/api/sources/{sid}/config",
lambda: SourceConfigResource().patch(sid), method="PATCH",
json={"retrieval": {"chunks": 7}},
)
assert resp.status_code == status
# ---------------------------------------------------------------------------
# Reads
# ---------------------------------------------------------------------------
class TestReads:
@pytest.mark.parametrize("user, status", [(OWNER, 200), (EDITOR, 200), (VIEWER, 200), (STRANGER, 404)])
def test_directory_structure(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.routes import DirectoryStructure
sid = _shared_source(pg_conn)
with _patch_db(pg_conn, ROUTES):
resp = _call(app, user, f"/api/directory_structure?id={sid}", DirectoryStructure().get)
assert resp.status_code == status
@pytest.mark.parametrize("user, status", [(VIEWER, 200), (STRANGER, 404)])
def test_get_chunks(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.chunks import GetChunks
sid = _shared_source(pg_conn)
store = MagicMock()
store.get_chunks.return_value = []
with _patch_db(pg_conn, CHUNKS), patch(f"{CHUNKS}.get_vector_store", return_value=store):
resp = _call(app, user, f"/api/get_chunks?id={sid}", GetChunks().get)
assert resp.status_code == status
# ---------------------------------------------------------------------------
# Wiki writes
# ---------------------------------------------------------------------------
class TestWikiWrites:
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
def test_put_page(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.routes import WikiPage
sid = _shared_source(pg_conn, type="wiki", config={"kind": "wiki"})
delay = MagicMock()
with _patch_db(pg_conn, ROUTES), patch(f"{ROUTES}.reembed_wiki_page.delay", delay):
resp = _call(
app, user, f"/api/sources/{sid}/wiki/page",
lambda: WikiPage().put(sid), method="PUT",
json={"path": "/a.md", "content": "hi"},
)
assert resp.status_code == status
if status == 200:
assert delay.call_args.kwargs["user"] == OWNER
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
def test_convert(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.routes import ConvertSourceToWiki
sid = _shared_source(pg_conn)
with _patch_db(pg_conn, ROUTES):
resp = _call(
app, user, f"/api/sources/{sid}/wiki/convert",
lambda: ConvertSourceToWiki().post(sid), method="POST",
)
assert resp.status_code == status
# ---------------------------------------------------------------------------
# Chunks
# ---------------------------------------------------------------------------
class TestChunkWrites:
@pytest.mark.parametrize("user, status", [(EDITOR, 201), (VIEWER, 403), (STRANGER, 404)])
def test_add(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.chunks import AddChunk
sid = _shared_source(pg_conn)
store = MagicMock()
store.add_chunk.return_value = "c1"
with _patch_db(pg_conn, CHUNKS), patch(f"{CHUNKS}.get_vector_store", return_value=store):
resp = _call(
app, user, "/api/add_chunk", AddChunk().post, method="POST",
json={"id": sid, "text": "hello"},
)
assert resp.status_code == status
assert store.add_chunk.called == (status == 201)
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
def test_delete(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.chunks import DeleteChunk
sid = _shared_source(pg_conn)
store = MagicMock()
store.delete_chunk.return_value = True
with _patch_db(pg_conn, CHUNKS), patch(f"{CHUNKS}.get_vector_store", return_value=store):
resp = _call(
app, user, f"/api/delete_chunk?id={sid}&chunk_id=c1",
DeleteChunk().delete, method="DELETE",
)
assert resp.status_code == status
@pytest.mark.parametrize("user, status", [(EDITOR, 200), (VIEWER, 403), (STRANGER, 404)])
def test_update(self, app, pg_conn, user, status):
from docsgpt.api.user.sources.chunks import UpdateChunk
sid = _shared_source(pg_conn)
store = MagicMock()
store.get_chunks.return_value = [{"doc_id": "c1", "text": "a", "metadata": {}}]
store.update_chunk.return_value = "c1"
with _patch_db(pg_conn, CHUNKS), patch(f"{CHUNKS}.get_vector_store", return_value=store):
resp = _call(
app, user, "/api/update_chunk", UpdateChunk().put, method="PUT",
json={"id": sid, "chunk_id": "c1", "text": "b"},
)
assert resp.status_code == status
# ---------------------------------------------------------------------------
# Files
# ---------------------------------------------------------------------------
class TestManageSourceFiles:
@pytest.mark.parametrize("user, status", [(EDITOR, 400), (VIEWER, 403), (STRANGER, 404)])
def test_matrix(self, app, pg_conn, user, status):
# An editor passes the role check and reaches the operation's own
# validation (no files → 400); viewer and stranger stop at the check.
from docsgpt.api.user.sources.upload import ManageSourceFiles
sid = _shared_source(pg_conn, file_path="/data/src")
with _patch_db(pg_conn, UPLOAD), patch(
f"{UPLOAD}.StorageCreator.get_storage", return_value=MagicMock()
):
resp = _call(
app, user, "/api/manage_source_files", ManageSourceFiles().post,
method="POST", data={"source_id": sid, "operation": "add"},
content_type="multipart/form-data",
)
assert resp.status_code == status
# ---------------------------------------------------------------------------
# Connector sync
# ---------------------------------------------------------------------------
def _connector_source(pg_conn):
return _shared_source(
pg_conn, name="drive-src", type="connector:file",
remote_data={"provider": "google_drive", "file_ids": ["f"], "folder_ids": []},
)
def _owner_session(pg_conn, token="st-owner", token_info=None):
from docsgpt.storage.db.repositories.connector_sessions import (
ConnectorSessionsRepository,
)
repo = ConnectorSessionsRepository(pg_conn)
row = repo.upsert(OWNER, "google_drive", status="authorized")
repo.update(
str(row["id"]),
{
"session_token": token,
"token_info": token_info or {"access_token": "a", "refresh_token": "r"},
},
)
return str(row["id"])
def _connector_sync(app, pg_conn, user, body):
from docsgpt.api.connector.routes import ConnectorSync
delay = MagicMock(return_value=MagicMock(id="t-sync"))
with _patch_db(pg_conn, CONNECTOR, CONNECTIONS), patch(
f"{CONNECTOR}.ingest_connector_task.delay", delay
):
resp = _call(app, user, "/api/connectors/sync", ConnectorSync().post, method="POST", json=body)
return resp, delay
class TestConnectorSync:
def test_editor_syncs_with_owner_session(self, app, pg_conn):
sid = _connector_source(pg_conn)
owner_connection = _owner_session(pg_conn)
resp, delay = _connector_sync(app, pg_conn, EDITOR, {"source_id": sid})
assert resp.status_code == 200
kwargs = delay.call_args.kwargs
assert kwargs["user"] == OWNER
assert kwargs["connection_id"] == owner_connection
def test_editor_cannot_substitute_own_session(self, app, pg_conn):
from docsgpt.storage.db.repositories.connector_sessions import (
ConnectorSessionsRepository,
)
sid = _connector_source(pg_conn)
owner_connection = _owner_session(pg_conn)
repo = ConnectorSessionsRepository(pg_conn)
row = repo.upsert(EDITOR, "google_drive", status="authorized")
repo.update(str(row["id"]), {"session_token": "st-editor", "token_info": {"access_token": "x"}})
resp, delay = _connector_sync(
app, pg_conn, EDITOR,
{"source_id": sid, "session_token": "st-editor", "connection_id": str(row["id"])},
)
assert resp.status_code == 200
assert delay.call_args.kwargs["connection_id"] == owner_connection
def test_owner_session_missing_returns_409(self, app, pg_conn):
sid = _connector_source(pg_conn)
resp, delay = _connector_sync(app, pg_conn, EDITOR, {"source_id": sid})
assert resp.status_code == 409
assert "owner" in resp.json["error"].lower()
delay.assert_not_called()
@pytest.mark.parametrize("user, status", [(VIEWER, 403), (STRANGER, 404)])
def test_viewer_and_stranger_denied(self, app, pg_conn, user, status):
sid = _connector_source(pg_conn)
_owner_session(pg_conn)
resp, delay = _connector_sync(app, pg_conn, user, {"source_id": sid})
assert resp.status_code == status
delay.assert_not_called()
def test_owner_still_uses_own_token(self, app, pg_conn):
sid = _connector_source(pg_conn)
owner_connection = _owner_session(pg_conn)
resp, delay = _connector_sync(
app, pg_conn, OWNER, {"source_id": sid, "session_token": "st-owner"}
)
assert resp.status_code == 200
assert delay.call_args.kwargs["user"] == OWNER
assert delay.call_args.kwargs["connection_id"] == owner_connection
def test_editor_syncs_with_the_sources_own_connection(self, app, pg_conn):
from sqlalchemy import text
from docsgpt.security.encryption import encrypt_json
from docsgpt.storage.db.repositories.connector_sessions import (
ConnectorSessionsRepository,
)
sid = _connector_source(pg_conn)
_owner_session(pg_conn)
second = str(ConnectorSessionsRepository(pg_conn).create(
OWNER, "google_drive", connector_key="google_drive", auth_kind="oauth",
account_label="second@example.com",
encrypted_credentials=encrypt_json({"token_info": {"access_token": "b", "refresh_token": "r"}}, OWNER),
)["id"])
pg_conn.execute(
text("UPDATE sources SET connection_id = CAST(:c AS uuid) WHERE id = CAST(:s AS uuid)"),
{"c": second, "s": sid},
)
resp, delay = _connector_sync(app, pg_conn, EDITOR, {"source_id": sid})
assert resp.status_code == 200
assert delay.call_args.kwargs["connection_id"] == second
def test_editor_sync_refuses_a_signed_out_source_connection(self, app, pg_conn):
from sqlalchemy import text
sid = _connector_source(pg_conn)
connection = _owner_session(pg_conn)
pg_conn.execute(
text("UPDATE connector_sessions SET status = 'reconnect_needed' WHERE id = CAST(:c AS uuid)"),
{"c": connection},
)
pg_conn.execute(
text("UPDATE sources SET connection_id = CAST(:c AS uuid) WHERE id = CAST(:s AS uuid)"),
{"c": connection, "s": sid},
)
resp, delay = _connector_sync(app, pg_conn, EDITOR, {"source_id": sid})
assert resp.status_code == 409
delay.assert_not_called()
View File
Whitespace-only changes.
@@ -0,0 +1,531 @@
"""Team sharing routes on the role model (``resource_access``), against real Postgres.
Driven through the real app.py chokepoint; only ``handle_auth`` /
``resolve_roles`` are patched, and the route modules' sessions are pointed at
the per-test ``pg_conn``.
"""
from __future__ import annotations
import json
import uuid
from contextlib import ExitStack, contextmanager
from unittest.mock import patch
import pytest
from docsgpt.api.user.resource_access import set_settings, settings_for
from docsgpt.storage.db.repositories.agents import AgentsRepository
from docsgpt.storage.db.repositories.prompts import PromptsRepository
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_tools import UserToolsRepository
from docsgpt.storage.db.repositories.users import UsersRepository
@pytest.fixture
def client():
from docsgpt.app import app as flask_app
flask_app.config["TESTING"] = True
return flask_app.test_client()
@pytest.fixture
def db(pg_conn):
@contextmanager
def _yield():
yield pg_conn
with ExitStack() as stack:
for target in (
"docsgpt.api.user.teams.routes.db_readonly",
"docsgpt.api.user.teams.routes.db_session",
"docsgpt.api.user.team_authz.db_readonly",
):
stack.enter_context(patch(target, _yield))
stack.enter_context(patch("docsgpt.api.user.teams.routes.publish_user_event"))
yield pg_conn
@contextmanager
def _as(sub, *roles):
with patch("docsgpt.app.handle_auth", return_value={"sub": sub}), patch(
"docsgpt.app.resolve_roles", return_value=list(roles) or ["user"]
):
yield
def _body(resp):
return json.loads(resp.data)
def _team(conn, owner="alice", members=(("bob", "team_member"),)):
team = TeamsRepository(conn).create("Acme", f"acme-{uuid.uuid4().hex[:8]}", owner)
tid = str(team["id"])
repo = TeamMembersRepository(conn)
repo.add_member(tid, owner, role="team_admin")
for user, role in members:
repo.add_member(tid, user, role=role)
return tid
def _agent(conn, owner="alice", name="Helper"):
return str(AgentsRepository(conn).create(owner, name, "published")["id"])
def _grant(conn, tid, rid, level="viewer", rtype="agent", owner="alice", target=None):
TeamResourceGrantsRepository(conn).grant(
tid, rtype, rid, owner, owner, access_level=level, target_user_id=target
)
def _grants(conn, tid):
return TeamResourceGrantsRepository(conn).list_for_team(tid)
# --- POST /api/teams/<tid>/grants -------------------------------------------
class TestShareNeedsShareAction:
def test_editor_without_switch_gets_403(self, client, db):
tid = _team(db, members=(("bob", "team_member"), ("carol", "team_member")))
aid = _agent(db)
_grant(db, tid, aid, "editor")
with _as("bob"):
resp = client.post(
f"/api/teams/{tid}/grants",
json={"resource_type": "agent", "resource_id": aid, "target_user_id": "carol"},
)
assert resp.status_code == 403
def test_editor_with_switch_can_share_as_owner(self, client, db):
tid = _team(db, members=(("bob", "team_member"), ("carol", "team_member")))
aid = _agent(db)
_grant(db, tid, aid, "editor")
set_settings(db, "agent", aid, {"editors_can_share": True}, "alice")
with _as("bob"):
resp = client.post(
f"/api/teams/{tid}/grants",
json={
"resource_type": "agent",
"resource_id": aid,
"target_user_id": "carol",
"access_level": "viewer",
},
)
assert resp.status_code == 201
grant = _body(resp)["grant"]
assert grant["owner_id"] == "alice"
assert grant["granted_by"] == "bob"
def test_editor_with_switch_can_change_level(self, client, db):
tid = _team(db)
aid = _agent(db)
_grant(db, tid, aid, "editor")
set_settings(db, "agent", aid, {"editors_can_share": True}, "alice")
with _as("bob"):
resp = client.post(
f"/api/teams/{tid}/grants",
json={"resource_type": "agent", "resource_id": aid, "access_level": "viewer"},
)
assert resp.status_code == 201
assert _grants(db, tid)[0]["access_level"] == "viewer"
def test_viewer_gets_403(self, client, db):
tid = _team(db)
aid = _agent(db)
_grant(db, tid, aid, "viewer")
set_settings(db, "agent", aid, {"editors_can_share": True}, "alice")
with _as("bob"):
resp = client.post(
f"/api/teams/{tid}/grants",
json={"resource_type": "agent", "resource_id": aid, "access_level": "editor"},
)
assert resp.status_code == 403
def test_stranger_resource_is_404(self, client, db):
tid = _team(db)
aid = _agent(db, owner="mallory")
with _as("bob"):
resp = client.post(
f"/api/teams/{tid}/grants", json={"resource_type": "agent", "resource_id": aid}
)
assert resp.status_code == 404
assert _grants(db, tid) == []
def test_owner_shares(self, client, db):
tid = _team(db)
aid = _agent(db)
with _as("alice"):
resp = client.post(
f"/api/teams/{tid}/grants",
json={"resource_type": "agent", "resource_id": aid, "access_level": "editor"},
)
assert resp.status_code == 201
assert _grants(db, tid)[0]["owner_id"] == "alice"
def test_caller_must_be_member(self, client, db):
tid = _team(db, owner="carol", members=())
aid = _agent(db)
with _as("alice"):
resp = client.post(
f"/api/teams/{tid}/grants", json={"resource_type": "agent", "resource_id": aid}
)
assert resp.status_code == 403
# --- DELETE /api/teams/<tid>/grants -----------------------------------------
class TestUnshare:
def test_owner_who_left_can_unshare(self, client, db):
tid = _team(db, owner="carol", members=(("alice", "team_member"),))
aid = _agent(db)
_grant(db, tid, aid, "viewer")
TeamMembersRepository(db).remove_member(tid, "alice")
with _as("alice"):
resp = client.delete(
f"/api/teams/{tid}/grants?resource_type=agent&resource_id={aid}"
)
assert resp.status_code == 200
assert _body(resp)["success"] is True
assert _grants(db, tid) == []
def test_team_admin_unshares_someone_elses(self, client, db):
tid = _team(db, owner="carol", members=(("alice", "team_member"),))
aid = _agent(db)
_grant(db, tid, aid, "viewer")
with _as("carol"):
resp = client.delete(
f"/api/teams/{tid}/grants?resource_type=agent&resource_id={aid}"
)
assert resp.status_code == 200
assert _grants(db, tid) == []
def test_member_without_share_gets_403(self, client, db):
tid = _team(db)
aid = _agent(db)
_grant(db, tid, aid, "editor")
with _as("bob"):
resp = client.delete(
f"/api/teams/{tid}/grants?resource_type=agent&resource_id={aid}"
)
assert resp.status_code == 403
assert len(_grants(db, tid)) == 1
def test_editor_with_switch_unshares_member_grant(self, client, db):
tid = _team(db, members=(("bob", "team_member"), ("carol", "team_member")))
aid = _agent(db)
_grant(db, tid, aid, "editor", target="bob")
_grant(db, tid, aid, "viewer", target="carol")
set_settings(db, "agent", aid, {"editors_can_share": True}, "alice")
with _as("bob"):
resp = client.delete(
f"/api/teams/{tid}/grants?resource_type=agent&resource_id={aid}&target_user_id=carol"
)
assert resp.status_code == 200
assert [g["target_user_id"] for g in _grants(db, tid)] == ["bob"]
def test_outsider_without_share_gets_403(self, client, db):
tid = _team(db)
aid = _agent(db)
_grant(db, tid, aid, "viewer")
with _as("mallory"):
resp = client.delete(
f"/api/teams/{tid}/grants?resource_type=agent&resource_id={aid}"
)
assert resp.status_code == 403
# --- GET /api/resource_shares -----------------------------------------------
class TestResourceShares:
def test_viewer_gets_403(self, client, db):
tid = _team(db)
aid = _agent(db)
_grant(db, tid, aid, "viewer")
with _as("bob"):
resp = client.get(f"/api/resource_shares?resource_type=agent&resource_id={aid}")
assert resp.status_code == 403
def test_editor_with_switch_lists(self, client, db):
tid = _team(db)
aid = _agent(db)
_grant(db, tid, aid, "editor")
set_settings(db, "agent", aid, {"editors_can_share": True}, "alice")
with _as("bob"):
resp = client.get(f"/api/resource_shares?resource_type=agent&resource_id={aid}")
assert resp.status_code == 200
assert len(_body(resp)["shares"]) == 1
def test_owner_lists(self, client, db):
tid = _team(db)
aid = _agent(db)
_grant(db, tid, aid, "viewer")
with _as("alice"):
resp = client.get(f"/api/resource_shares?resource_type=agent&resource_id={aid}")
assert resp.status_code == 200
assert _body(resp)["shares"][0]["team_name"] == "Acme"
def test_stranger_404(self, client, db):
aid = _agent(db)
with _as("mallory"):
resp = client.get(f"/api/resource_shares?resource_type=agent&resource_id={aid}")
assert resp.status_code == 404
# --- GET/PUT /api/resource_settings -----------------------------------------
class TestResourceSettings:
def test_owner_get_shape(self, client, db):
aid = _agent(db)
with _as("alice"):
resp = client.get(f"/api/resource_settings?resource_type=agent&resource_id={aid}")
assert resp.status_code == 200
body = _body(resp)
assert body["success"] is True
assert body["resource_type"] == "agent"
assert body["resource_id"] == aid
assert body["access"] == "owner"
assert "manage_settings" in body["allowed_actions"]
assert body["settings"][0] == {"key": "editors_can_share", "value": False, "default": False}
def test_viewer_can_read(self, client, db):
tid = _team(db)
aid = _agent(db)
_grant(db, tid, aid, "viewer")
with _as("bob"):
resp = client.get(f"/api/resource_settings?resource_type=agent&resource_id={aid}")
assert resp.status_code == 200
assert _body(resp)["access"] == "viewer"
assert _body(resp)["allowed_actions"] == ["pin", "use"]
def test_stranger_404(self, client, db):
aid = _agent(db)
with _as("mallory"):
resp = client.get(f"/api/resource_settings?resource_type=agent&resource_id={aid}")
assert resp.status_code == 404
def test_bad_type_400(self, client, db):
with _as("alice"):
resp = client.get(
f"/api/resource_settings?resource_type=nope&resource_id={uuid.uuid4()}"
)
assert resp.status_code == 400
def test_owner_put(self, client, db):
aid = _agent(db)
with _as("alice"):
resp = client.put(
"/api/resource_settings",
json={
"resource_type": "agent",
"resource_id": aid,
"settings": {"editors_can_share": True},
},
)
assert resp.status_code == 200
body = _body(resp)
assert body["settings"][0]["value"] is True
assert body["access"] == "owner"
assert settings_for(db, "agent", aid)["editors_can_share"] is True
def test_put_reflects_new_actions_for_prompt(self, client, db):
pid = str(PromptsRepository(db).create("alice", "P", "text")["id"])
with _as("alice"):
resp = client.put(
"/api/resource_settings",
json={
"resource_type": "prompt",
"resource_id": pid,
"settings": {"viewers_can_duplicate": False},
},
)
assert resp.status_code == 200
assert {s["key"]: s["value"] for s in _body(resp)["settings"]}["viewers_can_duplicate"] is False
def test_put_unknown_key_400(self, client, db):
aid = _agent(db)
with _as("alice"):
resp = client.put(
"/api/resource_settings",
json={"resource_type": "agent", "resource_id": aid, "settings": {"nope": True}},
)
assert resp.status_code == 400
def test_put_non_bool_400(self, client, db):
aid = _agent(db)
with _as("alice"):
resp = client.put(
"/api/resource_settings",
json={
"resource_type": "agent",
"resource_id": aid,
"settings": {"editors_can_share": "yes"},
},
)
assert resp.status_code == 400
def test_put_bad_type_400(self, client, db):
with _as("alice"):
resp = client.put(
"/api/resource_settings",
json={"resource_type": "nope", "resource_id": str(uuid.uuid4()), "settings": {}},
)
assert resp.status_code == 400
def test_editor_put_403(self, client, db):
tid = _team(db)
aid = _agent(db)
_grant(db, tid, aid, "editor")
set_settings(db, "agent", aid, {"editors_can_share": True}, "alice")
with _as("bob"):
resp = client.put(
"/api/resource_settings",
json={
"resource_type": "agent",
"resource_id": aid,
"settings": {"editors_can_delete": True},
},
)
assert resp.status_code == 403
assert settings_for(db, "agent", aid)["editors_can_delete"] is False
# --- GET /api/teams/<tid>/grants --------------------------------------------
class TestGrantListing:
def test_rows_are_enriched(self, client, db):
UsersRepository(db).upsert("alice", email="alice@example.com")
UsersRepository(db).upsert("bob", email="bob@example.com")
tid = _team(db)
aid = _agent(db, name="Support bot")
tool = UserToolsRepository(db).create("alice", "api_tool", custom_name="CRM")
_grant(db, tid, aid, "editor", target="bob")
_grant(db, tid, str(tool["id"]), "viewer", rtype="tool")
with _as("bob"):
resp = client.get(f"/api/teams/{tid}/grants")
assert resp.status_code == 200
body = _body(resp)
assert body["team_role"] == "team_member"
by_type = {g["resource_type"]: g for g in body["grants"]}
agent_row = by_type["agent"]
assert agent_row["resource_name"] == "Support bot"
assert agent_row["owner_id"] == "alice"
assert agent_row["owner_label"] == "alice@example.com"
assert agent_row["target_user_label"] == "bob@example.com"
assert agent_row["granted_by_label"] == "alice@example.com"
assert agent_row["created_at"]
assert agent_row["access_level"] == "editor"
assert agent_row["caller"]["access"] == "editor"
assert "edit" in agent_row["caller"]["allowed_actions"]
tool_row = by_type["tool"]
assert tool_row["resource_name"] == "CRM"
assert tool_row["target_user_label"] is None
assert tool_row["caller"] == {
"access": "viewer",
"allowed_actions": ["use", "use_in_own"],
}
def test_owner_caller_and_missing_labels(self, client, db):
tid = _team(db)
aid = _agent(db)
_grant(db, tid, aid, "viewer")
with _as("alice"):
body = _body(client.get(f"/api/teams/{tid}/grants"))
row = body["grants"][0]
assert body["team_role"] == "team_admin"
assert row["caller"]["access"] == "owner"
assert row["owner_label"] is None
def test_member_does_not_see_others_personal_grants(self, client, db):
tid = _team(db, members=(("bob", "team_member"), ("carol", "team_member")))
a1, a2, a3 = _agent(db, name="Team"), _agent(db, name="Bob"), _agent(db, name="Carol")
_grant(db, tid, a1, "viewer")
_grant(db, tid, a2, "viewer", target="bob")
_grant(db, tid, a3, "viewer", target="carol")
with _as("bob"):
names = {g["resource_name"] for g in _body(client.get(f"/api/teams/{tid}/grants"))["grants"]}
assert names == {"Team", "Bob"}
def test_admin_and_sharer_see_all(self, client, db):
tid = _team(
db,
owner="carol",
members=(("alice", "team_member"), ("bob", "team_member"), ("dave", "team_member")),
)
aid = _agent(db)
_grant(db, tid, aid, "viewer", target="bob")
with _as("carol"): # team_admin
assert len(_body(client.get(f"/api/teams/{tid}/grants"))["grants"]) == 1
with _as("alice"): # owner of the resource (has share)
assert len(_body(client.get(f"/api/teams/{tid}/grants"))["grants"]) == 1
with _as("dave"):
assert _body(client.get(f"/api/teams/{tid}/grants"))["grants"] == []
def test_resource_type_filter_kept(self, client, db):
tid = _team(db)
_grant(db, tid, _agent(db), "viewer")
pid = str(PromptsRepository(db).create("alice", "P", "text")["id"])
_grant(db, tid, pid, "viewer", rtype="prompt")
with _as("bob"):
body = _body(client.get(f"/api/teams/{tid}/grants?resource_type=prompt"))
assert [g["resource_name"] for g in body["grants"]] == ["P"]
# --- Team owner protection --------------------------------------------------
class TestTeamOwnerProtection:
def test_admin_cannot_demote_owner(self, client, db):
tid = _team(db, members=(("bob", "team_admin"),))
with _as("bob"):
resp = client.put(f"/api/teams/{tid}/members/alice", json={"role": "team_member"})
assert resp.status_code == 403
assert TeamMembersRepository(db).role_for("alice", tid) == "team_admin"
def test_admin_cannot_remove_owner(self, client, db):
tid = _team(db, members=(("bob", "team_admin"),))
with _as("bob"):
resp = client.delete(f"/api/teams/{tid}/members/alice")
assert resp.status_code == 403
assert TeamMembersRepository(db).is_member("alice", tid)
def test_owner_must_transfer_before_leaving(self, client, db):
tid = _team(db, members=(("bob", "team_admin"),))
with _as("alice"):
resp = client.delete(f"/api/teams/{tid}/members/alice")
assert resp.status_code == 400
assert "transfer" in _body(resp)["message"].lower()
assert TeamMembersRepository(db).is_member("alice", tid)
def test_owner_cannot_self_demote(self, client, db):
tid = _team(db, members=(("bob", "team_admin"),))
with _as("alice"):
resp = client.put(f"/api/teams/{tid}/members/alice", json={"role": "team_member"})
assert resp.status_code == 400
def test_admin_can_still_manage_other_admins(self, client, db):
tid = _team(db, members=(("bob", "team_admin"), ("carol", "team_admin")))
with _as("bob"):
resp = client.put(f"/api/teams/{tid}/members/carol", json={"role": "team_member"})
assert resp.status_code == 200
def test_team_payloads_carry_is_owner(self, client, db):
tid = _team(db)
with _as("alice"):
listed = _body(client.get("/api/teams"))["teams"]
detail = _body(client.get(f"/api/teams/{tid}"))["team"]
assert listed[0]["is_owner"] is True
assert detail["is_owner"] is True
assert detail["owner_id"] == "alice"
with _as("bob"):
assert _body(client.get("/api/teams"))["teams"][0]["is_owner"] is False
assert _body(client.get(f"/api/teams/{tid}"))["team"]["is_owner"] is False
+231
View File
@@ -0,0 +1,231 @@
"""What a team editor may newly reference from the owner's agent or workflow.
An agent (and its workflow) runs as its owner, so owning a resource is not
enough for an editor to wire it in: the editor must be able to use it
themselves. Otherwise an editor of one shared agent could attach the owner's
private tool (run with the owner's credentials), source or prompt, or the
workflow of another of the owner's agents and then edit that graph.
Also covers id casing: an uppercase UUID must resolve the same switches as
the canonical lowercase one. Uses real repositories on ``pg_conn``.
"""
from __future__ import annotations
import uuid
import pytest
from docsgpt.api.user.resource_access import resolve, set_settings
from docsgpt.storage.db.repositories.agents import AgentsRepository
from docsgpt.storage.db.repositories.prompts import PromptsRepository
from docsgpt.storage.db.repositories.sources import SourcesRepository
from docsgpt.storage.db.repositories.team_resource_grants import (
TeamResourceGrantsRepository,
)
from docsgpt.storage.db.repositories.user_tools import UserToolsRepository
from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
from tests.api.user.test_resource_sponsors import (
EDITOR,
OWNER,
_agent,
_call,
_put,
_row,
_status,
_wf_body,
)
@pytest.fixture
def app():
from flask import Flask
return Flask(__name__)
def _owner_private(conn):
"""A tool, prompt and source the owner never shared."""
tool = str(UserToolsRepository(conn).create(OWNER, "api_tool")["id"])
prompt = str(PromptsRepository(conn).create(OWNER, "private", "Owner prompt")["id"])
source = str(SourcesRepository(conn).create("owner-src", user_id=OWNER)["id"])
return tool, prompt, source
def _share_tool_with_editor(conn, team_id, tool, level="viewer"):
TeamResourceGrantsRepository(conn).grant(
team_id, "tool", tool, OWNER, OWNER, access_level=level, target_user_id=EDITOR
)
class TestAgentAttach:
def test_editor_cannot_attach_owner_private_tool(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, _, _ = _owner_private(pg_conn)
resp = _put(app, pg_conn, agent_id, EDITOR, {"tools": [tool]})
assert _status(resp) == 403
assert _row(pg_conn, agent_id)["tools"] in (None, [])
def test_editor_cannot_attach_owner_private_source(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
_, _, source = _owner_private(pg_conn)
resp = _put(app, pg_conn, agent_id, EDITOR, {"sources": [source]})
assert _status(resp) == 403
def test_editor_cannot_attach_owner_private_prompt(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
_, prompt, _ = _owner_private(pg_conn)
resp = _put(app, pg_conn, agent_id, EDITOR, {"prompt_id": prompt})
assert _status(resp) == 403
def test_editor_may_attach_owner_tool_shared_with_them(self, app, pg_conn):
agent_id, team_id = _agent(pg_conn)
tool, _, _ = _owner_private(pg_conn)
_share_tool_with_editor(pg_conn, team_id, tool)
resp = _put(app, pg_conn, agent_id, EDITOR, {"tools": [tool]})
assert _status(resp) == 200, resp.get_json()
row = _row(pg_conn, agent_id)
assert [str(t) for t in row["tools"]] == [tool]
# The owner owns it, so it runs as the owner: no sponsor.
assert not row.get("resource_sponsors")
def test_editor_keeps_owner_refs_already_on_agent(self, app, pg_conn):
tool, prompt, source = _owner_private(pg_conn)
agent_id, _ = _agent(pg_conn, tools=[tool], prompt_id=prompt, source_id=source)
resp = _put(
app, pg_conn, agent_id, EDITOR,
{"tools": [tool], "prompt_id": prompt, "source": source},
)
assert _status(resp) == 200, resp.get_json()
def test_owner_may_attach_own_private_tool(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, _, _ = _owner_private(pg_conn)
assert _status(_put(app, pg_conn, agent_id, OWNER, {"tools": [tool]})) == 200
class TestAgentWorkflowSwap:
def _two_workflow_agents(self, pg_conn):
"""Agent A (shared with EDITOR) and the owner's private agent B, each with a workflow."""
wf_a = str(WorkflowsRepository(pg_conn).create(OWNER, "A")["id"])
wf_b = str(WorkflowsRepository(pg_conn).create(OWNER, "B")["id"])
agent_a, _ = _agent(pg_conn, agent_type="workflow", workflow_id=wf_a)
AgentsRepository(pg_conn).create(
OWNER, "Private B", "published", description="d", key=f"k-{uuid.uuid4().hex}",
agent_type="workflow", workflow_id=wf_b,
)
return agent_a, wf_a, wf_b
def test_editor_cannot_swap_in_another_owner_workflow(self, app, pg_conn):
agent_a, wf_a, wf_b = self._two_workflow_agents(pg_conn)
resp = _put(app, pg_conn, agent_a, EDITOR, {"workflow": wf_b})
assert _status(resp) == 403
assert str(_row(pg_conn, agent_a)["workflow_id"]) == wf_a
def test_editor_may_resend_current_workflow(self, app, pg_conn):
agent_a, wf_a, _ = self._two_workflow_agents(pg_conn)
resp = _put(app, pg_conn, agent_a, EDITOR, {"workflow": wf_a})
assert _status(resp) == 200, resp.get_json()
def test_owner_may_swap_workflow(self, app, pg_conn):
agent_a, _, wf_b = self._two_workflow_agents(pg_conn)
assert _status(_put(app, pg_conn, agent_a, OWNER, {"workflow": wf_b})) == 200
assert str(_row(pg_conn, agent_a)["workflow_id"]) == wf_b
def test_editor_cannot_detach_workflow(self, app, pg_conn):
# Unpublishing in the same request makes the empty workflow allowed
# for a draft; detaching is still the owner's call.
agent_a, wf_a, _ = self._two_workflow_agents(pg_conn)
resp = _put(app, pg_conn, agent_a, EDITOR, {"status": "draft", "workflow": ""})
assert _status(resp) == 403
assert str(_row(pg_conn, agent_a)["workflow_id"]) == wf_a
def test_owner_may_detach_workflow(self, app, pg_conn):
agent_a, _, _ = self._two_workflow_agents(pg_conn)
resp = _put(app, pg_conn, agent_a, OWNER, {"status": "draft", "workflow": ""})
assert _status(resp) == 200, resp.get_json()
assert _row(pg_conn, agent_a)["workflow_id"] is None
def test_editor_empty_workflow_on_agent_without_one_is_a_no_op(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
resp = _put(app, pg_conn, agent_id, EDITOR, {"status": "draft", "workflow": ""})
assert _status(resp) == 200, resp.get_json()
class TestWorkflowNodeAttach:
def _setup(self, pg_conn):
wf = WorkflowsRepository(pg_conn).create(OWNER, "wf")
_, team_id = _agent(pg_conn, agent_type="workflow", workflow_id=str(wf["id"]))
return str(wf["id"]), team_id
def _put_wf(self, app, pg_conn, wid, user, body):
from docsgpt.api.user.workflows.routes import WorkflowDetail
return _call(app, pg_conn, WorkflowDetail, "put", f"/api/workflows/{wid}", user,
json=body, args=(wid,))
def test_editor_cannot_add_owner_private_tool_to_node(self, app, pg_conn):
wid, _ = self._setup(pg_conn)
tool, _, _ = _owner_private(pg_conn)
assert _status(self._put_wf(app, pg_conn, wid, EDITOR, _wf_body(tool=tool))) == 403
def test_editor_cannot_add_owner_private_source_to_node(self, app, pg_conn):
wid, _ = self._setup(pg_conn)
_, _, source = _owner_private(pg_conn)
assert _status(self._put_wf(app, pg_conn, wid, EDITOR, _wf_body(source=source))) == 403
def test_editor_keeps_owner_node_refs_already_in_graph(self, app, pg_conn):
wid, _ = self._setup(pg_conn)
tool, _, source = _owner_private(pg_conn)
body = _wf_body(tool=tool, source=source)
assert _status(self._put_wf(app, pg_conn, wid, OWNER, body)) == 200
resp = self._put_wf(app, pg_conn, wid, EDITOR, body)
assert _status(resp) == 200, resp.get_json()
def test_editor_may_add_tool_shared_with_them(self, app, pg_conn):
wid, team_id = self._setup(pg_conn)
tool, _, _ = _owner_private(pg_conn)
_share_tool_with_editor(pg_conn, team_id, tool)
resp = self._put_wf(app, pg_conn, wid, EDITOR, _wf_body(tool=tool))
assert _status(resp) == 200, resp.get_json()
class TestUppercaseIds:
def _tool_not_usable_in_own(self, pg_conn, team_id):
"""An owner tool the EDITOR only views, with ``viewers_can_use_in_agents`` off."""
tool, _, _ = _owner_private(pg_conn)
_share_tool_with_editor(pg_conn, team_id, tool)
set_settings(pg_conn, "tool", tool, {"viewers_can_use_in_agents": False}, OWNER)
return tool
def test_resolve_applies_switches_to_uppercase_id(self, pg_conn):
_, team_id = _agent(pg_conn)
tool = self._tool_not_usable_in_own(pg_conn, team_id)
lower = resolve(pg_conn, "tool", tool, EDITOR)
upper = resolve(pg_conn, "tool", tool.upper(), EDITOR)
assert lower is not None and upper is not None
assert not upper.can("use_in_own")
assert upper.settings == lower.settings
assert upper.resource_id == tool
def test_uppercase_tool_id_does_not_bypass_switch_on_attach(self, app, pg_conn):
agent_id, team_id = _agent(pg_conn)
tool = self._tool_not_usable_in_own(pg_conn, team_id)
resp = _put(app, pg_conn, agent_id, EDITOR, {"tools": [tool.upper()]})
assert _status(resp) == 403
def test_attached_ids_are_stored_canonical(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, prompt, source = _owner_private(pg_conn)
resp = _put(
app, pg_conn, agent_id, OWNER,
{"tools": [tool.upper()], "prompt_id": prompt.upper(), "sources": [source.upper()]},
)
assert _status(resp) == 200, resp.get_json()
row = _row(pg_conn, agent_id)
assert [str(t) for t in row["tools"]] == [tool]
assert str(row["prompt_id"]) == prompt
assert [str(s) for s in row["extra_source_ids"] or []] + (
[str(row["source_id"])] if row.get("source_id") else []
) == [source]
+2 -2
View File
@@ -319,9 +319,9 @@ class TestGetSinglePromptHappyPath:
from docsgpt.api.user.prompts.routes import GetSinglePrompt
# Presets are composed in-process now, so the failure this covers is a
# repository error on the custom-prompt path.
# database error on the custom-prompt path (the access check runs first).
with patch(
"docsgpt.api.user.prompts.routes.PromptsRepository",
"docsgpt.api.user.prompts.routes.require",
side_effect=OSError("boom"),
), app.test_request_context("/api/get_single_prompt?id=some-custom-id"):
from flask import request
+145
View File
@@ -0,0 +1,145 @@
"""Roles on prompts: listing payload, get, update, delete."""
from __future__ import annotations
import uuid
from contextlib import contextmanager
from unittest.mock import patch
import pytest
from flask import Flask
from sqlalchemy import text
from docsgpt.api.user.resource_access import set_settings
from docsgpt.storage.db.repositories.prompts import PromptsRepository
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
OWNER = "alice"
@pytest.fixture
def app():
return Flask(__name__)
@contextmanager
def _patch_db(conn):
@contextmanager
def _yield():
yield conn
with patch("docsgpt.api.user.prompts.routes.db_session", _yield), patch(
"docsgpt.api.user.prompts.routes.db_readonly", _yield
):
yield
def _call(app, conn, resource_cls, user, *, method="post", json=None, path="/api/x"):
with _patch_db(conn), app.test_request_context(path, method=method.upper(), json=json):
from flask import request
request.decoded_token = {"sub": user}
return getattr(resource_cls(), method)()
def _prompt(conn, name="P", content="C"):
return PromptsRepository(conn).create(OWNER, name, content)
def _share(conn, prompt_id, member, level):
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"]), "prompt", str(prompt_id), OWNER, OWNER, access_level=level
)
class TestGetPromptsAccess:
def _list(self, app, conn, user):
from docsgpt.api.user.prompts.routes import GetPrompts
resp = _call(app, conn, GetPrompts, user, method="get", path="/api/get_prompts")
assert resp.status_code == 200
return {p["id"]: p for p in resp.json}
def test_owner_and_grantee_payloads(self, app, pg_conn):
prompt = _prompt(pg_conn)
pid = str(prompt["id"])
_share(pg_conn, pid, "bob", "viewer")
own = self._list(app, pg_conn, OWNER)[pid]
assert own["access"] == "owner" and "delete" in own["allowed_actions"]
assert own["updated_at"]
shared = self._list(app, pg_conn, "bob")[pid]
assert shared["access"] == "viewer"
assert shared["allowed_actions"] == ["duplicate", "use"]
assert shared["type"] == "team" and shared["team_access"] == "viewer"
assert shared["updated_at"]
def test_presets_have_no_access_fields(self, app, pg_conn):
presets = self._list(app, pg_conn, OWNER)
assert "access" not in presets["default"]
class TestGetSinglePromptAccess:
def test_by_role(self, app, pg_conn):
from docsgpt.api.user.prompts.routes import GetSinglePrompt
prompt = _prompt(pg_conn, content="Hello")
pid = str(prompt["id"])
_share(pg_conn, pid, "bob", "viewer")
for user, access in ((OWNER, "owner"), ("bob", "viewer")):
resp = _call(app, pg_conn, GetSinglePrompt, user, method="get", path=f"/api/get_single_prompt?id={pid}")
assert resp.status_code == 200
assert resp.json["content"] == "Hello"
assert resp.json["access"] == access
assert resp.json["updated_at"]
resp = _call(app, pg_conn, GetSinglePrompt, "eve", method="get", path=f"/api/get_single_prompt?id={pid}")
assert resp.status_code == 404
class TestUpdatePromptAccess:
def test_by_role(self, app, pg_conn):
from docsgpt.api.user.prompts.routes import UpdatePrompt
prompt = _prompt(pg_conn)
pid = str(prompt["id"])
_share(pg_conn, pid, "ed", "editor")
_share(pg_conn, pid, "vi", "viewer")
body = {"id": pid, "name": "N", "content": "by editor"}
assert _call(app, pg_conn, UpdatePrompt, "ed", json=body).status_code == 200
assert PromptsRepository(pg_conn).get(pid, OWNER)["content"] == "by editor"
resp = _call(app, pg_conn, UpdatePrompt, "vi", json=body)
assert resp.status_code == 403 and resp.json["success"] is False
assert _call(app, pg_conn, UpdatePrompt, "eve", json=body).status_code == 404
def test_stale_write_is_409_for_owner_too(self, app, pg_conn):
from docsgpt.api.user.prompts.routes import UpdatePrompt
prompt = _prompt(pg_conn)
body = {"id": str(prompt["id"]), "name": "N", "content": "x",
"expected_updated_at": "2000-01-01T00:00:00+00:00"}
resp = _call(app, pg_conn, UpdatePrompt, OWNER, json=body)
assert resp.status_code == 409 and resp.json["code"] == "stale_write"
class TestDeletePromptAccess:
def test_by_role_and_cleanup(self, app, pg_conn):
from docsgpt.api.user.prompts.routes import DeletePrompt
prompt = _prompt(pg_conn)
pid = str(prompt["id"])
_share(pg_conn, pid, "ed", "editor")
set_settings(pg_conn, "prompt", pid, {"editors_can_share": True}, OWNER)
assert _call(app, pg_conn, DeletePrompt, "ed", json={"id": pid}).status_code == 403
assert _call(app, pg_conn, DeletePrompt, "eve", json={"id": pid}).status_code == 404
assert _call(app, pg_conn, DeletePrompt, OWNER, json={"id": pid}).status_code == 200
assert PromptsRepository(pg_conn).get(pid, OWNER) is None
for table in ("team_resource_grants", "resource_share_settings"):
count = pg_conn.execute(
text(f"SELECT count(*) FROM {table} WHERE resource_id = CAST(:id AS uuid)"), {"id": pid}
).scalar()
assert count == 0
+205
View File
@@ -0,0 +1,205 @@
"""Tests for the single resource-access check (roles + per-asset switches)."""
from __future__ import annotations
import uuid
import pytest
from docsgpt.api.user.resource_access import (
AccessDenied,
allowed_actions,
public_settings,
require,
resolve,
set_settings,
settings_for,
settings_many,
)
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
def _team(conn, owner="alice"):
return TeamsRepository(conn).create("Acme", f"acme-{uuid.uuid4().hex[:8]}", owner)
def _share(conn, team, resource_type, resource_id, level, member="bob", owner="alice"):
TeamMembersRepository(conn).add_member(str(team["id"]), member)
TeamResourceGrantsRepository(conn).grant(
str(team["id"]), resource_type, resource_id, owner, owner, access_level=level
)
def _agent(conn, owner="alice"):
return str(AgentsRepository(conn).create(owner, "A", "published")["id"])
class TestAllowedActions:
def test_owner_gets_everything(self):
actions = allowed_actions("agent", "owner", {})
assert {"delete", "share", "manage_settings", "move_folder", "edit"} <= actions
def test_agent_editor_defaults(self):
actions = allowed_actions("agent", "editor", {})
assert {"use", "edit", "publish", "view_logs", "manage_schedules", "export",
"manage_access_details", "edit_policy"} <= actions
assert not {"delete", "share", "manage_settings", "move_folder"} & actions
def test_agent_viewer_defaults(self):
actions = allowed_actions("agent", "viewer", {})
assert actions == {"use", "pin"}
def test_switches_widen_and_narrow(self):
settings = {
"editors_can_share": True,
"editors_can_delete": True,
"editors_can_manage_access_details": False,
"viewers_can_see_logs": True,
}
editor = allowed_actions("agent", "editor", settings)
assert {"share", "delete"} <= editor
assert "manage_access_details" not in editor
assert "view_logs" in allowed_actions("agent", "viewer", settings)
def test_switches_never_touch_owner_only_settings(self):
# Even with every switch on, managing the switches stays owner-only.
settings = {"editors_can_share": True, "editors_can_delete": True}
assert "manage_settings" not in allowed_actions("agent", "editor", settings)
def test_tool_viewer_can_use_in_own_by_default(self):
assert "use_in_own" in allowed_actions("tool", "viewer", {})
assert "use_in_own" not in allowed_actions(
"tool", "viewer", {"viewers_can_use_in_agents": False}
)
assert "edit_credentials" in allowed_actions("tool", "editor", {})
assert "edit_credentials" not in allowed_actions(
"tool", "editor", {"editors_can_change_credentials": False}
)
def test_source_and_prompt_defaults(self):
assert "view_config" in allowed_actions("source", "viewer", {})
assert "edit" not in allowed_actions("source", "viewer", {})
assert "edit" in allowed_actions("source", "editor", {})
assert "reconnect" not in allowed_actions("source", "editor", {})
assert "duplicate" in allowed_actions("prompt", "viewer", {})
assert "edit" in allowed_actions("prompt", "editor", {})
assert "edit" not in allowed_actions("prompt", "viewer", {})
def test_no_access_means_nothing(self):
assert allowed_actions("agent", None, {}) == set()
class TestSettings:
def test_defaults_when_no_row(self, pg_conn):
aid = _agent(pg_conn)
assert settings_for(pg_conn, "agent", aid) == {
"editors_can_share": False,
"editors_can_delete": False,
"editors_can_manage_access_details": True,
"viewers_can_see_logs": False,
}
def test_set_and_read_back(self, pg_conn):
aid = _agent(pg_conn)
set_settings(pg_conn, "agent", aid, {"viewers_can_see_logs": True}, "alice")
assert settings_for(pg_conn, "agent", aid)["viewers_can_see_logs"] is True
# A partial update keeps earlier values.
set_settings(pg_conn, "agent", aid, {"editors_can_share": True}, "alice")
merged = settings_for(pg_conn, "agent", aid)
assert merged["viewers_can_see_logs"] is True
assert merged["editors_can_share"] is True
def test_unknown_key_rejected(self, pg_conn):
aid = _agent(pg_conn)
with pytest.raises(ValueError):
set_settings(pg_conn, "agent", aid, {"viewers_can_delete": True}, "alice")
with pytest.raises(ValueError):
set_settings(pg_conn, "agent", aid, {"editors_can_share": "yes"}, "alice")
def test_settings_many(self, pg_conn):
a1, a2 = _agent(pg_conn), _agent(pg_conn)
set_settings(pg_conn, "agent", a1, {"editors_can_delete": True}, "alice")
many = settings_many(pg_conn, "agent", [a1, a2, "not-a-uuid"])
assert many[a1]["editors_can_delete"] is True
assert many[a2]["editors_can_delete"] is False
assert many["not-a-uuid"]["editors_can_delete"] is False
def test_public_settings_shape(self):
rows = public_settings("prompt", {"editors_can_share": True})
assert [r["key"] for r in rows] == ["editors_can_share", "viewers_can_duplicate"]
assert rows[0] == {"key": "editors_can_share", "value": True, "default": False}
class TestResolve:
def test_owner(self, pg_conn):
aid = _agent(pg_conn)
ra = resolve(pg_conn, "agent", aid, "alice")
assert ra.access == "owner"
assert ra.owner_id == "alice"
assert ra.can("delete")
def test_editor_and_viewer_via_team(self, pg_conn):
team = _team(pg_conn)
aid = _agent(pg_conn)
_share(pg_conn, team, "agent", aid, "editor", member="bob")
ra = resolve(pg_conn, "agent", aid, "bob")
assert ra.access == "editor"
assert ra.owner_id == "alice"
assert ra.can("view_logs") and not ra.can("delete")
aid2 = _agent(pg_conn)
_share(pg_conn, team, "agent", aid2, "viewer", member="carol")
rv = resolve(pg_conn, "agent", aid2, "carol")
assert rv.access == "viewer"
assert rv.actions == frozenset({"use", "pin"})
def test_switch_applies_to_grantee(self, pg_conn):
team = _team(pg_conn)
aid = _agent(pg_conn)
_share(pg_conn, team, "agent", aid, "viewer")
set_settings(pg_conn, "agent", aid, {"viewers_can_see_logs": True}, "alice")
assert resolve(pg_conn, "agent", aid, "bob").can("view_logs")
def test_stranger_and_bad_ids(self, pg_conn):
aid = _agent(pg_conn)
assert resolve(pg_conn, "agent", aid, "mallory") is None
assert resolve(pg_conn, "agent", "not-a-uuid", "alice") is None
assert resolve(pg_conn, "agent", str(uuid.uuid4()), "alice") is None
assert resolve(pg_conn, "nope", aid, "alice") is None
def test_payload(self, pg_conn):
aid = _agent(pg_conn)
payload = resolve(pg_conn, "agent", aid, "alice").payload()
assert payload["access"] == "owner"
assert "delete" in payload["allowed_actions"]
assert payload["allowed_actions"] == sorted(payload["allowed_actions"])
class TestRequire:
def test_allowed_returns_access(self, pg_conn):
aid = _agent(pg_conn)
assert require(pg_conn, "agent", aid, "alice", "delete").owner_id == "alice"
def test_visible_but_not_allowed_is_403(self, pg_conn):
team = _team(pg_conn)
aid = _agent(pg_conn)
_share(pg_conn, team, "agent", aid, "viewer")
with pytest.raises(AccessDenied) as exc:
require(pg_conn, "agent", aid, "bob", "edit")
assert exc.value.status == 403
def test_invisible_is_404(self, pg_conn):
aid = _agent(pg_conn)
with pytest.raises(AccessDenied) as exc:
require(pg_conn, "agent", aid, "mallory", "use")
assert exc.value.status == 404
def test_unknown_action_is_a_bug(self, pg_conn):
aid = _agent(pg_conn)
with pytest.raises(KeyError):
require(pg_conn, "agent", aid, "alice", "fly")
+459
View File
@@ -0,0 +1,459 @@
"""Resource sponsors: an editor's own tool/prompt/source runs inside the owner's agent.
An agent runs as its owner, so a resource the owner can't use used to be
dropped at run time even though an editor attached it. The editor who
attaches it becomes its sponsor (``resource_sponsors``); the resource then
runs while the sponsor can still edit the agent and still use the resource.
Uses real repositories on ``pg_conn``.
"""
from __future__ import annotations
import uuid
from contextlib import ExitStack, contextmanager
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from flask import Flask
from docsgpt.api.user.resource_access import (
active_sponsor,
agent_refs,
ref_principal,
set_settings,
sponsor_key,
sponsors_after_save,
)
from docsgpt.storage.db.repositories.agents import AgentsRepository
from docsgpt.storage.db.repositories.prompts import PromptsRepository
from docsgpt.storage.db.repositories.sources import SourcesRepository
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_tools import UserToolsRepository
from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
OWNER, EDITOR, VIEWER, OTHER = "sp-owner", "sp-editor", "sp-viewer", "sp-other"
_DB_MODULES = (
"docsgpt.api.user.agents.routes",
"docsgpt.api.user.workflows.routes",
"docsgpt.api.user.base",
"docsgpt.agents.tool_executor",
"docsgpt.api.answer.services.stream_processor",
"docsgpt.agents.workflows.workflow_engine",
"docsgpt.storage.db.session",
)
@pytest.fixture
def app():
return Flask(__name__)
@contextmanager
def _patch_db(conn):
import importlib
@contextmanager
def _yield():
yield conn
with ExitStack() as stack:
for mod_name in _DB_MODULES:
mod = importlib.import_module(mod_name)
for attr in ("db_session", "db_readonly"):
if hasattr(mod, attr):
stack.enter_context(patch(f"{mod_name}.{attr}", _yield))
yield
def _call(app, conn, resource_cls, method, path, user, *, json=None, args=()):
kwargs = {"method": method.upper()}
if json is not None:
kwargs["json"] = json
with _patch_db(conn), app.test_request_context(path, **kwargs):
from flask import request
request.decoded_token = {"sub": user}
return getattr(resource_cls(), method.lower())(*args)
def _status(resp) -> int:
return resp[1] if isinstance(resp, tuple) else resp.status_code
def _share_agent(conn, agent_id):
"""Share the agent with a team: EDITOR as editor, VIEWER as viewer."""
team = TeamsRepository(conn).create("T", f"t-{uuid.uuid4().hex[:8]}", OWNER)
tid = str(team["id"])
for member, level in ((EDITOR, "editor"), (VIEWER, "viewer")):
TeamMembersRepository(conn).add_member(tid, member)
TeamResourceGrantsRepository(conn).grant(
tid, "agent", agent_id, OWNER, OWNER, access_level=level, target_user_id=member
)
return tid
def _agent(conn, **extra):
extra.setdefault("agent_type", "classic")
extra.setdefault("chunks", 6)
row = AgentsRepository(conn).create(
OWNER, "Shared", "published", description="d", key=f"k-{uuid.uuid4().hex}", **extra
)
agent_id = str(row["id"])
team_id = _share_agent(conn, agent_id)
return agent_id, team_id
def _row(conn, agent_id):
return AgentsRepository(conn).get_by_id(agent_id)
def _editor_resources(conn):
tool = str(UserToolsRepository(conn).create(EDITOR, "api_tool")["id"])
prompt = str(PromptsRepository(conn).create(EDITOR, "mine", "Editor prompt")["id"])
source = str(SourcesRepository(conn).create("editor-src", user_id=EDITOR)["id"])
return tool, prompt, source
def _put(app, conn, agent_id, user, body):
from docsgpt.api.user.agents.routes import UpdateAgent
return _call(app, conn, UpdateAgent, "put", f"/api/update_agent/{agent_id}", user,
json=body, args=(agent_id,))
# ---------------------------------------------------------------------------
# sponsors_after_save
# ---------------------------------------------------------------------------
class TestSponsorsAfterSave:
def test_owner_usable_refs_need_no_sponsor(self, pg_conn):
agent_id, _ = _agent(pg_conn)
tool = str(UserToolsRepository(pg_conn).create(OWNER, "api_tool")["id"])
out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, EDITOR, [("tool", tool)])
assert out == {}
def test_editor_resources_are_sponsored_by_editor(self, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, prompt, source = _editor_resources(pg_conn)
refs = [("tool", tool), ("prompt", prompt), ("source", source)]
out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, EDITOR, refs)
assert out == {sponsor_key(t, i): EDITOR for t, i in refs}
def test_builtin_tool_and_preset_ids_are_skipped(self, pg_conn):
from docsgpt.agents.default_tools import loaded_builtin_agent_tools, synthesize_builtin_agent_tool
agent_id, _ = _agent(pg_conn)
builtin = next(iter(loaded_builtin_agent_tools()), None)
refs = [("prompt", "default")]
if builtin:
refs.append(("tool", str(synthesize_builtin_agent_tool(builtin)["id"])))
assert sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, EDITOR, refs) == {}
def test_owner_save_keeps_editor_sponsor(self, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
AgentsRepository(pg_conn).update_by_id(
agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
)
out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, OWNER, [("tool", tool)])
assert out == {sponsor_key("tool", tool): EDITOR}
def test_removed_ref_drops_out(self, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
AgentsRepository(pg_conn).update_by_id(
agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
)
assert sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, EDITOR, []) == {}
def test_another_editor_takes_over_when_sponsor_lost_access(self, pg_conn):
agent_id, team_id = _agent(pg_conn)
# A tool shared with the whole team: both editors may use it in their agents.
tool = str(UserToolsRepository(pg_conn).create(OTHER, "api_tool")["id"])
TeamMembersRepository(pg_conn).add_member(team_id, OTHER)
TeamResourceGrantsRepository(pg_conn).grant(team_id, "tool", tool, OTHER, OTHER)
TeamResourceGrantsRepository(pg_conn).grant(
team_id, "agent", agent_id, OWNER, OWNER, access_level="editor", target_user_id=OTHER
)
AgentsRepository(pg_conn).update_by_id(
agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
)
TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR)
out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, OTHER, [("tool", tool)])
assert out == {sponsor_key("tool", tool): OTHER}
def test_dead_sponsor_record_kept_when_nobody_qualifies(self, pg_conn):
agent_id, team_id = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
AgentsRepository(pg_conn).update_by_id(
agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
)
TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR)
out = sponsors_after_save(pg_conn, "agent", _row(pg_conn, agent_id), OWNER, OWNER, [("tool", tool)])
assert out == {sponsor_key("tool", tool): EDITOR}
# ---------------------------------------------------------------------------
# active_sponsor / ref_principal (live checks)
# ---------------------------------------------------------------------------
class TestActiveSponsor:
def _sponsored(self, pg_conn):
agent_id, team_id = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
AgentsRepository(pg_conn).update_by_id(
agent_id, {"tools": [tool], "resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
)
return agent_id, team_id, tool
def test_live_sponsor_is_principal(self, pg_conn):
agent_id, _, tool = self._sponsored(pg_conn)
agent = _row(pg_conn, agent_id)
assert active_sponsor(pg_conn, "agent", agent, "tool", tool) == EDITOR
assert ref_principal(pg_conn, "agent", agent, "tool", tool) == EDITOR
def test_owner_wins_over_sponsor(self, pg_conn):
agent_id, _ = _agent(pg_conn)
tool = str(UserToolsRepository(pg_conn).create(OWNER, "api_tool")["id"])
AgentsRepository(pg_conn).update_by_id(
agent_id, {"resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
)
assert ref_principal(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) == OWNER
def test_sponsor_removed_from_agent_stops(self, pg_conn):
agent_id, team_id, tool = self._sponsored(pg_conn)
TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR)
assert active_sponsor(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) is None
def test_sponsor_demoted_to_viewer_stops(self, pg_conn):
agent_id, team_id, tool = self._sponsored(pg_conn)
TeamResourceGrantsRepository(pg_conn).grant(
team_id, "agent", agent_id, OWNER, OWNER, access_level="viewer", target_user_id=EDITOR
)
assert active_sponsor(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) is None
def test_sponsor_losing_the_resource_stops(self, pg_conn):
agent_id, team_id = _agent(pg_conn)
tool = str(UserToolsRepository(pg_conn).create(OTHER, "api_tool")["id"])
TeamResourceGrantsRepository(pg_conn).grant(
team_id, "tool", tool, OTHER, OTHER, target_user_id=EDITOR
)
AgentsRepository(pg_conn).update_by_id(
agent_id, {"resource_sponsors": {sponsor_key("tool", tool): EDITOR}}
)
assert active_sponsor(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) == EDITOR
set_settings(pg_conn, "tool", tool, {"viewers_can_use_in_agents": False}, OTHER)
assert active_sponsor(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) is None
def test_no_record_no_sponsor(self, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
assert ref_principal(pg_conn, "agent", _row(pg_conn, agent_id), "tool", tool) is None
# ---------------------------------------------------------------------------
# update_agent / get_agent
# ---------------------------------------------------------------------------
class TestAgentRoutes:
def test_editor_attaching_own_resources_records_sponsor(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, prompt, source = _editor_resources(pg_conn)
resp = _put(app, pg_conn, agent_id, EDITOR,
{"tools": [tool], "prompt_id": prompt, "source": source})
assert _status(resp) == 200
row = _row(pg_conn, agent_id)
assert row["resource_sponsors"] == {
sponsor_key("tool", tool): EDITOR,
sponsor_key("prompt", prompt): EDITOR,
sponsor_key("source", source): EDITOR,
}
assert set(agent_refs(row)) >= {("tool", tool), ("prompt", prompt), ("source", source)}
def test_owner_save_does_not_wipe_sponsors(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
assert _status(_put(app, pg_conn, agent_id, EDITOR, {"tools": [tool]})) == 200
assert _status(_put(app, pg_conn, agent_id, OWNER, {"name": "Renamed", "tools": [tool]})) == 200
assert _row(pg_conn, agent_id)["resource_sponsors"] == {sponsor_key("tool", tool): EDITOR}
def test_detaching_clears_sponsor(self, app, pg_conn):
agent_id, _ = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
assert _status(_put(app, pg_conn, agent_id, EDITOR, {"tools": [tool]})) == 200
assert _status(_put(app, pg_conn, agent_id, EDITOR, {"tools": []})) == 200
assert _row(pg_conn, agent_id)["resource_sponsors"] == {}
def test_get_agent_lists_sponsors_for_editors_only(self, app, pg_conn):
from docsgpt.api.user.agents.routes import GetAgent
from docsgpt.storage.db.repositories.users import UsersRepository
agent_id, team_id = _agent(pg_conn)
tool, _, _ = _editor_resources(pg_conn)
UsersRepository(pg_conn).upsert(EDITOR, email="bob@example.com")
assert _status(_put(app, pg_conn, agent_id, EDITOR, {"tools": [tool]})) == 200
path = f"/api/get_agent?id={agent_id}"
owner_view = _call(app, pg_conn, GetAgent, "get", path, OWNER).get_json()
assert owner_view["resource_sponsors"] == [
{"type": "tool", "id": tool, "user_id": EDITOR, "label": "bob@example.com", "active": True}
]
viewer_view = _call(app, pg_conn, GetAgent, "get", path, VIEWER).get_json()
assert viewer_view["resource_sponsors"] == []
TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR)
owner_view = _call(app, pg_conn, GetAgent, "get", path, OWNER).get_json()
assert owner_view["resource_sponsors"][0]["active"] is False
# ---------------------------------------------------------------------------
# run time
# ---------------------------------------------------------------------------
class TestRunTime:
def _sponsored_agent(self, app, pg_conn):
agent_id, team_id = _agent(pg_conn)
tool, prompt, source = _editor_resources(pg_conn)
resp = _put(app, pg_conn, agent_id, EDITOR,
{"tools": [tool], "prompt_id": prompt, "source": source})
assert _status(resp) == 200
return _row(pg_conn, agent_id), team_id, tool, prompt, source
def test_agent_key_toolset_includes_sponsored_tool(self, app, pg_conn):
from docsgpt.agents.tool_executor import ToolExecutor
agent, team_id, tool, _, _ = self._sponsored_agent(app, pg_conn)
with _patch_db(pg_conn):
tools = ToolExecutor(user_api_key=agent["key"], user=OWNER)._get_tools_by_api_key(agent["key"])
assert tool in tools
# The row is the tool owner's, so its credentials decrypt as the editor.
assert tools[tool]["user_id"] == EDITOR
TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", str(agent["id"]), target_user_id=EDITOR)
with _patch_db(pg_conn):
tools = ToolExecutor(user_api_key=agent["key"], user=OWNER)._get_tools_by_api_key(agent["key"])
assert tool not in tools
def test_sponsored_prompt_renders(self, app, pg_conn):
from docsgpt.api.answer.services.stream_processor import authorized_prompt_id
agent, team_id, _, prompt, _ = self._sponsored_agent(app, pg_conn)
with _patch_db(pg_conn):
assert authorized_prompt_id(prompt, OWNER, agent) == prompt
assert authorized_prompt_id(prompt, OWNER) == "default"
TeamResourceGrantsRepository(pg_conn).revoke(
team_id, "agent", str(agent["id"]), target_user_id=EDITOR
)
assert authorized_prompt_id(prompt, OWNER, agent) == "default"
def test_agent_sources_include_sponsored_and_team_shared(self, app, pg_conn):
from docsgpt.api.answer.services.stream_processor import StreamProcessor
agent, team_id, _, _, source = self._sponsored_agent(app, pg_conn)
# A source shared with the owner by a team (not owned): used to be
# dropped by an owner-scoped read.
shared = str(SourcesRepository(pg_conn).create("shared-src", user_id=OTHER)["id"])
if not TeamMembersRepository(pg_conn).is_member(OWNER, team_id):
TeamMembersRepository(pg_conn).add_member(team_id, OWNER)
TeamResourceGrantsRepository(pg_conn).grant(team_id, "source", shared, OTHER, OTHER)
AgentsRepository(pg_conn).update_by_id(str(agent["id"]), {"extra_source_ids": [shared]})
processor = StreamProcessor.__new__(StreamProcessor)
with _patch_db(pg_conn):
data = processor._get_data_from_api_key(agent["key"])
assert [s["id"] for s in data["sources"]] == [source, shared]
def test_search_service_authorizes_sponsored_source(self, app, pg_conn):
from docsgpt.services.search_service import _authorized_source_ids
agent, team_id, _, _, source = self._sponsored_agent(app, pg_conn)
stranger_src = str(SourcesRepository(pg_conn).create("x", user_id="sp-stranger")["id"])
assert _authorized_source_ids(pg_conn, agent, [source, stranger_src]) == [source]
TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", str(agent["id"]), target_user_id=EDITOR)
assert _authorized_source_ids(pg_conn, agent, [source]) == []
# ---------------------------------------------------------------------------
# workflows
# ---------------------------------------------------------------------------
def _wf_body(tool=None, source=None):
agent_cfg = {"agent_type": "classic", "system_prompt": "s", "tools": [tool] if tool else [],
"sources": [source] if source else []}
return {
"name": "WF",
"description": "d",
"nodes": [
{"id": "start1", "type": "start", "position": {"x": 0, "y": 0}, "data": {}},
{"id": "a1", "type": "agent", "title": "A", "position": {"x": 50, "y": 0},
"data": {"config": agent_cfg}},
{"id": "end1", "type": "end", "position": {"x": 100, "y": 0}, "data": {}},
],
"edges": [
{"id": "e1", "source": "start1", "target": "a1"},
{"id": "e2", "source": "a1", "target": "end1"},
],
}
class TestWorkflows:
def _setup(self, pg_conn):
wf = WorkflowsRepository(pg_conn).create(OWNER, "wf")
_, team_id = _agent(pg_conn, agent_type="workflow", workflow_id=str(wf["id"]))
return str(wf["id"]), team_id
def _put(self, app, pg_conn, wid, user, body):
from docsgpt.api.user.workflows.routes import WorkflowDetail
return _call(app, pg_conn, WorkflowDetail, "put", f"/api/workflows/{wid}", user,
json=body, args=(wid,))
def test_editor_node_resources_are_sponsored(self, app, pg_conn):
wid, _ = self._setup(pg_conn)
tool, _, source = _editor_resources(pg_conn)
resp = self._put(app, pg_conn, wid, EDITOR, _wf_body(tool, source))
assert _status(resp) == 200, resp.get_json()
row = WorkflowsRepository(pg_conn).get_by_id(wid)
assert row["resource_sponsors"] == {
sponsor_key("tool", tool): EDITOR,
sponsor_key("source", source): EDITOR,
}
# Removing the node's refs clears them.
assert _status(self._put(app, pg_conn, wid, EDITOR, _wf_body())) == 200
assert WorkflowsRepository(pg_conn).get_by_id(wid)["resource_sponsors"] == {}
def test_engine_resolves_sponsored_node_refs(self, app, pg_conn):
from docsgpt.agents.workflows.workflow_engine import WorkflowEngine
wid, team_id = self._setup(pg_conn)
tool, _, source = _editor_resources(pg_conn)
assert _status(self._put(app, pg_conn, wid, EDITOR, _wf_body(tool, source))) == 200
engine = WorkflowEngine.__new__(WorkflowEngine)
engine.agent = SimpleNamespace(
workflow_row=WorkflowsRepository(pg_conn).get_by_id(wid),
_resolve_owner_id=lambda: OWNER,
user=OWNER,
decoded_token={"sub": OWNER},
)
with _patch_db(pg_conn):
assert engine._node_tool_principals([tool]) == {tool: EDITOR}
assert engine._authorized_node_sources([source]) == [source]
agent_id = str(pg_conn.exec_driver_sql(
f"SELECT id FROM agents WHERE workflow_id = '{wid}'"
).scalar())
TeamResourceGrantsRepository(pg_conn).revoke(team_id, "agent", agent_id, target_user_id=EDITOR)
assert engine._node_tool_principals([tool]) == {}
assert engine._authorized_node_sources([source]) == []
+64
View File
@@ -616,3 +616,67 @@ class TestExecuteScheduledRunBody:
meta = messages[0]._mapping["message_metadata"]
assert meta.get("scheduled") is True
assert "schedule.message.appended" in {e[0] for e in stub_events}
class TestRunAsOwnerAccessRecheck:
"""A schedule someone set on another user's agent re-checks their access
on every run; the run itself always executes as the agent's owner."""
def _member_run(self, conn, *, grant: bool):
import uuid as _uuid
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
agent_id = _make_agent(conn, "owner-x")
if grant:
team = TeamsRepository(conn).create("T", f"t-{_uuid.uuid4().hex[:8]}", "owner-x")
TeamMembersRepository(conn).add_member(str(team["id"]), "member-y")
TeamResourceGrantsRepository(conn).grant(
str(team["id"]), "agent", agent_id, "owner-x", "owner-x",
access_level="viewer",
)
schedule = SchedulesRepository(conn).create(
user_id="member-y", agent_id=agent_id, trigger_type="recurring",
instruction="hello", cron="* * * * *",
next_run_at=_now() + timedelta(minutes=5),
)
run = ScheduleRunsRepository(conn).record_pending(
str(schedule["id"]), "member-y", agent_id, _now(),
)
return run
def test_revoked_member_run_fails_without_running(
self, pg_engine, patched_engine, stub_events,
):
with pg_engine.begin() as conn:
run = self._member_run(conn, grant=False)
with patch(
"docsgpt.api.user.scheduler_worker.run_agent_headless",
) as headless:
result = execute_scheduled_run_body(str(run["id"]), "celery-r")
headless.assert_not_called()
assert result["status"] == "failed"
with pg_engine.connect() as conn:
row = ScheduleRunsRepository(conn).get_internal(str(run["id"]))
assert row["status"] == "failed"
assert row["error"] == "agent access revoked"
def test_member_with_grant_runs(self, pg_engine, patched_engine, stub_events):
with pg_engine.begin() as conn:
run = self._member_run(conn, grant=True)
with patch(
"docsgpt.api.user.scheduler_worker.run_agent_headless",
return_value={
"answer": "ok", "tool_calls": [], "sources": [], "thought": "",
"prompt_tokens": 1, "generated_tokens": 1, "denied": [],
"error_type": None, "model_id": "m",
},
) as headless:
result = execute_scheduled_run_body(str(run["id"]), "celery-g")
assert result["status"] == "success"
# Runs with the owner's agent row (owner's context).
assert headless.call_args.args[0]["user_id"] == "owner-x"
+1 -1
View File
@@ -493,7 +493,7 @@ class TestUnexpectedExceptionMasked:
raise RuntimeError("internal detail: secret connection string")
with _patch_db(pg_conn), patch.object(
SchedulesRepository, "get", side_effect=_boom,
SchedulesRepository, "get_internal", side_effect=_boom,
), app.test_request_context(
f"/api/schedules/{s['id']}", method="GET",
):
+780
View File
@@ -0,0 +1,780 @@
"""Roles on tools: listing payload, write actions, secrets, chat preferences, MCP save."""
from __future__ import annotations
import uuid
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from docsgpt.api.user.resource_access import set_settings
from docsgpt.security.encryption import decrypt_credentials, encrypt_credentials
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
from docsgpt.storage.db.repositories.users import UsersRepository
OWNER = "alice"
@pytest.fixture
def app():
return Flask(__name__)
@contextmanager
def _patch_db(conn):
@contextmanager
def _yield():
yield conn
with patch("docsgpt.api.user.tools.routes.db_session", _yield), patch(
"docsgpt.api.user.tools.routes.db_readonly", _yield
), patch("docsgpt.api.user.tools.mcp.db_session", _yield), patch(
"docsgpt.api.user.tools.mcp.db_readonly", _yield
), patch("docsgpt.api.user.tools.routes.validate_url"), patch(
"docsgpt.api.user.tools.mcp.validate_url"
):
# URL validation resolves DNS; the SSRF gate has its own tests.
yield
def _call(app, conn, resource_cls, user, *, method="post", json=None, path="/api/x"):
with _patch_db(conn), app.test_request_context(path, method=method.upper(), json=json):
from flask import request
request.decoded_token = {"sub": user}
return getattr(resource_cls(), method)()
def _share(conn, tool_id, member, level, *, team_name="Acme"):
team = TeamsRepository(conn).create(team_name, 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 _tool(conn, name="read_webpage", config=None, status=True, owner=OWNER):
return UserToolsRepository(conn).create(
owner, name, config=config or {}, display_name=name, description="",
actions=[{"name": "act", "active": True, "require_approval": False}], status=status,
)
def _api_config(url="https://api.example.com/users", header_value="sk-secret", query_value="q-secret"):
return {
"actions": {
"get_users": {
"name": "get_users",
"description": "Get users",
"url": url,
"method": "GET",
"active": True,
"headers": {"type": "object", "properties": {
"X-Key": {"type": "string", "description": "k", "value": header_value,
"filled_by_llm": False},
}},
"query_params": {"type": "object", "properties": {
"token": {"type": "string", "description": "t", "value": query_value,
"filled_by_llm": False},
"limit": {"type": "integer", "description": "l", "value": "", "filled_by_llm": True},
}},
"body": {"type": "object", "properties": {}},
}
}
}
def _row(conn, tool_id, owner=OWNER):
return UserToolsRepository(conn).get(str(tool_id), owner)
def _runtime_action(conn, tool_id, owner=OWNER):
from docsgpt.agents.tool_executor import api_tool_action_with_secrets
row = _row(conn, tool_id, owner)
return api_tool_action_with_secrets(row, "get_users", owner)
# ---------------------------------------------------------------------------
# 1. GET /api/get_tools
# ---------------------------------------------------------------------------
class TestGetToolsAccess:
def _tools(self, app, conn, user):
from docsgpt.api.user.tools.routes import GetTools
resp = _call(app, conn, GetTools, user, method="get", path="/api/get_tools")
assert resp.status_code == 200
return {t["id"]: t for t in resp.json["tools"] if not t.get("default") and not t.get("builtin")}
def test_owner_row_carries_owner_payload_and_in_chat_from_status(self, app, pg_conn):
tool = _tool(pg_conn, status=False)
row = self._tools(app, pg_conn, OWNER)[str(tool["id"])]
assert row["access"] == "owner"
assert {"delete", "share", "manage_settings", "edit"} <= set(row["allowed_actions"])
assert row["allowed_actions"] == sorted(row["allowed_actions"])
assert row["in_chat"] is False
assert row["ownership"] == "user"
def test_viewer_row_payload_shared_via_owner_label_and_preference(self, app, pg_conn):
UsersRepository(pg_conn).upsert(OWNER, email="alice@example.com")
tool = _tool(pg_conn)
_share(pg_conn, tool["id"], "bob", "viewer", team_name="Research")
row = self._tools(app, pg_conn, "bob")[str(tool["id"])]
assert row["access"] == "viewer"
assert row["allowed_actions"] == ["use", "use_in_own"]
assert row["in_chat"] is False # sharing never adds to chats
assert row["shared_via"] == "Research"
assert row["owner_label"] == "alice@example.com"
assert row["ownership"] == "team" and row["team_access"] == "viewer"
UserToolPreferencesRepository(pg_conn).set_in_chat("bob", str(tool["id"]), True)
assert self._tools(app, pg_conn, "bob")[str(tool["id"])]["in_chat"] is True
def test_owner_label_null_without_user_row(self, app, pg_conn):
tool = _tool(pg_conn)
_share(pg_conn, tool["id"], "bob", "editor")
row = self._tools(app, pg_conn, "bob")[str(tool["id"])]
assert row["owner_label"] is None
assert "edit" in row["allowed_actions"] and "delete" not in row["allowed_actions"]
def test_default_rows_carry_in_chat(self, app, pg_conn):
from docsgpt.api.user.tools.routes import GetTools
resp = _call(app, pg_conn, GetTools, OWNER, method="get", path="/api/get_tools")
defaults = [t for t in resp.json["tools"] if t.get("default")]
assert all(t["in_chat"] == t["status"] for t in defaults)
def test_api_tool_secret_values_masked_for_owner_and_grantee(self, app, pg_conn):
from docsgpt.api.user.tools.routes import _seal_api_tool_secrets
sealed = _seal_api_tool_secrets(_api_config(), {}, OWNER)
tool = _tool(pg_conn, name="api_tool", config=sealed)
_share(pg_conn, tool["id"], "bob", "viewer")
for user in (OWNER, "bob"):
row = self._tools(app, pg_conn, user)[str(tool["id"])]
action = row["config"]["actions"]["get_users"]
header = action["headers"]["properties"]["X-Key"]
assert header["value"] == "" and header["has_value"] is True
token = action["query_params"]["properties"]["token"]
assert token["value"] == "" and token["has_value"] is True
limit = action["query_params"]["properties"]["limit"]
assert limit["value"] == "" and limit.get("has_value") is False
assert "encrypted_action_secrets" not in row["config"]
assert "encrypted_credentials" not in row["config"]
def test_legacy_plaintext_api_tool_values_masked(self, app, pg_conn):
tool = _tool(pg_conn, name="api_tool", config=_api_config())
row = self._tools(app, pg_conn, OWNER)[str(tool["id"])]
header = row["config"]["actions"]["get_users"]["headers"]["properties"]["X-Key"]
assert header == {**header, "value": "", "has_value": True}
# ---------------------------------------------------------------------------
# 2. api_tool secret storage
# ---------------------------------------------------------------------------
class TestApiToolSecrets:
def _save(self, app, conn, user, tool_id, config):
from docsgpt.api.user.tools.routes import UpdateTool
return _call(app, conn, UpdateTool, user, json={"id": str(tool_id), "config": config})
def _masked(self, config):
import copy
out = copy.deepcopy(config)
for section in ("headers", "query_params"):
for spec in out["actions"]["get_users"][section]["properties"].values():
spec["has_value"] = bool(spec.get("value"))
spec["value"] = ""
return out
def test_values_stored_encrypted_not_plaintext(self, app, pg_conn):
tool = _tool(pg_conn, name="api_tool", config={})
assert self._save(app, pg_conn, OWNER, tool["id"], _api_config()).status_code == 200
stored = _row(pg_conn, tool["id"])["config"]
header = stored["actions"]["get_users"]["headers"]["properties"]["X-Key"]
assert header["value"] == ""
assert stored["encrypted_action_secrets"]
blob = decrypt_credentials(stored["encrypted_action_secrets"], OWNER)
assert blob["get_users"]["headers"]["X-Key"] == "sk-secret"
action = _runtime_action(pg_conn, tool["id"])
assert action["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
assert action["query_params"]["properties"]["token"]["value"] == "q-secret"
def test_empty_value_with_has_value_keeps_stored_secret(self, app, pg_conn):
tool = _tool(pg_conn, name="api_tool", config={})
self._save(app, pg_conn, OWNER, tool["id"], _api_config())
masked = self._masked(_api_config())
masked["actions"]["get_users"]["description"] = "Renamed"
assert self._save(app, pg_conn, OWNER, tool["id"], masked).status_code == 200
action = _runtime_action(pg_conn, tool["id"])
assert action["description"] == "Renamed"
assert action["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
def test_new_value_replaces_and_cleared_has_value_drops(self, app, pg_conn):
tool = _tool(pg_conn, name="api_tool", config={})
self._save(app, pg_conn, OWNER, tool["id"], _api_config())
cfg = self._masked(_api_config())
cfg["actions"]["get_users"]["headers"]["properties"]["X-Key"]["value"] = "sk-new"
cfg["actions"]["get_users"]["query_params"]["properties"]["token"]["has_value"] = False
self._save(app, pg_conn, OWNER, tool["id"], cfg)
action = _runtime_action(pg_conn, tool["id"])
assert action["headers"]["properties"]["X-Key"]["value"] == "sk-new"
assert action["query_params"]["properties"]["token"]["value"] == ""
def test_legacy_plaintext_reencrypted_on_save(self, app, pg_conn):
tool = _tool(pg_conn, name="api_tool", config=_api_config())
# Runtime reads the legacy plaintext value transparently.
assert _runtime_action(pg_conn, tool["id"])["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
self._save(app, pg_conn, OWNER, tool["id"], self._masked(_api_config()))
stored = _row(pg_conn, tool["id"])["config"]
assert stored["actions"]["get_users"]["headers"]["properties"]["X-Key"]["value"] == ""
assert _runtime_action(pg_conn, tool["id"])["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
def test_editor_save_encrypts_with_owner_key(self, app, pg_conn):
tool = _tool(pg_conn, name="api_tool", config={})
_share(pg_conn, tool["id"], "bob", "editor")
assert self._save(app, pg_conn, "bob", tool["id"], _api_config()).status_code == 200
stored = _row(pg_conn, tool["id"])["config"]
assert decrypt_credentials(stored["encrypted_action_secrets"], OWNER)
assert UserToolsRepository(pg_conn).list_for_user("bob") == []
def test_url_host_change_clears_secrets_and_requires_new_ones(self, app, pg_conn):
tool = _tool(pg_conn, name="api_tool", config={})
self._save(app, pg_conn, OWNER, tool["id"], _api_config())
moved = self._masked(_api_config(url="https://evil.example.org/users"))
resp = self._save(app, pg_conn, OWNER, tool["id"], moved)
assert resp.status_code == 400
assert "credentials" in resp.json["message"].lower()
# Same host, different path: stored values kept.
same_host = self._masked(_api_config(url="https://api.example.com/v2/users"))
assert self._save(app, pg_conn, OWNER, tool["id"], same_host).status_code == 200
assert _runtime_action(pg_conn, tool["id"])["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
@pytest.mark.parametrize("url", [
"http://api.example.com/users", # https -> http: cleartext
"https://api.example.com:8443/users", # another port, maybe another service
])
def test_url_scheme_or_port_change_requires_new_secrets(self, app, pg_conn, url):
tool = _tool(pg_conn, name="api_tool", config={})
self._save(app, pg_conn, OWNER, tool["id"], _api_config())
resp = self._save(app, pg_conn, OWNER, tool["id"], self._masked(_api_config(url=url)))
assert resp.status_code == 400
assert "credentials" in resp.json["message"].lower()
def test_url_with_explicit_default_port_keeps_secrets(self, app, pg_conn):
tool = _tool(pg_conn, name="api_tool", config={})
self._save(app, pg_conn, OWNER, tool["id"], _api_config())
same = self._masked(_api_config(url="https://API.example.com:443/users"))
assert self._save(app, pg_conn, OWNER, tool["id"], same).status_code == 200
assert _runtime_action(pg_conn, tool["id"])["headers"]["properties"]["X-Key"]["value"] == "sk-secret"
def test_url_host_change_with_new_secrets_succeeds(self, app, pg_conn):
tool = _tool(pg_conn, name="api_tool", config={})
self._save(app, pg_conn, OWNER, tool["id"], _api_config())
moved = _api_config(url="https://other.example.org/users", header_value="sk-2", query_value="q-2")
assert self._save(app, pg_conn, OWNER, tool["id"], moved).status_code == 200
action = _runtime_action(pg_conn, tool["id"])
assert action["headers"]["properties"]["X-Key"]["value"] == "sk-2"
# ---------------------------------------------------------------------------
# 3. Write actions
# ---------------------------------------------------------------------------
class TestToolWrites:
def test_update_tool_rename_by_role(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool
tool = _tool(pg_conn)
_share(pg_conn, tool["id"], "ed", "editor")
_share(pg_conn, tool["id"], "vi", "viewer")
body = {"id": str(tool["id"]), "customName": "Renamed"}
assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 200
assert _row(pg_conn, tool["id"])["custom_name"] == "Renamed"
resp = _call(app, pg_conn, UpdateTool, "vi", json=body)
assert resp.status_code == 403 and resp.json["success"] is False
assert _call(app, pg_conn, UpdateTool, "stranger", json=body).status_code == 404
def test_update_tool_config_needs_edit_credentials(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool, UpdateToolConfig
tool = _tool(pg_conn, name="brave", config={})
_share(pg_conn, tool["id"], "ed", "editor")
set_settings(pg_conn, "tool", str(tool["id"]), {"editors_can_change_credentials": False}, OWNER)
body = {"id": str(tool["id"]), "config": {"token": "x"}}
assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 403
assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 403
set_settings(pg_conn, "tool", str(tool["id"]), {"editors_can_change_credentials": True}, OWNER)
assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 200
def test_update_tool_config_encrypts_with_owner_key(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolConfig
tool = _tool(pg_conn, name="mcp_tool", config={"server_url": "https://mcp.example.com/x",
"auth_type": "bearer"})
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "config": {"server_url": "https://mcp.example.com/x",
"auth_type": "bearer", "bearer_token": "tok"}}
assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 200
stored = _row(pg_conn, tool["id"])["config"]
assert decrypt_credentials(stored["encrypted_credentials"], OWNER) == {"bearer_token": "tok"}
def test_update_tool_config_host_change_clears_stored_secrets(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolConfig
cfg = {"server_url": "https://mcp.example.com/x", "auth_type": "bearer",
"encrypted_credentials": encrypt_credentials({"bearer_token": "tok"}, OWNER)}
tool = _tool(pg_conn, name="mcp_tool", config=cfg)
body = {"id": str(tool["id"]), "config": {"server_url": "https://other.example.org/x",
"auth_type": "bearer"}}
resp = _call(app, pg_conn, UpdateToolConfig, OWNER, json=body)
assert resp.status_code == 400
assert "credentials" in resp.json["message"].lower()
stored = _row(pg_conn, tool["id"])["config"]
assert stored["server_url"] == "https://mcp.example.com/x"
def test_update_tool_config_scheme_change_clears_stored_secrets(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolConfig
cfg = {"server_url": "https://mcp.example.com/x", "auth_type": "bearer",
"encrypted_credentials": encrypt_credentials({"bearer_token": "tok"}, OWNER)}
tool = _tool(pg_conn, name="mcp_tool", config=cfg)
body = {"id": str(tool["id"]), "config": {"server_url": "http://mcp.example.com/x",
"auth_type": "bearer"}}
resp = _call(app, pg_conn, UpdateToolConfig, OWNER, json=body)
assert resp.status_code == 400
assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://mcp.example.com/x"
def test_api_tool_description_edit_needs_only_edit(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool, _seal_api_tool_secrets
tool = _tool(pg_conn, name="api_tool", config=_seal_api_tool_secrets(_api_config(), {}, OWNER))
_share(pg_conn, tool["id"], "ed", "editor")
set_settings(pg_conn, "tool", str(tool["id"]), {"editors_can_change_credentials": False}, OWNER)
cfg = TestApiToolSecrets()._masked(_api_config())
cfg["actions"]["get_users"]["description"] = "Edited"
body = {"id": str(tool["id"]), "config": cfg}
assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 200
# Changing the URL (or a secret value) needs edit_credentials.
cfg["actions"]["get_users"]["url"] = "https://api.example.com/v2"
assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 403
def test_update_tool_actions_by_role(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolActions
tool = _tool(pg_conn)
_share(pg_conn, tool["id"], "ed", "editor")
_share(pg_conn, tool["id"], "vi", "viewer")
actions = [{"name": "act", "active": True, "require_approval": True}]
body = {"id": str(tool["id"]), "actions": actions}
assert _call(app, pg_conn, UpdateToolActions, "ed", json=body).status_code == 200
assert _row(pg_conn, tool["id"])["actions"][0]["require_approval"] is True
assert _call(app, pg_conn, UpdateToolActions, "vi", json=body).status_code == 403
assert _call(app, pg_conn, UpdateToolActions, "nobody", json=body).status_code == 404
def test_delete_tool_by_role_cleans_grants_and_settings(self, app, pg_conn):
from sqlalchemy import text
from docsgpt.api.user.tools.routes import DeleteTool
tool = _tool(pg_conn)
tid = str(tool["id"])
_share(pg_conn, tid, "ed", "editor")
set_settings(pg_conn, "tool", tid, {"editors_can_share": True}, OWNER)
assert _call(app, pg_conn, DeleteTool, "ed", json={"id": tid}).status_code == 403
assert _call(app, pg_conn, DeleteTool, "nobody", json={"id": tid}).status_code == 404
assert _call(app, pg_conn, DeleteTool, OWNER, json={"id": tid}).status_code == 200
assert _row(pg_conn, tid) is None
grants = pg_conn.execute(
text("SELECT count(*) FROM team_resource_grants WHERE resource_id = CAST(:id AS uuid)"), {"id": tid}
).scalar()
settings_rows = pg_conn.execute(
text("SELECT count(*) FROM resource_share_settings WHERE resource_id = CAST(:id AS uuid)"), {"id": tid}
).scalar()
assert grants == 0 and settings_rows == 0
# ---------------------------------------------------------------------------
# 5. POST /api/update_tool_status
# ---------------------------------------------------------------------------
class TestUpdateToolStatusRoles:
def test_owner_writes_status(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolStatus
tool = _tool(pg_conn, status=True)
body = {"id": str(tool["id"]), "status": False}
assert _call(app, pg_conn, UpdateToolStatus, OWNER, json=body).status_code == 200
assert _row(pg_conn, tool["id"])["status"] is False
def test_grantee_writes_personal_preference(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolStatus
tool = _tool(pg_conn, status=False)
_share(pg_conn, tool["id"], "bob", "viewer")
body = {"id": str(tool["id"]), "status": True}
assert _call(app, pg_conn, UpdateToolStatus, "bob", json=body).status_code == 200
assert _row(pg_conn, tool["id"])["status"] is False # owner's switch untouched
prefs = UserToolPreferencesRepository(pg_conn)
assert prefs.in_chat_many("bob", [str(tool["id"])]) == {str(tool["id"]): True}
def test_grantee_without_use_in_own_is_forbidden(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolStatus
tool = _tool(pg_conn)
_share(pg_conn, tool["id"], "bob", "viewer")
set_settings(pg_conn, "tool", str(tool["id"]), {"viewers_can_use_in_agents": False}, OWNER)
body = {"id": str(tool["id"]), "status": True}
assert _call(app, pg_conn, UpdateToolStatus, "bob", json=body).status_code == 403
assert _call(app, pg_conn, UpdateToolStatus, "nobody", json=body).status_code == 404
# ---------------------------------------------------------------------------
# 4. MCP save
# ---------------------------------------------------------------------------
def _mcp_tool(conn, url="https://mcp.example.com/mcp", secrets=None, auth_type="bearer"):
cfg = {"server_url": url, "auth_type": auth_type, "transport_type": "http"}
if secrets:
cfg["encrypted_credentials"] = encrypt_credentials(secrets, OWNER)
return UserToolsRepository(conn).create(
OWNER, "mcp_tool", config=cfg, display_name="M", custom_name="M", description="",
actions=[{"name": "old", "active": True}], status=True,
)
def _stored_mcp_secret(conn, tool_id):
"""The secret a saved MCP tool runs with, and whose key it is under.
A save moves the secret onto a connection of the tool's owner (see
``docsgpt/connectors``); a tool without one keeps it in its config.
"""
from docsgpt.connectors import service
from docsgpt.storage.db.repositories.connector_sessions import ConnectorSessionsRepository
row = UserToolsRepository(conn).get_any(str(tool_id), OWNER)
if not row.get("connection_id"):
return decrypt_credentials(row["config"]["encrypted_credentials"], OWNER), OWNER
assert "encrypted_credentials" not in row["config"]
connection = ConnectorSessionsRepository(conn).get(str(row["connection_id"]))
return service.get_credentials(connection), connection["user_id"]
def _mcp_connection_tool(conn, secrets, url="https://mcp.example.com/mcp"):
"""An MCP tool whose key lives on an owner's connection, as a save leaves it."""
from docsgpt.connectors import catalog, service
connection, _ = service.create_api_key_connection(
conn, OWNER, catalog.get_definition("custom_mcp"), secrets,
server_url=catalog.base_url(url), display_name="M",
)
return UserToolsRepository(conn).create(
OWNER, "mcp_tool", config={"server_url": url, "auth_type": "bearer", "transport_type": "http"},
display_name="M", custom_name="M", description="",
actions=[{"name": "old", "active": True}], status=True, connection_id=str(connection["id"]),
), connection
class TestMCPSaveAccess:
def _save(self, app, conn, user, body, discovered=None):
from docsgpt.api.user.tools.mcp import MCPServerSave
fake = MagicMock()
fake.get_actions_metadata.return_value = discovered or [{"name": "t1"}]
with patch("docsgpt.api.user.tools.mcp.MCPTool", return_value=fake) as cls:
resp = _call(app, conn, MCPServerSave, user, json=body)
return resp, cls
def _count(self, conn):
from sqlalchemy import text
return conn.execute(text("SELECT count(*) FROM user_tools WHERE name = 'mcp_tool'")).scalar()
def test_editor_updates_owner_row_no_duplicate(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "Renamed",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
"transport_type": "http"}}
resp, cls = self._save(app, pg_conn, "ed", body)
assert resp.status_code == 200, resp.json
assert resp.json["id"] == str(tool["id"])
assert self._count(pg_conn) == 1
row = _row(pg_conn, tool["id"])
assert row["display_name"] == "Renamed"
# Stored creds were reused for discovery and kept, encrypted with the owner's key.
assert cls.call_args.kwargs.get("user_id") == OWNER
assert cls.call_args.kwargs["config"]["auth_credentials"] == {"bearer_token": "tok"}
assert _stored_mcp_secret(pg_conn, tool["id"]) == ({"bearer_token": "tok"}, OWNER)
def test_viewer_and_stranger_cannot_save_or_duplicate(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "vi", "viewer")
body = {"id": str(tool["id"]), "displayName": "X",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
"transport_type": "http", "bearer_token": "new"}}
assert self._save(app, pg_conn, "vi", body)[0].status_code == 403
assert self._save(app, pg_conn, "nobody", body)[0].status_code == 404
assert self._count(pg_conn) == 1
def test_host_change_without_credentials_is_rejected(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": "https://elsewhere.example.org/mcp", "auth_type": "bearer",
"transport_type": "http"}}
resp, cls = self._save(app, pg_conn, OWNER, body)
assert resp.status_code == 400
assert resp.json["message"] == "Enter credentials for the new server"
assert not cls.called
assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://mcp.example.com/mcp"
@pytest.mark.parametrize("url", ["http://mcp.example.com/mcp", "https://mcp.example.com:8443/mcp"])
def test_scheme_or_port_change_without_credentials_is_rejected(self, app, pg_conn, url):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": url, "auth_type": "bearer", "transport_type": "http"}}
resp, cls = self._save(app, pg_conn, "ed", body)
assert resp.status_code == 400
assert not cls.called
assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://mcp.example.com/mcp"
def test_host_change_with_new_credentials_replaces_them(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": "https://elsewhere.example.org/mcp", "auth_type": "bearer",
"transport_type": "http", "bearer_token": "tok2"}}
resp, _ = self._save(app, pg_conn, OWNER, body)
assert resp.status_code == 200
assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://elsewhere.example.org/mcp"
assert _stored_mcp_secret(pg_conn, tool["id"]) == ({"bearer_token": "tok2"}, OWNER)
def test_empty_secret_reuses_the_connections_key(self, app, pg_conn):
tool, connection = _mcp_connection_tool(pg_conn, {"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "Renamed",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
"transport_type": "http"}}
resp, cls = self._save(app, pg_conn, "ed", body)
assert resp.status_code == 200, resp.json
assert cls.call_args.kwargs["config"]["auth_credentials"] == {"bearer_token": "tok"}
assert cls.call_args.kwargs["user_id"] == OWNER
assert str(_row(pg_conn, tool["id"])["connection_id"]) == str(connection["id"])
def test_editors_new_key_does_not_rotate_the_owners_connection(self, app, pg_conn):
from docsgpt.connectors import service
from docsgpt.storage.db.repositories.connector_sessions import ConnectorSessionsRepository
tool, connection = _mcp_connection_tool(pg_conn, {"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
"transport_type": "http", "bearer_token": "tok2"}}
resp, _ = self._save(app, pg_conn, "ed", body)
assert resp.status_code == 200, resp.json
assert _stored_mcp_secret(pg_conn, tool["id"]) == ({"bearer_token": "tok2"}, OWNER)
# The owner's other tools on the old connection keep its key.
old = ConnectorSessionsRepository(pg_conn).get(str(connection["id"]))
assert service.get_credentials(old) == {"bearer_token": "tok"}
def test_moved_server_does_not_inherit_the_connections_key(self, app, pg_conn):
tool, _connection = _mcp_connection_tool(pg_conn, {"bearer_token": "tok"})
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": "https://elsewhere.example.org/mcp", "auth_type": "bearer",
"transport_type": "http"}}
resp, cls = self._save(app, pg_conn, OWNER, body)
assert resp.status_code == 400
assert not cls.called
def test_editor_blocked_when_credentials_switch_off(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
set_settings(pg_conn, "tool", str(tool["id"]), {"editors_can_change_credentials": False}, OWNER)
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
"transport_type": "http"}}
assert self._save(app, pg_conn, "ed", body)[0].status_code == 403
def test_editor_oauth_reconnect_is_owner_only(self, app, pg_conn):
tool = _mcp_tool(pg_conn, auth_type="oauth")
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth",
"transport_type": "http", "oauth_task_id": "task-1"}}
resp, _ = self._save(app, pg_conn, "ed", body)
assert resp.status_code == 403
def test_editor_oauth_save_without_reconnect_is_owner_only(self, app, pg_conn):
# A shared OAuth server's connection is the owner's: an editor can't
# save it, even with the same URL (the path, scopes or client could
# still change while reusing the owner's tokens).
tool = _mcp_tool(pg_conn, auth_type="oauth")
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "Renamed",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth",
"transport_type": "http"}}
resp, _ = self._save(app, pg_conn, "ed", body)
assert resp.status_code == 403
assert _row(pg_conn, tool["id"])["display_name"] == "M"
def test_editor_cannot_switch_oauth_server_to_other_auth(self, app, pg_conn):
tool = _mcp_tool(pg_conn, auth_type="oauth")
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "bearer",
"transport_type": "http", "bearer_token": "mine"}}
assert self._save(app, pg_conn, "ed", body)[0].status_code == 403
assert _row(pg_conn, tool["id"])["config"]["auth_type"] == "oauth"
def test_editor_cannot_switch_server_to_oauth(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "displayName": "M",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth",
"transport_type": "http"}}
assert self._save(app, pg_conn, "ed", body)[0].status_code == 403
def test_owner_oauth_save_needs_a_completed_sign_in(self, app, pg_conn):
tool = _mcp_tool(pg_conn, auth_type="oauth")
body = {"id": str(tool["id"]), "displayName": "Renamed",
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth",
"transport_type": "http"}}
resp, _ = self._save(app, pg_conn, OWNER, body)
assert resp.status_code == 400
assert _row(pg_conn, tool["id"])["display_name"] == "M"
def test_auth_status_includes_team_mcp_tools(self, app, pg_conn):
from docsgpt.api.user.tools.mcp import MCPAuthStatus
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "vi", "viewer")
resp = _call(app, pg_conn, MCPAuthStatus, "vi", method="get", path="/api/mcp_server/auth_status")
assert resp.json["statuses"] == {str(tool["id"]): "configured"}
class TestMCPTestEndpointAccess:
def _test(self, app, conn, user, body):
from docsgpt.api.user.tools.mcp import TestMCPServerConfig
fake = MagicMock()
fake.test_connection.return_value = {"success": True, "tools_count": 1, "tools": []}
with patch("docsgpt.api.user.tools.mcp.MCPTool", return_value=fake) as cls:
resp = _call(app, conn, TestMCPServerConfig, user, json=body)
return resp, cls
def _body(self, tool, url="https://mcp.example.com/mcp", **extra):
return {"id": str(tool["id"]), "config": {"server_url": url, "auth_type": "bearer",
"transport_type": "http", **extra}}
def test_empty_secret_uses_stored_one_as_owner(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
resp, cls = self._test(app, pg_conn, "ed", self._body(tool))
assert resp.status_code == 200 and resp.json["success"] is True
assert cls.call_args.kwargs["config"]["auth_credentials"] == {"bearer_token": "tok"}
assert cls.call_args.kwargs["user_id"] == OWNER
def test_host_change_without_secret_is_400(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
resp, cls = self._test(app, pg_conn, OWNER, self._body(tool, url="https://new.example.org/mcp"))
assert resp.status_code == 400
assert resp.json["message"] == "Enter credentials for the new server"
assert not cls.called
resp, cls = self._test(app, pg_conn, OWNER, self._body(tool, url="https://new.example.org/mcp",
bearer_token="t2"))
assert cls.call_args.kwargs["config"]["auth_credentials"] == {"bearer_token": "t2"}
def test_scheme_downgrade_without_secret_is_400(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
resp, cls = self._test(app, pg_conn, "ed", self._body(tool, url="http://mcp.example.com/mcp"))
assert resp.status_code == 400
assert not cls.called
def test_viewer_and_stranger_denied(self, app, pg_conn):
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "vi", "viewer")
assert self._test(app, pg_conn, "vi", self._body(tool))[0].status_code == 403
assert self._test(app, pg_conn, "eve", self._body(tool))[0].status_code == 404
def test_editor_test_of_stored_oauth_server_is_owner_only(self, app, pg_conn):
tool = _mcp_tool(pg_conn, auth_type="oauth")
_share(pg_conn, tool["id"], "ed", "editor")
resp, cls = self._test(app, pg_conn, "ed", self._body(tool, bearer_token="mine"))
assert resp.status_code == 403 and not cls.called
def test_editor_oauth_test_is_owner_only(self, app, pg_conn):
tool = _mcp_tool(pg_conn, auth_type="oauth")
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]), "config": {"server_url": "https://mcp.example.com/mcp",
"auth_type": "oauth", "transport_type": "http"}}
resp, cls = self._test(app, pg_conn, "ed", body)
assert resp.status_code == 403 and not cls.called
# ---------------------------------------------------------------------------
# 6. Shared OAuth MCP servers: the connection is the owner's
# ---------------------------------------------------------------------------
class TestSharedOAuthMCPConfig:
"""``/api/update_tool`` and ``/api/update_tool_config`` can't move a shared
OAuth server or switch a shared server to OAuth: OAuth tokens are looked up
by owner + server URL, so either would run on the owner's sign-in
elsewhere. Connection changes on OAuth servers are the owner's."""
OTHER = {"server_url": "https://other-mcp.example.org/mcp", "auth_type": "oauth"}
def _oauth_tool(self, conn):
tool = _mcp_tool(conn, auth_type="oauth")
_share(conn, tool["id"], "ed", "editor")
return tool
def test_update_tool_config_cannot_move_oauth_server(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolConfig
tool = self._oauth_tool(pg_conn)
body = {"id": str(tool["id"]), "config": dict(self.OTHER)}
assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 403
assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://mcp.example.com/mcp"
def test_update_tool_cannot_move_oauth_server(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool
tool = self._oauth_tool(pg_conn)
body = {"id": str(tool["id"]), "config": dict(self.OTHER)}
assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 403
assert _row(pg_conn, tool["id"])["config"]["server_url"] == "https://mcp.example.com/mcp"
def test_update_tool_config_cannot_switch_server_to_oauth(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolConfig
tool = _mcp_tool(pg_conn, secrets={"bearer_token": "tok"})
_share(pg_conn, tool["id"], "ed", "editor")
body = {"id": str(tool["id"]),
"config": {"server_url": "https://mcp.example.com/mcp", "auth_type": "oauth"}}
assert _call(app, pg_conn, UpdateToolConfig, "ed", json=body).status_code == 403
assert _row(pg_conn, tool["id"])["config"]["auth_type"] == "bearer"
def test_editor_still_renames_oauth_server_without_config(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateTool
tool = self._oauth_tool(pg_conn)
body = {"id": str(tool["id"]), "customName": "Renamed"}
assert _call(app, pg_conn, UpdateTool, "ed", json=body).status_code == 200
assert _row(pg_conn, tool["id"])["custom_name"] == "Renamed"
def test_owner_may_change_oauth_server_config(self, app, pg_conn):
from docsgpt.api.user.tools.routes import UpdateToolConfig
tool = _mcp_tool(pg_conn, auth_type="oauth")
body = {"id": str(tool["id"]), "config": dict(self.OTHER)}
assert _call(app, pg_conn, UpdateToolConfig, OWNER, json=body).status_code == 200
assert _row(pg_conn, tool["id"])["config"]["server_url"] == self.OTHER["server_url"]
+16 -8
View File
@@ -1388,16 +1388,24 @@ class TestUpdateToolActionsHappy:
assert self._stored(pg_conn, tool)["active"] is True
def test_team_editor_can_change_actions_but_not_pins(self, app, pg_conn):
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
owner, editor = "u-owner", "u-editor"
tool = self._seed_actions_tool(pg_conn, owner)
with patch("docsgpt.api.user.tools.routes.effective_write_owner", return_value=owner):
toggled = self._post(
app, pg_conn, editor, str(tool["id"]), [{"name": "telegram_send_message", "active": False}],
)
pinned = self._post(app, pg_conn, editor, str(tool["id"]), [{
"name": "telegram_send_message",
"parameters": {"properties": {"chat_id": {"filled_by_llm": False, "value": "666"}}},
}])
team = TeamsRepository(pg_conn).create("Pins", "t-pins", owner)
TeamMembersRepository(pg_conn).add_member(str(team["id"]), editor)
TeamResourceGrantsRepository(pg_conn).grant(
str(team["id"]), "tool", str(tool["id"]), owner, owner, access_level="editor",
)
toggled = self._post(
app, pg_conn, editor, str(tool["id"]), [{"name": "telegram_send_message", "active": False}],
)
pinned = self._post(app, pg_conn, editor, str(tool["id"]), [{
"name": "telegram_send_message",
"parameters": {"properties": {"chat_id": {"filled_by_llm": False, "value": "666"}}},
}])
assert toggled.status_code == 200
assert pinned.status_code == 403
stored = self._stored(pg_conn, tool)
@@ -474,20 +474,24 @@ class TestWorkflowDetailPut:
def test_validation_failure_returns_400(self, app, pg_conn):
from docsgpt.api.user.workflows.routes import WorkflowDetail
from docsgpt.storage.db.repositories.workflows import WorkflowsRepository
# Access is checked before the graph is validated (validation runs as
# the workflow's owner), so the workflow has to exist.
wid = str(WorkflowsRepository(pg_conn).create("u1", "wf")["id"])
body = {
"name": "bad",
"nodes": [{"id": "end1", "type": "end"}],
"edges": [],
}
with _patch_wf_db(pg_conn), app.test_request_context(
"/api/workflows/abc",
f"/api/workflows/{wid}",
method="PUT",
json=body,
):
from flask import request
request.decoded_token = {"sub": "u1"}
response = WorkflowDetail().put("abc")
response = WorkflowDetail().put(wid)
assert response.status_code == 400
def test_updates_workflow(self, app, pg_conn):