diff --git a/.gitignore b/.gitignore
index 5bb2d60b..8cccab2f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -158,6 +158,10 @@ frontend/yarn-debug.log*
frontend/yarn-error.log*
frontend/pnpm-debug.log*
frontend/lerna-debug.log*
+# tsc build output (composite tsconfig.node.json); Vite would load vite.config.js before .ts
+frontend/*.tsbuildinfo
+frontend/vite.config.js
+frontend/vite.config.d.ts
# Keep frontend utility helpers tracked (overrides global lib/ ignore)
!frontend/src/lib/
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..cddf693e 100644
--- a/docsgpt/agents/headless_runner.py
+++ b/docsgpt/agents/headless_runner.py
@@ -14,7 +14,11 @@ 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.api.user.resource_access import ref_principal
from docsgpt.core.settings import settings
from docsgpt.quotas.service import QuotaExceededError, QuotaService
from docsgpt.retriever.retriever_creator import RetrieverCreator
@@ -148,7 +152,13 @@ def _run_agent_headless(
source_active: Any = {}
if source_id:
with db_readonly() as conn:
- src_row = SourcesRepository(conn).get(str(source_id), owner)
+ # Owned or team-shared to the owner, else attached by an editor
+ # who still qualifies; read unscoped once authorized.
+ src_row = (
+ SourcesRepository(conn).get_by_id(str(source_id))
+ if ref_principal(conn, "agent", agent_config, "source", str(source_id))
+ else None
+ )
if src_row:
source_active = str(src_row["id"])
retriever_kind = src_row.get("retriever", retriever_kind)
@@ -156,7 +166,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, agent_config)
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..dd9e6f0b 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)."""
@@ -465,6 +505,9 @@ class ToolExecutor:
# get_tools() resolves EXACTLY these ids — builtin synthetic ids and
# user_tools rows alike — with no defaults mixed in. None = unscoped.
self.allowed_tool_ids: Optional[List[str]] = None
+ # Tool id -> the user to resolve it as, for a workflow node's tools
+ # sponsored by an editor (see resource_access.active_sponsor).
+ self.tool_principals: Dict[str, str] = {}
self.conversation_id: Optional[str] = None
# Set by the workflow engine for agent nodes so run-scoped tools
# (artifact_generator / code_executor) address artifacts by the
@@ -527,6 +570,8 @@ class ToolExecutor:
tools: List[Dict] = []
for tid in tool_ids:
row = resolve_tool_by_id(tid, self.user, user_tools_repo=tools_repo)
+ if row is None and str(tid) in self.tool_principals:
+ row = resolve_tool_by_id(tid, self.tool_principals[str(tid)], user_tools_repo=tools_repo)
if row is None:
logger.warning("tool id %s did not resolve; dropped from scoped toolset", tid)
continue
@@ -548,6 +593,15 @@ class ToolExecutor:
tools: List[Dict] = []
for tid in tool_ids:
row = resolve_tool_by_id(tid, owner, user_tools_repo=tools_repo)
+ if row is None:
+ # A tool the owner can't use runs as the editor who
+ # attached it, while they still qualify.
+ # Lazy: docsgpt.api's package import pulls in every route module.
+ from docsgpt.api.user.resource_access import active_sponsor
+
+ sponsor = active_sponsor(conn, "agent", agent_data, "tool", str(tid))
+ if sponsor:
+ row = resolve_tool_by_id(tid, sponsor, user_tools_repo=tools_repo)
if row is None:
continue
# Workflow-only builtins (read_document) never resolve for a
@@ -565,6 +619,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 +636,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 +1342,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 +1609,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/agents/workflow_agent.py b/docsgpt/agents/workflow_agent.py
index 2c08bc0c..010f7287 100644
--- a/docsgpt/agents/workflow_agent.py
+++ b/docsgpt/agents/workflow_agent.py
@@ -45,6 +45,7 @@ class WorkflowAgent(BaseAgent):
):
super().__init__(*args, **kwargs)
self.workflow_id = workflow_id
+ self.workflow_row: Optional[Dict[str, Any]] = None
self.workflow_owner = workflow_owner
self._workflow_data = workflow
self._engine: Optional[WorkflowEngine] = None
@@ -195,6 +196,9 @@ class WorkflowAgent(BaseAgent):
if workflow_row is None:
logger.error(f"Workflow {self.workflow_id} not found or inaccessible for user {owner_id}")
return None
+ # Node tools/sources the owner can't use resolve through its
+ # ``resource_sponsors`` (see WorkflowEngine).
+ self.workflow_row = workflow_row
pg_workflow_id = str(workflow_row["id"])
graph_version = workflow_row.get("current_graph_version", 1)
try:
diff --git a/docsgpt/agents/workflows/node_agent.py b/docsgpt/agents/workflows/node_agent.py
index 17fa17a8..45215317 100644
--- a/docsgpt/agents/workflows/node_agent.py
+++ b/docsgpt/agents/workflows/node_agent.py
@@ -19,6 +19,7 @@ class _WorkflowNodeMixin:
model_id: str,
api_key: str,
tool_ids: Optional[List[str]] = None,
+ tool_principals: Optional[Dict[str, str]] = None,
**kwargs,
):
super().__init__(
@@ -34,6 +35,8 @@ class _WorkflowNodeMixin:
# (Artifact / Code Executor / Read Document) and ``user_tools`` rows
# alike, and an empty list means the node's LLM gets no tools.
self.tool_executor.allowed_tool_ids = [str(t) for t in (tool_ids or [])]
+ # Tools the owner can't use resolve as the editor who attached them.
+ self.tool_executor.tool_principals = dict(tool_principals or {})
class WorkflowNodeClassicAgent(_WorkflowNodeMixin, ClassicAgent):
diff --git a/docsgpt/agents/workflows/workflow_engine.py b/docsgpt/agents/workflows/workflow_engine.py
index e7cf437a..22017726 100644
--- a/docsgpt/agents/workflows/workflow_engine.py
+++ b/docsgpt/agents/workflows/workflow_engine.py
@@ -405,6 +405,7 @@ class WorkflowEngine:
"model_user_id": getattr(self.agent, "model_user_id", None),
"api_key": node_api_key,
"tool_ids": node_config.tools,
+ "tool_principals": self._node_tool_principals(node_config.tools),
"prompt": node_prompt,
"chat_history": self.agent.chat_history,
"decoded_token": self.agent.decoded_token,
@@ -1321,6 +1322,36 @@ class WorkflowEngine:
docs_together = "\n\n".join(docs_together_parts) if docs_together_parts else None
return docs, docs_together
+ def _node_tool_principals(self, tool_ids) -> Dict[str, str]:
+ """Node tool id -> the editor to resolve it as, for sponsored tools.
+
+ Only tools with a live sponsor on the workflow appear; the executor
+ still tries the owner first.
+
+ Args:
+ tool_ids: The node's configured tool ids.
+
+ Returns:
+ dict: ``tool_id -> sponsor`` user id.
+ """
+ workflow_row = getattr(self.agent, "workflow_row", None)
+ if not tool_ids or not workflow_row or not workflow_row.get("resource_sponsors"):
+ return {}
+ from docsgpt.api.user.resource_access import active_sponsor
+ from docsgpt.storage.db.session import db_readonly
+
+ principals: Dict[str, str] = {}
+ try:
+ with db_readonly() as conn:
+ for tid in tool_ids:
+ sponsor = active_sponsor(conn, "workflow", workflow_row, "tool", str(tid))
+ if sponsor:
+ principals[str(tid)] = sponsor
+ except Exception:
+ logger.exception("Workflow node tool sponsor lookup failed; using the owner only.")
+ return {}
+ return principals
+
def _authorized_node_sources(self, sources) -> list:
"""Filter a node's configured source ids to those its owner may read.
@@ -1329,7 +1360,9 @@ class WorkflowEngine:
tenant's source id and the retriever — which filters only on
``source_id`` — handed the documents back. Gate on the workflow owner
(not the runner): a shared workflow legitimately reads its owner's
- sources, exactly like a shared agent does.
+ sources, exactly like a shared agent does. A source the owner can't
+ read still passes while the editor who attached it (its sponsor)
+ qualifies.
Args:
sources: Source ids from the stored node config.
@@ -1348,14 +1381,19 @@ class WorkflowEngine:
logger.warning("Workflow node sources dropped: no owner to authorize.")
return []
+ from docsgpt.api.user.resource_access import active_sponsor
from docsgpt.api.user.team_sharing import can_access
from docsgpt.storage.db.session import db_readonly
+ workflow_row = getattr(self.agent, "workflow_row", None)
allowed = []
try:
with db_readonly() as conn:
for sid in ids:
- if sid and can_access(conn, "source", str(sid), owner):
+ if sid and (
+ can_access(conn, "source", str(sid), owner)
+ or active_sponsor(conn, "workflow", workflow_row, "source", str(sid))
+ ):
allowed.append(sid)
else:
logger.warning(
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/alembic/versions/0039_resource_sponsors.py b/docsgpt/alembic/versions/0039_resource_sponsors.py
new file mode 100644
index 00000000..2fc4f5df
--- /dev/null
+++ b/docsgpt/alembic/versions/0039_resource_sponsors.py
@@ -0,0 +1,40 @@
+"""0039 resource sponsors — who vouches for a saved resource the owner can't use.
+
+An agent (and a workflow) runs as its owner, so every source, prompt and tool
+it references is authorized against the owner. A team editor who attaches
+their own private tool, prompt or source would have it dropped at run time.
+``resource_sponsors`` records the editor who attached such a resource, keyed
+``":"`` (e.g. ``{"tool:": "bob"}``). At run time the resource
+is authorized as that sponsor, provided they can still edit the agent and
+still use the resource (``docsgpt/api/user/resource_access.py``).
+
+An empty map means today's behaviour: owner-only authorization.
+
+Idempotent both ways.
+
+Revision ID: 0039_resource_sponsors
+Revises: 0038_resource_access_settings
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+
+
+revision: str = "0039_resource_sponsors"
+down_revision: Union[str, None] = "0038_resource_access_settings"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ for table in ("agents", "workflows"):
+ op.execute(
+ f"ALTER TABLE {table} ADD COLUMN IF NOT EXISTS "
+ "resource_sponsors JSONB NOT NULL DEFAULT '{}'::jsonb;"
+ )
+
+
+def downgrade() -> None:
+ for table in ("agents", "workflows"):
+ op.execute(f"ALTER TABLE {table} DROP COLUMN IF EXISTS resource_sponsors;")
diff --git a/docsgpt/api/answer/services/stream_processor.py b/docsgpt/api/answer/services/stream_processor.py
index 24688aa7..b453aff0 100644
--- a/docsgpt/api/answer/services/stream_processor.py
+++ b/docsgpt/api/answer/services/stream_processor.py
@@ -109,6 +109,71 @@ 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], agent: Optional[dict] = None) -> Any:
+ """``prompt_id`` if ``principal`` (or the agent's sponsor) 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). On an agent
+ run, a prompt the owner can't use still renders while the editor who
+ attached it (its sponsor) qualifies. 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.
+ agent: The agent row on an agent run, for its ``resource_sponsors``.
+
+ 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 active_sponsor, resolve
+
+ try:
+ with db_readonly() as conn:
+ ra = resolve(conn, "prompt", pid, principal) if principal else None
+ usable = ra is not None and ra.can("use")
+ if not usable and agent and agent.get("id"):
+ usable = active_sponsor(conn, "agent", agent, "prompt", pid) is not None
+ except Exception:
+ logger.exception("Prompt access check failed for %s", pid)
+ usable = False
+ if usable:
+ return prompt_id
+ logger.info("prompt %s not usable by %s; using the default prompt", pid, principal)
+ return "default"
+
+
+def _agent_source_doc(conn: Any, sources_repo: Any, agent: dict, source_id: Any) -> Optional[dict]:
+ """The source row an agent may retrieve from, or None.
+
+ Authorized as the owner (owned or team-shared to them), else as the
+ editor who attached it while they still qualify. Read unscoped once
+ authorized: an owner-scoped read misses a team-shared source.
+ """
+ from docsgpt.api.user.resource_access import ref_principal
+
+ if not ref_principal(conn, "agent", agent, "source", str(source_id)):
+ logger.info("agent %s source %s not usable; skipped", agent.get("id"), source_id)
+ return None
+ return sources_repo.get_by_id(str(source_id))
+
+
+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")
@@ -676,13 +741,12 @@ class StreamProcessor:
# the legacy ``data["source"]`` slot.
sources_list: list = []
seen: set = set()
- owner = agent.get("user_id")
primary_id = agent.get("source_id")
# ``sources`` row may have NULL ``retriever``/``chunks`` —
# fall back to the agent's value (``dict.get`` returns None
# even when the key exists with value None).
if primary_id:
- source_doc = sources_repo.get(str(primary_id), owner)
+ source_doc = _agent_source_doc(conn, sources_repo, agent, primary_id)
if source_doc:
sid = str(source_doc["id"])
data["source"] = sid
@@ -715,7 +779,7 @@ class StreamProcessor:
for sid_raw in agent.get("extra_source_ids") or []:
if not sid_raw:
continue
- source_doc = sources_repo.get(str(sid_raw), owner)
+ source_doc = _agent_source_doc(conn, sources_repo, agent, sid_raw)
if not source_doc:
continue
sid = str(source_doc["id"])
@@ -934,7 +998,13 @@ 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"),
+ self._agent_data,
+ ),
"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 +1068,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 +1205,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 +1224,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..2715b842 100644
--- a/docsgpt/api/user/agents/routes.py
+++ b/docsgpt/api/user/agents/routes.py
@@ -25,10 +25,20 @@ from docsgpt.core.json_schema_utils import (
normalize_json_schema_payload,
)
from docsgpt.core.settings import settings
-from docsgpt.storage.db.base_repository import looks_like_uuid
+from docsgpt.storage.db.base_repository import canonical_uuid, looks_like_uuid
+from docsgpt.api.user.resource_access import (
+ AccessDenied,
+ agent_refs,
+ delete_settings,
+ payload_for,
+ require,
+ resolve,
+ settings_many,
+ sponsor_details,
+ sponsors_after_save,
+)
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 +206,80 @@ 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, caller: str) -> bool:
+ """Whether ``caller`` may newly attach ``tool_id`` to an agent.
+
+ Builtin synthetic ids belong to no one. Otherwise the caller must own the
+ tool or reach it with ``use_in_own``. The agent owner owning it is not
+ enough: an editor could otherwise wire the owner's private tool (run with
+ the owner's credentials) into an agent the editor controls.
+
+ Args:
+ conn: Open database connection.
+ tool_id: The tool id being attached.
+ 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
+ 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, caller: str) -> bool:
+ """Whether ``caller`` may newly reference a source/prompt from an agent.
+
+ Like tools, the caller's own access counts, not the agent owner's.
+
+ Args:
+ conn: Open database connection.
+ resource_type: ``source`` or ``prompt``.
+ resource_id: The referenced id.
+ caller: The user making the change.
+
+ Returns:
+ True when the caller owns it or a team grant reaches them.
+ """
+ if not resource_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,24 +535,28 @@ class GetAgent(Resource):
return {"success": False, "message": "ID required"}, 400
try:
user = decoded_token["sub"]
- ownership, team_access = "user", None
+ agent = None
+ sponsored: list = []
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)
+ # Edit-page detail: who vouches for resources the owner can't use.
+ if agent and ra.can("view"):
+ sponsored = sponsor_details(conn, "agent", agent)
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(),
)
+ data["resource_sponsors"] = sponsored
return make_response(jsonify(data), 200)
except Exception as e:
current_app.logger.error(f"Agent fetch error: {e}", exc_info=True)
@@ -483,10 +587,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 +606,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
]
@@ -705,11 +822,11 @@ class CreateAgent(Resource):
if src == "default":
continue
if looks_like_uuid(src):
- extra_source_ids.append(src)
+ extra_source_ids.append(canonical_uuid(src))
else:
source_value = data.get("source", "")
if source_value and source_value != "default" and looks_like_uuid(source_value):
- source_id_resolved = source_value
+ source_id_resolved = canonical_uuid(source_value)
# Team-sharing write gate: you may reference sources/prompts you
# own or that a team has shared with you directly. (Transitive
@@ -731,8 +848,20 @@ 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):
+ return make_response(
+ jsonify({"success": False, "message": "Tool not accessible"}),
+ 403,
+ )
build_data = dict(data)
+ if isinstance(data.get("tools"), list):
+ build_data["tools"] = [canonical_uuid(t) for t in data["tools"]]
+ if looks_like_uuid(data.get("prompt_id")):
+ build_data["prompt_id"] = canonical_uuid(data["prompt_id"])
build_data["folder_id"] = pg_folder_id
build_data["workflow_id"] = pg_workflow_id
build_data["source_id"] = source_id_resolved
@@ -888,23 +1017,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,13 +1080,15 @@ 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")
if not source_id or source_id == "default":
update_fields["source_id"] = None
elif looks_like_uuid(source_id):
- update_fields["source_id"] = source_id
+ update_fields["source_id"] = canonical_uuid(source_id)
else:
return _reject(
f"Invalid source ID format: {source_id}", user, field
@@ -980,7 +1103,7 @@ class UpdateAgent(Resource):
if src == "default":
continue
if looks_like_uuid(src):
- valid.append(src)
+ valid.append(canonical_uuid(src))
else:
return _reject(
f"Invalid source ID in list: {src}", user, field
@@ -1008,7 +1131,7 @@ class UpdateAgent(Resource):
tools_list = data.get("tools", [])
if not isinstance(tools_list, list):
return _reject("Tools must be a list", user, field)
- update_fields["tools"] = tools_list
+ update_fields["tools"] = [canonical_uuid(t) for t in tools_list]
elif field == "json_schema":
json_schema = data.get("json_schema")
if json_schema is not None:
@@ -1084,10 +1207,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
@@ -1105,20 +1242,35 @@ class UpdateAgent(Resource):
if not normalized:
if workflow_required:
return _reject("Workflow is required", user, field)
- update_fields["workflow_id"] = None
+ pg_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
- update_fields["workflow_id"] = pg_workflow_id
+ # Only the owner may change which workflow the agent
+ # uses, detaching included: editing rights on this
+ # agent extend to the graph it uses, so swapping in the
+ # workflow of another of the owner's agents would hand
+ # that graph to the editor. Editing the graph itself
+ # goes through the workflow routes.
+ current_workflow = existing_agent.get("workflow_id")
+ if is_team_editor and pg_workflow_id != (
+ str(current_workflow) if current_workflow else None
+ ):
+ return _denied(
+ AccessDenied(403, "Only the owner can change this agent's workflow")
+ )
+ update_fields["workflow_id"] = pg_workflow_id
elif field == "prompt_id":
value = data["prompt_id"]
if not value or value == "default":
update_fields["prompt_id"] = None
elif looks_like_uuid(value):
- update_fields["prompt_id"] = value
+ update_fields["prompt_id"] = canonical_uuid(value)
else:
return _reject(f"Invalid prompt_id: {value}", user, field)
elif field == "allow_system_prompt_override":
@@ -1220,7 +1372,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, user):
return make_response(
jsonify({"success": False, "message": "Source not accessible"}), 403
)
@@ -1228,28 +1380,25 @@ 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, 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 caller's own or reach them with
+ # ``use_in_own`` -- the owner owning it is not enough. 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, user):
return make_response(
jsonify(
{"success": False, "message": "Tool not accessible"}
@@ -1257,19 +1406,25 @@ 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)
+ # A resource the owner can't use runs as the editor who
+ # attached it (its sponsor); record who that is.
+ after_save = dict(existing_agent)
+ for ref_field in ("source_id", "extra_source_ids", "prompt_id", "tools"):
+ if ref_field in update_fields:
+ after_save[ref_field] = update_fields[ref_field]
+ sponsors = sponsors_after_save(
+ conn, "agent", existing_agent, owner_id, user, agent_refs(after_save)
+ )
+ if sponsors != (existing_agent.get("resource_sponsors") or {}):
+ update_fields["resource_sponsors"] = sponsors
+
+ # 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 +1483,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 +1515,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 +1549,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 +1620,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 +1641,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 +1750,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 +1788,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..7d3f5627
--- /dev/null
+++ b/docsgpt/api/user/resource_access.py
@@ -0,0 +1,558 @@
+"""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
+import logging
+from dataclasses import dataclass, field
+from typing import Iterable, Optional
+
+from sqlalchemy import Connection, text
+
+from docsgpt.storage.db.base_repository import canonical_uuid, 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
+
+logger = logging.getLogger(__name__)
+
+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 (write-only); OAuth servers stay owner-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."""
+ rid = canonical_uuid(str(resource_id))
+ return settings_many(conn, resource_type, [rid])[rid]
+
+
+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.
+
+ Keys are canonical (lowercase) UUIDs, the form Postgres returns.
+ """
+ ids = [canonical_uuid(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
+ # Postgres matches any casing but returns lowercase; canonicalise so the
+ # switch lookup (keyed by the returned id) can't miss.
+ resource_id = canonical_uuid(str(resource_id))
+ owned = repo_cls(conn).get_any(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
+
+
+# --- Resource sponsors ------------------------------------------------------
+#
+# An agent (or workflow) runs as its owner, so a source, prompt or tool it
+# references is authorized against the owner. When a team editor attaches one
+# the owner can't use, the editor becomes its *sponsor*: the holder row's
+# ``resource_sponsors`` maps ``":"`` to the editor's id, and at run
+# time the resource is authorized as the sponsor while they can still edit the
+# holder and still use the resource. A tool still runs with its own row's
+# credentials (the tool owner's), whoever the principal is.
+
+# Action a principal needs on a referenced resource for a holder to run it.
+REF_USE_ACTION = {"source": "use", "prompt": "use", "tool": "use_in_own"}
+
+
+def sponsor_key(resource_type: str, resource_id: str) -> str:
+ """The ``resource_sponsors`` key for one referenced resource."""
+ return f"{resource_type}:{resource_id}"
+
+
+def can_use_ref(conn: Connection, resource_type: str, resource_id: str, user_id: Optional[str]) -> bool:
+ """Whether ``user_id`` may have ``resource_id`` run inside something they hold.
+
+ Args:
+ conn: Open database connection.
+ resource_type: ``source``, ``prompt`` or ``tool``.
+ resource_id: The referenced id.
+ user_id: The would-be principal.
+
+ Returns:
+ True when the user owns the resource or a team grant gives them
+ ``use`` (``use_in_own`` for a tool).
+ """
+ if not user_id or not resource_id:
+ return False
+ ra = resolve(conn, resource_type, str(resource_id), user_id)
+ return ra is not None and ra.can(REF_USE_ACTION[resource_type])
+
+
+def _holder_editable_by(conn: Connection, holder_type: str, holder: dict, user_id: str) -> bool:
+ """Whether ``user_id`` may still edit the agent or workflow ``holder``.
+
+ A workflow is edited through an agent of its owner that uses it, so the
+ check is ``edit`` on any such agent (mirrors the workflow routes).
+ """
+ if holder_type == "agent":
+ ra = resolve(conn, "agent", str(holder["id"]), user_id)
+ return ra is not None and ra.can("edit")
+ if holder_type == "workflow":
+ agent_ids = conn.execute(
+ text("SELECT id FROM agents WHERE workflow_id = CAST(:wid AS uuid) AND user_id = :owner"),
+ {"wid": str(holder["id"]), "owner": holder.get("user_id")},
+ ).scalars().all()
+ for agent_id in agent_ids:
+ ra = resolve(conn, "agent", str(agent_id), user_id)
+ if ra is not None and ra.can("edit"):
+ return True
+ return False
+ raise ValueError(f"Unknown sponsor holder type: {holder_type}")
+
+
+def active_sponsor(
+ conn: Connection, holder_type: str, holder: Optional[dict], resource_type: str, resource_id: str
+) -> Optional[str]:
+ """The sponsor a holder may run ``resource_id`` as, checked live.
+
+ Args:
+ conn: Open database connection.
+ holder_type: ``agent`` or ``workflow``.
+ holder: The holder row (needs ``id``, ``user_id``, ``resource_sponsors``).
+ resource_type: ``source``, ``prompt`` or ``tool``.
+ resource_id: The referenced id.
+
+ Returns:
+ The sponsor's id when one is recorded, still edits the holder and
+ can still use the resource; else None.
+ """
+ if not holder or not resource_id:
+ return None
+ sponsor = (holder.get("resource_sponsors") or {}).get(sponsor_key(resource_type, str(resource_id)))
+ if not sponsor or sponsor == holder.get("user_id"):
+ return None
+ try:
+ if not _holder_editable_by(conn, holder_type, holder, sponsor):
+ return None
+ return sponsor if can_use_ref(conn, resource_type, str(resource_id), sponsor) else None
+ except Exception:
+ logger.exception("Sponsor check failed for %s %s", resource_type, resource_id)
+ return None
+
+
+def ref_principal(
+ conn: Connection, holder_type: str, holder: Optional[dict], resource_type: str, resource_id: str
+) -> Optional[str]:
+ """The user a holder's referenced resource is authorized as, or None.
+
+ The owner when they may use it (the default), else a live sponsor.
+ """
+ if not holder:
+ return None
+ owner = holder.get("user_id")
+ if can_use_ref(conn, resource_type, str(resource_id), owner):
+ return owner
+ return active_sponsor(conn, holder_type, holder, resource_type, resource_id)
+
+
+def _sponsorable(resource_type: str, resource_id: str) -> bool:
+ """Only real rows need a principal: skip presets and builtin tool ids."""
+ rid = str(resource_id or "")
+ if not looks_like_uuid(rid):
+ return False
+ if resource_type == "tool":
+ # Lazy: default_tools imports this module lazily too.
+ from docsgpt.agents.default_tools import is_synthesized_tool_id
+
+ return not is_synthesized_tool_id(rid)
+ return True
+
+
+def agent_refs(agent: dict) -> list[tuple[str, str]]:
+ """The ``(type, id)`` resources an agent row references."""
+ refs = [("source", str(s)) for s in [agent.get("source_id"), *(agent.get("extra_source_ids") or [])] if s]
+ if agent.get("prompt_id"):
+ refs.append(("prompt", str(agent["prompt_id"])))
+ refs.extend(("tool", str(t)) for t in agent.get("tools") or [] if t)
+ return refs
+
+
+def sponsors_after_save(
+ conn: Connection,
+ holder_type: str,
+ holder: Optional[dict],
+ owner_id: str,
+ caller: str,
+ refs: Iterable[tuple[str, str]],
+) -> dict[str, str]:
+ """The ``resource_sponsors`` map to store after ``caller`` saves ``refs``.
+
+ Per referenced resource the owner can't use: a recorded sponsor who still
+ qualifies is kept; otherwise the caller takes it over when they can use it
+ (a new attachment, or one whose sponsor lost access); otherwise the old
+ record is kept so the editor can show who added it. Resources the owner
+ can use, presets and builtin tools need no sponsor. Removed refs drop out.
+
+ Args:
+ conn: Open database connection.
+ holder_type: ``agent`` or ``workflow``.
+ holder: The holder row before the save (None when creating it).
+ owner_id: The holder's owner.
+ caller: The user saving.
+ refs: Every ``(type, id)`` the holder references after the save.
+
+ Returns:
+ dict: ``":" -> user_id``.
+ """
+ previous = (holder or {}).get("resource_sponsors") or {}
+ out: dict[str, str] = {}
+ for resource_type, resource_id in refs:
+ key = sponsor_key(resource_type, resource_id)
+ if key in out or not _sponsorable(resource_type, resource_id):
+ continue
+ if can_use_ref(conn, resource_type, resource_id, owner_id):
+ continue
+ if active_sponsor(conn, holder_type, holder, resource_type, resource_id):
+ out[key] = previous[key]
+ elif caller != owner_id and can_use_ref(conn, resource_type, resource_id, caller):
+ out[key] = caller
+ elif previous.get(key):
+ out[key] = previous[key]
+ return out
+
+
+def sponsor_details(conn: Connection, holder_type: str, holder: dict) -> list[dict]:
+ """The holder's sponsored resources for its edit page.
+
+ Args:
+ conn: Open database connection.
+ holder_type: ``agent`` or ``workflow``.
+ holder: The holder row.
+
+ Returns:
+ list: ``{type, id, user_id, label, active}`` per sponsored resource;
+ ``label`` is the sponsor's email when on file, ``active`` whether it
+ runs (the sponsor still edits the holder and can use the resource).
+ """
+ sponsors = holder.get("resource_sponsors") or {}
+ if not sponsors:
+ return []
+ user_ids = sorted({u for u in sponsors.values() if u})
+ labels = dict(
+ conn.execute(
+ text(
+ "SELECT user_id, email FROM users WHERE user_id = ANY(:ids) "
+ "AND email IS NOT NULL AND email <> ''"
+ ),
+ {"ids": user_ids},
+ ).fetchall()
+ ) if user_ids else {}
+ out = []
+ for key, user_id in sponsors.items():
+ resource_type, _, resource_id = key.partition(":")
+ if resource_type not in REF_USE_ACTION or not resource_id:
+ continue
+ out.append(
+ {
+ "type": resource_type,
+ "id": resource_id,
+ "user_id": user_id,
+ "label": labels.get(user_id) or user_id,
+ "active": active_sponsor(conn, holder_type, holder, resource_type, resource_id) is not None,
+ }
+ )
+ return out
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..fab6968a 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,31 @@ 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 cut down to its ``kind`` when the
+ caller's role may not ``view_config`` (a viewer when the owner turned
+ ``viewers_can_see_config`` off). ``kind`` stays because the UI needs it to
+ pick the wiki / graph view.
+
+ 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"]:
+ config = entry.get("config")
+ if config is not None:
+ entry["config"] = {"kind": (config or {}).get("kind", "classic")}
+ entry.update(payload)
+ return entry
+
+
@sources_ns.route("/sources")
class CombinedJson(Resource):
@api.doc(description="Provide JSON file with combined available indexes")
@@ -93,6 +122,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 +133,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 +151,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 +213,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 +223,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 +285,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 +329,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 +392,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 +433,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 +505,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 +515,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 +578,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 +605,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 +664,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 +697,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 +750,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 +888,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 +934,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 +975,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 +1064,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 +1176,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 +1288,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 +1421,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 +1470,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..5f284c82 100644
--- a/docsgpt/api/user/tools/mcp.py
+++ b/docsgpt/api/user/tools/mcp.py
@@ -7,7 +7,16 @@ 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_origin_changed,
+ check_oauth_mcp_owner_only,
+ 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 +84,61 @@ def _validate_mcp_server_url(config: dict) -> None:
raise ValueError(f"Invalid server URL: {exc}") from exc
+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 origin (scheme, host, port) is unchanged; a new
+ origin never inherits them.
+ A server that is or would become OAuth is the owner's alone (its tokens
+ are the owner's sign-in).
+
+ 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), can't change its
+ credentials (403), or isn't the owner of an OAuth server (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 {}
+ check_oauth_mcp_owner_only(ra, existing_config, config)
+ moved = _mcp_origin_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 +169,14 @@ 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
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 +209,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,12 +270,20 @@ 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
auth_type = config.get("auth_type", "none")
mcp_config = config.copy()
- mcp_config["auth_credentials"] = auth_credentials
+ mcp_config["auth_credentials"] = merged_credentials
if auth_type == "oauth":
+ # Only the owner reaches here for an existing server (see
+ # ``_existing_mcp_context``), and every OAuth save needs the
+ # sign-in they just completed.
if not config.get("oauth_task_id"):
return make_response(
jsonify(
@@ -239,8 +310,8 @@ class MCPServerSave(Resource):
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 +320,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",
@@ -288,53 +339,48 @@ class MCPServerSave(Resource):
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 +401,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 +492,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 +516,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 +528,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 +538,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..25d826e1 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,284 @@ 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 origin 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)
+
+
+_DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443}
+
+
+def url_origin(url: Any) -> str:
+ """``scheme://host:port`` of ``url``, lower-cased, default port filled in.
+
+ Saved secrets follow the origin, not just the host: the same host over
+ ``http`` would send them in cleartext, and another port can be another
+ service. ``''`` when the URL has no host or doesn't parse.
+ """
+ try:
+ parts = urlparse(str(url or "").strip())
+ host = (parts.hostname or "").lower()
+ port = parts.port
+ except ValueError:
+ return ""
+ if not host:
+ return ""
+ scheme = (parts.scheme or "").lower()
+ return f"{scheme}://{host}:{port or _DEFAULT_PORTS.get(scheme, '')}"
+
+
+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 origin 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: an origin 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_origin(old.get("url")) != url_origin(
+ 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_origin_changed(new_config: dict, existing_config: dict) -> bool:
+ """Whether a save moves an MCP server to another scheme, host or port."""
+ old_url = (existing_config or {}).get("server_url")
+ return bool(old_url) and url_origin(old_url) != url_origin((new_config or {}).get("server_url"))
+
+
+SHARED_OAUTH_OWNER_ONLY = "Only the owner can change or reconnect this server"
+
+
+def check_oauth_mcp_owner_only(
+ ra: ResourceAccess, existing_config: Optional[dict], new_config: Optional[dict]
+) -> None:
+ """Keep a shared OAuth MCP server's connection with its owner.
+
+ MCP OAuth tokens are looked up by owner + server URL and a shared server
+ runs as its owner, so a grantee who moved an OAuth server, switched a
+ server to OAuth, or re-ran its sign-in would be using the owner's account
+ somewhere the owner never chose. Until connectors own OAuth accounts, any
+ connection change on a server that is (or would become) OAuth is
+ owner-only.
+
+ Args:
+ ra: The caller's access to the tool.
+ existing_config: The stored ``config``.
+ new_config: The incoming ``config``.
+
+ Raises:
+ AccessDenied: 403 when a non-owner touches an OAuth server's config.
+ """
+ if ra.access == "owner":
+ return
+ configs = [c if isinstance(c, dict) else {} for c in (existing_config, new_config)]
+ auth_types = {c.get("auth_type") for c in configs}
+ if "oauth" in auth_types:
+ raise AccessDenied(403, SHARED_OAUTH_OWNER_ONLY)
+
+
+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
+ origin-change rule (a new scheme, host or port drops stored credentials).
+
+ Raises:
+ CredentialsRequired: the MCP origin 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_origin_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 +534,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 +556,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 +585,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 +697,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 +792,49 @@ 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 == "mcp_tool":
+ check_oauth_mcp_owner_only(ra, existing_config, data["config"])
+ 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 +849,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,12 +914,14 @@ 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)
tool_name = tool_doc.get("name")
if tool_name == "mcp_tool":
+ check_oauth_mcp_owner_only(ra, tool_doc.get("config"), data["config"])
server_url = (data["config"].get("server_url") or "").strip()
if server_url:
try:
@@ -633,11 +953,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 +1006,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 +1067,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 +1119,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..3941e786 100644
--- a/docsgpt/api/user/workflows/routes.py
+++ b/docsgpt/api/user/workflows/routes.py
@@ -1,14 +1,22 @@
"""Workflow management routes."""
-from typing import Any, Dict, List, Optional, Set
+from typing import Any, Dict, List, Optional, Set, Tuple
-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,
+ can_use_ref,
+ resolve,
+ sponsor_details,
+ sponsors_after_save,
+)
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 +54,118 @@ 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 _node_refs(nodes: List[Dict]) -> List[Tuple[str, str]]:
+ """The ``(type, id)`` tools and sources a workflow's agent nodes reference.
+
+ Args:
+ nodes: Nodes as the builder sends them (config under ``data`` or
+ ``data.config``, like the engine reads it).
+
+ Returns:
+ list: ``("tool", id)`` and ``("source", id)`` pairs.
+ """
+ refs: List[Tuple[str, str]] = []
+ for node in nodes or []:
+ if not isinstance(node, dict) or node.get("type") != "agent":
+ continue
+ data = node.get("data") or {}
+ cfg = data.get("config") if isinstance(data.get("config"), dict) else data
+ for resource_type, key in (("tool", "tools"), ("source", "sources")):
+ values = cfg.get(key) or []
+ if isinstance(values, (str, int)):
+ values = [values]
+ refs.extend((resource_type, str(v)) for v in values if v)
+ return refs
+
+
+def _new_node_ref_denied(
+ conn, previous_nodes: List[Dict], new_nodes: List[Dict], caller: str
+) -> Optional[AccessDenied]:
+ """403 for the first node tool/source ``caller`` newly adds but can't use.
+
+ A workflow runs as its owner, so an editor saving the owner's graph must
+ not reference the owner's private tools or sources: the caller's own
+ access counts (``use_in_own`` for a tool, ``use`` for a source), not the
+ owner's. Refs already in the stored graph stay, like an agent's.
+
+ Args:
+ conn: Open database connection.
+ previous_nodes: The stored graph's nodes, in builder shape.
+ new_nodes: The nodes being saved.
+ caller: The editor saving.
+
+ Returns:
+ An :class:`AccessDenied` to return, or None when every new ref is fine.
+ """
+ from docsgpt.agents.default_tools import is_synthesized_tool_id
+
+ existing = set(_node_refs(previous_nodes))
+ for resource_type, resource_id in _node_refs(new_nodes):
+ if (resource_type, resource_id) in existing:
+ continue
+ if resource_type == "tool" and is_synthesized_tool_id(resource_id):
+ continue
+ if resource_type == "source" and resource_id == "default":
+ continue
+ if not can_use_ref(conn, resource_type, resource_id, caller):
+ return AccessDenied(403, f"{resource_type.capitalize()} not accessible")
+ return None
+
+
+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 +619,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(
@@ -511,6 +631,7 @@ class WorkflowDetail(Resource):
edges = WorkflowEdgesRepository(conn).find_by_version(
pg_workflow_id, graph_version,
)
+ sponsored = sponsor_details(conn, "workflow", workflow)
except Exception as err:
return _workflow_error_response("Failed to fetch workflow", err)
@@ -519,6 +640,7 @@ class WorkflowDetail(Resource):
"workflow": serialize_workflow(workflow),
"nodes": [serialize_node(n) for n in nodes],
"edges": [serialize_edge(e) for e in edges],
+ "resource_sponsors": sponsored,
}
)
@@ -533,37 +655,54 @@ 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)
+ if acting != user_id:
+ previous_nodes = [
+ serialize_node(n)
+ for n in WorkflowNodesRepository(conn).find_by_version(
+ pg_workflow_id, current_graph_version,
+ )
+ ]
+ denied = _new_node_ref_denied(conn, previous_nodes, nodes_data, user_id)
+ if denied is not None:
+ return _denied(denied)
next_graph_version = current_graph_version + 1
_write_graph(
conn, pg_workflow_id, next_graph_version,
nodes_data, edges_data,
)
- repo.update(
- pg_workflow_id, user_id,
- {
- "name": name,
- "description": description,
- "current_graph_version": next_graph_version,
- },
+ workflow_fields = {
+ "name": name,
+ "description": description,
+ "current_graph_version": next_graph_version,
+ }
+ # A node tool/source the owner can't use runs as the editor
+ # who attached it (its sponsor); record who that is.
+ sponsors = sponsors_after_save(
+ conn, "workflow", workflow, acting, user_id, _node_refs(nodes_data)
)
+ if sponsors != (workflow.get("resource_sponsors") or {}):
+ workflow_fields["resource_sponsors"] = sponsors
+ repo.update(pg_workflow_id, acting, workflow_fields)
WorkflowNodesRepository(conn).delete_other_versions(
pg_workflow_id, next_graph_version,
)
@@ -582,11 +721,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/services/search_service.py b/docsgpt/services/search_service.py
index 68756c91..3fd55e4e 100644
--- a/docsgpt/services/search_service.py
+++ b/docsgpt/services/search_service.py
@@ -48,7 +48,7 @@ def _collect_source_ids(agent: Dict[str, Any]) -> List[str]:
def _authorized_source_ids(conn, agent: Dict[str, Any], source_ids: List[str]) -> List[str]:
- """Drop source ids the agent's owner may not read.
+ """Drop source ids the agent may not read.
``_collect_source_ids`` trusts whatever the agent row carries, and this
service searches those ids directly. That made it the second half of a
@@ -64,20 +64,23 @@ def _authorized_source_ids(conn, agent: Dict[str, Any], source_ids: List[str]) -
agent: The agent row resolved from the API key.
source_ids: Ids extracted from that row.
+ A source the owner can't read still searches while the editor who
+ attached it (its sponsor) can edit the agent and read the source.
+
Returns:
- list: The subset the agent's owner may read.
+ list: The subset the agent's owner (or a live sponsor) may read.
"""
owner = agent.get("user_id")
if not owner:
logger.warning("Agent %s has no owner; refusing to search its sources.", agent.get("id"))
return []
- from docsgpt.api.user.team_sharing import can_access
+ from docsgpt.api.user.resource_access import ref_principal
allowed = []
for sid in source_ids:
try:
- permitted = can_access(conn, "source", str(sid), owner)
+ permitted = ref_principal(conn, "agent", agent, "source", str(sid)) is not None
except Exception:
# Fail closed, matching the answer path.
logger.warning("Access check failed for source %s; dropping it.", sid)
diff --git a/docsgpt/storage/db/base_repository.py b/docsgpt/storage/db/base_repository.py
index 3a8bee0d..9d92dbe3 100644
--- a/docsgpt/storage/db/base_repository.py
+++ b/docsgpt/storage/db/base_repository.py
@@ -35,6 +35,24 @@ def looks_like_uuid(value: Any) -> bool:
return isinstance(value, str) and bool(_UUID_RE.match(value))
+def canonical_uuid(value: Any) -> Any:
+ """The lowercase canonical form of a UUID string; anything else unchanged.
+
+ Postgres accepts any casing on ``CAST(... AS uuid)`` but returns the
+ lowercase form, so an id used as a dict key or stored in a JSON/array
+ column must be canonical to match what the database hands back.
+
+ Args:
+ value: A candidate id.
+
+ Returns:
+ ``str(UUID(value))`` for a UUID, else ``value`` as given.
+ """
+ if isinstance(value, UUID):
+ return str(value)
+ return str(UUID(value)) if looks_like_uuid(value) else value
+
+
def row_to_dict(row: Any) -> dict:
"""Convert a SQLAlchemy ``Row`` to a plain JSON-safe dict.
diff --git a/docsgpt/storage/db/models.py b/docsgpt/storage/db/models.py
index ebd25567..34841da6 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,
@@ -367,6 +400,9 @@ agents_table = Table(
# Per-agent behavior contract (AgentConfig — guardrails today). Empty
# ``{}`` parses to guardrails-disabled.
Column("config", JSONB, nullable=False, server_default=text("'{}'::jsonb")),
+ # ``":" -> user_id`` of the editor vouching for a referenced
+ # resource the owner can't use (migration 0039, see resource_access.py).
+ Column("resource_sponsors", JSONB, nullable=False, server_default=text("'{}'::jsonb")),
Column("default_model_id", Text),
Column("folder_id", UUID(as_uuid=True), ForeignKey("agent_folders.id", ondelete="SET NULL")),
Column("workflow_id", UUID(as_uuid=True), ForeignKey("workflows.id", ondelete="SET NULL")),
@@ -925,6 +961,8 @@ workflows_table = Table(
Column("name", Text, nullable=False),
Column("description", Text),
Column("current_graph_version", Integer, nullable=False, server_default="1"),
+ # Same shape as ``agents.resource_sponsors``, for node tools and sources.
+ Column("resource_sponsors", JSONB, nullable=False, server_default=text("'{}'::jsonb")),
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
Column("legacy_mongo_id", Text),
diff --git a/docsgpt/storage/db/repositories/agents.py b/docsgpt/storage/db/repositories/agents.py
index 2e8163dc..8676966b 100644
--- a/docsgpt/storage/db/repositories/agents.py
+++ b/docsgpt/storage/db/repositories/agents.py
@@ -43,14 +43,14 @@ class AgentsRepository:
"limited_token_mode", "limited_request_mode",
"allow_system_prompt_override",
"shared", "shared_token", "shared_metadata",
- "tools", "json_schema", "models", "config", "legacy_mongo_id",
+ "tools", "json_schema", "models", "config", "resource_sponsors", "legacy_mongo_id",
"created_at", "updated_at", "last_used_at",
}
for col, val in kwargs.items():
if col not in _ALLOWED or val is None:
continue
- if col in ("tools", "json_schema", "models", "shared_metadata", "config"):
+ if col in ("tools", "json_schema", "models", "shared_metadata", "config", "resource_sponsors"):
# JSONB columns: pass the Python object directly. SQLAlchemy
# Core's JSONB type processor json.dumps it once during
# bind; pre-serialising would double-encode and the value
@@ -234,7 +234,7 @@ class AgentsRepository:
allowed = {
"name", "description", "agent_type", "status", "key", "slug", "source_id",
"chunks", "retriever", "prompt_id", "tools", "json_schema", "models",
- "config",
+ "config", "resource_sponsors",
"default_model_id", "folder_id", "workflow_id",
"extra_source_ids", "image",
"limited_token_mode", "token_limit",
@@ -248,7 +248,7 @@ class AgentsRepository:
return False
values: dict = {}
for col, val in filtered.items():
- if col in ("tools", "json_schema", "models", "shared_metadata", "config"):
+ if col in ("tools", "json_schema", "models", "shared_metadata", "config", "resource_sponsors"):
values[col] = val
elif col in ("source_id", "prompt_id", "folder_id", "workflow_id"):
values[col] = str(val) if val else None
@@ -294,7 +294,7 @@ class AgentsRepository:
allowed = {
"name", "description", "agent_type", "status", "key", "slug", "source_id",
"chunks", "retriever", "prompt_id", "tools", "json_schema", "models",
- "config",
+ "config", "resource_sponsors",
"default_model_id", "folder_id", "workflow_id",
"extra_source_ids", "image",
"limited_token_mode", "token_limit",
@@ -309,7 +309,7 @@ class AgentsRepository:
values: dict = {}
for col, val in filtered.items():
- if col in ("tools", "json_schema", "models", "shared_metadata", "config"):
+ if col in ("tools", "json_schema", "models", "shared_metadata", "config", "resource_sponsors"):
# See note in create(): JSONB columns receive Python
# objects, the type processor handles serialisation.
values[col] = val
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/docsgpt/storage/db/repositories/workflows.py b/docsgpt/storage/db/repositories/workflows.py
index b27b45e4..8f3563bf 100644
--- a/docsgpt/storage/db/repositories/workflows.py
+++ b/docsgpt/storage/db/repositories/workflows.py
@@ -9,6 +9,7 @@ Covers CRUD on workflow metadata:
from __future__ import annotations
import copy
+import json
from typing import Optional
from sqlalchemy import Connection, text
@@ -85,12 +86,17 @@ class WorkflowsRepository:
return [row_to_dict(r) for r in result.fetchall()]
def update(self, workflow_id: str, user_id: str, fields: dict) -> bool:
- allowed = {"name", "description", "current_graph_version"}
+ allowed = {"name", "description", "current_graph_version", "resource_sponsors"}
filtered = {k: v for k, v in fields.items() if k in allowed}
if not filtered:
return False
- set_parts = [f"{col} = :{col}" for col in filtered]
+ set_parts = [
+ f"{col} = CAST(:{col} AS jsonb)" if col == "resource_sponsors" else f"{col} = :{col}"
+ for col in filtered
+ ]
+ if "resource_sponsors" in filtered:
+ filtered["resource_sponsors"] = json.dumps(filtered["resource_sponsors"] or {})
set_parts.append("updated_at = now()")
params = {**filtered, "id": workflow_id, "user_id": user_id}
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
index eaf5651a..b2c8b657 100644
--- a/frontend/DESIGN.md
+++ b/frontend/DESIGN.md
@@ -17,7 +17,8 @@ from the Tailwind scale, never from arbitrary `[...]` values. Runtime values
go in CSS custom properties or, when unavoidable, an inline style with a
disable comment. Conditional classes go through `cn(...)`, never a template
literal (prettier sorts the classes inside `cn`, and `cn` merges conflicts).
-Every user-visible string, attributes included (`aria-label`, IconButton
+What a role may do is gated with `can(item, action)` and a refused action is
+not rendered (see Access and roles). Every user-visible string, attributes included (`aria-label`, IconButton
`label`, `placeholder`, `title`, `alt`, `hint`), is a `t()` key in all seven
locales (`de en es jp ru zh zh-TW`); admin pages are English by design.
Interpolated user text (a name, a timezone, a date) passes `interpolation: {
@@ -228,13 +229,14 @@ pill`, the only `outline-primary` on the agent pages themselves (the Access
`outline field pill` (Save draft, Preview with `Play` first), Cancel `ghost
field pill`, and page-level actions go in the `ActionMenu` (`size="toolbar"`)
at the end of the title row, through `SectionShell titleAction`: Access
- details and Share with team on Overview, and Preview until the agent is
+ details and Share with team on Overview (each only when the role allows
+ it, see Access and roles), and Preview until the agent is
published (before that the preview only says "Publish to preview"). So the
row never holds more than three buttons and fits a phone, where the main
one stretches across it (`flex-1 sm:flex-none`; `PageToolbar`'s action slot
spans the stacked row below `sm`). The workflow builder's toolbar has no
title row and keeps its ⋯ at the row's end, holding Edit details, Access
- details and Delete (see Workflow builder). The
+ details, Share with team and Delete (see Workflow builder). The
agent's Delete is in Overview's danger zone; only the workflow builder's
toolbar has it in the ⋯ menu, as a `destructive` item. The row itself is
`agents/components/AgentPageToolbar` (see Page chrome). A row's common
@@ -294,7 +296,10 @@ justify-start` and `aria-expanded`: a lucide `ChevronRight` first
(`has-[[data-variant=section-toggle]:focus-visible]:ring-3 … ring-inset`,
inset because a scrolling column clips an outset ring), so pages pass
nothing for it. Status badges go beside the button, not inside it, or the
- hover underline runs under them.
+ hover underline runs under them. Because the ring comes from the Card,
+ `section-toggle` only goes inside a Card: a collapsible group in a Modal
+ or drawer (Share's Access settings) is the inline `link sm` disclosure
+ toggle above.
- Drop `text-white` on default and destructive buttons; the foreground token
already provides it. Drop `disabled:cursor-not-allowed` on buttons; the
base disables pointer events. Fields are the other way round (see Focus,
@@ -466,7 +471,10 @@ pill, including schedule and run status pills (`agents/schedules/StatusBadge.tsx
maps schedule and run statuses to Badge variants), trace chips and statuses,
token scope chips, "Disabled" and tool chips. A grey chip is `neutral`, never
a `bg-muted` pill. The admin role is `default` wherever it shows (Teams, Admin
-→ Users); every other role is `neutral`. Chips that show code (token scopes) pass `font-mono`, and
+→ Users); every other role is `neutral`. A shared asset's tile (agent,
+source, tool, prompt) shows the caller's role with `components/RoleBadge`: a
+`neutral` Badge with `Users` first (Editor, Viewer, from `roleOf()`); your own
+assets show none. Chips that show code (token scopes) pass `font-mono`, and
stat chips `tabular-nums`, as approved exceptions. HTTP method pills take their variant from
`getMethodBadgeVariant` (`utils/httpMethodColors.ts`): GET `success`, POST
`info`, PUT `warning`, DELETE `destructive`, PATCH `default`, anything else
@@ -603,6 +611,13 @@ state assignment) is not a bare row: each of its fields has a floating
label. Never hand-build a label above a field, a
hand-positioned floating label, or a `div.flex-col` + `Label` + `
` stack.
+A saved secret (an API key, a tool's credential, a custom header value) never
+comes back from the API, for any role. Its field is empty and `type="password"`;
+in a FormField the saved state is the `hint` ("Saved. Leave empty to keep
+it."), because a placeholder is hidden while the label rests. Only a
+label-less table cell carries it as the placeholder
+(`settings.tools.savedSecretPlaceholder`). Never a `••••` placeholder.
+
### SettingRow (`ui/setting-row.tsx`)
A setting with a control on the right (a Switch, a short Input) is
@@ -632,7 +647,10 @@ description use a Switch in a SettingRow instead.
### Switch, TimePicker and Calendar (`ui/switch.tsx`, `ui/time-picker.tsx`, `ui/calendar.tsx`)
`Switch` (Radix) is an on/off setting, placed inside a `SettingRow` that names
-it. The track is `primary` when on and `bg-input` when off, with a white
+it. A tile's own on/off (the tool tile's "In my chats") is the one exception:
+a `Label text-muted-foreground text-xs font-normal` + `Switch` pair at the
+tile's bottom-right. For a shared tool it is the caller's own preference,
+never a switch that turns the tool off for everyone. The track is `primary` when on and `bg-input` when off, with a white
thumb in both themes (see Elevation).
`TimePicker` picks a time of day as two `SelectTrigger`s, hours and minutes,
@@ -680,6 +698,11 @@ row (the Agents filter pills) are not a ToggleGroup: they are `Button asChild
variant={active ? 'outline' : 'ghost-muted'} size="sm" shape="pill"` around
each `Link`, with `aria-current="page"` on the current one.
+Filtering a list by kind (a team's shared resources, Share's People) is this
+`xs` group in its muted track, each item `{label} {formatCount(n)}`, beside a
+`SearchInput size="sm"` (`w-full sm:w-56`); no match is `EmptyState size="xs"
+illustration="none"`.
+
### Separator (`ui/separator.tsx`)
A 1px `bg-border` rule, horizontal or `orientation="vertical"`, decorative
@@ -717,7 +740,9 @@ height of its own). `label` puts a muted caption under the ring (a long job:
Convert to wiki, Enable GraphRAG); it is also the ring's name. `size` takes
the Spinner sizes (`default` unless set): `sm` inside a picker (the
MultiSelect popover's list), `lg` for a full panel (a remote device's
-config). Spinners
+config). A role guard shows it while it resolves, then redirects
+silently: `AdminRoute` at `fill="screen"`, `AgentRouteGuard` at
+`fill="parent"` (it renders inside the app shell's scroll column) (see Access and roles). Spinners
inside a control (a busy Button, a picker's `sm` ring) stay `Spinner`.
Nothing to show is `EmptyState`: `size` `default | sm | xs` (128 / 96 / 64px
@@ -838,6 +863,43 @@ variant="destructive"` above the form, not text in or beside the button.
publish validation) is a destructive `Alert` floating at `z-20` on an opaque
`bg-card rounded-xl shadow-md` wrapper that stays until closed; a toast
would truncate each error to one line and dismiss itself.
+- A switch moves at once and flips back when the server refuses, with a
+ destructive toast (an Alert inside a modal). A delete removes its row only
+ after the server confirms; a 403 shows `errors.forbidden`.
+
+### Access and roles
+
+Agents, sources, tools and prompts come to the UI with `access` and
+`allowed_actions`. Gate every control with `can(item, action)`
+(`utils/accessUtils`; `canAgent` in `agents/agentAccess.ts` for agents, which
+also refuses Logs, Schedules and Pin on a draft), never on `ownership`,
+`team_access` or the user id.
+
+- An action the role doesn't allow is not rendered. A menu builds its
+ options from `can()`, and a ⋯ with no options is not drawn; a footer or
+ row left empty goes, with its `Separator`. Never a disabled Save or a
+ disabled menu item for a role.
+- A control stays visible but `disabled` only when it shows state the caller
+ should still see (a switch's on/off, a policy's value). Then the reason is
+ on screen (an Alert or a FormField `hint`), never a bare grey control. A
+ control that can't apply to the caller at all (a tool's "In my chats" when
+ the grant doesn't allow it in their chats) is hidden, not disabled.
+- A view-only form opens with the same fields and `ViewOnlyNotice`
+ (`components/ViewOnlyNotice`: an `Alert role="note"` with `Lock` first and
+ `common.viewOnlyNotice`) as its first child. Where only part of an editable
+ form is locked (a tool's credentials when the owner turned off "Editors can
+ change credentials"), the same notice passes its own `message`. There is no Save; Cancel becomes a lone Close.
+ Fields are `disabled` (a group: `
) : null}
- openSheet('add')}
- >
- {t('settings.sources.addChunk')}
-
+ {canEdit ? (
+ openSheet('add')}
+ >
+ {t('settings.sources.addChunk')}
+
+ ) : null}
);
@@ -664,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/ConfigFields.test.tsx b/frontend/src/components/ConfigFields.test.tsx
new file mode 100644
index 00000000..3965ea0c
--- /dev/null
+++ b/frontend/src/components/ConfigFields.test.tsx
@@ -0,0 +1,64 @@
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+import ConfigFields from './ConfigFields';
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+describe('ConfigFields secrets', () => {
+ 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();
+ });
+
+ const render = async (hasEncryptedCredentials: boolean) => {
+ await act(async () => {
+ root.render(
+ undefined}
+ isEditing
+ hasEncryptedCredentials={hasEncryptedCredentials}
+ />,
+ );
+ });
+ };
+
+ it('a saved secret stays empty, with the saved state as the hint', async () => {
+ await render(true);
+ const input = container.querySelector('input');
+ expect(input?.type).toBe('password');
+ expect(input?.value).toBe('');
+ expect(input?.placeholder).not.toContain('•');
+ expect(container.textContent).toContain('common.savedSecretHint');
+ });
+
+ it('an unsaved secret has no saved hint', async () => {
+ await render(false);
+ expect(container.textContent).not.toContain('common.savedSecretHint');
+ expect(container.querySelector('input')?.placeholder).toBe(
+ 'Your provider key',
+ );
+ });
+});
diff --git a/frontend/src/components/ConfigFields.tsx b/frontend/src/components/ConfigFields.tsx
index ab0d7db2..ac1f798e 100644
--- a/frontend/src/components/ConfigFields.tsx
+++ b/frontend/src/components/ConfigFields.tsx
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
import { ConfigRequirements } from '../modals/types';
import { FormField, type FormFieldProps } from './ui/form-field';
@@ -43,6 +44,7 @@ export default function ConfigFields({
hasEncryptedCredentials = false,
labelSurface,
}: ConfigFieldsProps) {
+ const { t } = useTranslation();
const sortedFields = useMemo(
() =>
Object.entries(configRequirements).sort(
@@ -59,9 +61,10 @@ export default function ConfigFields({
if (!shouldShowField(spec, values)) return null;
const value = values[key] ?? spec.default ?? '';
+ // A saved secret never comes back: the field stays empty and the
+ // hint says it is saved.
const hasEncrypted =
isEditing && spec.secret && hasEncryptedCredentials;
- const placeholder = hasEncrypted ? '••••••••' : '';
const error = errors[key] || undefined;
if (spec.enum) {
@@ -98,6 +101,7 @@ export default function ConfigFields({
key={key}
label={spec.label || key}
required={!!spec.required}
+ hint={hasEncrypted ? t('common.savedSecretHint') : undefined}
error={error}
labelSurface={labelSurface}
>
@@ -122,7 +126,7 @@ export default function ConfigFields({
onChange(key, v);
}
}}
- placeholder={placeholder || spec.description || ''}
+ placeholder={spec.description || ''}
min={spec.type === 'number' ? 1 : undefined}
max={
spec.type === 'number' && key === 'timeout' ? 300 : undefined
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/RoleBadge.test.tsx b/frontend/src/components/RoleBadge.test.tsx
new file mode 100644
index 00000000..71e93cd4
--- /dev/null
+++ b/frontend/src/components/RoleBadge.test.tsx
@@ -0,0 +1,52 @@
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+import RoleBadge from './RoleBadge';
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+describe('RoleBadge', () => {
+ 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();
+ });
+
+ it('shows the role as a neutral Badge with the Users icon first', async () => {
+ await act(async () =>
+ root.render(),
+ );
+ const badge = container.querySelector('[data-slot="badge"]');
+ expect(badge?.dataset.variant ?? badge?.className).toMatch(/neutral|muted/);
+ const icon = badge?.firstElementChild;
+ expect(icon?.getAttribute('class')).toContain('lucide-users');
+ expect(icon?.getAttribute('class')).not.toContain('size-3');
+ expect(badge?.textContent).toBe('teamAccess.editor');
+ });
+
+ it('reads the legacy team fields through roleOf', async () => {
+ await act(async () =>
+ root.render(
+ ,
+ ),
+ );
+ expect(container.textContent).toBe('teamAccess.viewer');
+ });
+
+ it('renders nothing for the caller’s own item', async () => {
+ await act(async () => root.render());
+ expect(container.innerHTML).toBe('');
+ });
+});
diff --git a/frontend/src/components/RoleBadge.tsx b/frontend/src/components/RoleBadge.tsx
new file mode 100644
index 00000000..59d6b3fd
--- /dev/null
+++ b/frontend/src/components/RoleBadge.tsx
@@ -0,0 +1,29 @@
+import { Users } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+
+import { roleOf, type AccessFields } from '../utils/accessUtils';
+import { Badge } from './ui/badge';
+
+/**
+ * The caller's role on a team-shared asset tile (agent, source, tool): a
+ * neutral Badge with the Users icon and Editor or Viewer. Nothing for the
+ * caller's own items.
+ */
+export default function RoleBadge({
+ item,
+ className,
+}: {
+ item: AccessFields;
+ /** Placement only (a tile pins it beside its menu). */
+ className?: string;
+}) {
+ const { t } = useTranslation();
+ const role = roleOf(item);
+ if (role === 'owner') return null;
+ return (
+
+
+ {role === 'editor' ? t('teamAccess.editor') : t('teamAccess.viewer')}
+
+ );
+}
diff --git a/frontend/src/components/ViewOnlyNotice.test.tsx b/frontend/src/components/ViewOnlyNotice.test.tsx
new file mode 100644
index 00000000..d24d4872
--- /dev/null
+++ b/frontend/src/components/ViewOnlyNotice.test.tsx
@@ -0,0 +1,35 @@
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+
+vi.mock('react-i18next', () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+import ViewOnlyNotice from './ViewOnlyNotice';
+
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
+
+describe('ViewOnlyNotice', () => {
+ 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();
+ });
+
+ it('is a quiet default Alert (role="note") with the shared sentence', async () => {
+ await act(async () => root.render());
+ const alert = container.querySelector('[data-slot="alert"]');
+ expect(alert?.getAttribute('role')).toBe('note');
+ expect(alert?.getAttribute('data-variant')).toBe('default');
+ expect(alert?.querySelector('svg')).not.toBeNull();
+ expect(alert?.textContent).toBe('common.viewOnlyNotice');
+ });
+});
diff --git a/frontend/src/components/ViewOnlyNotice.tsx b/frontend/src/components/ViewOnlyNotice.tsx
new file mode 100644
index 00000000..a2709090
--- /dev/null
+++ b/frontend/src/components/ViewOnlyNotice.tsx
@@ -0,0 +1,28 @@
+import { Lock } from 'lucide-react';
+import type React from 'react';
+import { useTranslation } from 'react-i18next';
+
+import { Alert, AlertDescription } from './ui/alert';
+
+/**
+ * The first child of a form the caller's role can only view: the same
+ * fields, disabled, under one quiet note. Every view-only form shows this
+ * one sentence, so a role reads the same everywhere. `message` replaces it
+ * only where part of an editable form is locked (a tool's credentials).
+ */
+export default function ViewOnlyNotice({
+ message,
+ ...props
+}: Omit, 'children' | 'variant'> & {
+ message?: string;
+}) {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {message ?? t('common.viewOnlyNotice')}
+
+
+ );
+}
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}
/>
-
-