mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 22:17:00 +00:00
Merge main (roles and access revamp) into connectors
Main's access model (team editors and viewers, edit_credentials, owner-only OAuth servers, per-user tool preferences, resource sponsors) now applies to connection-backed tools and sources. Our migrations are renumbered to 0040_connections and 0041_connection_account_name, after main's 0038_resource_access_settings and 0039_resource_sponsors. Where the two sides met: MCP tools save and load their connections as the tool owner, editors may add a key (a new connection on the owner's account) but never rewrite an existing connection's secret, fixed values stay owner-only, and a member's own connection is used in member mode.
This commit is contained in:
commit
a1789d2ab4
167 files changed
+16909
-2126
No files matched your search
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import copy
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
@@ -30,6 +31,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)."""
|
||||
@@ -483,6 +523,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
|
||||
@@ -550,6 +593,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
|
||||
@@ -571,6 +616,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
|
||||
@@ -588,6 +642,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.
|
||||
@@ -604,6 +659,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.
|
||||
|
||||
@@ -1497,7 +1578,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)
|
||||
)
|
||||
@@ -1695,6 +1776,7 @@ class ToolExecutor:
|
||||
return cached
|
||||
|
||||
tm = ToolManager(config={})
|
||||
load_user = self.user
|
||||
|
||||
if tool_data["name"] == "api_tool":
|
||||
action_config = tool_data["config"]["actions"][action_name]
|
||||
@@ -1721,6 +1803,13 @@ class ToolExecutor:
|
||||
# agentless path where the tool row carries no user_id.
|
||||
tool_owner = tool_data.get("user_id") or self.user
|
||||
resolved = self._resolve_connection(tool_data)
|
||||
if tool_data["name"] == "mcp_tool":
|
||||
# MCP OAuth tokens are read as the account the call runs on:
|
||||
# the resolved connection's owner (a member's own in member
|
||||
# mode), else the tool owner, so a shared server without a
|
||||
# connection runs on the owner's sign-in like every other
|
||||
# credential.
|
||||
load_user = ((resolved.row or {}).get("user_id") if resolved else None) or tool_owner
|
||||
if resolved is not None:
|
||||
self._apply_connection(tool_data, tool_id, tool_config, resolved)
|
||||
elif tool_config.get("encrypted_credentials") and tool_owner:
|
||||
@@ -1778,7 +1867,7 @@ class ToolExecutor:
|
||||
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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user