diff --git a/docsgpt/agents/tools/wiki.py b/docsgpt/agents/tools/wiki.py index 372532f8..6cb00a68 100644 --- a/docsgpt/agents/tools/wiki.py +++ b/docsgpt/agents/tools/wiki.py @@ -3,6 +3,7 @@ from typing import Any, Dict, List, Optional from docsgpt.agents.tools.base import Tool from docsgpt.agents.tools.path_utils import validate_tool_path +from docsgpt.storage.db.repositories.sources import SourcesRepository from docsgpt.storage.db.repositories.wiki_pages import ( WikiPageConflict, WikiPagesRepository, @@ -21,6 +22,11 @@ MAX_WIKI_PAGE_BYTES = 1_000_000 _WRITE_ACTIONS = frozenset({"create", "str_replace", "insert", "delete", "rename"}) +OUTSIDE_EDITS_DENIED = ( + "Error: This wiki's owner doesn't let API, widget or public-link users edit it, " + "so it can't be changed from here. You can still read it." +) + class WikiTool(Tool): """Wiki @@ -39,6 +45,9 @@ class WikiTool(Tool): self.config = config self.source_id: Optional[str] = config.get("source_id") self.source_owner_id: Optional[str] = config.get("source_owner_id") + # An API-key, widget or public-link run: it writes only while the + # wiki's owner allows such edits. + self.outside_caller: bool = bool(config.get("outside_caller")) decoded_token = config.get("decoded_token") or {} self.updated_by: Optional[str] = ( (decoded_token.get("sub") if decoded_token else None) @@ -228,8 +237,30 @@ class WikiTool(Tool): return message if access is None or not access.can("edit"): return message + if self.outside_caller and not self._outside_edits_allowed(): + return OUTSIDE_EDITS_DENIED return None + def _outside_edits_allowed(self) -> bool: + """Read the wiki's live ``wiki_outside_edits`` setting. + + Read on every write rather than trusted from the run's setup, so the + owner turning it off stops a conversation that is already going. + Fails closed on a missing row or a failed lookup. + + Returns: + bool: Whether an API, widget or public-link run may edit the wiki. + """ + try: + with db_readonly() as conn: + row = SourcesRepository(conn).get_by_id(str(self.source_id)) + except Exception: + logger.exception( + "Wiki outside-edits check failed for source %s", self.source_id + ) + return False + return outside_edits_allowed(row) + def get_config_requirements(self) -> Dict[str, Any]: return {} @@ -500,11 +531,17 @@ class WikiTool(Tool): return f"Renamed: {validated_old} -> {validated_new}" -def build_wiki_tool_entry() -> Dict[str, Any]: - """Build the synthetic tools_dict entry for the WikiTool.""" +def build_wiki_tool_entry(writes_allowed: bool = True) -> Dict[str, Any]: + """Build the synthetic tools_dict entry for the WikiTool. + + Args: + writes_allowed: False offers the model only ``wiki_view``. + """ entry = {"name": "wiki"} entry["actions"] = [ - {**action, "active": True} for action in _wiki_actions_metadata() + {**action, "active": True} + for action in _wiki_actions_metadata() + if writes_allowed or action["name"] == "wiki_view" ] return entry @@ -513,11 +550,17 @@ def _wiki_actions_metadata() -> List[Dict[str, Any]]: return WikiTool().get_actions_metadata() +def outside_edits_allowed(source_row: Optional[Dict[str, Any]]) -> bool: + """Whether a wiki's owner lets API, widget and public-link runs edit it.""" + return bool(source_row and source_row.get("wiki_outside_edits")) + + def build_wiki_tool_config( source_id: str, source_owner_id: str, decoded_token: Optional[Dict] = None, user: Optional[str] = None, + outside_caller: bool = False, ) -> Dict[str, Any]: """Build the config dict passed to the injected WikiTool.""" return { @@ -525,6 +568,7 @@ def build_wiki_tool_config( "source_owner_id": source_owner_id, "decoded_token": decoded_token, "user": user, + "outside_caller": bool(outside_caller), } @@ -534,15 +578,19 @@ def add_wiki_tool(tools_dict: Dict, config: Dict) -> None: Mirrors ``add_internal_search_tool``: the entry carries ``id=WIKI_TOOL_ID`` so the executor can resolve the synthetic (DB-rowless) tool, and a ``config`` the executor copies into the loaded tool. Mutates ``tools_dict`` in place. + ``writes_allowed=False`` (an API, widget or public-link run on a wiki + whose owner hasn't allowed their edits) offers only ``wiki_view``; the + tool still refuses writes itself from ``outside_caller``. """ if not config or not config.get("source_id") or not config.get("source_owner_id"): return - entry = build_wiki_tool_entry() + entry = build_wiki_tool_entry(writes_allowed=config.get("writes_allowed", True) is not False) entry["id"] = WIKI_TOOL_ID entry["config"] = build_wiki_tool_config( source_id=config["source_id"], source_owner_id=config["source_owner_id"], decoded_token=config.get("decoded_token"), user=config.get("user"), + outside_caller=bool(config.get("outside_caller")), ) tools_dict[WIKI_TOOL_ID] = entry diff --git a/docsgpt/alembic/versions/0043_wiki_outside_edits.py b/docsgpt/alembic/versions/0043_wiki_outside_edits.py new file mode 100644 index 00000000..9975de63 --- /dev/null +++ b/docsgpt/alembic/versions/0043_wiki_outside_edits.py @@ -0,0 +1,33 @@ +"""0043 wiki outside edits — the wiki owner's say on API, widget and public-link edits. + +An agent run from its API key or widget acts as the agent's owner, and a +public-link user is a stranger to them, so neither should rewrite a wiki the +agent can edit unless the wiki's owner allows it. ``wiki_outside_edits`` +records that choice on the source; it is off by default, so such runs can +still read the wiki but not change it. + +Idempotent both ways. + +Revision ID: 0043_wiki_outside_edits +Revises: 0042_schedule_created_via_api +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "0043_wiki_outside_edits" +down_revision: Union[str, None] = "0042_schedule_created_via_api" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE sources ADD COLUMN IF NOT EXISTS wiki_outside_edits BOOLEAN NOT NULL DEFAULT false;" + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE sources DROP COLUMN IF EXISTS wiki_outside_edits;") diff --git a/docsgpt/api/answer/services/stream_processor.py b/docsgpt/api/answer/services/stream_processor.py index 2ce88a70..1c783489 100644 --- a/docsgpt/api/answer/services/stream_processor.py +++ b/docsgpt/api/answer/services/stream_processor.py @@ -27,6 +27,7 @@ from docsgpt.core.model_utils import ( get_provider_from_model_id, validate_model_id, ) +from docsgpt.agents.tools.wiki import outside_edits_allowed from docsgpt.core.settings import settings from docsgpt.guardrails.config import AgentConfig from sqlalchemy import text as sql_text @@ -1259,10 +1260,22 @@ class StreamProcessor: writable wiki source; the first match wins and the scan stops there so this runs at most one owner+source lookup per chat on the hot path. Returns None when no writable wiki source is present. + + An API-key, widget or public-link run (``outside_caller``) gets the + edit actions only when the wiki's owner turned on + ``wiki_outside_edits``; otherwise ``writes_allowed`` is False and the + tool offers only ``wiki_view``. """ caller = self.decoded_token.get("sub") if self.decoded_token else None if not caller: return None + # Processors built without __init__ (tests, resume helpers) lack these. + run_config = getattr(self, "agent_config", None) or {} + outside_caller = bool( + run_config.get("external_api_caller") + or run_config.get("public_link_caller") + or getattr(self, "external_caller", False) + ) wiki_config: Optional[Dict[str, Any]] = None try: @@ -1286,6 +1299,8 @@ class StreamProcessor: "source_owner_id": owner, "decoded_token": self.decoded_token, "user": caller, + "outside_caller": outside_caller, + "writes_allowed": not outside_caller or outside_edits_allowed(source_doc), } break except Exception: diff --git a/docsgpt/api/pat/rules.py b/docsgpt/api/pat/rules.py index 4af3d484..afb2b0bd 100644 --- a/docsgpt/api/pat/rules.py +++ b/docsgpt/api/pat/rules.py @@ -224,6 +224,7 @@ RULES: dict[tuple[str, str], Rule] = { ("/api/get_chunks", "GET"): _rule("sources:read", (QUERY, "id")), ("/api/sources//wiki/pages", "GET"): _rule("sources:read", (VIEW, "source_id")), ("/api/sources//wiki/page", "GET"): _rule("sources:read", (VIEW, "source_id")), + ("/api/sources//wiki/settings", "GET"): _rule("sources:read", (VIEW, "source_id")), ("/api/sources//graph", "GET"): _rule("sources:read", (VIEW, "source_id")), ("/api/sources//graph/node/", "GET"): _rule( "sources:read", (VIEW, "source_id") @@ -357,6 +358,8 @@ DENIED: dict[str, tuple[str, ...]] = { "/api/teams//grants": ("POST", "DELETE"), "/api/teams//transfer_owner": ("*",), "/api/resource_settings": ("PUT",), + # Who may edit a wiki from outside the app is the owner's call in a session. + "/api/sources//wiki/settings": ("PUT",), "/swagger.json": ("*",), } DENIED_PREFIXES = ( diff --git a/docsgpt/api/user/sources/routes.py b/docsgpt/api/user/sources/routes.py index 639b4533..e66db1a6 100644 --- a/docsgpt/api/user/sources/routes.py +++ b/docsgpt/api/user/sources/routes.py @@ -1056,6 +1056,87 @@ class WikiPage(Resource): ) +def _wiki_settings_body(doc: dict, ra) -> dict: + """The wiki settings response: the stored switch plus the caller's access.""" + return { + "success": True, + "allow_outside_edits": bool(doc.get("wiki_outside_edits")), + **ra.payload(), + } + + +@sources_ns.route("/sources//wiki/settings") +class WikiSettings(Resource): + @api.doc( + description="A wiki's settings. Anyone who can see the wiki may read " + "them; returns allow_outside_edits plus the caller's access." + ) + def get(self, source_id): + decoded_token = request.decoded_token + if not decoded_token: + return make_response(jsonify({"success": False}), 401) + user = decoded_token.get("sub") + try: + with db_readonly() as conn: + try: + doc, ra = load_source(conn, source_id, user, "use") + except AccessDenied as err: + return denied_response(err) + except Exception as err: + current_app.logger.error( + f"Error reading wiki settings for {source_id}: {err}", exc_info=True + ) + return make_response(jsonify({"success": False}), 400) + return make_response(jsonify(_wiki_settings_body(doc, ra)), 200) + + @api.doc( + description="Change a wiki's settings (owner only, manage_settings). " + "Body: {\"allow_outside_edits\": bool}: whether runs from the agent's " + "API key, widget or public link may edit the wiki." + ) + def put(self, source_id): + decoded_token = request.decoded_token + if not decoded_token: + return make_response(jsonify({"success": False}), 401) + user = decoded_token.get("sub") + data = request.get_json(silent=True) or {} + allowed = data.get("allow_outside_edits") + if not isinstance(allowed, bool): + return make_response( + jsonify( + {"success": False, "message": "allow_outside_edits must be true or false"} + ), + 400, + ) + try: + with db_session() as conn: + try: + doc, ra = load_source(conn, source_id, user, "manage_settings") + except AccessDenied as err: + return denied_response(err) + if SourceConfig.parse(doc.get("config")).kind != "wiki": + return make_response( + jsonify({"success": False, "message": "Source is not a wiki"}), 400 + ) + SourcesRepository(conn).set_wiki_outside_edits( + str(doc["id"]), ra.owner_id, allowed + ) + record_event( + conn, + "source.wiki_settings_updated", + actor=user, + source_id=str(doc["id"]), + allow_outside_edits=allowed, + ) + doc["wiki_outside_edits"] = allowed + except Exception as err: + current_app.logger.error( + f"Error updating wiki settings for {source_id}: {err}", exc_info=True + ) + return make_response(jsonify({"success": False}), 400) + return make_response(jsonify(_wiki_settings_body(doc, ra)), 200) + + def _source_is_blank(doc): """True when a source has no ingested files to convert into pages.""" structure = doc.get("directory_structure") or {} diff --git a/docsgpt/storage/db/models.py b/docsgpt/storage/db/models.py index 0decb61f..992af0a1 100644 --- a/docsgpt/storage/db/models.py +++ b/docsgpt/storage/db/models.py @@ -381,6 +381,8 @@ sources_table = Table( ), # Whose account a shared resource runs with: the owner's, or each member's. Column("credential_mode", Text, nullable=False, server_default="owner"), + # A wiki's owner lets API, widget and public-link runs edit it (off: read only). + Column("wiki_outside_edits", Boolean, nullable=False, server_default="false"), ) agents_table = Table( diff --git a/docsgpt/storage/db/repositories/sources.py b/docsgpt/storage/db/repositories/sources.py index 2406af23..ca5e7236 100644 --- a/docsgpt/storage/db/repositories/sources.py +++ b/docsgpt/storage/db/repositories/sources.py @@ -399,6 +399,30 @@ class SourcesRepository: ) self._conn.execute(stmt) + def set_wiki_outside_edits(self, source_id: str, user_id: str, allowed: bool) -> bool: + """Record whether API, widget and public-link runs may edit this wiki. + + Kept out of :meth:`update`'s columns so no route that forwards a + request body can change it; only the owner-checked wiki settings + route calls this. + + Args: + source_id: The source's UUID. + user_id: The owner's id; the row is scoped to it. + allowed: The new value. + + Returns: + bool: Whether a row was updated. + """ + t = sources_table + result = self._conn.execute( + t.update() + .where(t.c.id == source_id) + .where(t.c.user_id == user_id) + .values(wiki_outside_edits=bool(allowed), updated_at=func.now()) + ) + return result.rowcount > 0 + def get_by_legacy_id( self, legacy_mongo_id: str, user_id: Optional[str] = None, ) -> Optional[dict]: diff --git a/tests/agents/test_headless_runner_callers.py b/tests/agents/test_headless_runner_callers.py index 7c67f665..6305e717 100644 --- a/tests/agents/test_headless_runner_callers.py +++ b/tests/agents/test_headless_runner_callers.py @@ -11,7 +11,7 @@ from unittest.mock import MagicMock, patch import pytest -def _executor_kwargs(monkeypatch, config_extra=None, **run_kwargs): +def _executor_kwargs(monkeypatch, config_extra=None, agent_kwargs=None, **run_kwargs): from docsgpt.agents import headless_runner as hr agent = MagicMock(name="agent") @@ -27,7 +27,12 @@ def _executor_kwargs(monkeypatch, config_extra=None, **run_kwargs): monkeypatch.setattr(hr, "get_prompt", lambda _pid: "system prompt") monkeypatch.setattr(hr.RetrieverCreator, "create_retriever", classmethod(lambda cls, *a, **kw: retriever)) monkeypatch.setattr(hr, "ToolExecutor", _executor) - monkeypatch.setattr(hr.AgentCreator, "create_agent", classmethod(lambda cls, *a, **kw: agent)) + def _create_agent(cls, *_args, **kwargs): + if agent_kwargs is not None: + agent_kwargs.update(kwargs) + return agent + + monkeypatch.setattr(hr.AgentCreator, "create_agent", classmethod(_create_agent)) monkeypatch.setattr(hr.QuotaService, "check", lambda *a, **kw: None) config = {"user_id": "u1", "id": "agent-1", "default_model_id": "m", **(config_extra or {})} with patch("docsgpt.core.model_utils.validate_model_id", return_value=True), \ @@ -51,3 +56,12 @@ class TestHeadlessCallerRules: kwargs = _executor_kwargs(monkeypatch, config, **{flag: True}) assert kwargs[flag] is True assert kwargs["api_write_allowlist"] == ["tool-1:send"] + + @pytest.mark.parametrize("flag", ["external_caller", "public_link_caller"]) + def test_outside_caller_run_gets_no_wiki_editor(self, monkeypatch, flag): + # A scheduled or webhook run has no wiki tool at all, so an outside + # caller's schedule can't edit a wiki whatever the wiki allows. + agent_kwargs = {} + _executor_kwargs(monkeypatch, agent_kwargs=agent_kwargs, **{flag: True}) + assert agent_kwargs + assert "wiki_config" not in agent_kwargs diff --git a/tests/agents/tools/test_wiki.py b/tests/agents/tools/test_wiki.py index a830b5be..6d23b878 100644 --- a/tests/agents/tools/test_wiki.py +++ b/tests/agents/tools/test_wiki.py @@ -640,3 +640,154 @@ class TestBuildAgentGating: assert cfg is not None # v1 binds the first writable wiki source; the extra is skipped. assert cfg["source_id"] == "wiki-1" + + +# ===================================================================== +# API, widget and public-link callers (the wiki's outside-edits setting) +# ===================================================================== + + +def _outside_tool(monkeypatch, allowed): + from docsgpt.agents.tools.wiki import WikiTool + + class _Sources: + def __init__(self, conn): + pass + + def get_by_id(self, sid): + return {"id": sid, "wiki_outside_edits": allowed} + + monkeypatch.setattr("docsgpt.agents.tools.wiki.SourcesRepository", _Sources) + return WikiTool( + { + "source_id": "src-1", + "source_owner_id": "owner-sub", + "decoded_token": {"sub": "owner-sub"}, + "user": "owner-sub", + "outside_caller": True, + } + ) + + +_WRITES = ( + ("create", {"path": "/b.md", "content": "x"}), + ("str_replace", {"path": "/a.md", "old_str": "one", "new_str": "two"}), + ("insert", {"path": "/a.md", "insert_line": 1, "insert_text": "x"}), + ("delete", {"path": "/a.md"}), + ("rename", {"old_path": "/a.md", "new_path": "/c.md"}), +) + + +@pytest.mark.unit +class TestOutsideCallerWrites: + def test_refused_while_the_setting_is_off(self, patched_wiki, monkeypatch, reembed_mock): + _FakeWikiRepo().upsert("src-1", "/a.md", "one") + tool = _outside_tool(monkeypatch, False) + for action, kwargs in _WRITES: + result = tool.execute_action(action, **kwargs) + assert "API, widget or public-link" in result, action + reembed_mock.assert_not_called() + assert _FakeWikiRepo().get_by_path("src-1", "/a.md")["content"] == "one" + # Reading stays open to them. + assert "one" in tool.execute_action("view", path="/a.md") + + def test_allowed_once_the_owner_turns_it_on(self, patched_wiki, monkeypatch): + tool = _outside_tool(monkeypatch, True) + assert tool.execute_action("create", path="/b.md", content="x") == "Page created: /b.md" + + def test_a_missing_row_refuses(self, patched_wiki, monkeypatch): + tool = _outside_tool(monkeypatch, True) + + class _Gone: + def __init__(self, conn): + pass + + def get_by_id(self, sid): + return None + + monkeypatch.setattr("docsgpt.agents.tools.wiki.SourcesRepository", _Gone) + assert "API, widget or public-link" in tool.execute_action("create", path="/b.md", content="x") + + def test_owner_and_team_runs_skip_the_setting(self, wiki_tool, monkeypatch): + class _Boom: + def __init__(self, conn): + raise AssertionError("an in-app run must not read the setting") + + monkeypatch.setattr("docsgpt.agents.tools.wiki.SourcesRepository", _Boom) + assert wiki_tool.execute_action("create", path="/b.md", content="x") == "Page created: /b.md" + + +@pytest.mark.unit +class TestReadOnlyEntry: + def _entry(self, **extra): + from docsgpt.agents.tools.wiki import WIKI_TOOL_ID, add_wiki_tool + + tools_dict = {} + add_wiki_tool(tools_dict, {"source_id": "s1", "source_owner_id": "owner", "user": "owner", **extra}) + return tools_dict[WIKI_TOOL_ID] + + def test_outside_caller_without_the_setting_is_offered_only_view(self): + entry = self._entry(outside_caller=True, writes_allowed=False) + assert [a["name"] for a in entry["actions"]] == ["wiki_view"] + assert entry["config"]["outside_caller"] is True + + def test_writes_offered_when_allowed(self): + entry = self._entry(outside_caller=True, writes_allowed=True) + names = {a["name"] for a in entry["actions"]} + assert {"wiki_view", "wiki_create", "wiki_str_replace", "wiki_delete"} <= names + assert entry["config"]["outside_caller"] is True + + def test_in_app_config_keeps_every_action(self): + entry = self._entry() + assert len(entry["actions"]) == 6 + assert entry["config"]["outside_caller"] is False + + +@pytest.mark.unit +class TestBuildConfigOutsideCallers: + def _cfg(self, monkeypatch, agent_config, allowed=False): + from docsgpt.api.answer.services.stream_processor import StreamProcessor + + class _SrcRepo: + def __init__(self, conn): + pass + + def get_any(self, sid, owner): + return {"id": sid, "config": {"kind": "wiki"}, "wiki_outside_edits": allowed} + + monkeypatch.setattr("docsgpt.api.answer.services.stream_processor.SourcesRepository", _SrcRepo) + monkeypatch.setattr("docsgpt.api.answer.services.stream_processor.db_readonly", _noop_conn) + monkeypatch.setattr( + "docsgpt.api.answer.services.stream_processor._wiki_write_owner", lambda conn, sid, uid: "owner-x" + ) + proc = StreamProcessor.__new__(StreamProcessor) + proc.all_sources = [{"id": "wiki-src"}] + proc.decoded_token = {"sub": "owner-x"} + if agent_config is not None: + proc.agent_config = agent_config + return proc._build_wiki_config() + + @pytest.mark.parametrize("flag", ["external_api_caller", "public_link_caller"]) + def test_outside_caller_gets_read_only_while_off(self, monkeypatch, flag): + cfg = self._cfg(monkeypatch, {flag: True}) + assert cfg["outside_caller"] is True + assert cfg["writes_allowed"] is False + + @pytest.mark.parametrize("flag", ["external_api_caller", "public_link_caller"]) + def test_outside_caller_may_write_when_on(self, monkeypatch, flag): + cfg = self._cfg(monkeypatch, {flag: True}, allowed=True) + assert cfg["outside_caller"] is True + assert cfg["writes_allowed"] is True + + def test_v1_key_holder_gets_read_only(self, monkeypatch): + from docsgpt.api.answer.services.stream_processor import StreamProcessor + + monkeypatch.setattr(StreamProcessor, "external_caller", True, raising=False) + cfg = self._cfg(monkeypatch, {}) + assert cfg["writes_allowed"] is False + + @pytest.mark.parametrize("agent_config", [None, {}, {"external_api_caller": False}]) + def test_owner_and_team_unaffected(self, monkeypatch, agent_config): + cfg = self._cfg(monkeypatch, agent_config) + assert cfg["outside_caller"] is False + assert cfg["writes_allowed"] is True diff --git a/tests/api/answer/services/test_stream_processor_access.py b/tests/api/answer/services/test_stream_processor_access.py index 1ac58ede..55932d9e 100644 --- a/tests/api/answer/services/test_stream_processor_access.py +++ b/tests/api/answer/services/test_stream_processor_access.py @@ -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, {}) diff --git a/tests/api/test_pat_rules.py b/tests/api/test_pat_rules.py index a1bb0977..88e4205e 100644 --- a/tests/api/test_pat_rules.py +++ b/tests/api/test_pat_rules.py @@ -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//wiki/settings", "PUT"), ], ) def test_sensitive_routes_are_never_token_reachable(self, rule, method): diff --git a/tests/api/user/sources/test_wiki_routes.py b/tests/api/user/sources/test_wiki_routes.py index bf4df9aa..62ec960e 100644 --- a/tests/api/user/sources/test_wiki_routes.py +++ b/tests/api/user/sources/test_wiki_routes.py @@ -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 diff --git a/tests/storage/db/test_migration_0043.py b/tests/storage/db/test_migration_0043.py new file mode 100644 index 00000000..942e47fe --- /dev/null +++ b/tests/storage/db/test_migration_0043.py @@ -0,0 +1,52 @@ +"""Migration round-trip test for 0043_wiki_outside_edits.""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest +from sqlalchemy import text + + +pytestmark = pytest.mark.integration + +_0042 = "0042_schedule_created_via_api" + + +def _run_alembic(url: str, *args: str) -> None: + ini = Path(__file__).resolve().parents[3] / "docsgpt" / "alembic.ini" + subprocess.check_call( + [sys.executable, "-m", "alembic", "-c", str(ini), *args], + timeout=120, + env={**os.environ, "POSTGRES_URI": url}, + ) + + +def _column_exists(conn) -> bool: + return conn.execute( + text( + "SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' " + "AND table_name = 'sources' AND column_name = 'wiki_outside_edits'" + ) + ).fetchone() is not None + + +class TestMigration0043RoundTrip: + def test_head_defaults_to_off(self, pg_engine): + with pg_engine.begin() as conn: + value = conn.execute( + text("INSERT INTO sources (user_id, name) VALUES ('u', 'w') RETURNING wiki_outside_edits") + ).scalar() + assert value is False + + def test_downgrade_drops_then_upgrade_restores(self, pg_engine): + url = pg_engine.url.render_as_string(hide_password=False) + _run_alembic(url, "downgrade", _0042) + with pg_engine.connect() as conn: + assert not _column_exists(conn) + _run_alembic(url, "upgrade", "head") + with pg_engine.connect() as conn: + assert _column_exists(conn)