From 98afa5d93ce15579fcbb18c27b4bdcea8ddd0425 Mon Sep 17 00:00:00 2001
From: Pavel
Date: Tue, 29 Sep 2026 10:42:39 +0400
Subject: [PATCH] Roles audit and revamp
---
docsgpt/agents/default_tools.py | 27 +-
docsgpt/agents/headless_runner.py | 9 +-
docsgpt/agents/tool_executor.py | 74 +-
docsgpt/agents/tools/wiki.py | 38 +
.../versions/0038_resource_access_settings.py | 65 ++
.../api/answer/services/stream_processor.py | 62 +-
docsgpt/api/connector/routes.py | 105 ++-
docsgpt/api/pat/rules.py | 2 +
docsgpt/api/user/agents/folders.py | 25 +-
docsgpt/api/user/agents/guardrails.py | 63 +-
docsgpt/api/user/agents/portability.py | 16 +-
docsgpt/api/user/agents/routes.py | 297 +++++--
docsgpt/api/user/agents/sharing.py | 56 +-
docsgpt/api/user/agents/webhooks.py | 12 +-
docsgpt/api/user/analytics/routes.py | 53 +-
docsgpt/api/user/prompts/routes.py | 126 +--
docsgpt/api/user/resource_access.py | 334 ++++++++
docsgpt/api/user/scheduler_worker.py | 43 ++
docsgpt/api/user/schedules/routes.py | 168 +++-
docsgpt/api/user/sources/access.py | 53 ++
docsgpt/api/user/sources/chunks.py | 75 +-
docsgpt/api/user/sources/retrieval_test.py | 16 +-
docsgpt/api/user/sources/routes.py | 319 ++++----
docsgpt/api/user/sources/upload.py | 23 +-
docsgpt/api/user/team_sharing.py | 31 +-
docsgpt/api/user/teams/routes.py | 353 ++++++++-
docsgpt/api/user/tools/mcp.py | 247 +++---
docsgpt/api/user/tools/routes.py | 444 ++++++++---
docsgpt/api/user/workflows/routes.py | 103 ++-
docsgpt/storage/db/models.py | 33 +
.../db/repositories/team_resource_grants.py | 5 +-
.../db/repositories/user_tool_preferences.py | 91 +++
frontend/DESIGN.md | 5 +-
frontend/src/Navigation.tsx | 8 +-
frontend/src/agents/AgentCard.test.tsx | 266 +++++++
frontend/src/agents/AgentCard.tsx | 155 ++--
frontend/src/agents/AgentPageHeader.test.tsx | 25 +
frontend/src/agents/AgentPageHeader.tsx | 16 +-
frontend/src/agents/AgentRouteGuard.test.tsx | 128 +++
frontend/src/agents/AgentRouteGuard.tsx | 83 ++
frontend/src/agents/NewAgent.test.tsx | 199 ++++-
frontend/src/agents/NewAgent.tsx | 154 ++--
frontend/src/agents/agentAccess.test.ts | 54 ++
frontend/src/agents/agentAccess.ts | 51 ++
frontend/src/agents/index.tsx | 36 +-
frontend/src/agents/types/index.ts | 5 +
.../src/agents/workflow/WorkflowBuilder.tsx | 70 +-
.../src/agents/workflow/workflowHelpers.ts | 5 +
frontend/src/api/endpoints.ts | 1 +
.../src/api/services/teamsService.test.ts | 81 ++
frontend/src/api/services/teamsService.ts | 91 +++
frontend/src/components/Chunks.test.tsx | 35 +
frontend/src/components/Chunks.tsx | 64 +-
.../src/components/ConnectorTree.test.tsx | 95 ++-
frontend/src/components/ConnectorTree.tsx | 53 +-
frontend/src/components/FileTree.tsx | 11 +-
frontend/src/components/GraphView.tsx | 4 +
frontend/src/components/MessageInput.tsx | 48 +-
.../src/components/graph/GraphChunkSheet.tsx | 61 +-
.../src/components/graph/GraphEntities.tsx | 4 +
.../src/components/graph/GraphNodePanel.tsx | 7 +
.../components/graph/GraphSourceView.test.tsx | 84 +-
.../src/components/graph/GraphSourceView.tsx | 11 +
.../src/components/tree/TreeBrowser.test.tsx | 11 +
frontend/src/components/tree/TreeBrowser.tsx | 7 +
frontend/src/components/ui/list-row.test.tsx | 11 +
frontend/src/components/ui/list-row.tsx | 23 +-
frontend/src/conversation/Conversation.tsx | 6 +-
frontend/src/locale/de.json | 228 +++++-
frontend/src/locale/en.json | 228 +++++-
frontend/src/locale/es.json | 228 +++++-
frontend/src/locale/jp.json | 228 +++++-
frontend/src/locale/ru.json | 228 +++++-
frontend/src/locale/zh-TW.json | 228 +++++-
frontend/src/locale/zh.json | 228 +++++-
.../src/modals/AgentDetailsModal.test.tsx | 118 +++
frontend/src/modals/AgentDetailsModal.tsx | 126 ++-
frontend/src/modals/MCPServerModal.test.tsx | 200 +++++
frontend/src/modals/MCPServerModal.tsx | 137 +++-
frontend/src/models/misc.ts | 12 +-
frontend/src/navigation/sections.test.ts | 42 +
frontend/src/navigation/sections.ts | 66 +-
frontend/src/navigation/useSectionContext.ts | 11 +-
frontend/src/navigation/useSectionResolver.ts | 11 +-
.../src/preferences/PromptsModal.test.tsx | 36 +
frontend/src/preferences/PromptsModal.tsx | 13 +-
frontend/src/settings/Prompts.test.tsx | 228 +++++-
frontend/src/settings/Prompts.tsx | 109 ++-
.../src/settings/SourceConfigModal.test.tsx | 105 +++
frontend/src/settings/SourceConfigModal.tsx | 8 +-
frontend/src/settings/Sources.test.tsx | 318 ++++++++
frontend/src/settings/Sources.tsx | 124 +--
frontend/src/settings/Teams.test.tsx | 358 +++++++++
frontend/src/settings/Teams.tsx | 730 ++++++++++++++++--
frontend/src/settings/ToolConfig.test.tsx | 140 ++++
frontend/src/settings/ToolConfig.tsx | 137 ++--
frontend/src/settings/Tools.test.tsx | 266 +++++++
frontend/src/settings/Tools.tsx | 128 ++-
frontend/src/settings/index.test.tsx | 138 ++++
frontend/src/settings/index.tsx | 44 +-
frontend/src/settings/types/index.ts | 18 +
frontend/src/teams/ShareToTeamModal.test.tsx | 324 ++++++++
frontend/src/teams/ShareToTeamModal.tsx | 686 +++++++++++-----
frontend/src/teams/accessSettings.ts | 164 ++++
frontend/src/teams/teamsSlice.test.ts | 65 ++
frontend/src/teams/teamsSlice.ts | 14 +-
frontend/src/utils/accessUtils.test.ts | 54 ++
frontend/src/utils/accessUtils.ts | 76 ++
frontend/src/utils/toolUtils.test.ts | 92 ++-
frontend/src/utils/toolUtils.ts | 31 +
tests/agents/test_default_tools.py | 5 +-
tests/agents/test_headless_runner_prompt.py | 76 ++
tests/agents/test_tool_access_runtime.py | 155 ++++
tests/agents/test_tool_executor_headless.py | 5 +
tests/agents/tools/test_wiki.py | 77 +-
.../services/test_stream_processor_access.py | 118 +++
tests/api/test_agent_team_sharing.py | 37 +-
tests/api/test_connector_routes_happy.py | 22 +-
tests/api/test_data_plane_audit.py | 33 +-
tests/api/test_teams_endpoints.py | 26 +-
tests/api/user/agents/test_roles_access.py | 703 +++++++++++++++++
tests/api/user/sources/test_chunks.py | 49 +-
.../sources/test_paginated_team_sharing.py | 5 +
tests/api/user/sources/test_routes.py | 16 +-
tests/api/user/sources/test_source_roles.py | 509 ++++++++++++
tests/api/user/teams/__init__.py | 0
.../api/user/teams/test_team_roles_routes.py | 531 +++++++++++++
tests/api/user/test_prompts.py | 4 +-
tests/api/user/test_prompts_access.py | 145 ++++
tests/api/user/test_resource_access.py | 205 +++++
tests/api/user/test_scheduler_worker.py | 64 ++
tests/api/user/test_schedules_routes.py | 2 +-
tests/api/user/test_tools_access.py | 571 ++++++++++++++
.../user/workflows/test_routes_coverage.py | 8 +-
134 files changed, 13725 insertions(+), 1702 deletions(-)
create mode 100644 docsgpt/alembic/versions/0038_resource_access_settings.py
create mode 100644 docsgpt/api/user/resource_access.py
create mode 100644 docsgpt/api/user/sources/access.py
create mode 100644 docsgpt/storage/db/repositories/user_tool_preferences.py
create mode 100644 frontend/src/agents/AgentCard.test.tsx
create mode 100644 frontend/src/agents/AgentRouteGuard.test.tsx
create mode 100644 frontend/src/agents/AgentRouteGuard.tsx
create mode 100644 frontend/src/agents/agentAccess.test.ts
create mode 100644 frontend/src/agents/agentAccess.ts
create mode 100644 frontend/src/api/services/teamsService.test.ts
create mode 100644 frontend/src/modals/AgentDetailsModal.test.tsx
create mode 100644 frontend/src/modals/MCPServerModal.test.tsx
create mode 100644 frontend/src/settings/SourceConfigModal.test.tsx
create mode 100644 frontend/src/settings/Sources.test.tsx
create mode 100644 frontend/src/settings/Teams.test.tsx
create mode 100644 frontend/src/settings/Tools.test.tsx
create mode 100644 frontend/src/settings/index.test.tsx
create mode 100644 frontend/src/teams/ShareToTeamModal.test.tsx
create mode 100644 frontend/src/teams/accessSettings.ts
create mode 100644 frontend/src/teams/teamsSlice.test.ts
create mode 100644 frontend/src/utils/accessUtils.test.ts
create mode 100644 frontend/src/utils/accessUtils.ts
create mode 100644 tests/agents/test_headless_runner_prompt.py
create mode 100644 tests/agents/test_tool_access_runtime.py
create mode 100644 tests/api/answer/services/test_stream_processor_access.py
create mode 100644 tests/api/user/agents/test_roles_access.py
create mode 100644 tests/api/user/sources/test_source_roles.py
create mode 100644 tests/api/user/teams/__init__.py
create mode 100644 tests/api/user/teams/test_team_roles_routes.py
create mode 100644 tests/api/user/test_prompts_access.py
create mode 100644 tests/api/user/test_resource_access.py
create mode 100644 tests/api/user/test_tools_access.py
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 `
)}
- {modeConfig[effectiveMode].showSaveDraft && (
+ {modeConfig[effectiveMode].showSaveDraft && can(agent, 'edit') && (
)}
-
- {modeConfig[effectiveMode].buttonText}
-
+ {canSubmit && (
+
+ {modeConfig[effectiveMode].buttonText}
+
+ )}
);
@@ -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 && (
-
-
- setDeleteConfirmation('ACTIVE')}
- className="shrink-0"
+ padding="lg"
+ className="flex-row flex-wrap items-start justify-between"
>
- {t('agents.form.dangerZone.deleteButton')}
-
-
- )}
+
+ setDeleteConfirmation('ACTIVE')}
+ className="shrink-0"
+ >
+ {t('agents.form.dangerZone.deleteButton')}
+
+
+ )}
- {t('agents.form.buttons.publish')}
-
+ can(agent, 'publish') ? (
+
+ {t('agents.form.buttons.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')}
-
- {primaryActionLabel}
-
+ {canSubmit && (
+
+ {primaryActionLabel}
+
+ )}
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}
- openSheet('add')}
- >
- {t('settings.sources.addChunk')}
-
+ {canEdit ? (
+ openSheet('add')}
+ >
+ {t('settings.sources.addChunk')}
+
+ ) : null}
);
@@ -675,16 +683,18 @@ const Chunks: React.FC = ({
disabled={!canGoNext}
onClick={() => goToChunk(openPosition + 1)}
/>
- openSheet('edit')}
- >
-
- {t('modals.chunk.edit')}
-
+ {canEdit ? (
+ openSheet('edit')}
+ >
+
+ {t('modals.chunk.edit')}
+
+ ) : 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}
- setSyncConfirmationModal('ACTIVE')}
- disabled={isSyncing}
- >
- {syncDone ? (
-
- ) : isSyncing ? (
- // The busy state shows its percentage, so it keeps the label and
- // draws the app's ring spinner at icon size (DESIGN.md, Button).
-
- ) : (
-
- )}
- {isSyncing
- ? `${syncProgress}%`
- : syncDone
- ? t('settings.sources.syncDone')
- : t('settings.sources.sync')}
-
+ {canEdit ? (
+ setSyncConfirmationModal('ACTIVE')}
+ disabled={isSyncing}
+ >
+ {syncDone ? (
+
+ ) : isSyncing ? (
+ // The busy state shows its percentage, so it keeps the label and
+ // draws the app's ring spinner at icon size (DESIGN.md, Button).
+
+ ) : (
+
+ )}
+ {isSyncing
+ ? `${syncProgress}%`
+ : syncDone
+ ? t('settings.sources.syncDone')
+ : t('settings.sources.sync')}
+
+ ) : 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 ? (
{t('settings.sources.addFile')}
@@ -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 ? (
-
{
- close();
- onOpenInFiles(path);
- }}
- >
- {t('settings.sources.graphrag.view.openInFiles')}
-
- ) : null}
-
-
- {t('modals.chunk.edit')}
-
-
+ {/* A reader with nothing to open or edit gets no action row. */}
+ {showOpenInFiles || canEdit ? (
+ <>
+
+
+ {showOpenInFiles ? (
+
{
+ close();
+ onOpenInFiles?.(path);
+ }}
+ >
+ {t('settings.sources.graphrag.view.openInFiles')}
+
+ ) : null}
+ {canEdit ? (
+
+
+ {t('modals.chunk.edit')}
+
+ ) : 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={
void;
/** Refetch the detail after a chunk edit, keeping it on screen. */
onChunkSaved?: () => void;
+ /** Whether the chunk drawer offers Edit (`can(source, 'edit')`). */
+ canEdit?: boolean;
}
/**
@@ -105,6 +107,7 @@ export default function GraphNodePanel({
overview,
onOpenInFiles,
onChunkSaved,
+ canEdit = true,
}: GraphNodePanelProps) {
const { t } = useTranslation();
const name = detail?.name ?? node.name;
@@ -166,6 +169,7 @@ export default function GraphNodePanel({
overview={overview}
onOpenInFiles={onOpenInFiles}
onChunkSaved={onChunkSaved}
+ canEdit={canEdit}
/>
) : (
void;
onChunkSaved?: () => void;
+ canEdit: boolean;
}) {
const { t } = useTranslation();
const [descriptionOpen, setDescriptionOpen] = useState(false);
@@ -413,6 +419,7 @@ function NodeDetailBody({
onClose={() => setOpenChunk(null)}
onOpenInFiles={onOpenInFiles}
onSaved={onChunkSaved}
+ canEdit={canEdit}
/>
>
);
diff --git a/frontend/src/components/graph/GraphSourceView.test.tsx b/frontend/src/components/graph/GraphSourceView.test.tsx
index dceedf42..04e1e49f 100644
--- a/frontend/src/components/graph/GraphSourceView.test.tsx
+++ b/frontend/src/components/graph/GraphSourceView.test.tsx
@@ -40,6 +40,7 @@ vi.mock('../FileTree', async () => {
return {
default: (props: {
embedded?: boolean;
+ canEdit?: boolean;
initialPath?: string;
actionsTarget?: HTMLElement | null;
onCrumbsChange?: (crumbs: { label: string }[]) => void;
@@ -51,6 +52,7 @@ vi.mock('../FileTree', async () => {
return (
{props.embedded ? 'embedded' : 'page'}
@@ -72,6 +74,7 @@ vi.mock('../Chunks', async () => {
return {
default: (props: {
embedded?: boolean;
+ canEdit?: boolean;
documentId: string;
onOpenChunkChange?: (position: number | 'unplaced' | null) => void;
controllerRef?: { current: { closeChunk: () => boolean } | null };
@@ -90,7 +93,11 @@ vi.mock('../Chunks', async () => {
};
}
return (
-
+
{props.embedded ? 'embedded' : 'page'}
setOpen(2)}>
OPEN CHUNK
@@ -104,7 +111,9 @@ vi.mock('../Chunks', async () => {
};
});
vi.mock('../ConnectorTree', () => ({
- default: () => ,
+ default: (props: { canEdit?: boolean }) => (
+
+ ),
}));
vi.mock('../../api/services/userService', () => ({
@@ -207,6 +216,7 @@ describe('GraphSourceView', () => {
sourceType?: string,
onBack = vi.fn(),
isNested?: boolean,
+ canEdit?: boolean,
) => {
await act(async () => {
root.render(
@@ -215,6 +225,7 @@ describe('GraphSourceView', () => {
sourceName="Key Accounts"
sourceType={sourceType}
isNested={isNested}
+ canEdit={canEdit}
onBackToDocuments={onBack}
headerAction={Test retrieval}
/>,
@@ -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(
+
+
+ ,
+ );
+ expect(html).toContain('bg-secondary hover:bg-secondary');
+ expect(html).not.toContain('hover:bg-accent');
+ expect(html).toContain('aria-current="true"');
+ });
});
diff --git a/frontend/src/components/ui/list-row.tsx b/frontend/src/components/ui/list-row.tsx
index 5487082e..cf6862b1 100644
--- a/frontend/src/components/ui/list-row.tsx
+++ b/frontend/src/components/ui/list-row.tsx
@@ -24,6 +24,12 @@ type ListRowProps = Omit, 'title'> & {
trailing?: React.ReactNode;
/** The whole row is a target: hover fill and an inset focus ring. */
interactive?: boolean;
+ /**
+ * The row whose detail is open beside the list (the team page's shared
+ * resources drawer): the `bg-secondary` brand tint, kept on hover, and
+ * `aria-current`, like a selected TableRow.
+ */
+ selected?: boolean;
/**
* `sm` is the dense row of a narrow side panel (the graph node panel's
* relationships): 6px / 8px padding, rounded, top-aligned so a small
@@ -45,6 +51,7 @@ function ListRow({
description,
trailing,
interactive = false,
+ selected = false,
size = 'default',
asChild = false,
className,
@@ -59,7 +66,9 @@ function ListRow({
// Inset, because a row list usually sits in an overflow-hidden rounded
// box that would clip an outer ring.
interactive &&
- 'hover:bg-accent focus-visible:ring-ring/50 w-full text-left transition-colors outline-none focus-visible:ring-3 focus-visible:ring-inset',
+ 'focus-visible:ring-ring/50 w-full text-left transition-colors outline-none focus-visible:ring-3 focus-visible:ring-inset',
+ interactive && !selected && 'hover:bg-accent',
+ selected && 'bg-secondary hover:bg-secondary',
!asChild && className,
);
const content = (
@@ -80,7 +89,10 @@ function ListRow({
if (asChild) {
return (
-
+
{React.isValidElement(children)
? React.cloneElement(
children as React.ReactElement<{ children?: React.ReactNode }>,
@@ -94,7 +106,12 @@ function ListRow({
}
return (
-
+
{content}
);
diff --git a/frontend/src/conversation/Conversation.tsx b/frontend/src/conversation/Conversation.tsx
index 0679c160..a0d1e605 100644
--- a/frontend/src/conversation/Conversation.tsx
+++ b/frontend/src/conversation/Conversation.tsx
@@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useParams } from 'react-router-dom';
import userService from '../api/services/userService';
+import { canOpenAgentEditor } from '../agents/agentAccess';
import SharedAgentCard from '../agents/SharedAgentCard';
import { Agent } from '../agents/types';
import ArtifactSidebar from '../components/ArtifactSidebar';
@@ -12,6 +13,7 @@ import MessageInput from '../components/MessageInput';
import { agentChatPath, agentEditPathFor } from '../agents/paths';
import { useMediaQuery } from '../hooks';
import {
+ selectAgents,
selectConversationId,
selectSelectedAgent,
selectToken,
@@ -61,6 +63,7 @@ export default function Conversation() {
const status = useSelector(selectStatus);
const conversationId = useSelector(selectConversationId);
const selectedAgent = useSelector(selectSelectedAgent);
+ const agents = useSelector(selectAgents);
const completedAttachments = useSelector(selectCompletedAttachments);
const attachments = useSelector(selectAttachments);
// A direct send (hero card) that must wait for pending attachments is
@@ -401,7 +404,8 @@ export default function Conversation() {
navigate(agentEditPathFor(selectedAgent))
: undefined
}
diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json
index d1c06fc5..8a6b3c81 100644
--- a/frontend/src/locale/de.json
+++ b/frontend/src/locale/de.json
@@ -92,7 +92,10 @@
"edit": "Prompt bearbeiten",
"view": "Prompt anzeigen",
"duplicate": "Prompt duplizieren",
- "delete": "Prompt löschen"
+ "delete": "Prompt löschen",
+ "deleteFailed": "Dieser Prompt konnte nicht gelöscht werden.",
+ "saveFailed": "Dieser Prompt konnte nicht gespeichert werden.",
+ "editConflict": "Jemand anderes hat diesen Prompt geändert. Öffne ihn erneut, um dessen Version zu sehen."
}
},
"sources": {
@@ -437,7 +440,15 @@
"editChunk": "Chunk bearbeiten",
"editChunkDescription": "{{file}} · Chunk {{n}} · {{tokens}} Tokens",
"previousChunk": "Vorheriger Chunk",
- "nextChunk": "Nächster Chunk"
+ "nextChunk": "Nächster Chunk",
+ "viewConfig": "Quelleneinstellungen ansehen",
+ "errors": {
+ "forbidden": "Dazu hast du für diese Quelle keine Berechtigung.",
+ "delete": "Die Quelle konnte nicht gelöscht werden.",
+ "sync": "Die Quelle konnte nicht synchronisiert werden.",
+ "syncFrequency": "Die Synchronisierungshäufigkeit konnte nicht geändert werden.",
+ "reingest": "Die Neuaufnahme konnte nicht gestartet werden."
+ }
},
"analytics": {
"label": "Analytik",
@@ -729,7 +740,184 @@
"teamLabel": "Team",
"viaTeam": "über {{team}}",
"removeAccess": "Zugriff entfernen",
- "access": "Zugriff"
+ "access": "Zugriff",
+ "showAll": "Alle {{count}} anzeigen",
+ "andMore": "und {{count}} weitere",
+ "back": "Zurück",
+ "allSummary": "{{name}} · Teams: {{teams}} · Personen: {{people}}",
+ "searchAccess": "Personen und Teams suchen…",
+ "filterLabel": "Personen mit Zugriff filtern",
+ "filter": {
+ "all": "Alle",
+ "teams": "Teams",
+ "people": "Personen",
+ "editors": "Bearbeiter"
+ }
+ },
+ "accessChangeError": "Zugriff konnte nicht geändert werden.",
+ "accessSettings": {
+ "title": "Zugriffseinstellungen",
+ "saveError": "Die Zugriffseinstellung konnte nicht gespeichert werden.",
+ "agent": {
+ "editors_can_share": {
+ "label": "Bearbeiter dürfen teilen",
+ "description": "Personen und Teams hinzufügen und ihren Zugriff ändern."
+ },
+ "editors_can_delete": {
+ "label": "Bearbeiter dürfen löschen",
+ "description": "Den Agenten für alle löschen."
+ },
+ "editors_can_manage_access_details": {
+ "label": "Bearbeiter dürfen Zugangsdaten verwalten",
+ "description": "API-Schlüssel, Webhook und öffentlicher Link."
+ },
+ "viewers_can_see_logs": {
+ "label": "Betrachter sehen Protokolle",
+ "description": "Unterhaltungen und Analysen dieses Agenten."
+ }
+ },
+ "source": {
+ "editors_can_share": {
+ "label": "Bearbeiter dürfen teilen",
+ "description": "Personen und Teams hinzufügen und ihren Zugriff ändern."
+ },
+ "editors_can_delete": {
+ "label": "Bearbeiter dürfen löschen",
+ "description": "Die Quelle für alle löschen."
+ },
+ "viewers_can_see_config": {
+ "label": "Betrachter sehen Einstellungen",
+ "description": "Chunking-, Retriever- und Sync-Einstellungen, nur lesend."
+ }
+ },
+ "tool": {
+ "editors_can_change_credentials": {
+ "label": "Bearbeiter dürfen Anmeldedaten und Verbindung ändern",
+ "description": "Sie ersetzen gespeicherte Geheimnisse; niemand kann sie auslesen."
+ },
+ "editors_can_share": {
+ "label": "Bearbeiter dürfen teilen",
+ "description": "Personen und Teams hinzufügen und ihren Zugriff ändern."
+ },
+ "viewers_can_use_in_agents": {
+ "label": "Betrachter dürfen es in eigenen Agenten nutzen",
+ "description": "Es läuft mit deinen Anmeldedaten."
+ }
+ },
+ "prompt": {
+ "editors_can_share": {
+ "label": "Bearbeiter dürfen teilen",
+ "description": "Personen und Teams hinzufügen und ihren Zugriff ändern."
+ },
+ "viewers_can_duplicate": {
+ "label": "Betrachter dürfen duplizieren",
+ "description": "Eine eigene Kopie zum Bearbeiten anlegen."
+ }
+ }
+ },
+ "editorHint": {
+ "agent": "Bearbeiter können ihn ändern, einschließlich Protokollen, Zeitplänen und Zugangsdaten.",
+ "agentNoAccessDetails": "Bearbeiter können ihn ändern, einschließlich Protokollen und Zeitplänen, aber nicht die Zugangsdaten.",
+ "source": "Bearbeiter können Chunks und Dateien bearbeiten, synchronisieren und Einstellungen ändern.",
+ "tool": "Bearbeiter können Aktionen ändern und Anmeldedaten ersetzen, aber keine Geheimnisse lesen.",
+ "toolNoCredentials": "Bearbeiter können Aktionen ändern, aber keine Anmeldedaten.",
+ "prompt": "Bearbeiter können den Text ändern.",
+ "noShareNoDelete": "Sie können es weder teilen noch löschen.",
+ "shareOnly": "Sie können es auch teilen, aber nicht löschen.",
+ "deleteOnly": "Sie können es auch löschen, aber nicht teilen.",
+ "shareAndDelete": "Sie können es auch teilen und löschen.",
+ "noShare": "Sie können es weder teilen noch löschen.",
+ "share": "Sie können es auch teilen, aber nicht löschen."
+ },
+ "capabilities": {
+ "agent": {
+ "viewers": "Betrachter: damit chatten und ihn anheften",
+ "editors": "Bearbeiter: bearbeiten, veröffentlichen, Protokolle sehen und Zeitpläne verwalten"
+ },
+ "source": {
+ "viewers": "Betrachter: durchsuchen und in eigenen Agenten nutzen",
+ "editors": "Bearbeiter: Chunks und Dateien bearbeiten, synchronisieren, Einstellungen ändern"
+ },
+ "tool": {
+ "viewers": "Betrachter: in den Agenten des Eigentümers nutzen",
+ "editors": "Bearbeiter: Aktionen und Freigaben ändern"
+ },
+ "prompt": {
+ "viewers": "Betrachter: lesen und in eigenen Agenten nutzen",
+ "editors": "Bearbeiter: den Text ändern"
+ },
+ "switch": {
+ "editors_can_share": {
+ "on": "Bearbeiter können es teilen",
+ "off": "Bearbeiter können es nicht teilen"
+ },
+ "editors_can_delete": {
+ "on": "Bearbeiter können es löschen",
+ "off": "Bearbeiter können es nicht löschen"
+ },
+ "editors_can_manage_access_details": {
+ "on": "Bearbeiter verwalten API-Schlüssel, Webhook und öffentlichen Link",
+ "off": "Bearbeiter verwalten weder API-Schlüssel noch Webhook oder öffentlichen Link"
+ },
+ "viewers_can_see_logs": {
+ "on": "Betrachter sehen Protokolle",
+ "off": "Betrachter sehen keine Protokolle"
+ },
+ "viewers_can_see_config": {
+ "on": "Betrachter sehen die Einstellungen",
+ "off": "Betrachter sehen die Einstellungen nicht"
+ },
+ "editors_can_change_credentials": {
+ "on": "Bearbeiter können Anmeldedaten ersetzen",
+ "off": "Bearbeiter können Anmeldedaten nicht ändern"
+ },
+ "viewers_can_use_in_agents": {
+ "on": "Betrachter können es in eigenen Agenten nutzen",
+ "off": "Betrachter können es nicht in eigenen Agenten nutzen"
+ },
+ "viewers_can_duplicate": {
+ "on": "Betrachter können es duplizieren",
+ "off": "Betrachter können es nicht duplizieren"
+ }
+ }
+ },
+ "sharedList": {
+ "badgeWithEditors": "{{level}} · +{{count}} Bearbeiter",
+ "meta": "{{type}} · {{owner}}",
+ "filterLabel": "Geteilte Ressourcen filtern",
+ "filter": {
+ "all": "Alle",
+ "agent": "Agenten",
+ "source": "Quellen",
+ "tool": "Werkzeuge",
+ "prompt": "Prompts"
+ },
+ "search": "Geteilte suchen…",
+ "noMatches": "Keine Treffer."
+ },
+ "drawer": {
+ "close": "Schließen",
+ "subtitle": "{{type}} · Eigentümer: {{owner}}",
+ "open": "{{type}} öffnen",
+ "manageSharing": "Freigabe verwalten",
+ "owner": "Eigentümer",
+ "shared": "Geteilt",
+ "sharedOnBy": "{{date}} von {{name}}",
+ "yourAccess": "Dein Zugriff",
+ "yourAccessLevel": {
+ "owner": "Eigentümer",
+ "editor": "Bearbeiter",
+ "viewer": "Betrachter",
+ "none": "Kein Zugriff"
+ },
+ "accessIn": "Zugriff in {{team}}",
+ "everyone": "Alle in {{team}}",
+ "teamGrant": "Ganzes Team",
+ "memberGrant": "Nur diese Person",
+ "removeGrant": "Zugriff entfernen",
+ "otherTeamsHint": "Auch mit anderen Teams geteilt? Diese Freigaben verwaltest du unter „Freigabe verwalten“.",
+ "whatPeopleCanDo": "Was Personen hier dürfen",
+ "capabilitiesHint": "Aus den Zugriffseinstellungen des Eigentümers. Hier nur lesend."
}
},
"tools": {
@@ -803,7 +991,6 @@
"addServer": "MCP-Server hinzufügen",
"editServer": "Server bearbeiten",
"reconnectServer": "Server erneut verbinden",
- "reenterCredentials": "Gib deine Zugangsdaten erneut ein, um die Verbindung zu testen und zu aktualisieren.",
"serverName": "Servername",
"serverUrl": "Server-URL",
"headerName": "Header-Name",
@@ -848,7 +1035,18 @@
"oauthFailed": "OAuth-Prozess fehlgeschlagen oder abgebrochen",
"oauthTimeout": "OAuth-Prozess abgelaufen, bitte erneut versuchen",
"timeoutRange": "Timeout muss zwischen 1 und 300 Sekunden liegen"
- }
+ },
+ "sharedByEditor": "Geteilt von {{owner}} · du bist Bearbeiter",
+ "aTeammate": "einem Teammitglied",
+ "sharedCredentialsNotice": "Gespeicherte Zugangsdaten bleiben verborgen. Was du eingibst, ersetzt sie für alle, die dieses Tool nutzen.",
+ "serverChangedNotice": "Der Server hat sich geändert, daher werden die gespeicherten Zugangsdaten ({{credential}}) gelöscht. Gib sie für den neuen Server ein, um zu speichern.",
+ "credentialNames": {
+ "apiKey": "API-Schlüssel",
+ "bearer": "Token",
+ "password": "Passwort"
+ },
+ "savedKeyHint": "Ein Schlüssel ist gespeichert. Leer lassen, um ihn zu behalten (nur solange der Server unverändert ist).",
+ "sharedOAuthOwnerOnly": "Nur der Eigentümer kann die Anmeldung neu verbinden. Du kannst das Tool umbenennen, aber weder Server noch Konto ändern."
},
"configErrors": {
"required": "{{field}} ist erforderlich",
@@ -856,7 +1054,14 @@
"maxTimeout": "Das maximale Timeout beträgt 300 Sekunden"
},
"headerValuePlaceholder": "z. B. application/json",
- "toolIconTitle": "{{name}}-Symbol"
+ "toolIconTitle": "{{name}}-Symbol",
+ "view": "Ansehen",
+ "inMyChats": "In meinen Chats",
+ "useInMyChatsAria": "{{toolName}} in meinen Chats verwenden",
+ "statusUpdateFailed": "Konnte nicht ändern, ob dieses Tool in deinen Chats ist.",
+ "deleteFailed": "Dieses Tool konnte nicht gelöscht werden.",
+ "sharedBy": "Geteilt von {{team}}",
+ "savedSecretPlaceholder": "Gespeichert · neuen Wert eingeben zum Ersetzen"
},
"devices": {
"label": "Geräte",
@@ -1353,7 +1558,9 @@
"test": "Testen",
"learnMore": "Mehr erfahren",
"resetKey": "Schlüssel zurücksetzen",
- "resetKeyConfirm": "Möchten Sie den API-Schlüssel wirklich zurücksetzen? Der aktuelle Schlüssel funktioniert sofort nicht mehr und diese Aktion kann nicht rückgängig gemacht werden."
+ "resetKeyConfirm": "Möchten Sie den API-Schlüssel wirklich zurücksetzen? Der aktuelle Schlüssel funktioniert sofort nicht mehr und diese Aktion kann nicht rückgängig gemacht werden.",
+ "actionFailed": "Das hat nicht funktioniert. Bitte versuche es erneut.",
+ "apiKeyAfterPublish": "Veröffentliche den Agenten, um seinen API-Schlüssel zu erstellen."
},
"importSpec": {
"title": "API-Spezifikation importieren",
@@ -1774,7 +1981,6 @@
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
"remove": "Remove",
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
- "ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
@@ -1792,7 +1998,8 @@
"modes": {
"monitorOnly": "Monitor only",
"scanAll": "Enforce everywhere"
- }
+ },
+ "readOnly": "Du kannst diese Richtlinie sehen, aber deine Rolle kann sie nicht ändern."
},
"byline": {
"new": "Richten Sie den Agenten ein und veröffentlichen Sie ihn, um mit ihm zu chatten."
@@ -2234,7 +2441,8 @@
"classicDescription": "Erstelle einen Standard-KI-Agenten mit einem Modell, Tools und Wissensquellen",
"workflowTitle": "Workflow-Agent",
"workflowDescription": "Entwirf komplexe mehrstufige Workflows mit verschiedenen Modellen, bedingter Logik und Zustandsverwaltung"
- }
+ },
+ "deleteFailed": "Der Agent konnte nicht gelöscht werden. Bitte versuche es erneut."
},
"components": {
"fileUpload": {
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index 85db5a4c..520b6108 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -96,7 +96,10 @@
"edit": "Edit prompt",
"view": "View prompt",
"duplicate": "Duplicate prompt",
- "delete": "Delete prompt"
+ "delete": "Delete prompt",
+ "deleteFailed": "Couldn't delete this prompt.",
+ "saveFailed": "Couldn't save this prompt.",
+ "editConflict": "Someone else changed this prompt. Reopen it to see their version."
}
},
"sources": {
@@ -442,7 +445,15 @@
"editChunk": "Edit chunk",
"editChunkDescription": "{{file}} · chunk {{n}} · {{tokens}} tokens",
"previousChunk": "Previous chunk",
- "nextChunk": "Next chunk"
+ "nextChunk": "Next chunk",
+ "viewConfig": "View source settings",
+ "errors": {
+ "forbidden": "You don't have permission to do that on this source.",
+ "delete": "Couldn't delete the source.",
+ "sync": "Couldn't sync the source.",
+ "syncFrequency": "Couldn't change the sync frequency.",
+ "reingest": "Couldn't start the reingest."
+ }
},
"analytics": {
"label": "Analytics",
@@ -735,7 +746,184 @@
"teamLabel": "Team",
"viaTeam": "via {{team}}",
"removeAccess": "Remove access",
- "access": "Access"
+ "access": "Access",
+ "showAll": "Show all {{count}}",
+ "andMore": "and {{count}} more",
+ "back": "Back",
+ "allSummary": "{{name}} · Teams: {{teams}} · People: {{people}}",
+ "searchAccess": "Search people and teams…",
+ "filterLabel": "Filter people with access",
+ "filter": {
+ "all": "All",
+ "teams": "Teams",
+ "people": "People",
+ "editors": "Editors"
+ }
+ },
+ "accessChangeError": "Could not change access.",
+ "accessSettings": {
+ "title": "Access settings",
+ "saveError": "Could not save the access setting.",
+ "agent": {
+ "editors_can_share": {
+ "label": "Editors can share",
+ "description": "Add people and teams and change their access."
+ },
+ "editors_can_delete": {
+ "label": "Editors can delete",
+ "description": "Delete the agent for everyone."
+ },
+ "editors_can_manage_access_details": {
+ "label": "Editors can manage access details",
+ "description": "API key, webhook and public link."
+ },
+ "viewers_can_see_logs": {
+ "label": "Viewers can see logs",
+ "description": "Conversations and analytics for this agent."
+ }
+ },
+ "source": {
+ "editors_can_share": {
+ "label": "Editors can share",
+ "description": "Add people and teams and change their access."
+ },
+ "editors_can_delete": {
+ "label": "Editors can delete",
+ "description": "Delete the source for everyone."
+ },
+ "viewers_can_see_config": {
+ "label": "Viewers can see settings",
+ "description": "Chunking, retriever and sync settings, read only."
+ }
+ },
+ "tool": {
+ "editors_can_change_credentials": {
+ "label": "Editors can change credentials and connection",
+ "description": "They replace saved secrets; nobody can read them back."
+ },
+ "editors_can_share": {
+ "label": "Editors can share",
+ "description": "Add people and teams and change their access."
+ },
+ "viewers_can_use_in_agents": {
+ "label": "Viewers can use it in their own agents",
+ "description": "It runs with your credentials."
+ }
+ },
+ "prompt": {
+ "editors_can_share": {
+ "label": "Editors can share",
+ "description": "Add people and teams and change their access."
+ },
+ "viewers_can_duplicate": {
+ "label": "Viewers can duplicate",
+ "description": "Make their own copy to edit."
+ }
+ }
+ },
+ "editorHint": {
+ "agent": "Editors can change it, including logs, schedules and access details.",
+ "agentNoAccessDetails": "Editors can change it, including logs and schedules, but not access details.",
+ "source": "Editors can edit chunks and files, sync and change settings.",
+ "tool": "Editors can change actions and replace credentials, but can’t read secrets.",
+ "toolNoCredentials": "Editors can change actions, but not credentials.",
+ "prompt": "Editors can change the text.",
+ "noShareNoDelete": "They can’t share or delete it.",
+ "shareOnly": "They can also share it, but can’t delete it.",
+ "deleteOnly": "They can also delete it, but can’t share it.",
+ "shareAndDelete": "They can also share and delete it.",
+ "noShare": "They can’t share or delete it.",
+ "share": "They can also share it, but can’t delete it."
+ },
+ "capabilities": {
+ "agent": {
+ "viewers": "Viewers: chat with it and pin it",
+ "editors": "Editors: edit it, publish it, see logs and manage schedules"
+ },
+ "source": {
+ "viewers": "Viewers: browse, search and use it in their agents",
+ "editors": "Editors: edit chunks and files, sync, change settings"
+ },
+ "tool": {
+ "viewers": "Viewers: use it inside the owner’s agents",
+ "editors": "Editors: change actions and approvals"
+ },
+ "prompt": {
+ "viewers": "Viewers: read it and use it in their agents",
+ "editors": "Editors: change the text"
+ },
+ "switch": {
+ "editors_can_share": {
+ "on": "Editors can share it",
+ "off": "Editors can’t share it"
+ },
+ "editors_can_delete": {
+ "on": "Editors can delete it",
+ "off": "Editors can’t delete it"
+ },
+ "editors_can_manage_access_details": {
+ "on": "Editors can manage the API key, webhook and public link",
+ "off": "Editors can’t manage the API key, webhook or public link"
+ },
+ "viewers_can_see_logs": {
+ "on": "Viewers can see logs",
+ "off": "Viewers can’t see logs"
+ },
+ "viewers_can_see_config": {
+ "on": "Viewers can see its settings",
+ "off": "Viewers can’t see its settings"
+ },
+ "editors_can_change_credentials": {
+ "on": "Editors can replace credentials",
+ "off": "Editors can’t change credentials"
+ },
+ "viewers_can_use_in_agents": {
+ "on": "Viewers can use it in their own agents",
+ "off": "Viewers can’t use it in their own agents"
+ },
+ "viewers_can_duplicate": {
+ "on": "Viewers can duplicate it",
+ "off": "Viewers can’t duplicate it"
+ }
+ }
+ },
+ "sharedList": {
+ "badgeWithEditors": "{{level}} · +{{count}} Editor",
+ "meta": "{{type}} · {{owner}}",
+ "filterLabel": "Filter shared resources",
+ "filter": {
+ "all": "All",
+ "agent": "Agents",
+ "source": "Sources",
+ "tool": "Tools",
+ "prompt": "Prompts"
+ },
+ "search": "Search shared…",
+ "noMatches": "Nothing matches."
+ },
+ "drawer": {
+ "close": "Close",
+ "subtitle": "{{type}} · owned by {{owner}}",
+ "open": "Open {{type}}",
+ "manageSharing": "Manage sharing",
+ "owner": "Owner",
+ "shared": "Shared",
+ "sharedOnBy": "{{date}} by {{name}}",
+ "yourAccess": "Your access",
+ "yourAccessLevel": {
+ "owner": "Owner",
+ "editor": "Editor",
+ "viewer": "Viewer",
+ "none": "No access"
+ },
+ "accessIn": "Access in {{team}}",
+ "everyone": "Everyone in {{team}}",
+ "teamGrant": "Whole team",
+ "memberGrant": "Only this person",
+ "removeGrant": "Remove access",
+ "otherTeamsHint": "Shared with other teams too? Those grants are managed in “Manage sharing”.",
+ "whatPeopleCanDo": "What people here can do",
+ "capabilitiesHint": "From the owner’s access settings. Read-only here."
}
},
"tools": {
@@ -809,7 +997,6 @@
"addServer": "Add MCP Server",
"editServer": "Edit Server",
"reconnectServer": "Reconnect Server",
- "reenterCredentials": "Re-enter your credentials to test and update the connection.",
"serverName": "Server Name",
"serverUrl": "Server URL",
"headerName": "Header Name",
@@ -854,7 +1041,18 @@
"oauthFailed": "OAuth process failed or was cancelled",
"oauthTimeout": "OAuth process timed out, please try again",
"timeoutRange": "Timeout must be between 1 and 300 seconds"
- }
+ },
+ "sharedByEditor": "Shared by {{owner}} · you're an editor",
+ "aTeammate": "a teammate",
+ "sharedCredentialsNotice": "Saved credentials stay hidden. Anything you enter replaces them for everyone who uses this tool.",
+ "serverChangedNotice": "The server changed, so the saved {{credential}} will be cleared. Enter it for the new server to save.",
+ "credentialNames": {
+ "apiKey": "API key",
+ "bearer": "token",
+ "password": "password"
+ },
+ "savedKeyHint": "A key is saved. Leave empty to keep it (only while the server is unchanged).",
+ "sharedOAuthOwnerOnly": "Only the owner can reconnect its sign-in, so you can rename it but not change its server or account."
},
"configErrors": {
"required": "{{field}} is required",
@@ -862,7 +1060,14 @@
"maxTimeout": "Maximum timeout is 300 seconds"
},
"headerValuePlaceholder": "e.g., application/json",
- "toolIconTitle": "{{name}} icon"
+ "toolIconTitle": "{{name}} icon",
+ "view": "View",
+ "inMyChats": "In my chats",
+ "useInMyChatsAria": "Use {{toolName}} in my chats",
+ "statusUpdateFailed": "Couldn't change whether this tool is in your chats.",
+ "deleteFailed": "Couldn't delete this tool.",
+ "sharedBy": "Shared by {{team}}",
+ "savedSecretPlaceholder": "Saved · enter a new value to replace"
},
"devices": {
"label": "Devices",
@@ -1359,7 +1564,9 @@
"test": "Test",
"learnMore": "Learn more",
"resetKey": "Reset key",
- "resetKeyConfirm": "Are you sure you want to reset the API key? The current key will stop working immediately and this action cannot be undone."
+ "resetKeyConfirm": "Are you sure you want to reset the API key? The current key will stop working immediately and this action cannot be undone.",
+ "actionFailed": "That didn't work. Please try again.",
+ "apiKeyAfterPublish": "Publish the agent to create its API key."
},
"importSpec": {
"title": "Import API Specification",
@@ -1792,7 +1999,6 @@
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
"remove": "Remove",
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
- "ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
@@ -1810,7 +2016,8 @@
"modes": {
"monitorOnly": "Monitor only",
"scanAll": "Enforce everywhere"
- }
+ },
+ "readOnly": "You can see this policy, but your role can't change it."
},
"byline": {
"new": "Set up the agent, then publish it to chat with it."
@@ -2266,7 +2473,8 @@
"classicDescription": "Create a standard AI agent with a single model, tools, and knowledge sources",
"workflowTitle": "Workflow Agent",
"workflowDescription": "Design complex multi-step workflows with different models, conditional logic, and state management"
- }
+ },
+ "deleteFailed": "Could not delete the agent. Please try again."
},
"components": {
"fileUpload": {
diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json
index fb2eeb4f..43b88552 100644
--- a/frontend/src/locale/es.json
+++ b/frontend/src/locale/es.json
@@ -92,7 +92,10 @@
"edit": "Editar prompt",
"view": "Ver prompt",
"duplicate": "Duplicar prompt",
- "delete": "Eliminar prompt"
+ "delete": "Eliminar prompt",
+ "deleteFailed": "No se pudo eliminar este prompt.",
+ "saveFailed": "No se pudo guardar este prompt.",
+ "editConflict": "Otra persona cambió este prompt. Vuelve a abrirlo para ver su versión."
}
},
"sources": {
@@ -437,7 +440,15 @@
"editChunk": "Editar fragmento",
"editChunkDescription": "{{file}} · fragmento {{n}} · {{tokens}} tokens",
"previousChunk": "Fragmento anterior",
- "nextChunk": "Fragmento siguiente"
+ "nextChunk": "Fragmento siguiente",
+ "viewConfig": "Ver ajustes de la fuente",
+ "errors": {
+ "forbidden": "No tienes permiso para hacer eso en esta fuente.",
+ "delete": "No se pudo eliminar la fuente.",
+ "sync": "No se pudo sincronizar la fuente.",
+ "syncFrequency": "No se pudo cambiar la frecuencia de sincronización.",
+ "reingest": "No se pudo iniciar la reingesta."
+ }
},
"analytics": {
"label": "Analítica",
@@ -729,7 +740,184 @@
"teamLabel": "Equipo",
"viaTeam": "vía {{team}}",
"removeAccess": "Quitar acceso",
- "access": "Acceso"
+ "access": "Acceso",
+ "showAll": "Ver los {{count}}",
+ "andMore": "y {{count}} más",
+ "back": "Atrás",
+ "allSummary": "{{name}} · Equipos: {{teams}} · Personas: {{people}}",
+ "searchAccess": "Buscar personas y equipos…",
+ "filterLabel": "Filtrar personas con acceso",
+ "filter": {
+ "all": "Todos",
+ "teams": "Equipos",
+ "people": "Personas",
+ "editors": "Editores"
+ }
+ },
+ "accessChangeError": "No se pudo cambiar el acceso.",
+ "accessSettings": {
+ "title": "Ajustes de acceso",
+ "saveError": "No se pudo guardar el ajuste de acceso.",
+ "agent": {
+ "editors_can_share": {
+ "label": "Los editores pueden compartir",
+ "description": "Añadir personas y equipos y cambiar su acceso."
+ },
+ "editors_can_delete": {
+ "label": "Los editores pueden eliminar",
+ "description": "Eliminar el agente para todos."
+ },
+ "editors_can_manage_access_details": {
+ "label": "Los editores pueden gestionar los datos de acceso",
+ "description": "Clave de API, webhook y enlace público."
+ },
+ "viewers_can_see_logs": {
+ "label": "Los lectores pueden ver los registros",
+ "description": "Conversaciones y analíticas de este agente."
+ }
+ },
+ "source": {
+ "editors_can_share": {
+ "label": "Los editores pueden compartir",
+ "description": "Añadir personas y equipos y cambiar su acceso."
+ },
+ "editors_can_delete": {
+ "label": "Los editores pueden eliminar",
+ "description": "Eliminar la fuente para todos."
+ },
+ "viewers_can_see_config": {
+ "label": "Los lectores pueden ver los ajustes",
+ "description": "Ajustes de fragmentación, recuperador y sincronización, solo lectura."
+ }
+ },
+ "tool": {
+ "editors_can_change_credentials": {
+ "label": "Los editores pueden cambiar credenciales y conexión",
+ "description": "Sustituyen los secretos guardados; nadie puede volver a leerlos."
+ },
+ "editors_can_share": {
+ "label": "Los editores pueden compartir",
+ "description": "Añadir personas y equipos y cambiar su acceso."
+ },
+ "viewers_can_use_in_agents": {
+ "label": "Los lectores pueden usarla en sus propios agentes",
+ "description": "Se ejecuta con tus credenciales."
+ }
+ },
+ "prompt": {
+ "editors_can_share": {
+ "label": "Los editores pueden compartir",
+ "description": "Añadir personas y equipos y cambiar su acceso."
+ },
+ "viewers_can_duplicate": {
+ "label": "Los lectores pueden duplicarlo",
+ "description": "Hacer su propia copia para editarla."
+ }
+ }
+ },
+ "editorHint": {
+ "agent": "Los editores pueden cambiarlo, incluidos los registros, las programaciones y los datos de acceso.",
+ "agentNoAccessDetails": "Los editores pueden cambiarlo, incluidos los registros y las programaciones, pero no los datos de acceso.",
+ "source": "Los editores pueden editar fragmentos y archivos, sincronizar y cambiar ajustes.",
+ "tool": "Los editores pueden cambiar acciones y sustituir credenciales, pero no pueden leer secretos.",
+ "toolNoCredentials": "Los editores pueden cambiar acciones, pero no las credenciales.",
+ "prompt": "Los editores pueden cambiar el texto.",
+ "noShareNoDelete": "No pueden compartirlo ni eliminarlo.",
+ "shareOnly": "También pueden compartirlo, pero no eliminarlo.",
+ "deleteOnly": "También pueden eliminarlo, pero no compartirlo.",
+ "shareAndDelete": "También pueden compartirlo y eliminarlo.",
+ "noShare": "No pueden compartirlo ni eliminarlo.",
+ "share": "También pueden compartirlo, pero no eliminarlo."
+ },
+ "capabilities": {
+ "agent": {
+ "viewers": "Lectores: chatear con él y fijarlo",
+ "editors": "Editores: editarlo, publicarlo, ver registros y gestionar programaciones"
+ },
+ "source": {
+ "viewers": "Lectores: explorar, buscar y usarla en sus agentes",
+ "editors": "Editores: editar fragmentos y archivos, sincronizar, cambiar ajustes"
+ },
+ "tool": {
+ "viewers": "Lectores: usarla dentro de los agentes del propietario",
+ "editors": "Editores: cambiar acciones y aprobaciones"
+ },
+ "prompt": {
+ "viewers": "Lectores: leerlo y usarlo en sus agentes",
+ "editors": "Editores: cambiar el texto"
+ },
+ "switch": {
+ "editors_can_share": {
+ "on": "Los editores pueden compartirlo",
+ "off": "Los editores no pueden compartirlo"
+ },
+ "editors_can_delete": {
+ "on": "Los editores pueden eliminarlo",
+ "off": "Los editores no pueden eliminarlo"
+ },
+ "editors_can_manage_access_details": {
+ "on": "Los editores pueden gestionar la clave de API, el webhook y el enlace público",
+ "off": "Los editores no pueden gestionar la clave de API, el webhook ni el enlace público"
+ },
+ "viewers_can_see_logs": {
+ "on": "Los lectores pueden ver los registros",
+ "off": "Los lectores no pueden ver los registros"
+ },
+ "viewers_can_see_config": {
+ "on": "Los lectores pueden ver sus ajustes",
+ "off": "Los lectores no pueden ver sus ajustes"
+ },
+ "editors_can_change_credentials": {
+ "on": "Los editores pueden sustituir credenciales",
+ "off": "Los editores no pueden cambiar credenciales"
+ },
+ "viewers_can_use_in_agents": {
+ "on": "Los lectores pueden usarla en sus propios agentes",
+ "off": "Los lectores no pueden usarla en sus propios agentes"
+ },
+ "viewers_can_duplicate": {
+ "on": "Los lectores pueden duplicarlo",
+ "off": "Los lectores no pueden duplicarlo"
+ }
+ }
+ },
+ "sharedList": {
+ "badgeWithEditors": "{{level}} · +{{count}} Editor",
+ "meta": "{{type}} · {{owner}}",
+ "filterLabel": "Filtrar recursos compartidos",
+ "filter": {
+ "all": "Todos",
+ "agent": "Agentes",
+ "source": "Fuentes",
+ "tool": "Herramientas",
+ "prompt": "Prompts"
+ },
+ "search": "Buscar compartidos…",
+ "noMatches": "No hay coincidencias."
+ },
+ "drawer": {
+ "close": "Cerrar",
+ "subtitle": "{{type}} · propiedad de {{owner}}",
+ "open": "Abrir {{type}}",
+ "manageSharing": "Gestionar uso compartido",
+ "owner": "Propietario",
+ "shared": "Compartido",
+ "sharedOnBy": "{{date}} por {{name}}",
+ "yourAccess": "Tu acceso",
+ "yourAccessLevel": {
+ "owner": "Propietario",
+ "editor": "Editor",
+ "viewer": "Lector",
+ "none": "Sin acceso"
+ },
+ "accessIn": "Acceso en {{team}}",
+ "everyone": "Todos en {{team}}",
+ "teamGrant": "Todo el equipo",
+ "memberGrant": "Solo esta persona",
+ "removeGrant": "Quitar acceso",
+ "otherTeamsHint": "¿También compartido con otros equipos? Esos accesos se gestionan en «Gestionar uso compartido».",
+ "whatPeopleCanDo": "Qué pueden hacer aquí",
+ "capabilitiesHint": "Según los ajustes de acceso del propietario. Solo lectura aquí."
}
},
"tools": {
@@ -803,7 +991,6 @@
"addServer": "Add MCP Server",
"editServer": "Edit Server",
"reconnectServer": "Reconectar servidor",
- "reenterCredentials": "Vuelve a introducir tus credenciales para probar y actualizar la conexión.",
"serverName": "Server Name",
"serverUrl": "Server URL",
"headerName": "Header Name",
@@ -848,7 +1035,18 @@
"oauthFailed": "OAuth process failed or was cancelled",
"oauthTimeout": "OAuth process timed out, please try again",
"timeoutRange": "Timeout must be between 1 and 300 seconds"
- }
+ },
+ "sharedByEditor": "Compartido por {{owner}} · eres editor",
+ "aTeammate": "un compañero",
+ "sharedCredentialsNotice": "Las credenciales guardadas permanecen ocultas. Lo que introduzcas las reemplaza para todos los que usan esta herramienta.",
+ "serverChangedNotice": "El servidor cambió, así que se borrarán las credenciales guardadas ({{credential}}). Introdúcelas para el nuevo servidor para guardar.",
+ "credentialNames": {
+ "apiKey": "clave de API",
+ "bearer": "token",
+ "password": "contraseña"
+ },
+ "savedKeyHint": "Hay una clave guardada. Déjalo vacío para conservarla (solo mientras el servidor no cambie).",
+ "sharedOAuthOwnerOnly": "Solo el propietario puede volver a conectar su inicio de sesión, así que puedes cambiarle el nombre, pero no su servidor ni su cuenta."
},
"configErrors": {
"required": "{{field}} es obligatorio",
@@ -856,7 +1054,14 @@
"maxTimeout": "El tiempo de espera máximo es de 300 segundos"
},
"headerValuePlaceholder": "p. ej., application/json",
- "toolIconTitle": "Icono de {{name}}"
+ "toolIconTitle": "Icono de {{name}}",
+ "view": "Ver",
+ "inMyChats": "En mis chats",
+ "useInMyChatsAria": "Usar {{toolName}} en mis chats",
+ "statusUpdateFailed": "No se pudo cambiar si esta herramienta está en tus chats.",
+ "deleteFailed": "No se pudo eliminar esta herramienta.",
+ "sharedBy": "Compartido por {{team}}",
+ "savedSecretPlaceholder": "Guardado · introduce un valor nuevo para reemplazarlo"
},
"devices": {
"label": "Dispositivos",
@@ -1353,7 +1558,9 @@
"test": "Test",
"learnMore": "Learn more",
"resetKey": "Restablecer clave",
- "resetKeyConfirm": "¿Seguro que quieres restablecer la clave de API? La clave actual dejará de funcionar de inmediato y esta acción no se puede deshacer."
+ "resetKeyConfirm": "¿Seguro que quieres restablecer la clave de API? La clave actual dejará de funcionar de inmediato y esta acción no se puede deshacer.",
+ "actionFailed": "No funcionó. Inténtalo de nuevo.",
+ "apiKeyAfterPublish": "Publica el agente para crear su clave de API."
},
"importSpec": {
"title": "Importar especificación de API",
@@ -1774,7 +1981,6 @@
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
"remove": "Remove",
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
- "ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
@@ -1792,7 +1998,8 @@
"modes": {
"monitorOnly": "Monitor only",
"scanAll": "Enforce everywhere"
- }
+ },
+ "readOnly": "Puedes ver esta política, pero tu rol no puede cambiarla."
},
"byline": {
"new": "Configura el agente y publícalo para chatear con él."
@@ -2234,7 +2441,8 @@
"classicDescription": "Crea un agente de IA estándar con un solo modelo, herramientas y fuentes de conocimiento",
"workflowTitle": "Agente de flujo de trabajo",
"workflowDescription": "Diseña flujos de trabajo complejos de varios pasos con distintos modelos, lógica condicional y gestión de estado"
- }
+ },
+ "deleteFailed": "No se pudo eliminar el agente. Inténtalo de nuevo."
},
"components": {
"fileUpload": {
diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json
index 7b4ce48a..03f5f466 100644
--- a/frontend/src/locale/jp.json
+++ b/frontend/src/locale/jp.json
@@ -92,7 +92,10 @@
"edit": "プロンプトを編集",
"view": "プロンプトを表示",
"duplicate": "プロンプトを複製",
- "delete": "プロンプトを削除"
+ "delete": "プロンプトを削除",
+ "deleteFailed": "このプロンプトを削除できませんでした。",
+ "saveFailed": "このプロンプトを保存できませんでした。",
+ "editConflict": "他のユーザーがこのプロンプトを変更しました。開き直して最新の内容を確認してください。"
}
},
"sources": {
@@ -428,7 +431,15 @@
"editChunk": "チャンクを編集",
"editChunkDescription": "{{file}} · チャンク {{n}} · {{tokens}} トークン",
"previousChunk": "前のチャンク",
- "nextChunk": "次のチャンク"
+ "nextChunk": "次のチャンク",
+ "viewConfig": "ソース設定を表示",
+ "errors": {
+ "forbidden": "このソースでその操作を行う権限がありません。",
+ "delete": "ソースを削除できませんでした。",
+ "sync": "ソースを同期できませんでした。",
+ "syncFrequency": "同期頻度を変更できませんでした。",
+ "reingest": "再取り込みを開始できませんでした。"
+ }
},
"analytics": {
"label": "分析",
@@ -720,7 +731,184 @@
"teamLabel": "チーム",
"viaTeam": "{{team}}経由",
"removeAccess": "アクセス権を削除",
- "access": "アクセス"
+ "access": "アクセス",
+ "showAll": "すべて表示({{count}})",
+ "andMore": "ほか {{count}} 件",
+ "back": "戻る",
+ "allSummary": "{{name}} · チーム: {{teams}} · ユーザー: {{people}}",
+ "searchAccess": "ユーザーとチームを検索…",
+ "filterLabel": "アクセス権を持つユーザーを絞り込む",
+ "filter": {
+ "all": "すべて",
+ "teams": "チーム",
+ "people": "ユーザー",
+ "editors": "編集者"
+ }
+ },
+ "accessChangeError": "アクセス権を変更できませんでした。",
+ "accessSettings": {
+ "title": "アクセス設定",
+ "saveError": "アクセス設定を保存できませんでした。",
+ "agent": {
+ "editors_can_share": {
+ "label": "編集者が共有できる",
+ "description": "ユーザーやチームを追加し、アクセス権を変更します。"
+ },
+ "editors_can_delete": {
+ "label": "編集者が削除できる",
+ "description": "全員に対してエージェントを削除します。"
+ },
+ "editors_can_manage_access_details": {
+ "label": "編集者がアクセス情報を管理できる",
+ "description": "API キー、Webhook、公開リンク。"
+ },
+ "viewers_can_see_logs": {
+ "label": "閲覧者がログを見られる",
+ "description": "このエージェントの会話と分析。"
+ }
+ },
+ "source": {
+ "editors_can_share": {
+ "label": "編集者が共有できる",
+ "description": "ユーザーやチームを追加し、アクセス権を変更します。"
+ },
+ "editors_can_delete": {
+ "label": "編集者が削除できる",
+ "description": "全員に対してソースを削除します。"
+ },
+ "viewers_can_see_config": {
+ "label": "閲覧者が設定を見られる",
+ "description": "チャンク分割、リトリーバー、同期の設定(読み取り専用)。"
+ }
+ },
+ "tool": {
+ "editors_can_change_credentials": {
+ "label": "編集者が認証情報と接続を変更できる",
+ "description": "保存済みのシークレットを置き換えます。誰も読み戻せません。"
+ },
+ "editors_can_share": {
+ "label": "編集者が共有できる",
+ "description": "ユーザーやチームを追加し、アクセス権を変更します。"
+ },
+ "viewers_can_use_in_agents": {
+ "label": "閲覧者が自分のエージェントで使える",
+ "description": "あなたの認証情報で実行されます。"
+ }
+ },
+ "prompt": {
+ "editors_can_share": {
+ "label": "編集者が共有できる",
+ "description": "ユーザーやチームを追加し、アクセス権を変更します。"
+ },
+ "viewers_can_duplicate": {
+ "label": "閲覧者が複製できる",
+ "description": "編集用に自分のコピーを作成します。"
+ }
+ }
+ },
+ "editorHint": {
+ "agent": "編集者はログ、スケジュール、アクセス情報を含めて変更できます。",
+ "agentNoAccessDetails": "編集者はログとスケジュールを含めて変更できますが、アクセス情報は変更できません。",
+ "source": "編集者はチャンクとファイルの編集、同期、設定の変更ができます。",
+ "tool": "編集者はアクションの変更と認証情報の置き換えができますが、シークレットは読めません。",
+ "toolNoCredentials": "編集者はアクションを変更できますが、認証情報は変更できません。",
+ "prompt": "編集者はテキストを変更できます。",
+ "noShareNoDelete": "共有と削除はできません。",
+ "shareOnly": "共有もできますが、削除はできません。",
+ "deleteOnly": "削除もできますが、共有はできません。",
+ "shareAndDelete": "共有と削除もできます。",
+ "noShare": "共有と削除はできません。",
+ "share": "共有もできますが、削除はできません。"
+ },
+ "capabilities": {
+ "agent": {
+ "viewers": "閲覧者:チャットとピン留め",
+ "editors": "編集者:編集、公開、ログの閲覧、スケジュールの管理"
+ },
+ "source": {
+ "viewers": "閲覧者:閲覧、検索、自分のエージェントでの利用",
+ "editors": "編集者:チャンクとファイルの編集、同期、設定の変更"
+ },
+ "tool": {
+ "viewers": "閲覧者:所有者のエージェント内で利用",
+ "editors": "編集者:アクションと承認の変更"
+ },
+ "prompt": {
+ "viewers": "閲覧者:閲覧と自分のエージェントでの利用",
+ "editors": "編集者:テキストの変更"
+ },
+ "switch": {
+ "editors_can_share": {
+ "on": "編集者は共有できます",
+ "off": "編集者は共有できません"
+ },
+ "editors_can_delete": {
+ "on": "編集者は削除できます",
+ "off": "編集者は削除できません"
+ },
+ "editors_can_manage_access_details": {
+ "on": "編集者は API キー、Webhook、公開リンクを管理できます",
+ "off": "編集者は API キー、Webhook、公開リンクを管理できません"
+ },
+ "viewers_can_see_logs": {
+ "on": "閲覧者はログを見られます",
+ "off": "閲覧者はログを見られません"
+ },
+ "viewers_can_see_config": {
+ "on": "閲覧者は設定を見られます",
+ "off": "閲覧者は設定を見られません"
+ },
+ "editors_can_change_credentials": {
+ "on": "編集者は認証情報を置き換えられます",
+ "off": "編集者は認証情報を変更できません"
+ },
+ "viewers_can_use_in_agents": {
+ "on": "閲覧者は自分のエージェントで使えます",
+ "off": "閲覧者は自分のエージェントで使えません"
+ },
+ "viewers_can_duplicate": {
+ "on": "閲覧者は複製できます",
+ "off": "閲覧者は複製できません"
+ }
+ }
+ },
+ "sharedList": {
+ "badgeWithEditors": "{{level}} · +{{count}} 編集者",
+ "meta": "{{type}} · {{owner}}",
+ "filterLabel": "共有リソースを絞り込む",
+ "filter": {
+ "all": "すべて",
+ "agent": "エージェント",
+ "source": "ソース",
+ "tool": "ツール",
+ "prompt": "プロンプト"
+ },
+ "search": "共有を検索…",
+ "noMatches": "一致するものはありません。"
+ },
+ "drawer": {
+ "close": "閉じる",
+ "subtitle": "{{type}} · 所有者: {{owner}}",
+ "open": "{{type}}を開く",
+ "manageSharing": "共有を管理",
+ "owner": "所有者",
+ "shared": "共有日",
+ "sharedOnBy": "{{date}}({{name}})",
+ "yourAccess": "あなたのアクセス権",
+ "yourAccessLevel": {
+ "owner": "所有者",
+ "editor": "編集者",
+ "viewer": "閲覧者",
+ "none": "アクセス権なし"
+ },
+ "accessIn": "{{team}} でのアクセス権",
+ "everyone": "{{team}} の全員",
+ "teamGrant": "チーム全体",
+ "memberGrant": "この人のみ",
+ "removeGrant": "アクセス権を削除",
+ "otherTeamsHint": "ほかのチームとも共有していますか?それらは「共有を管理」で管理します。",
+ "whatPeopleCanDo": "ここでできること",
+ "capabilitiesHint": "所有者のアクセス設定に基づきます。ここでは読み取り専用です。"
}
},
"tools": {
@@ -794,7 +982,6 @@
"addServer": "Add MCP Server",
"editServer": "Edit Server",
"reconnectServer": "サーバーに再接続",
- "reenterCredentials": "接続をテストして更新するには、認証情報を再入力してください。",
"serverName": "Server Name",
"serverUrl": "Server URL",
"headerName": "Header Name",
@@ -839,7 +1026,18 @@
"oauthFailed": "OAuth process failed or was cancelled",
"oauthTimeout": "OAuth process timed out, please try again",
"timeoutRange": "Timeout must be between 1 and 300 seconds"
- }
+ },
+ "sharedByEditor": "{{owner}} が共有 · あなたは編集者です",
+ "aTeammate": "チームメンバー",
+ "sharedCredentialsNotice": "保存済みの認証情報は表示されません。入力した内容は、このツールを使うすべての人の認証情報を置き換えます。",
+ "serverChangedNotice": "サーバーが変更されたため、保存済みの{{credential}}は消去されます。保存するには新しいサーバー用に入力してください。",
+ "credentialNames": {
+ "apiKey": "API キー",
+ "bearer": "トークン",
+ "password": "パスワード"
+ },
+ "savedKeyHint": "キーが保存されています。空のままにすると保持されます(サーバーが変わらない場合のみ)。",
+ "sharedOAuthOwnerOnly": "サインインを再接続できるのはオーナーだけです。名前は変更できますが、サーバーやアカウントは変更できません。"
},
"configErrors": {
"required": "{{field}}は必須です",
@@ -847,7 +1045,14 @@
"maxTimeout": "タイムアウトの上限は300秒です"
},
"headerValuePlaceholder": "例: application/json",
- "toolIconTitle": "{{name}}のアイコン"
+ "toolIconTitle": "{{name}}のアイコン",
+ "view": "表示",
+ "inMyChats": "自分のチャットで使用",
+ "useInMyChatsAria": "{{toolName}} を自分のチャットで使用",
+ "statusUpdateFailed": "このツールをチャットで使うかどうかを変更できませんでした。",
+ "deleteFailed": "このツールを削除できませんでした。",
+ "sharedBy": "{{team}} が共有",
+ "savedSecretPlaceholder": "保存済み · 置き換えるには新しい値を入力"
},
"devices": {
"label": "デバイス",
@@ -1344,7 +1549,9 @@
"test": "Test",
"learnMore": "Learn more",
"resetKey": "キーをリセット",
- "resetKeyConfirm": "APIキーをリセットしてもよろしいですか?現在のキーは直ちに無効になり、この操作は元に戻せません。"
+ "resetKeyConfirm": "APIキーをリセットしてもよろしいですか?現在のキーは直ちに無効になり、この操作は元に戻せません。",
+ "actionFailed": "うまくいきませんでした。もう一度お試しください。",
+ "apiKeyAfterPublish": "APIキーを作成するには、エージェントを公開してください。"
},
"importSpec": {
"title": "API仕様のインポート",
@@ -1761,7 +1968,6 @@
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
"remove": "Remove",
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
- "ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
@@ -1779,7 +1985,8 @@
"modes": {
"monitorOnly": "Monitor only",
"scanAll": "Enforce everywhere"
- }
+ },
+ "readOnly": "このポリシーは表示できますが、あなたのロールでは変更できません。"
},
"byline": {
"new": "エージェントを設定し、公開するとチャットできます。"
@@ -2221,7 +2428,8 @@
"classicDescription": "単一のモデル、ツール、ナレッジソースを持つ標準的な AI エージェントを作成します",
"workflowTitle": "ワークフローエージェント",
"workflowDescription": "複数のモデル、条件ロジック、状態管理を使った複雑なマルチステップのワークフローを設計します"
- }
+ },
+ "deleteFailed": "エージェントを削除できませんでした。もう一度お試しください。"
},
"components": {
"fileUpload": {
diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json
index d7e48ac6..71a053d3 100644
--- a/frontend/src/locale/ru.json
+++ b/frontend/src/locale/ru.json
@@ -92,7 +92,10 @@
"edit": "Редактировать промпт",
"view": "Просмотреть промпт",
"duplicate": "Дублировать промпт",
- "delete": "Удалить промпт"
+ "delete": "Удалить промпт",
+ "deleteFailed": "Не удалось удалить этот промпт.",
+ "saveFailed": "Не удалось сохранить этот промпт.",
+ "editConflict": "Кто-то другой изменил этот промпт. Откройте его заново, чтобы увидеть новую версию."
}
},
"sources": {
@@ -465,7 +468,15 @@
"editChunk": "Редактировать фрагмент",
"editChunkDescription": "{{file}} · фрагмент {{n}} · токенов: {{tokens}}",
"previousChunk": "Предыдущий фрагмент",
- "nextChunk": "Следующий фрагмент"
+ "nextChunk": "Следующий фрагмент",
+ "viewConfig": "Посмотреть настройки источника",
+ "errors": {
+ "forbidden": "У вас нет прав на это действие с этим источником.",
+ "delete": "Не удалось удалить источник.",
+ "sync": "Не удалось синхронизировать источник.",
+ "syncFrequency": "Не удалось изменить частоту синхронизации.",
+ "reingest": "Не удалось запустить повторную загрузку."
+ }
},
"analytics": {
"label": "Аналитика",
@@ -771,7 +782,184 @@
"teamLabel": "Команда",
"viaTeam": "через {{team}}",
"removeAccess": "Убрать доступ",
- "access": "Доступ"
+ "access": "Доступ",
+ "showAll": "Показать все ({{count}})",
+ "andMore": "и ещё {{count}}",
+ "back": "Назад",
+ "allSummary": "{{name}} · Команды: {{teams}} · Люди: {{people}}",
+ "searchAccess": "Поиск людей и команд…",
+ "filterLabel": "Фильтр пользователей с доступом",
+ "filter": {
+ "all": "Все",
+ "teams": "Команды",
+ "people": "Люди",
+ "editors": "Редакторы"
+ }
+ },
+ "accessChangeError": "Не удалось изменить доступ.",
+ "accessSettings": {
+ "title": "Настройки доступа",
+ "saveError": "Не удалось сохранить настройку доступа.",
+ "agent": {
+ "editors_can_share": {
+ "label": "Редакторы могут делиться",
+ "description": "Добавлять людей и команды и менять их доступ."
+ },
+ "editors_can_delete": {
+ "label": "Редакторы могут удалять",
+ "description": "Удалить агента для всех."
+ },
+ "editors_can_manage_access_details": {
+ "label": "Редакторы управляют данными доступа",
+ "description": "API-ключ, вебхук и публичная ссылка."
+ },
+ "viewers_can_see_logs": {
+ "label": "Читатели видят журналы",
+ "description": "Диалоги и аналитика этого агента."
+ }
+ },
+ "source": {
+ "editors_can_share": {
+ "label": "Редакторы могут делиться",
+ "description": "Добавлять людей и команды и менять их доступ."
+ },
+ "editors_can_delete": {
+ "label": "Редакторы могут удалять",
+ "description": "Удалить источник для всех."
+ },
+ "viewers_can_see_config": {
+ "label": "Читатели видят настройки",
+ "description": "Настройки разбиения, ретривера и синхронизации, только чтение."
+ }
+ },
+ "tool": {
+ "editors_can_change_credentials": {
+ "label": "Редакторы могут менять учётные данные и подключение",
+ "description": "Они заменяют сохранённые секреты; прочитать их не может никто."
+ },
+ "editors_can_share": {
+ "label": "Редакторы могут делиться",
+ "description": "Добавлять людей и команды и менять их доступ."
+ },
+ "viewers_can_use_in_agents": {
+ "label": "Читатели могут использовать его в своих агентах",
+ "description": "Он работает с вашими учётными данными."
+ }
+ },
+ "prompt": {
+ "editors_can_share": {
+ "label": "Редакторы могут делиться",
+ "description": "Добавлять людей и команды и менять их доступ."
+ },
+ "viewers_can_duplicate": {
+ "label": "Читатели могут создавать копию",
+ "description": "Сделать свою копию для редактирования."
+ }
+ }
+ },
+ "editorHint": {
+ "agent": "Редакторы могут менять его, включая журналы, расписания и данные доступа.",
+ "agentNoAccessDetails": "Редакторы могут менять его, включая журналы и расписания, но не данные доступа.",
+ "source": "Редакторы могут править фрагменты и файлы, синхронизировать и менять настройки.",
+ "tool": "Редакторы могут менять действия и заменять учётные данные, но не читать секреты.",
+ "toolNoCredentials": "Редакторы могут менять действия, но не учётные данные.",
+ "prompt": "Редакторы могут менять текст.",
+ "noShareNoDelete": "Делиться и удалять они не могут.",
+ "shareOnly": "Они также могут делиться, но не удалять.",
+ "deleteOnly": "Они также могут удалять, но не делиться.",
+ "shareAndDelete": "Они также могут делиться и удалять.",
+ "noShare": "Делиться и удалять они не могут.",
+ "share": "Они также могут делиться, но не удалять."
+ },
+ "capabilities": {
+ "agent": {
+ "viewers": "Читатели: общаться с ним и закреплять его",
+ "editors": "Редакторы: править, публиковать, смотреть журналы и управлять расписаниями"
+ },
+ "source": {
+ "viewers": "Читатели: просматривать, искать и использовать в своих агентах",
+ "editors": "Редакторы: править фрагменты и файлы, синхронизировать, менять настройки"
+ },
+ "tool": {
+ "viewers": "Читатели: использовать в агентах владельца",
+ "editors": "Редакторы: менять действия и подтверждения"
+ },
+ "prompt": {
+ "viewers": "Читатели: читать и использовать в своих агентах",
+ "editors": "Редакторы: менять текст"
+ },
+ "switch": {
+ "editors_can_share": {
+ "on": "Редакторы могут делиться",
+ "off": "Редакторы не могут делиться"
+ },
+ "editors_can_delete": {
+ "on": "Редакторы могут удалять",
+ "off": "Редакторы не могут удалять"
+ },
+ "editors_can_manage_access_details": {
+ "on": "Редакторы управляют API-ключом, вебхуком и публичной ссылкой",
+ "off": "Редакторы не управляют API-ключом, вебхуком и публичной ссылкой"
+ },
+ "viewers_can_see_logs": {
+ "on": "Читатели видят журналы",
+ "off": "Читатели не видят журналы"
+ },
+ "viewers_can_see_config": {
+ "on": "Читатели видят настройки",
+ "off": "Читатели не видят настройки"
+ },
+ "editors_can_change_credentials": {
+ "on": "Редакторы могут заменять учётные данные",
+ "off": "Редакторы не могут менять учётные данные"
+ },
+ "viewers_can_use_in_agents": {
+ "on": "Читатели могут использовать его в своих агентах",
+ "off": "Читатели не могут использовать его в своих агентах"
+ },
+ "viewers_can_duplicate": {
+ "on": "Читатели могут создавать копию",
+ "off": "Читатели не могут создавать копию"
+ }
+ }
+ },
+ "sharedList": {
+ "badgeWithEditors": "{{level}} · +{{count}} ред.",
+ "meta": "{{type}} · {{owner}}",
+ "filterLabel": "Фильтр общих ресурсов",
+ "filter": {
+ "all": "Все",
+ "agent": "Агенты",
+ "source": "Источники",
+ "tool": "Инструменты",
+ "prompt": "Промпты"
+ },
+ "search": "Поиск в общих…",
+ "noMatches": "Ничего не найдено."
+ },
+ "drawer": {
+ "close": "Закрыть",
+ "subtitle": "{{type}} · владелец: {{owner}}",
+ "open": "Открыть: {{type}}",
+ "manageSharing": "Управлять доступом",
+ "owner": "Владелец",
+ "shared": "Открыт",
+ "sharedOnBy": "{{date}}, {{name}}",
+ "yourAccess": "Ваш доступ",
+ "yourAccessLevel": {
+ "owner": "Владелец",
+ "editor": "Редактор",
+ "viewer": "Читатель",
+ "none": "Нет доступа"
+ },
+ "accessIn": "Доступ в {{team}}",
+ "everyone": "Все в {{team}}",
+ "teamGrant": "Вся команда",
+ "memberGrant": "Только этот человек",
+ "removeGrant": "Убрать доступ",
+ "otherTeamsHint": "Доступ есть и у других команд? Им управляют в «Управлять доступом».",
+ "whatPeopleCanDo": "Что здесь можно делать",
+ "capabilitiesHint": "Из настроек доступа владельца. Здесь только для чтения."
}
},
"tools": {
@@ -845,7 +1033,6 @@
"addServer": "Add MCP Server",
"editServer": "Edit Server",
"reconnectServer": "Переподключить сервер",
- "reenterCredentials": "Введите учетные данные ещё раз, чтобы проверить и обновить подключение.",
"serverName": "Server Name",
"serverUrl": "Server URL",
"headerName": "Header Name",
@@ -890,7 +1077,18 @@
"oauthFailed": "OAuth process failed or was cancelled",
"oauthTimeout": "OAuth process timed out, please try again",
"timeoutRange": "Timeout must be between 1 and 300 seconds"
- }
+ },
+ "sharedByEditor": "Предоставил(а) {{owner}} · вы редактор",
+ "aTeammate": "участник команды",
+ "sharedCredentialsNotice": "Сохранённые учётные данные скрыты. Введённые вами данные заменят их для всех, кто пользуется этим инструментом.",
+ "serverChangedNotice": "Сервер изменился, поэтому сохранённый {{credential}} будет удалён. Введите его для нового сервера, чтобы сохранить.",
+ "credentialNames": {
+ "apiKey": "API-ключ",
+ "bearer": "токен",
+ "password": "пароль"
+ },
+ "savedKeyHint": "Ключ сохранён. Оставьте поле пустым, чтобы сохранить его (только пока сервер не изменился).",
+ "sharedOAuthOwnerOnly": "Переподключить вход может только владелец: вы можете переименовать инструмент, но не менять его сервер или аккаунт."
},
"configErrors": {
"required": "Поле «{{field}}» обязательно",
@@ -898,7 +1096,14 @@
"maxTimeout": "Максимальный тайм-аут — 300 секунд"
},
"headerValuePlaceholder": "например, application/json",
- "toolIconTitle": "Значок {{name}}"
+ "toolIconTitle": "Значок {{name}}",
+ "view": "Просмотр",
+ "inMyChats": "В моих чатах",
+ "useInMyChatsAria": "Использовать {{toolName}} в моих чатах",
+ "statusUpdateFailed": "Не удалось изменить, используется ли этот инструмент в ваших чатах.",
+ "deleteFailed": "Не удалось удалить этот инструмент.",
+ "sharedBy": "Предоставлено: {{team}}",
+ "savedSecretPlaceholder": "Сохранено · введите новое значение для замены"
},
"devices": {
"label": "Устройства",
@@ -1409,7 +1614,9 @@
"test": "Test",
"learnMore": "Learn more",
"resetKey": "Сбросить ключ",
- "resetKeyConfirm": "Вы уверены, что хотите сбросить API-ключ? Текущий ключ немедленно перестанет работать, и это действие нельзя отменить."
+ "resetKeyConfirm": "Вы уверены, что хотите сбросить API-ключ? Текущий ключ немедленно перестанет работать, и это действие нельзя отменить.",
+ "actionFailed": "Не получилось. Попробуйте ещё раз.",
+ "apiKeyAfterPublish": "Опубликуйте агента, чтобы создать его API-ключ."
},
"importSpec": {
"title": "Импорт спецификации API",
@@ -1838,7 +2045,6 @@
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
"remove": "Remove",
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
- "ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
@@ -1856,7 +2062,8 @@
"modes": {
"monitorOnly": "Monitor only",
"scanAll": "Enforce everywhere"
- }
+ },
+ "readOnly": "Вы видите эту политику, но ваша роль не позволяет её изменить."
},
"byline": {
"new": "Настройте агента и опубликуйте его, чтобы с ним общаться."
@@ -2310,7 +2517,8 @@
"classicDescription": "Создайте стандартного ИИ-агента с одной моделью, инструментами и источниками знаний",
"workflowTitle": "Агент рабочего процесса",
"workflowDescription": "Создавайте сложные многошаговые рабочие процессы с разными моделями, условной логикой и управлением состоянием"
- }
+ },
+ "deleteFailed": "Не удалось удалить агента. Попробуйте ещё раз."
},
"components": {
"fileUpload": {
diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json
index 3d3f5e40..3f10dc96 100644
--- a/frontend/src/locale/zh-TW.json
+++ b/frontend/src/locale/zh-TW.json
@@ -92,7 +92,10 @@
"edit": "編輯提示詞",
"view": "檢視提示詞",
"duplicate": "複製提示詞",
- "delete": "刪除提示詞"
+ "delete": "刪除提示詞",
+ "deleteFailed": "無法刪除此提示詞。",
+ "saveFailed": "無法儲存此提示詞。",
+ "editConflict": "其他人已變更此提示詞。請重新開啟以查看其版本。"
}
},
"sources": {
@@ -428,7 +431,15 @@
"editChunk": "編輯文本塊",
"editChunkDescription": "{{file}} · 第 {{n}} 個文本塊 · {{tokens}} Token",
"previousChunk": "上一個文本塊",
- "nextChunk": "下一個文本塊"
+ "nextChunk": "下一個文本塊",
+ "viewConfig": "檢視來源設定",
+ "errors": {
+ "forbidden": "你沒有權限對此來源執行該操作。",
+ "delete": "無法刪除來源。",
+ "sync": "無法同步來源。",
+ "syncFrequency": "無法變更同步頻率。",
+ "reingest": "無法開始重新匯入。"
+ }
},
"analytics": {
"label": "分析",
@@ -720,7 +731,184 @@
"teamLabel": "團隊",
"viaTeam": "透過 {{team}}",
"removeAccess": "移除存取權",
- "access": "存取權"
+ "access": "存取權",
+ "showAll": "顯示全部 {{count}} 個",
+ "andMore": "還有 {{count}} 個",
+ "back": "返回",
+ "allSummary": "{{name}} · 團隊:{{teams}} · 人員:{{people}}",
+ "searchAccess": "搜尋人員和團隊…",
+ "filterLabel": "篩選具有存取權的人員",
+ "filter": {
+ "all": "全部",
+ "teams": "團隊",
+ "people": "人員",
+ "editors": "編輯者"
+ }
+ },
+ "accessChangeError": "無法變更存取權。",
+ "accessSettings": {
+ "title": "存取設定",
+ "saveError": "無法儲存存取設定。",
+ "agent": {
+ "editors_can_share": {
+ "label": "編輯者可以共用",
+ "description": "新增人員和團隊並變更其存取權。"
+ },
+ "editors_can_delete": {
+ "label": "編輯者可以刪除",
+ "description": "為所有人刪除此代理。"
+ },
+ "editors_can_manage_access_details": {
+ "label": "編輯者可以管理存取詳細資料",
+ "description": "API 金鑰、Webhook 和公開連結。"
+ },
+ "viewers_can_see_logs": {
+ "label": "檢視者可以查看記錄",
+ "description": "此代理的對話和分析。"
+ }
+ },
+ "source": {
+ "editors_can_share": {
+ "label": "編輯者可以共用",
+ "description": "新增人員和團隊並變更其存取權。"
+ },
+ "editors_can_delete": {
+ "label": "編輯者可以刪除",
+ "description": "為所有人刪除此來源。"
+ },
+ "viewers_can_see_config": {
+ "label": "檢視者可以查看設定",
+ "description": "分塊、檢索器和同步設定,唯讀。"
+ }
+ },
+ "tool": {
+ "editors_can_change_credentials": {
+ "label": "編輯者可以變更憑證和連線",
+ "description": "他們會取代已儲存的密鑰;任何人都無法讀回。"
+ },
+ "editors_can_share": {
+ "label": "編輯者可以共用",
+ "description": "新增人員和團隊並變更其存取權。"
+ },
+ "viewers_can_use_in_agents": {
+ "label": "檢視者可以在自己的代理中使用",
+ "description": "它會以你的憑證執行。"
+ }
+ },
+ "prompt": {
+ "editors_can_share": {
+ "label": "編輯者可以共用",
+ "description": "新增人員和團隊並變更其存取權。"
+ },
+ "viewers_can_duplicate": {
+ "label": "檢視者可以複製",
+ "description": "建立自己的副本來編輯。"
+ }
+ }
+ },
+ "editorHint": {
+ "agent": "編輯者可以修改它,包括記錄、排程和存取詳細資料。",
+ "agentNoAccessDetails": "編輯者可以修改它,包括記錄和排程,但不能修改存取詳細資料。",
+ "source": "編輯者可以編輯分塊和檔案、同步並變更設定。",
+ "tool": "編輯者可以變更動作並取代憑證,但無法讀取密鑰。",
+ "toolNoCredentials": "編輯者可以變更動作,但不能變更憑證。",
+ "prompt": "編輯者可以修改文字。",
+ "noShareNoDelete": "他們不能共用或刪除它。",
+ "shareOnly": "他們也可以共用它,但不能刪除。",
+ "deleteOnly": "他們也可以刪除它,但不能共用。",
+ "shareAndDelete": "他們也可以共用和刪除它。",
+ "noShare": "他們不能共用或刪除它。",
+ "share": "他們也可以共用它,但不能刪除。"
+ },
+ "capabilities": {
+ "agent": {
+ "viewers": "檢視者:與其對話並釘選",
+ "editors": "編輯者:編輯、發布、查看記錄和管理排程"
+ },
+ "source": {
+ "viewers": "檢視者:瀏覽、搜尋並在自己的代理中使用",
+ "editors": "編輯者:編輯分塊和檔案、同步、變更設定"
+ },
+ "tool": {
+ "viewers": "檢視者:在擁有者的代理中使用",
+ "editors": "編輯者:變更動作和核准"
+ },
+ "prompt": {
+ "viewers": "檢視者:閱讀並在自己的代理中使用",
+ "editors": "編輯者:修改文字"
+ },
+ "switch": {
+ "editors_can_share": {
+ "on": "編輯者可以共用",
+ "off": "編輯者不能共用"
+ },
+ "editors_can_delete": {
+ "on": "編輯者可以刪除",
+ "off": "編輯者不能刪除"
+ },
+ "editors_can_manage_access_details": {
+ "on": "編輯者可以管理 API 金鑰、Webhook 和公開連結",
+ "off": "編輯者不能管理 API 金鑰、Webhook 或公開連結"
+ },
+ "viewers_can_see_logs": {
+ "on": "檢視者可以查看記錄",
+ "off": "檢視者不能查看記錄"
+ },
+ "viewers_can_see_config": {
+ "on": "檢視者可以查看設定",
+ "off": "檢視者不能查看設定"
+ },
+ "editors_can_change_credentials": {
+ "on": "編輯者可以取代憑證",
+ "off": "編輯者不能變更憑證"
+ },
+ "viewers_can_use_in_agents": {
+ "on": "檢視者可以在自己的代理中使用",
+ "off": "檢視者不能在自己的代理中使用"
+ },
+ "viewers_can_duplicate": {
+ "on": "檢視者可以複製",
+ "off": "檢視者不能複製"
+ }
+ }
+ },
+ "sharedList": {
+ "badgeWithEditors": "{{level}} · +{{count}} 編輯者",
+ "meta": "{{type}} · {{owner}}",
+ "filterLabel": "篩選共用資源",
+ "filter": {
+ "all": "全部",
+ "agent": "代理",
+ "source": "來源",
+ "tool": "工具",
+ "prompt": "提示"
+ },
+ "search": "搜尋共用…",
+ "noMatches": "沒有相符項目。"
+ },
+ "drawer": {
+ "close": "關閉",
+ "subtitle": "{{type}} · 擁有者:{{owner}}",
+ "open": "開啟{{type}}",
+ "manageSharing": "管理共用",
+ "owner": "擁有者",
+ "shared": "共用時間",
+ "sharedOnBy": "{{date}},由 {{name}}",
+ "yourAccess": "你的存取權",
+ "yourAccessLevel": {
+ "owner": "擁有者",
+ "editor": "編輯者",
+ "viewer": "檢視者",
+ "none": "無存取權"
+ },
+ "accessIn": "{{team}} 中的存取權",
+ "everyone": "{{team}} 的所有人",
+ "teamGrant": "整個團隊",
+ "memberGrant": "僅此人",
+ "removeGrant": "移除存取權",
+ "otherTeamsHint": "也與其他團隊共用了嗎?這些授權在「管理共用」中管理。",
+ "whatPeopleCanDo": "這裡的人員可以做什麼",
+ "capabilitiesHint": "來自擁有者的存取設定。此處為唯讀。"
}
},
"tools": {
@@ -794,7 +982,6 @@
"addServer": "Add MCP Server",
"editServer": "Edit Server",
"reconnectServer": "重新連線伺服器",
- "reenterCredentials": "重新輸入您的憑證以測試並更新連線。",
"serverName": "Server Name",
"serverUrl": "Server URL",
"headerName": "Header Name",
@@ -839,7 +1026,18 @@
"oauthFailed": "OAuth process failed or was cancelled",
"oauthTimeout": "OAuth process timed out, please try again",
"timeoutRange": "Timeout must be between 1 and 300 seconds"
- }
+ },
+ "sharedByEditor": "由 {{owner}} 分享 · 你是編輯者",
+ "aTeammate": "一位隊友",
+ "sharedCredentialsNotice": "已儲存的憑證不會顯示。你輸入的內容將為所有使用此工具的人取代它們。",
+ "serverChangedNotice": "伺服器已變更,因此已儲存的{{credential}}將被清除。請為新伺服器輸入後再儲存。",
+ "credentialNames": {
+ "apiKey": "API 金鑰",
+ "bearer": "權杖",
+ "password": "密碼"
+ },
+ "savedKeyHint": "已儲存金鑰。留空即可保留(僅在伺服器未變更時)。",
+ "sharedOAuthOwnerOnly": "只有擁有者可以重新連結其登入,因此你可以重新命名,但不能變更其伺服器或帳戶。"
},
"configErrors": {
"required": "{{field}}為必填項",
@@ -847,7 +1045,14 @@
"maxTimeout": "逾時時間最長為 300 秒"
},
"headerValuePlaceholder": "例如:application/json",
- "toolIconTitle": "{{name}} 圖示"
+ "toolIconTitle": "{{name}} 圖示",
+ "view": "檢視",
+ "inMyChats": "在我的聊天中",
+ "useInMyChatsAria": "在我的聊天中使用 {{toolName}}",
+ "statusUpdateFailed": "無法變更此工具是否用於你的聊天。",
+ "deleteFailed": "無法刪除此工具。",
+ "sharedBy": "由 {{team}} 分享",
+ "savedSecretPlaceholder": "已儲存 · 輸入新值以取代"
},
"devices": {
"label": "裝置",
@@ -1344,7 +1549,9 @@
"test": "Test",
"learnMore": "Learn more",
"resetKey": "重設金鑰",
- "resetKeyConfirm": "確定要重設 API 金鑰嗎?目前的金鑰將立即停止運作,此操作無法復原。"
+ "resetKeyConfirm": "確定要重設 API 金鑰嗎?目前的金鑰將立即停止運作,此操作無法復原。",
+ "actionFailed": "操作未成功,請再試一次。",
+ "apiKeyAfterPublish": "發布此代理後即可建立其 API 金鑰。"
},
"importSpec": {
"title": "匯入 API 規格",
@@ -1761,7 +1968,6 @@
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
"remove": "Remove",
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
- "ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
@@ -1779,7 +1985,8 @@
"modes": {
"monitorOnly": "Monitor only",
"scanAll": "Enforce everywhere"
- }
+ },
+ "readOnly": "你可以檢視此政策,但你的角色無法變更它。"
},
"byline": {
"new": "設定好代理後發佈,即可與它對話。"
@@ -2221,7 +2428,8 @@
"classicDescription": "建立一個使用單一模型、工具和知識來源的標準 AI 代理",
"workflowTitle": "工作流程代理",
"workflowDescription": "設計包含不同模型、條件邏輯和狀態管理的複雜多步驟工作流程"
- }
+ },
+ "deleteFailed": "無法刪除此代理,請再試一次。"
},
"components": {
"fileUpload": {
diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json
index 34bb52b6..1220c12c 100644
--- a/frontend/src/locale/zh.json
+++ b/frontend/src/locale/zh.json
@@ -92,7 +92,10 @@
"edit": "编辑提示词",
"view": "查看提示词",
"duplicate": "复制提示词",
- "delete": "删除提示词"
+ "delete": "删除提示词",
+ "deleteFailed": "无法删除此提示词。",
+ "saveFailed": "无法保存此提示词。",
+ "editConflict": "其他人已更改此提示词。请重新打开以查看其版本。"
}
},
"sources": {
@@ -428,7 +431,15 @@
"editChunk": "编辑文本块",
"editChunkDescription": "{{file}} · 第 {{n}} 个文本块 · {{tokens}} 个令牌",
"previousChunk": "上一个文本块",
- "nextChunk": "下一个文本块"
+ "nextChunk": "下一个文本块",
+ "viewConfig": "查看来源设置",
+ "errors": {
+ "forbidden": "你无权对此来源执行该操作。",
+ "delete": "无法删除来源。",
+ "sync": "无法同步来源。",
+ "syncFrequency": "无法更改同步频率。",
+ "reingest": "无法开始重新导入。"
+ }
},
"analytics": {
"label": "分析",
@@ -720,7 +731,184 @@
"teamLabel": "团队",
"viaTeam": "通过 {{team}}",
"removeAccess": "移除访问权限",
- "access": "访问权限"
+ "access": "访问权限",
+ "showAll": "显示全部 {{count}} 个",
+ "andMore": "还有 {{count}} 个",
+ "back": "返回",
+ "allSummary": "{{name}} · 团队:{{teams}} · 人员:{{people}}",
+ "searchAccess": "搜索人员和团队…",
+ "filterLabel": "筛选有权访问的人员",
+ "filter": {
+ "all": "全部",
+ "teams": "团队",
+ "people": "人员",
+ "editors": "编辑者"
+ }
+ },
+ "accessChangeError": "无法更改访问权限。",
+ "accessSettings": {
+ "title": "访问设置",
+ "saveError": "无法保存访问设置。",
+ "agent": {
+ "editors_can_share": {
+ "label": "编辑者可以共享",
+ "description": "添加人员和团队并更改其访问权限。"
+ },
+ "editors_can_delete": {
+ "label": "编辑者可以删除",
+ "description": "为所有人删除该代理。"
+ },
+ "editors_can_manage_access_details": {
+ "label": "编辑者可以管理访问详情",
+ "description": "API 密钥、Webhook 和公开链接。"
+ },
+ "viewers_can_see_logs": {
+ "label": "查看者可以查看日志",
+ "description": "该代理的对话和分析。"
+ }
+ },
+ "source": {
+ "editors_can_share": {
+ "label": "编辑者可以共享",
+ "description": "添加人员和团队并更改其访问权限。"
+ },
+ "editors_can_delete": {
+ "label": "编辑者可以删除",
+ "description": "为所有人删除该来源。"
+ },
+ "viewers_can_see_config": {
+ "label": "查看者可以查看设置",
+ "description": "分块、检索器和同步设置,只读。"
+ }
+ },
+ "tool": {
+ "editors_can_change_credentials": {
+ "label": "编辑者可以更改凭据和连接",
+ "description": "他们替换已保存的密钥;任何人都无法读回。"
+ },
+ "editors_can_share": {
+ "label": "编辑者可以共享",
+ "description": "添加人员和团队并更改其访问权限。"
+ },
+ "viewers_can_use_in_agents": {
+ "label": "查看者可以在自己的代理中使用",
+ "description": "它使用你的凭据运行。"
+ }
+ },
+ "prompt": {
+ "editors_can_share": {
+ "label": "编辑者可以共享",
+ "description": "添加人员和团队并更改其访问权限。"
+ },
+ "viewers_can_duplicate": {
+ "label": "查看者可以复制",
+ "description": "创建自己的副本进行编辑。"
+ }
+ }
+ },
+ "editorHint": {
+ "agent": "编辑者可以修改它,包括日志、计划和访问详情。",
+ "agentNoAccessDetails": "编辑者可以修改它,包括日志和计划,但不能修改访问详情。",
+ "source": "编辑者可以编辑分块和文件、同步并更改设置。",
+ "tool": "编辑者可以更改操作并替换凭据,但无法读取密钥。",
+ "toolNoCredentials": "编辑者可以更改操作,但不能更改凭据。",
+ "prompt": "编辑者可以修改文本。",
+ "noShareNoDelete": "他们不能共享或删除它。",
+ "shareOnly": "他们还可以共享它,但不能删除。",
+ "deleteOnly": "他们还可以删除它,但不能共享。",
+ "shareAndDelete": "他们还可以共享和删除它。",
+ "noShare": "他们不能共享或删除它。",
+ "share": "他们还可以共享它,但不能删除。"
+ },
+ "capabilities": {
+ "agent": {
+ "viewers": "查看者:与其对话并置顶",
+ "editors": "编辑者:编辑、发布、查看日志和管理计划"
+ },
+ "source": {
+ "viewers": "查看者:浏览、搜索并在自己的代理中使用",
+ "editors": "编辑者:编辑分块和文件、同步、更改设置"
+ },
+ "tool": {
+ "viewers": "查看者:在所有者的代理中使用",
+ "editors": "编辑者:更改操作和审批"
+ },
+ "prompt": {
+ "viewers": "查看者:阅读并在自己的代理中使用",
+ "editors": "编辑者:修改文本"
+ },
+ "switch": {
+ "editors_can_share": {
+ "on": "编辑者可以共享",
+ "off": "编辑者不能共享"
+ },
+ "editors_can_delete": {
+ "on": "编辑者可以删除",
+ "off": "编辑者不能删除"
+ },
+ "editors_can_manage_access_details": {
+ "on": "编辑者可以管理 API 密钥、Webhook 和公开链接",
+ "off": "编辑者不能管理 API 密钥、Webhook 或公开链接"
+ },
+ "viewers_can_see_logs": {
+ "on": "查看者可以查看日志",
+ "off": "查看者不能查看日志"
+ },
+ "viewers_can_see_config": {
+ "on": "查看者可以查看设置",
+ "off": "查看者不能查看设置"
+ },
+ "editors_can_change_credentials": {
+ "on": "编辑者可以替换凭据",
+ "off": "编辑者不能更改凭据"
+ },
+ "viewers_can_use_in_agents": {
+ "on": "查看者可以在自己的代理中使用",
+ "off": "查看者不能在自己的代理中使用"
+ },
+ "viewers_can_duplicate": {
+ "on": "查看者可以复制",
+ "off": "查看者不能复制"
+ }
+ }
+ },
+ "sharedList": {
+ "badgeWithEditors": "{{level}} · +{{count}} 编辑者",
+ "meta": "{{type}} · {{owner}}",
+ "filterLabel": "筛选共享资源",
+ "filter": {
+ "all": "全部",
+ "agent": "代理",
+ "source": "来源",
+ "tool": "工具",
+ "prompt": "提示词"
+ },
+ "search": "搜索共享…",
+ "noMatches": "没有匹配项。"
+ },
+ "drawer": {
+ "close": "关闭",
+ "subtitle": "{{type}} · 所有者:{{owner}}",
+ "open": "打开{{type}}",
+ "manageSharing": "管理共享",
+ "owner": "所有者",
+ "shared": "共享时间",
+ "sharedOnBy": "{{date}},由 {{name}}",
+ "yourAccess": "你的访问权限",
+ "yourAccessLevel": {
+ "owner": "所有者",
+ "editor": "编辑者",
+ "viewer": "查看者",
+ "none": "无访问权限"
+ },
+ "accessIn": "{{team}} 中的访问权限",
+ "everyone": "{{team}} 的所有人",
+ "teamGrant": "整个团队",
+ "memberGrant": "仅此人",
+ "removeGrant": "移除访问权限",
+ "otherTeamsHint": "也与其他团队共享了?这些授权在“管理共享”中管理。",
+ "whatPeopleCanDo": "这里的人员可以做什么",
+ "capabilitiesHint": "来自所有者的访问设置。此处只读。"
}
},
"tools": {
@@ -794,7 +982,6 @@
"addServer": "Add MCP Server",
"editServer": "Edit Server",
"reconnectServer": "重新连接服务器",
- "reenterCredentials": "重新输入您的凭据以测试并更新连接。",
"serverName": "Server Name",
"serverUrl": "Server URL",
"headerName": "Header Name",
@@ -839,7 +1026,18 @@
"oauthFailed": "OAuth process failed or was cancelled",
"oauthTimeout": "OAuth process timed out, please try again",
"timeoutRange": "Timeout must be between 1 and 300 seconds"
- }
+ },
+ "sharedByEditor": "由 {{owner}} 共享 · 你是编辑者",
+ "aTeammate": "一位队友",
+ "sharedCredentialsNotice": "已保存的凭据不会显示。你输入的内容将为所有使用此工具的人替换它们。",
+ "serverChangedNotice": "服务器已更改,因此已保存的{{credential}}将被清除。请为新服务器输入后再保存。",
+ "credentialNames": {
+ "apiKey": "API 密钥",
+ "bearer": "令牌",
+ "password": "密码"
+ },
+ "savedKeyHint": "已保存密钥。留空即可保留(仅在服务器未更改时)。",
+ "sharedOAuthOwnerOnly": "只有所有者可以重新连接其登录,因此你可以重命名它,但不能更改其服务器或账户。"
},
"configErrors": {
"required": "{{field}}为必填项",
@@ -847,7 +1045,14 @@
"maxTimeout": "超时时间最长为 300 秒"
},
"headerValuePlaceholder": "例如:application/json",
- "toolIconTitle": "{{name}} 图标"
+ "toolIconTitle": "{{name}} 图标",
+ "view": "查看",
+ "inMyChats": "在我的聊天中",
+ "useInMyChatsAria": "在我的聊天中使用 {{toolName}}",
+ "statusUpdateFailed": "无法更改此工具是否用于你的聊天。",
+ "deleteFailed": "无法删除此工具。",
+ "sharedBy": "由 {{team}} 共享",
+ "savedSecretPlaceholder": "已保存 · 输入新值以替换"
},
"devices": {
"label": "设备",
@@ -1344,7 +1549,9 @@
"test": "Test",
"learnMore": "Learn more",
"resetKey": "重置密钥",
- "resetKeyConfirm": "确定要重置 API 密钥吗?当前密钥将立即停止工作,此操作无法撤销。"
+ "resetKeyConfirm": "确定要重置 API 密钥吗?当前密钥将立即停止工作,此操作无法撤销。",
+ "actionFailed": "操作未成功,请重试。",
+ "apiKeyAfterPublish": "发布该代理后即可创建其 API 密钥。"
},
"importSpec": {
"title": "导入 API 规范",
@@ -1761,7 +1968,6 @@
"pickAtLeastOne": "Pick at least one — the check cannot run with none selected.",
"remove": "Remove",
"instanceDisabled": "Guardrails are switched off for this instance, so nothing configured here will run. Ask your administrator to set GUARDRAILS_ENABLED.",
- "ownerOnly": "Guardrails are set by the agent's owner. You can see this policy but only the owner can change it.",
"floorNotice": "{{count}} control(s) are required by this instance and always apply.",
"floorControl": "{{stage}}: {{action}} — required by the instance policy",
"unknownCheck": "This agent uses a check that is not available here ({{check}}). It will still run if the check returns.",
@@ -1779,7 +1985,8 @@
"modes": {
"monitorOnly": "Monitor only",
"scanAll": "Enforce everywhere"
- }
+ },
+ "readOnly": "你可以查看此策略,但你的角色无法更改它。"
},
"byline": {
"new": "设置好智能体后发布,即可与它对话。"
@@ -2221,7 +2428,8 @@
"classicDescription": "创建一个使用单一模型、工具和知识来源的标准 AI 智能体",
"workflowTitle": "工作流智能体",
"workflowDescription": "设计包含不同模型、条件逻辑和状态管理的复杂多步骤工作流"
- }
+ },
+ "deleteFailed": "无法删除该代理,请重试。"
},
"components": {
"fileUpload": {
diff --git a/frontend/src/modals/AgentDetailsModal.test.tsx b/frontend/src/modals/AgentDetailsModal.test.tsx
new file mode 100644
index 00000000..c070e0a6
--- /dev/null
+++ b/frontend/src/modals/AgentDetailsModal.test.tsx
@@ -0,0 +1,118 @@
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+const mocks = vi.hoisted(() => ({
+ shareAgent: vi.fn(),
+ getAgentWebhook: vi.fn(),
+ regenerateAgentKey: vi.fn(),
+}));
+
+vi.mock('react-redux', () => ({
+ useSelector: (selector: (s: unknown) => unknown) =>
+ selector({ preference: { token: null } }),
+}));
+
+vi.mock('../api/services/userService', () => ({ default: mocks }));
+
+vi.mock('./ConfirmationModal', () => ({
+ default: ({
+ modalState,
+ handleSubmit,
+ }: {
+ modalState: string;
+ handleSubmit: () => void;
+ }) =>
+ modalState === 'ACTIVE' ? (
+
+ ) : null,
+}));
+
+import type { Agent } from '../agents/types';
+import AgentDetailsModal from './AgentDetailsModal';
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+const respond = (body: unknown, ok = true) =>
+ Promise.resolve({ ok, json: () => Promise.resolve(body) });
+
+describe('AgentDetailsModal', () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+ Object.values(mocks).forEach((m) => m.mockReset());
+ document.body.innerHTML = '';
+ });
+
+ const render = async (agent: Partial) => {
+ await act(async () => {
+ root.render(
+ undefined}
+ />,
+ );
+ });
+ };
+
+ // The three sections each have a Generate button until they hold a value.
+ const generateButtons = () =>
+ Array.from(document.querySelectorAll('button')).filter(
+ (b) => b.textContent === 'modals.agentDetails.generate',
+ );
+
+ it('generates a missing API key through the reset confirmation', async () => {
+ mocks.regenerateAgentKey.mockReturnValue(respond({ key: 'new-key' }));
+ await render({ status: 'published' });
+ const [, apiKey] = generateButtons();
+ await act(async () => apiKey.click());
+ await act(async () =>
+ document
+ .querySelector('[data-testid="confirm-key"]')!
+ .click(),
+ );
+ expect(mocks.regenerateAgentKey).toHaveBeenCalledWith('a1', null);
+ expect(document.body.textContent).toContain('new-key');
+ });
+
+ it('asks a draft to publish first instead of offering a key', async () => {
+ await render({ status: 'draft' });
+ expect(generateButtons()).toHaveLength(2);
+ expect(document.body.textContent).toContain(
+ 'modals.agentDetails.apiKeyAfterPublish',
+ );
+ });
+
+ it('shows a refused public link in an alert', async () => {
+ mocks.shareAgent.mockReturnValue(
+ respond({ success: false, message: 'Not allowed' }, false),
+ );
+ await render({ status: 'published' });
+ await act(async () => generateButtons()[0].click());
+ const alert = document.querySelector('[role="alert"]');
+ expect(alert?.textContent).toContain('Not allowed');
+ });
+
+ it('shows a failed webhook in an alert with a fallback message', async () => {
+ mocks.getAgentWebhook.mockReturnValue(respond({}, false));
+ await render({ status: 'published' });
+ const buttons = generateButtons();
+ await act(async () => buttons[buttons.length - 1].click());
+ const alert = document.querySelector('[role="alert"]');
+ expect(alert?.textContent).toContain('modals.agentDetails.actionFailed');
+ });
+});
diff --git a/frontend/src/modals/AgentDetailsModal.tsx b/frontend/src/modals/AgentDetailsModal.tsx
index e52e2123..5f5322e3 100644
--- a/frontend/src/modals/AgentDetailsModal.tsx
+++ b/frontend/src/modals/AgentDetailsModal.tsx
@@ -1,5 +1,5 @@
import { envVar } from '@/env';
-import { ExternalLink } from 'lucide-react';
+import { CircleX, ExternalLink } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
@@ -7,6 +7,7 @@ import { useSelector } from 'react-redux';
import { Agent } from '../agents/types';
import userService from '../api/services/userService';
import CopyButton from '../components/CopyButton';
+import { Alert, AlertDescription } from '../components/ui/alert';
import { Button } from '../components/ui/button';
import { Modal } from '../components/ui/modal';
import { SectionHeader } from '../components/ui/section-header';
@@ -16,6 +17,18 @@ import ConfirmationModal from './ConfirmationModal';
const baseURL = envVar('VITE_BASE_URL');
+/** The backend's `message` on a refused call, else null. */
+const errorMessage = async (response: Response): Promise => {
+ try {
+ const body = await response.json();
+ return typeof body?.message === 'string' && body.message.trim()
+ ? body.message
+ : null;
+ } catch {
+ return null;
+ }
+};
+
type AgentDetailsModalProps = {
agent: Agent;
mode: 'new' | 'edit' | 'draft';
@@ -41,6 +54,8 @@ export default function AgentDetailsModal({
const [webhookUrl, setWebhookUrl] = useState(null);
const [resetKeyConfirmState, setResetKeyConfirmState] =
useState('INACTIVE');
+ // A failed generate or reset, shown in the modal until the next attempt.
+ const [error, setError] = useState(null);
const [loadingStates, setLoadingStates] = useState({
publicLink: false,
apiKey: false,
@@ -54,49 +69,61 @@ export default function AgentDetailsModal({
setLoadingStates((prev) => ({ ...prev, [key]: state }));
};
- const handleGeneratePublicLink = async () => {
- setLoading('publicLink', true);
- const response = await userService.shareAgent(
- { id: agent.id ?? '', shared: true },
- token,
- );
- if (!response.ok) {
- setLoading('publicLink', false);
- return;
- }
- const data = await response.json();
- setSharedToken(data.shared_token);
- setLoading('publicLink', false);
- };
-
- const handleGenerateWebhook = async () => {
- setLoading('webhook', true);
- const response = await userService.getAgentWebhook(agent.id ?? '', token);
- if (!response.ok) {
- setLoading('webhook', false);
- return;
- }
- const data = await response.json();
- setWebhookUrl(data.webhook_url);
- setLoading('webhook', false);
- };
-
- const handleRegenerateKey = async () => {
- setLoading('apiKey', true);
+ /**
+ * Runs one of the modal's calls, showing its failure in the Alert.
+ *
+ * @param key Which button shows the spinner.
+ * @param request The call; resolves to the response.
+ * @param onSuccess Receives the parsed body of a successful response.
+ */
+ const run = async (
+ key: 'publicLink' | 'apiKey' | 'webhook',
+ request: () => Promise,
+ onSuccess: (data: Record) => void,
+ ) => {
+ setLoading(key, true);
+ setError(null);
try {
- const response = await userService.regenerateAgentKey(
- agent.id ?? '',
- token,
- );
- if (!response.ok) return;
- const data = await response.json();
- setApiKey(data.key);
- onKeyRegenerated?.(data.key);
+ const response = await request();
+ if (!response.ok) {
+ setError(
+ (await errorMessage(response)) ??
+ t('modals.agentDetails.actionFailed'),
+ );
+ return;
+ }
+ onSuccess(await response.json());
+ } catch {
+ setError(t('modals.agentDetails.actionFailed'));
} finally {
- setLoading('apiKey', false);
+ setLoading(key, false);
}
};
+ const handleGeneratePublicLink = () =>
+ run(
+ 'publicLink',
+ () => userService.shareAgent({ id: agent.id ?? '', shared: true }, token),
+ (data) => setSharedToken(data.shared_token),
+ );
+
+ const handleGenerateWebhook = () =>
+ run(
+ 'webhook',
+ () => userService.getAgentWebhook(agent.id ?? '', token),
+ (data) => setWebhookUrl(data.webhook_url),
+ );
+
+ const handleRegenerateKey = () =>
+ run(
+ 'apiKey',
+ () => userService.regenerateAgentKey(agent.id ?? '', token),
+ (data) => {
+ setApiKey(data.key);
+ onKeyRegenerated?.(data.key);
+ },
+ );
+
useEffect(() => {
setSharedToken(agent.shared_token ?? null);
setApiKey(agent.key ?? null);
@@ -111,6 +138,12 @@ export default function AgentDetailsModal({
size="md"
>
+ {error && (
+
+
+ {error}
+
+ )}
@@ -216,8 +249,21 @@ export default function AgentDetailsModal({
)}
+ ) : agent.status === 'draft' ? (
+ // A draft has no key yet: the first one is minted on publish.
+
+ {t('modals.agentDetails.apiKeyAfterPublish')}
+
) : (
-
+ // No key shown on a published agent: minting one replaces
+ // any key it has, so it goes through the reset confirmation.
+ setResetKeyConfirmState('ACTIVE')}
+ loading={loadingStates.apiKey}
+ >
{t('modals.agentDetails.generate')}
)}
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 : (
+
+ {t('settings.tools.mcp.testConnection')}
+
+ )
}
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' && (
-
-
-
- {t('settings.tools.mcp.reenterCredentials', {
- defaultValue:
- 'Re-enter your credentials to test and update the connection.',
- })}
-
-
- )}
+ {isShared && (
+
+
+
+ {t('settings.tools.mcp.sharedCredentialsNotice')}
+ {oauthOwnerOnly &&
+ ` ${t('settings.tools.mcp.sharedOAuthOwnerOnly')}`}
+
+
+ )}
handleInputChange('server_url', e.target.value)}
placeholder="https://example.com/mcp"
+ disabled={oauthOwnerOnly}
/>
+ {serverChanged && (
+
+
+
+ {t('settings.tools.mcp.serverChangedNotice', {
+ interpolation: { escapeValue: false },
+ credential: t(
+ `settings.tools.mcp.credentialNames.${savedSecret.nameKey}`,
+ ),
+ })}
+
+
+ )}
)}
+
!open && closeDrawer()}
+ >
+ {openResource && selected && (
+
+
+ {/* pr-12 keeps the header clear of the close X. */}
+
+
+ {resourceTypeIcon(openResource.type)}
+
+
+
+ {resourceName(openResource)}
+
+
+ {t('settings.teams.drawer.subtitle', {
+ interpolation: { escapeValue: false },
+ type: resourceTypeLabel(openResource.type),
+ owner: ownerLabel(openResource),
+ })}
+
+
+
+
+
{
+ const path = openAssetPath(openResource);
+ closeDrawer();
+ navigate(path);
+ }}
+ >
+
+ {t('settings.teams.drawer.open', {
+ interpolation: { escapeValue: false },
+ type: resourceTypeLabel(openResource.type),
+ })}
+
+ {callerCanShare && (
+
{
+ setDrawerOpen(false);
+ setShareTarget(openResource);
+ }}
+ >
+
+ {t('settings.teams.drawer.manageSharing')}
+
+ )}
+
+
+
+
+
+ {ownerLabel(openResource)}
+
+
+ {grantedAt(openResource)}
+
+
+ {callerAccessLabel(openCaller?.access)}
+
+
+
+
+
+
+
+ {[
+ ...(openResource.teamGrant
+ ? [openResource.teamGrant]
+ : []),
+ ...openResource.memberGrants,
+ ].map((g) => {
+ const isTeam = !g.target_user_id;
+ const label = isTeam
+ ? t('settings.teams.drawer.everyone', {
+ interpolation: { escapeValue: false },
+ team: selected.name,
+ })
+ : g.target_user_label ||
+ truncateSub(g.target_user_id!);
+ const busy = busyGrants.has(grantKey(g));
+ return (
+
+
+ {initialOf(isTeam ? selected.name : label)}
+
+
+ }
+ title={{label}}
+ description={
+ isTeam
+ ? t('settings.teams.drawer.teamGrant')
+ : t('settings.teams.drawer.memberGrant')
+ }
+ trailing={
+ <>
+ {callerCanShare ? (
+
+ ) : (
+
+ {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}
/>
-
- {t('settings.tools.save')}
-
+ {!readOnly && (
+
+ {t('settings.tools.save')}
+
+ )}
{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({
{tool.config.actions &&
Object.keys(tool.config.actions).length > 0 ? (
-
+
+
+
) : (
{
const isExpanded = expandedUserActions.has(originalIndex);
return (
-
>
)}
-
+
);
})}
>
@@ -768,19 +802,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);
setShowUnsavedModal(false);
handleGoBack();
} catch {
@@ -811,6 +833,7 @@ function APIToolConfig({
}) {
const [apiTool, setApiTool] = React.useState
(tool);
const { t } = useTranslation();
+ const { canEdit, canEditCredentials } = React.useContext(ToolAccessContext);
const [actionToDelete, setActionToDelete] = React.useState(
null,
);
@@ -925,9 +948,10 @@ function APIToolConfig({
{filteredActions.map(([actionName, action], actionIndex) => {
const isExpanded = expandedActions.has(actionName);
return (
-
{
setApiTool((prevApiTool) => {
const updatedActions = {
@@ -1213,7 +1238,7 @@ function APIToolConfig({
>
)}
-
+
);
})}
@@ -1249,6 +1274,7 @@ function APIActionTable({
) => void;
}) {
const { t } = useTranslation();
+ const { canEditCredentials } = React.useContext(ToolAccessContext);
const idPrefix = React.useId();
const [action, setAction] = React.useState(apiAction);
@@ -1541,10 +1567,18 @@ function APIActionTable({
handlePropertyChange(section, key, 'value', e.target.value)
}
+ {...(section === 'query_params' &&
+ param.has_value && {
+ type: 'password',
+ placeholder: t('settings.tools.savedSecretPlaceholder'),
+ })}
size="sm"
/>
@@ -1697,7 +1731,14 @@ function APIActionTable({
e.target.value,
)
}
- placeholder={t('settings.tools.headerValuePlaceholder')}
+ // A saved value never comes back: empty keeps it.
+ type={param.has_value ? 'password' : 'text'}
+ placeholder={
+ param.has_value
+ ? t('settings.tools.savedSecretPlaceholder')
+ : t('settings.tools.headerValuePlaceholder')
+ }
+ disabled={!canEditCredentials}
size="sm"
/>
diff --git a/frontend/src/settings/Tools.test.tsx b/frontend/src/settings/Tools.test.tsx
new file mode 100644
index 00000000..caf80038
--- /dev/null
+++ b/frontend/src/settings/Tools.test.tsx
@@ -0,0 +1,266 @@
+import { act, useState } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+
+const dispatch = vi.fn();
+vi.mock('react-redux', () => ({
+ useSelector: () => 'token',
+ useDispatch: () => dispatch,
+}));
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({
+ t: (key: string, opts?: Record) => {
+ const { interpolation: _i, ...rest } = opts ?? {};
+ void _i;
+ return Object.keys(rest).length ? `${key}:${JSON.stringify(rest)}` : key;
+ },
+ }),
+}));
+
+vi.mock('../hooks', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useLoaderState: (initial: boolean) => useState(initial),
+}));
+
+// The menu is a Radix dropdown; the tests only need its options.
+vi.mock('../components/ui/dropdown-menu', () => ({
+ ActionMenu: ({
+ options,
+ }: {
+ options: { label: string; onClick: () => void }[];
+ }) => (
+
+ {options.map((o) => (
+
+ {o.label}
+
+ ))}
+
+ ),
+}));
+
+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;
+ }) => (
+ handleDeleteDocument(0, paginatedDocuments[0])}
+ >
+ DELETE
+
+ ),
+}));
+
+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 && (
+
+
+
+ {t('settings.teams.share.loadError')}
+
+
+ )}
+ {actionError && (
+
+
+ {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 = (
+
+
setStep('main')}
+ >
+
+ {t('settings.teams.share.back')}
+
+
+
+ {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 && (
+
+
+ setSettingsOpen((open) => !open)}
+ >
+
+
+ {t('settings.teams.accessSettings.title')}
+
+
+
+ {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. */}
+
+
+
+
+
+ {t('settings.teams.share.addPlaceholder')}
+
+
+
+
+
+
+
+ {!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 && (
-
-
-
- {t('settings.teams.share.loadError')}
-
-
- )}
- {actionError && (
-
-
- {actionError}
-
- )}
-
- {teams.length === 0 ? (
- {t('settings.teams.share.noTeams')}
- ) : (
- <>
- {/* Add row: type-ahead combobox + access level select. */}
-
-
-
-
-
- {t('settings.teams.share.addPlaceholder')}
-
-
-
-
-
-
-
- {!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):