Roles audit and revamp

This commit is contained in:
Pavel committed 2026-09-29 10:42:39 +04:00
1 parent 208d34abc7
commit 98afa5d93c
134 files changed
+13725 -1702

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
+33 -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,7 +193,7 @@ 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),
]
+20 -2
View File
@@ -484,17 +484,35 @@ 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
with app.test_request_context(
"/api/connectors/sync", method="POST", json={"source_id": "x"}
"/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_session_token_returns_400(self, app, pg_conn):
# The owner syncs with their own session token; team editors don't
# send one (their sync uses the owner's session).
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,
+703
View File
@@ -0,0 +1,703 @@
"""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(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")
resp = self._put(app, pg_conn, agent_id, EDITOR, {"workflow": str(wf["id"])})
assert _status(resp) == 200
assert str(_row(pg_conn, agent_id)["workflow_id"]) == str(wf["id"])
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)
ok = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [owner_tool, shared_tool]})
assert _status(ok) == 200
denied = self._put(app, pg_conn, agent_id, EDITOR, {"tools": [foreign_tool]})
assert _status(denied) == 403
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
@@ -675,9 +675,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(
@@ -688,7 +688,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
+509
View File
@@ -0,0 +1,509 @@
"""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"
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)
assert "config" not in v
assert v["allowed_actions"] == ["use"]
assert "config" not in vp
assert "config" in e
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"},
},
)
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), 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_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["session_token"] == "st-owner"
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_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"}
)
assert resp.status_code == 200
assert delay.call_args.kwargs["session_token"] == "st-owner"
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_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
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
+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")
+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",
):
+571
View File
@@ -0,0 +1,571 @@
"""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"
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_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,
)
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 decrypt_credentials(row["config"]["encrypted_credentials"], OWNER) == {"bearer_token": "tok"}
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"
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
row = _row(pg_conn, tool["id"])
assert decrypt_credentials(row["config"]["encrypted_credentials"], OWNER) == {"bearer_token": "tok2"}
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_edit_without_reconnect_keeps_actions(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": "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 == 200, resp.json
row = _row(pg_conn, tool["id"])
assert row["display_name"] == "Renamed"
assert [a["name"] for a in row["actions"]] == ["old"]
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_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_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
@@ -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):