mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-18 10:14:24 +00:00
MCP rollback for connectors
This commit is contained in:
1 parent
0622883ce4
commit
68c1e12b6f
29 files changed
+650
-160
No files matched your search
@@ -25,7 +25,7 @@ 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,
|
||||
@@ -221,17 +221,17 @@ def _denied(err: AccessDenied):
|
||||
return make_response(jsonify({"success": False, "message": err.message}), err.status)
|
||||
|
||||
|
||||
def _tool_attachable(conn, tool_id: str, owner_id: str, caller: str) -> bool:
|
||||
"""Whether ``caller`` may attach ``tool_id`` to an agent owned by ``owner_id``.
|
||||
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 tool must be the
|
||||
agent owner's (it runs with the owner's credentials) or reach the caller
|
||||
with ``use_in_own``.
|
||||
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.
|
||||
owner_id: The agent's owner.
|
||||
caller: The user making the change.
|
||||
|
||||
Returns:
|
||||
@@ -240,29 +240,26 @@ def _tool_attachable(conn, tool_id: str, owner_id: str, caller: str) -> bool:
|
||||
tid = str(tool_id)
|
||||
if is_synthesized_tool_id(tid):
|
||||
return True
|
||||
if UserToolsRepository(conn).get_any(tid, owner_id) is not None:
|
||||
return True
|
||||
ra = resolve(conn, "tool", tid, caller)
|
||||
return ra is not None and ra.can("use_in_own")
|
||||
|
||||
|
||||
def _ref_attachable(conn, resource_type: str, resource_id: str, owner_id: str, caller: str) -> bool:
|
||||
"""Whether a source/prompt may be referenced by an agent owned by ``owner_id``.
|
||||
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.
|
||||
owner_id: The agent's owner.
|
||||
caller: The user making the change.
|
||||
|
||||
Returns:
|
||||
True when the agent owner owns it or the caller can ``use`` it.
|
||||
True when the caller owns it or a team grant reaches them.
|
||||
"""
|
||||
if not resource_id:
|
||||
return True
|
||||
if owner_id != caller and can_access(conn, resource_type, str(resource_id), owner_id):
|
||||
return True
|
||||
return can_access(conn, resource_type, str(resource_id), caller)
|
||||
|
||||
|
||||
@@ -825,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
|
||||
@@ -854,13 +851,17 @@ class CreateAgent(Resource):
|
||||
# Tools run with the agent owner's credentials: attach only your
|
||||
# own, or ones a team lets you use in your agents.
|
||||
for tid in data.get("tools") or []:
|
||||
if not _tool_attachable(conn, tid, user, user):
|
||||
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
|
||||
@@ -1087,7 +1088,7 @@ class UpdateAgent(Resource):
|
||||
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
|
||||
@@ -1102,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
|
||||
@@ -1130,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:
|
||||
@@ -1250,13 +1251,25 @@ class UpdateAgent(Resource):
|
||||
)
|
||||
if wf_err:
|
||||
return wf_err
|
||||
# Only the owner may point the agent at a different
|
||||
# workflow: 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.
|
||||
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":
|
||||
@@ -1358,7 +1371,7 @@ class UpdateAgent(Resource):
|
||||
for sid in referenced_sources:
|
||||
if str(sid) in existing_source_refs:
|
||||
continue
|
||||
if not _ref_attachable(conn, "source", sid, owner_id, user):
|
||||
if not _ref_attachable(conn, "source", sid, user):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Source not accessible"}), 403
|
||||
)
|
||||
@@ -1366,16 +1379,17 @@ class UpdateAgent(Resource):
|
||||
if (
|
||||
new_prompt_id
|
||||
and str(new_prompt_id) != str(existing_agent.get("prompt_id") or "")
|
||||
and not _ref_attachable(conn, "prompt", new_prompt_id, owner_id, user)
|
||||
and not _ref_attachable(conn, "prompt", new_prompt_id, user)
|
||||
):
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Prompt not accessible"}), 403
|
||||
)
|
||||
# Tools run with the OWNER's credentials (the agent-key path
|
||||
# resolves and decrypts them as the owner), so a newly attached
|
||||
# tool must be the owner's or reach the caller with
|
||||
# ``use_in_own``. Tools already on the agent stay. Builtin
|
||||
# synthetic ids belong to no one and are always allowed.
|
||||
# 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 [])
|
||||
@@ -1383,7 +1397,7 @@ class UpdateAgent(Resource):
|
||||
for tid in update_fields["tools"] or []:
|
||||
if str(tid) in existing_tools:
|
||||
continue
|
||||
if not _tool_attachable(conn, tid, owner_id, user):
|
||||
if not _tool_attachable(conn, tid, user):
|
||||
return make_response(
|
||||
jsonify(
|
||||
{"success": False, "message": "Tool not accessible"}
|
||||
|
||||
@@ -27,7 +27,7 @@ from typing import Iterable, Optional
|
||||
|
||||
from sqlalchemy import Connection, text
|
||||
|
||||
from docsgpt.storage.db.base_repository import looks_like_uuid
|
||||
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
|
||||
@@ -73,7 +73,7 @@ ACTIONS: dict[str, dict[str, str]] = {
|
||||
"use": "viewer", # see it and run it inside the owner's shared agents
|
||||
"use_in_own": "viewer", # add it to my own agents and chats
|
||||
"edit": "editor", # name, action descriptions, parameters, approval
|
||||
"edit_credentials": "editor", # secrets, URL, auth, reconnect OAuth (write-only)
|
||||
"edit_credentials": "editor", # secrets, URL, auth (write-only); OAuth servers stay owner-only
|
||||
"share": "owner",
|
||||
"delete": "owner",
|
||||
"manage_settings": "owner",
|
||||
@@ -186,14 +186,18 @@ def public_settings(resource_type: str, settings: Optional[dict]) -> list[dict]:
|
||||
|
||||
def settings_for(conn: Connection, resource_type: str, resource_id: str) -> dict[str, bool]:
|
||||
"""The resource's switches, defaults filled in."""
|
||||
return settings_many(conn, resource_type, [resource_id])[resource_id]
|
||||
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."""
|
||||
ids = [str(r) for r in resource_ids]
|
||||
"""``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:
|
||||
@@ -298,7 +302,10 @@ def resolve(
|
||||
repo_cls = _REPO_FOR_TYPE.get(resource_type)
|
||||
if repo_cls is None or not resource_id or not user_id:
|
||||
return None
|
||||
owned = repo_cls(conn).get_any(str(resource_id), user_id)
|
||||
# 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))
|
||||
|
||||
@@ -13,6 +13,7 @@ from docsgpt.api.user.tools.routes import (
|
||||
_CREDENTIALS_FOR_NEW_SERVER,
|
||||
_MCP_CREDENTIAL_AUTH_TYPES,
|
||||
_mcp_host_changed,
|
||||
check_oauth_mcp_owner_only,
|
||||
denied_response,
|
||||
transform_actions,
|
||||
)
|
||||
@@ -83,9 +84,6 @@ def _validate_mcp_server_url(config: dict) -> None:
|
||||
raise ValueError(f"Invalid server URL: {exc}") from exc
|
||||
|
||||
|
||||
_ONLY_OWNER_RECONNECTS = "Only the owner can reconnect this account"
|
||||
|
||||
|
||||
def _existing_mcp_context(tool_id, user, config):
|
||||
"""Resolve the stored MCP tool a test/save refers to, and its credentials.
|
||||
|
||||
@@ -93,14 +91,16 @@ def _existing_mcp_context(tool_id, user, config):
|
||||
the caller needs ``edit_credentials`` on that tool and everything runs as
|
||||
its owner. Stored secrets are write-only, so an empty secret field reuses
|
||||
the stored one while the host is unchanged; a new host never inherits them.
|
||||
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) or can't change
|
||||
its credentials (403).
|
||||
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:
|
||||
@@ -113,6 +113,7 @@ def _existing_mcp_context(tool_id, user, config):
|
||||
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_host_changed(config, existing_config)
|
||||
auth_type = config.get("auth_type", "none")
|
||||
new_secret_keys = set(auth_credentials) - {"api_key_header"}
|
||||
@@ -170,13 +171,7 @@ class TestMCPServerConfig(Resource):
|
||||
ctx = _existing_mcp_context(data.get("id"), user, config)
|
||||
if not isinstance(ctx, tuple):
|
||||
return ctx
|
||||
_existing_doc, owner_id, is_owner, _moved, auth_credentials = ctx
|
||||
if not is_owner and config.get("auth_type") == "oauth":
|
||||
# An OAuth flow would store tokens under the editor's account
|
||||
# (and its popup event goes to that account), not the owner's.
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": _ONLY_OWNER_RECONNECTS}), 403
|
||||
)
|
||||
_existing_doc, owner_id, _is_owner, _moved, auth_credentials = ctx
|
||||
test_config = config.copy()
|
||||
test_config["auth_credentials"] = auth_credentials
|
||||
|
||||
@@ -279,50 +274,16 @@ class MCPServerSave(Resource):
|
||||
ctx = _existing_mcp_context(data.get("id"), user, config)
|
||||
if not isinstance(ctx, tuple):
|
||||
return ctx
|
||||
existing_doc, owner_id, is_owner, moved, merged_credentials = ctx
|
||||
existing_config = (existing_doc or {}).get("config") or {}
|
||||
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"] = merged_credentials
|
||||
keep_actions = False
|
||||
|
||||
if auth_type == "oauth":
|
||||
if config.get("oauth_task_id"):
|
||||
if not is_owner:
|
||||
# The OAuth flow stores tokens under the account that
|
||||
# ran it; reconnecting as the owner is owner-only.
|
||||
return make_response(
|
||||
jsonify({
|
||||
"success": False,
|
||||
"message": _ONLY_OWNER_RECONNECTS,
|
||||
}),
|
||||
403,
|
||||
)
|
||||
redis_client = get_redis_instance()
|
||||
manager = MCPOAuthManager(redis_client)
|
||||
result = manager.get_oauth_status(
|
||||
config["oauth_task_id"], user
|
||||
)
|
||||
if not result.get("status") == "completed":
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": "OAuth failed or not completed. Please try authorizing again.",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
actions_metadata = result.get("tools", [])
|
||||
elif (
|
||||
existing_doc is not None
|
||||
and not moved
|
||||
and existing_config.get("auth_type") == "oauth"
|
||||
):
|
||||
# Editing an already-connected server: keep its tools.
|
||||
actions_metadata = existing_doc.get("actions") or []
|
||||
keep_actions = True
|
||||
else:
|
||||
# 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(
|
||||
{
|
||||
@@ -332,6 +293,22 @@ class MCPServerSave(Resource):
|
||||
),
|
||||
400,
|
||||
)
|
||||
redis_client = get_redis_instance()
|
||||
manager = MCPOAuthManager(redis_client)
|
||||
result = manager.get_oauth_status(
|
||||
config["oauth_task_id"], user
|
||||
)
|
||||
if not result.get("status") == "completed":
|
||||
return make_response(
|
||||
jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": "OAuth failed or not completed. Please try authorizing again.",
|
||||
}
|
||||
),
|
||||
400,
|
||||
)
|
||||
actions_metadata = result.get("tools", [])
|
||||
elif auth_type == "none" or merged_credentials:
|
||||
mcp_tool = MCPTool(config=mcp_config, user_id=owner_id)
|
||||
mcp_tool.discover_tools()
|
||||
@@ -356,8 +333,7 @@ class MCPServerSave(Resource):
|
||||
"redirect_uri",
|
||||
]:
|
||||
storage_config.pop(field, None)
|
||||
# Kept actions already carry the owner's on/off and approval flags.
|
||||
transformed_actions = actions_metadata if keep_actions else transform_actions(actions_metadata)
|
||||
transformed_actions = transform_actions(actions_metadata)
|
||||
|
||||
display_name = data["displayName"]
|
||||
description = f"MCP Server: {storage_config.get('server_url', 'Unknown')}"
|
||||
|
||||
@@ -350,6 +350,37 @@ def _mcp_host_changed(new_config: dict, existing_config: dict) -> bool:
|
||||
return bool(old_url) and url_host(old_url) != url_host((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.
|
||||
|
||||
@@ -776,6 +807,8 @@ class UpdateTool(Resource):
|
||||
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
|
||||
):
|
||||
@@ -873,6 +906,7 @@ class UpdateToolConfig(Resource):
|
||||
|
||||
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:
|
||||
|
||||
@@ -12,6 +12,7 @@ from docsgpt.agents.workflows.cel_evaluator import (
|
||||
)
|
||||
from docsgpt.api.user.resource_access import (
|
||||
AccessDenied,
|
||||
can_use_ref,
|
||||
resolve,
|
||||
sponsor_details,
|
||||
sponsors_after_save,
|
||||
@@ -124,6 +125,40 @@ def _node_refs(nodes: List[Dict]) -> List[Tuple[str, str]]:
|
||||
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(
|
||||
@@ -639,6 +674,16 @@ class WorkflowDetail(Resource):
|
||||
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(
|
||||
|
||||
Reference in new issue
Block a user