diff --git a/docsgpt/agents/default_tools.py b/docsgpt/agents/default_tools.py index f3bd1a09..187c57aa 100644 --- a/docsgpt/agents/default_tools.py +++ b/docsgpt/agents/default_tools.py @@ -369,6 +369,11 @@ def resolve_tool_by_id( Dual-registered tools (e.g. ``scheduler``) get both flags on the resolved row so callers can branch on either path without losing the discriminator. + + A ``user_tools`` row resolves when ``user`` owns it or a team grant gives + ``user`` the ``use_in_own`` action on it (checked live, so a revoked grant + drops the tool on the next run). The returned row is the owner's, so its + ``user_id`` is the credential owner. """ default_name = default_tool_name_for_id(tool_id) builtin_name = builtin_agent_tool_name_for_id(tool_id) @@ -382,4 +387,24 @@ def resolve_tool_by_id( return synthesize_builtin_agent_tool(builtin_name) if user_tools_repo is None or not user: return None - return user_tools_repo.get_any(str(tool_id), user) + row = user_tools_repo.get_any(str(tool_id), user) + if row is not None: + return row + return _resolve_shared_tool(str(tool_id), user, user_tools_repo) + + +def _resolve_shared_tool(tool_id: str, user: str, user_tools_repo: Any) -> Optional[Dict[str, Any]]: + """The owner's row for a team-shared tool ``user`` may use in their own agents.""" + conn = getattr(user_tools_repo, "_conn", None) + if conn is None: + return None + # Lazy: resource_access lives under docsgpt.api, whose package import + # pulls in every route module (and those import this module). + from docsgpt.api.user.resource_access import resolve + + ra = resolve(conn, "tool", tool_id, user) + if ra is None or ra.access == "owner" or not ra.can("use_in_own"): + if ra is not None: + logger.info("shared tool %s not usable by %s (access=%s); dropped", tool_id, user, ra.access) + return None + return user_tools_repo.get_any(ra.resource_id, ra.owner_id) diff --git a/docsgpt/agents/headless_runner.py b/docsgpt/agents/headless_runner.py index 0db09d55..5e1413d3 100644 --- a/docsgpt/agents/headless_runner.py +++ b/docsgpt/agents/headless_runner.py @@ -14,7 +14,10 @@ from docsgpt.api.answer.services.prompt_renderer import ( prompt_embeds_documents, resolve_prompt_skeleton, ) -from docsgpt.api.answer.services.stream_processor import get_prompt +from docsgpt.api.answer.services.stream_processor import ( + authorized_prompt_id, + get_prompt, +) from docsgpt.core.settings import settings from docsgpt.quotas.service import QuotaExceededError, QuotaService from docsgpt.retriever.retriever_creator import RetrieverCreator @@ -156,7 +159,9 @@ def _run_agent_headless( # ``chunks=0`` switches retrieval off; only a missing value takes the default. raw_chunks = agent_config.get("chunks") chunks = 6 if raw_chunks in (None, "") else int(raw_chunks) - prompt_id = agent_config.get("prompt_id", "default") + # Runs as the owner: a prompt they can no longer use (revoked grant, + # deleted) falls back to the default instead of rendering anyway. + prompt_id = authorized_prompt_id(agent_config.get("prompt_id", "default"), owner) user_api_key = agent_config.get("key") agent_id = _resolve_agent_id(agent_config) agent_type = agent_config.get("agent_type", "classic") diff --git a/docsgpt/agents/tool_executor.py b/docsgpt/agents/tool_executor.py index bc7f809a..a8e043c4 100644 --- a/docsgpt/agents/tool_executor.py +++ b/docsgpt/agents/tool_executor.py @@ -1,3 +1,4 @@ +import copy import logging import re import uuid @@ -29,6 +30,45 @@ from docsgpt.storage.db.session import db_readonly, db_session logger = logging.getLogger(__name__) +#: ``user_tools.config`` key holding an api_tool's encrypted header / +#: query-param values: ``{action: {section: {param: value}}}``, keyed by the +#: tool owner's id. The plaintext ``value`` of those entries is kept empty. +API_TOOL_SECRETS_KEY = "encrypted_action_secrets" +API_TOOL_SECRET_SECTIONS = ("headers", "query_params") + + +def api_tool_action_with_secrets(tool_data: Dict, action_name: str, fallback_owner: Optional[str] = None) -> Dict: + """An api_tool action definition with its stored secret values merged back. + + Secrets are decrypted with the tool row's ``user_id`` (the owner), never + the invoker's id. Legacy rows that still hold plaintext values need no + merge and are returned as stored. + + Args: + tool_data: The ``user_tools`` row. + action_name: The action key in ``config["actions"]``. + fallback_owner: Used only when the row carries no ``user_id``. + + Returns: + A deep copy of the action with header / query-param values filled in. + """ + config = tool_data.get("config") or {} + action = copy.deepcopy(config["actions"][action_name]) + blob = config.get(API_TOOL_SECRETS_KEY) + owner = tool_data.get("user_id") or fallback_owner + if not blob or not owner: + return action + secrets = decrypt_credentials(blob, owner).get(action_name) or {} + for section in API_TOOL_SECRET_SECTIONS: + block = action.get(section) + props = block.get("properties") if isinstance(block, dict) else None + if not isinstance(props, dict): + continue + for param, value in (secrets.get(section) or {}).items(): + if isinstance(props.get(param), dict): + props[param]["value"] = value + return action + def record_tool_span_start(call: Any, **attributes: Any) -> Any: """Open an ``execute_tool`` span for ``call`` (no-op without an active trace).""" @@ -565,6 +605,7 @@ class ToolExecutor: """Resolve an agentless chat's toolset: explicit user tools plus defaults.""" with db_readonly() as conn: user_tools = UserToolsRepository(conn).list_active_for_user(user) + user_tools.extend(self._shared_in_chat_tools(conn, user)) user_doc = UsersRepository(conn).get(user) if self.agent_id is None else None # Headless agentless runs (e.g. scheduled fire) drop chat-only # tools (``scheduler``) from explicit user_tools too. @@ -581,6 +622,32 @@ class ToolExecutor: tools[str(default_row["id"])] = default_row return tools + @staticmethod + def _shared_in_chat_tools(conn, user: str) -> List[Dict]: + """Team-shared tools the user switched into their chats and may still use. + + The grant (and the ``use_in_own`` switch) is re-checked on every call; + a revoked tool is dropped silently. Rows are the owner's, so their + credentials decrypt with the owner's id. + """ + from docsgpt.storage.db.repositories.user_tool_preferences import ( + UserToolPreferencesRepository, + ) + + tool_ids = UserToolPreferencesRepository(conn).list_in_chat_tool_ids(user) + if not tool_ids: + return [] + repo = UserToolsRepository(conn) + rows: List[Dict] = [] + for tid in tool_ids: + row = resolve_tool_by_id(tid, user, user_tools_repo=repo) + if row is None or row.get("user_id") == user: + if row is None: + logger.info("in-chat shared tool %s no longer usable by %s; dropped", tid, user) + continue + rows.append(row) + return rows + def merge_client_tools(self, tools_dict: Dict, client_tools: List[Dict]) -> Dict: """Merge client-provided tool definitions into tools_dict. @@ -1261,7 +1328,7 @@ class ToolExecutor: return error_message, call_id yield {"type": "tool_call", "data": {**tool_call_data, "status": "pending"}} action_data = ( - tool_data["config"]["actions"][action_name] + api_tool_action_with_secrets(tool_data, action_name, self.user) if tool_data["name"] == "api_tool" else next(action for action in tool_data["actions"] if action["name"] == action_name) ) @@ -1528,10 +1595,13 @@ class ToolExecutor: if tool_data["name"] == "mcp_tool": tool_config["query_mode"] = True + # MCP OAuth tokens are looked up by user id: a shared server runs on + # the owner's connection, like every other credential. + load_user = (tool_data.get("user_id") or self.user) if tool_data["name"] == "mcp_tool" else self.user tool = tm.load_tool( tool_data["name"], tool_config=tool_config, - user_id=self.user, + user_id=load_user, ) # Don't cache api_tool since config varies by action diff --git a/docsgpt/agents/tools/wiki.py b/docsgpt/agents/tools/wiki.py index 65003033..372532f8 100644 --- a/docsgpt/agents/tools/wiki.py +++ b/docsgpt/agents/tools/wiki.py @@ -19,6 +19,8 @@ WIKI_UPDATED_VIA_AGENT = "agent" MAX_WIKI_PAGE_BYTES = 1_000_000 +_WRITE_ACTIONS = frozenset({"create", "str_replace", "insert", "delete", "rename"}) + class WikiTool(Tool): """Wiki @@ -49,6 +51,11 @@ class WikiTool(Tool): if not self.source_id: return "Error: WikiTool requires a source_id." + if action_name in _WRITE_ACTIONS: + denied = self._write_denied() + if denied: + return denied + if action_name == "view": return self._view(kwargs.get("path", "/"), kwargs.get("view_range")) if action_name == "create": @@ -192,6 +199,37 @@ class WikiTool(Tool): }, ] + def _write_denied(self) -> Optional[str]: + """Re-check the caller's live ``edit`` right before a write. + + The tool is attached for a writable source when the conversation + starts, but a grant can be revoked (or downgraded to viewer) mid-run. + Resolving access on every write stops the agent from editing once the + caller no longer may. Fails closed on an unknown caller or a failed + lookup. Re-embeds still run as the owner. + + Returns: + Optional[str]: An error message for the LLM, or None when allowed. + """ + from docsgpt.api.user import resource_access + + message = "Error: You no longer have edit access to this wiki, so it can't be changed." + if not self.updated_by: + return message + try: + with db_readonly() as conn: + access = resource_access.resolve( + conn, "source", str(self.source_id), self.updated_by + ) + except Exception: + logger.exception( + "Wiki write access check failed for source %s", self.source_id + ) + return message + if access is None or not access.can("edit"): + return message + return None + def get_config_requirements(self) -> Dict[str, Any]: return {} diff --git a/docsgpt/alembic/versions/0038_resource_access_settings.py b/docsgpt/alembic/versions/0038_resource_access_settings.py new file mode 100644 index 00000000..5c6f68d0 --- /dev/null +++ b/docsgpt/alembic/versions/0038_resource_access_settings.py @@ -0,0 +1,65 @@ +"""0038 resource access settings — per-asset sharing switches and tool chat prefs. + +``resource_share_settings`` holds the owner's per-asset switches that adjust +what the fixed roles may do on one shared resource ("Editors can share", +"Viewers can see logs", ...). One row per ``(resource_type, resource_id)``; +a missing row means every switch is at its default. The table is polymorphic +like ``team_resource_grants``, so it has no FK and the switch keys are +validated in code (``docsgpt/api/user/resource_access.py``), which keeps new +switches migration-free. + +``user_tool_preferences`` is the personal "In my chats" switch for a tool +shared with the caller. A grantee can't write the owner's ``user_tools.status`` +(that is the owner's own chat setting), so each grantee gets a row here. +A missing row means off: sharing a tool never adds it to anyone's chats. + +Idempotent both ways. + +Revision ID: 0038_resource_access_settings +Revises: 0037_request_traces +""" + +from typing import Sequence, Union + +from alembic import op + + +revision: str = "0038_resource_access_settings" +down_revision: Union[str, None] = "0037_request_traces" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE IF NOT EXISTS resource_share_settings ( + resource_type TEXT NOT NULL + CONSTRAINT resource_share_settings_type_check + CHECK (resource_type IN ('agent', 'source', 'prompt', 'tool')), + resource_id UUID NOT NULL, + settings JSONB NOT NULL DEFAULT '{}'::jsonb, + updated_by TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (resource_type, resource_id) + ); + """ + ) + op.execute( + """ + CREATE TABLE IF NOT EXISTS user_tool_preferences ( + user_id TEXT NOT NULL, + tool_id UUID NOT NULL REFERENCES user_tools(id) ON DELETE CASCADE, + in_chat BOOLEAN NOT NULL DEFAULT false, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, tool_id) + ); + CREATE INDEX IF NOT EXISTS user_tool_preferences_tool_idx + ON user_tool_preferences (tool_id); + """ + ) + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS user_tool_preferences;") + op.execute("DROP TABLE IF EXISTS resource_share_settings;") diff --git a/docsgpt/api/answer/services/stream_processor.py b/docsgpt/api/answer/services/stream_processor.py index 24688aa7..c72b1055 100644 --- a/docsgpt/api/answer/services/stream_processor.py +++ b/docsgpt/api/answer/services/stream_processor.py @@ -109,6 +109,50 @@ def get_prompt(prompt_id: str, prompts_collection=None) -> str: raise ValueError(f"Invalid prompt ID: {prompt_id}") from e +_PROMPT_PRESETS_WITHOUT_ROW = ("reduce",) + + +def authorized_prompt_id(prompt_id: Any, principal: Optional[str]) -> Any: + """``prompt_id`` if ``principal`` may use it, else ``"default"``. + + Presets pass through. A custom prompt must be owned by ``principal`` or + reach them through a team grant with ``use`` (checked live); a revoked, + deleted or foreign prompt falls back to the default prompt. + + Args: + prompt_id: The configured prompt (preset name, UUID or legacy id). + principal: The agent owner for an agent run, else the caller. + + Returns: + The prompt id to render. + """ + if prompt_id is None or prompt_id == "": + return prompt_id + pid = str(prompt_id) + if is_composed_preset(pid) or pid in _PROMPT_PRESETS_WITHOUT_ROW: + return prompt_id + from docsgpt.api.user.resource_access import resolve + + try: + with db_readonly() as conn: + ra = resolve(conn, "prompt", pid, principal) if principal else None + except Exception: + logger.exception("Prompt access check failed for %s", pid) + ra = None + if ra is not None and ra.can("use"): + return prompt_id + logger.info("prompt %s not usable by %s; using the default prompt", pid, principal) + return "default" + + +def _wiki_write_owner(conn: Any, source_id: str, caller: str) -> Optional[str]: + """The owner id to write a wiki source as, when ``caller`` may edit it.""" + from docsgpt.api.user.resource_access import resolve + + ra = resolve(conn, "source", source_id, caller) + return ra.owner_id if ra is not None and ra.can("edit") else None + + T = TypeVar("T") @@ -934,7 +978,12 @@ class StreamProcessor: self.agent_config.update( { - "prompt_id": self._agent_data.get("prompt_id", "default"), + # The agent runs in its owner's context: its prompt must + # be one the owner may use (re-checked on every run). + "prompt_id": authorized_prompt_id( + self._agent_data.get("prompt_id", "default"), + self._agent_data.get("user"), + ), "agent_type": self._agent_data.get("agent_type", settings.AGENT_NAME), "user_api_key": effective_key, "json_schema": self._agent_data.get("json_schema"), @@ -998,9 +1047,10 @@ class StreamProcessor: if preview_workflow_id: self.agent_config["workflow_id"] = str(preview_workflow_id) + caller = self.decoded_token.get("sub") if isinstance(self.decoded_token, dict) else None self.agent_config.update( { - "prompt_id": self.data.get("prompt_id", "default"), + "prompt_id": authorized_prompt_id(self.data.get("prompt_id", "default"), caller), "agent_type": agent_type, "user_api_key": None, "json_schema": None, @@ -1134,14 +1184,12 @@ class StreamProcessor: """Resolve the WikiTool config for the first writable wiki source. A source qualifies when ``SourceConfig.parse(config).kind == "wiki"`` and - the principal can write it (``effective_write_owner`` returns an owner — - owner or team editor; viewers get None and no tool). v1 supports one + the principal may ``edit`` it (owner or team editor; viewers get no + tool) — resolved live through ``resource_access``. v1 supports one 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. """ - from docsgpt.api.user.team_sharing import effective_write_owner - caller = self.decoded_token.get("sub") if self.decoded_token else None if not caller: return None @@ -1155,7 +1203,7 @@ class StreamProcessor: if not sid or sid == "default": continue sid = str(sid) - owner = effective_write_owner(conn, "source", sid, caller) + owner = _wiki_write_owner(conn, sid, caller) if not owner: continue source_doc = repo.get_any(sid, owner) diff --git a/docsgpt/api/connector/routes.py b/docsgpt/api/connector/routes.py index 5240ee58..dccdda16 100644 --- a/docsgpt/api/connector/routes.py +++ b/docsgpt/api/connector/routes.py @@ -17,6 +17,8 @@ from flask_restx import fields, Namespace, Resource from docsgpt.api import api +from docsgpt.api.user.resource_access import AccessDenied +from docsgpt.api.user.sources.access import load_source from docsgpt.api.user.tasks import ( ingest_connector_task, ) @@ -26,7 +28,6 @@ from docsgpt.storage.db.repositories.connector_sessions import ( ConnectorSessionsRepository, owns_connector_session, ) -from docsgpt.storage.db.repositories.sources import SourcesRepository from docsgpt.storage.db.session import db_readonly, db_session @@ -499,6 +500,40 @@ class ConnectorDisconnect(Resource): return make_response(jsonify({"success": False, "error": "Failed to disconnect session"}), 500) +def _owner_connector_session(conn, owner_id: str, provider: str) -> Optional[dict]: + """The owner's usable connector session for ``provider``, or None. + + Used when a team editor syncs a shared connector source: the sync runs + with the owner's account. A session with no token, no stored credentials, + or an expired access token that can't be refreshed counts as missing. + + Args: + conn: Open database connection. + owner_id: The source owner's ``sub``. + provider: The source's connector provider. + + Returns: + Optional[dict]: The session row, or None when the owner must reconnect. + """ + candidates = [ + s for s in ConnectorSessionsRepository(conn).list_for_user(owner_id) + if owns_connector_session(s, owner_id, provider) + and s.get("session_token") and s.get("token_info") + ] + if not candidates: + return None + session = candidates[0] + token_info = session["token_info"] + if not token_info.get("refresh_token"): + try: + if ConnectorCreator.create_auth(provider).is_token_expired(token_info): + return None + except Exception: + # Providers without an expiry check leave the verdict to the sync. + pass + return session + + @connectors_ns.route("/api/connectors/sync") class ConnectorSync(Resource): @api.expect( @@ -506,7 +541,11 @@ class ConnectorSync(Resource): "ConnectorSyncModel", { "source_id": fields.String(required=True, description="Source ID to sync"), - "session_token": fields.String(required=True, description="Authentication token") + "session_token": fields.String( + required=False, + description="The owner's connector session token (ignored for team editors, " + "whose sync uses the owner's session)", + ) }, ) ) @@ -517,33 +556,41 @@ class ConnectorSync(Resource): return make_response(jsonify({"success": False}), 401) try: - data = request.get_json() + data = request.get_json() or {} source_id = data.get('source_id') session_token = data.get('session_token') - if not all([source_id, session_token]): + if not source_id: return make_response( jsonify({ "success": False, "error": "source_id and session_token are required" - }), + }), 400 ) user_id = decoded_token.get('sub') - with db_readonly() as conn: - source = SourcesRepository(conn).get_any(source_id, user_id) - if not source: + # Owner or team editor. The sync always runs AS the owner, with the + # owner's connector account: a grantee can't point the source at + # their own account (that is ``reconnect``, owner-only). + try: + with db_readonly() as conn: + source, ra = load_source(conn, source_id, user_id, "edit") + except AccessDenied as err: + return make_response( + jsonify({"success": False, "error": err.message, "message": err.message}), + err.status, + ) + owner_id = ra.owner_id + is_owner = ra.access == "owner" + if is_owner and not session_token: return make_response( jsonify({ "success": False, - "error": "Source not found" + "error": "source_id and session_token are required" }), - 404 + 400 ) - # ``get_any`` already scopes by ``user_id``; an extra guard - # here would be dead code. - remote_data = source.get('remote_data') or {} if isinstance(remote_data, str): try: @@ -558,17 +605,31 @@ class ConnectorSync(Resource): jsonify({ "success": False, "error": "Source provider not found in remote_data" - }), + }), 400 ) - with db_readonly() as conn: - session = ConnectorSessionsRepository(conn).get_by_session_token(session_token) - if not owns_connector_session(session, user_id, source_type): - return make_response( - jsonify({"success": False, "error": "Invalid or unauthorized session"}), - 401, - ) + if is_owner: + with db_readonly() as conn: + session = ConnectorSessionsRepository(conn).get_by_session_token(session_token) + if not owns_connector_session(session, user_id, source_type): + return make_response( + jsonify({"success": False, "error": "Invalid or unauthorized session"}), + 401, + ) + else: + with db_readonly() as conn: + session = _owner_connector_session(conn, owner_id, source_type) + if session is None: + message = ( + "The owner needs to reconnect this source's account " + "before it can be synced." + ) + return make_response( + jsonify({"success": False, "error": message, "message": message}), + 409, + ) + session_token = session["session_token"] # Extract configuration from remote_data file_ids = remote_data.get('file_ids', []) @@ -578,7 +639,7 @@ class ConnectorSync(Resource): # Start the sync task task = ingest_connector_task.delay( job_name=source.get('name'), - user=decoded_token.get('sub'), + user=owner_id, source_type=source_type, session_token=session_token, file_ids=file_ids, diff --git a/docsgpt/api/pat/rules.py b/docsgpt/api/pat/rules.py index cea33e79..80223f81 100644 --- a/docsgpt/api/pat/rules.py +++ b/docsgpt/api/pat/rules.py @@ -309,6 +309,7 @@ RULES: dict[tuple[str, str], Rule] = { ("/api/teams//members", "GET"): _rule("teams:read"), ("/api/teams//grants", "GET"): _rule("teams:read"), ("/api/resource_shares", "GET"): _rule("teams:read"), + ("/api/resource_settings", "GET"): _rule("teams:read"), # Chat ("/api/answer", "POST"): _rule("chat:run", **_CHAT), ("/stream", "POST"): _rule("chat:run", **_CHAT), @@ -355,6 +356,7 @@ DENIED: dict[str, tuple[str, ...]] = { "/api/teams//members/": ("*",), "/api/teams//grants": ("POST", "DELETE"), "/api/teams//transfer_owner": ("*",), + "/api/resource_settings": ("PUT",), "/swagger.json": ("*",), } DENIED_PREFIXES = ( diff --git a/docsgpt/api/user/agents/folders.py b/docsgpt/api/user/agents/folders.py index f4653a5b..e70b9212 100644 --- a/docsgpt/api/user/agents/folders.py +++ b/docsgpt/api/user/agents/folders.py @@ -8,6 +8,7 @@ from flask_restx import Namespace, Resource, fields from sqlalchemy import text as _sql_text from docsgpt.api import api +from docsgpt.api.user.resource_access import AccessDenied, payload_for, require, settings_many from docsgpt.storage.db.base_repository import looks_like_uuid from docsgpt.storage.db.repositories.agent_folders import AgentFoldersRepository from docsgpt.storage.db.repositories.agents import AgentsRepository @@ -143,11 +144,16 @@ class AgentFolder(Resource): ), {"user_id": user, "fid": pg_folder_id}, ).fetchall() + switches = settings_many( + conn, "agent", [str(row._mapping["id"]) for row in agents_rows] + ) + # Folder contents are the caller's own agents. agents_list = [ { "id": str(row._mapping["id"]), "name": row._mapping["name"], "description": row._mapping.get("description", "") or "", + **payload_for("agent", "owner", switches[str(row._mapping["id"])]), } for row in agents_rows ] @@ -298,7 +304,14 @@ class MoveAgentToFolder(Resource): try: with db_session() as conn: agents_repo = AgentsRepository(conn) - agent = agents_repo.get_any(agent_id_input, user) + # Folders are the owner's own organisation of their agents. + try: + ra = require(conn, "agent", agent_id_input, user, "move_folder") + except AccessDenied as denied: + return make_response( + jsonify({"success": False, "message": denied.message}), denied.status + ) + agent = agents_repo.get_by_id(ra.resource_id) if not agent: return make_response( jsonify({"success": False, "message": "Agent not found"}), @@ -357,10 +370,18 @@ class BulkMoveAgents(Resource): 404, ) pg_folder_id = str(folder["id"]) + # Only the caller's own agents move (``move_folder`` is + # owner-only); anything else is reported back, not dropped. + moved, skipped = [], [] for agent_id_input in agent_ids: agent = agents_repo.get_any(agent_id_input, user) if agent is not None: agents_repo.set_folder(str(agent["id"]), user, pg_folder_id) - return make_response(jsonify({"success": True}), 200) + moved.append(str(agent_id_input)) + else: + skipped.append(str(agent_id_input)) + return make_response( + jsonify({"success": True, "moved": moved, "skipped": skipped}), 200 + ) except Exception as err: return _folder_error_response("Failed to move agents", err) diff --git a/docsgpt/api/user/agents/guardrails.py b/docsgpt/api/user/agents/guardrails.py index 2d0a0635..46bd8417 100644 --- a/docsgpt/api/user/agents/guardrails.py +++ b/docsgpt/api/user/agents/guardrails.py @@ -4,7 +4,7 @@ from flask import jsonify, make_response, request from flask_restx import Namespace, Resource from docsgpt.api import api -from docsgpt.api.user.team_sharing import team_access_for +from docsgpt.api.user.resource_access import AccessDenied, ResourceAccess, require from docsgpt.core.settings import settings from docsgpt.guardrails.checks.patterns import DEFAULT_PII_ENTITIES, PII_PATTERNS from docsgpt.guardrails.config import DEFAULT_BLOCK_MESSAGE, MODES @@ -70,15 +70,30 @@ class GuardrailCatalog(Resource): ) -def _readable_agent(conn, agent_id: str, user: str): - """Return the agent row when the caller may read it, else None.""" - repo = AgentsRepository(conn) - agent = repo.get_any(agent_id, user) - if agent: - return agent - if team_access_for(conn, user, "agent", agent_id): - return repo.get_by_id(agent_id) - return None +def _logs_access(conn, agent_id: str, user: str) -> tuple[dict, ResourceAccess]: + """The agent row and the caller's access, for reading its guardrail journal. + + Args: + conn: Open database connection. + agent_id: The agent's id (UUID or legacy). + user: The caller. + + Returns: + ``(agent, access)``; rows are read as ``access.owner_id``, so a team + member with ``view_logs`` sees exactly what the owner sees. + + Raises: + AccessDenied: 404 when the agent isn't visible, 403 without ``view_logs``. + """ + ra = require(conn, "agent", agent_id, user, "view_logs") + agent = AgentsRepository(conn).get_by_id(ra.resource_id) + if agent is None: + raise AccessDenied(404, "Agent not found") + return agent, ra + + +def _denied(err: AccessDenied): + return make_response(jsonify({"success": False, "message": err.message}), err.status) @agents_guardrails_ns.route("/guardrails/events") @@ -106,17 +121,16 @@ class GuardrailEvents(Resource): 400, ) with db_readonly() as conn: - agent = _readable_agent(conn, agent_id, user) - if not agent: - return make_response( - jsonify({"success": False, "message": "Agent not found"}), 404 - ) + try: + agent, ra = _logs_access(conn, agent_id, user) + except AccessDenied as denied: + return _denied(denied) # Query on the row's UUID, not the caller's argument: a legacy # 24-hex Mongo id resolves fine above but would blow up the cast. - # Rows stay scoped to the requesting user even on a shared agent — - # another member's blocked prompts are not this caller's to read. + # Rows are the owner's view: ``view_logs`` shows a team member + # what the owner sees, never other members' own chats. events = GuardrailEventsRepository(conn).list_for_agent( - str(agent["id"]), user, limit=limit, offset=offset + str(agent["id"]), ra.owner_id, limit=limit, offset=offset ) return make_response(jsonify({"success": True, "events": events}), 200) @@ -143,14 +157,15 @@ class GuardrailSummary(Resource): agent_id = request.args.get("agent_id") with db_readonly() as conn: scoped_id = None + scope_user = user if agent_id: - agent = _readable_agent(conn, agent_id, user) - if not agent: - return make_response( - jsonify({"success": False, "message": "Agent not found"}), 404 - ) + try: + agent, ra = _logs_access(conn, agent_id, user) + except AccessDenied as denied: + return _denied(denied) scoped_id = str(agent["id"]) + scope_user = ra.owner_id summary = GuardrailEventsRepository(conn).summary_for_user( - user, days=days, agent_id=scoped_id + scope_user, days=days, agent_id=scoped_id ) return make_response(jsonify({"success": True, **summary}), 200) diff --git a/docsgpt/api/user/agents/portability.py b/docsgpt/api/user/agents/portability.py index ca0e0b65..289f2512 100644 --- a/docsgpt/api/user/agents/portability.py +++ b/docsgpt/api/user/agents/portability.py @@ -37,6 +37,7 @@ from docsgpt.agents.default_tools import ( ) from docsgpt.api import api from docsgpt.api.pat.rules import allowed_ids +from docsgpt.api.user.resource_access import AccessDenied, require from docsgpt.core.model_utils import validate_model_id from docsgpt.core.url_validation import SSRFError, validate_url from docsgpt.security.safe_url import UnsafeUserUrlError, validate_user_base_url @@ -1756,14 +1757,23 @@ class ExportAgent(Resource): return make_response(jsonify({"success": False, "message": "id is required"}), 400) with db_session() as conn: repo = AgentsRepository(conn) - agent = repo.get_any(agent_id, user) + try: + ra = require(conn, "agent", agent_id, user, "export") + except AccessDenied as denied: + return make_response( + jsonify({"success": False, "message": denied.message}), denied.status + ) + agent = repo.get_by_id(ra.resource_id) if not agent: return make_response( jsonify({"success": False, "message": "Agent not found"}), 404 ) - agent["slug"] = ensure_agent_slug(conn, agent, user) + # Serialized as the owner: the agent's prompt, sources, tools and + # workflow are the owner's (secrets are never exported). + owner_id = ra.owner_id + agent["slug"] = ensure_agent_slug(conn, agent, owner_id) try: - export = serialize_agent(conn, agent, user) + export = serialize_agent(conn, agent, owner_id) except AgentExportError as exc: return make_response( jsonify({"success": False, "message": str(exc)}), 400 diff --git a/docsgpt/api/user/agents/routes.py b/docsgpt/api/user/agents/routes.py index e1cf6f1e..6b9daf45 100644 --- a/docsgpt/api/user/agents/routes.py +++ b/docsgpt/api/user/agents/routes.py @@ -26,9 +26,16 @@ from docsgpt.core.json_schema_utils import ( ) from docsgpt.core.settings import settings from docsgpt.storage.db.base_repository import looks_like_uuid +from docsgpt.api.user.resource_access import ( + AccessDenied, + delete_settings, + payload_for, + require, + resolve, + settings_many, +) from docsgpt.api.user.team_sharing import ( can_access, - team_access_for, visible_with_access, ) from docsgpt.agents.default_tools import is_synthesized_tool_id @@ -196,6 +203,83 @@ def _resolve_folder_id(conn, folder_id, user): return str(folder["id"]), None +# What a caller who reached an agent only through its public share link may +# do: chat with it and pin it. A team grant, when there is one, wins. +LINK_SHARED_ACCESS = {"access": "viewer", "allowed_actions": ["pin", "use"]} + +# Agent fields that are the owner's policy (guardrails and the pooled quota). +POLICY_FIELDS = ( + "config", "token_limit", "request_limit", "limited_token_mode", "limited_request_mode", +) + + +def _denied(err: AccessDenied): + """The JSON error response for an :class:`AccessDenied`.""" + return make_response(jsonify({"success": False, "message": err.message}), err.status) + + +def _tool_attachable(conn, tool_id: str, owner_id: str, caller: str) -> bool: + """Whether ``caller`` may attach ``tool_id`` to an agent owned by ``owner_id``. + + Builtin synthetic ids belong to no one. Otherwise the tool must be the + agent owner's (it runs with the owner's credentials) or reach the caller + with ``use_in_own``. + + Args: + conn: Open database connection. + tool_id: The tool id being attached. + owner_id: The agent's owner. + caller: The user making the change. + + Returns: + True when the attachment is allowed. + """ + tid = str(tool_id) + if is_synthesized_tool_id(tid): + return True + if UserToolsRepository(conn).get_any(tid, owner_id) is not None: + return True + ra = resolve(conn, "tool", tid, caller) + return ra is not None and ra.can("use_in_own") + + +def _ref_attachable(conn, resource_type: str, resource_id: str, owner_id: str, caller: str) -> bool: + """Whether a source/prompt may be referenced by an agent owned by ``owner_id``. + + Args: + conn: Open database connection. + resource_type: ``source`` or ``prompt``. + resource_id: The referenced id. + owner_id: The agent's owner. + caller: The user making the change. + + Returns: + True when the agent owner owns it or the caller can ``use`` it. + """ + if not resource_id: + return True + if owner_id != caller and can_access(conn, resource_type, str(resource_id), owner_id): + return True + return can_access(conn, resource_type, str(resource_id), caller) + + +def _policy_changed(existing: dict, update_fields: dict) -> bool: + """True when ``update_fields`` changes any guardrail or quota value.""" + for key in POLICY_FIELDS: + if key not in update_fields: + continue + new, old = update_fields[key], existing.get(key) + if key == "config": + if (new or {}) != (old or {}): + return True + elif key.startswith("limited_"): + if bool(new) != bool(old): + return True + elif int(new or 0) != int(old or 0): + return True + return False + + def _reject(message: str, user: str, field: str = "-"): """Log a request-validation rejection at WARN and return its 400 response. @@ -228,6 +312,7 @@ def _format_agent_output( ownership: str = "user", team_access: str | None = None, resolve_names: bool = False, + access: dict | None = None, ) -> dict: """Shape a PG agent row into the outward API response dict. @@ -239,7 +324,16 @@ def _format_agent_output( ``ownership`` is ``"user"`` for the caller's own agents or ``"team"`` for ones shared with a team they're in; ``team_access`` (``viewer``/``editor``) is set on team-shared agents so the UI can gate edit controls. + + ``access`` is the caller's ``{"access", "allowed_actions"}`` payload + (owner with default switches when omitted). It is embedded verbatim and + decides what leaves the server: the full guardrail ``config`` needs + ``view``, the (masked) ``key`` and public ``shared_token`` need + ``manage_access_details``. """ + if access is None: + access = payload_for("agent", "owner", {}) + allowed = set(access.get("allowed_actions") or []) source_id = agent.get("source_id") extra_source_ids = agent.get("extra_source_ids") or [] source_value = str(source_id) if source_id else "" @@ -288,7 +382,13 @@ def _format_agent_output( ), "ownership": ownership, "team_access": team_access, + "access": access.get("access"), + "allowed_actions": sorted(allowed), } + # Guardrail policy is edit-page config; a chat-only caller doesn't need it + # (and reading the banned-term list makes evading it trivial). + if "view" not in allowed: + out["config"] = {} # Resolve prompt/source NAMES by id (owner-agnostic) so a team member # viewing a shared agent sees the owner's prompt + source names instead of # a blank prompt / "External KB" (the client otherwise resolves these from @@ -298,9 +398,9 @@ def _format_agent_output( out["source_details"] = resolve_source_details( ([source_id] if source_id else []) + list(extra_source_ids) ) - # Never expose the owner's share/API secrets to a team grantee — the - # public ``shared_token`` and the (masked) agent ``key`` are owner-only. - if ownership == "team": + # The public ``shared_token`` and the (masked) agent ``key`` are access + # details: only a caller who may manage them sees them. + if "manage_access_details" not in allowed: out["shared_token"] = "" return out if include_key_masked: @@ -435,23 +535,22 @@ class GetAgent(Resource): return {"success": False, "message": "ID required"}, 400 try: user = decoded_token["sub"] - ownership, team_access = "user", None + agent = None with db_readonly() as conn: - repo = AgentsRepository(conn) - agent = repo.get_any(agent_id, user) - if not agent: - # Team fallback: only after a grant check, fetch ownerless. - team_access = team_access_for(conn, user, "agent", agent_id) - if team_access: - agent = repo.get_by_id(agent_id) - ownership = "team" + # Anyone who can see the agent reads it (a viewer needs it to + # chat); what they get back is trimmed by their actions. + ra = resolve(conn, "agent", agent_id, user) + if ra is not None: + agent = AgentsRepository(conn).get_by_id(ra.resource_id) if not agent: return {"status": "Not found"}, 404 + is_owner = ra.access == "owner" data = _format_agent_output( agent, - ownership=ownership, - team_access=team_access, + ownership="user" if is_owner else "team", + team_access=None if is_owner else ra.access, resolve_names=True, + access=ra.payload(), ) return make_response(jsonify(data), 200) except Exception as e: @@ -483,10 +582,18 @@ class GetAgents(Resource): shared_ids = [aid for aid in team_shared if aid not in owned_ids] shared_agents = agents_repo.list_by_ids(shared_ids) + switches = settings_many( + conn, "agent", [str(a["id"]) for a in agents + shared_agents] + ) + # Every agent is listed: one with no source skips retrieval and # answers from the model and its tools, so it is still runnable. list_agents = [ - _format_agent_output(agent, pinned=str(agent["id"]) in pinned_ids) + _format_agent_output( + agent, + pinned=str(agent["id"]) in pinned_ids, + access=payload_for("agent", "owner", switches[str(agent["id"])]), + ) for agent in agents ] list_agents += [ @@ -494,6 +601,11 @@ class GetAgents(Resource): agent, ownership="team", team_access=team_shared.get(str(agent["id"])), + access=payload_for( + "agent", + team_shared.get(str(agent["id"])), + switches[str(agent["id"])], + ), ) for agent in shared_agents ] @@ -731,6 +843,14 @@ class CreateAgent(Resource): jsonify({"success": False, "message": "Prompt not accessible"}), 403, ) + # Tools run with the agent owner's credentials: attach only your + # own, or ones a team lets you use in your agents. + for tid in data.get("tools") or []: + if not _tool_attachable(conn, tid, user, user): + return make_response( + jsonify({"success": False, "message": "Tool not accessible"}), + 403, + ) build_data = dict(data) build_data["folder_id"] = pg_folder_id @@ -888,23 +1008,15 @@ class UpdateAgent(Resource): try: with db_session() as conn: agents_repo = AgentsRepository(conn) - is_team_editor = False - existing_agent = agents_repo.get_any(agent_id, user) - if not existing_agent: - # Team write path: only an 'editor' grant may modify a - # team-shared agent; a 'viewer' is read-only. Fetch the - # ownerless row only AFTER confirming editor access. - access = team_access_for(conn, user, "agent", agent_id) - if access == "editor": - existing_agent = agents_repo.get_by_id(agent_id) - is_team_editor = True - elif access == "viewer": - return make_response( - jsonify( - {"success": False, "message": "Read-only: editor access required"} - ), - 403, - ) + try: + ra = require(conn, "agent", agent_id, user, "edit") + except AccessDenied as denied: + return _denied(denied) + # Every write lands as the owner; the row is fetched only after + # the access check above. + owner_id = ra.owner_id + is_team_editor = ra.access != "owner" + existing_agent = agents_repo.get_by_id(ra.resource_id) if not existing_agent: return make_response( jsonify( @@ -959,6 +1071,8 @@ class UpdateAgent(Resource): user, field, ) + if new_status != existing_agent.get("status") and not ra.can("publish"): + return _denied(AccessDenied(403, "Your access to this item doesn't allow that")) update_fields["status"] = new_status elif field == "source": source_id = data.get("source") @@ -1084,10 +1198,24 @@ class UpdateAgent(Resource): field, ) elif field == "folder_id": - folder_input = data.get("folder_id") + # Folders are the owner's own organisation; re-sending + # the current folder is a no-op anyone may do. + folder_input = data.get("folder_id") or None + current_folder = ( + str(existing_agent["folder_id"]) + if existing_agent.get("folder_id") + else None + ) + if folder_input == current_folder: + update_fields["folder_id"] = current_folder + continue + if not ra.can("move_folder"): + return _denied( + AccessDenied(403, "Only the owner can move this agent between folders") + ) if folder_input: pg_folder_id, folder_err = _resolve_folder_id( - conn, folder_input, user, + conn, folder_input, owner_id, ) if folder_err: return folder_err @@ -1107,8 +1235,10 @@ class UpdateAgent(Resource): return _reject("Workflow is required", user, field) update_fields["workflow_id"] = None else: + # The agent runs its workflow as the owner, so it + # must be one of the owner's workflows. pg_workflow_id, wf_err = _resolve_workflow_for_user( - conn, workflow_input, user, + conn, workflow_input, owner_id, ) if wf_err: return wf_err @@ -1220,7 +1350,7 @@ class UpdateAgent(Resource): for sid in referenced_sources: if str(sid) in existing_source_refs: continue - if not can_access(conn, "source", sid, user): + if not _ref_attachable(conn, "source", sid, owner_id, user): return make_response( jsonify({"success": False, "message": "Source not accessible"}), 403 ) @@ -1228,28 +1358,24 @@ class UpdateAgent(Resource): if ( new_prompt_id and str(new_prompt_id) != str(existing_agent.get("prompt_id") or "") - and not can_access(conn, "prompt", new_prompt_id, user) + and not _ref_attachable(conn, "prompt", new_prompt_id, owner_id, user) ): return make_response( jsonify({"success": False, "message": "Prompt not accessible"}), 403 ) - # A team editor must not attach tools they can't access onto a - # shared agent: at run time the agent-key path resolves+decrypts - # tools as the OWNER, so an unchecked tool here would let an - # editor invoke arbitrary owner credentials. Owners are - # unrestricted (they own their tools). Default/builtin synthetic - # tool ids belong to no one and are always allowed. - if is_team_editor and "tools" in update_fields: - from docsgpt.agents.default_tools import is_synthesized_tool_id - + # Tools run with the OWNER's credentials (the agent-key path + # resolves and decrypts them as the owner), so a newly attached + # tool must be the owner's or reach the caller with + # ``use_in_own``. Tools already on the agent stay. Builtin + # synthetic ids belong to no one and are always allowed. + if "tools" in update_fields: existing_tools = { str(t) for t in (existing_agent.get("tools") or []) } for tid in update_fields["tools"] or []: - tid_s = str(tid) - if tid_s in existing_tools or is_synthesized_tool_id(tid_s): + if str(tid) in existing_tools: continue - if not can_access(conn, "tool", tid_s, user): + if not _tool_attachable(conn, tid, owner_id, user): return make_response( jsonify( {"success": False, "message": "Tool not accessible"} @@ -1257,19 +1383,13 @@ class UpdateAgent(Resource): 403, ) - # Per-agent quota lives on the row and is pooled across all - # members; only the owner may resize that shared pool, so a - # team editor's quota changes are dropped. - if is_team_editor: - for _q in ( - "token_limit", "request_limit", - "limited_token_mode", "limited_request_mode", - # Guardrails are the owner's policy for their agent. - # An editor who could clear them would silently strip - # protection from everyone else using it. - "config", - ): - update_fields.pop(_q, None) + # Guardrails and the pooled quota are policy: an unchanged + # value re-sent by a full-form save is fine, a change needs + # ``edit_policy``. + if not ra.can("edit_policy") and _policy_changed(existing_agent, update_fields): + return _denied( + AccessDenied(403, "Your access doesn't allow changing guardrails or limits") + ) # Apply update. Owner writes use the dual-key guard; team-editor # writes go by-id (already authorized) with an optimistic-lock @@ -1328,7 +1448,9 @@ class UpdateAgent(Resource): "id": pg_agent_id, "message": "Agent updated successfully", } - if newly_generated_key: + # A freshly minted key is an access detail: returned only to a caller + # who may manage it (the key is still stored either way). + if newly_generated_key and ra.can("manage_access_details"): response_data["key"] = ( newly_generated_key if may_see_agent_keys(request) @@ -1358,10 +1480,13 @@ class RegenerateAgentKey(Resource): try: with db_session() as conn: agents_repo = AgentsRepository(conn) - # Owner-only: rotating a credential is destructive to live - # integrations, so this is intentionally stricter than - # update_agent (which also allows team editors). - existing_agent = agents_repo.get_any(agent_id, user) + # Rotating the key is an access detail: the owner, or an editor + # while ``editors_can_manage_access_details`` is on. + try: + ra = require(conn, "agent", agent_id, user, "manage_access_details") + except AccessDenied as denied: + return _denied(denied) + existing_agent = agents_repo.get_by_id(ra.resource_id) if not existing_agent: return make_response( jsonify( @@ -1389,7 +1514,7 @@ class RegenerateAgentKey(Resource): ) new_key = str(uuid.uuid4()) - updated = agents_repo.update(pg_agent_id, user, {"key": new_key}) + updated = agents_repo.update(pg_agent_id, ra.owner_id, {"key": new_key}) if not updated: return make_response( jsonify( @@ -1460,7 +1585,12 @@ class DeleteAgent(Resource): try: with db_session() as conn: agents_repo = AgentsRepository(conn) - agent = agents_repo.get_any(agent_id, user) + try: + ra = require(conn, "agent", agent_id, user, "delete") + except AccessDenied as denied: + return _denied(denied) + owner_id = ra.owner_id + agent = agents_repo.get_by_id(ra.resource_id) if not agent: return make_response( jsonify({"success": False, "message": "Agent not found"}), 404 @@ -1476,14 +1606,20 @@ class DeleteAgent(Resource): # that user's graph. if agent.get("agent_type") == "workflow" and workflow_id: try: - WorkflowsRepository(conn).delete(str(workflow_id), user) + WorkflowsRepository(conn).delete(str(workflow_id), owner_id) except Exception as wf_err: current_app.logger.warning( f"Workflow cleanup failed for agent {pg_agent_id}: {wf_err}" ) - agents_repo.delete(pg_agent_id, user) - # Strip pinned/shared entries for this agent from the owner's prefs. - UsersRepository(conn).remove_agent_from_all(user, pg_agent_id) + # Team grants go with the row (AFTER DELETE trigger, 0021); + # the switches table has no FK, so drop it explicitly. + agents_repo.delete(pg_agent_id, owner_id) + delete_settings(conn, "agent", pg_agent_id) + # Strip pinned/shared entries for this agent from the owner's + # (and the deleting editor's) prefs. + users_repo = UsersRepository(conn) + for uid in {owner_id, user}: + users_repo.remove_agent_from_all(uid, pg_agent_id) record_event( conn, "agent.deleted", @@ -1579,10 +1715,19 @@ class PinnedAgents(Resource): for agent in pinned_agents if _user_may_pin(conn, agent, user_id, shared_with_me) ] + # A pin reached only through a share link (or a template) + # has no grant: chat + pin, nothing more. + access_by_id = {} + for agent in pinned_agents: + ra = resolve(conn, "agent", str(agent["id"]), user_id) + access_by_id[str(agent["id"])] = ( + ra.payload() if ra is not None else dict(LINK_SHARED_ACCESS) + ) list_pinned_agents = [] for agent in pinned_agents: source_id = agent.get("source_id") + access = access_by_id[str(agent["id"])] list_pinned_agents.append( { "id": str(agent["id"]), @@ -1608,10 +1753,12 @@ class PinnedAgents(Resource): "last_used_at": agent.get("last_used_at", ""), "key": ( f"{agent['key'][:4]}...{agent['key'][-4:]}" - if agent.get("key") and agent.get("user_id") == user_id + if agent.get("key") + and "manage_access_details" in access["allowed_actions"] else "" ), "pinned": True, + **access, } ) except Exception as err: diff --git a/docsgpt/api/user/agents/sharing.py b/docsgpt/api/user/agents/sharing.py index f15a827d..508ab639 100644 --- a/docsgpt/api/user/agents/sharing.py +++ b/docsgpt/api/user/agents/sharing.py @@ -10,6 +10,7 @@ from sqlalchemy import text as _sql_text from docsgpt.api import api from docsgpt.core.settings import settings from docsgpt.api.user.base import resolve_tool_details +from docsgpt.api.user.resource_access import AccessDenied, require, resolve from docsgpt.storage.db.base_repository import looks_like_uuid from docsgpt.storage.db.repositories.agents import AgentsRepository from docsgpt.storage.db.repositories.users import UsersRepository @@ -21,12 +22,38 @@ agents_sharing_ns = Namespace( ) +# A caller who reached an agent only through its public link may chat with it +# and pin it; a team grant, when there is one, gives more. +LINK_SHARED_ACCESS = {"access": "viewer", "allowed_actions": ["pin", "use"]} + + +def _link_access(conn, agent_id: str, user_id) -> dict: + """The ``access`` payload for an agent the caller reached by share link. + + Args: + conn: Open database connection. + agent_id: The agent's id. + user_id: The caller, or None when anonymous. + + Returns: + The caller's own access (owner or a team grant) when they have one, + else viewer with ``pin`` and ``use``. + """ + if user_id: + ra = resolve(conn, "agent", agent_id, user_id) + if ra is not None: + return ra.payload() + return dict(LINK_SHARED_ACCESS) + + def _serialize_agent_basic(agent: dict) -> dict: - """Shape a PG agent row into the API response dict.""" + """Shape a PG agent row into the API response dict. + + The owner's user id is deliberately not included: a share link is public. + """ source_id = agent.get("source_id") return { "id": str(agent["id"]), - "user": agent.get("user_id", ""), "name": agent.get("name", ""), "image": ( generate_image_url( @@ -91,8 +118,8 @@ class SharedAgent(Resource): enriched_tools.append(detail.get("name", "")) data["tools"] = enriched_tools decoded_token = getattr(request, "decoded_token", None) - if decoded_token: - user_id = decoded_token.get("sub") + user_id = decoded_token.get("sub") if decoded_token else None + if user_id: owner_id = shared_agent.get("user_id") if user_id != owner_id: @@ -100,6 +127,8 @@ class SharedAgent(Resource): users_repo = UsersRepository(conn) users_repo.upsert(user_id) users_repo.add_shared(user_id, agent_id) + with db_readonly() as conn: + data.update(_link_access(conn, agent_id, user_id)) return make_response(jsonify(data), 200) except Exception as err: current_app.logger.error(f"Error retrieving shared agent: {err}") @@ -152,6 +181,10 @@ class SharedAgents(Resource): if isinstance(user_doc.get("agent_preferences"), dict) else [] ) + access_by_id = { + str(agent["id"]): _link_access(conn, str(agent["id"]), user_id) + for agent in shared_agents + } list_shared_agents = [] for agent in shared_agents: @@ -185,6 +218,7 @@ class SharedAgents(Resource): "shared": bool(agent.get("shared", False)), "shared_token": agent.get("shared_token", "") or "", "shared_metadata": agent.get("shared_metadata", {}) or {}, + **access_by_id[agent_id_str], } ) @@ -244,7 +278,15 @@ class ShareAgent(Resource): try: with db_session() as conn: repo = AgentsRepository(conn) - agent = repo.get_any(agent_id, user) + # The public link is an access detail; it is written as the owner. + try: + ra = require(conn, "agent", agent_id, user, "manage_access_details") + except AccessDenied as denied: + return make_response( + jsonify({"success": False, "message": denied.message}), denied.status + ) + owner_id = ra.owner_id + agent = repo.get_by_id(ra.resource_id) if not agent: return make_response( jsonify({"success": False, "message": "Agent not found"}), 404 @@ -258,7 +300,7 @@ class ShareAgent(Resource): } shared_token = secrets.token_urlsafe(32) repo.update( - str(agent["id"]), user, + str(agent["id"]), owner_id, { "shared": True, "shared_token": shared_token, @@ -267,7 +309,7 @@ class ShareAgent(Resource): ) else: repo.update( - str(agent["id"]), user, + str(agent["id"]), owner_id, { "shared": False, "shared_token": None, diff --git a/docsgpt/api/user/agents/webhooks.py b/docsgpt/api/user/agents/webhooks.py index 7c334a63..56244317 100644 --- a/docsgpt/api/user/agents/webhooks.py +++ b/docsgpt/api/user/agents/webhooks.py @@ -9,6 +9,7 @@ from sqlalchemy import text as sql_text from docsgpt.api import api from docsgpt.api.user.base import require_agent +from docsgpt.api.user.resource_access import AccessDenied, require from docsgpt.api.user.tasks import process_agent_webhook from docsgpt.core.settings import settings from docsgpt.storage.db.base_repository import looks_like_uuid @@ -71,7 +72,14 @@ class AgentWebhook(Resource): ) try: with db_readonly() as conn: - agent = AgentsRepository(conn).get_any(agent_id, user) + # The webhook URL is an access detail; it is minted as the owner. + try: + ra = require(conn, "agent", agent_id, user, "manage_access_details") + except AccessDenied as denied: + return make_response( + jsonify({"success": False, "message": denied.message}), denied.status + ) + agent = AgentsRepository(conn).get_by_id(ra.resource_id) if not agent: return make_response( jsonify({"success": False, "message": "Agent not found"}), 404 @@ -81,7 +89,7 @@ class AgentWebhook(Resource): webhook_token = secrets.token_urlsafe(32) with db_session() as conn: AgentsRepository(conn).update( - str(agent["id"]), user, + str(agent["id"]), ra.owner_id, {"incoming_webhook_token": webhook_token}, ) base_url = settings.API_URL.rstrip("/") diff --git a/docsgpt/api/user/analytics/routes.py b/docsgpt/api/user/analytics/routes.py index 3642ed3a..14270670 100644 --- a/docsgpt/api/user/analytics/routes.py +++ b/docsgpt/api/user/analytics/routes.py @@ -8,6 +8,7 @@ from flask_restx import fields, Namespace, Resource from sqlalchemy import Connection, text as _sql_text from docsgpt.api import api +from docsgpt.api.user.resource_access import AccessDenied, resolve from docsgpt.api.user.base import ( generate_date_range, generate_hourly_range, @@ -74,27 +75,38 @@ def _intervals_for_filter(filter_option, start_date, end_date): def _resolve_agent(conn, api_key_id, user_id): - """Owner-scoped agent lookup for analytics filters. + """Access-checked agent lookup for analytics filters. Returns ``(agent, api_key, agent_pg_id)``. ``agent`` is ``None`` when - the id doesn't resolve to one of the caller's agents — callers must + the id doesn't resolve to an agent the caller can see — callers must short-circuit with an empty result, not fall back to sentinel filter - values. ``api_key`` is ``None`` (never ``""``) for key-less agents: - draft agents store ``key = ''``, and an ``''`` filter would match the - ``''`` that writers like ``stack_logs`` stamp on every key-less - request — leaking rows across users. NULL matches nothing. Accepts - UUID or legacy Mongo ObjectId ids. + values. A visible agent needs ``view_logs`` (owner and editors; viewers + when the owner turns on ``viewers_can_see_logs``), and the caller then + sees exactly the owner's view of it. ``api_key`` is ``None`` (never + ``""``) for key-less agents: draft agents store ``key = ''``, and an + ``''`` filter would match the ``''`` that writers like ``stack_logs`` + stamp on every key-less request — leaking rows across users. NULL + matches nothing. Accepts UUID or legacy Mongo ObjectId ids. + + Raises: + AccessDenied: 403 when the agent is visible but ``view_logs`` isn't allowed. """ - agent = ( - AgentsRepository(conn).get_any(api_key_id, user_id) - if api_key_id - else None - ) + ra = resolve(conn, "agent", api_key_id, user_id) if api_key_id else None + if ra is None: + return None, None, None + if not ra.can("view_logs"): + raise AccessDenied(403, "Your access to this agent doesn't include its logs") + agent = AgentsRepository(conn).get_by_id(ra.resource_id) api_key = (agent or {}).get("key") or None agent_pg_id = str(agent["id"]) if agent else None return agent, api_key, agent_pg_id +def _denied(err: AccessDenied): + """The JSON error response for an :class:`AccessDenied`.""" + return make_response(jsonify({"success": False, "message": err.message}), err.status) + + def _trace_branch(name: str, sources_sql: str, scope: str) -> dict: """A ``get_user_logs`` branch listing stored traces of the given sources.""" return { @@ -238,6 +250,8 @@ class GetTraces(Resource): traces = RequestTracesRepository(conn).list_by_ref( field, value, user_id=user, agent_id=agent_pg_id ) + except AccessDenied as denied: + return _denied(denied) except Exception as err: current_app.logger.error(f"Error getting traces: {err}", exc_info=True) return make_response(jsonify({"success": False}), 400) @@ -342,6 +356,8 @@ class GetMessageAnalytics(Resource): daily_messages = {interval: 0 for interval in intervals} for row in rows: daily_messages[row._mapping["bucket"]] = int(row._mapping["count"]) + except AccessDenied as denied: + return _denied(denied) except Exception as err: current_app.logger.error( f"Error getting message analytics: {err}", exc_info=True @@ -466,6 +482,8 @@ class GetTokenAnalytics(Resource): if key not in series: series[key] = {interval: 0 for interval in intervals} series[key][bucket] = series[key].get(bucket, 0) + total + except AccessDenied as denied: + return _denied(denied) except Exception as err: current_app.logger.error( f"Error getting token analytics: {err}", exc_info=True @@ -592,6 +610,8 @@ class GetFeedbackAnalytics(Resource): "positive": int(row._mapping["positive"] or 0), "negative": int(row._mapping["negative"] or 0), } + except AccessDenied as denied: + return _denied(denied) except Exception as err: current_app.logger.error( f"Error getting feedback analytics: {err}", exc_info=True @@ -710,6 +730,8 @@ class GetToolAnalytics(Resource): } for row in rows ] + except AccessDenied as denied: + return _denied(denied) except Exception as err: current_app.logger.error( f"Error getting tool analytics: {err}", exc_info=True @@ -825,6 +847,8 @@ class GetScheduleAnalytics(Resource): "failed": int(row._mapping["failed"] or 0), "skipped": int(row._mapping["skipped"] or 0), } + except AccessDenied as denied: + return _denied(denied) except Exception as err: current_app.logger.error( f"Error getting schedule analytics: {err}", exc_info=True @@ -927,7 +951,8 @@ class GetUserLogs(Resource): 200, ) params: dict = { - "user_id": user, + # Agent-scoped logs are the owner's view of the agent. + "user_id": agent["user_id"] if agent else user, "limit": page_size + 1, "offset": (page - 1) * page_size, } @@ -1313,6 +1338,8 @@ class GetUserLogs(Resource): "Could not attach trace summaries to the logs page", exc_info=True, ) + except AccessDenied as denied: + return _denied(denied) except Exception as err: current_app.logger.error( f"Error getting user logs: {err}", exc_info=True diff --git a/docsgpt/api/user/prompts/routes.py b/docsgpt/api/user/prompts/routes.py index af5b9867..88428d8f 100644 --- a/docsgpt/api/user/prompts/routes.py +++ b/docsgpt/api/user/prompts/routes.py @@ -6,7 +6,14 @@ from flask_restx import fields, Namespace, Resource from docsgpt.api import api from docsgpt.api.pat.rules import filter_listing -from docsgpt.api.user.team_sharing import team_access_for, visible_with_access +from docsgpt.api.user.resource_access import ( + AccessDenied, + delete_settings, + payload_for, + require, + settings_many, +) +from docsgpt.api.user.team_sharing import visible_with_access from docsgpt.storage.db.repositories.prompts import PromptsRepository from docsgpt.prompts.composer import compose_preset, is_composed_preset from docsgpt.storage.db.session import db_readonly, db_session @@ -17,6 +24,16 @@ prompts_ns = Namespace( ) +def _denied(err: AccessDenied): + """JSON response for an :class:`AccessDenied` (403 or 404).""" + return make_response(jsonify({"success": False, "message": err.message}), err.status) + + +def _iso(value): + """ISO-8601 string for a timestamp (``expected_updated_at`` round-trips it).""" + return value.isoformat() if hasattr(value, "isoformat") else value + + @prompts_ns.route("/create_prompt") class CreatePrompt(Resource): create_prompt_model = api.model( @@ -67,26 +84,35 @@ class GetPrompts(Resource): team_shared = visible_with_access(conn, user, "prompt") shared_ids = [pid for pid in team_shared if pid not in owned_ids] shared_prompts = repo.list_by_ids(shared_ids) + switches = settings_many( + conn, "prompt", [*owned_ids, *(str(p["id"]) for p in shared_prompts)] + ) list_prompts = [ {"id": "default", "name": "default", "type": "public"}, {"id": "creative", "name": "creative", "type": "public"}, {"id": "strict", "name": "strict", "type": "public"}, ] for prompt in prompts: + pid = str(prompt["id"]) list_prompts.append( { - "id": str(prompt["id"]), + "id": pid, "name": prompt["name"], "type": "private", + "updated_at": _iso(prompt.get("updated_at")), + **payload_for("prompt", "owner", switches.get(pid)), } ) for prompt in shared_prompts: + pid = str(prompt["id"]) list_prompts.append( { - "id": str(prompt["id"]), + "id": pid, "name": prompt["name"], "type": "team", - "team_access": team_shared.get(str(prompt["id"])), + "team_access": team_shared.get(pid), + "updated_at": _iso(prompt.get("updated_at")), + **payload_for("prompt", team_shared.get(pid), switches.get(pid)), } ) except Exception as err: @@ -115,19 +141,28 @@ class GetSinglePrompt(Resource): jsonify({"content": compose_preset(prompt_id)}), 200 ) with db_readonly() as conn: - repo = PromptsRepository(conn) - prompt = repo.get_any(prompt_id, user) - if not prompt and team_access_for(conn, user, "prompt", prompt_id): - # Team fallback: ownerless fetch only after a grant check. - prompt = repo.get_for_rendering(prompt_id) + ra = require(conn, "prompt", prompt_id, user, "use") + prompt = PromptsRepository(conn).get_any(ra.resource_id, ra.owner_id) if not prompt: return make_response( jsonify({"success": False, "message": "Prompt not found"}), 404 ) + except AccessDenied as err: + return _denied(err) except Exception as err: current_app.logger.error(f"Error retrieving prompt: {err}", exc_info=True) return make_response(jsonify({"success": False}), 400) - return make_response(jsonify({"content": prompt["content"]}), 200) + return make_response( + jsonify( + { + "content": prompt["content"], + "name": prompt.get("name"), + "updated_at": _iso(prompt.get("updated_at")), + **ra.payload(), + } + ), + 200, + ) @prompts_ns.route("/delete_prompt") @@ -151,14 +186,12 @@ class DeletePrompt(Resource): return missing_fields try: with db_session() as conn: - repo = PromptsRepository(conn) - prompt = repo.get_any(data["id"], user) - if not prompt: - return make_response( - jsonify({"success": False, "message": "Prompt not found"}), - 404, - ) - repo.delete(str(prompt["id"]), user) + ra = require(conn, "prompt", data["id"], user, "delete") + # Grants go with the row (delete trigger); switches have no FK. + PromptsRepository(conn).delete(ra.resource_id, ra.owner_id) + delete_settings(conn, "prompt", ra.resource_id) + except AccessDenied as err: + return _denied(err) except Exception as err: current_app.logger.error(f"Error deleting prompt: {err}", exc_info=True) return make_response(jsonify({"success": False}), 400) @@ -192,43 +225,26 @@ class UpdatePrompt(Resource): return missing_fields try: with db_session() as conn: - repo = PromptsRepository(conn) - prompt = repo.get_any(data["id"], user) - if prompt: - repo.update(str(prompt["id"]), user, data["name"], data["content"]) - else: - # Team editor write path (viewer is read-only). - access = team_access_for(conn, user, "prompt", data["id"]) - if access == "editor": - result = repo.update_by_id( - data["id"], - data["name"], - data["content"], - expected_updated_at=data.get("expected_updated_at"), - ) - if result is None: - return make_response( - jsonify( - { - "success": False, - "message": "Prompt was modified by someone else", - "code": "stale_write", - } - ), - 409, - ) - elif access == "viewer": - return make_response( - jsonify( - {"success": False, "message": "Read-only: editor access required"} - ), - 403, - ) - else: - return make_response( - jsonify({"success": False, "message": "Prompt not found"}), - 404, - ) + ra = require(conn, "prompt", data["id"], user, "edit") + result = PromptsRepository(conn).update_by_id( + ra.resource_id, + data["name"], + data["content"], + expected_updated_at=data.get("expected_updated_at"), + ) + if result is None: + return make_response( + jsonify( + { + "success": False, + "message": "Prompt was modified by someone else", + "code": "stale_write", + } + ), + 409, + ) + except AccessDenied as err: + return _denied(err) except Exception as err: current_app.logger.error(f"Error updating prompt: {err}", exc_info=True) return make_response(jsonify({"success": False}), 400) diff --git a/docsgpt/api/user/resource_access.py b/docsgpt/api/user/resource_access.py new file mode 100644 index 00000000..574e9487 --- /dev/null +++ b/docsgpt/api/user/resource_access.py @@ -0,0 +1,334 @@ +"""The one access check for team-shared resources: roles, actions and switches. + +Every shareable resource (``agent``, ``source``, ``tool``, ``prompt``) has an +owner and may be shared to teams as ``viewer`` or ``editor``. What each role +may do is a fixed table of *actions* per resource type (``ACTIONS``). The +owner can adjust a few of those rows on one resource with *switches* +(``SWITCHES``), stored in ``resource_share_settings``. A switch only ever moves +one action between two roles; it never touches owner-only actions such as +``manage_settings``. + +Routes ask one question, ``require(conn, type, id, user, action)``, and get +back a :class:`ResourceAccess` (whose ``owner_id`` is the id to write as) or +an :class:`AccessDenied` carrying 404 (not visible) or 403 (visible, but the +role may not do this). List and get responses embed ``ResourceAccess.payload()`` +(``access`` + ``allowed_actions``) so the frontend never re-derives the rules. + +Access is resolved live on every call (grants JOIN ``team_members``), so a +revoked grant or membership denies on the next request. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Iterable, Optional + +from sqlalchemy import Connection, text + +from docsgpt.storage.db.base_repository import looks_like_uuid +from docsgpt.storage.db.repositories.agents import AgentsRepository +from docsgpt.storage.db.repositories.prompts import PromptsRepository +from docsgpt.storage.db.repositories.sources import SourcesRepository +from docsgpt.storage.db.repositories.team_resource_grants import ( + TeamResourceGrantsRepository, +) +from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository +from docsgpt.storage.db.repositories.user_tools import UserToolsRepository + +RESOURCE_TYPES = ("agent", "source", "tool", "prompt") + +# Weakest role that may perform each action by default. ``owner`` rows are +# owner-only unless a switch below moves them. +ACTIONS: dict[str, dict[str, str]] = { + "agent": { + "use": "viewer", # chat with it + "pin": "viewer", + "view": "editor", # open the edit page and read its full config + "edit": "editor", + "publish": "editor", + "edit_policy": "editor", # guardrails and quotas + "view_logs": "editor", + "manage_schedules": "editor", + "export": "editor", + "manage_access_details": "editor", # API key, webhook, public link + "move_folder": "owner", + "share": "owner", + "delete": "owner", + "manage_settings": "owner", + }, + "source": { + "use": "viewer", # browse files, chunks, graph, wiki; test retrieval; attach to agents + "view_config": "editor", + "edit": "editor", # chunks, files, wiki, config, sync, reingest, GraphRAG, convert + "reconnect": "owner", # change the connector account + "share": "owner", + "delete": "owner", + "manage_settings": "owner", + }, + "tool": { + "use": "viewer", # see it and run it inside the owner's shared agents + "use_in_own": "viewer", # add it to my own agents and chats + "edit": "editor", # name, action descriptions, parameters, approval + "edit_credentials": "editor", # secrets, URL, auth, reconnect OAuth (write-only) + "share": "owner", + "delete": "owner", + "manage_settings": "owner", + }, + "prompt": { + "use": "viewer", # read it and use it in my own agents + "duplicate": "editor", + "edit": "editor", + "share": "owner", + "delete": "owner", + "manage_settings": "owner", + }, +} + + +@dataclass(frozen=True) +class Switch: + """One owner switch: moves ``action`` to ``role_on`` or ``role_off``.""" + + key: str + default: bool + action: str + role_on: str + role_off: str + + +# Order is the order the share dialog lists them in. +SWITCHES: dict[str, tuple[Switch, ...]] = { + "agent": ( + Switch("editors_can_share", False, "share", "editor", "owner"), + Switch("editors_can_delete", False, "delete", "editor", "owner"), + Switch("editors_can_manage_access_details", True, "manage_access_details", "editor", "owner"), + Switch("viewers_can_see_logs", False, "view_logs", "viewer", "editor"), + ), + "source": ( + Switch("editors_can_share", False, "share", "editor", "owner"), + Switch("editors_can_delete", False, "delete", "editor", "owner"), + Switch("viewers_can_see_config", True, "view_config", "viewer", "editor"), + ), + "tool": ( + Switch("editors_can_change_credentials", True, "edit_credentials", "editor", "owner"), + Switch("editors_can_share", False, "share", "editor", "owner"), + Switch("viewers_can_use_in_agents", True, "use_in_own", "viewer", "editor"), + ), + "prompt": ( + Switch("editors_can_share", False, "share", "editor", "owner"), + Switch("viewers_can_duplicate", True, "duplicate", "viewer", "editor"), + ), +} + +_RANK = {"viewer": 1, "editor": 2, "owner": 3} + +_REPO_FOR_TYPE = { + "agent": AgentsRepository, + "source": SourcesRepository, + "prompt": PromptsRepository, + "tool": UserToolsRepository, +} + + +class AccessDenied(Exception): + """Raised by :func:`require`; ``status`` is 404 (not visible) or 403.""" + + def __init__(self, status: int, message: str) -> None: + super().__init__(message) + self.status = status + self.message = message + + +def default_settings(resource_type: str) -> dict[str, bool]: + """Every switch of ``resource_type`` at its default.""" + return {s.key: s.default for s in SWITCHES.get(resource_type, ())} + + +def _merge(resource_type: str, stored: Optional[dict]) -> dict[str, bool]: + merged = default_settings(resource_type) + for key, value in (stored or {}).items(): + if key in merged and isinstance(value, bool): + merged[key] = value + return merged + + +def role_table(resource_type: str, settings: Optional[dict]) -> dict[str, str]: + """``action -> weakest role`` for one resource, switches applied.""" + table = dict(ACTIONS[resource_type]) + merged = _merge(resource_type, settings) + for switch in SWITCHES.get(resource_type, ()): + table[switch.action] = switch.role_on if merged[switch.key] else switch.role_off + return table + + +def allowed_actions( + resource_type: str, access: Optional[str], settings: Optional[dict] +) -> set[str]: + """The actions ``access`` may perform on a resource with these switches.""" + if access not in _RANK or resource_type not in ACTIONS: + return set() + rank = _RANK[access] + return {action for action, role in role_table(resource_type, settings).items() if rank >= _RANK[role]} + + +def public_settings(resource_type: str, settings: Optional[dict]) -> list[dict]: + """The switches as ``[{key, value, default}]`` in display order.""" + merged = _merge(resource_type, settings) + return [ + {"key": s.key, "value": merged[s.key], "default": s.default} + for s in SWITCHES.get(resource_type, ()) + ] + + +def settings_for(conn: Connection, resource_type: str, resource_id: str) -> dict[str, bool]: + """The resource's switches, defaults filled in.""" + return settings_many(conn, resource_type, [resource_id])[resource_id] + + +def settings_many( + conn: Connection, resource_type: str, resource_ids: Iterable[str] +) -> dict[str, dict[str, bool]]: + """``resource_id -> switches`` for many resources in one query.""" + ids = [str(r) for r in resource_ids] + out = {rid: default_settings(resource_type) for rid in ids} + uuids = [rid for rid in ids if looks_like_uuid(rid)] + if not uuids: + return out + rows = conn.execute( + text( + """ + SELECT resource_id, settings FROM resource_share_settings + WHERE resource_type = :t AND resource_id = ANY(CAST(:ids AS uuid[])) + """ + ), + {"t": resource_type, "ids": uuids}, + ).fetchall() + for rid, stored in rows: + out[str(rid)] = _merge(resource_type, stored) + return out + + +def set_settings( + conn: Connection, resource_type: str, resource_id: str, changes: dict, updated_by: str +) -> dict[str, bool]: + """Merge ``changes`` into the resource's switches and return the result. + + Raises: + ValueError: an unknown key or a non-boolean value. + """ + known = default_settings(resource_type) + for key, value in changes.items(): + if key not in known: + raise ValueError(f"Unknown setting: {key}") + if not isinstance(value, bool): + raise ValueError(f"Setting {key} must be true or false") + merged = {**settings_for(conn, resource_type, resource_id), **changes} + conn.execute( + text( + """ + INSERT INTO resource_share_settings (resource_type, resource_id, settings, updated_by) + VALUES (:t, CAST(:id AS uuid), CAST(:s AS jsonb), :by) + ON CONFLICT (resource_type, resource_id) + DO UPDATE SET settings = EXCLUDED.settings, updated_by = EXCLUDED.updated_by, + updated_at = now() + """ + ), + {"t": resource_type, "id": resource_id, "s": json.dumps(merged), "by": updated_by}, + ) + return merged + + +def delete_settings(conn: Connection, resource_type: str, resource_id: str) -> None: + """Drop a deleted resource's switches (the table has no FK to cascade).""" + if looks_like_uuid(resource_id): + conn.execute( + text("DELETE FROM resource_share_settings WHERE resource_type = :t AND resource_id = CAST(:id AS uuid)"), + {"t": resource_type, "id": resource_id}, + ) + + +@dataclass(frozen=True) +class ResourceAccess: + """What one user may do on one resource.""" + + resource_type: str + resource_id: str + access: str # owner | editor | viewer + owner_id: str # the id to read and write the resource as + settings: dict = field(default_factory=dict) + actions: frozenset = frozenset() + + def can(self, action: str) -> bool: + return action in self.actions + + def payload(self) -> dict: + """The fields every API response embeds for this resource.""" + return {"access": self.access, "allowed_actions": sorted(self.actions)} + + +def build(resource_type: str, resource_id: str, access: str, owner_id: str, settings: dict) -> ResourceAccess: + """A :class:`ResourceAccess` from already-known parts (list endpoints).""" + merged = _merge(resource_type, settings) + return ResourceAccess( + resource_type=resource_type, + resource_id=str(resource_id), + access=access, + owner_id=owner_id, + settings=merged, + actions=frozenset(allowed_actions(resource_type, access, merged)), + ) + + +def payload_for(resource_type: str, access: Optional[str], settings: Optional[dict]) -> dict: + """``access`` + ``allowed_actions`` without an owner lookup (list endpoints).""" + return { + "access": access, + "allowed_actions": sorted(allowed_actions(resource_type, access, settings)), + } + + +def resolve( + conn: Connection, resource_type: str, resource_id: str, user_id: str +) -> Optional[ResourceAccess]: + """The caller's access to a resource, or None when they can't see it.""" + repo_cls = _REPO_FOR_TYPE.get(resource_type) + if repo_cls is None or not resource_id or not user_id: + return None + owned = repo_cls(conn).get_any(str(resource_id), user_id) + if owned is not None: + rid = str(owned.get("id") or resource_id) + return build(resource_type, rid, "owner", user_id, settings_for(conn, resource_type, rid)) + # Only canonical UUIDs can carry a grant; casting anything else would + # poison the transaction. + if not looks_like_uuid(str(resource_id)): + return None + level = TeamScopeRepository(conn).effective_access(user_id, resource_type, str(resource_id)) + if level is None: + return None + grants = TeamResourceGrantsRepository(conn).list_for_resource(resource_type, str(resource_id)) + if not grants: + return None + # Every grant row carries the same denormalised owner id. + owner_id = grants[0].get("owner_id") + return build(resource_type, str(resource_id), level, owner_id, settings_for(conn, resource_type, str(resource_id))) + + +def require( + conn: Connection, resource_type: str, resource_id: str, user_id: str, action: str +) -> ResourceAccess: + """Resolve and check one action. + + Raises: + KeyError: ``action`` is not an action of ``resource_type`` (a bug). + AccessDenied: 404 when the resource isn't visible, 403 when the + caller's role may not perform ``action``. + """ + if action not in ACTIONS.get(resource_type, {}): + raise KeyError(f"{resource_type} has no action {action!r}") + ra = resolve(conn, resource_type, resource_id, user_id) + if ra is None: + raise AccessDenied(404, f"{resource_type.capitalize()} not found") + if not ra.can(action): + raise AccessDenied(403, "Your access to this item doesn't allow that") + return ra diff --git a/docsgpt/api/user/scheduler_worker.py b/docsgpt/api/user/scheduler_worker.py index 3b2d8e62..fe581759 100644 --- a/docsgpt/api/user/scheduler_worker.py +++ b/docsgpt/api/user/scheduler_worker.py @@ -206,6 +206,33 @@ def _append_one_time_turn( return message +def _scheduler_still_allowed(schedule: Dict[str, Any], agent_config: Dict[str, Any]) -> bool: + """Whether the schedule's user may still run this agent. + + The run always executes as the agent's owner. A schedule stored under + someone else (set from chat on a shared agent) needs that user to still + see the agent — a live team grant, or a public link that is still on — + so revoking a grant stops their schedules on the next tick. + + Args: + schedule: The schedule row. + agent_config: The agent row (or the agentless ephemeral config). + + Returns: + True when the run may proceed. + """ + user_id = schedule.get("user_id") + agent_id = agent_config.get("id") + if not agent_id or not user_id or agent_config.get("user_id") == user_id: + return True + if agent_config.get("shared"): + return True + from docsgpt.api.user.resource_access import resolve + + with get_engine().connect() as conn: + return resolve(conn, "agent", str(agent_id), user_id) is not None + + def execute_scheduled_run_body(run_id: str, celery_task_id: Optional[str]) -> Dict[str, Any]: """Execute one scheduled run by id; returns a result dict for tracing.""" if not settings.POSTGRES_URI: @@ -256,6 +283,22 @@ def execute_scheduled_run_body(run_id: str, celery_task_id: Optional[str]) -> Di error="agent missing") return {"status": "failed", "reason": "agent missing"} + if not _scheduler_still_allowed(schedule, agent_config): + with engine.begin() as conn: + updated = ScheduleRunsRepository(conn).update( + run_id, + { + "status": "failed", + "finished_at": datetime.now(timezone.utc), + "error_type": "internal", + "error": "agent access revoked", + }, + ) + SchedulesRepository(conn).bump_failure_count(str(schedule["id"])) + _publish_run_event("schedule.run.failed", updated or run, schedule, + error="agent access revoked") + return {"status": "failed", "reason": "agent access revoked"} + with engine.begin() as conn: if not ScheduleRunsRepository(conn).mark_running(run_id, celery_task_id): return {"status": "skipped", "reason": "lost race to mark_running"} diff --git a/docsgpt/api/user/schedules/routes.py b/docsgpt/api/user/schedules/routes.py index b63e39a3..6b15db57 100644 --- a/docsgpt/api/user/schedules/routes.py +++ b/docsgpt/api/user/schedules/routes.py @@ -1,4 +1,11 @@ -"""Schedules REST API (owner-scoped via request.decoded_token).""" +"""Schedules REST API. + +A schedule on an agent belongs to the agent's owner: it is stored (and runs) +under the owner's ``user_id`` whoever creates it, and managing it needs +``manage_schedules`` on the agent (owner and team editors). A schedule the +caller made themselves (e.g. via the chat scheduler tool on a shared agent) +stays theirs to manage. +""" from __future__ import annotations @@ -20,6 +27,7 @@ from docsgpt.agents.scheduler_utils import ( resolve_timezone, ) from docsgpt.api import api +from docsgpt.api.user.resource_access import AccessDenied, require from docsgpt.core.settings import settings from docsgpt.storage.db.base_repository import looks_like_uuid from docsgpt.storage.db.repositories.agents import AgentsRepository @@ -103,11 +111,70 @@ def _format_run(row: Dict[str, Any]) -> Dict[str, Any]: return out -def _agent_owned(agent_id: str, user_id: str) -> Optional[Dict[str, Any]]: +def _agent_for_schedules(agent_id: str, user_id: str) -> tuple[Dict[str, Any], str]: + """The agent row and the id its schedules live under. + + Args: + agent_id: Agent id from the URL. + user_id: The caller. + + Returns: + ``(agent, owner_id)``. + + Raises: + AccessDenied: 404 when the agent isn't visible, 403 without + ``manage_schedules``. + """ if not looks_like_uuid(str(agent_id)): - return None + raise AccessDenied(404, "agent not found") with db_readonly() as conn: - return AgentsRepository(conn).get_any(agent_id, user_id) + try: + ra = require(conn, "agent", agent_id, user_id, "manage_schedules") + except AccessDenied as denied: + if denied.status == 404: + raise AccessDenied(404, "agent not found") + raise + agent = AgentsRepository(conn).get_by_id(ra.resource_id) + if agent is None: + raise AccessDenied(404, "agent not found") + return agent, ra.owner_id + + +def _schedule_for(conn, schedule_id: str, user_id: str) -> tuple[Dict[str, Any], str]: + """Fetch a schedule the caller may manage, and the id to act as. + + The caller's own schedule is always theirs. Otherwise it must be a + schedule of an agent they hold ``manage_schedules`` on, stored under + that agent's owner (another member's private schedule stays hidden). + + Args: + conn: Open database connection. + schedule_id: Schedule UUID. + user_id: The caller. + + Returns: + ``(schedule, acting_user_id)``. + + Raises: + AccessDenied: 404 when not visible, 403 when the role can't manage it. + """ + row = SchedulesRepository(conn).get_internal(schedule_id) + if row is None: + raise AccessDenied(404, "schedule not found") + if row.get("user_id") == user_id: + return row, user_id + agent_id = row.get("agent_id") + if not agent_id: + raise AccessDenied(404, "schedule not found") + try: + ra = require(conn, "agent", str(agent_id), user_id, "manage_schedules") + except AccessDenied as denied: + if denied.status == 404: + raise AccessDenied(404, "schedule not found") + raise + if row.get("user_id") != ra.owner_id: + raise AccessDenied(404, "schedule not found") + return row, ra.owner_id def _user_id() -> Optional[str]: @@ -150,13 +217,14 @@ class AgentSchedules(Resource): user_id = _user_id() if not user_id: return _err("unauthorized", 401) - agent = _agent_owned(agent_id, user_id) - if agent is None: - return _err("agent not found", 404) + try: + agent, owner_id = _agent_for_schedules(agent_id, user_id) + except AccessDenied as denied: + return _err(denied.message, denied.status) try: with db_readonly() as conn: rows = SchedulesRepository(conn).list_for_agent( - str(agent["id"]), user_id, + str(agent["id"]), owner_id, ) except Exception as exc: current_app.logger.error("list schedules failed: %s", exc, exc_info=True) @@ -195,9 +263,10 @@ class AgentSchedules(Resource): user_id = _user_id() if not user_id: return _err("unauthorized", 401) - agent = _agent_owned(agent_id, user_id) - if agent is None: - return _err("agent not found", 404) + try: + agent, owner_id = _agent_for_schedules(agent_id, user_id) + except AccessDenied as denied: + return _err(denied.message, denied.status) data = request.get_json(silent=True) or {} instruction = (data.get("instruction") or "").strip() tz_name = (data.get("timezone") or "UTC").strip() or "UTC" @@ -219,7 +288,7 @@ class AgentSchedules(Resource): except (TypeError, ValueError): return _err("token_budget must be a non-negative integer") with db_readonly() as conn: - count = SchedulesRepository(conn).count_active_for_user(user_id) + count = SchedulesRepository(conn).count_active_for_user(owner_id) if ( settings.SCHEDULE_MAX_PER_USER > 0 and count >= settings.SCHEDULE_MAX_PER_USER @@ -240,7 +309,7 @@ class AgentSchedules(Resource): try: with db_session() as conn: created = SchedulesRepository(conn).create( - user_id=user_id, + user_id=owner_id, agent_id=str(agent["id"]), trigger_type="once", instruction=instruction, @@ -295,7 +364,7 @@ class AgentSchedules(Resource): try: with db_session() as conn: created = SchedulesRepository(conn).create( - user_id=user_id, + user_id=owner_id, agent_id=str(agent["id"]), trigger_type="recurring", instruction=instruction, @@ -337,9 +406,10 @@ class AgentScheduleStats(Resource): user_id = _user_id() if not user_id: return _err("unauthorized", 401) - agent = _agent_owned(agent_id, user_id) - if agent is None: - return _err("agent not found", 404) + try: + agent, owner_id = _agent_for_schedules(agent_id, user_id) + except AccessDenied as denied: + return _err(denied.message, denied.status) try: days = max(1, min(int(request.args.get("days", 30)), 365)) except (TypeError, ValueError): @@ -347,7 +417,7 @@ class AgentScheduleStats(Resource): try: with db_readonly() as conn: stats = ScheduleRunsRepository(conn).stats_for_agent( - str(agent["id"]), user_id, days=days, + str(agent["id"]), owner_id, days=days, ) except Exception as exc: current_app.logger.error( @@ -377,9 +447,10 @@ class ScheduleResource(Resource): if not looks_like_uuid(schedule_id): return _err("invalid schedule id", 400) with db_readonly() as conn: - row = SchedulesRepository(conn).get(schedule_id, user_id) - if row is None: - return _err("schedule not found", 404) + try: + row, _acting = _schedule_for(conn, schedule_id, user_id) + except AccessDenied as denied: + return _err(denied.message, denied.status) return _ok({"schedule": _format_schedule(row)}) @api.doc(description="Edit a schedule's editable fields.") @@ -439,9 +510,10 @@ class ScheduleResource(Resource): fields_in["end_at"] = None # Recompute next_run_at when cron/tz changes. with db_session() as conn: - existing = SchedulesRepository(conn).get(schedule_id, user_id) - if existing is None: - return _err("schedule not found", 404) + try: + existing, acting = _schedule_for(conn, schedule_id, user_id) + except AccessDenied as denied: + return _err(denied.message, denied.status) if ( ("cron" in fields_in or "timezone" in fields_in) and existing.get("trigger_type") == "recurring" @@ -467,7 +539,7 @@ class ScheduleResource(Resource): except ScheduleValidationError as exc: return _err(str(exc)) updated = SchedulesRepository(conn).update( - schedule_id, user_id, fields_in, + schedule_id, acting, fields_in, ) return _ok({"schedule": _format_schedule(updated or {})}) @@ -484,9 +556,10 @@ class ScheduleResource(Resource): if action not in {"pause", "resume"}: return _err("action must be 'pause' or 'resume'") with db_session() as conn: - existing = SchedulesRepository(conn).get(schedule_id, user_id) - if existing is None: - return _err("schedule not found", 404) + try: + existing, acting = _schedule_for(conn, schedule_id, user_id) + except AccessDenied as denied: + return _err(denied.message, denied.status) if existing.get("status") in ("cancelled", "completed"): return _err("schedule is terminal", 409) if action == "pause": @@ -538,13 +611,13 @@ class ScheduleResource(Resource): ) fields_in["next_run_at"] = run_at_dt updated = SchedulesRepository(conn).update( - schedule_id, user_id, fields_in, + schedule_id, acting, fields_in, ) if action == "resume": SchedulesRepository(conn).reset_failure_count(schedule_id) if action == "resume" and updated: _publish_schedule_event( - user_id, "schedule.resumed", schedule_id, status="active", + acting, "schedule.resumed", schedule_id, status="active", ) return _ok({"schedule": _format_schedule(updated or {})}) @@ -557,11 +630,15 @@ class ScheduleResource(Resource): if not looks_like_uuid(schedule_id): return _err("invalid schedule id", 400) with db_session() as conn: - ok = SchedulesRepository(conn).delete(schedule_id, user_id) + try: + _row, acting = _schedule_for(conn, schedule_id, user_id) + except AccessDenied as denied: + return _err(denied.message, denied.status) + ok = SchedulesRepository(conn).delete(schedule_id, acting) if not ok: return _err("schedule not found", 404) _publish_schedule_event( - user_id, "schedule.cancelled", schedule_id, status="cancelled", + acting, "schedule.cancelled", schedule_id, status="cancelled", ) return _ok({"success": True}) @@ -579,8 +656,12 @@ class ScheduleRunNow(Resource): # FOR UPDATE serializes concurrent Run-Now POSTs (timestamp-unique # scheduled_for values would otherwise sneak past the unique index). with db_session() as conn: + try: + _row, acting = _schedule_for(conn, schedule_id, user_id) + except AccessDenied as denied: + return _err(denied.message, denied.status) schedule = SchedulesRepository(conn).get_for_update( - schedule_id, user_id, + schedule_id, acting, ) if schedule is None: return _err("schedule not found", 404) @@ -590,9 +671,10 @@ class ScheduleRunNow(Resource): return _err("a run is already in flight", 409) scheduled_for = datetime.now(timezone.utc) agent_id_raw = schedule.get("agent_id") + # The run belongs to (and executes as) the schedule's owner. run = ScheduleRunsRepository(conn).record_pending( schedule_id, - user_id, + acting, str(agent_id_raw) if agent_id_raw else None, scheduled_for, trigger_source="manual", @@ -633,11 +715,12 @@ class ScheduleRunList(Resource): except (TypeError, ValueError): offset = 0 with db_readonly() as conn: - schedule = SchedulesRepository(conn).get(schedule_id, user_id) - if schedule is None: - return _err("schedule not found", 404) + try: + _schedule, acting = _schedule_for(conn, schedule_id, user_id) + except AccessDenied as denied: + return _err(denied.message, denied.status) rows = ScheduleRunsRepository(conn).list_runs( - schedule_id, user_id, limit=limit, offset=offset, + schedule_id, acting, limit=limit, offset=offset, ) return _ok( { @@ -659,10 +742,11 @@ class ScheduleRunDetail(Resource): if not looks_like_uuid(schedule_id) or not looks_like_uuid(run_id): return _err("invalid id", 400) with db_readonly() as conn: - schedule = SchedulesRepository(conn).get(schedule_id, user_id) - if schedule is None: - return _err("schedule not found", 404) - run = ScheduleRunsRepository(conn).get(run_id, user_id) + try: + schedule, acting = _schedule_for(conn, schedule_id, user_id) + except AccessDenied as denied: + return _err(denied.message, denied.status) + run = ScheduleRunsRepository(conn).get(run_id, acting) if run is None or str(run.get("schedule_id")) != str( schedule["id"] ): diff --git a/docsgpt/api/user/sources/access.py b/docsgpt/api/user/sources/access.py new file mode 100644 index 00000000..180fd951 --- /dev/null +++ b/docsgpt/api/user/sources/access.py @@ -0,0 +1,53 @@ +"""Role checks shared by the source routes (sources, chunks, upload, search). + +Thin wrappers over :mod:`docsgpt.api.user.resource_access` so every source +endpoint resolves the row the same way and answers denials with the same +JSON shape: 404 when the caller can't see the source, 403 when they can but +their role may not perform the action. +""" + +from __future__ import annotations + +from typing import Optional + +from flask import jsonify, make_response +from sqlalchemy import Connection + +from docsgpt.api.user.resource_access import AccessDenied, ResourceAccess, require +from docsgpt.storage.db.repositories.sources import SourcesRepository + + +def load_source( + conn: Connection, source_id: Optional[str], user: str, action: str +) -> tuple[dict, ResourceAccess]: + """Check ``action`` on a source and return its row with the caller's access. + + The row is fetched by the canonical id only after the check passes, so a + team member gets the owner's row without ownership scoping. + + Args: + conn: Open database connection. + source_id: Source id from the request (UUID or legacy id). + user: The caller's ``sub``. + action: A ``source`` action from ``resource_access.ACTIONS``. + + Returns: + tuple: ``(source_row, ResourceAccess)``; write as ``ra.owner_id``. + + Raises: + AccessDenied: 404 when not visible, 403 when the role can't do it. + """ + if not source_id: + raise AccessDenied(404, "Source not found") + ra = require(conn, "source", str(source_id), user, action) + doc = SourcesRepository(conn).get_by_id(ra.resource_id) + if doc is None: + raise AccessDenied(404, "Source not found") + return doc, ra + + +def denied_response(err: AccessDenied): + """The JSON error response for an :class:`AccessDenied`.""" + return make_response( + jsonify({"success": False, "message": err.message}), err.status + ) diff --git a/docsgpt/api/user/sources/chunks.py b/docsgpt/api/user/sources/chunks.py index 240ed571..d0185384 100644 --- a/docsgpt/api/user/sources/chunks.py +++ b/docsgpt/api/user/sources/chunks.py @@ -7,8 +7,8 @@ from flask_restx import fields, Namespace, Resource from docsgpt.api import api from docsgpt.api.user.base import get_vector_store -from docsgpt.api.user.team_sharing import can_access, effective_write_owner -from docsgpt.storage.db.repositories.sources import SourcesRepository +from docsgpt.api.user.resource_access import AccessDenied +from docsgpt.api.user.sources.access import denied_response, load_source from docsgpt.storage.db.session import db_readonly from docsgpt.utils import check_required_fields, num_tokens_from_string from docsgpt.vectorstore.base import InvalidChunkMetadataError @@ -18,38 +18,27 @@ sources_chunks_ns = Namespace( ) -def _resolve_source(doc_id: str, user: str): - """Resolve a source (UUID or legacy ObjectId) the caller may READ. +def _resolve_source(doc_id: str, user: str, action: str = "use") -> dict: + """Resolve a source (UUID or legacy ObjectId) the caller may ``action`` on. - Read access = owner or any team grant (viewer/editor). Returns the row - dict (with PG UUID in ``id``) or ``None`` if missing or not visible. + ``use`` (browse chunks) is open to every role; ``edit`` (add / delete / + update chunks) needs owner or team editor. The vector partition is keyed + by source id, so a team editor's write needs no owner id. + + Args: + doc_id: Source id from the request. + user: The caller's ``sub``. + action: ``use`` for reads, ``edit`` for chunk writes. + + Returns: + dict: The source row (PG UUID in ``id``). + + Raises: + AccessDenied: 404 when not visible, 403 when the role can't do it. """ with db_readonly() as conn: - doc = SourcesRepository(conn).get_any(doc_id, user) - if doc is not None: - return doc - if not can_access(conn, "source", doc_id, user): - return None - return SourcesRepository(conn).get_by_id(doc_id) - - -def _resolve_source_for_write(doc_id: str, user: str): - """Resolve a source the caller may WRITE chunks on. - - Returns the row dict when ``user`` owns the source, or when they hold a - team ``editor`` grant (adding/removing/editing documents is editor-allowed - — the vector partition is keyed by source_id, owner-agnostic). Returns - ``None`` for viewer-only / no access. Source deletion stays owner-only and - is handled elsewhere. - """ - with db_readonly() as conn: - doc = SourcesRepository(conn).get_any(doc_id, user) - if doc is not None: - return doc - owner = effective_write_owner(conn, "source", doc_id, user) - if not owner: - return None - return SourcesRepository(conn).get_any(doc_id, owner) + doc, _ra = load_source(conn, doc_id, user, action) + return doc def _remap_graph_chunk(doc: dict, old_chunk_id: str, new_chunk_id: str) -> None: @@ -193,13 +182,11 @@ class GetChunks(Resource): return make_response(jsonify({"error": "Invalid doc_id"}), 400) try: doc = _resolve_source(doc_id, user) + except AccessDenied as err: + return denied_response(err) except Exception as e: current_app.logger.error(f"Error resolving source: {e}", exc_info=True) return make_response(jsonify({"error": "Invalid doc_id"}), 400) - if not doc: - return make_response( - jsonify({"error": "Document not found or access denied"}), 404 - ) resolved_id = str(doc["id"]) try: store = get_vector_store(resolved_id) @@ -278,12 +265,12 @@ class AddChunk(Resource): metadata["token_count"] = token_count try: - doc = _resolve_source_for_write(doc_id, user) + doc = _resolve_source(doc_id, user, "edit") + except AccessDenied as err: + return denied_response(err) except Exception as e: current_app.logger.error(f"Error resolving source: {e}", exc_info=True) return make_response(jsonify({"error": "Invalid doc_id"}), 400) - if not doc: - return make_response(jsonify({"error": "Source not accessible"}), 403) try: store = get_vector_store(str(doc["id"])) chunk_id = store.add_chunk(text, metadata) @@ -311,12 +298,12 @@ class DeleteChunk(Resource): chunk_id = request.args.get("chunk_id") try: - doc = _resolve_source_for_write(doc_id, user) + doc = _resolve_source(doc_id, user, "edit") + except AccessDenied as err: + return denied_response(err) except Exception as e: current_app.logger.error(f"Error resolving source: {e}", exc_info=True) return make_response(jsonify({"error": "Invalid doc_id"}), 400) - if not doc: - return make_response(jsonify({"error": "Source not accessible"}), 403) try: store = get_vector_store(str(doc["id"])) deleted = store.delete_chunk(chunk_id) @@ -378,12 +365,12 @@ class UpdateChunk(Resource): metadata = {} metadata["token_count"] = token_count try: - doc = _resolve_source_for_write(doc_id, user) + doc = _resolve_source(doc_id, user, "edit") + except AccessDenied as err: + return denied_response(err) except Exception as e: current_app.logger.error(f"Error resolving source: {e}", exc_info=True) return make_response(jsonify({"error": "Invalid doc_id"}), 400) - if not doc: - return make_response(jsonify({"error": "Source not accessible"}), 403) try: store = get_vector_store(str(doc["id"])) diff --git a/docsgpt/api/user/sources/retrieval_test.py b/docsgpt/api/user/sources/retrieval_test.py index b26ca043..e1c0f812 100644 --- a/docsgpt/api/user/sources/retrieval_test.py +++ b/docsgpt/api/user/sources/retrieval_test.py @@ -21,7 +21,8 @@ from flask_restx import fields, Namespace, Resource from pydantic import ValidationError from docsgpt.api import api -from docsgpt.api.user.sources.routes import _resolve_readable_source +from docsgpt.api.user.resource_access import AccessDenied +from docsgpt.api.user.sources.access import denied_response, load_source from docsgpt.core.model_utils import get_default_model_id from docsgpt.retriever.dispatcher import Dispatcher from docsgpt.retriever.retriever_creator import RetrieverCreator @@ -102,21 +103,16 @@ class SourceSearch(Resource): # Read access = owner or any team grant (viewer included), matching # the other source read endpoints (wiki pages, graph). with db_readonly() as conn: - doc = _resolve_readable_source(conn, source_id, user) + doc, _ra = load_source(conn, source_id, user, "use") + except AccessDenied as err: + return denied_response(err) except Exception as e: - # An unresolvable id yields None (→ 404); reaching here means the + # An unresolvable id is AccessDenied (404); reaching here means the # lookup itself failed, which is ours, not the caller's. logger.error(f"Error resolving source: {e}", exc_info=True) return make_response( jsonify({"success": False, "message": "Could not resolve source"}), 500 ) - if not doc: - return make_response( - jsonify( - {"success": False, "message": "Source not found or access denied"} - ), - 404, - ) resolved_id = str(doc["id"]) # A supplied config is validated exactly as strictly as a saved one (D7 diff --git a/docsgpt/api/user/sources/routes.py b/docsgpt/api/user/sources/routes.py index 9043e566..abbb0492 100644 --- a/docsgpt/api/user/sources/routes.py +++ b/docsgpt/api/user/sources/routes.py @@ -3,6 +3,7 @@ import json import math import uuid +from typing import Optional from flask import current_app, jsonify, make_response, redirect, request from flask_restx import fields, Namespace, Resource @@ -19,11 +20,14 @@ from docsgpt.api.user.tasks import ( reingest_source_task, sync_source, ) -from docsgpt.api.user.team_sharing import ( - can_access, - effective_write_owner, - visible_with_access, +from docsgpt.api.user.resource_access import ( + AccessDenied, + delete_settings, + payload_for, + settings_many, ) +from docsgpt.api.user.sources.access import denied_response, load_source +from docsgpt.api.user.team_sharing import visible_with_access from docsgpt.core.settings import settings from docsgpt.graphrag import graphrag_available from docsgpt.parser.remote.remote_creator import normalize_remote_data @@ -70,6 +74,28 @@ def _get_provider_from_remote_data(remote_data): return None +def _with_access(entry: dict, access: Optional[str], switches: Optional[dict]) -> dict: + """Add ``access`` + ``allowed_actions`` to a listed source row. + + The source's behaviour ``config`` is dropped when the caller's role may + not ``view_config`` (a viewer when the owner turned + ``viewers_can_see_config`` off). + + Args: + entry: The row as the list endpoint builds it. + access: ``owner`` / ``editor`` / ``viewer``. + switches: The source's owner switches (``settings_many`` output). + + Returns: + dict: ``entry`` with the access payload merged in. + """ + payload = payload_for("source", access, switches) + if "view_config" not in payload["allowed_actions"]: + entry.pop("config", None) + entry.update(payload) + return entry + + @sources_ns.route("/sources") class CombinedJson(Resource): @api.doc(description="Provide JSON file with combined available indexes") @@ -93,6 +119,7 @@ class CombinedJson(Resource): team_shared = visible_with_access(conn, user, "source") shared_ids = [sid for sid in team_shared if sid not in owned_ids] shared_sources = repo.list_by_ids(shared_ids) + switches = settings_many(conn, "source", [*owned_ids, *shared_ids]) # list_for_user sorts by created_at DESC; legacy shape sorted by # "date" DESC. Both are monotonic on creation so the ordering is # equivalent for dev; re-sort defensively. @@ -103,7 +130,7 @@ class CombinedJson(Resource): def _source_entry(index, *, ownership="user", team_access=None): provider = _get_provider_from_remote_data(index.get("remote_data")) - return { + entry = { "id": str(index["id"]), "name": index.get("name"), "date": index.get("date"), @@ -121,6 +148,11 @@ class CombinedJson(Resource): "ownership": ownership, "team_access": team_access, } + return _with_access( + entry, + "owner" if ownership == "user" else team_access, + switches.get(str(index["id"])), + ) for index in indexes: data.append(_source_entry(index)) @@ -178,6 +210,9 @@ class PaginatedSources(Resource): sort_order=sort_order, extra_ids=extra_ids, ) + switches = settings_many( + conn, "source", [str(doc["id"]) for doc in window] + ) paginated_docs = [] for doc in window: @@ -185,32 +220,37 @@ class PaginatedSources(Resource): # Owner vs team-shared: a row in the window is the caller's own # when its user_id matches; otherwise it arrived via extra_ids. owned = str(doc.get("user_id")) == str(user) + entry = { + "id": str(doc["id"]), + "name": doc.get("name", ""), + "date": doc.get("date", ""), + "model": settings.EMBEDDINGS_NAME, + "location": "local", + "tokens": doc.get("tokens", ""), + "retriever": doc.get("retriever", "classic"), + "syncFrequency": doc.get("sync_frequency", ""), + "provider": provider, + "isNested": bool(doc.get("directory_structure")), + "type": doc.get("type", "file"), + # Lenient read (D7): always emit a fully-defaulted + # config so the edit modal can pre-fill, even for a + # legacy {} row. + "config": SourceConfig.parse( + doc.get("config") + ).model_dump(), + # Derived in SourcesRepository.list_for_user. + "ingestStatus": doc.get("ingest_status"), + "ownership": "user" if owned else "team", + "team_access": ( + None if owned else team_shared.get(str(doc["id"])) + ), + } paginated_docs.append( - { - "id": str(doc["id"]), - "name": doc.get("name", ""), - "date": doc.get("date", ""), - "model": settings.EMBEDDINGS_NAME, - "location": "local", - "tokens": doc.get("tokens", ""), - "retriever": doc.get("retriever", "classic"), - "syncFrequency": doc.get("sync_frequency", ""), - "provider": provider, - "isNested": bool(doc.get("directory_structure")), - "type": doc.get("type", "file"), - # Lenient read (D7): always emit a fully-defaulted - # config so the edit modal can pre-fill, even for a - # legacy {} row. - "config": SourceConfig.parse( - doc.get("config") - ).model_dump(), - # Derived in SourcesRepository.list_for_user. - "ingestStatus": doc.get("ingest_status"), - "ownership": "user" if owned else "team", - "team_access": ( - None if owned else team_shared.get(str(doc["id"])) - ), - } + _with_access( + entry, + "owner" if owned else team_shared.get(str(doc["id"])), + switches.get(str(doc["id"])), + ) ) response = { "total": total_documents, @@ -242,14 +282,17 @@ class DeleteOldIndexes(Resource): return make_response( jsonify({"success": False, "message": "Missing required fields"}), 400 ) + # Owner-only unless the owner turned ``editors_can_delete`` on; the + # row is deleted as the owner either way. try: with db_readonly() as conn: - doc = SourcesRepository(conn).get_any(source_id, user) + doc, ra = load_source(conn, source_id, user, "delete") + except AccessDenied as err: + return denied_response(err) except Exception as err: current_app.logger.error(f"Error looking up source: {err}", exc_info=True) return make_response(jsonify({"success": False}), 400) - if not doc: - return make_response(jsonify({"status": "not found"}), 404) + owner = ra.owner_id storage = StorageCreator.get_storage() resolved_id = str(doc["id"]) @@ -283,13 +326,17 @@ class DeleteOldIndexes(Resource): return make_response(jsonify({"success": False}), 400) try: with db_session() as conn: - SourcesRepository(conn).delete(resolved_id, user) + # The AFTER DELETE trigger drops the source's team grants; the + # owner switches have no FK, so clear them here. + SourcesRepository(conn).delete(resolved_id, owner) + delete_settings(conn, "source", resolved_id) record_event( conn, "source.deleted", actor=user, source_id=resolved_id, name=doc.get("name"), + owner=owner if owner != user else None, ) except Exception as err: current_app.logger.error( @@ -342,21 +389,13 @@ class ManageSync(Resource): ) try: with db_session() as conn: - repo = SourcesRepository(conn) - doc = repo.get_any(source_id, user) - if doc is not None: - repo.update(str(doc["id"]), user, {"sync_frequency": sync_frequency}) - else: - # Team editor write path (sync_frequency is metadata, no - # ingestion side effects). Reingest/sync triggers stay - # owner-only pending a cost/side-effect decision. - owner = effective_write_owner(conn, "source", source_id, user) - if not owner: - return make_response( - jsonify({"success": False, "message": "Source not found"}), - 404, - ) - repo.update(source_id, owner, {"sync_frequency": sync_frequency}) + # Owner or team editor; the write lands as the owner. + doc, ra = load_source(conn, source_id, user, "edit") + SourcesRepository(conn).update( + str(doc["id"]), ra.owner_id, {"sync_frequency": sync_frequency} + ) + except AccessDenied as err: + return denied_response(err) except Exception as err: current_app.logger.error( f"Error updating sync frequency: {err}", exc_info=True @@ -391,21 +430,15 @@ class SyncSource(Resource): # source_id (owner-agnostic), so dispatching as the owner is correct. try: with db_readonly() as conn: - doc = SourcesRepository(conn).get_any(source_id, user) - owner = user - if doc is None: - owner = effective_write_owner(conn, "source", source_id, user) - if owner: - doc = SourcesRepository(conn).get_any(source_id, owner) + doc, ra = load_source(conn, source_id, user, "edit") + except AccessDenied as err: + return denied_response(err) except Exception as err: current_app.logger.error(f"Error looking up source: {err}", exc_info=True) return make_response( jsonify({"success": False, "message": "Invalid source ID"}), 400 ) - if not doc: - return make_response( - jsonify({"success": False, "message": "Source not accessible"}), 403 - ) + owner = ra.owner_id source_type = doc.get("type", "") if source_type and source_type.startswith("connector"): return make_response( @@ -469,12 +502,9 @@ class ReingestSource(Resource): # (owner-agnostic), so dispatching as the owner is correct. try: with db_readonly() as conn: - doc = SourcesRepository(conn).get_any(source_id, user) - owner = user - if doc is None: - owner = effective_write_owner(conn, "source", source_id, user) - if owner: - doc = SourcesRepository(conn).get_any(source_id, owner) + doc, ra = load_source(conn, source_id, user, "edit") + except AccessDenied as err: + return denied_response(err) except Exception as err: current_app.logger.error( f"Error looking up source: {err}", exc_info=True @@ -482,10 +512,7 @@ class ReingestSource(Resource): return make_response( jsonify({"success": False, "message": "Invalid source ID"}), 400 ) - if not doc: - return make_response( - jsonify({"success": False, "message": "Source not accessible"}), 403 - ) + owner = ra.owner_id resolved_source_id = str(doc["id"]) # Drop the stale chunk-progress row so the sources list stops # deriving a 'failed' status; reingest never rewrites it itself. @@ -548,11 +575,7 @@ class DirectoryStructure(Resource): return make_response(jsonify({"error": "Document ID is required"}), 400) try: with db_readonly() as conn: - doc = _resolve_readable_source(conn, doc_id, user) - if not doc: - return make_response( - jsonify({"error": "Document not found or access denied"}), 404 - ) + doc, _ra = load_source(conn, doc_id, user, "use") directory_structure = doc.get("directory_structure", {}) base_path = doc.get("file_path", "") @@ -579,6 +602,8 @@ class DirectoryStructure(Resource): ), 200, ) + except AccessDenied as err: + return denied_response(err) except Exception as e: current_app.logger.error( f"Error retrieving directory structure: {e}", exc_info=True @@ -636,23 +661,9 @@ class SourceConfigResource(Resource): try: with db_session() as conn: repo = SourcesRepository(conn) - # Resolve the owner to write AS: ``user`` when they own the - # source, the real owner when ``user`` holds a team ``editor`` - # grant. A viewer / no-access resolves to None → 403. - owner = effective_write_owner(conn, "source", source_id, user) - if not owner: - return make_response( - jsonify( - {"success": False, "message": "Source not accessible"} - ), - 403, - ) - doc = repo.get_any(source_id, owner) - if doc is None: - return make_response( - jsonify({"success": False, "message": "Source not found"}), - 404, - ) + # Owner or team editor; the write lands as the owner. + doc, ra = load_source(conn, source_id, user, "edit") + owner = ra.owner_id # Ingest-time fields (config.chunking) only take effect after a # re-ingest (D8); compare against the current config to decide. current_config = SourceConfig.parse(doc.get("config")) @@ -683,6 +694,8 @@ class SourceConfigResource(Resource): repo.update( str(doc["id"]), owner, {"config": new_config.model_dump()} ) + except AccessDenied as err: + return denied_response(err) except Exception as err: current_app.logger.error( f"Error updating source config for {source_id}: {err}", exc_info=True @@ -734,20 +747,6 @@ def _unsupported_retrieval_warnings(config) -> list: return warnings -def _resolve_readable_source(conn, source_id, user): - """Return a source dict the caller may READ, or None. - - Read access = owner or any team grant (viewer/editor). Resolves the row - without ownership scoping only after the grant check passes. - """ - doc = SourcesRepository(conn).get_any(source_id, user) - if doc is not None: - return doc - if not can_access(conn, "source", source_id, user): - return None - return SourcesRepository(conn).get_by_id(source_id) - - def _wiki_page_node(page): return { "path": page.get("path"), @@ -886,12 +885,10 @@ class WikiPages(Resource): user = decoded_token.get("sub") try: with db_readonly() as conn: - doc = _resolve_readable_source(conn, source_id, user) - if doc is None: - return make_response( - jsonify({"success": False, "message": "Source not found"}), - 404, - ) + try: + doc, _ra = load_source(conn, source_id, user, "use") + except AccessDenied as err: + return denied_response(err) pages = WikiPagesRepository(conn).list_for_source(str(doc["id"])) directory_structure = doc.get("directory_structure") or {} except Exception as err: @@ -934,12 +931,10 @@ class WikiPage(Resource): ) try: with db_readonly() as conn: - doc = _resolve_readable_source(conn, source_id, user) - if doc is None: - return make_response( - jsonify({"success": False, "message": "Source not found"}), - 404, - ) + try: + doc, _ra = load_source(conn, source_id, user, "use") + except AccessDenied as err: + return denied_response(err) page = WikiPagesRepository(conn).get_by_path(str(doc["id"]), path) except Exception as err: current_app.logger.error( @@ -977,20 +972,12 @@ class WikiPage(Resource): expected_version = data.get("expected_version") try: with db_session() as conn: - owner = effective_write_owner(conn, "source", source_id, user) - if not owner: - return make_response( - jsonify( - {"success": False, "message": "Source not accessible"} - ), - 403, - ) - doc = SourcesRepository(conn).get_any(source_id, owner) - if doc is None: - return make_response( - jsonify({"success": False, "message": "Source not found"}), - 404, - ) + # Owner or team editor; writes and re-embeds run as the owner. + try: + doc, ra = load_source(conn, source_id, user, "edit") + except AccessDenied as err: + return denied_response(err) + owner = ra.owner_id resolved_source_id = str(doc["id"]) try: page = WikiPagesRepository(conn).upsert( @@ -1074,20 +1061,12 @@ class ConvertSourceToWiki(Resource): user = decoded_token.get("sub") try: with db_session() as conn: - owner = effective_write_owner(conn, "source", source_id, user) - if not owner: - return make_response( - jsonify( - {"success": False, "message": "Source not accessible"} - ), - 403, - ) - doc = SourcesRepository(conn).get_any(source_id, owner) - if doc is None: - return make_response( - jsonify({"success": False, "message": "Source not found"}), - 404, - ) + # Owner or team editor; writes and re-embeds run as the owner. + try: + doc, ra = load_source(conn, source_id, user, "edit") + except AccessDenied as err: + return denied_response(err) + owner = ra.owner_id resolved_source_id = str(doc["id"]) # A mid-ingest source has an incomplete directory structure, so # it could be mis-detected as blank and wrongly enabled inline. @@ -1194,20 +1173,12 @@ class EnableSourceGraphRAG(Resource): user = decoded_token.get("sub") try: with db_session() as conn: - owner = effective_write_owner(conn, "source", source_id, user) - if not owner: - return make_response( - jsonify( - {"success": False, "message": "Source not accessible"} - ), - 403, - ) - doc = SourcesRepository(conn).get_any(source_id, owner) - if doc is None: - return make_response( - jsonify({"success": False, "message": "Source not found"}), - 404, - ) + # Owner or team editor; writes and re-embeds run as the owner. + try: + doc, ra = load_source(conn, source_id, user, "edit") + except AccessDenied as err: + return denied_response(err) + owner = ra.owner_id resolved_source_id = str(doc["id"]) cfg = SourceConfig.parse(doc.get("config")) repo = SourcesRepository(conn) @@ -1314,12 +1285,10 @@ class SourceGraph(Resource): limit = None try: with db_readonly() as conn: - doc = _resolve_readable_source(conn, source_id, user) - if doc is None: - return make_response( - jsonify({"success": False, "message": "Source not found"}), - 404, - ) + try: + doc, _ra = load_source(conn, source_id, user, "use") + except AccessDenied as err: + return denied_response(err) resolved_source_id = str(doc["id"]) except Exception as err: current_app.logger.error( @@ -1449,12 +1418,10 @@ class SourceGraphNodes(Resource): type_key = request.args.get("type") try: with db_readonly() as conn: - doc = _resolve_readable_source(conn, source_id, user) - if doc is None: - return make_response( - jsonify({"success": False, "message": "Source not found"}), - 404, - ) + try: + doc, _ra = load_source(conn, source_id, user, "use") + except AccessDenied as err: + return denied_response(err) resolved_source_id = str(doc["id"]) except Exception as err: current_app.logger.error( @@ -1500,12 +1467,10 @@ class SourceGraphNode(Resource): user = decoded_token.get("sub") try: with db_readonly() as conn: - doc = _resolve_readable_source(conn, source_id, user) - if doc is None: - return make_response( - jsonify({"success": False, "message": "Source not found"}), - 404, - ) + try: + doc, _ra = load_source(conn, source_id, user, "use") + except AccessDenied as err: + return denied_response(err) resolved_source_id = str(doc["id"]) except Exception as err: current_app.logger.error( diff --git a/docsgpt/api/user/sources/upload.py b/docsgpt/api/user/sources/upload.py index f15bee65..4fc52e7e 100644 --- a/docsgpt/api/user/sources/upload.py +++ b/docsgpt/api/user/sources/upload.py @@ -14,7 +14,8 @@ from sqlalchemy import text as sql_text from docsgpt.api import api from docsgpt.api.audit import record_event from docsgpt.api.user.tasks import ingest, ingest_connector_task, ingest_remote -from docsgpt.api.user.team_sharing import effective_write_owner +from docsgpt.api.user.resource_access import AccessDenied +from docsgpt.api.user.sources.access import denied_response, load_source from docsgpt.core.settings import settings from docsgpt.storage.db.source_ids import derive_source_id as _derive_source_id from docsgpt.parser.connectors.connector_creator import ConnectorCreator @@ -721,22 +722,10 @@ class ManageSourceFiles(Resource): # (owner-agnostic), so running the ops as the owner is correct. try: with db_readonly() as conn: - source = SourcesRepository(conn).get_any(source_id, user) - owner = user - if source is None: - owner = effective_write_owner(conn, "source", source_id, user) - if owner: - source = SourcesRepository(conn).get_any(source_id, owner) - if not source: - return make_response( - jsonify( - { - "success": False, - "message": "Source not found or access denied", - } - ), - 404, - ) + source, ra = load_source(conn, source_id, user, "edit") + owner = ra.owner_id + except AccessDenied as err: + return denied_response(err) except Exception as err: current_app.logger.error(f"Error finding source: {err}", exc_info=True) return make_response( diff --git a/docsgpt/api/user/team_sharing.py b/docsgpt/api/user/team_sharing.py index fb2b8ccf..56e5b821 100644 --- a/docsgpt/api/user/team_sharing.py +++ b/docsgpt/api/user/team_sharing.py @@ -13,13 +13,10 @@ from typing import Optional from sqlalchemy import Connection -from docsgpt.storage.db.base_repository import looks_like_uuid +from docsgpt.api.user.resource_access import resolve from docsgpt.storage.db.repositories.agents import AgentsRepository from docsgpt.storage.db.repositories.prompts import PromptsRepository from docsgpt.storage.db.repositories.sources import SourcesRepository -from docsgpt.storage.db.repositories.team_resource_grants import ( - TeamResourceGrantsRepository, -) from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository from docsgpt.storage.db.repositories.user_tools import UserToolsRepository @@ -89,22 +86,14 @@ def effective_write_owner( ...)`` repo methods (which match on ``WHERE id AND user_id = :owner``) without a separate ownerless write path. None means viewer-only or no access → the route should answer 403/404. Delete is never authorized here — owner-only. + + A thin wrapper over :func:`resource_access.resolve`; new code should call + ``resource_access.require`` with a specific action instead. """ - if owns_resource(conn, resource_type, resource_id, user_id): - return user_id - # Past the ownership check, only canonical-UUID resources can carry a team - # grant; a legacy/non-UUID id can't, and casting it would poison the txn. - if not looks_like_uuid(resource_id): + ra = resolve(conn, resource_type, resource_id, user_id) + if ra is None or ra.access not in ("owner", "editor"): return None - grants = TeamResourceGrantsRepository(conn).list_for_resource( - resource_type, resource_id - ) - if not grants: - return None - if TeamScopeRepository(conn).can_write(user_id, resource_type, resource_id): - # All grant rows carry the same denormalised owner_id. - return grants[0].get("owner_id") - return None + return ra.owner_id def can_access( @@ -116,9 +105,9 @@ def can_access( ``source_id`` to an agent): you may reference what you own or what a team has shared with you directly. Transitive access *through* a shared agent is a separate, run-time concept and is intentionally NOT gated here. + + A thin wrapper over :func:`resource_access.resolve`. """ if not resource_id: return True - if owns_resource(conn, resource_type, resource_id, user_id): - return True - return TeamScopeRepository(conn).can_read(user_id, resource_type, resource_id) + return resolve(conn, resource_type, resource_id, user_id) is not None diff --git a/docsgpt/api/user/teams/routes.py b/docsgpt/api/user/teams/routes.py index df51ec0e..adfd5374 100644 --- a/docsgpt/api/user/teams/routes.py +++ b/docsgpt/api/user/teams/routes.py @@ -6,9 +6,12 @@ Authorization model (two planes, see ``team_authz.py``): - Team detail / member list / grant list require team membership. - Member management and team edit require ``team_admin``. - Team deletion and owner transfer are owner-only (a global ``admin`` overrides). -- Sharing a resource requires the caller to OWN it (dispatched by resource_type) - and be a member of the target team. Sharing is additive visibility — the - resource's owner is never changed. +- Sharing a resource requires the ``share`` action on it (the owner, or an + editor when the owner turned on ``editors_can_share``; see + ``resource_access.py``) and membership of the target team. Unsharing needs + ``share`` (no membership required) or ``team_admin`` of the team. Sharing is + additive visibility — the resource's owner is never changed. +- The team owner can't be demoted or removed; they transfer ownership first. ``team_id`` always comes from the URL path (never the body) — enforced by ``require_team_role`` and by reading ``team_id`` as a route kwarg here. @@ -22,14 +25,25 @@ import uuid from flask import jsonify, make_response, request from flask_restx import Namespace, Resource +from sqlalchemy import text from docsgpt.api.user.authz import ROLE_ADMIN, has_role +from docsgpt.api.user.resource_access import ( + RESOURCE_TYPES, + AccessDenied, + ResourceAccess, + build, + public_settings, + require, + set_settings, + settings_many, +) from docsgpt.api.user.team_authz import ( has_team_role, team_admin_required, team_member_required, ) -from docsgpt.api.user.team_sharing import is_valid_resource_type, owns_resource +from docsgpt.api.user.team_sharing import is_valid_resource_type from docsgpt.events.publisher import publish_user_event from docsgpt.storage.db.base_repository import looks_like_uuid from docsgpt.storage.db.repositories.agents import AgentsRepository @@ -44,6 +58,7 @@ from docsgpt.storage.db.repositories.team_members import ( from docsgpt.storage.db.repositories.team_resource_grants import ( TeamResourceGrantsRepository, ) +from docsgpt.storage.db.repositories.team_scope import TeamScopeRepository 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 @@ -62,6 +77,99 @@ def _current_user() -> str | None: return token.get("sub") if isinstance(token, dict) else None +def _denied(err: AccessDenied): + """JSON response for an :class:`AccessDenied` (404 not visible, 403 not allowed).""" + return make_response(jsonify({"success": False, "message": err.message}), err.status) + + +def _team_payload(team: dict, user: str | None) -> dict: + """Add ``is_owner`` so the UI can gate owner-only actions (delete, transfer).""" + team["is_owner"] = bool(user) and team.get("owner_id") == user + return team + + +# Name + owner of each shareable resource, looked up unscoped by id (the grant +# row already proves it was shared; the caller's own access is computed below). +_RESOURCE_ROW_SQL = { + "agent": "SELECT id, name, user_id FROM agents WHERE id = ANY(CAST(:ids AS uuid[]))", + "source": "SELECT id, name, user_id FROM sources WHERE id = ANY(CAST(:ids AS uuid[]))", + "prompt": "SELECT id, name, user_id FROM prompts WHERE id = ANY(CAST(:ids AS uuid[]))", + "tool": ( + "SELECT id, COALESCE(NULLIF(custom_name, ''), NULLIF(display_name, ''), name) AS name, " + "user_id FROM user_tools WHERE id = ANY(CAST(:ids AS uuid[]))" + ), +} + + +def _resource_rows(conn, grants: list[dict]) -> dict[tuple[str, str], dict]: + """``(type, id) -> {name, user_id}`` for every resource the grants point at.""" + ids_by_type: dict[str, set[str]] = {} + for g in grants: + ids_by_type.setdefault(g["resource_type"], set()).add(str(g["resource_id"])) + out: dict[tuple[str, str], dict] = {} + for rtype, ids in ids_by_type.items(): + sql = _RESOURCE_ROW_SQL.get(rtype) + if not sql: + continue + for rid, name, owner in conn.execute(text(sql), {"ids": list(ids)}).fetchall(): + out[(rtype, str(rid))] = {"name": name, "user_id": owner} + return out + + +def _caller_access( + conn, user: str, grants: list[dict], rows: dict[tuple[str, str], dict] +) -> dict[tuple[str, str], ResourceAccess]: + """The caller's live access to each granted resource, in a few bulk queries. + + Same answer as ``resource_access.resolve`` per resource (owner by the + resource's ``user_id``, else the strongest team grant reaching the caller), + without four queries per row. + """ + scope = TeamScopeRepository(conn) + out: dict[tuple[str, str], ResourceAccess] = {} + for rtype in {g["resource_type"] for g in grants}: + ids = sorted({str(g["resource_id"]) for g in grants if g["resource_type"] == rtype}) + via_teams = scope.visible_with_access(user, rtype) + settings = settings_many(conn, rtype, ids) + for rid in ids: + row = rows.get((rtype, rid)) + if row is None: + continue # dangling grant: the resource is gone + if row["user_id"] == user: + level = "owner" + else: + level = via_teams.get(rid) + if level is None: + continue + out[(rtype, rid)] = build(rtype, rid, level, row["user_id"], settings[rid]) + return out + + +def _user_labels(conn, user_ids: set[str]) -> dict[str, str]: + """``user_id -> email`` for the users on file with an email.""" + ids = [u for u in user_ids if u] + if not ids: + return {} + rows = conn.execute( + text( + "SELECT user_id, email FROM users WHERE user_id = ANY(:ids) " + "AND email IS NOT NULL AND email <> ''" + ), + {"ids": ids}, + ).fetchall() + return {uid: email for uid, email in rows} + + +def _valid_resource(resource_type, resource_id) -> bool: + """A known shareable type and a canonical-UUID id (grants are UUID-only).""" + return ( + is_valid_resource_type(resource_type) + and bool(resource_id) + and isinstance(resource_id, str) + and looks_like_uuid(resource_id) + ) + + # Metadata keys naming the user a team event acted on, most specific first. # Lets ``_audit`` fill ``target_id`` without every call site repeating it. _TARGET_METADATA_KEYS = ("target_user", "target_user_id", "new_owner") @@ -218,6 +326,7 @@ class Teams(Resource): try: with db_readonly() as conn: teams = TeamsRepository(conn).list_for_user(user) + teams = [_team_payload(t, user) for t in teams] return make_response(jsonify({"success": True, "teams": teams}), 200) except Exception as err: logger.error("List teams failed: %s", err, exc_info=True) @@ -243,6 +352,7 @@ class Teams(Resource): ) _audit(conn, user, "team.create", team_id=team["id"], name=name) team["member_role"] = ROLE_TEAM_ADMIN + _team_payload(team, user) return make_response(jsonify({"success": True, "team": team}), 201) except Exception as err: logger.error("Create team failed: %s", err, exc_info=True) @@ -263,6 +373,7 @@ class Team(Resource): members = TeamMembersRepository(conn) team["members"] = members.list_members(team_id) team["member_role"] = members.role_for(user, team_id) + _team_payload(team, user) return make_response(jsonify({"success": True, "team": team}), 200) except Exception as err: logger.error("Get team failed: %s", err, exc_info=True) @@ -370,6 +481,10 @@ class TeamMember(Resource): return {"success": False, "message": "invalid role"}, 400 try: with db_session() as conn: + if role == ROLE_TEAM_MEMBER: + blocked = self._owner_guard(conn, team_id, member_id, "demote") + if blocked is not None: + return blocked members = TeamMembersRepository(conn) if role == ROLE_TEAM_MEMBER and self._would_orphan_admins( members, team_id, member_id @@ -403,6 +518,9 @@ class TeamMember(Resource): return {"success": False, "message": "Forbidden"}, 403 try: with db_session() as conn: + blocked = self._owner_guard(conn, team_id, member_id, "remove") + if blocked is not None: + return blocked members = TeamMembersRepository(conn) if self._would_orphan_admins(members, team_id, member_id): return { @@ -423,6 +541,38 @@ class TeamMember(Resource): logger.error("Remove member failed: %s", err, exc_info=True) return {"success": False}, 400 + @staticmethod + def _owner_guard(conn, team_id: str, member_id: str, change: str): + """Refuse to demote or remove the team owner. + + Another admin gets 403; the owner themselves gets 400 telling them to + transfer ownership first (the team would otherwise have an owner who + isn't an admin, or isn't a member at all). + + Args: + conn: Open connection. + team_id: Team from the URL path. + member_id: The member being changed. + change: ``"demote"`` or ``"remove"`` (for the message). + + Returns: + A response to return, or None when the change may proceed. + """ + team = TeamsRepository(conn).get(team_id) + if not team or team.get("owner_id") != member_id: + return None + if member_id == _current_user(): + message = ( + "Transfer team ownership to another member before you leave the team" + if change == "remove" + else "Transfer team ownership to another member before you step down as admin" + ) + return make_response(jsonify({"success": False, "message": message}), 400) + verb = "removed" if change == "remove" else "demoted" + return make_response( + jsonify({"success": False, "message": f"The team owner can't be {verb}"}), 403 + ) + @staticmethod def _would_orphan_admins( members: TeamMembersRepository, team_id: str, member_id: str @@ -443,21 +593,66 @@ class TeamMember(Resource): class TeamGrants(Resource): @team_member_required def get(self, team_id): - """List resources shared with this team. Requires membership.""" + """List resources shared with this team. Requires membership. + + Each row carries the resource's name, owner and people labels (email + when on file, else null) and ``caller`` — the caller's own live + ``{access, allowed_actions}`` on that resource (null if none). A plain + member sees whole-team grants and grants aimed at them; a team_admin, + or someone with ``share`` on the resource, sees every grant. + """ + user = _current_user() + token = getattr(request, "decoded_token", None) resource_type = request.args.get("resource_type") try: with db_readonly() as conn: grants = TeamResourceGrantsRepository(conn).list_for_team( team_id, resource_type ) - return make_response(jsonify({"success": True, "grants": grants}), 200) + team_role = TeamMembersRepository(conn).role_for(user, team_id) + sees_all = team_role == ROLE_TEAM_ADMIN or has_role(token, ROLE_ADMIN) + rows = _resource_rows(conn, grants) + access = _caller_access(conn, user, grants, rows) + visible = [] + for g in grants: + key = (g["resource_type"], str(g["resource_id"])) + ra = access.get(key) + target = g.get("target_user_id") + if not (sees_all or not target or target == user or (ra and ra.can("share"))): + continue + row = rows.get(key) or {} + g["resource_name"] = row.get("name") + g["owner_id"] = row.get("user_id") or g.get("owner_id") + g["caller"] = ra.payload() if ra else None + visible.append(g) + labels = _user_labels( + conn, + { + u + for g in visible + for u in (g.get("owner_id"), g.get("target_user_id"), g.get("granted_by")) + if u + }, + ) + for g in visible: + g["owner_label"] = labels.get(g.get("owner_id")) + g["target_user_label"] = labels.get(g.get("target_user_id")) + g["granted_by_label"] = labels.get(g.get("granted_by")) + return make_response( + jsonify({"success": True, "grants": visible, "team_role": team_role}), 200 + ) except Exception as err: logger.error("List grants failed: %s", err, exc_info=True) return {"success": False}, 400 @team_member_required def post(self, team_id): - """Share a resource the caller OWNS with this team (additive visibility).""" + """Share a resource with this team, or change an existing grant's level. + + Needs ``share`` on the resource (the owner, or an editor when the owner + turned on ``editors_can_share``) and membership of the team. The grant + records the real owner as ``owner_id`` and the caller as ``granted_by``. + """ user = _current_user() data = request.get_json(silent=True) or {} resource_type = data.get("resource_type") @@ -465,20 +660,18 @@ class TeamGrants(Resource): access_level = data.get("access_level", "viewer") # None → share with the whole team; a sub → share with that one member. target_user_id = (data.get("target_user_id") or "").strip() or None - if ( - not is_valid_resource_type(resource_type) - or not resource_id - or not looks_like_uuid(resource_id) - ): + if not _valid_resource(resource_type, resource_id): return {"success": False, "message": "invalid resource"}, 400 if access_level not in _VALID_ACCESS_LEVELS: return {"success": False, "message": "invalid access_level"}, 400 try: with db_session() as conn: - # Ownership is the security boundary: dispatch by resource_type so - # a mismatched type/id can't register a bogus grant. - if not owns_resource(conn, resource_type, resource_id, user): - return {"success": False, "message": "Not the resource owner"}, 403 + # ``require`` dispatches by resource_type, so a mismatched + # type/id can't register a bogus grant (the table has no FK). + try: + ra = require(conn, resource_type, resource_id, user, "share") + except AccessDenied as denied: + return _denied(denied) # A per-member share target must actually be a member of the team. if target_user_id and not TeamMembersRepository(conn).is_member( target_user_id, team_id @@ -487,8 +680,8 @@ class TeamGrants(Resource): grant = TeamResourceGrantsRepository(conn).grant( team_id, resource_type, - resource_id, - owner_id=user, + ra.resource_id, + owner_id=ra.owner_id, granted_by=user, access_level=access_level, target_user_id=target_user_id, @@ -512,15 +705,21 @@ class TeamGrants(Resource): logger.error("Share resource failed: %s", err, exc_info=True) return {"success": False}, 400 - @team_member_required def delete(self, team_id): - """Unshare a resource. Allowed for the resource owner or a team_admin. + """Unshare a resource from this team. - Identifiers come from query params (some proxies strip DELETE bodies), - with a JSON-body fallback for older clients. + Allowed with ``share`` on the resource — no membership needed, so an + owner who left the team can still pull their resource back — or for a + team_admin of this team. ``target_user_id`` picks one member's grant + (absent → the whole-team grant). Identifiers come from query params + (some proxies strip DELETE bodies), with a JSON-body fallback. """ user = _current_user() token = getattr(request, "decoded_token", None) + if not user: + return make_response( + jsonify({"success": False, "message": "Authentication required"}), 401 + ) data = request.get_json(silent=True) or {} resource_type = request.args.get("resource_type") or data.get("resource_type") resource_id = request.args.get("resource_id") or data.get("resource_id") @@ -528,17 +727,15 @@ class TeamGrants(Resource): target_user_id = ( request.args.get("target_user_id") or data.get("target_user_id") or "" ).strip() or None - if ( - not is_valid_resource_type(resource_type) - or not resource_id - or not looks_like_uuid(resource_id) - ): + if not _valid_resource(resource_type, resource_id): return {"success": False, "message": "invalid resource"}, 400 try: with db_session() as conn: - is_owner = owns_resource(conn, resource_type, resource_id, user) - if not is_owner and not has_team_role(token, team_id, ROLE_TEAM_ADMIN): - return {"success": False, "message": "Forbidden"}, 403 + try: + require(conn, resource_type, resource_id, user, "share") + except AccessDenied: + if not has_team_role(token, team_id, ROLE_TEAM_ADMIN): + return {"success": False, "message": "Forbidden"}, 403 revoked = TeamResourceGrantsRepository(conn).revoke( team_id, resource_type, resource_id, target_user_id=target_user_id ) @@ -602,26 +799,25 @@ class TeamOwnerTransfer(Resource): @teams_ns.route("/resource_shares") class ResourceShares(Resource): def get(self): - """List the teams a resource the caller OWNS is shared with. + """List the teams a resource is shared with. Needs ``share`` on it. - Powers the share dialog (show current shares + unshare). Owner-only so a - non-owner can't enumerate a resource's sharing graph. + Powers the share dialog (current shares + unshare), so only someone who + may change the sharing can enumerate it: 404 when the resource isn't + visible, 403 when the caller's role can't share. """ user = _current_user() if not user: return {"success": False}, 401 resource_type = request.args.get("resource_type") resource_id = request.args.get("resource_id") - if ( - not is_valid_resource_type(resource_type) - or not resource_id - or not looks_like_uuid(resource_id) - ): + if not _valid_resource(resource_type, resource_id): return {"success": False, "message": "invalid resource"}, 400 try: with db_readonly() as conn: - if not owns_resource(conn, resource_type, resource_id, user): - return {"success": False, "message": "Not the resource owner"}, 403 + try: + require(conn, resource_type, resource_id, user, "share") + except AccessDenied as denied: + return _denied(denied) shares = TeamResourceGrantsRepository(conn).list_for_resource( resource_type, resource_id ) @@ -631,6 +827,83 @@ class ResourceShares(Resource): return {"success": False}, 400 +def _settings_response(ra: ResourceAccess): + """The ``resource_settings`` body: switches plus the caller's access.""" + return make_response( + jsonify( + { + "success": True, + "resource_type": ra.resource_type, + "resource_id": ra.resource_id, + "settings": public_settings(ra.resource_type, ra.settings), + **ra.payload(), + } + ), + 200, + ) + + +@teams_ns.route("/resource_settings") +class ResourceSettings(Resource): + def get(self): + """A resource's sharing switches. Anyone with access may read them. + + Query: ``resource_type``, ``resource_id``. Returns ``settings`` as + ``[{key, value, default}]`` in display order, plus the caller's + ``access`` and ``allowed_actions``. + """ + user = _current_user() + if not user: + return {"success": False}, 401 + resource_type = request.args.get("resource_type") + resource_id = request.args.get("resource_id") + if resource_type not in RESOURCE_TYPES or not resource_id: + return {"success": False, "message": "invalid resource"}, 400 + try: + with db_readonly() as conn: + try: + ra = require(conn, resource_type, resource_id, user, "use") + except AccessDenied as denied: + return _denied(denied) + return _settings_response(ra) + except Exception as err: + logger.error("Get resource settings failed: %s", err, exc_info=True) + return {"success": False}, 400 + + def put(self): + """Change a resource's sharing switches. Needs ``manage_settings`` (owner). + + Body: ``{"resource_type", "resource_id", "settings": {key: bool}}``. + An unknown key or a non-boolean value is a 400. Returns the GET shape. + """ + user = _current_user() + if not user: + return {"success": False}, 401 + data = request.get_json(silent=True) or {} + resource_type = data.get("resource_type") + resource_id = data.get("resource_id") + changes = data.get("settings") + if resource_type not in RESOURCE_TYPES or not resource_id or not isinstance(resource_id, str): + return {"success": False, "message": "invalid resource"}, 400 + if not isinstance(changes, dict): + return {"success": False, "message": "settings must be an object"}, 400 + try: + with db_session() as conn: + try: + ra = require(conn, resource_type, resource_id, user, "manage_settings") + except AccessDenied as denied: + return _denied(denied) + try: + merged = set_settings(conn, resource_type, ra.resource_id, changes, user) + except ValueError as bad: + return {"success": False, "message": str(bad)}, 400 + updated = build(resource_type, ra.resource_id, ra.access, ra.owner_id, merged) + return _settings_response(updated) + except Exception as err: + logger.error("Update resource settings failed: %s", err, exc_info=True) + return {"success": False}, 400 + + @teams_ns.route("/admin/teams") class AllTeams(Resource): method_decorators = [] diff --git a/docsgpt/api/user/tools/mcp.py b/docsgpt/api/user/tools/mcp.py index bcc99084..bdc9281b 100644 --- a/docsgpt/api/user/tools/mcp.py +++ b/docsgpt/api/user/tools/mcp.py @@ -7,7 +7,15 @@ from flask_restx import Namespace, Resource, fields from docsgpt.agents.tools.mcp_tool import MCPOAuthManager, MCPTool from docsgpt.api import api -from docsgpt.api.user.tools.routes import transform_actions +from docsgpt.api.user.resource_access import AccessDenied, require +from docsgpt.api.user.team_sharing import visible_with_access +from docsgpt.api.user.tools.routes import ( + _CREDENTIALS_FOR_NEW_SERVER, + _MCP_CREDENTIAL_AUTH_TYPES, + _mcp_host_changed, + denied_response, + transform_actions, +) from docsgpt.cache import get_redis_instance from docsgpt.core.url_validation import SSRFError, validate_url from docsgpt.security.encryption import decrypt_credentials, encrypt_credentials @@ -75,12 +83,60 @@ def _validate_mcp_server_url(config: dict) -> None: raise ValueError(f"Invalid server URL: {exc}") from exc +_ONLY_OWNER_RECONNECTS = "Only the owner can reconnect this account" + + +def _existing_mcp_context(tool_id, user, config): + """Resolve the stored MCP tool a test/save refers to, and its credentials. + + With no ``tool_id`` the caller acts on their own new server. With one, + the caller needs ``edit_credentials`` on that tool and everything runs as + its owner. Stored secrets are write-only, so an empty secret field reuses + the stored one while the host is unchanged; a new host never inherits them. + + Returns: + ``(existing_doc, owner_id, is_owner, moved, credentials)``, or a Flask + response (404 / 400) to return as is. + + Raises: + AccessDenied: the caller can't see the tool (404) or can't change + its credentials (403). + """ + auth_credentials = _extract_auth_credentials(config) + if not tool_id: + return None, user, True, False, auth_credentials + with db_readonly() as conn: + ra = require(conn, "tool", tool_id, user, "edit_credentials") + existing_doc = UserToolsRepository(conn).get_any(ra.resource_id, ra.owner_id) + if not existing_doc or existing_doc.get("name") != "mcp_tool": + return make_response( + jsonify({"success": False, "message": "Tool not found or access denied"}), 404, + ) + existing_config = existing_doc.get("config") or {} + moved = _mcp_host_changed(config, existing_config) + auth_type = config.get("auth_type", "none") + new_secret_keys = set(auth_credentials) - {"api_key_header"} + if moved and auth_type in _MCP_CREDENTIAL_AUTH_TYPES and not new_secret_keys: + return make_response( + jsonify({"success": False, "message": _CREDENTIALS_FOR_NEW_SERVER}), 400 + ) + credentials = dict(auth_credentials) + existing_encrypted = None if moved else existing_config.get("encrypted_credentials") + if existing_encrypted: + credentials = {**decrypt_credentials(existing_encrypted, ra.owner_id), **auth_credentials} + return existing_doc, ra.owner_id, ra.access == "owner", moved, credentials + + @tools_mcp_ns.route("/mcp_server/test") class TestMCPServerConfig(Resource): @api.expect( api.model( "MCPServerTestModel", { + "id": fields.String( + required=False, + description="Stored tool to test with (empty secrets reuse its stored ones)", + ), "config": fields.Raw( required=True, description="MCP server configuration to test" ), @@ -111,11 +167,20 @@ class TestMCPServerConfig(Resource): _validate_mcp_server_url(config) - auth_credentials = _extract_auth_credentials(config) + ctx = _existing_mcp_context(data.get("id"), user, config) + if not isinstance(ctx, tuple): + return ctx + _existing_doc, owner_id, is_owner, _moved, auth_credentials = ctx + if not is_owner and config.get("auth_type") == "oauth": + # An OAuth flow would store tokens under the editor's account + # (and its popup event goes to that account), not the owner's. + return make_response( + jsonify({"success": False, "message": _ONLY_OWNER_RECONNECTS}), 403 + ) test_config = config.copy() test_config["auth_credentials"] = auth_credentials - mcp_tool = MCPTool(config=test_config, user_id=user) + mcp_tool = MCPTool(config=test_config, user_id=owner_id) result = mcp_tool.test_connection() if result.get("requires_oauth"): @@ -148,6 +213,8 @@ class TestMCPServerConfig(Resource): "tools": result.get("tools", []), } return make_response(jsonify(safe_result), 200) + except AccessDenied as e: + return denied_response(e) except ValueError as e: current_app.logger.warning(f"Invalid MCP server test request: {e}") return make_response( @@ -207,13 +274,55 @@ class MCPServerSave(Resource): _validate_mcp_server_url(config) - auth_credentials = _extract_auth_credentials(config) + # An existing id is always an update of THAT row, written as its + # owner; it never falls through to creating a copy for the caller. + ctx = _existing_mcp_context(data.get("id"), user, config) + if not isinstance(ctx, tuple): + return ctx + existing_doc, owner_id, is_owner, moved, merged_credentials = ctx + existing_config = (existing_doc or {}).get("config") or {} auth_type = config.get("auth_type", "none") mcp_config = config.copy() - mcp_config["auth_credentials"] = auth_credentials + mcp_config["auth_credentials"] = merged_credentials + keep_actions = False if auth_type == "oauth": - if not config.get("oauth_task_id"): + if config.get("oauth_task_id"): + if not is_owner: + # The OAuth flow stores tokens under the account that + # ran it; reconnecting as the owner is owner-only. + return make_response( + jsonify({ + "success": False, + "message": _ONLY_OWNER_RECONNECTS, + }), + 403, + ) + redis_client = get_redis_instance() + manager = MCPOAuthManager(redis_client) + result = manager.get_oauth_status( + config["oauth_task_id"], user + ) + if not result.get("status") == "completed": + return make_response( + jsonify( + { + "success": False, + "error": "OAuth failed or not completed. Please try authorizing again.", + } + ), + 400, + ) + actions_metadata = result.get("tools", []) + elif ( + existing_doc is not None + and not moved + and existing_config.get("auth_type") == "oauth" + ): + # Editing an already-connected server: keep its tools. + actions_metadata = existing_doc.get("actions") or [] + keep_actions = True + else: return make_response( jsonify( { @@ -223,24 +332,8 @@ class MCPServerSave(Resource): ), 400, ) - redis_client = get_redis_instance() - manager = MCPOAuthManager(redis_client) - result = manager.get_oauth_status( - config["oauth_task_id"], user - ) - if not result.get("status") == "completed": - return make_response( - jsonify( - { - "success": False, - "error": "OAuth failed or not completed. Please try authorizing again.", - } - ), - 400, - ) - actions_metadata = result.get("tools", []) - elif auth_type == "none" or auth_credentials: - mcp_tool = MCPTool(config=mcp_config, user_id=user) + elif auth_type == "none" or merged_credentials: + mcp_tool = MCPTool(config=mcp_config, user_id=owner_id) mcp_tool.discover_tools() actions_metadata = mcp_tool.get_actions_metadata() else: @@ -249,30 +342,10 @@ class MCPServerSave(Resource): ) storage_config = config.copy() - tool_id = data.get("id") - existing_doc = None - existing_encrypted = None - if tool_id: - with db_readonly() as conn: - repo = UserToolsRepository(conn) - existing_doc = repo.get_any(tool_id, user) - if existing_doc and existing_doc.get("name") == "mcp_tool": - existing_encrypted = (existing_doc.get("config") or {}).get( - "encrypted_credentials" - ) - else: - existing_doc = None - - if auth_credentials: - if existing_encrypted: - existing_secrets = decrypt_credentials(existing_encrypted, user) - existing_secrets.update(auth_credentials) - auth_credentials = existing_secrets + if merged_credentials: storage_config["encrypted_credentials"] = encrypt_credentials( - auth_credentials, user + merged_credentials, owner_id ) - elif existing_encrypted: - storage_config["encrypted_credentials"] = existing_encrypted for field in [ "api_key", @@ -283,58 +356,54 @@ class MCPServerSave(Resource): "redirect_uri", ]: storage_config.pop(field, None) - transformed_actions = transform_actions(actions_metadata) + # Kept actions already carry the owner's on/off and approval flags. + transformed_actions = actions_metadata if keep_actions else transform_actions(actions_metadata) display_name = data["displayName"] description = f"MCP Server: {storage_config.get('server_url', 'Unknown')}" status_bool = bool(data.get("status", True)) + fields_out = { + "display_name": display_name, + "custom_name": display_name, + "description": description, + "config": storage_config, + "actions": transformed_actions, + } + updated_message = ( + f"MCP server updated successfully! Discovered {len(transformed_actions)} tools." + ) with db_session() as conn: repo = UserToolsRepository(conn) - if existing_doc: - repo.update( - str(existing_doc["id"]), user, - { - "display_name": display_name, - "custom_name": display_name, - "description": description, - "config": storage_config, - "actions": transformed_actions, - "status": status_bool, - }, - ) + if existing_doc is not None: + # ``status`` is the owner's own chat switch; an editor's + # save doesn't flip it. + if is_owner: + fields_out["status"] = status_bool + repo.update(str(existing_doc["id"]), owner_id, fields_out) saved_id = str(existing_doc["id"]) response_data = { "success": True, "id": saved_id, - "message": f"MCP server updated successfully! Discovered {len(transformed_actions)} tools.", + "message": updated_message, "tools_count": len(transformed_actions), } else: + fields_out["status"] = status_bool # Fall back to find_by_user_and_name — the original # dual-write path also ran an existence check before # deciding between insert and update. existing_by_name = repo.find_by_user_and_name(user, "mcp_tool") - if tool_id is None and existing_by_name and ( + if existing_by_name and ( (existing_by_name.get("config") or {}).get("server_url") == storage_config.get("server_url") ): - repo.update( - str(existing_by_name["id"]), user, - { - "display_name": display_name, - "custom_name": display_name, - "description": description, - "config": storage_config, - "actions": transformed_actions, - "status": status_bool, - }, - ) + repo.update(str(existing_by_name["id"]), user, fields_out) saved_id = str(existing_by_name["id"]) response_data = { "success": True, "id": saved_id, - "message": f"MCP server updated successfully! Discovered {len(transformed_actions)} tools.", + "message": updated_message, "tools_count": len(transformed_actions), } else: @@ -355,18 +424,9 @@ class MCPServerSave(Resource): "message": f"MCP server created successfully! Discovered {len(transformed_actions)} tools.", "tools_count": len(transformed_actions), } - if tool_id and existing_doc is None: - # Client requested update on a non-existent tool id. - return make_response( - jsonify( - { - "success": False, - "error": "Tool not found or access denied", - } - ), - 404, - ) return make_response(jsonify(response_data), 200) + except AccessDenied as e: + return denied_response(e) except ValueError as e: current_app.logger.warning(f"Invalid MCP server save request: {e}") return make_response( @@ -455,6 +515,13 @@ class MCPAuthStatus(Resource): tools_repo = UserToolsRepository(conn) sessions_repo = ConnectorSessionsRepository(conn) all_tools = tools_repo.list_for_user(user) + owned_ids = {str(t["id"]) for t in all_tools} + # Team-shared MCP servers the caller can see run with the + # owner's connection, so their status is the owner's. + shared_ids = [ + tid for tid in visible_with_access(conn, user, "tool") if tid not in owned_ids + ] + all_tools = all_tools + tools_repo.list_by_ids(shared_ids) mcp_tools = [t for t in all_tools if t.get("name") == "mcp_tool"] if not mcp_tools: return make_response( @@ -472,7 +539,7 @@ class MCPAuthStatus(Resource): if server_url: parsed = urlparse(server_url) base_url = f"{parsed.scheme}://{parsed.netloc}" - oauth_server_urls[tool_id] = base_url + oauth_server_urls[tool_id] = (tool.get("user_id") or user, base_url) else: statuses[tool_id] = "needs_auth" else: @@ -484,9 +551,9 @@ class MCPAuthStatus(Resource): # and the URL in ``server_url``; reuse the repo's # per-URL accessor rather than an ad-hoc $in query. url_has_tokens: dict = {} - for base_url in set(oauth_server_urls.values()): + for owner_id, base_url in set(oauth_server_urls.values()): session = sessions_repo.get_by_user_and_server_url( - user, base_url, + owner_id, base_url, ) tokens = ( (session or {}).get("session_data", {}) or {} @@ -494,13 +561,13 @@ class MCPAuthStatus(Resource): # MCP code also stashes tokens into token_info on # the row; consider either present as "connected". token_info = (session or {}).get("token_info") or {} - url_has_tokens[base_url] = bool( + url_has_tokens[(owner_id, base_url)] = bool( tokens.get("access_token") or token_info.get("access_token") ) - for tool_id, base_url in oauth_server_urls.items(): - if url_has_tokens.get(base_url): + for tool_id, key in oauth_server_urls.items(): + if url_has_tokens.get(key): statuses[tool_id] = "connected" else: statuses[tool_id] = "needs_auth" diff --git a/docsgpt/api/user/tools/routes.py b/docsgpt/api/user/tools/routes.py index ddc56916..72cbd6c3 100644 --- a/docsgpt/api/user/tools/routes.py +++ b/docsgpt/api/user/tools/routes.py @@ -1,7 +1,12 @@ """Tool management routes.""" +import copy +from typing import Any, Optional +from urllib.parse import urlparse + from flask import current_app, jsonify, make_response, request from flask_restx import fields, Namespace, Resource +from sqlalchemy import Connection, text from docsgpt.agents.default_tools import ( builtin_agent_tools_for_management, @@ -13,12 +18,21 @@ from docsgpt.agents.default_tools import ( is_synthesized_tool_id, WORKFLOW_ONLY_BUILTINS, ) +from docsgpt.agents.tool_executor import API_TOOL_SECRET_SECTIONS, API_TOOL_SECRETS_KEY from docsgpt.agents.tools.spec_parser import parse_spec from docsgpt.agents.tools.tool_manager import ToolManager from docsgpt.api import api from docsgpt.api.pat.rules import filter_listing from docsgpt.api.user.artifacts.authz import Principal, authorize_artifact -from docsgpt.api.user.team_sharing import effective_write_owner, visible_with_access +from docsgpt.api.user.resource_access import ( + AccessDenied, + delete_settings, + payload_for, + require, + ResourceAccess, + settings_many, +) +from docsgpt.api.user.team_sharing import visible_with_access from docsgpt.core.settings import settings from docsgpt.core.url_validation import SSRFError, validate_url from docsgpt.security.encryption import decrypt_credentials, encrypt_credentials @@ -26,6 +40,9 @@ from docsgpt.storage.db.base_repository import looks_like_uuid from docsgpt.storage.db.repositories.artifacts import ArtifactsRepository from docsgpt.storage.db.repositories.notes import NotesRepository from docsgpt.storage.db.repositories.todos import TodosRepository +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 from docsgpt.storage.db.session import db_readonly, db_session @@ -166,6 +183,238 @@ def _merge_secrets_on_update(new_config, existing_config, config_requirements, u return storage_config +# --------------------------------------------------------------------------- +# Access + secrets helpers +# --------------------------------------------------------------------------- +_CREDENTIALS_FOR_NEW_SERVER = "Enter credentials for the new server" +_FORBIDDEN_MESSAGE = "Your access to this item doesn't allow that" +_MCP_CREDENTIAL_AUTH_TYPES = {"api_key", "bearer", "basic"} +_META_KEYS = ("name", "displayName", "customName", "description", "actions") + + +class CredentialsRequired(Exception): + """A save moved a tool to a new host without supplying new secrets.""" + + +def denied_response(err: AccessDenied): + """JSON response for an :class:`AccessDenied` (403 or 404).""" + return make_response(jsonify({"success": False, "message": err.message}), err.status) + + +def check_action(ra: ResourceAccess, action: str) -> None: + """Raise a 403 :class:`AccessDenied` unless ``ra`` allows ``action``.""" + if not ra.can(action): + raise AccessDenied(403, _FORBIDDEN_MESSAGE) + + +def url_host(url: Any) -> str: + """Lower-cased host of ``url`` ('' when it has none).""" + try: + return (urlparse(str(url or "").strip()).hostname or "").lower() + except ValueError: + return "" + + +def _has_value(value: Any) -> bool: + return value is not None and value != "" + + +def _secret_props(action: Any): + """Yield ``(section, param, spec)`` for an api_tool action's secret-bearing params.""" + if not isinstance(action, dict): + return + for section in API_TOOL_SECRET_SECTIONS: + block = action.get(section) + props = block.get("properties") if isinstance(block, dict) else None + if not isinstance(props, dict): + continue + for param, spec in props.items(): + if isinstance(spec, dict): + yield section, param, spec + + +def _stored_api_tool_secrets(config: dict, owner_id: str) -> dict: + """Decrypted ``{action: {section: {param: value}}}`` plus legacy plaintext values.""" + config = config or {} + blob = config.get(API_TOOL_SECRETS_KEY) + secrets: dict = decrypt_credentials(blob, owner_id) if blob else {} + for name, action in (config.get("actions") or {}).items(): + for section, param, spec in _secret_props(action): + value = spec.get("value") + if _has_value(value): + secrets.setdefault(name, {}).setdefault(section, {}).setdefault(param, value) + return secrets + + +def mask_api_tool_config(config: dict) -> dict: + """Copy of an api_tool config with header/query values blanked and ``has_value`` set. + + Args: + config: The stored ``user_tools.config``. + + Returns: + A deep copy safe to return to any caller: the encrypted blob is dropped + and every header / query-param entry has ``value: ""`` plus ``has_value``. + """ + out = copy.deepcopy(config or {}) + out.pop(API_TOOL_SECRETS_KEY, None) + for action in (out.get("actions") or {}).values(): + for _section, _param, spec in _secret_props(action): + spec["has_value"] = _has_value(spec.get("value")) or bool(spec.get("has_value")) + spec["value"] = "" + return out + + +def _seal_api_tool_secrets(new_config: dict, existing_config: dict, owner_id: str) -> dict: + """Move api_tool header/query values into an encrypted blob keyed by the owner. + + An incoming entry with a value replaces the stored one; an empty value with + ``has_value`` keeps it (legacy plaintext values included); anything else + clears it. When an action's URL host changes the stored values are not + carried over. + + Args: + new_config: The config the client sent. + existing_config: The stored config (``{}`` on create). + owner_id: The tool row's ``user_id`` — the encryption key owner. + + Returns: + The config to persist. + + Raises: + CredentialsRequired: a host changed, the client asked to keep a value, + and there is nothing to keep. + """ + existing_config = existing_config or {} + stored = _stored_api_tool_secrets(existing_config, owner_id) + old_actions = existing_config.get("actions") or {} + out = copy.deepcopy(new_config or {}) + out.pop(API_TOOL_SECRETS_KEY, None) + sealed: dict = {} + for name, action in (out.get("actions") or {}).items(): + old = old_actions.get(name) if isinstance(old_actions, dict) else None + moved = isinstance(old, dict) and url_host(old.get("url")) != url_host( + action.get("url") if isinstance(action, dict) else "" + ) + prior = {} if moved else stored.get(name, {}) + for section, param, spec in _secret_props(action): + value = spec.get("value") + if _has_value(value): + kept = value + elif spec.get("has_value"): + kept = (prior.get(section) or {}).get(param) + if not _has_value(kept): + if moved: + raise CredentialsRequired(_CREDENTIALS_FOR_NEW_SERVER) + kept = None + else: + kept = None + spec["value"] = "" + spec["has_value"] = kept is not None + if kept is not None: + sealed.setdefault(name, {}).setdefault(section, {})[param] = kept + if sealed: + out[API_TOOL_SECRETS_KEY] = encrypt_credentials(sealed, owner_id) + return out + + +def _api_tool_config_needs_credentials(new_config: dict, existing_config: dict) -> bool: + """Whether an api_tool config change touches endpoints or secrets. + + Descriptions, parameter schemas and on/off flags are ``edit``; a new or + changed URL, a new action (it brings a URL), a secret value or any other + config key is ``edit_credentials``. + """ + new_config = new_config or {} + existing_config = existing_config or {} + ignore = ("actions", API_TOOL_SECRETS_KEY, "has_encrypted_credentials") + if {k: v for k, v in new_config.items() if k not in ignore} != { + k: v for k, v in existing_config.items() if k not in ignore + }: + return True + old_actions = existing_config.get("actions") or {} + for name, action in (new_config.get("actions") or {}).items(): + old = old_actions.get(name) + if not isinstance(old, dict) or not isinstance(action, dict): + return True + if str(action.get("url") or "") != str(old.get("url") or ""): + return True + for _section, _param, spec in _secret_props(action): + if _has_value(spec.get("value")): + return True + return False + + +def _mcp_host_changed(new_config: dict, existing_config: dict) -> bool: + old_url = (existing_config or {}).get("server_url") + return bool(old_url) and url_host(old_url) != url_host((new_config or {}).get("server_url")) + + +def _prepare_tool_config(tool_doc: dict, new_config: dict, config_requirements: dict) -> dict: + """Validate-free merge of an incoming config with the stored one, as the owner. + + Handles the three secret stores: ``config_requirements`` secrets + (``encrypted_credentials``), api_tool header/query values, and the MCP + host-change rule (a new server host drops stored credentials). + + Raises: + CredentialsRequired: the MCP host changed and no new secret arrived. + """ + owner_id = tool_doc["user_id"] + existing_config = tool_doc.get("config") or {} + if tool_doc.get("name") == "api_tool": + return _seal_api_tool_secrets(new_config, existing_config, owner_id) + moved = tool_doc.get("name") == "mcp_tool" and _mcp_host_changed(new_config, existing_config) + if moved: + existing_config = {k: v for k, v in existing_config.items() if k != "encrypted_credentials"} + final = _merge_secrets_on_update(new_config, existing_config, config_requirements, owner_id) + if moved and final.get("auth_type") in _MCP_CREDENTIAL_AUTH_TYPES and not final.get( + "encrypted_credentials" + ): + raise CredentialsRequired(_CREDENTIALS_FOR_NEW_SERVER) + return final + + +def _shared_via(conn: Connection, user_id: str, tool_ids: list) -> dict: + """``tool_id -> team name`` through which a grant reaches ``user_id``.""" + ids = [str(t) for t in tool_ids if looks_like_uuid(str(t))] + if not ids: + return {} + rows = conn.execute( + text( + """ + SELECT DISTINCT ON (g.resource_id) g.resource_id, t.name + FROM team_resource_grants g + JOIN team_members m ON m.team_id = g.team_id + JOIN teams t ON t.id = g.team_id + WHERE m.user_id = :user_id AND g.resource_type = 'tool' + AND g.resource_id = ANY(CAST(:ids AS uuid[])) + AND (g.target_user_id IS NULL OR g.target_user_id = :user_id) + ORDER BY g.resource_id, (g.access_level = 'editor') DESC, t.name + """ + ), + {"user_id": user_id, "ids": ids}, + ).fetchall() + return {str(r[0]): r[1] for r in rows} + + +def _owner_labels(conn: Connection, owner_ids) -> dict: + """``user_id -> email`` for the owners that have one on record.""" + ids = sorted({str(o) for o in owner_ids if o}) + if not ids: + return {} + rows = conn.execute( + text("SELECT user_id, email FROM users WHERE user_id = ANY(:ids) AND email IS NOT NULL"), + {"ids": ids}, + ).fetchall() + return {r[0]: r[1] for r in rows} + + +def _load_owned_row(conn: Connection, ra: ResourceAccess) -> Optional[dict]: + """The tool row behind ``ra``, read as its owner.""" + return UserToolsRepository(conn).get_any(ra.resource_id, ra.owner_id) + + def transform_actions(actions_metadata): """Set default flags on action metadata for storage. @@ -239,6 +488,10 @@ class GetTools(Resource): team_shared = visible_with_access(conn, user, "tool") shared_ids = [tid for tid in team_shared if tid not in owned_ids] shared_rows = tools_repo.list_by_ids(shared_ids) + switches = settings_many(conn, "tool", [*owned_ids, *shared_ids]) + prefs = UserToolPreferencesRepository(conn).in_chat_many(user, shared_ids) + shared_via = _shared_via(conn, user, shared_ids) + owner_labels = _owner_labels(conn, [r.get("user_id") for r in shared_rows]) user_tools = [] def _shape_tool(row, *, ownership="user", force_strip_secret=False): @@ -257,14 +510,25 @@ class GetTools(Resource): ): tool_copy["config"]["has_encrypted_credentials"] = True tool_copy["config"].pop("encrypted_credentials", None) + if tool_copy.get("name") == "api_tool": + # Header / query-param values are secrets for everyone. + tool_copy["config"] = mask_api_tool_config(tool_copy.get("config") or {}) tool_copy["ownership"] = ownership return tool_copy for row in rows: - user_tools.append(_shape_tool(row)) + shaped = _shape_tool(row) + shaped.update(payload_for("tool", "owner", switches.get(str(row["id"])))) + shaped["in_chat"] = bool(row.get("status")) + user_tools.append(shaped) for row in shared_rows: + tid = str(row["id"]) shaped = _shape_tool(row, ownership="team", force_strip_secret=True) - shaped["team_access"] = team_shared.get(str(row["id"])) + shaped["team_access"] = team_shared.get(tid) + shaped.update(payload_for("tool", team_shared.get(tid), switches.get(tid))) + shaped["in_chat"] = prefs.get(tid, False) + shaped["shared_via"] = shared_via.get(tid) + shaped["owner_label"] = owner_labels.get(row.get("user_id")) user_tools.append(shaped) # ``scheduler`` is dual-registered (default chat tool + agent- @@ -275,6 +539,7 @@ class GetTools(Resource): for default_row in default_tools_for_management(user_doc): default_copy = _row_to_api(default_row) default_copy["default"] = True + default_copy["in_chat"] = bool(default_copy.get("status")) if default_copy.get("name") in BUILTIN_AGENT_TOOLS: default_copy["builtin"] = True seen_ids.add(str(default_copy["id"])) @@ -386,9 +651,12 @@ class CreateTool(Resource): ), 400, ) - storage_config = _encrypt_secret_fields( - data["config"], config_requirements, user - ) + if data["name"] == "api_tool": + storage_config = _seal_api_tool_secrets(data["config"], {}, user) + else: + storage_config = _encrypt_secret_fields( + data["config"], config_requirements, user + ) with db_session() as conn: created = UserToolsRepository(conn).create( user, @@ -478,43 +746,47 @@ class UpdateTool(Resource): ), 400, ) + if "config" in data and isinstance(data["config"], dict) and "actions" in data["config"]: + for action_name in list((data["config"]["actions"] or {}).keys()): + if not validate_function_name(action_name): + return make_response( + jsonify( + { + "success": False, + "message": f"Invalid function name '{action_name}'. Function names must match pattern '^[a-zA-Z0-9_-]+$'.", + "param": "tools[].function.name", + } + ), + 400, + ) try: update_data: dict = {} - for key in ("name", "displayName", "customName", "description", "actions"): + for key in _META_KEYS: if key in data: update_data[key] = data[key] - if "config" in data: - if "actions" in data["config"]: - for action_name in list(data["config"]["actions"].keys()): - if not validate_function_name(action_name): - return make_response( - jsonify( - { - "success": False, - "message": f"Invalid function name '{action_name}'. Function names must match pattern '^[a-zA-Z0-9_-]+$'.", - "param": "tools[].function.name", - } - ), - 400, - ) - with db_session() as conn: - repo = UserToolsRepository(conn) - tool_doc = repo.get_any(data["id"], user) - if not tool_doc: - return make_response( - jsonify({"success": False, "message": "Tool not found"}), - 404, - ) + with db_session() as conn: + ra = require(conn, "tool", data["id"], user, "use") + tool_doc = _load_owned_row(conn, ra) + if not tool_doc: + return make_response( + jsonify({"success": False, "message": "Tool not found"}), 404, + ) + if update_data: + check_action(ra, "edit") + if "config" in data: tool_name = tool_doc.get("name", data.get("name")) + existing_config = tool_doc.get("config", {}) or {} + if tool_name == "api_tool" and not _api_tool_config_needs_credentials( + data["config"], existing_config + ): + check_action(ra, "edit") + else: + check_action(ra, "edit_credentials") tool_instance = tool_manager.tools.get(tool_name) config_requirements = ( - tool_instance.get_config_requirements() - if tool_instance - else {} + tool_instance.get_config_requirements() if tool_instance else {} ) - existing_config = tool_doc.get("config", {}) or {} has_existing_secrets = "encrypted_credentials" in existing_config - if config_requirements: validation_errors = _validate_config( data["config"], config_requirements, @@ -529,29 +801,27 @@ class UpdateTool(Resource): }), 400, ) - - update_data["config"] = _merge_secrets_on_update( - data["config"], existing_config, config_requirements, user + update_data["config"] = _prepare_tool_config( + tool_doc, data["config"], config_requirements ) - if "status" in data: - update_data["status"] = bool(data["status"]) - repo.update( - str(tool_doc["id"]), user, _api_to_update_fields(update_data), - ) - else: if "status" in data: - update_data["status"] = bool(data["status"]) - with db_session() as conn: - repo = UserToolsRepository(conn) - tool_doc = repo.get_any(data["id"], user) - if not tool_doc: - return make_response( - jsonify({"success": False, "message": "Tool not found"}), - 404, + if ra.access == "owner": + update_data["status"] = bool(data["status"]) + else: + # A grantee's chat switch is personal; the owner's + # ``status`` is the owner's own chat setting. + check_action(ra, "use_in_own") + UserToolPreferencesRepository(conn).set_in_chat( + user, str(tool_doc["id"]), bool(data["status"]) ) - repo.update( - str(tool_doc["id"]), user, _api_to_update_fields(update_data), + if update_data: + UserToolsRepository(conn).update( + str(tool_doc["id"]), ra.owner_id, _api_to_update_fields(update_data), ) + except AccessDenied as err: + return denied_response(err) + except CredentialsRequired as err: + return make_response(jsonify({"success": False, "message": str(err)}), 400) except Exception as err: current_app.logger.error(f"Error updating tool: {err}", exc_info=True) return make_response(jsonify({"success": False}), 400) @@ -596,7 +866,8 @@ class UpdateToolConfig(Resource): try: with db_session() as conn: repo = UserToolsRepository(conn) - tool_doc = repo.get_any(data["id"], user) + ra = require(conn, "tool", data["id"], user, "edit_credentials") + tool_doc = _load_owned_row(conn, ra) if not tool_doc: return make_response(jsonify({"success": False}), 404) @@ -633,11 +904,13 @@ class UpdateToolConfig(Resource): 400, ) - final_config = _merge_secrets_on_update( - data["config"], existing_config, config_requirements, user - ) + final_config = _prepare_tool_config(tool_doc, data["config"], config_requirements) - repo.update(str(tool_doc["id"]), user, {"config": final_config}) + repo.update(str(tool_doc["id"]), ra.owner_id, {"config": final_config}) + except AccessDenied as err: + return denied_response(err) + except CredentialsRequired as err: + return make_response(jsonify({"success": False, "message": str(err)}), 400) except Exception as err: current_app.logger.error( f"Error updating tool config: {err}", exc_info=True @@ -684,20 +957,12 @@ class UpdateToolActions(Resource): ) try: with db_session() as conn: - repo = UserToolsRepository(conn) - tool_doc = repo.get_any(data["id"], user) - if tool_doc: - repo.update(str(tool_doc["id"]), user, {"actions": data["actions"]}) - else: - # Team editor write path (secrets stay owner-only — actions - # carry no credentials, so editing them is safe). - owner = effective_write_owner(conn, "tool", data["id"], user) - if not owner: - return make_response( - jsonify({"success": False, "message": "Tool not found"}), - 404, - ) - repo.update(data["id"], owner, {"actions": data["actions"]}) + # ``edit`` covers action on/off, descriptions and approval + # (``require_approval``); actions carry no credentials. + ra = require(conn, "tool", data["id"], user, "edit") + UserToolsRepository(conn).update(ra.resource_id, ra.owner_id, {"actions": data["actions"]}) + except AccessDenied as err: + return denied_response(err) except Exception as err: current_app.logger.error( f"Error updating tool actions: {err}", exc_info=True @@ -753,16 +1018,19 @@ class UpdateToolStatus(Resource): 400, ) with db_session() as conn: - repo = UserToolsRepository(conn) - tool_doc = repo.get_any(data["id"], user) - if not tool_doc: - return make_response( - jsonify({"success": False, "message": "Tool not found"}), - 404, + ra = require(conn, "tool", data["id"], user, "use") + if ra.access == "owner": + UserToolsRepository(conn).update( + ra.resource_id, ra.owner_id, {"status": bool(data["status"])}, ) - repo.update( - str(tool_doc["id"]), user, {"status": bool(data["status"])}, - ) + else: + # A grantee's "In my chats" switch is personal. + check_action(ra, "use_in_own") + UserToolPreferencesRepository(conn).set_in_chat( + user, ra.resource_id, bool(data["status"]) + ) + except AccessDenied as err: + return denied_response(err) except Exception as err: current_app.logger.error( f"Error updating tool status: {err}", exc_info=True @@ -802,13 +1070,13 @@ class DeleteTool(Resource): ) try: with db_session() as conn: - repo = UserToolsRepository(conn) - tool_doc = repo.get_any(data["id"], user) - if not tool_doc: - return make_response( - jsonify({"success": False, "message": "Tool not found"}), 404 - ) - repo.delete(str(tool_doc["id"]), user) + ra = require(conn, "tool", data["id"], user, "delete") + # Grants are removed by the ``user_tools`` delete trigger and + # chat preferences by FK cascade; the switches have no FK. + UserToolsRepository(conn).delete(ra.resource_id, ra.owner_id) + delete_settings(conn, "tool", ra.resource_id) + except AccessDenied as err: + return denied_response(err) except Exception as err: current_app.logger.error(f"Error deleting tool: {err}", exc_info=True) return make_response(jsonify({"success": False}), 400) diff --git a/docsgpt/api/user/workflows/routes.py b/docsgpt/api/user/workflows/routes.py index e36e5e1c..3faad82e 100644 --- a/docsgpt/api/user/workflows/routes.py +++ b/docsgpt/api/user/workflows/routes.py @@ -2,13 +2,15 @@ from typing import Any, Dict, List, Optional, Set -from flask import current_app, request +from flask import current_app, jsonify, make_response, request from flask_restx import Namespace, Resource +from sqlalchemy import text as sql_text from docsgpt.agents.workflows.cel_evaluator import ( CelEvaluationError, validate_cel_expression, ) +from docsgpt.api.user.resource_access import AccessDenied, resolve from docsgpt.storage.db.base_repository import looks_like_uuid from docsgpt.storage.db.repositories.workflow_edges import WorkflowEdgesRepository from docsgpt.storage.db.repositories.workflow_nodes import WorkflowNodesRepository @@ -46,6 +48,60 @@ def _resolve_workflow(repo: WorkflowsRepository, workflow_id: str, user_id: str) return repo.get_by_legacy_id(workflow_id, user_id) +def _workflow_access(conn, workflow_id: str, user_id: str, action: str): + """Resolve a workflow the caller may ``action``, and the id to act as. + + The caller's own workflow is always theirs. Otherwise access comes from + an agent of the workflow's owner that uses it: ``view`` to read it, + ``edit`` to change it, ``delete`` to remove it (checked on that agent). + + Args: + conn: Open database connection. + workflow_id: Workflow UUID or legacy id. + user_id: The caller. + action: Agent action required (``view``, ``edit`` or ``delete``). + + Returns: + ``(workflow, acting_user_id)``. + + Raises: + AccessDenied: 404 when not visible, 403 when the role can't ``action``. + """ + repo = WorkflowsRepository(conn) + own = _resolve_workflow(repo, workflow_id, user_id) + if own is not None: + return own, user_id + if not looks_like_uuid(str(workflow_id)): + raise AccessDenied(404, "Workflow not found") + workflow = repo.get_by_id(str(workflow_id)) + if workflow is None: + raise AccessDenied(404, "Workflow not found") + agent_ids = conn.execute( + sql_text( + "SELECT id FROM agents WHERE workflow_id = CAST(:wid AS uuid) AND user_id = :owner" + ), + {"wid": str(workflow["id"]), "owner": workflow["user_id"]}, + ).scalars().all() + visible = False + for agent_id in agent_ids: + ra = resolve(conn, "agent", str(agent_id), user_id) + if ra is None: + continue + visible = True + if ra.can(action): + return workflow, ra.owner_id + if not visible: + raise AccessDenied(404, "Workflow not found") + raise AccessDenied(403, "Your access to this item doesn't allow that") + + +def _denied(err: AccessDenied): + """403/404 in this module's ``error`` shape, plus the shared ``message`` key.""" + return make_response( + jsonify({"success": False, "error": err.message, "message": err.message}), err.status + ) + + def _write_graph( conn, pg_workflow_id: str, @@ -499,10 +555,10 @@ class WorkflowDetail(Resource): user_id = get_user_id() try: with db_readonly() as conn: - repo = WorkflowsRepository(conn) - workflow = _resolve_workflow(repo, workflow_id, user_id) - if workflow is None: - return error_response("Workflow not found", 404) + try: + workflow, _acting = _workflow_access(conn, workflow_id, user_id, "view") + except AccessDenied as denied: + return _denied(denied) pg_workflow_id = str(workflow["id"]) graph_version = get_workflow_graph_version(workflow) nodes = WorkflowNodesRepository(conn).find_by_version( @@ -533,21 +589,23 @@ class WorkflowDetail(Resource): nodes_data = data.get("nodes", []) edges_data = data.get("edges", []) - validation_errors = validate_workflow_structure( - nodes_data, edges_data, user_id=user_id - ) - if validation_errors: - return error_response( - "Workflow validation failed", errors=validation_errors - ) - nodes_data = normalize_agent_node_json_schemas(nodes_data) - try: with db_session() as conn: repo = WorkflowsRepository(conn) - workflow = _resolve_workflow(repo, workflow_id, user_id) - if workflow is None: - return error_response("Workflow not found", 404) + try: + workflow, acting = _workflow_access(conn, workflow_id, user_id, "edit") + except AccessDenied as denied: + return _denied(denied) + # Validated as the owner: the workflow runs with the owner's + # models, so their BYOM ids are the ones that must resolve. + validation_errors = validate_workflow_structure( + nodes_data, edges_data, user_id=acting + ) + if validation_errors: + return error_response( + "Workflow validation failed", errors=validation_errors + ) + nodes_data = normalize_agent_node_json_schemas(nodes_data) pg_workflow_id = str(workflow["id"]) current_graph_version = get_workflow_graph_version(workflow) next_graph_version = current_graph_version + 1 @@ -557,7 +615,7 @@ class WorkflowDetail(Resource): nodes_data, edges_data, ) repo.update( - pg_workflow_id, user_id, + pg_workflow_id, acting, { "name": name, "description": description, @@ -582,11 +640,12 @@ class WorkflowDetail(Resource): try: with db_session() as conn: repo = WorkflowsRepository(conn) - workflow = _resolve_workflow(repo, workflow_id, user_id) - if workflow is None: - return error_response("Workflow not found", 404) + try: + workflow, acting = _workflow_access(conn, workflow_id, user_id, "delete") + except AccessDenied as denied: + return _denied(denied) # ON DELETE CASCADE on workflow_nodes/edges cleans children. - repo.delete(str(workflow["id"]), user_id) + repo.delete(str(workflow["id"]), acting) except Exception as err: return _workflow_error_response("Failed to delete workflow", err) diff --git a/docsgpt/storage/db/models.py b/docsgpt/storage/db/models.py index ebd25567..1e6c3866 100644 --- a/docsgpt/storage/db/models.py +++ b/docsgpt/storage/db/models.py @@ -187,6 +187,23 @@ Index( team_resource_grants_table.c.resource_id, ) +# Per-asset sharing switches set by the owner (migration 0038). A missing row +# means every switch is at its default; keys are validated in +# ``docsgpt/api/user/resource_access.py``. +resource_share_settings_table = Table( + "resource_share_settings", + metadata, + Column("resource_type", Text, primary_key=True), + Column("resource_id", UUID(as_uuid=True), primary_key=True), + Column("settings", JSONB, nullable=False, server_default="{}"), + Column("updated_by", Text), + Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()), + CheckConstraint( + "resource_type IN ('agent', 'source', 'prompt', 'tool')", + name="resource_share_settings_type_check", + ), +) + prompts_table = Table( "prompts", @@ -218,6 +235,22 @@ user_tools_table = Table( Column("legacy_mongo_id", Text), ) +# A grantee's personal "In my chats" switch for a tool shared with them +# (migration 0038). The owner's own switch stays ``user_tools.status``. +user_tool_preferences_table = Table( + "user_tool_preferences", + metadata, + Column("user_id", Text, primary_key=True), + Column( + "tool_id", + UUID(as_uuid=True), + ForeignKey("user_tools.id", ondelete="CASCADE"), + primary_key=True, + ), + Column("in_chat", Boolean, nullable=False, server_default="false"), + Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()), +) + token_usage_table = Table( "token_usage", metadata, diff --git a/docsgpt/storage/db/repositories/team_resource_grants.py b/docsgpt/storage/db/repositories/team_resource_grants.py index 8492591e..7af5402b 100644 --- a/docsgpt/storage/db/repositories/team_resource_grants.py +++ b/docsgpt/storage/db/repositories/team_resource_grants.py @@ -107,8 +107,9 @@ class TeamResourceGrantsRepository: that one member (the caller must have validated they're a team member). ``ON CONFLICT`` on the functional dedup index makes re-sharing last-write-wins on ``access_level``. The caller MUST have verified - ``granted_by`` owns the resource (dispatched by ``resource_type``) — the - polymorphic table has no FK to catch a type/id mismatch. + ``granted_by`` holds ``share`` on the resource (``resource_access.require``, + dispatched by ``resource_type``) and pass the real owner as ``owner_id`` — + the polymorphic table has no FK to catch a type/id mismatch. """ result = self._conn.execute( text( diff --git a/docsgpt/storage/db/repositories/user_tool_preferences.py b/docsgpt/storage/db/repositories/user_tool_preferences.py new file mode 100644 index 00000000..29ad5986 --- /dev/null +++ b/docsgpt/storage/db/repositories/user_tool_preferences.py @@ -0,0 +1,91 @@ +"""Repository for the ``user_tool_preferences`` table. + +A grantee's personal "In my chats" switch for a tool shared with them. The +owner's own switch stays in ``user_tools.status``; a missing row here means +off, so sharing a tool never adds it to anyone's chats. +""" + +from __future__ import annotations + +from typing import Iterable + +from sqlalchemy import Connection, text + +from docsgpt.storage.db.base_repository import looks_like_uuid + + +class UserToolPreferencesRepository: + """Per-user, per-tool chat preferences for shared tools.""" + + def __init__(self, conn: Connection) -> None: + self._conn = conn + + def set_in_chat(self, user_id: str, tool_id: str, in_chat: bool) -> None: + """Upsert the caller's "In my chats" switch for one tool. + + Args: + user_id: The grantee's user id. + tool_id: The shared tool's UUID. + in_chat: Whether the tool joins the grantee's agentless chats. + """ + self._conn.execute( + text( + """ + INSERT INTO user_tool_preferences (user_id, tool_id, in_chat) + VALUES (:user_id, CAST(:tool_id AS uuid), :in_chat) + ON CONFLICT (user_id, tool_id) + DO UPDATE SET in_chat = EXCLUDED.in_chat, updated_at = now() + """ + ), + {"user_id": user_id, "tool_id": str(tool_id), "in_chat": bool(in_chat)}, + ) + + def in_chat_many(self, user_id: str, tool_ids: Iterable[str]) -> dict[str, bool]: + """``tool_id -> in_chat`` for the given tools; missing rows are False. + + Args: + user_id: The grantee's user id. + tool_ids: Tool ids to look up (non-UUIDs are ignored). + + Returns: + A dict with one entry per UUID-shaped input id. + """ + ids = [str(t) for t in tool_ids if looks_like_uuid(str(t))] + out = {tid: False for tid in ids} + if not ids or not user_id: + return out + rows = self._conn.execute( + text( + """ + SELECT tool_id, in_chat FROM user_tool_preferences + WHERE user_id = :user_id AND tool_id = ANY(CAST(:ids AS uuid[])) + """ + ), + {"user_id": user_id, "ids": ids}, + ).fetchall() + for tool_id, in_chat in rows: + out[str(tool_id)] = bool(in_chat) + return out + + def list_in_chat_tool_ids(self, user_id: str) -> list[str]: + """Ids of tools the user switched into their chats (any owner). + + Args: + user_id: The grantee's user id. + + Returns: + Tool ids as strings; access must still be re-checked by the caller. + """ + if not user_id: + return [] + rows = self._conn.execute( + text( + """ + SELECT tool_id FROM user_tool_preferences + WHERE user_id = :user_id AND in_chat = true + ORDER BY updated_at + """ + ), + {"user_id": user_id}, + ).fetchall() + return [str(r[0]) for r in rows] diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index eaf5651a..4e918b93 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -890,7 +890,10 @@ line, a trailing control) is `ListRow` inside `ListRows` (`divide-y divide-border`, no box of its own; wrap it in `Card padding="none"` or a bordered list for one). Rows are `px-4 py-3`, the title `text-sm font-medium`. `interactive` (with `asChild` around a `` or ` + ) : null, +})); vi.mock('../preferences/PromptsModal', () => ({ default: () => null })); vi.mock('../navigation/SectionPills', () => ({ default: () =>
, @@ -534,3 +552,178 @@ describe('NewAgent form', () => { } }); }); + +describe('NewAgent gating by role', () => { + let container: HTMLDivElement; + let root: Root; + + const OWNER = [ + 'delete', + 'edit', + 'edit_policy', + 'export', + 'manage_access_details', + 'manage_schedules', + 'manage_settings', + 'move_folder', + 'pin', + 'publish', + 'share', + 'use', + 'view', + 'view_logs', + ]; + const EDITOR = [ + 'edit', + 'edit_policy', + 'export', + 'manage_access_details', + 'manage_schedules', + 'pin', + 'publish', + 'use', + 'view', + 'view_logs', + ]; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + mocks.dispatch.mockClear(); + mocks.getAgent.mockReset(); + mocks.getAgent.mockImplementation(() => jsonResponse({})); + mocks.deleteAgent.mockReset(); + mocks.deleteAgent.mockImplementation(() => jsonResponse({})); + mocks.guardrailsProps.mockClear(); + }); + + const renderEdit = async (access: 'owner' | 'editor', allowed: string[]) => { + mocks.getAgent.mockImplementation(() => + jsonResponse({ + id: 'agent-1', + name: 'Deal Desk', + description: 'Researches deals', + status: 'published', + agent_type: 'classic', + prompt_id: 'default', + ownership: access === 'owner' ? 'user' : 'team', + team_access: access === 'owner' ? null : access, + access, + allowed_actions: allowed, + }), + ); + await act(async () => { + root.render( + + + } + /> + + , + ); + }); + }; + + const buttonByText = (text: string) => + Array.from(container.querySelectorAll('button')).find((b) => + b.textContent?.includes(text), + ); + + const menuLabels = async () => { + const menu = container.querySelector( + 'button[aria-label="agents.form.buttons.moreActions"]', + )!; + await act(async () => { + menu.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0 }), + ); + }); + return Array.from( + document.querySelectorAll('[role="menuitem"]'), + ).map((item) => item.textContent); + }; + + const lastGuardrailsDisabled = () => + mocks.guardrailsProps.mock.calls.at(-1)?.[0].disabled; + + it('gives the owner Share, Access details and the danger zone', async () => { + await renderEdit('owner', OWNER); + expect(buttonByText('agents.form.dangerZone.deleteButton')).toBeDefined(); + expect(await menuLabels()).toEqual([ + 'agents.form.buttons.accessDetails', + 'agents.shareWithTeam', + ]); + }); + + it('hides Share and Delete from an editor but keeps Access details', async () => { + await renderEdit('editor', EDITOR); + expect(buttonByText('agents.form.dangerZone.deleteButton')).toBeUndefined(); + expect(await menuLabels()).toEqual(['agents.form.buttons.accessDetails']); + }); + + it('lets an editor change guardrails and quotas', async () => { + await renderEdit('editor', EDITOR); + expect(lastGuardrailsDisabled()).toBe(false); + await act(async () => + buttonByText('agents.form.sections.advanced')!.click(), + ); + const switches = + container.querySelectorAll('[role="switch"]'); + expect(switches.length).toBeGreaterThan(0); + for (const s of Array.from(switches)) expect(s.disabled).toBe(false); + }); + + it('locks guardrails and quotas without edit_policy', async () => { + await renderEdit( + 'editor', + EDITOR.filter((a) => a !== 'edit_policy'), + ); + expect(lastGuardrailsDisabled()).toBe(true); + await act(async () => + buttonByText('agents.form.sections.advanced')!.click(), + ); + const token = container.querySelector( + 'input[placeholder="agents.form.placeholders.enterTokenLimit"]', + )!; + const tokenSwitch = token + .closest('[data-slot="setting-row"]') + ?.querySelector('[role="switch"]'); + expect(tokenSwitch?.disabled).toBe(true); + expect(token.disabled).toBe(true); + }); + + it('hides Access details without manage_access_details', async () => { + await renderEdit( + 'editor', + EDITOR.filter((a) => a !== 'manage_access_details'), + ); + expect(await menuLabels()).toEqual([]); + }); + + it('reports a failed delete in a toast instead of throwing', async () => { + mocks.deleteAgent.mockImplementation(() => + jsonResponse({ message: 'Only the owner can delete' }, false), + ); + await renderEdit('owner', OWNER); + await act(async () => + buttonByText('agents.form.dangerZone.deleteButton')!.click(), + ); + await act(async () => + container + .querySelector('[data-testid="confirm-delete"]')! + .click(), + ); + expect(mocks.dispatch).toHaveBeenCalledWith({ + type: 'actionToast/showActionToast', + payload: { variant: 'destructive', message: 'Only the owner can delete' }, + }); + }); +}); diff --git a/frontend/src/agents/NewAgent.tsx b/frontend/src/agents/NewAgent.tsx index e240f007..9176bd44 100644 --- a/frontend/src/agents/NewAgent.tsx +++ b/frontend/src/agents/NewAgent.tsx @@ -56,6 +56,7 @@ import AgentDetailsModal from '../modals/AgentDetailsModal'; import ShareToTeamModal from '../teams/ShareToTeamModal'; import ConfirmationModal from '../modals/ConfirmationModal'; import { ActiveState, Prompt } from '../models/misc'; +import { showActionToast } from '../notifications/actionToastSlice'; import { selectAgentFolders, selectSelectedAgent, @@ -69,6 +70,7 @@ import { import PromptsModal from '../preferences/PromptsModal'; import Prompts from '../settings/Prompts'; import { UserToolType } from '../settings/types'; +import { can } from '../utils/accessUtils'; import Upload from '../upload/Upload'; import { selectedSourceIdsFromAgent, @@ -78,7 +80,7 @@ import { } from '../utils/sourceUtils'; import { getToolDisplayName, - isClassicAgentToolVisible, + isAgentPickerToolVisible, } from '../utils/toolUtils'; import { agentsListPath } from './paths'; import GuardrailsSection, { @@ -359,9 +361,27 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { }; const handleDelete = async (agentId: string) => { - const response = await userService.deleteAgent(agentId, token); - if (!response.ok) throw new Error('Failed to delete agent'); - navigateBackToAgents(); + try { + const response = await userService.deleteAgent(agentId, token); + if (!response.ok) { + dispatch( + showActionToast({ + variant: 'destructive', + message: await extractApiError(response, t('agents.deleteFailed')), + }), + ); + return; + } + navigateBackToAgents(); + } catch (error) { + console.error('Error deleting agent:', error); + dispatch( + showActionToast({ + variant: 'destructive', + message: t('agents.deleteFailed'), + }), + ); + } }; const handleSaveDraft = async () => { @@ -588,7 +608,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { // Hide workflow-only builtins (e.g. read_document) from the classic // agent picker; they belong to the workflow-node picker only. const visibleTools = (data.tools as UserToolType[]).filter( - isClassicAgentToolVisible, + isAgentPickerToolVisible, ); const devicesById = new Map< string, @@ -829,6 +849,12 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { }, [agent, dispatch, effectiveMode, imageFile, jsonSchemaText]); const isPublished = agent.status === 'published'; + // What the caller's role allows on this agent (`allowed_actions` from the + // API). A new agent carries no access fields, so it reads as the owner's. + const canEditPolicy = can(agent, 'edit_policy'); + // Save on a published agent is an edit; on a draft or a new agent the main + // button publishes it. + const canSubmit = can(agent, effectiveMode === 'edit' ? 'edit' : 'publish'); const agentDisplayName = agent.name?.trim() || t('agents.pageHeader.fallbackName'); @@ -845,7 +871,8 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { onClick: () => setPreviewOpen(true), }, ]), - ...(modeConfig[effectiveMode].showAccessDetails + ...(modeConfig[effectiveMode].showAccessDetails && + can(agent, 'manage_access_details') ? [ { label: t('agents.form.buttons.accessDetails'), @@ -853,10 +880,9 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { }, ] : []), - // Sharing is owner-only — hidden for agents shared into the workspace by - // a team (ownership === 'team'). + // Sharing is the owner's, unless the owner lets editors share. ...(modeConfig[effectiveMode].showAccessDetails && - agent.ownership !== 'team' && + can(agent, 'share') && agent.id ? [ { @@ -882,7 +908,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { {t('agents.form.buttons.cancel')} )} - {modeConfig[effectiveMode].showSaveDraft && ( + {modeConfig[effectiveMode].showSaveDraft && can(agent, 'edit') && ( + {canSubmit && ( + + )}
); @@ -1369,7 +1397,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { : undefined, }) } - disabled={!agent.limited_token_mode} + disabled={!agent.limited_token_mode || !canEditPolicy} placeholder={t( 'agents.form.placeholders.enterTokenLimit', )} @@ -1381,6 +1409,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { { setAgent({ ...agent, @@ -1411,7 +1440,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { : undefined, }) } - disabled={!agent.limited_request_mode} + disabled={!agent.limited_request_mode || !canEditPolicy} placeholder={t( 'agents.form.placeholders.enterRequestLimit', )} @@ -1423,6 +1452,7 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { { setAgent({ ...agent, @@ -1459,15 +1489,11 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { setAgent({ @@ -1476,29 +1502,31 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) { }) } /> - {modeConfig[effectiveMode].showDelete && agent.id && ( - - - - - )} + + + + )} - {t('agents.form.buttons.publish')} - + can(agent, 'publish') ? ( + + ) : undefined } /> diff --git a/frontend/src/agents/agentAccess.test.ts b/frontend/src/agents/agentAccess.test.ts new file mode 100644 index 00000000..8eb309f6 --- /dev/null +++ b/frontend/src/agents/agentAccess.test.ts @@ -0,0 +1,54 @@ +import { canAgent, canOpenAgentEditor } from './agentAccess'; +import type { Agent } from './types'; + +const agent = (fields: Partial) => ({ id: 'a1', ...fields }) as Agent; + +describe('canOpenAgentEditor', () => { + it('follows the list copy of the agent when there is one', () => { + const listed = agent({ access: 'editor', allowed_actions: ['view'] }); + expect(canOpenAgentEditor(agent({}), [listed])).toBe(true); + const viewer = agent({ access: 'viewer', allowed_actions: ['use'] }); + expect(canOpenAgentEditor(agent({}), [viewer])).toBe(false); + }); + + it('uses the selected agent when it carries the actions', () => { + expect( + canOpenAgentEditor( + agent({ access: 'viewer', allowed_actions: ['pin', 'use'] }), + [], + ), + ).toBe(false); + expect( + canOpenAgentEditor(agent({ access: 'owner', allowed_actions: ['view'] })), + ).toBe(true); + }); + + it('refuses an unlisted agent with no access fields', () => { + expect(canOpenAgentEditor(agent({}), [])).toBe(false); + expect(canOpenAgentEditor(null, [])).toBe(false); + }); +}); + +describe('canAgent', () => { + const all = ['view', 'view_logs', 'manage_schedules', 'pin']; + it('follows allowed_actions on a published agent', () => { + const a = agent({ + status: 'published', + access: 'editor', + allowed_actions: all, + }); + expect(all.every((x) => canAgent(a, x))).toBe(true); + }); + + it('drops Logs, Schedules and Pin on a draft, keeps the editor', () => { + const a = agent({ status: 'draft', access: 'owner', allowed_actions: all }); + expect(canAgent(a, 'view')).toBe(true); + expect(canAgent(a, 'view_logs')).toBe(false); + expect(canAgent(a, 'manage_schedules')).toBe(false); + expect(canAgent(a, 'pin')).toBe(false); + }); + + it('treats an agent with no status yet as published', () => { + expect(canAgent(agent({ allowed_actions: all }), 'view_logs')).toBe(true); + }); +}); diff --git a/frontend/src/agents/agentAccess.ts b/frontend/src/agents/agentAccess.ts new file mode 100644 index 00000000..83ef2505 --- /dev/null +++ b/frontend/src/agents/agentAccess.ts @@ -0,0 +1,51 @@ +import { can, type AccessFields } from '../utils/accessUtils'; +import type { Agent } from './types'; + +/** + * Whether the chat header and the phone top bar offer Edit for an agent. + * + * Prefers the agent list's copy, which carries the server's access fields. + * An agent that is not in the list (a template, a link-shared agent) must + * carry `allowed_actions` itself; without them it gets no Edit, so a record + * with no access fields is never taken for the caller's own. + * + * @param agent The agent the chat is with. + * @param agents The caller's agent list (own and team-shared). + * @returns True when the role may open the agent's edit page. + */ +export function canOpenAgentEditor( + agent: Agent | null | undefined, + agents?: Agent[] | null, +): boolean { + if (!agent?.id) return false; + const listed = agents?.find((a) => a.id === agent.id); + if (listed) return can(listed, 'view'); + return Boolean(agent.allowed_actions) && can(agent, 'view'); +} + +/** Actions that only make sense once an agent is published. */ +const PUBLISHED_ONLY = new Set(['view_logs', 'manage_schedules', 'pin']); + +/** + * `can()` for an agent, minus what a draft can't have: a draft has no runs + * to log, no schedule that fires and nothing to pin, so Logs, Schedules and + * Pin wait until it's published. An agent without a status (a record still + * loading) is treated as published. + * + * @param agent The agent, with its access fields and status. + * @param action The agent action to check. + * @returns True when the role allows it and the agent's state makes sense. + */ +export function canAgent( + agent: (AccessFields & { status?: string }) | null | undefined, + action: string, +): boolean { + if (!agent) return false; + if ( + PUBLISHED_ONLY.has(action) && + agent.status && + agent.status !== 'published' + ) + return false; + return can(agent, action); +} diff --git a/frontend/src/agents/index.tsx b/frontend/src/agents/index.tsx index 380fd188..2ead2092 100644 --- a/frontend/src/agents/index.tsx +++ b/frontend/src/agents/index.tsx @@ -1,6 +1,7 @@ import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; import AgentLogs from './AgentLogs'; +import AgentRouteGuard from './AgentRouteGuard'; import AgentsList from './AgentsList'; import NewAgent from './NewAgent'; import { AGENTS_MANAGE_ROOT } from './paths'; @@ -31,13 +32,40 @@ export default function Agents() { } /> } /> } /> - } /> - } /> - } /> + {/* An agent's pages open only for a role that may use them; the + guard sends anyone else back to the list. */} + + + + } + /> + + + + } + /> + + + + } + /> } /> } + element={ + + + + } /> {/* Using an agent someone shared. */} diff --git a/frontend/src/agents/types/index.ts b/frontend/src/agents/types/index.ts index 44ce6886..8fa98a32 100644 --- a/frontend/src/agents/types/index.ts +++ b/frontend/src/agents/types/index.ts @@ -1,3 +1,5 @@ +import type { AccessFields } from '../../utils/accessUtils'; + export type ToolSummary = { id: string; name: string; @@ -30,6 +32,9 @@ export type Agent = { // sharing with a team) are gated on 'user'. ownership?: 'user' | 'team'; team_access?: 'viewer' | 'editor' | null; + /** The caller's role and the actions it allows (see `utils/accessUtils`). */ + access?: AccessFields['access']; + allowed_actions?: AccessFields['allowed_actions']; // Owner-agnostic display names resolved server-side (GET /api/get_agent) so a // team member viewing a shared agent sees the owner's prompt/source names // instead of a blank prompt / "External KB" (the client can only resolve diff --git a/frontend/src/agents/workflow/WorkflowBuilder.tsx b/frontend/src/agents/workflow/WorkflowBuilder.tsx index bfcb2e9a..0d765d88 100644 --- a/frontend/src/agents/workflow/WorkflowBuilder.tsx +++ b/frontend/src/agents/workflow/WorkflowBuilder.tsx @@ -1,6 +1,14 @@ import 'reactflow/dist/style.css'; -import { CircleAlert, Link, Pencil, Play, Trash2, X } from 'lucide-react'; +import { + CircleAlert, + Link, + Pencil, + Play, + Trash2, + Users, + X, +} from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; @@ -31,6 +39,8 @@ import userService from '../../api/services/userService'; import AgentDetailsModal from '../../modals/AgentDetailsModal'; import ConfirmationModal from '../../modals/ConfirmationModal'; import { ActiveState } from '../../models/misc'; +import ShareToTeamModal from '../../teams/ShareToTeamModal'; +import { can } from '../../utils/accessUtils'; import { selectSourceDocs, selectToken, @@ -85,6 +95,7 @@ import { validateJsonSchemaConfig, } from './workflowHelpers'; import { selectWorkflowPreviewStatus } from './workflowPreviewSlice'; +import { canAddToolToOwn } from '../../utils/toolUtils'; import type { Model } from '../../models/types'; @@ -219,6 +230,7 @@ function WorkflowBuilderInner() { const [deleteConfirmation, setDeleteConfirmation] = useState('INACTIVE'); const [agentDetails, setAgentDetails] = useState('INACTIVE'); + const [shareModalOpen, setShareModalOpen] = useState(false); const [isDeletingAgent, setIsDeletingAgent] = useState(false); const [currentAgent, setCurrentAgent] = useState( createEmptyWorkflowAgent(), @@ -789,7 +801,10 @@ function WorkflowBuilderInner() { const toolsResponse = await userService.getUserTools(token); if (toolsResponse.ok) { const toolsData = await toolsResponse.json(); - setAvailableTools(toolsData.tools); + // Shared tools the caller can't add to their own agents stay out. + setAvailableTools( + (toolsData.tools as UserTool[]).filter(canAddToolToOwn), + ); } } catch (error) { console.error('Failed to load models or tools:', error); @@ -1517,8 +1532,11 @@ function WorkflowBuilderInner() { }); }, [detailsSaveRequested, persistWorkflow]); + // Save on a saved workflow is an edit; the first save publishes it. A new + // workflow has no access fields, so it reads as the owner's. + const canSubmit = can(currentAgent, canManageAgent ? 'edit' : 'publish'); const isPrimaryActionDisabled = - isPublishing || (canManageAgent && !hasSavableChanges); + !canSubmit || isPublishing || (canManageAgent && !hasSavableChanges); const primaryActionLabel = canManageAgent ? t('agents.form.buttons.save') : t('agents.form.buttons.publish'); @@ -1642,6 +1660,7 @@ function WorkflowBuilderInner() { agentEditPath={agentEditPath(effectiveAgentId, true)} agentImage={currentAgentImage} currentPage="overview" + access={canManageAgent ? currentAgent : undefined} onNameClick={openDetails} status={ canManageAgent && currentAgent.status !== 'draft' ? ( @@ -1678,16 +1697,18 @@ function WorkflowBuilderInner() { {t('agents.form.sections.preview')} - + {canSubmit && ( + + )} setAgentDetails('ACTIVE'), }, + ] + : []), + ...(canManageAgent && can(currentAgent, 'share') + ? [ + { + label: t('agents.shareWithTeam'), + icon: Users, + onClick: () => setShareModalOpen(true), + }, + ] + : []), + ...(canManageAgent && can(currentAgent, 'delete') + ? [ { label: t('agents.form.buttons.delete'), icon: Trash2, @@ -1927,6 +1961,14 @@ function WorkflowBuilderInner() { cancelLabel={t('agents.form.buttons.cancel')} variant="destructive" /> + {shareModalOpen && effectiveAgentId && ( + setShareModalOpen(false)} + /> + )} {canManageAgent && ( `/api/teams/${id}/transfer_owner`, RESOURCE_SHARES: (resourceType: string, resourceId: string) => `/api/resource_shares?resource_type=${resourceType}&resource_id=${resourceId}`, + RESOURCE_SETTINGS: '/api/resource_settings', ALL_TEAMS: '/api/admin/teams', PROMPTS: '/api/get_prompts', CREATE_PROMPT: '/api/create_prompt', diff --git a/frontend/src/api/services/teamsService.test.ts b/frontend/src/api/services/teamsService.test.ts new file mode 100644 index 00000000..0fc3fd60 --- /dev/null +++ b/frontend/src/api/services/teamsService.test.ts @@ -0,0 +1,81 @@ +const get = vi.fn(); +const post = vi.fn(); +const put = vi.fn(); +const del = vi.fn(); + +vi.mock('../client', () => ({ + default: { + get: (...args: unknown[]) => get(...args), + post: (...args: unknown[]) => post(...args), + put: (...args: unknown[]) => put(...args), + delete: (...args: unknown[]) => del(...args), + }, +})); + +import teamsService, { TeamsApiError } from './teamsService'; + +const response = (status: number, body: unknown) => + ({ + ok: status >= 200 && status < 300, + status, + json: () => Promise.resolve(body), + }) as unknown as Response; + +describe('teamsService', () => { + beforeEach(() => { + get.mockReset(); + post.mockReset(); + put.mockReset(); + del.mockReset(); + }); + + it('returns the parsed body on 2xx', async () => { + get.mockResolvedValue(response(200, { success: true, teams: [] })); + await expect(teamsService.list('t')).resolves.toEqual({ + success: true, + teams: [], + }); + }); + + it('throws with the server message on 403', async () => { + del.mockResolvedValue( + response(403, { success: false, message: 'Only the owner can delete' }), + ); + const error = await teamsService.remove('team-1', 't').catch((e) => e); + expect(error).toBeInstanceOf(TeamsApiError); + expect(error.status).toBe(403); + expect(error.message).toBe('Only the owner can delete'); + }); + + it('throws on a non-2xx with no JSON body', async () => { + get.mockResolvedValue({ + ok: false, + status: 500, + json: () => Promise.reject(new Error('not json')), + }); + const error = await teamsService.list('t').catch((e) => e); + expect(error).toBeInstanceOf(TeamsApiError); + expect(error.status).toBe(500); + }); + + it('reads and writes resource settings', async () => { + get.mockResolvedValue(response(200, { success: true, settings: [] })); + await teamsService.getResourceSettings('agent', 'a 1', 't'); + expect(get.mock.calls[0][0]).toBe( + '/api/resource_settings?resource_type=agent&resource_id=a%201', + ); + put.mockResolvedValue(response(200, { success: true, settings: [] })); + await teamsService.updateResourceSettings( + 'agent', + 'a1', + { editors_can_share: true }, + 't', + ); + expect(put.mock.calls[0][0]).toBe('/api/resource_settings'); + expect(put.mock.calls[0][1]).toEqual({ + resource_type: 'agent', + resource_id: 'a1', + settings: { editors_can_share: true }, + }); + }); +}); diff --git a/frontend/src/api/services/teamsService.ts b/frontend/src/api/services/teamsService.ts index 2d460feb..818096de 100644 --- a/frontend/src/api/services/teamsService.ts +++ b/frontend/src/api/services/teamsService.ts @@ -25,13 +25,73 @@ export type ResourceShare = { team_slug?: string; access_level: AccessLevel; target_user_id?: string | null; + created_at?: string | null; }; +// A grant row from GET /api/teams//grants. The server resolves names and +// labels, plus the caller's own access to the resource (`caller`). +export type TeamGrant = { + resource_type: ResourceType; + resource_id: string; + access_level: AccessLevel; + target_user_id?: string | null; + resource_name?: string | null; + owner_id?: string | null; + owner_label?: string | null; + target_user_label?: string | null; + created_at?: string | null; + granted_by?: string | null; + granted_by_label?: string | null; + caller?: { + access: 'owner' | 'editor' | 'viewer'; + allowed_actions: string[]; + } | null; +}; + +// One owner switch on a resource (`resource_share_settings`). +export type ResourceSetting = { key: string; value: boolean; default: boolean }; + +export type ResourceSettingsResponse = { + success: boolean; + resource_type: ResourceType; + resource_id: string; + settings: ResourceSetting[]; + access?: 'owner' | 'editor' | 'viewer' | null; + allowed_actions?: string[]; +}; + +/** A non-2xx teams API response; `message` is the server's own message. */ +export class TeamsApiError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.name = 'TeamsApiError'; + this.status = status; + } +} + // apiClient resolves to the raw fetch Response (the app convention); services // consumed by slices/components parse the JSON here so callers get plain data. +// A non-2xx status rejects with the server's message, so callers never mistake +// a 403/404 for success. const json = async (response: Response | unknown) => { const r = response as Response; if (!r || !('json' in r) || typeof r.json !== 'function') return r as unknown; + if (typeof r.ok === 'boolean' && !r.ok) { + let message = `Request failed (${r.status})`; + try { + const body = await r.json(); + if (body && typeof body.message === 'string' && body.message) { + message = body.message; + } else if (body && typeof body.error === 'string' && body.error) { + message = body.error; + } + } catch { + // Not JSON: keep the generic message. + } + throw new TeamsApiError(r.status, message); + } return r.json(); }; @@ -154,6 +214,37 @@ const teamsService = { ), ), + getResourceSettings: async ( + resourceType: ResourceType, + resourceId: string, + token: string | null, + ): Promise => + json( + await apiClient.get( + `${endpoints.USER.RESOURCE_SETTINGS}?resource_type=${resourceType}&resource_id=${encodeURIComponent( + resourceId, + )}`, + token, + ), + ) as Promise, + updateResourceSettings: async ( + resourceType: ResourceType, + resourceId: string, + settings: Record, + token: string | null, + ): Promise => + json( + await apiClient.put( + endpoints.USER.RESOURCE_SETTINGS, + { + resource_type: resourceType, + resource_id: resourceId, + settings, + }, + token, + ), + ) as Promise, + listAll: async (token: string | null): Promise => json(await apiClient.get(endpoints.USER.ALL_TEAMS, token)), }; diff --git a/frontend/src/components/Chunks.test.tsx b/frontend/src/components/Chunks.test.tsx index 93460cda..8ec5c9f3 100644 --- a/frontend/src/components/Chunks.test.tsx +++ b/frontend/src/components/Chunks.test.tsx @@ -843,4 +843,39 @@ describe('Chunks', () => { await act(async () => buttonByText('retry')!.click()); expect(tile()).not.toBeNull(); }); + const openReaderMenu = async () => { + const trigger = buttonByLabel('settings.sources.menuAlt')!; + await act(async () => { + trigger.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0 }), + ); + trigger.click(); + }); + return Array.from( + document.querySelectorAll('[role="menuitem"]'), + ).map((el) => el.textContent); + }; + + it('read-only (canEdit false): no Add chunk, Edit or Delete; reading stays', async () => { + await render({ embedded: true, canEdit: false }); + expect(buttonByText('settings.sources.addChunk')).toBeUndefined(); + await act(async () => tile()!.click()); + expect(container.querySelector('h2')?.textContent).toBe( + 'Late pickup clause', + ); + expect(buttonByText('modals.chunk.edit')).toBeUndefined(); + expect(buttonByLabel('settings.sources.nextChunk')).not.toBeNull(); + expect(await openReaderMenu()).toEqual(['settings.sources.copyText']); + }); + + it('an editor (canEdit true) keeps Add chunk, Edit and Delete', async () => { + await render({ embedded: true, canEdit: true }); + expect(buttonByText('settings.sources.addChunk')).toBeDefined(); + await act(async () => tile()!.click()); + expect(buttonByText('modals.chunk.edit')).toBeDefined(); + expect(await openReaderMenu()).toEqual([ + 'settings.sources.copyText', + 'modals.chunk.delete', + ]); + }); }); diff --git a/frontend/src/components/Chunks.tsx b/frontend/src/components/Chunks.tsx index 0ab28f53..cbfea62c 100644 --- a/frontend/src/components/Chunks.tsx +++ b/frontend/src/components/Chunks.tsx @@ -89,6 +89,11 @@ interface ChunksProps { /** Where the open chunk is; see {@link OpenChunkPosition}. */ onOpenChunkChange?: (position: OpenChunkPosition) => void; controllerRef?: React.MutableRefObject; + /** + * Whether the caller may change the source (`can(source, 'edit')`). False + * hides Add chunk, Edit and Delete; reading, copying and paging stay. + */ + canEdit?: boolean; } type SheetMode = 'edit' | 'add'; @@ -103,6 +108,7 @@ const Chunks: React.FC = ({ embedded = false, onOpenChunkChange, controllerRef, + canEdit = true, }) => { const { t } = useTranslation(); const dispatch = useDispatch(); @@ -556,15 +562,17 @@ const Chunks: React.FC = ({ })}

) : null} - + {canEdit ? ( + + ) : null} ); @@ -675,16 +683,18 @@ const Chunks: React.FC = ({ disabled={!canGoNext} onClick={() => goToChunk(openPosition + 1)} /> - + {canEdit ? ( + + ) : null} = ({ ); }, }, - { - icon: Trash2, - label: t('modals.chunk.delete'), - variant: 'destructive', - onClick: () => confirmDeleteChunk(chunk), - }, + ...(canEdit + ? [ + { + icon: Trash2, + label: t('modals.chunk.delete'), + variant: 'destructive' as const, + onClick: () => confirmDeleteChunk(chunk), + }, + ] + : []), ]} /> diff --git a/frontend/src/components/ConnectorTree.test.tsx b/frontend/src/components/ConnectorTree.test.tsx index cde38ca5..f5a6ebb3 100644 --- a/frontend/src/components/ConnectorTree.test.tsx +++ b/frontend/src/components/ConnectorTree.test.tsx @@ -1,6 +1,15 @@ import { act, useState } from 'react'; import { createRoot, type Root } from 'react-dom/client'; +const { tree } = vi.hoisted(() => ({ + tree: { + structure: { + 'a.docx': { type: 'docx' }, + 'b.docx': { type: 'docx' }, + } as Record, + }, +})); + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); @@ -24,10 +33,7 @@ vi.mock('../api/services/userService', () => ({ getDirectoryStructure: vi.fn(async () => ({ json: async () => ({ provider: 'google_drive', - directory_structure: { - 'a.docx': { type: 'docx' }, - 'b.docx': { type: 'docx' }, - }, + directory_structure: tree.structure, }), })), getDocumentChunks: vi.fn(async () => ({ @@ -144,4 +150,85 @@ describe('ConnectorTree and FileTree headers', () => { ); expect(crumbs()).toEqual(['settings.sources.label', 'Files', 'a.docx']); }); + const openFirstRowMenu = async () => { + const trigger = container.querySelector( + 'tbody button[aria-label="settings.sources.menuAlt"]', + )!; + await act(async () => { + trigger.dispatchEvent( + new PointerEvent('pointerdown', { bubbles: true, button: 0 }), + ); + trigger.click(); + }); + return Array.from( + document.querySelectorAll('[role="menuitem"]'), + ).map((el) => el.textContent); + }; + + it('read-only ConnectorTree hides Sync, keeps headerAction', async () => { + await render( + , + ); + expect(container.textContent).toContain('retrieval'); + expect(container.textContent).not.toContain('settings.sources.sync'); + }); + + it('read-only FileTree hides Add file and the row Delete', async () => { + await render( + , + ); + expect(container.textContent).toContain('retrieval'); + expect(container.textContent).not.toContain('settings.sources.addFile'); + expect(await openFirstRowMenu()).not.toContain('convTile.delete'); + }); + + it('an editable FileTree keeps Add file and the row Delete', async () => { + await render( + , + ); + expect(container.textContent).toContain('settings.sources.addFile'); + expect(await openFirstRowMenu()).toContain('convTile.delete'); + }); + + it('a read-only one-file FileTree has no header menu and no Add chunk', async () => { + tree.structure = { 'a.docx': { type: 'docx' } }; + try { + await render( + , + ); + expect( + container.querySelector( + 'button[aria-label="settings.sources.menuAlt"]', + ), + ).toBeNull(); + expect(container.textContent).not.toContain('settings.sources.addChunk'); + } finally { + tree.structure = { + 'a.docx': { type: 'docx' }, + 'b.docx': { type: 'docx' }, + }; + } + }); }); diff --git a/frontend/src/components/ConnectorTree.tsx b/frontend/src/components/ConnectorTree.tsx index 3b5bac0a..ffbe2bb2 100644 --- a/frontend/src/components/ConnectorTree.tsx +++ b/frontend/src/components/ConnectorTree.tsx @@ -32,6 +32,11 @@ interface ConnectorTreeProps { initialPath?: string; /** Embedded only: the tree's crumbs, for the host's header (see TreeBrowser). */ onCrumbsChange?: (crumbs: Crumb[]) => void; + /** + * Whether the caller may change the source (`can(source, 'edit')`). + * False hides Sync and the chunk writes; browsing stays. + */ + canEdit?: boolean; } // Provider names are brand names, so they are not translated. @@ -64,6 +69,7 @@ const ConnectorTree: React.FC = ({ actionsTarget, initialPath, onCrumbsChange, + canEdit = true, }) => { const { t } = useTranslation(); const token = useSelector(selectToken); @@ -145,28 +151,30 @@ const ConnectorTree: React.FC = ({ const topRightAction = ( <> {embedded ? null : headerAction} - + {canEdit ? ( + + ) : null} ); @@ -188,6 +196,7 @@ const ConnectorTree: React.FC = ({ onBackToDocuments={onBackToDocuments} embedded={embedded} onCrumbsChange={onCrumbsChange} + canEdit={canEdit} actionsTarget={actionsTarget} initialPath={initialPath} badge={ diff --git a/frontend/src/components/FileTree.tsx b/frontend/src/components/FileTree.tsx index f4f78e1e..1218464c 100644 --- a/frontend/src/components/FileTree.tsx +++ b/frontend/src/components/FileTree.tsx @@ -38,6 +38,11 @@ interface FileTreeProps { initialPath?: string; /** Embedded only: the tree's crumbs, for the host's header (see TreeBrowser). */ onCrumbsChange?: (crumbs: Crumb[]) => void; + /** + * Whether the caller may change the source (`can(source, 'edit')`). + * False hides Add file, file and folder Delete (row and header menus) and the chunk writes; browsing stays. + */ + canEdit?: boolean; } const FileTree: React.FC = ({ @@ -49,6 +54,7 @@ const FileTree: React.FC = ({ actionsTarget, initialPath, onCrumbsChange, + canEdit = true, }) => { const { t } = useTranslation(); const token = useSelector(selectToken); @@ -226,6 +232,8 @@ const FileTree: React.FC = ({ isFile, defaultViewOption, }: RowMenuContext): MenuOption[] => { + // Read-only: View only, so a one-file source draws no header menu. + if (!canEdit) return [defaultViewOption]; return [ defaultViewOption, { @@ -248,7 +256,7 @@ const FileTree: React.FC = ({ const topRightAction = ( <> {embedded ? null : headerAction} - {!isProcessing ? ( + {canEdit && !isProcessing ? ( @@ -281,6 +289,7 @@ const FileTree: React.FC = ({ onBackToDocuments={onBackToDocuments} embedded={embedded} onCrumbsChange={onCrumbsChange} + canEdit={canEdit} actionsTarget={actionsTarget} initialPath={initialPath} columnOrder="size-first" diff --git a/frontend/src/components/GraphView.tsx b/frontend/src/components/GraphView.tsx index 3147edf3..955f59ad 100644 --- a/frontend/src/components/GraphView.tsx +++ b/frontend/src/components/GraphView.tsx @@ -79,6 +79,8 @@ interface GraphViewProps { active?: boolean; /** Show a chunk's file on the Files tab (the chunk drawer's "Open in Files"). */ onOpenInFiles?: (path: string) => void; + /** Whether the chunk drawer offers Edit (`can(source, 'edit')`). */ + canEdit?: boolean; } type PositionedNode = NodeObject & { x?: number; y?: number }; @@ -110,6 +112,7 @@ const GraphView: React.FC = ({ onSelect, active = true, onOpenInFiles, + canEdit = true, }) => { const { t } = useTranslation(); const { isDesktop } = useMediaQuery(); @@ -590,6 +593,7 @@ const GraphView: React.FC = ({ overview={data} onOpenInFiles={onOpenInFiles} onChunkSaved={nodeDetail.reload} + canEdit={canEdit} /> ) : null; diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index 0ea7c995..d1f295a1 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -63,7 +63,9 @@ import { } from '../constants/fileUpload'; import { UserToolType } from '../settings/types'; import { sourceItemId, toSourcePickerItems } from '../utils/sourceUtils'; -import { isChatToolVisible } from '../utils/toolUtils'; +import { showActionToast } from '../notifications/actionToastSlice'; +import { isOwner } from '../utils/accessUtils'; +import { isChatPickerToolVisible, toolInChat } from '../utils/toolUtils'; const generateId = (): string => `${Date.now()}-${Math.random().toString(36).substring(2)}`; @@ -1551,7 +1553,7 @@ export default function MessageInput({ .getUserTools(token) .then((res) => res.json()) .then((data) => { - const filtered = (data.tools || []).filter(isChatToolVisible); + const filtered = (data.tools || []).filter(isChatPickerToolVisible); setUserTools(filtered); }) .catch((error) => { @@ -1568,25 +1570,53 @@ export default function MessageInput({ id: tool.id, label: tool.customName || tool.displayName, icon: , + description: + !isOwner(tool) && tool.shared_via + ? t('settings.tools.sharedBy', { + interpolation: { escapeValue: false }, + team: tool.shared_via, + }) + : undefined, })); const selectedToolIds = userTools - .filter((tool) => tool.status) + .filter((tool) => toolInChat(tool)) .map((tool) => tool.id); + // Ticks at once and unticks again when the server refuses it. + const setToolInChat = (id: string, value: boolean) => + setUserTools((prev) => + prev.map((tool) => + tool.id !== id + ? tool + : isOwner(tool) + ? { ...tool, status: value, in_chat: value } + : { ...tool, in_chat: value }, + ), + ); + const handleToggleTool = (id: string) => { const tool = userTools.find((t) => t.id === id); if (!tool) return; - const newStatus = !tool.status; + const newStatus = !toolInChat(tool); + setToolInChat(id, newStatus); + const fail = () => { + setToolInChat(id, !newStatus); + dispatch( + showActionToast({ + variant: 'destructive', + message: t('settings.tools.statusUpdateFailed'), + }), + ); + }; userService .updateToolStatus({ id, status: newStatus }, token) - .then(() => { - setUserTools((prev) => - prev.map((t) => (t.id === id ? { ...t, status: newStatus } : t)), - ); + .then((response: Response) => { + if (!response.ok) fail(); }) - .catch((error) => { + .catch((error: unknown) => { console.error('Failed to update tool status:', error); + fail(); }); }; diff --git a/frontend/src/components/graph/GraphChunkSheet.tsx b/frontend/src/components/graph/GraphChunkSheet.tsx index d3327277..c616767f 100644 --- a/frontend/src/components/graph/GraphChunkSheet.tsx +++ b/frontend/src/components/graph/GraphChunkSheet.tsx @@ -30,6 +30,8 @@ interface GraphChunkSheetProps { onOpenInFiles?: (path: string) => void; /** Called after a saved edit, to refetch the node detail. */ onSaved?: () => void; + /** Whether the read drawer offers Edit (`can(source, 'edit')`). */ + canEdit?: boolean; } /** @@ -45,6 +47,7 @@ export default function GraphChunkSheet({ onClose, onOpenInFiles, onSaved, + canEdit = true, }: GraphChunkSheetProps) { const { t } = useTranslation(); const dispatch = useDispatch(); @@ -91,6 +94,8 @@ export default function GraphChunkSheet({ ? t('settings.sources.graphrag.view.chunkTokens', { tokens }) : ''; + const showOpenInFiles = !!onOpenInFiles && !!path; + const close = () => { setEditing(false); setSaveFailed(false); @@ -173,32 +178,44 @@ export default function GraphChunkSheet({ highlight={highlight} /> - -
- {onOpenInFiles && path ? ( - - ) : null} - -
+ {/* A reader with nothing to open or edit gets no action row. */} + {showOpenInFiles || canEdit ? ( + <> + +
+ {showOpenInFiles ? ( + + ) : null} + {canEdit ? ( + + ) : null} +
+ + ) : null} void; + /** Whether the chunk drawer offers Edit (`can(source, 'edit')`). */ + canEdit?: boolean; }) { const { t } = useTranslation(); const token = useSelector(selectToken); @@ -157,6 +160,7 @@ export default function GraphEntities({ overview={overview} onOpenInFiles={onOpenInFiles} onChunkSaved={nodeDetail.reload} + canEdit={canEdit} action={ } />, @@ -554,6 +565,75 @@ describe('GraphSourceView', () => { ).toBe(''); }); + it('passes canEdit to every Files view', async () => { + const canEditOf = (testId: string) => + container + .querySelector(`[data-testid="${testId}"]`) + ?.getAttribute('data-can-edit'); + await render(undefined, vi.fn(), true, false); + await openTab('settings.sources.graphrag.view.tabs.files'); + expect(canEditOf('file-tree')).toBe('false'); + await render('connector:file', vi.fn(), true, false); + expect(canEditOf('connector-tree')).toBe('false'); + await render(undefined, vi.fn(), false, false); + expect(canEditOf('chunks')).toBe('false'); + await render(undefined, vi.fn(), false, true); + expect(canEditOf('chunks')).toBe('true'); + }); + + const openEntityChunk = async () => { + service.getSourceGraphNode.mockResolvedValue( + ok({ + node: { + id: 'n', + name: 'Nordhaven', + type: 'Company', + degree: 9, + relationships: [], + chunks: [ + { + chunk_id: 'c1', + text: 'Nordhaven runs the lane.', + metadata: { source: 'briefs/Nordhaven.md' }, + }, + ], + }, + }), + ); + await openTab('settings.sources.graphrag.view.tabs.entities'); + const row = Array.from(container.querySelectorAll('tbody tr')).find((r) => + r.textContent?.includes('Nordhaven'), + ) as HTMLTableRowElement; + await act(async () => row.click()); + await flush(); + const tile = Array.from( + container.querySelectorAll('button[data-slot="card"]'), + ).find((b) => + b.textContent?.includes('Nordhaven runs the lane.'), + ) as HTMLButtonElement; + await act(async () => tile.click()); + }; + + const drawerButtons = () => + Array.from(document.body.querySelectorAll('[role="dialog"] button')).map( + (b) => b.textContent, + ); + + it("a read-only graph's chunk drawer has Open in Files but no Edit", async () => { + await render(undefined, vi.fn(), true, false); + await openEntityChunk(); + expect(drawerButtons()).toContain( + 'settings.sources.graphrag.view.openInFiles', + ); + expect(drawerButtons()).not.toContain('modals.chunk.edit'); + }); + + it("an editor's graph chunk drawer keeps Edit", async () => { + await render(undefined, vi.fn(), true, true); + await openEntityChunk(); + expect(drawerButtons()).toContain('modals.chunk.edit'); + }); + it('a new entity filter fetches page 1 once, not the old page first', async () => { service.getSourceGraphNodes.mockImplementation(async () => ok({ diff --git a/frontend/src/components/graph/GraphSourceView.tsx b/frontend/src/components/graph/GraphSourceView.tsx index 62faa677..584de5c5 100644 --- a/frontend/src/components/graph/GraphSourceView.tsx +++ b/frontend/src/components/graph/GraphSourceView.tsx @@ -50,6 +50,11 @@ interface GraphSourceViewProps { onBackToDocuments: () => void; /** Extra header control (Test retrieval), right-aligned in the title row. */ headerAction?: ReactNode; + /** + * Whether the caller may change the source (`can(source, 'edit')`). False + * hides the Files tab's writes and the graph chunk drawer's Edit. + */ + canEdit?: boolean; } /** @@ -66,6 +71,7 @@ export default function GraphSourceView({ isNested = true, onBackToDocuments, headerAction, + canEdit = true, }: GraphSourceViewProps) { const { t } = useTranslation(); const token = useSelector(selectToken); @@ -172,6 +178,7 @@ export default function GraphSourceView({ const files = !isNested ? ( @@ -286,6 +296,7 @@ export default function GraphSourceView({ onShowInGraph={showInGraph} overview={data} onOpenInFiles={openInFiles} + canEdit={canEdit} /> diff --git a/frontend/src/components/tree/TreeBrowser.test.tsx b/frontend/src/components/tree/TreeBrowser.test.tsx index 9d36bd54..3707ef30 100644 --- a/frontend/src/components/tree/TreeBrowser.test.tsx +++ b/frontend/src/components/tree/TreeBrowser.test.tsx @@ -337,6 +337,17 @@ describe('TreeBrowser', () => { ).not.toBeNull(); }); + it('canEdit false reaches the chunk list: no Add chunk', async () => { + await render({ 'report.pdf': { type: 'pdf' } }, { canEdit: false }); + expect(chunkListOpen()).toBe(true); + expect(container.textContent).not.toContain('settings.sources.addChunk'); + }); + + it('an editable tree keeps Add chunk on the chunk list', async () => { + await render({ 'report.pdf': { type: 'pdf' } }); + expect(container.textContent).toContain('settings.sources.addChunk'); + }); + it('a failed load says so and retries', async () => { const getDirectoryStructure = vi.mocked(userService.getDirectoryStructure); getDirectoryStructure.mockImplementationOnce(async () => { diff --git a/frontend/src/components/tree/TreeBrowser.tsx b/frontend/src/components/tree/TreeBrowser.tsx index d76d3b11..4ca78968 100644 --- a/frontend/src/components/tree/TreeBrowser.tsx +++ b/frontend/src/components/tree/TreeBrowser.tsx @@ -114,6 +114,11 @@ export interface TreeBrowserProps { * changes. */ initialPath?: string; + /** + * Whether the caller may change the source (`can(source, 'edit')`). + * False hides the chunk list's Add, Edit and Delete; browsing stays. + */ + canEdit?: boolean; } /** @@ -194,6 +199,7 @@ const TreeBrowser: React.FC = ({ actionsTarget, initialPath, onCrumbsChange, + canEdit = true, }) => { const { t } = useTranslation(); const [loading, setLoading] = useLoaderState(true, 500); @@ -688,6 +694,7 @@ const TreeBrowser: React.FC = ({ fileName={file.name} controllerRef={chunksControllerRef} onOpenChunkChange={setOpenChunkPosition} + canEdit={canEdit} /> ); diff --git a/frontend/src/components/ui/list-row.test.tsx b/frontend/src/components/ui/list-row.test.tsx index 15094134..327f618c 100644 --- a/frontend/src/components/ui/list-row.test.tsx +++ b/frontend/src/components/ui/list-row.test.tsx @@ -45,4 +45,15 @@ describe('ListRow', () => { expect(html).not.toContain('px-4 py-3'); expect(html).toContain('focus-visible:ring-inset'); }); + + it('keeps the brand tint on a selected row, hover included', () => { + const html = renderToStaticMarkup( + + )} diff --git a/frontend/src/modals/MCPServerModal.test.tsx b/frontend/src/modals/MCPServerModal.test.tsx new file mode 100644 index 00000000..8f49dcf6 --- /dev/null +++ b/frontend/src/modals/MCPServerModal.test.tsx @@ -0,0 +1,200 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +vi.mock('react-redux', () => ({ + useSelector: (selector: (state: unknown) => unknown) => + selector({ notifications: { recentEvents: [] }, preference: {} }), +})); +vi.mock('../preferences/preferenceSlice', () => ({ + selectToken: () => 'token', +})); +vi.mock('../notifications/notificationsSlice', () => ({ + selectRecentEvents: (state: { notifications: { recentEvents: unknown[] } }) => + state.notifications.recentEvents, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + if (!opts || typeof opts !== 'object') return key; + const { defaultValue: _d, interpolation: _i, ...rest } = opts; + void _d; + void _i; + return Object.keys(rest).length ? `${key}:${JSON.stringify(rest)}` : key; + }, + }), +})); + +const testMCPConnection = vi.fn(); +const saveMCPServer = vi.fn(); +vi.mock('../api/services/userService', () => ({ + default: { + testMCPConnection: (...args: unknown[]) => testMCPConnection(...args), + saveMCPServer: (...args: unknown[]) => saveMCPServer(...args), + }, +})); + +import MCPServerModal from './MCPServerModal'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const server = { + id: 'tool-1', + displayName: 'Carrier Rates MCP', + server_url: 'https://mcp.dana-tools.dev/sse', + auth_type: 'api_key', + timeout: 30, + oauth_scopes: '', + has_encrypted_credentials: true, + access: 'owner', + owner_label: null as string | null, +}; + +const json = (body: unknown, ok = true, status = 200) => + Promise.resolve({ ok, status, json: () => Promise.resolve(body) }); + +describe('MCPServerModal', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + testMCPConnection.mockReset(); + saveMCPServer.mockReset(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + document.body.innerHTML = ''; + }); + + const render = async (overrides: Partial = {}) => { + await act(async () => { + root.render( + {}} + server={{ ...server, ...overrides }} + onServerSaved={() => {}} + />, + ); + }); + }; + + const text = () => document.body.textContent ?? ''; + const button = (label: string) => + Array.from( + document.body.querySelectorAll('button'), + ).find((b) => b.textContent === label)!; + const typeInto = async (input: HTMLInputElement, value: string) => { + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set?.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + }; + const urlInput = () => + document.body.querySelector( + 'input[placeholder="https://example.com/mcp"]', + )!; + + it('tells an editor whose tool it is and that their entry replaces it for everyone', async () => { + await render({ access: 'editor', owner_label: 'Lena Fischer' }); + expect(text()).toContain( + 'settings.tools.mcp.sharedByEditor:{"owner":"Lena Fischer"}', + ); + const info = Array.from( + document.body.querySelectorAll('[role="alert"]'), + ).find((a) => + a.textContent?.includes('settings.tools.mcp.sharedCredentialsNotice'), + ); + expect(info?.dataset.variant ?? info?.className).toMatch(/info/); + }); + + it('falls back to "a teammate" without an owner label', async () => { + await render({ access: 'editor', owner_label: null }); + expect(text()).toContain( + 'settings.tools.mcp.sharedByEditor:{"owner":"settings.tools.mcp.aTeammate"}', + ); + }); + + it("shows no sharing notices on the caller's own tool", async () => { + await render(); + expect(text()).not.toContain('settings.tools.mcp.sharedByEditor'); + expect(text()).not.toContain('settings.tools.mcp.sharedCredentialsNotice'); + }); + + it('hints that a saved key is kept when left empty', async () => { + await render(); + expect(text()).toContain('settings.tools.mcp.savedKeyHint'); + }); + + it('tests with the saved key while the server is unchanged', async () => { + testMCPConnection.mockReturnValue(json({ success: true, tools: [] })); + await render(); + await act(async () => button('settings.tools.mcp.testConnection').click()); + expect(testMCPConnection).toHaveBeenCalledWith( + expect.objectContaining({ id: 'tool-1' }), + 'token', + ); + }); + + it('warns and requires a new key when the server host changes', async () => { + await render({ access: 'editor', owner_label: 'Lena' }); + await typeInto(urlInput(), 'https://evil.example.com/sse'); + expect(text()).toContain( + 'settings.tools.mcp.serverChangedNotice:{"credential":"settings.tools.mcp.credentialNames.apiKey"}', + ); + expect(text()).not.toContain('settings.tools.mcp.savedKeyHint'); + await act(async () => button('settings.tools.mcp.testConnection').click()); + expect(testMCPConnection).not.toHaveBeenCalled(); + expect(text()).toContain('settings.tools.mcp.errors.apiKeyRequired'); + }); + + it('keeps the saved key for a path-only change on the same host', async () => { + await render(); + await typeInto(urlInput(), 'https://mcp.dana-tools.dev/v2/sse'); + expect(text()).not.toContain('settings.tools.mcp.serverChangedNotice'); + }); + + it("shows the server's save error in the error Alert", async () => { + testMCPConnection.mockReturnValue(json({ success: true, tools: [] })); + saveMCPServer.mockReturnValue( + json({ success: false, message: 'Invalid server URL' }, false, 400), + ); + await render(); + await act(async () => button('settings.tools.mcp.testConnection').click()); + await act(async () => button('settings.tools.mcp.save').click()); + expect(text()).toContain('Invalid server URL'); + }); + + it('lets an editor rename an OAuth tool without reconnecting it', async () => { + saveMCPServer.mockReturnValue(json({ success: true })); + await render({ + access: 'editor', + owner_label: 'Lena', + auth_type: 'oauth', + has_encrypted_credentials: false, + }); + expect(text()).toContain('settings.tools.mcp.sharedOAuthOwnerOnly'); + expect(urlInput().disabled).toBe(true); + expect(button('settings.tools.mcp.testConnection')).toBeUndefined(); + const save = button('settings.tools.mcp.save'); + expect(save.disabled).toBe(false); + await act(async () => save.click()); + expect(saveMCPServer).toHaveBeenCalledTimes(1); + expect(testMCPConnection).not.toHaveBeenCalled(); + }); + + it('keeps OAuth reconnect for the owner', async () => { + await render({ auth_type: 'oauth', has_encrypted_credentials: false }); + expect(urlInput().disabled).toBe(false); + expect(button('settings.tools.mcp.testConnection')).toBeDefined(); + expect(text()).not.toContain('settings.tools.mcp.sharedOAuthOwnerOnly'); + }); +}); diff --git a/frontend/src/modals/MCPServerModal.tsx b/frontend/src/modals/MCPServerModal.tsx index cf9cd5b9..5a377591 100644 --- a/frontend/src/modals/MCPServerModal.tsx +++ b/frontend/src/modals/MCPServerModal.tsx @@ -1,4 +1,4 @@ -import { CircleAlert, CircleCheck, TriangleAlert } from 'lucide-react'; +import { CircleAlert, CircleCheck, Info, TriangleAlert } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; @@ -30,6 +30,22 @@ interface MCPServerModalProps { onServerSaved: () => void; } +/** The host of a URL, or '' while it doesn't parse. */ +function hostOf(url: string): string { + try { + return new URL(url.trim()).host.toLowerCase(); + } catch { + return ''; + } +} + +// The saved secret each auth type keeps, named for the host-change notice. +const SECRET_FIELDS: Record = { + api_key: { field: 'api_key', nameKey: 'apiKey' }, + bearer: { field: 'bearer_token', nameKey: 'bearer' }, + basic: { field: 'password', nameKey: 'password' }, +}; + export default function MCPServerModal({ modalState, setModalState, @@ -95,6 +111,24 @@ export default function MCPServerModal({ const [oauthCompleted, setOAuthCompleted] = useState(false); const [saveActive, setSaveActive] = useState(false); + // A tool shared with the caller (an editor reconnecting the owner's + // server): its saved secrets stay hidden and a new entry replaces them. + const isShared = !!server?.access && server.access !== 'owner'; + // Only the owner can re-run an OAuth sign-in (the tokens are theirs), so a + // teammate may rename an OAuth tool but not change its server or account. + const oauthOwnerOnly = isShared && server?.auth_type === 'oauth'; + const savedSecret = SECRET_FIELDS[formData.auth_type]; + const hasSavedSecret = + !!server?.has_encrypted_credentials && + !!savedSecret && + formData.auth_type === server?.auth_type; + // The server clears the saved secret when the host changes, so the key + // can't be pointed at another server. + const serverChanged = + hasSavedSecret && + hostOf(formData.server_url) !== hostOf(server?.server_url || ''); + const keepSavedSecret = hasSavedSecret && !serverChanged; + const cleanupOAuthListener = useCallback(() => { setOauthTaskId(null); handledEventIdsRef.current = new Set(); @@ -167,17 +201,17 @@ export default function MCPServerModal({ const authFieldChecks: { [key: string]: () => void } = { api_key: () => { - if (!formData.api_key.trim()) + if (!formData.api_key.trim() && !keepSavedSecret) newErrors.api_key = t('settings.tools.mcp.errors.apiKeyRequired'); }, bearer: () => { - if (!formData.bearer_token.trim()) + if (!formData.bearer_token.trim() && !keepSavedSecret) newErrors.bearer_token = t('settings.tools.mcp.errors.tokenRequired'); }, basic: () => { if (!formData.username.trim()) newErrors.username = t('settings.tools.mcp.errors.usernameRequired'); - if (!formData.password.trim()) + if (!formData.password.trim() && !keepSavedSecret) newErrors.password = t('settings.tools.mcp.errors.passwordRequired'); }, }; @@ -299,6 +333,7 @@ export default function MCPServerModal({ setTestResult({ success: true, message: t('settings.tools.mcp.oauthPopupBlocked', { + interpolation: { escapeValue: false }, defaultValue: 'Popup blocked by browser. Click below to authorize:', }), @@ -369,7 +404,11 @@ export default function MCPServerModal({ setOAuthCompleted(false); try { const config = buildToolConfig(); - const response = await userService.testMCPConnection({ config }, token); + // The id lets the server test with the saved secret left empty. + const response = await userService.testMCPConnection( + { config, ...(server?.id && { id: server.id }) }, + token, + ); const result = await response.json(); if ( @@ -441,7 +480,10 @@ export default function MCPServerModal({ resetForm(); } else { setErrors({ - general: result.error || t('settings.tools.mcp.errors.saveFailed'), + general: + result.message || + result.error || + t('settings.tools.mcp.errors.saveFailed'), }); } } catch { @@ -460,6 +502,11 @@ export default function MCPServerModal({ label={t('settings.tools.mcp.authTypes.apiKey')} required error={errors.api_key} + hint={ + keepSavedSecret + ? t('settings.tools.mcp.savedKeyHint') + : undefined + } > ); @@ -560,21 +616,31 @@ export default function MCPServerModal({ }) : t('settings.tools.mcp.addServer') } + description={ + isShared + ? t('settings.tools.mcp.sharedByEditor', { + interpolation: { escapeValue: false }, + owner: server.owner_label || t('settings.tools.mcp.aTeammate'), + }) + : undefined + } size="lg" mobileVariant="sheet" footer={ - {t('settings.tools.mcp.testConnection')} - + oauthOwnerOnly ? undefined : ( + + ) } cancelLabel={t('settings.tools.mcp.cancel')} onCancel={() => { @@ -584,23 +650,21 @@ export default function MCPServerModal({ submitLabel={t('settings.tools.mcp.save')} onSubmit={handleSave} pending={loading} - disabled={!saveActive} + disabled={!saveActive && !oauthOwnerOnly} /> } >
- {server?.has_encrypted_credentials && - formData.auth_type !== 'oauth' && ( - - - )} + {isShared && ( + + + )} handleInputChange('server_url', e.target.value)} placeholder="https://example.com/mcp" + disabled={oauthOwnerOnly} /> + {serverChanged && ( + + + )} + handleGrantAccess(g, value as AccessLevel) + } + > + + + + + {(['viewer', 'editor'] as const).map( + (level) => ( + + {accessLevelLabel(level)} + + ), + )} + + + ) : ( + + {accessLevelLabel(g.access_level)} + + )} + {(callerCanShare || isAdmin) && ( + handleUnshare(g)} + /> + )} + + } + /> + ); + })} + + +

+ {t('settings.teams.drawer.otherTeamsHint')} +

+ + +
+ +
    + {capabilityLines( + t, + openResource.type, + resolveSettings( + openResource.type, + drawerSettings?.settings, + ), + ).map((line) => ( +
  • + {line.allowed ? ( + + ) : ( + + )} + + {line.text} + +
  • + ))} +
+

+ {t('settings.teams.drawer.capabilitiesHint')} +

+
+
+ + + )} + + + {shareTarget && ( + { + setShareTarget(null); + // Back to the drawer, with the grants the dialog may have changed. + setDrawerOpen(true); + refreshGrants(); + }} + /> + )} + @@ -923,6 +1508,7 @@ export default function Teams() { { expect(cancel?.dataset.size).toBe('sm'); expect(cancel?.dataset.shape).toBe('pill'); }); + + describe('access', () => { + const viewer = { access: 'viewer', allowed_actions: ['use'] }; + const editorNoCreds = { + access: 'editor', + allowed_actions: ['edit', 'use', 'use_in_own'], + }; + const configTool = { + ...userTool, + configRequirements: { + token: { type: 'string', label: 'Token', secret: true, required: true }, + }, + config: { has_encrypted_credentials: true }, + } as unknown as UserToolType; + // A disabled fieldset disables its controls in the browser; jsdom doesn't + // apply that to `:disabled`, so check for the fieldset as well. + const disabled = (el: Element) => + el.matches(':disabled') || el.closest('fieldset[disabled]') !== null; + const nameInput = () => + container.querySelector( + 'input[placeholder="settings.tools.customNamePlaceholder"]', + )!; + const expandFirstAction = async () => { + await act(async () => { + ( + container.querySelector('[class*="cursor-pointer"]') as HTMLElement + ).click(); + }); + }; + + it('opens read-only without edit or edit_credentials: no Save, every field disabled', async () => { + await render({ ...configTool, ...viewer } as UserToolType); + expect(buttonByText('settings.tools.save')).toBeUndefined(); + expect(nameInput().disabled).toBe(true); + const secret = Array.from( + container.querySelectorAll('input'), + ).find((i) => i.placeholder === '••••••••'); + expect(secret && disabled(secret)).toBe(true); + container + .querySelectorAll('[role="switch"]') + .forEach((sw) => expect(disabled(sw)).toBe(true)); + await expandFirstAction(); + container + .querySelectorAll('table input') + .forEach((input) => expect(disabled(input)).toBe(true)); + }); + + it('keeps the actions search usable when read-only', async () => { + await render({ ...userTool, ...viewer } as UserToolType); + const label = Array.from(container.querySelectorAll('label')).find( + (el) => el.textContent === 'settings.tools.searchActions', + )!; + expect( + container.querySelector( + `input[id="${label.htmlFor}"]`, + )?.disabled, + ).toBe(false); + }); + + it('hides the API tool Import and Add action buttons when read-only', async () => { + await render({ ...apiTool, ...viewer } as APIToolType); + expect(buttonByText('settings.tools.importSpec')).toBeUndefined(); + expect(buttonByText('settings.tools.addAction')).toBeUndefined(); + }); + + it('lets an editor without edit_credentials rename but not touch credentials', async () => { + await render({ ...configTool, ...editorNoCreds } as UserToolType); + expect(nameInput().disabled).toBe(false); + const authInputs = Array.from( + container.querySelectorAll('input'), + ).filter((i) => i !== nameInput() && !i.closest('table')); + const credential = authInputs.find((i) => i.placeholder === '••••••••'); + expect(credential && disabled(credential)).toBe(true); + }); + + it("disables an API tool's URL and header values without edit_credentials", async () => { + await render({ ...apiTool, ...editorNoCreds } as APIToolType); + await expandFirstAction(); + const url = Array.from( + container.querySelectorAll('input'), + ).find((i) => i.value === 'https://example.com'); + expect(url?.disabled).toBe(true); + const headerValue = container.querySelector( + 'input[placeholder="settings.tools.headerValuePlaceholder"]', + ); + expect(headerValue?.disabled).toBe(true); + }); + }); + + it('masks a saved API header value with a replace-to-change placeholder', async () => { + const saved = JSON.parse(JSON.stringify(apiTool)) as APIToolType; + saved.config.actions.list.headers.properties.Accept.has_value = true; + await render(saved); + await act(async () => { + ( + container.querySelector('[class*="cursor-pointer"]') as HTMLElement + ).click(); + }); + const masked = container.querySelector( + 'input[placeholder="settings.tools.savedSecretPlaceholder"]', + ); + expect(masked).not.toBeNull(); + expect(masked?.type).toBe('password'); + expect(masked?.value).toBe(''); + }); + + it('shows a non-2xx save response as a destructive Alert', async () => { + updateTool.mockResolvedValue({ + ok: false, + status: 403, + json: () => Promise.resolve({ success: false, message: 'Forbidden' }), + }); + const goBack = vi.fn(); + await act(async () => { + root.render( + {}} + handleGoBack={goBack} + />, + ); + }); + const name = container.querySelector( + 'input[placeholder="settings.tools.customNamePlaceholder"]', + ); + await act(async () => { + Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )?.set?.call(name, 'Renamed'); + name?.dispatchEvent(new Event('input', { bubbles: true })); + }); + await act(async () => { + buttonByText('settings.tools.save')?.click(); + }); + expect( + container.querySelector('[role="alert"]')?.textContent, + ).toBe('settings.tools.saveFailed'); + expect(goBack).not.toHaveBeenCalled(); + }); }); diff --git a/frontend/src/settings/ToolConfig.tsx b/frontend/src/settings/ToolConfig.tsx index 65e7f9d9..b8d8d628 100644 --- a/frontend/src/settings/ToolConfig.tsx +++ b/frontend/src/settings/ToolConfig.tsx @@ -39,6 +39,7 @@ import ImportSpecModal from '../modals/ImportSpecModal'; import { ActiveState } from '../models/misc'; import { selectToken } from '../preferences/preferenceSlice'; import { getMethodBadgeVariant } from '../utils/httpMethodColors'; +import { can } from '../utils/accessUtils'; import { areObjectsEqual } from '../utils/objectUtils'; import { cn, focusRing } from '@/lib/utils'; import { APIActionType, APIToolType, UserToolType } from './types'; @@ -52,6 +53,16 @@ const BODY_TYPE_HINT_KEYS: Record = { 'application/octet-stream': 'octetStream', }; +/** + * What the caller may change on the open tool (`utils/accessUtils` `can`): + * `canEdit` covers the name and the actions, `canEditCredentials` the + * secrets, URLs and header / query values. + */ +const ToolAccessContext = React.createContext({ + canEdit: true, + canEditCredentials: true, +}); + /** Maps a body content type to its hint's locale key suffix (JSON by default). */ function bodyTypeHintKey(contentType?: string): string { return BODY_TYPE_HINT_KEYS[contentType || 'application/json'] ?? 'json'; @@ -108,6 +119,14 @@ export default function ToolConfig({ Set >(new Set()); const { t } = useTranslation(); + const canEdit = can(tool, 'edit'); + const canEditCredentials = can(tool, 'edit_credentials'); + // Neither: the tool opens as a read-only view with no Save. + const readOnly = !canEdit && !canEditCredentials; + const access = React.useMemo( + () => ({ canEdit, canEditCredentials }), + [canEdit, canEditCredentials], + ); const toggleUserActionExpand = (index: number) => { setExpandedUserActions((prev) => { @@ -245,6 +264,24 @@ export default function ToolConfig({ }); }; + /** Sends the edit; a non-2xx response throws so the caller shows it. */ + const saveTool = async (configToSave: { [key: string]: any }) => { + const response = await userService.updateTool( + { + id: tool.id, + name: tool.name, + displayName: tool.displayName, + customName: customName, + description: tool.description, + config: configToSave, + actions: 'actions' in tool ? tool.actions : [], + status: tool.status, + }, + token, + ); + if (!response?.ok) throw new Error('Failed to save tool'); + }; + const handleSaveChanges = async () => { if (!validateConfig()) return; const configToSave = buildConfigToSave(); @@ -253,19 +290,7 @@ export default function ToolConfig({ setSaveError(''); try { - await userService.updateTool( - { - id: tool.id, - name: tool.name, - displayName: tool.displayName, - customName: customName, - description: tool.description, - config: configToSave, - actions: 'actions' in tool ? tool.actions : [], - status: tool.status, - }, - token, - ); + await saveTool(configToSave); setInitialState({ customName, configValues: { ...configValues }, @@ -357,16 +382,18 @@ export default function ToolConfig({ currentLabel={tool.customName || tool.displayName || tool.name} onParentClick={handleBackClick} /> - + {!readOnly && ( + + )} {saveError && ( @@ -383,6 +410,7 @@ export default function ToolConfig({ value={customName} onChange={(e) => setCustomName(e.target.value)} placeholder={t('settings.tools.customNamePlaceholder')} + disabled={!canEdit} />
@@ -394,7 +422,10 @@ export default function ToolConfig({ size="xs" title={t('settings.tools.authentication')} /> -
+
-
+
)} @@ -415,7 +446,7 @@ export default function ToolConfig({ + ))} + + ), +})); + +const toolConfigProps = vi.fn(); +vi.mock('./ToolConfig', () => ({ + default: (props: unknown) => { + toolConfigProps(props); + return
; + }, +})); +vi.mock('./RemoteDeviceConfig', () => ({ default: () => null })); +vi.mock('../modals/AddToolModal', () => ({ default: () => null })); +vi.mock('../modals/ConfirmationModal', () => ({ default: () => null })); +const mcpModalProps = vi.fn(); +vi.mock('../modals/MCPServerModal', () => ({ + default: (props: unknown) => { + mcpModalProps(props); + return null; + }, +})); +vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null })); +vi.mock('../api/services/devicesService', () => ({ default: {} })); + +const getUserTools = vi.fn(); +const updateToolStatus = vi.fn(); +vi.mock('../api/services/userService', () => ({ + default: { + getUserTools: (...args: unknown[]) => getUserTools(...args), + getMCPAuthStatus: () => + Promise.resolve({ json: () => Promise.resolve({ success: false }) }), + updateToolStatus: (...args: unknown[]) => updateToolStatus(...args), + }, +})); + +import Tools from './Tools'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const baseTool = { + name: 'mcp_tool', + displayName: 'Carrier Rates MCP', + description: 'Live rates', + config: { server_url: 'https://mcp.example.com/sse', auth_type: 'api_key' }, + actions: [], +}; + +const ownTool = { + ...baseTool, + id: 'own', + displayName: 'own', + status: true, + in_chat: true, + access: 'owner', + allowed_actions: [ + 'delete', + 'edit', + 'edit_credentials', + 'manage_settings', + 'share', + 'use', + 'use_in_own', + ], +}; +const editorTool = { + ...baseTool, + id: 'ed', + displayName: 'ed', + status: true, + in_chat: false, + ownership: 'team', + team_access: 'editor', + access: 'editor', + allowed_actions: ['edit', 'edit_credentials', 'use', 'use_in_own'], + shared_via: 'Logistics', + owner_label: 'lena@example.com', +}; +const viewerTool = { + ...baseTool, + id: 'vw', + displayName: 'vw', + status: true, + in_chat: false, + ownership: 'team', + team_access: 'viewer', + access: 'viewer', + allowed_actions: ['use'], +}; + +const jsonResponse = (body: unknown, ok = true, status = 200) => + Promise.resolve({ ok, status, json: () => Promise.resolve(body) }); + +describe('Tools', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + dispatch.mockReset(); + getUserTools.mockReset(); + updateToolStatus.mockReset(); + toolConfigProps.mockReset(); + mcpModalProps.mockReset(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + const render = async (tools: unknown[]) => { + getUserTools.mockImplementation(() => jsonResponse({ tools })); + await act(async () => { + root.render(); + }); + }; + + const card = (name: string) => + Array.from(container.querySelectorAll('[data-slot="card"]')) + .filter((c) => c.querySelector('[data-testid="menu"]')) + .find((c) => c.querySelector('h2')?.textContent === name)!; + const menuLabels = (id: string) => + Array.from(card(id).querySelectorAll('[data-testid="menu"] button')).map( + (b) => b.textContent, + ); + const switchOf = (id: string) => + card(id).querySelector('[role="switch"]')!; + + it('shows Edit, Reconnect, Share and Delete to the owner', async () => { + await render([ownTool]); + expect(menuLabels('own')).toEqual([ + 'settings.tools.edit', + 'settings.tools.reconnect', + 'settings.tools.shareWithTeam', + 'settings.tools.delete', + ]); + }); + + it('shows Edit and Reconnect to an editor', async () => { + await render([editorTool]); + expect(menuLabels('ed')).toEqual([ + 'settings.tools.edit', + 'settings.tools.reconnect', + ]); + }); + + it('shows only View to a viewer, which opens the config read-only', async () => { + await render([viewerTool]); + expect(menuLabels('vw')).toEqual(['settings.tools.view']); + await act(async () => { + ( + card('vw').querySelector('[data-testid="menu"] button') as HTMLElement + ).click(); + }); + expect(container.querySelector('[data-testid="tool-config"]')).not.toBe( + null, + ); + }); + + it('passes the tool owner and role to the Reconnect modal', async () => { + await render([editorTool]); + const reconnect = Array.from( + card('ed').querySelectorAll( + '[data-testid="menu"] button', + ), + ).find((b) => b.textContent === 'settings.tools.reconnect')!; + await act(async () => reconnect.click()); + const last = mcpModalProps.mock.calls.at(-1)![0] as { + server: Record; + }; + expect(last.server).toMatchObject({ + id: 'ed', + displayName: 'ed', + access: 'editor', + owner_label: 'lena@example.com', + }); + }); + + it('labels the switch "In my chats" and binds it to in_chat', async () => { + await render([ownTool, editorTool]); + const sw = switchOf('ed'); + expect(sw.getAttribute('aria-checked')).toBe('false'); + expect(switchOf('own').getAttribute('aria-checked')).toBe('true'); + const label = card('ed').querySelector( + `label[for="${sw.id}"]`, + ); + expect(label?.textContent).toBe('settings.tools.inMyChats'); + expect(sw.getAttribute('aria-label')).toBe( + 'settings.tools.useInMyChatsAria:{"toolName":"ed"}', + ); + }); + + it('disables the switch, keeping its label, for a shared tool without use_in_own', async () => { + await render([viewerTool]); + const sw = switchOf('vw'); + expect(sw.disabled).toBe(true); + expect(card('vw').querySelector(`label[for="${sw.id}"]`)?.textContent).toBe( + 'settings.tools.inMyChats', + ); + }); + + it('reverts the switch and shows an error toast when the update fails', async () => { + await render([editorTool]); + updateToolStatus.mockImplementation(() => + jsonResponse({ success: false, message: 'Forbidden' }, false, 403), + ); + await act(async () => switchOf('ed').click()); + expect(updateToolStatus).toHaveBeenCalledWith( + { id: 'ed', status: true }, + 'token', + ); + expect(switchOf('ed').getAttribute('aria-checked')).toBe('false'); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ variant: 'destructive' }), + }), + ); + }); + + it('keeps the new value when the update succeeds', async () => { + await render([editorTool]); + updateToolStatus.mockImplementation(() => jsonResponse({ success: true })); + await act(async () => switchOf('ed').click()); + expect(switchOf('ed').getAttribute('aria-checked')).toBe('true'); + expect(dispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/settings/Tools.tsx b/frontend/src/settings/Tools.tsx index eb408ad4..4f3d62fa 100644 --- a/frontend/src/settings/Tools.tsx +++ b/frontend/src/settings/Tools.tsx @@ -1,7 +1,7 @@ -import { Pencil, RefreshCw, Trash2, Users } from 'lucide-react'; +import { Eye, Pencil, RefreshCw, Trash2, Users } from 'lucide-react'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import devicesService from '../api/services/devicesService'; import userService from '../api/services/userService'; @@ -12,6 +12,7 @@ import ToolIcon from '../components/ToolIcon'; import { Badge } from '../components/ui/badge'; import { Button } from '../components/ui/button'; import { Card, CardDescription, CardTitle } from '../components/ui/card'; +import { Label } from '../components/ui/label'; import { Switch } from '../components/ui/switch'; import { ActionMenu, type MenuOption } from '../components/ui/dropdown-menu'; import { EmptyState } from '../components/ui/empty-state'; @@ -20,8 +21,11 @@ import AddToolModal from '../modals/AddToolModal'; import ConfirmationModal from '../modals/ConfirmationModal'; import MCPServerModal from '../modals/MCPServerModal'; import { ActiveState } from '../models/misc'; +import { showActionToast } from '../notifications/actionToastSlice'; import { selectToken } from '../preferences/preferenceSlice'; import ShareToTeamModal from '../teams/ShareToTeamModal'; +import { can, isOwner } from '../utils/accessUtils'; +import { canAddToolToOwn, toolInChat } from '../utils/toolUtils'; import RemoteDeviceConfig from './RemoteDeviceConfig'; import ToolConfig from './ToolConfig'; import { APIToolType, UserToolType } from './types'; @@ -29,6 +33,7 @@ import { APIToolType, UserToolType } from './types'; export default function Tools() { const { t } = useTranslation(); const token = useSelector(selectToken); + const dispatch = useDispatch(); const [searchTerm, setSearchTerm] = React.useState(''); const [addToolModalState, setAddToolModalState] = @@ -81,7 +86,21 @@ export default function Tools() { .catch((error) => console.error('Failed to revoke device:', error)); return; } - userService.deleteTool({ id: toolToDelete.id }, token).then(afterDelete); + userService + .deleteTool({ id: toolToDelete.id }, token) + .then((response: Response) => { + if (response.ok) return afterDelete(); + setDeleteModalState('INACTIVE'); + dispatch( + showActionToast({ + variant: 'destructive', + message: t('settings.tools.deleteFailed'), + }), + ); + }) + .catch((error: unknown) => + console.error('Failed to delete tool:', error), + ); }; const handleReconnect = (tool: UserToolType) => { @@ -97,41 +116,51 @@ export default function Tools() { timeout: config.timeout || 30, oauth_scopes: oauthScopes, has_encrypted_credentials: !!config.has_encrypted_credentials, + access: tool.access ?? (isOwner(tool) ? 'owner' : tool.team_access), + owner_label: tool.owner_label ?? null, }); setReconnectModalState('ACTIVE'); }; const getMenuOptions = (tool: UserToolType): MenuOption[] => { + const canEdit = can(tool, 'edit') || can(tool, 'edit_credentials'); const options: MenuOption[] = [ - { - icon: Pencil, - label: t('settings.tools.edit'), - onClick: () => handleSettingsClick(tool), - variant: 'default', - }, - { - icon: Trash2, - label: t('settings.tools.delete'), - onClick: () => handleDeleteTool(tool), - variant: 'destructive', - }, + canEdit + ? { + icon: Pencil, + label: t('settings.tools.edit'), + onClick: () => handleSettingsClick(tool), + variant: 'default', + } + : { + icon: Eye, + label: t('settings.tools.view'), + onClick: () => handleSettingsClick(tool), + variant: 'default', + }, ]; - // Sharing is an owner-only action: hide it for tools shared into the - // user's workspace by a team. - if (tool.ownership !== 'team') { - options.splice(options.length - 1, 0, { + if (tool.name === 'mcp_tool' && can(tool, 'edit_credentials')) { + options.push({ + icon: RefreshCw, + label: t('settings.tools.reconnect'), + onClick: () => handleReconnect(tool), + variant: 'default', + }); + } + if (can(tool, 'share')) { + options.push({ icon: Users, label: t('settings.tools.shareWithTeam'), onClick: () => setToolToShare(tool), variant: 'default', }); } - if (tool.name === 'mcp_tool') { - options.splice(1, 0, { - icon: RefreshCw, - label: t('settings.tools.reconnect'), - onClick: () => handleReconnect(tool), - variant: 'default', + if (can(tool, 'delete')) { + options.push({ + icon: Trash2, + label: t('settings.tools.delete'), + onClick: () => handleDeleteTool(tool), + variant: 'destructive', }); } return options; @@ -174,18 +203,37 @@ export default function Tools() { }); }; + const setToolInChat = (toolId: string, value: boolean) => + setUserTools((prevTools) => + prevTools.map((tool) => + tool.id !== toolId + ? tool + : isOwner(tool) + ? { ...tool, status: value, in_chat: value } + : { ...tool, in_chat: value }, + ), + ); + + // The switch moves at once and flips back when the server refuses it. const updateToolStatus = (toolId: string, newStatus: boolean) => { + setToolInChat(toolId, newStatus); + const fail = () => { + setToolInChat(toolId, !newStatus); + dispatch( + showActionToast({ + variant: 'destructive', + message: t('settings.tools.statusUpdateFailed'), + }), + ); + }; userService .updateToolStatus({ id: toolId, status: newStatus }, token) - .then(() => { - setUserTools((prevTools) => - prevTools.map((tool) => - tool.id === toolId ? { ...tool, status: newStatus } : tool, - ), - ); + .then((response: Response) => { + if (!response.ok) fail(); }) - .catch((error) => { + .catch((error: unknown) => { console.error('Failed to update tool status:', error); + fail(); }); }; @@ -317,6 +365,7 @@ export default function Tools() {
-
+
+ updateToolStatus(tool.id, checked) } + disabled={!canAddToolToOwn(tool)} id={`toolToggle-${index}`} - aria-label={t('settings.tools.toggleToolAria', { + aria-label={t('settings.tools.useInMyChatsAria', { + interpolation: { escapeValue: false }, toolName: tool.customName || tool.displayName, })} /> @@ -401,6 +458,7 @@ export default function Tools() { /> ({ + dispatch: vi.fn(), + service: { deletePath: vi.fn() }, + state: { + preference: { + token: null, + sourceDocs: [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + ], + paginatedDocuments: [{ id: 'b', name: 'B' }], + }, + }, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('react-redux', () => ({ + useDispatch: () => dispatch, + useSelector: (selector: (s: unknown) => unknown) => selector(state), +})); + +vi.mock('../hooks', () => ({ + useMediaQuery: () => ({ isMobile: false, isDesktop: true }), +})); + +vi.mock('../api/services/userService', () => ({ default: service })); +vi.mock('../navigation/SectionShell', () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); +vi.mock('../navigation/SectionIndexPage', () => ({ default: () => null })); +vi.mock('./Analytics', () => ({ default: () => null })); +vi.mock('./CustomModels', () => ({ default: () => null })); +vi.mock('./General', () => ({ default: () => null })); +vi.mock('./Logs', () => ({ default: () => null })); +vi.mock('./PersonalAccessTokens', () => ({ default: () => null })); +vi.mock('./Tools', () => ({ default: () => null })); +// Sources: one button that deletes the only listed source. +vi.mock('./Sources', () => ({ + default: ({ + paginatedDocuments, + handleDeleteDocument, + }: { + paginatedDocuments: { id: string; name: string }[]; + handleDeleteDocument: (index: number, doc: unknown) => void; + }) => ( + + ), +})); + +import Settings from './index'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +describe('Settings source delete', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + dispatch.mockReset(); + service.deletePath.mockReset(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + const clickDelete = async () => { + await act(async () => { + root.render( + + + } /> + + , + ); + }); + await act(async () => container.querySelector('button')!.click()); + }; + + const actionTypes = () => dispatch.mock.calls.map(([a]) => a?.type); + + it('a forbidden delete shows an error toast and keeps the source', async () => { + service.deletePath.mockResolvedValue({ ok: false, status: 403 }); + await clickDelete(); + expect(service.deletePath).toHaveBeenCalledWith('b', null); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'actionToast/showActionToast', + payload: expect.objectContaining({ + variant: 'destructive', + message: 'settings.sources.errors.forbidden', + }), + }), + ); + expect(actionTypes()).not.toContain('preference/setSourceDocs'); + }); + + it('a failed request shows an error toast', async () => { + service.deletePath.mockRejectedValue(new Error('network')); + await clickDelete(); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + message: 'settings.sources.errors.delete', + }), + }), + ); + }); + + it('a successful delete drops the source by id from both lists', async () => { + service.deletePath.mockResolvedValue({ ok: true, status: 200 }); + await clickDelete(); + expect(dispatch).toHaveBeenCalledWith({ + type: 'preference/setPaginatedDocuments', + payload: [], + }); + expect(dispatch).toHaveBeenCalledWith({ + type: 'preference/setSourceDocs', + payload: [{ id: 'a', name: 'A' }], + }); + }); +}); diff --git a/frontend/src/settings/index.tsx b/frontend/src/settings/index.tsx index 723e0061..8798edd4 100644 --- a/frontend/src/settings/index.tsx +++ b/frontend/src/settings/index.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import { Navigate, Route, Routes, useLocation } from 'react-router-dom'; @@ -6,6 +7,7 @@ import { useMediaQuery } from '../hooks'; import { Doc } from '../models/misc'; import SectionIndexPage from '../navigation/SectionIndexPage'; import SectionShell from '../navigation/SectionShell'; +import { showActionToast } from '../notifications/actionToastSlice'; import { SETTINGS_SECTION } from '../navigation/sections'; import { selectPaginatedDocuments, @@ -29,6 +31,7 @@ import Tools from './Tools'; * `/settings` shows the destination list as page content instead. */ export default function Settings() { + const { t } = useTranslation(); const dispatch = useDispatch(); const location = useLocation(); const { isMobile } = useMediaQuery(); @@ -39,27 +42,36 @@ export default function Settings() { const documents = useSelector(selectSourceDocs); const paginatedDocuments = useSelector(selectPaginatedDocuments); - const updateDocumentsList = (documents: Doc[], index: number) => [ - ...documents.slice(0, index), - ...documents.slice(index + 1), - ]; + const showDeleteError = (message: string) => + dispatch(showActionToast({ variant: 'destructive', message })); - const handleDeleteClick = (index: number, doc: Doc) => { + /** + * Deletes a source and drops it from both lists by id. A refused or failed + * delete (403 for a role without `delete`) shows a destructive toast and + * leaves the lists alone. + */ + const handleDeleteClick = (_index: number, doc: Doc) => { + const withoutDoc = (list: Doc[]) => list.filter((d) => d.id !== doc.id); userService .deletePath(doc.id ?? '', token) - .then((response) => { - if (response.ok && documents) { - if (paginatedDocuments) { - dispatch( - setPaginatedDocuments( - updateDocumentsList(paginatedDocuments, index), - ), - ); - } - dispatch(setSourceDocs(updateDocumentsList(documents, index))); + .then((response: Response) => { + if (!response.ok) { + showDeleteError( + response.status === 403 + ? t('settings.sources.errors.forbidden') + : t('settings.sources.errors.delete'), + ); + return; } + if (paginatedDocuments) { + dispatch(setPaginatedDocuments(withoutDoc(paginatedDocuments))); + } + if (documents) dispatch(setSourceDocs(withoutDoc(documents))); }) - .catch((error) => console.error(error)); + .catch((error) => { + console.error(error); + showDeleteError(t('settings.sources.errors.delete')); + }); }; if (showIndex) { diff --git a/frontend/src/settings/types/index.ts b/frontend/src/settings/types/index.ts index 76006b84..072ed6d8 100644 --- a/frontend/src/settings/types/index.ts +++ b/frontend/src/settings/types/index.ts @@ -1,4 +1,5 @@ import { ConfigRequirements } from '../../modals/types'; +import type { Access } from '../../utils/accessUtils'; export type ChunkType = { doc_id: string; @@ -140,6 +141,9 @@ export type ParameterGroupType = { value: string | number; filled_by_llm: boolean; required?: boolean; + // A saved secret header / query value: the server sends `value: ""` + // and this flag; an empty value on save keeps the stored one. + has_value?: boolean; }; }; }; @@ -168,6 +172,16 @@ export type UserToolType = { // Access level when shared via a team: 'viewer' (use) or 'editor' (edit // actions; secrets stay owner-only). Null/absent for tools the caller owns. team_access?: 'viewer' | 'editor' | null; + // Caller's role and what it may do (`utils/accessUtils` `can`). + access?: Access | null; + allowed_actions?: string[]; + // Whether the tool joins the caller's own agentless chats: the owner's + // `status`, or a grantee's personal preference (default off). + in_chat?: boolean; + // A team through which a shared tool reaches the caller. + shared_via?: string | null; + // The owner's email or name, when the server can resolve it. + owner_label?: string | null; config: { [key: string]: any; }; @@ -228,4 +242,8 @@ export type APIToolType = { status: boolean; config: { actions: { [key: string]: APIActionType } }; configRequirements?: ConfigRequirements; + access?: Access | null; + allowed_actions?: string[]; + ownership?: 'user' | 'team'; + team_access?: 'viewer' | 'editor' | null; }; diff --git a/frontend/src/teams/ShareToTeamModal.test.tsx b/frontend/src/teams/ShareToTeamModal.test.tsx new file mode 100644 index 00000000..aa5117ee --- /dev/null +++ b/frontend/src/teams/ShareToTeamModal.test.tsx @@ -0,0 +1,324 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +const mockState = { + preference: { token: 'tok' }, + teams: { + teams: [ + { id: 't1', name: 'Logistics', slug: 'logistics', owner_id: 'u1' }, + { id: 't2', name: 'Sales Ops', slug: 'sales', owner_id: 'u1' }, + ], + }, +}; + +vi.mock('react-redux', () => ({ + useSelector: (selector: (s: unknown) => unknown) => selector(mockState), + useDispatch: () => () => undefined, +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + if (!opts) return key; + const params = Object.entries(opts) + .filter(([k]) => k !== 'defaultValue' && k !== 'interpolation') + .map(([k, v]) => `${k}=${v}`) + .join(','); + return params ? `${key}(${params})` : key; + }, + }), +})); + +const listResourceShares = vi.fn(); +const getResourceSettings = vi.fn(); +const updateResourceSettings = vi.fn(); +const listMembers = vi.fn(); +const share = vi.fn(); +const unshare = vi.fn(); + +vi.mock('../api/services/teamsService', () => ({ + default: { + listResourceShares: (...a: unknown[]) => listResourceShares(...a), + getResourceSettings: (...a: unknown[]) => getResourceSettings(...a), + updateResourceSettings: (...a: unknown[]) => updateResourceSettings(...a), + listMembers: (...a: unknown[]) => listMembers(...a), + share: (...a: unknown[]) => share(...a), + unshare: (...a: unknown[]) => unshare(...a), + }, +})); + +import ShareToTeamModal from './ShareToTeamModal'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const OWNER_ACTIONS = ['delete', 'edit', 'manage_settings', 'share', 'use']; +const EDITOR_ACTIONS = ['edit', 'share', 'use']; + +const settingsResponse = ( + values: Record = {}, + access: 'owner' | 'editor' = 'owner', +) => ({ + success: true, + resource_type: 'agent', + resource_id: 'a1', + settings: [ + { key: 'editors_can_share', default: false }, + { key: 'editors_can_delete', default: false }, + { key: 'editors_can_manage_access_details', default: true }, + { key: 'viewers_can_see_logs', default: false }, + ].map((s) => ({ ...s, value: values[s.key] ?? s.default })), + access, + allowed_actions: access === 'owner' ? OWNER_ACTIONS : EDITOR_ACTIONS, +}); + +const apiError = (message: string) => + Object.assign(new Error(message), { name: 'TeamsApiError', status: 403 }); + +const flush = async () => { + for (let i = 0; i < 6; i += 1) { + await act(async () => { + await Promise.resolve(); + }); + } +}; + +const body = () => document.body; +const text = () => body().textContent ?? ''; +const buttonByText = (label: string) => + Array.from(body().querySelectorAll('button')).find((b) => + b.textContent?.trim().startsWith(label), + ); + +describe('ShareToTeamModal', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + listResourceShares.mockReset().mockResolvedValue({ shares: [] }); + getResourceSettings.mockReset().mockResolvedValue(settingsResponse()); + updateResourceSettings.mockReset(); + listMembers.mockReset().mockResolvedValue({ members: [] }); + share.mockReset().mockResolvedValue({ success: true }); + unshare.mockReset().mockResolvedValue({ success: true }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + document.body.innerHTML = ''; + }); + + const render = async () => { + act(() => { + root.render( + undefined} + />, + ); + }); + await flush(); + }; + + describe('access settings', () => { + it('shows a collapsed Access settings toggle to the owner', async () => { + await render(); + const toggle = buttonByText('settings.teams.accessSettings.title'); + expect(toggle).toBeDefined(); + expect(toggle?.getAttribute('aria-expanded')).toBe('false'); + expect(toggle?.getAttribute('data-variant')).toBe('section-toggle'); + expect(body().querySelectorAll('[role="switch"]')).toHaveLength(0); + act(() => toggle!.click()); + expect(body().querySelectorAll('[role="switch"]')).toHaveLength(4); + expect(text()).toContain( + 'settings.teams.accessSettings.agent.editors_can_share.label', + ); + }); + + it('opens by default when a setting is off its default', async () => { + getResourceSettings.mockResolvedValue( + settingsResponse({ editors_can_share: true }), + ); + await render(); + const toggle = buttonByText('settings.teams.accessSettings.title'); + expect(toggle?.getAttribute('aria-expanded')).toBe('true'); + expect(body().querySelectorAll('[role="switch"]')).toHaveLength(4); + }); + + it('is hidden from an editor without manage_settings', async () => { + getResourceSettings.mockResolvedValue(settingsResponse({}, 'editor')); + await render(); + expect(buttonByText('settings.teams.accessSettings.title')).toBe( + undefined, + ); + }); + + it('PUTs the new value when a switch is toggled', async () => { + updateResourceSettings.mockResolvedValue( + settingsResponse({ editors_can_share: true }), + ); + await render(); + act(() => buttonByText('settings.teams.accessSettings.title')!.click()); + const sw = body().querySelector( + '#share-setting-editors_can_share', + ); + act(() => sw!.click()); + await flush(); + expect(updateResourceSettings).toHaveBeenCalledWith( + 'agent', + 'a1', + { editors_can_share: true }, + 'tok', + ); + expect(sw!.getAttribute('aria-checked')).toBe('true'); + }); + + it('reverts the switch and shows an Alert when the PUT fails', async () => { + updateResourceSettings.mockRejectedValue(apiError('Forbidden')); + await render(); + act(() => buttonByText('settings.teams.accessSettings.title')!.click()); + const sw = body().querySelector( + '#share-setting-editors_can_share', + ); + act(() => sw!.click()); + await flush(); + expect(sw!.getAttribute('aria-checked')).toBe('false'); + const alert = body().querySelector('[role="alert"]'); + expect(alert?.textContent).toContain('Forbidden'); + }); + }); + + describe('editor hint', () => { + it('describes the default editor rights', async () => { + await render(); + const hint = body().querySelector('[data-testid="share-editor-hint"]'); + expect(hint?.textContent).toContain('settings.teams.editorHint.agent'); + expect(hint?.textContent).toContain( + 'settings.teams.editorHint.noShareNoDelete', + ); + expect(hint?.className).toContain('text-muted-foreground'); + expect(hint?.className).toContain('mt-1.5'); + expect(hint?.className).toContain('text-xs'); + }); + + it('says editors can also share when the switch is on', async () => { + getResourceSettings.mockResolvedValue( + settingsResponse({ + editors_can_share: true, + editors_can_manage_access_details: false, + }), + ); + await render(); + const hint = body().querySelector('[data-testid="share-editor-hint"]'); + expect(hint?.textContent).toContain( + 'settings.teams.editorHint.agentNoAccessDetails', + ); + expect(hint?.textContent).toContain( + 'settings.teams.editorHint.shareOnly', + ); + }); + }); + + describe('long list', () => { + const manyShares = (n: number) => + Array.from({ length: n }, (_, i) => ({ + team_id: i % 2 ? 't1' : 't2', + team_name: i % 2 ? 'Logistics' : 'Sales Ops', + access_level: i < 2 ? 'editor' : 'viewer', + target_user_id: i < 2 ? null : `user-${i}`, + created_at: `2026-09-${String(10 + i).padStart(2, '0')}T00:00:00Z`, + })); + + it('shows every grant and no Show all link up to 5', async () => { + listResourceShares.mockResolvedValue({ shares: manyShares(5) }); + await render(); + expect(buttonByText('settings.teams.share.showAll')).toBe(undefined); + // You + 5 grants. + expect(body().querySelectorAll('[data-slot="list-row"]')).toHaveLength(6); + }); + + it('shows You + 3 most recent and a Show all link above 5', async () => { + listResourceShares.mockResolvedValue({ shares: manyShares(8) }); + await render(); + const rows = body().querySelectorAll('[data-slot="list-row"]'); + expect(rows).toHaveLength(4); + // Most recent first: user-7, user-6, user-5. + expect(rows[1].textContent).toContain('user-7'); + expect(text()).toContain('settings.teams.share.andMore(count=5)'); + const showAll = buttonByText('settings.teams.share.showAll'); + expect(showAll?.getAttribute('data-variant')).toBe('link'); + expect(body().querySelector('.max-h-72')).toBeNull(); + }); + + it('opens the full list step with search and filters', async () => { + listResourceShares.mockResolvedValue({ shares: manyShares(8) }); + await render(); + act(() => buttonByText('settings.teams.share.showAll')!.click()); + expect(text()).toContain( + 'settings.teams.share.allSummary(name=QBR Report Builder,teams=2,people=6)', + ); + expect(buttonByText('settings.teams.share.back')).toBeDefined(); + // You + all 8. + expect(body().querySelectorAll('[data-slot="list-row"]')).toHaveLength(9); + + const teamsPill = Array.from( + body().querySelectorAll('[role="radio"]'), + ).find((b) => + b.textContent?.includes('settings.teams.share.filter.teams'), + ); + act(() => teamsPill!.click()); + // The two whole-team grants; You is only listed under All. + expect(body().querySelectorAll('[data-slot="list-row"]')).toHaveLength(2); + + const allPill = Array.from( + body().querySelectorAll('[role="radio"]'), + ).find((b) => b.textContent?.includes('settings.teams.share.filter.all')); + act(() => allPill!.click()); + const search = body().querySelector( + 'input[aria-label="settings.teams.share.searchAccess"]', + ); + act(() => { + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!; + setter.call(search, 'user-6'); + search!.dispatchEvent(new Event('input', { bubbles: true })); + }); + const rows = body().querySelectorAll('[data-slot="list-row"]'); + expect(rows).toHaveLength(1); + expect(rows[0].textContent).toContain('user-6'); + + act(() => buttonByText('settings.teams.share.back')!.click()); + expect(buttonByText('settings.teams.share.showAll')).toBeDefined(); + }); + }); + + it('shows the server message when a share fails', async () => { + listResourceShares.mockResolvedValue({ + shares: [ + { + team_id: 't1', + team_name: 'Logistics', + access_level: 'viewer', + target_user_id: null, + }, + ], + }); + unshare.mockRejectedValue(apiError('Not allowed')); + await render(); + const remove = body().querySelector( + 'button[aria-label="settings.teams.share.removeAccess"]', + ); + act(() => remove!.click()); + await flush(); + expect(body().querySelector('[role="alert"]')?.textContent).toContain( + 'Not allowed', + ); + }); +}); diff --git a/frontend/src/teams/ShareToTeamModal.tsx b/frontend/src/teams/ShareToTeamModal.tsx index 54f4c9d7..dc464a59 100644 --- a/frontend/src/teams/ShareToTeamModal.tsx +++ b/frontend/src/teams/ShareToTeamModal.tsx @@ -1,14 +1,23 @@ -import { CircleAlert, Trash2 } from 'lucide-react'; +import { + ArrowLeft, + ArrowRight, + ChevronRight, + CircleAlert, + Trash2, +} from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import teamsService, { AccessLevel, + ResourceSetting, + ResourceSettingsResponse, ResourceShare, ResourceType, TeamMember, } from '../api/services/teamsService'; +import SearchInput from '../components/SearchInput'; import { Alert, AlertDescription } from '../components/ui/alert'; import { Avatar } from '../components/ui/avatar'; import { Button } from '../components/ui/button'; @@ -24,6 +33,9 @@ import { IconButton } from '../components/ui/icon-button'; import { ListRow, ListRows } from '../components/ui/list-row'; import { Modal } from '../components/ui/modal'; import { SectionHeader } from '../components/ui/section-header'; +import { SettingRow, SettingRows } from '../components/ui/setting-row'; +import { Switch } from '../components/ui/switch'; +import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group'; import { Popover, PopoverContent, @@ -36,9 +48,18 @@ import { SelectTrigger, SelectValue, } from '../components/ui/select'; +import { cn } from '../lib/utils'; import { selectToken } from '../preferences/preferenceSlice'; import { AppDispatch } from '../store'; +import { can } from '../utils/accessUtils'; import { decodeJwtPayload } from '../utils/jwtUtils'; +import { + anyChanged, + editorHint, + errorMessage, + resolveSettings, + settingCopy, +} from './accessSettings'; import { loadTeams, selectTeams } from './teamsSlice'; type Props = { @@ -69,6 +90,13 @@ const initialOf = (label: string): string => { const shareKey = (share: ResourceShare): string => `${share.team_id}:${share.target_user_id ?? ''}`; +// Up to this many grants the dialog lists them all; above it, You plus the +// PREVIEW_COUNT most recent and a "Show all" step. +const SHORT_LIST_MAX = 5; +const PREVIEW_COUNT = 3; + +type AccessFilter = 'all' | 'teams' | 'people' | 'editors'; + type Suggestion = | { kind: 'team'; key: string; teamId: string; teamName: string } | { @@ -101,6 +129,21 @@ export default function ShareToTeamModal({ const [loadError, setLoadError] = useState(false); const [actionError, setActionError] = useState(null); + // The owner's per-resource switches and the caller's own access, from + // GET /api/resource_settings. Null until loaded (or when it fails: the hint + // then uses the defaults and the settings group stays hidden). + const [settingsInfo, setSettingsInfo] = + useState(null); + // Access settings is collapsed by default and opens itself once when a + // switch is off its default; after that it follows the user's clicks. + const [settingsOpen, setSettingsOpen] = useState(false); + const [savingSettings, setSavingSettings] = useState>(new Set()); + + // 'all' is the "People with access" step with search and filters. + const [step, setStep] = useState<'main' | 'all'>('main'); + const [accessQuery, setAccessQuery] = useState(''); + const [accessFilter, setAccessFilter] = useState('all'); + // The access level applied to the next suggestion picked from the combobox. const [accessLevel, setAccessLevel] = useState('viewer'); @@ -171,8 +214,62 @@ export default function ShareToTeamModal({ useEffect(() => { dispatch(loadTeams({ token })); refreshShares(); + setSettingsInfo(null); + teamsService + .getResourceSettings(resourceType, resourceId, token) + .then((r) => { + setSettingsInfo(r); + if (anyChanged(resolveSettings(resourceType, r?.settings))) { + setSettingsOpen(true); + } + }) + .catch(() => { + // Without settings the hint falls back to the defaults and the + // owner-only group stays hidden; sharing itself still works. + }); }, [resourceType, resourceId]); + const settings = useMemo( + () => resolveSettings(resourceType, settingsInfo?.settings), + [resourceType, settingsInfo], + ); + const canManageSettings = can(settingsInfo, 'manage_settings'); + + // Optimistically flip one switch, then adopt the server's answer; revert + // and show an Alert when the PUT fails. + const toggleSetting = async (key: string, value: boolean) => { + if (!settingsInfo) return; + const previous = settingsInfo; + setActionError(null); + setSettingsInfo({ + ...previous, + settings: resolveSettings(resourceType, previous.settings).map((s) => + s.key === key ? { ...s, value } : s, + ), + }); + setSavingSettings((prev) => new Set(prev).add(key)); + try { + const r = await teamsService.updateResourceSettings( + resourceType, + resourceId, + { [key]: value }, + token, + ); + if (r?.settings) setSettingsInfo(r); + } catch (error) { + setSettingsInfo(previous); + setActionError( + errorMessage(error, t('settings.teams.accessSettings.saveError')), + ); + } finally { + setSavingSettings((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); + } + }; + // Stable signature of the current team set; lets the fan-out effect below // depend on team identity rather than the array reference (which is a fresh // object on every loadTeams.fulfilled and would otherwise re-fire on reload). @@ -327,8 +424,8 @@ export default function ShareToTeamModal({ }, token, ); - } catch { - setActionError(t('settings.teams.share.shareError')); + } catch (error) { + setActionError(errorMessage(error, t('settings.teams.share.shareError'))); } finally { // Clear this action's in-flight flag *before* reconciling so the refresh // adopts the server's canonical state for this pick (while still @@ -363,8 +460,8 @@ export default function ShareToTeamModal({ }, token, ); - } catch { - setActionError(t('settings.teams.share.shareError')); + } catch (error) { + setActionError(errorMessage(error, t('settings.teams.share.shareError'))); } finally { // Free this row before reconciling so the refresh adopts server state for // it; other rows still busy keep their in-flight optimistic edits. @@ -389,8 +486,10 @@ export default function ShareToTeamModal({ }, token, ); - } catch { - setActionError(t('settings.teams.share.unshareError')); + } catch (error) { + setActionError( + errorMessage(error, t('settings.teams.share.unshareError')), + ); } finally { setRowBusy(key, false); await refreshShares(); @@ -398,8 +497,12 @@ export default function ShareToTeamModal({ }; const title = resourceName - ? t('settings.teams.share.titleNamed', { name: resourceName }) + ? t('settings.teams.share.titleNamed', { + interpolation: { escapeValue: false }, + name: resourceName, + }) : t('settings.teams.share.titleGeneric', { + interpolation: { escapeValue: false }, type: t(`settings.teams.resourceType.${resourceType}`).toLowerCase(), }); @@ -448,7 +551,10 @@ export default function ShareToTeamModal({ : memberDisplay(share.team_id, share.target_user_id!); const secondary = isTeam ? t('settings.teams.share.teamLabel') - : t('settings.teams.share.viaTeam', { team: name }); + : t('settings.teams.share.viaTeam', { + interpolation: { escapeValue: false }, + team: name, + }); return ( { + const indexed = shares.map((share, index) => ({ share, index })); + indexed.sort((x, y) => { + const ax = x.share.created_at ?? ''; + const ay = y.share.created_at ?? ''; + if (ax !== ay) return ax < ay ? 1 : -1; + return y.index - x.index; + }); + return indexed.map((entry) => entry.share); + }, [shares]); + + const isLongList = shares.length > SHORT_LIST_MAX; + const previewShares = isLongList + ? recentShares.slice(0, PREVIEW_COUNT) + : shares; + + const teamGrantCount = shares.filter((s) => !s.target_user_id).length; + const personGrantCount = shares.length - teamGrantCount; + const editorGrantCount = shares.filter( + (s) => s.access_level === 'editor', + ).length; + + const shareLabel = (share: ResourceShare): string => { + const name = share.team_name ?? teamName(share.team_id); + return share.target_user_id + ? `${memberDisplay(share.team_id, share.target_user_id)} ${share.target_user_id} ${name}` + : name; + }; + + const filteredShares = shares.filter((share) => { + if (accessFilter === 'teams' && share.target_user_id) return false; + if (accessFilter === 'people' && !share.target_user_id) return false; + if (accessFilter === 'editors' && share.access_level !== 'editor') + return false; + return matches(shareLabel(share), accessQuery); + }); + + const openAllStep = () => { + setAccessQuery(''); + setAccessFilter('all'); + setStep('all'); + }; + + // The caller's own row: the owner, or an editor the owner let share. + const yourRole = + settingsInfo?.access === 'editor' + ? t('settings.teams.share.accessLevel.editor') + : t('settings.teams.share.owner'); + + const youRow = ( + + {initialOf(t('settings.teams.share.you'))} + + } + title={t('settings.teams.share.you')} + trailing={ + + {yourRole} + + } + /> + ); + + const errors = ( + <> + {loadError && ( + + + )} + {actionError && ( + + + )} + + ); + + const filterOptions: Array<{ value: AccessFilter; count: number }> = [ + { value: 'all', count: shares.length }, + { value: 'teams', count: teamGrantCount }, + { value: 'people', count: personGrantCount }, + { value: 'editors', count: editorGrantCount }, + ]; + + const allStep = ( +
+ +
+

+ {t('settings.teams.share.peopleWithAccess')} +

+

+ {t('settings.teams.share.allSummary', { + interpolation: { escapeValue: false }, + name: resourceName ?? '', + teams: teamGrantCount, + people: personGrantCount, + })} +

+
+ {errors} + setAccessQuery(e.target.value)} + /> +
+ + value && setAccessFilter(value as AccessFilter) + } + aria-label={t('settings.teams.share.filterLabel')} + > + {filterOptions.map((option) => ( + + {t(`settings.teams.share.filter.${option.value}`)} {option.count} + + ))} + +
+ + {accessFilter === 'all' && !accessQuery.trim() && youRow} + {filteredShares.map(renderShareRow)} + + {filteredShares.length === 0 && ( +

+ {t('settings.teams.share.noMatches')} +

+ )} +
+ ); + + const accessSettings = canManageSettings && ( +
+

+ +

+ {settingsOpen && ( + + {settings.map((setting) => { + const copy = settingCopy(t, resourceType, setting.key); + const id = `share-setting-${setting.key}`; + return ( + + toggleSetting(setting.key, value)} + /> + + ); + })} + + )} +
+ ); + + const mainStep = ( +
+
+

+ {t('settings.teams.share.subtitle')} +

+ {errors} +
+ + {teams.length === 0 ? ( +

{t('settings.teams.share.noTeams')}

+ ) : ( + <> +
+ {/* Add row: type-ahead combobox + access level select. */} +
+ + + + + + + + + {!hasSuggestions && ( + + {t('settings.teams.share.noMatches')} + + )} + {teamSuggestions.length > 0 && ( + + {teamSuggestions.map((suggestion) => ( + commitSuggestion(suggestion)} + > + + {initialOf(suggestion.teamName)} + + + {suggestion.teamName} + + + ))} + + )} + {memberSuggestions.length > 0 && ( + + {memberSuggestions.map((suggestion) => + suggestion.kind === 'member' ? ( + commitSuggestion(suggestion)} + > + + {initialOf(suggestion.label)} + + + {suggestion.label} + + {' · '} + {suggestion.teamName} + + + + ) : null, + )} + + )} + + + + + + +
+

+ {editorHint(t, resourceType, settings)} +

+
+ + {/* People with access. */} +
+ + {t('settings.teams.share.showAll', { + count: shares.length, + })} + + + ) + } + /> + + {youRow} + {previewShares.map(renderShareRow)} + + {isLongList && ( +

+ {t('settings.teams.share.andMore', { + count: shares.length - previewShares.length, + })} +

+ )} +
+ + )} + + {accessSettings} +
+ ); + return ( @@ -485,178 +978,7 @@ export default function ShareToTeamModal({ } > -

- {t('settings.teams.share.subtitle')} -

- - {loadError && ( - - - )} - {actionError && ( - - - )} - - {teams.length === 0 ? ( -

{t('settings.teams.share.noTeams')}

- ) : ( - <> - {/* Add row: type-ahead combobox + access level select. */} -
- - - - - - - - - {!hasSuggestions && ( - - {t('settings.teams.share.noMatches')} - - )} - {teamSuggestions.length > 0 && ( - - {teamSuggestions.map((suggestion) => ( - commitSuggestion(suggestion)} - > - - {initialOf(suggestion.teamName)} - - - {suggestion.teamName} - - - ))} - - )} - {memberSuggestions.length > 0 && ( - - {memberSuggestions.map((suggestion) => - suggestion.kind === 'member' ? ( - commitSuggestion(suggestion)} - > - - {initialOf(suggestion.label)} - - - {suggestion.label} - - {' · '} - {suggestion.teamName} - - - - ) : null, - )} - - )} - - - - - - -
- - {/* People with access. */} -
- - - {/* Owner — pinned, non-interactive. */} - - {initialOf(t('settings.teams.share.you'))} - - } - title={t('settings.teams.share.you')} - trailing={ - - {t('settings.teams.share.owner')} - - } - /> - {shares.map(renderShareRow)} - -
- - )} + {step === 'all' ? allStep : mainStep}
); } diff --git a/frontend/src/teams/accessSettings.ts b/frontend/src/teams/accessSettings.ts new file mode 100644 index 00000000..02caa3c4 --- /dev/null +++ b/frontend/src/teams/accessSettings.ts @@ -0,0 +1,164 @@ +import type { TFunction } from 'i18next'; + +import type { + ResourceSetting, + ResourceType, +} from '../api/services/teamsService'; + +/** + * The owner's per-resource switches ("Option A"), mirrored from + * `SWITCHES` in `docsgpt/api/user/resource_access.py`: key and default, in + * the order the share dialog lists them. Only used for labels, ordering and + * a fallback before the server's `/api/resource_settings` answers; the + * server's values always win. + */ +export const SWITCH_DEFAULTS: Record< + ResourceType, + ReadonlyArray<{ key: string; default: boolean }> +> = { + agent: [ + { key: 'editors_can_share', default: false }, + { key: 'editors_can_delete', default: false }, + { key: 'editors_can_manage_access_details', default: true }, + { key: 'viewers_can_see_logs', default: false }, + ], + source: [ + { key: 'editors_can_share', default: false }, + { key: 'editors_can_delete', default: false }, + { key: 'viewers_can_see_config', default: true }, + ], + tool: [ + { key: 'editors_can_change_credentials', default: true }, + { key: 'editors_can_share', default: false }, + { key: 'viewers_can_use_in_agents', default: true }, + ], + prompt: [ + { key: 'editors_can_share', default: false }, + { key: 'viewers_can_duplicate', default: true }, + ], +}; + +/** + * The switches for `type`, in display order, with the server's values + * merged over the defaults. Unknown server keys are appended. + */ +export function resolveSettings( + type: ResourceType, + server: ResourceSetting[] | null | undefined, +): ResourceSetting[] { + const byKey = new Map((server ?? []).map((s) => [s.key, s])); + const known = SWITCH_DEFAULTS[type].map((d) => { + const s = byKey.get(d.key); + return s ?? { key: d.key, value: d.default, default: d.default }; + }); + const extra = (server ?? []).filter( + (s) => !SWITCH_DEFAULTS[type].some((d) => d.key === s.key), + ); + return [...known, ...extra]; +} + +export const settingValue = ( + settings: ResourceSetting[], + key: string, +): boolean => settings.find((s) => s.key === key)?.value ?? false; + +/** Whether any switch is off its default (opens Access settings by default). */ +export const anyChanged = (settings: ResourceSetting[]): boolean => + settings.some((s) => s.value !== s.default); + +/** + * What an Editor can do on this resource, as one or two sentences: a base + * sentence per type, then whether they may also share or delete it. + */ +export function editorHint( + t: TFunction, + type: ResourceType, + settings: ResourceSetting[], +): string { + const on = (key: string) => settingValue(settings, key); + let base: string; + if (type === 'agent') { + base = on('editors_can_manage_access_details') + ? t('settings.teams.editorHint.agent') + : t('settings.teams.editorHint.agentNoAccessDetails'); + } else if (type === 'tool') { + base = on('editors_can_change_credentials') + ? t('settings.teams.editorHint.tool') + : t('settings.teams.editorHint.toolNoCredentials'); + } else { + base = t(`settings.teams.editorHint.${type}`); + } + const share = on('editors_can_share'); + let tail: string; + if (type === 'agent' || type === 'source') { + const del = on('editors_can_delete'); + tail = t( + share && del + ? 'settings.teams.editorHint.shareAndDelete' + : share + ? 'settings.teams.editorHint.shareOnly' + : del + ? 'settings.teams.editorHint.deleteOnly' + : 'settings.teams.editorHint.noShareNoDelete', + ); + } else { + tail = t( + share + ? 'settings.teams.editorHint.share' + : 'settings.teams.editorHint.noShare', + ); + } + return `${base} ${tail}`; +} + +/** A switch's label and description in the share dialog. */ +export const settingCopy = ( + t: TFunction, + type: ResourceType, + key: string, +): { label: string; description: string } => ({ + label: t(`settings.teams.accessSettings.${type}.${key}.label`, { + defaultValue: key, + }), + description: t(`settings.teams.accessSettings.${type}.${key}.description`, { + defaultValue: '', + }), +}); + +/** + * The read-only "What people here can do" lines: the viewer and editor + * baselines, then one line per switch (a check when on, an x when off). + */ +export function capabilityLines( + t: TFunction, + type: ResourceType, + settings: ResourceSetting[], +): Array<{ key: string; allowed: boolean; text: string }> { + return [ + { + key: 'viewers', + allowed: true, + text: t(`settings.teams.capabilities.${type}.viewers`), + }, + { + key: 'editors', + allowed: true, + text: t(`settings.teams.capabilities.${type}.editors`), + }, + ...settings.map((s) => ({ + key: s.key, + allowed: s.value, + text: t( + `settings.teams.capabilities.switch.${s.key}.${s.value ? 'on' : 'off'}`, + { defaultValue: s.key }, + ), + })), + ]; +} + +// A teams API error carries the server's message; anything else (a network +// failure) falls back to the caller's generic copy. +export const errorMessage = (error: unknown, fallback: string): string => + error instanceof Error && error.name === 'TeamsApiError' && error.message + ? error.message + : fallback; diff --git a/frontend/src/teams/teamsSlice.test.ts b/frontend/src/teams/teamsSlice.test.ts new file mode 100644 index 00000000..ac4f6ffa --- /dev/null +++ b/frontend/src/teams/teamsSlice.test.ts @@ -0,0 +1,65 @@ +const remove = vi.fn(); +const list = vi.fn(); + +vi.mock('../api/services/teamsService', () => ({ + default: { + remove: (...args: unknown[]) => remove(...args), + list: (...args: unknown[]) => list(...args), + }, +})); + +import { configureStore } from '@reduxjs/toolkit'; + +import reducer, { deleteTeam, loadTeams, Team } from './teamsSlice'; + +const team: Team = { id: 't1', name: 'Ops', slug: 'ops', owner_id: 'u1' }; + +const makeStore = () => + configureStore({ + reducer: { teams: reducer }, + preloadedState: { + teams: { + teams: [team], + currentTeamId: null, + loading: false, + error: null, + }, + }, + }); + +describe('teamsSlice', () => { + beforeEach(() => { + remove.mockReset(); + list.mockReset(); + }); + + it('keeps the team when the delete is rejected (403)', async () => { + remove.mockRejectedValue(new Error('Only the owner can delete')); + const store = makeStore(); + const result = await store.dispatch(deleteTeam({ id: 't1', token: null })); + expect(result.type).toBe('teams/delete/rejected'); + expect(store.getState().teams.teams).toHaveLength(1); + }); + + it('keeps the team when a 2xx body says success:false', async () => { + remove.mockResolvedValue({ success: false, message: 'nope' }); + const store = makeStore(); + await store.dispatch(deleteTeam({ id: 't1', token: null })); + expect(store.getState().teams.teams).toHaveLength(1); + }); + + it('removes the team on success', async () => { + remove.mockResolvedValue({ success: true }); + const store = makeStore(); + await store.dispatch(deleteTeam({ id: 't1', token: null })); + expect(store.getState().teams.teams).toHaveLength(0); + }); + + it('records a load failure instead of an empty list', async () => { + list.mockRejectedValue(new Error('boom')); + const store = makeStore(); + await store.dispatch(loadTeams({ token: null })); + expect(store.getState().teams.error).toBe('boom'); + expect(store.getState().teams.teams).toHaveLength(1); + }); +}); diff --git a/frontend/src/teams/teamsSlice.ts b/frontend/src/teams/teamsSlice.ts index 1165f663..07946cc7 100644 --- a/frontend/src/teams/teamsSlice.ts +++ b/frontend/src/teams/teamsSlice.ts @@ -8,6 +8,9 @@ export type Team = { slug: string; description?: string | null; owner_id: string; + // Whether the caller owns the team (only the owner may delete it). Newer + // servers send it; older ones only `owner_id`, compared to the caller's sub. + is_owner?: boolean; member_role?: TeamRole; // Annotations from the list endpoint for the teams grid cards. member_count?: number; @@ -35,7 +38,11 @@ const initialState: TeamsState = { export const loadTeams = createAsyncThunk( 'teams/load', async ({ token }) => { + // teamsService rejects on a non-2xx, so a failed load lands in .rejected. const r = await teamsService.list(token); + if (r?.success === false) { + throw new Error(r?.message ?? 'Failed to load teams'); + } return (r?.teams as Team[]) ?? []; }, ); @@ -58,7 +65,12 @@ export const deleteTeam = createAsyncThunk< string, { id: string; token: string | null } >('teams/delete', async ({ id, token }) => { - await teamsService.remove(id, token); + // A non-2xx (e.g. 403 for a non-owner) rejects in teamsService, so the team + // stays in the list; a 2xx that still says success:false rejects too. + const r = await teamsService.remove(id, token); + if (r?.success === false) { + throw new Error(r?.message ?? 'Failed to delete team'); + } return id; }); diff --git a/frontend/src/utils/accessUtils.test.ts b/frontend/src/utils/accessUtils.test.ts new file mode 100644 index 00000000..9b3b8e2e --- /dev/null +++ b/frontend/src/utils/accessUtils.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { can, isOwner, roleOf } from './accessUtils'; + +describe('can', () => { + it('reads allowed_actions from the server', () => { + const item = { + access: 'editor' as const, + allowed_actions: ['edit', 'use'], + }; + expect(can(item, 'edit')).toBe(true); + expect(can(item, 'delete')).toBe(false); + }); + + it('treats an item without access fields as the caller’s own', () => { + // A row created in this session, before the list is refetched. + expect(can({}, 'delete')).toBe(true); + expect(can({ access: 'owner' }, 'delete')).toBe(true); + }); + + it('falls back to the role defaults for a shared item without an action list', () => { + // An API older than the frontend sends only the legacy team fields. + const viewer = { + ownership: 'team' as const, + team_access: 'viewer' as const, + }; + expect(can(viewer, 'pin')).toBe(true); + expect(can(viewer, 'use')).toBe(true); + expect(can(viewer, 'view_config')).toBe(true); + expect(can(viewer, 'edit')).toBe(false); + const editor = { access: 'editor' as const }; + expect(can(editor, 'edit')).toBe(true); + expect(can(editor, 'view_logs')).toBe(true); + expect(can(editor, 'delete')).toBe(false); + expect(can(editor, 'share')).toBe(false); + expect(can(editor, 'manage_settings')).toBe(false); + }); + + it('denies a missing item', () => { + expect(can(null, 'use')).toBe(false); + expect(can(undefined, 'use')).toBe(false); + }); +}); + +describe('roleOf / isOwner', () => { + it('prefers access, then the legacy team fields', () => { + expect(roleOf({ access: 'viewer' })).toBe('viewer'); + expect(roleOf({ ownership: 'team', team_access: 'editor' })).toBe('editor'); + expect(roleOf({ ownership: 'team' })).toBe('viewer'); + expect(roleOf({})).toBe('owner'); + expect(isOwner({ access: 'editor' })).toBe(false); + expect(isOwner({})).toBe(true); + }); +}); diff --git a/frontend/src/utils/accessUtils.ts b/frontend/src/utils/accessUtils.ts new file mode 100644 index 00000000..1fbc54de --- /dev/null +++ b/frontend/src/utils/accessUtils.ts @@ -0,0 +1,76 @@ +/** + * Access to a team-shared resource (agent, source, tool, prompt). + * + * The backend resolves the caller's role and the owner's per-asset switches + * and sends the result on every list and get response as `access` plus + * `allowed_actions` (`docsgpt/api/user/resource_access.py`). The UI only asks + * `can(item, action)`; it never re-derives the rules from the role. + */ +export type Access = 'owner' | 'editor' | 'viewer'; + +export type AccessFields = { + access?: Access | null; + allowed_actions?: string[]; + /** Legacy fields, still sent: `team` marks a resource shared with you. */ + ownership?: 'user' | 'team'; + team_access?: 'viewer' | 'editor' | null; +}; + +/** The caller's role on an item; an item with no access fields is their own. */ +export function roleOf(item: AccessFields): Access { + if (item.access) return item.access; + if (item.ownership === 'team') return item.team_access ?? 'viewer'; + return 'owner'; +} + +export function isOwner(item: AccessFields): boolean { + return roleOf(item) === 'owner'; +} + +/** + * Each role's default actions, used only when a shared item arrives without + * `allowed_actions` (an API older than this frontend). Mirrors the defaults + * of `ACTIONS` in `docsgpt/api/user/resource_access.py`; action names don't + * collide across resource types, so one table covers all four. Owner-only + * actions (share, delete, move, settings) are never in it. + */ +const VIEWER_DEFAULTS = [ + 'use', + 'pin', + 'use_in_own', + 'view_config', + 'duplicate', +]; +const ROLE_DEFAULTS: Record, ReadonlySet> = { + viewer: new Set(VIEWER_DEFAULTS), + editor: new Set([ + ...VIEWER_DEFAULTS, + 'view', + 'edit', + 'publish', + 'edit_policy', + 'view_logs', + 'manage_schedules', + 'export', + 'manage_access_details', + 'edit_credentials', + ]), +}; + +/** + * Whether the caller may perform `action` on `item`. + * + * Uses the server's `allowed_actions` when present. Without it the caller's + * own items allow everything (a row created in this session, before a + * refetch), and a shared item gets its role's defaults, so a stale API + * degrades to sensible menus rather than empty ones. + */ +export function can( + item: AccessFields | null | undefined, + action: string, +): boolean { + if (!item) return false; + if (item.allowed_actions) return item.allowed_actions.includes(action); + const role = roleOf(item); + return role === 'owner' || ROLE_DEFAULTS[role].has(action); +} diff --git a/frontend/src/utils/toolUtils.test.ts b/frontend/src/utils/toolUtils.test.ts index f9b5dccf..a389cd72 100644 --- a/frontend/src/utils/toolUtils.test.ts +++ b/frontend/src/utils/toolUtils.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { isChatToolVisible, isClassicAgentToolVisible } from './toolUtils'; +import { + canAddToolToOwn, + isAgentPickerToolVisible, + isChatPickerToolVisible, + isChatToolVisible, + isClassicAgentToolVisible, + toolInChat, +} from './toolUtils'; // Regression for the filter drift introduced when ``scheduler`` was // dual-registered (both ``default: true`` and ``builtin: true``). The @@ -38,3 +45,86 @@ describe('isClassicAgentToolVisible', () => { expect(isClassicAgentToolVisible({})).toBe(true); }); }); + +// The "In my chats" switch: the owner's value is ``status``; a grantee's is +// their own ``in_chat`` preference, which the server sends for everyone. +describe('toolInChat', () => { + it('prefers in_chat over status', () => { + expect(toolInChat({ status: true, in_chat: false })).toBe(false); + expect(toolInChat({ status: false, in_chat: true })).toBe(true); + }); + + it('falls back to status when in_chat is absent', () => { + expect(toolInChat({ status: true })).toBe(true); + expect(toolInChat({ status: false })).toBe(false); + }); +}); + +describe('canAddToolToOwn', () => { + it("allows the caller's own tools", () => { + expect(canAddToolToOwn({})).toBe(true); + expect(canAddToolToOwn({ access: 'owner', allowed_actions: [] })).toBe( + true, + ); + }); + + it('follows use_in_own for a shared tool', () => { + expect( + canAddToolToOwn({ access: 'viewer', allowed_actions: ['use'] }), + ).toBe(false); + expect( + canAddToolToOwn({ + access: 'viewer', + allowed_actions: ['use', 'use_in_own'], + }), + ).toBe(true); + }); + + it('falls back to the role default for a legacy shared row', () => { + // Viewers and editors may add a shared tool to their own agents by default. + expect(canAddToolToOwn({ ownership: 'team', team_access: 'editor' })).toBe( + true, + ); + }); +}); + +describe('isChatPickerToolVisible', () => { + it('hides shared tools the caller may not add to their own chats', () => { + expect( + isChatPickerToolVisible({ access: 'viewer', allowed_actions: ['use'] }), + ).toBe(false); + }); + + it("keeps shared tools with use_in_own and the caller's own tools", () => { + expect( + isChatPickerToolVisible({ + access: 'editor', + allowed_actions: ['use', 'use_in_own', 'edit'], + }), + ).toBe(true); + expect(isChatPickerToolVisible({})).toBe(true); + }); + + it('still drops pure builtins', () => { + expect(isChatPickerToolVisible({ builtin: true })).toBe(false); + }); +}); + +describe('isAgentPickerToolVisible', () => { + it('keeps own tools and shared tools usable in own agents', () => { + expect(isAgentPickerToolVisible({})).toBe(true); + expect( + isAgentPickerToolVisible({ + access: 'viewer', + allowed_actions: ['use', 'use_in_own'], + }), + ).toBe(true); + }); + + it('hides shared tools without use_in_own and workflow-only builtins', () => { + expect( + isAgentPickerToolVisible({ access: 'viewer', allowed_actions: ['use'] }), + ).toBe(false); + expect(isAgentPickerToolVisible({ workflow_only: true })).toBe(false); + }); +}); diff --git a/frontend/src/utils/toolUtils.ts b/frontend/src/utils/toolUtils.ts index 72f0feac..3515fd07 100644 --- a/frontend/src/utils/toolUtils.ts +++ b/frontend/src/utils/toolUtils.ts @@ -1,3 +1,5 @@ +import { type AccessFields, can, isOwner } from './accessUtils'; + type ToolLabelSource = { customName?: string | null; displayName?: string | null; @@ -31,3 +33,32 @@ export const isChatToolVisible = (tool: { export const isClassicAgentToolVisible = (tool: { workflow_only?: boolean; }): boolean => !tool.workflow_only; + +/** + * Whether a tool is on in the caller's own agentless chats (the "In my chats" + * switch). The server sends `in_chat` for every row: the owner's `status`, or + * a grantee's personal preference. Older payloads only carry `status`. + */ +export const toolInChat = (tool: { + status?: boolean; + in_chat?: boolean; +}): boolean => Boolean(tool.in_chat ?? tool.status); + +/** + * Whether the caller may add a tool to their own agents and chats: always + * for their own tools, and for a shared one only with `use_in_own`. + */ +export const canAddToolToOwn = (tool: AccessFields): boolean => + isOwner(tool) || can(tool, 'use_in_own'); + +// Composer Tools picker: the chat-popup rule, minus shared tools the caller +// can't turn on for their own chats. +export const isChatPickerToolVisible = ( + tool: AccessFields & { default?: boolean; builtin?: boolean }, +): boolean => isChatToolVisible(tool) && canAddToolToOwn(tool); + +// Classic agent and workflow-node pickers: the classic rule, minus shared +// tools the caller can't add to their own agents (`use_in_own` off). +export const isAgentPickerToolVisible = ( + tool: AccessFields & { workflow_only?: boolean }, +): boolean => isClassicAgentToolVisible(tool) && canAddToolToOwn(tool); diff --git a/tests/agents/test_default_tools.py b/tests/agents/test_default_tools.py index 472698ea..29bd9a3a 100644 --- a/tests/agents/test_default_tools.py +++ b/tests/agents/test_default_tools.py @@ -502,6 +502,9 @@ class TestFkBoundToolsIsInSync: "notes": "notes", "todos": "todo_list", } + # FK-bound tables that are not a tool's storage: per-user settings keyed + # by a tool row, never written by a running tool. + _NOT_TOOL_STORAGE = {"user_tool_preferences"} def test_fk_bound_tools_matches_metadata(self): from docsgpt.storage.db.models import metadata @@ -509,7 +512,7 @@ class TestFkBoundToolsIsInSync: fk_bound_tables = set() for tbl in metadata.tables.values(): tool_id_col = tbl.columns.get("tool_id") - if tool_id_col is None: + if tool_id_col is None or tbl.name in self._NOT_TOOL_STORAGE: continue for fk in tool_id_col.foreign_keys: if fk.target_fullname == "user_tools.id": diff --git a/tests/agents/test_headless_runner_prompt.py b/tests/agents/test_headless_runner_prompt.py new file mode 100644 index 00000000..1abb2c0f --- /dev/null +++ b/tests/agents/test_headless_runner_prompt.py @@ -0,0 +1,76 @@ +"""``run_agent_headless`` renders only a prompt the agent's owner may use. + +Scheduled and webhook runs execute as the owner; a prompt the owner lost +access to (a revoked team grant, a deleted prompt) falls back to the default. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from unittest.mock import MagicMock, patch + +import pytest + +PROMPT_ID = "11111111-1111-1111-1111-111111111111" + + +def _rendered_prompt_id(monkeypatch, *, usable: bool) -> str: + from docsgpt.agents import headless_runner as hr + from docsgpt.api.user.resource_access import build + + agent = MagicMock(name="agent") + agent.gen.return_value = iter([{"answer": "ok"}]) + agent.llm.token_usage = {"prompt_tokens": 1, "generated_tokens": 1} + retriever = MagicMock(name="retriever") + retriever.search.return_value = [] + tool_executor = MagicMock(name="tool_executor") + tool_executor.headless_denials = [] + rendered: list = [] + + def _get_prompt(pid): + rendered.append(pid) + return "system prompt" + + seen: list = [] + + def _resolve(_conn, resource_type, resource_id, user_id): + seen.append((resource_type, resource_id, user_id)) + return build(resource_type, resource_id, "viewer", "x", {}) if usable else None + + @contextmanager + def _conn(): + yield MagicMock() + + monkeypatch.setattr(hr, "get_prompt", _get_prompt) + monkeypatch.setattr( + hr.RetrieverCreator, "create_retriever", + classmethod(lambda cls, *a, **kw: retriever), + ) + monkeypatch.setattr(hr, "ToolExecutor", lambda *a, **kw: tool_executor) + monkeypatch.setattr( + hr.AgentCreator, "create_agent", classmethod(lambda cls, *a, **kw: agent), + ) + config = { + "user_id": "owner-1", "id": "agent-1", "default_model_id": "m", + "prompt_id": PROMPT_ID, + } + with patch("docsgpt.core.model_utils.validate_model_id", return_value=True), \ + patch("docsgpt.core.model_utils.get_default_model_id", return_value="m"), \ + patch("docsgpt.core.model_utils.get_provider_from_model_id", return_value="openai"), \ + patch("docsgpt.core.model_utils.get_api_key_for_provider", return_value="k"), \ + patch("docsgpt.utils.calculate_doc_token_budget", return_value=1000), \ + patch("docsgpt.api.user.resource_access.resolve", _resolve), \ + patch("docsgpt.api.answer.services.stream_processor.db_readonly", _conn): + hr.run_agent_headless(config, "do the thing") + # Checked as the agent's owner, not whoever scheduled the run. + assert seen == [("prompt", PROMPT_ID, "owner-1")] + return rendered[0] + + +@pytest.mark.unit +class TestHeadlessRunnerPromptAccess: + def test_usable_prompt_is_rendered(self, monkeypatch): + assert _rendered_prompt_id(monkeypatch, usable=True) == PROMPT_ID + + def test_unusable_prompt_falls_back_to_default(self, monkeypatch): + assert _rendered_prompt_id(monkeypatch, usable=False) == "default" diff --git a/tests/agents/test_tool_access_runtime.py b/tests/agents/test_tool_access_runtime.py new file mode 100644 index 00000000..7d6444ab --- /dev/null +++ b/tests/agents/test_tool_access_runtime.py @@ -0,0 +1,155 @@ +"""Runtime resolution of team-shared tools (owner context, live grant checks).""" + +from __future__ import annotations + +import uuid +from contextlib import contextmanager +from unittest.mock import Mock + +import pytest + +from docsgpt.agents.default_tools import resolve_tool_by_id +from docsgpt.agents.tool_executor import ToolExecutor +from docsgpt.api.user.resource_access import set_settings +from docsgpt.security.encryption import encrypt_credentials +from docsgpt.storage.db.repositories.agents import AgentsRepository +from docsgpt.storage.db.repositories.team_members import TeamMembersRepository +from docsgpt.storage.db.repositories.team_resource_grants import ( + TeamResourceGrantsRepository, +) +from docsgpt.storage.db.repositories.teams import TeamsRepository +from docsgpt.storage.db.repositories.user_tool_preferences import ( + UserToolPreferencesRepository, +) +from docsgpt.storage.db.repositories.user_tools import UserToolsRepository + +OWNER = "alice" + + +def _tool(conn, name="read_webpage", config=None, status=True): + return UserToolsRepository(conn).create( + OWNER, name, config=config or {}, display_name=name, description="", + actions=[{"name": "act", "active": True}], status=status, + ) + + +def _share(conn, tool_id, member, level="viewer"): + team = TeamsRepository(conn).create("Acme", f"t-{uuid.uuid4().hex[:8]}", OWNER) + TeamMembersRepository(conn).add_member(str(team["id"]), member) + TeamResourceGrantsRepository(conn).grant( + str(team["id"]), "tool", str(tool_id), OWNER, OWNER, access_level=level + ) + return team + + +def _revoke(conn, team, tool_id): + TeamResourceGrantsRepository(conn).revoke(str(team["id"]), "tool", str(tool_id)) + + +@pytest.fixture +def use_conn(monkeypatch, pg_conn): + @contextmanager + def _yield(): + yield pg_conn + + monkeypatch.setattr("docsgpt.agents.tool_executor.db_readonly", _yield) + return pg_conn + + +class TestResolveToolById: + def test_owner_resolves(self, pg_conn): + tool = _tool(pg_conn) + row = resolve_tool_by_id(str(tool["id"]), OWNER, user_tools_repo=UserToolsRepository(pg_conn)) + assert row is not None and row["user_id"] == OWNER + + def test_grantee_with_use_in_own_resolves_owner_row(self, pg_conn): + tool = _tool(pg_conn) + _share(pg_conn, tool["id"], "bob") + row = resolve_tool_by_id(str(tool["id"]), "bob", user_tools_repo=UserToolsRepository(pg_conn)) + assert row is not None and row["user_id"] == OWNER + + def test_viewer_without_use_in_own_does_not_resolve(self, pg_conn): + tool = _tool(pg_conn) + _share(pg_conn, tool["id"], "bob") + set_settings(pg_conn, "tool", str(tool["id"]), {"viewers_can_use_in_agents": False}, OWNER) + repo = UserToolsRepository(pg_conn) + assert resolve_tool_by_id(str(tool["id"]), "bob", user_tools_repo=repo) is None + # Editors keep use_in_own when the viewer switch is off. + _share(pg_conn, tool["id"], "carol", "editor") + assert resolve_tool_by_id(str(tool["id"]), "carol", user_tools_repo=repo) is not None + + def test_stranger_does_not_resolve(self, pg_conn): + tool = _tool(pg_conn) + assert resolve_tool_by_id(str(tool["id"]), "eve", user_tools_repo=UserToolsRepository(pg_conn)) is None + + +class TestAgentToolsForOwner: + def _agent(self, conn, owner, tool_ids): + key = f"k-{uuid.uuid4().hex[:8]}" + AgentsRepository(conn).create(owner, "A", "published", key=key, tools=[str(t) for t in tool_ids]) + return key + + def test_team_tool_on_grantees_agent_resolves_then_drops_on_revoke(self, use_conn): + tool = _tool(use_conn) + team = _share(use_conn, tool["id"], "bob") + key = self._agent(use_conn, "bob", [tool["id"]]) + # Carol chats with Bob's agent: tools resolve under Bob, the agent owner. + tools = ToolExecutor(user_api_key=key, user="carol")._get_tools_by_api_key(key) + assert str(tool["id"]) in tools + assert tools[str(tool["id"])]["user_id"] == OWNER + _revoke(use_conn, team, tool["id"]) + assert ToolExecutor(user_api_key=key, user="carol")._get_tools_by_api_key(key) == {} + + def test_explicit_ids_path_checks_the_caller(self, use_conn): + tool = _tool(use_conn) + executor = ToolExecutor(user="bob") + assert executor._get_tools_by_ids([str(tool["id"])]) == {} + _share(use_conn, tool["id"], "bob") + assert str(tool["id"]) in executor._get_tools_by_ids([str(tool["id"])]) + + +class TestAgentlessChatTools: + def test_includes_in_chat_team_tools(self, use_conn): + own = _tool(use_conn, name="brave") + shared = _tool(use_conn, name="read_webpage") + team = _share(use_conn, shared["id"], "bob") + UserToolsRepository(use_conn).create("bob", "cryptoprice", status=True) + names = lambda: sorted( # noqa: E731 + t["name"] for t in ToolExecutor(user="bob")._get_user_tools("bob").values() if t.get("user_id") + ) + assert names() == ["cryptoprice"] # sharing alone never adds to chats + UserToolPreferencesRepository(use_conn).set_in_chat("bob", str(shared["id"]), True) + assert names() == ["cryptoprice", "read_webpage"] + set_settings(use_conn, "tool", str(shared["id"]), {"viewers_can_use_in_agents": False}, OWNER) + assert names() == ["cryptoprice"] + set_settings(use_conn, "tool", str(shared["id"]), {"viewers_can_use_in_agents": True}, OWNER) + _revoke(use_conn, team, shared["id"]) + assert names() == ["cryptoprice"] + assert own["id"] # the owner's own tool never leaks into bob's chat + + +class TestRuntimeCredentials: + def test_mcp_tool_loads_with_owner_identity(self, monkeypatch): + executor = ToolExecutor(user="bob") + mock_tm = Mock() + monkeypatch.setattr("docsgpt.agents.tool_executor.ToolManager", lambda config: mock_tm) + tool_data = {"id": str(uuid.uuid4()), "name": "mcp_tool", "user_id": OWNER, "config": {}} + executor._get_or_load_tool(tool_data, "t1", "act") + assert mock_tm.load_tool.call_args.kwargs["user_id"] == OWNER + + def test_api_tool_secret_values_decrypt_with_owner(self): + from docsgpt.agents.tool_executor import api_tool_action_with_secrets + + blob = encrypt_credentials({"a": {"headers": {"X-Key": "sk"}}}, OWNER) + tool_data = { + "user_id": OWNER, + "config": { + "encrypted_action_secrets": blob, + "actions": {"a": {"url": "https://x", "headers": {"properties": { + "X-Key": {"value": "", "has_value": True, "filled_by_llm": False}}}}}, + }, + } + action = api_tool_action_with_secrets(tool_data, "a", "bob") + assert action["headers"]["properties"]["X-Key"]["value"] == "sk" + # The stored row is not mutated. + assert tool_data["config"]["actions"]["a"]["headers"]["properties"]["X-Key"]["value"] == "" diff --git a/tests/agents/test_tool_executor_headless.py b/tests/agents/test_tool_executor_headless.py index ae3c1dc5..bf48cca6 100644 --- a/tests/agents/test_tool_executor_headless.py +++ b/tests/agents/test_tool_executor_headless.py @@ -148,6 +148,11 @@ class TestHeadlessSchedulerExclusion: "get": lambda _self, _u: None, })(), ) + # No team tools switched into chats either. + monkeypatch.setattr( + te_module.ToolExecutor, "_shared_in_chat_tools", + staticmethod(lambda _conn, _user: []), + ) sched_id = default_tool_id("scheduler") diff --git a/tests/agents/tools/test_wiki.py b/tests/agents/tools/test_wiki.py index db3d21d8..a830b5be 100644 --- a/tests/agents/tools/test_wiki.py +++ b/tests/agents/tools/test_wiki.py @@ -152,6 +152,17 @@ def patched_wiki(monkeypatch, reembed_mock, rebuild_mock): task = MagicMock() task.delay = reembed_mock monkeypatch.setattr("docsgpt.api.user.tasks.reembed_wiki_page", task) + # Every write re-checks the caller's live grant; default to editor. + monkeypatch.setattr( + "docsgpt.api.user.resource_access.resolve", + lambda conn, rt, rid, uid: _access("editor"), + ) + + +def _access(level): + from docsgpt.api.user.resource_access import build + + return build("source", "src-1", level, "owner-sub", {}) @pytest.fixture @@ -194,6 +205,54 @@ class TestBasics: assert wiki_tool.updated_by == "caller-sub" +# ===================================================================== +# Live grant re-check on writes +# ===================================================================== + + +@pytest.mark.unit +class TestLiveWriteCheck: + @pytest.mark.parametrize("revoked", [None, "viewer"]) + def test_revoked_or_viewer_cannot_write(self, wiki_tool, monkeypatch, reembed_mock, revoked): + wiki_tool.execute_action("create", path="/a.md", content="one") + monkeypatch.setattr( + "docsgpt.api.user.resource_access.resolve", + lambda conn, rt, rid, uid: _access(revoked) if revoked else None, + ) + reembed_mock.reset_mock() + for action, kwargs in ( + ("create", {"path": "/b.md", "content": "x"}), + ("str_replace", {"path": "/a.md", "old_str": "one", "new_str": "two"}), + ("insert", {"path": "/a.md", "insert_line": 1, "insert_text": "x"}), + ("delete", {"path": "/a.md"}), + ("rename", {"old_path": "/a.md", "new_path": "/c.md"}), + ): + result = wiki_tool.execute_action(action, **kwargs) + assert "no longer have edit access" in result, action + reembed_mock.assert_not_called() + # Reads still work. + assert "one" in wiki_tool.execute_action("view", path="/a.md") + + def test_checks_the_invoking_user(self, wiki_tool, monkeypatch): + seen = [] + + def _resolve(conn, rt, rid, uid): + seen.append((rt, rid, uid)) + return _access("editor") + + monkeypatch.setattr("docsgpt.api.user.resource_access.resolve", _resolve) + wiki_tool.execute_action("create", path="/a.md", content="x") + assert seen == [("source", "src-1", "caller-sub")] + + def test_no_caller_denied(self, patched_wiki): + from docsgpt.agents.tools.wiki import WikiTool + + tool = WikiTool({"source_id": "src-1", "source_owner_id": "owner-sub"}) + assert "no longer have edit access" in tool.execute_action( + "create", path="/a.md", content="x" + ) + + # ===================================================================== # create / view # ===================================================================== @@ -498,8 +557,8 @@ class TestBuildAgentGating: _noop_conn, ) monkeypatch.setattr( - "docsgpt.api.user.team_sharing.effective_write_owner", - lambda conn, rt, rid, uid: "owner-x", + "docsgpt.api.answer.services.stream_processor._wiki_write_owner", + lambda conn, sid, uid: "owner-x", ) cfg = proc._build_wiki_config() assert cfg is not None @@ -524,10 +583,10 @@ class TestBuildAgentGating: "docsgpt.api.answer.services.stream_processor.db_readonly", _noop_conn, ) - # Viewer: effective_write_owner returns None. + # Viewer: no ``edit`` on the source, so no write owner. monkeypatch.setattr( - "docsgpt.api.user.team_sharing.effective_write_owner", - lambda conn, rt, rid, uid: None, + "docsgpt.api.answer.services.stream_processor._wiki_write_owner", + lambda conn, sid, uid: None, ) assert proc._build_wiki_config() is None @@ -550,8 +609,8 @@ class TestBuildAgentGating: _noop_conn, ) monkeypatch.setattr( - "docsgpt.api.user.team_sharing.effective_write_owner", - lambda conn, rt, rid, uid: "owner-x", + "docsgpt.api.answer.services.stream_processor._wiki_write_owner", + lambda conn, sid, uid: "owner-x", ) assert proc._build_wiki_config() is None @@ -574,8 +633,8 @@ class TestBuildAgentGating: _noop_conn, ) monkeypatch.setattr( - "docsgpt.api.user.team_sharing.effective_write_owner", - lambda conn, rt, rid, uid: "owner-x", + "docsgpt.api.answer.services.stream_processor._wiki_write_owner", + lambda conn, sid, uid: "owner-x", ) cfg = proc._build_wiki_config() assert cfg is not None diff --git a/tests/api/answer/services/test_stream_processor_access.py b/tests/api/answer/services/test_stream_processor_access.py new file mode 100644 index 00000000..1ac58ede --- /dev/null +++ b/tests/api/answer/services/test_stream_processor_access.py @@ -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 diff --git a/tests/api/test_agent_team_sharing.py b/tests/api/test_agent_team_sharing.py index 26a4f284..624bfcc0 100644 --- a/tests/api/test_agent_team_sharing.py +++ b/tests/api/test_agent_team_sharing.py @@ -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), ] diff --git a/tests/api/test_connector_routes_happy.py b/tests/api/test_connector_routes_happy.py index 141f87b0..e1302399 100644 --- a/tests/api/test_connector_routes_happy.py +++ b/tests/api/test_connector_routes_happy.py @@ -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 diff --git a/tests/api/test_data_plane_audit.py b/tests/api/test_data_plane_audit.py index b298917b..4647242e 100644 --- a/tests/api/test_data_plane_audit.py +++ b/tests/api/test_data_plane_audit.py @@ -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 == [] diff --git a/tests/api/test_teams_endpoints.py b/tests/api/test_teams_endpoints.py index f8fec458..378b43b7 100644 --- a/tests/api/test_teams_endpoints.py +++ b/tests/api/test_teams_endpoints.py @@ -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, diff --git a/tests/api/user/agents/test_roles_access.py b/tests/api/user/agents/test_roles_access.py new file mode 100644 index 00000000..80ddb1aa --- /dev/null +++ b/tests/api/user/agents/test_roles_access.py @@ -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 diff --git a/tests/api/user/sources/test_chunks.py b/tests/api/user/sources/test_chunks.py index 052e54b4..f5cd4999 100644 --- a/tests/api/user/sources/test_chunks.py +++ b/tests/api/user/sources/test_chunks.py @@ -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 diff --git a/tests/api/user/sources/test_paginated_team_sharing.py b/tests/api/user/sources/test_paginated_team_sharing.py index e6105bae..d2aee4ad 100644 --- a/tests/api/user/sources/test_paginated_team_sharing.py +++ b/tests/api/user/sources/test_paginated_team_sharing.py @@ -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] diff --git a/tests/api/user/sources/test_routes.py b/tests/api/user/sources/test_routes.py index 0fb117c8..ecfb386c 100644 --- a/tests/api/user/sources/test_routes.py +++ b/tests/api/user/sources/test_routes.py @@ -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 diff --git a/tests/api/user/sources/test_source_roles.py b/tests/api/user/sources/test_source_roles.py new file mode 100644 index 00000000..c4a2282a --- /dev/null +++ b/tests/api/user/sources/test_source_roles.py @@ -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 diff --git a/tests/api/user/teams/__init__.py b/tests/api/user/teams/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/api/user/teams/test_team_roles_routes.py b/tests/api/user/teams/test_team_roles_routes.py new file mode 100644 index 00000000..e7245eea --- /dev/null +++ b/tests/api/user/teams/test_team_roles_routes.py @@ -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//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//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//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 diff --git a/tests/api/user/test_prompts.py b/tests/api/user/test_prompts.py index d0440a88..3e6b8df4 100644 --- a/tests/api/user/test_prompts.py +++ b/tests/api/user/test_prompts.py @@ -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 diff --git a/tests/api/user/test_prompts_access.py b/tests/api/user/test_prompts_access.py new file mode 100644 index 00000000..cc6a8ced --- /dev/null +++ b/tests/api/user/test_prompts_access.py @@ -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 diff --git a/tests/api/user/test_resource_access.py b/tests/api/user/test_resource_access.py new file mode 100644 index 00000000..822f7f77 --- /dev/null +++ b/tests/api/user/test_resource_access.py @@ -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") diff --git a/tests/api/user/test_scheduler_worker.py b/tests/api/user/test_scheduler_worker.py index 09528cd5..71afc851 100644 --- a/tests/api/user/test_scheduler_worker.py +++ b/tests/api/user/test_scheduler_worker.py @@ -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" diff --git a/tests/api/user/test_schedules_routes.py b/tests/api/user/test_schedules_routes.py index 7baf5256..6d8f99cc 100644 --- a/tests/api/user/test_schedules_routes.py +++ b/tests/api/user/test_schedules_routes.py @@ -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", ): diff --git a/tests/api/user/test_tools_access.py b/tests/api/user/test_tools_access.py new file mode 100644 index 00000000..07d6f3c3 --- /dev/null +++ b/tests/api/user/test_tools_access.py @@ -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 diff --git a/tests/api/user/workflows/test_routes_coverage.py b/tests/api/user/workflows/test_routes_coverage.py index e1247053..716c961c 100644 --- a/tests/api/user/workflows/test_routes_coverage.py +++ b/tests/api/user/workflows/test_routes_coverage.py @@ -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):