mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-15 00:16:46 +00:00
Let a wiki's owner decide whether API, widget and public-link runs edit it
A run from an agent's API key or widget acts as the agent's owner, so it could rewrite any wiki the owner can edit. A new per-wiki setting, wiki_outside_edits (off by default), decides whether such runs, and runs from the agent's public link, get the wiki's edit actions. While it is off they are offered only wiki_view, and the tool refuses writes itself after reading the live setting. The owner changes it through the owner-only /api/sources/<id>/wiki/settings route; tokens can read it but not change it.
This commit is contained in:
1 parent
33a73bc442
commit
3b44b6851d
13 files changed
+560
-6
No files matched your search
@@ -116,3 +116,33 @@ class TestWikiConfigAccess:
|
||||
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
|
||||
|
||||
|
||||
class TestWikiOutsideEdits:
|
||||
"""API, widget and public-link runs edit a wiki only when its owner allows."""
|
||||
|
||||
def _tools(self, conn, caller, sid, agent_config):
|
||||
from docsgpt.agents.tools.wiki import WIKI_TOOL_ID, add_wiki_tool
|
||||
from docsgpt.api.answer.services.stream_processor import StreamProcessor
|
||||
|
||||
proc = StreamProcessor.__new__(StreamProcessor)
|
||||
proc.all_sources = [{"id": sid}]
|
||||
proc.decoded_token = {"sub": caller}
|
||||
proc.agent_config = agent_config
|
||||
cfg = proc._build_wiki_config()
|
||||
tools = {}
|
||||
add_wiki_tool(tools, cfg)
|
||||
return {a["name"] for a in tools[WIKI_TOOL_ID]["actions"]}
|
||||
|
||||
@pytest.mark.parametrize("flag", ["external_api_caller", "public_link_caller"])
|
||||
def test_outside_caller_reads_until_the_owner_allows_edits(self, use_conn, flag):
|
||||
sid = str(SourcesRepository(use_conn).create("W", user_id=OWNER, config={"kind": "wiki"})["id"])
|
||||
assert self._tools(use_conn, OWNER, sid, {flag: True}) == {"wiki_view"}
|
||||
SourcesRepository(use_conn).set_wiki_outside_edits(sid, OWNER, True)
|
||||
assert "wiki_create" in self._tools(use_conn, OWNER, sid, {flag: True})
|
||||
|
||||
def test_owner_and_team_editor_keep_every_action(self, use_conn):
|
||||
sid = str(SourcesRepository(use_conn).create("W", user_id=OWNER, config={"kind": "wiki"})["id"])
|
||||
_share(use_conn, "source", sid, "ed", "editor")
|
||||
assert "wiki_create" in self._tools(use_conn, OWNER, sid, {})
|
||||
assert "wiki_create" in self._tools(use_conn, "ed", sid, {})
|
||||
@@ -98,6 +98,8 @@ class TestClassification:
|
||||
("/api/devices/pairings", "POST"),
|
||||
("/api/connectors/auth", "GET"),
|
||||
("/api/mcp_server/callback", "GET"),
|
||||
("/api/resource_settings", "PUT"),
|
||||
("/api/sources/<string:source_id>/wiki/settings", "PUT"),
|
||||
],
|
||||
)
|
||||
def test_sensitive_routes_are_never_token_reachable(self, rule, method):
|
||||
|
||||
@@ -606,3 +606,102 @@ class TestWikiPageEdit:
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_reembed.assert_not_called()
|
||||
|
||||
|
||||
def _settings_call(app, pg_conn, sid, user, method="GET", body=None):
|
||||
from docsgpt.api.user.sources.routes import WikiSettings
|
||||
|
||||
with _patch_db(pg_conn), app.test_request_context(
|
||||
f"/api/sources/{sid}/wiki/settings", method=method, json=body
|
||||
):
|
||||
from flask import request
|
||||
request.decoded_token = {"sub": user} if user else None
|
||||
resource = WikiSettings()
|
||||
return resource.get(sid) if method == "GET" else resource.put(sid)
|
||||
|
||||
|
||||
class TestWikiSettings:
|
||||
def _wiki(self, pg_conn, owner, kind="wiki"):
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
src = SourcesRepository(pg_conn).create(
|
||||
"wiki", user_id=owner, type=kind, config={"kind": kind}
|
||||
)
|
||||
return str(src["id"])
|
||||
|
||||
def _stored(self, pg_conn, sid):
|
||||
from docsgpt.storage.db.repositories.sources import SourcesRepository
|
||||
|
||||
return SourcesRepository(pg_conn).get_by_id(sid)["wiki_outside_edits"]
|
||||
|
||||
def test_returns_401_unauthenticated(self, app, pg_conn):
|
||||
assert _settings_call(app, pg_conn, str(uuid.uuid4()), None).status_code == 401
|
||||
assert _settings_call(app, pg_conn, str(uuid.uuid4()), None, "PUT", {}).status_code == 401
|
||||
|
||||
def test_defaults_to_off(self, app, pg_conn):
|
||||
sid = self._wiki(pg_conn, "alice-ws")
|
||||
response = _settings_call(app, pg_conn, sid, "alice-ws")
|
||||
assert response.status_code == 200
|
||||
assert response.json["allow_outside_edits"] is False
|
||||
assert "manage_settings" in response.json["allowed_actions"]
|
||||
|
||||
def test_owner_turns_it_on_and_off(self, app, pg_conn):
|
||||
sid = self._wiki(pg_conn, "alice-ws-on")
|
||||
response = _settings_call(
|
||||
app, pg_conn, sid, "alice-ws-on", "PUT", {"allow_outside_edits": True}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json["allow_outside_edits"] is True
|
||||
assert self._stored(pg_conn, sid) is True
|
||||
assert _settings_call(app, pg_conn, sid, "alice-ws-on").json["allow_outside_edits"] is True
|
||||
_settings_call(app, pg_conn, sid, "alice-ws-on", "PUT", {"allow_outside_edits": False})
|
||||
assert self._stored(pg_conn, sid) is False
|
||||
|
||||
def test_change_is_audited(self, app, pg_conn):
|
||||
from sqlalchemy import text
|
||||
|
||||
sid = self._wiki(pg_conn, "alice-ws-audit")
|
||||
_settings_call(app, pg_conn, sid, "alice-ws-audit", "PUT", {"allow_outside_edits": True})
|
||||
row = pg_conn.execute(
|
||||
text("SELECT metadata FROM auth_events WHERE event = 'source.wiki_settings_updated' AND actor_id = :a"),
|
||||
{"a": "alice-ws-audit"},
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row[0]["source_id"] == sid and row[0]["allow_outside_edits"] is True
|
||||
|
||||
@pytest.mark.parametrize("level", ["editor", "viewer"])
|
||||
def test_team_member_reads_but_cannot_change(self, app, pg_conn, level):
|
||||
owner, member = f"alice-ws-{level}", f"bob-ws-{level}"
|
||||
sid = self._wiki(pg_conn, owner)
|
||||
_grant_team_access(pg_conn, owner, member, sid, level)
|
||||
read = _settings_call(app, pg_conn, sid, member)
|
||||
assert read.status_code == 200
|
||||
assert read.json["allow_outside_edits"] is False
|
||||
assert "manage_settings" not in read.json["allowed_actions"]
|
||||
response = _settings_call(
|
||||
app, pg_conn, sid, member, "PUT", {"allow_outside_edits": True}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert self._stored(pg_conn, sid) is False
|
||||
|
||||
def test_stranger_gets_404(self, app, pg_conn):
|
||||
sid = self._wiki(pg_conn, "alice-ws-404")
|
||||
assert _settings_call(app, pg_conn, sid, "eve-ws").status_code == 404
|
||||
response = _settings_call(app, pg_conn, sid, "eve-ws", "PUT", {"allow_outside_edits": True})
|
||||
assert response.status_code == 404
|
||||
assert self._stored(pg_conn, sid) is False
|
||||
|
||||
@pytest.mark.parametrize("body", [{}, {"allow_outside_edits": "yes"}, {"allow_outside_edits": 1}])
|
||||
def test_bad_body_is_400(self, app, pg_conn, body):
|
||||
sid = self._wiki(pg_conn, "alice-ws-400")
|
||||
response = _settings_call(app, pg_conn, sid, "alice-ws-400", "PUT", body)
|
||||
assert response.status_code == 400
|
||||
assert self._stored(pg_conn, sid) is False
|
||||
|
||||
def test_not_a_wiki_is_400(self, app, pg_conn):
|
||||
sid = self._wiki(pg_conn, "alice-ws-classic", kind="classic")
|
||||
response = _settings_call(
|
||||
app, pg_conn, sid, "alice-ws-classic", "PUT", {"allow_outside_edits": True}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert self._stored(pg_conn, sid) is False
|
||||
Reference in new issue
Block a user