Give workflow node tools the owner's toolset whoever runs the workflow

A node's tools resolved as the person running the workflow, so a teammate
or public-link user lost every owner tool they could not use themselves.
They now resolve as the workflow owner, then as the editor who attached
them, like an agent's own tools. The runner stays the invoker, so a
member-mode connection still uses their own account.
This commit is contained in:
arc53-machine committed 2026-09-29 15:09:48 +01:00
1 parent 23e7145cee
commit 5a503e9b3c
4 files changed
+123 -11

No files matched your search

+11 -4
View File
@@ -523,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
# Who an explicit tool-id scope resolves as: the workflow owner for a
# node, so whoever runs it gets the owner's tools. None = ``user``.
self.tool_owner: Optional[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] = {}
@@ -582,17 +585,21 @@ class ToolExecutor:
"""Resolve an explicit tool-id scope — exactly these ids, no defaults.
Used by workflow agent nodes: the node's configured tools (builtin
synthetic ids like Artifact/Code Executor/Read Document, or the user's
``user_tools`` rows) are the node's WHOLE toolset. An unresolvable id
is dropped with a warning rather than failing the node.
synthetic ids like Artifact/Code Executor/Read Document, or the
``user_tools`` rows of ``tool_owner``) are the node's WHOLE toolset.
Rows resolve as the workflow owner, then as the editor who attached
them, never as whoever runs the workflow — the same rule as an agent's
own tools. An unresolvable id is dropped with a warning rather than
failing the node.
"""
if not tool_ids:
return {}
principal = self.tool_owner or self.user
with db_readonly() as conn:
tools_repo = UserToolsRepository(conn)
tools: List[Dict] = []
for tid in tool_ids:
row = resolve_tool_by_id(tid, self.user, user_tools_repo=tools_repo)
row = resolve_tool_by_id(tid, principal, 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:
+4 -1
View File
@@ -20,6 +20,7 @@ class _WorkflowNodeMixin:
api_key: str,
tool_ids: Optional[List[str]] = None,
tool_principals: Optional[Dict[str, str]] = None,
tool_owner: Optional[str] = None,
**kwargs,
):
super().__init__(
@@ -35,7 +36,9 @@ 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.
# The node's tools are the workflow owner's whoever runs it; tools the
# owner can't use resolve as the editor who attached them.
self.tool_executor.tool_owner = tool_owner
self.tool_executor.tool_principals = dict(tool_principals or {})
+11 -4
View File
@@ -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_owner": self._workflow_owner_id(),
"tool_principals": self._node_tool_principals(node_config.tools),
"prompt": node_prompt,
"chat_history": self.agent.chat_history,
@@ -1322,6 +1323,15 @@ class WorkflowEngine:
docs_together = "\n\n".join(docs_together_parts) if docs_together_parts else None
return docs, docs_together
def _workflow_owner_id(self) -> Optional[str]:
"""The workflow's owner, whom node tools and sources run as.
Returns:
The owner's user id, or None when the run has none.
"""
resolve_owner = getattr(self.agent, "_resolve_owner_id", None)
return (resolve_owner() if callable(resolve_owner) else None) or self._resolve_user_id()
def _node_tool_principals(self, tool_ids) -> Dict[str, str]:
"""Node tool id -> the editor to resolve it as, for sponsored tools.
@@ -1373,10 +1383,7 @@ class WorkflowEngine:
if not sources:
return []
ids = sources if isinstance(sources, list) else [sources]
resolve_owner = getattr(self.agent, "_resolve_owner_id", None)
owner = (resolve_owner() if callable(resolve_owner) else None) or (
self._resolve_user_id()
)
owner = self._workflow_owner_id()
if not owner:
logger.warning("Workflow node sources dropped: no owner to authorize.")
return []