mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 10:17:55 +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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user