mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-13 22:15:10 +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
+163
-77
@@ -8,7 +8,16 @@ from flask_restx import Namespace, Resource, fields
|
||||
from docsgpt.agents.tool_pins import carry_pins_between
|
||||
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
|
||||
@@ -116,13 +125,14 @@ def _mcp_connection(user, config, auth_type, auth_credentials, display_name):
|
||||
return str(row["id"]) if row else None
|
||||
|
||||
|
||||
def _previous_connection(existing_doc, config, user) -> str | None:
|
||||
def _previous_connection(existing_doc, config, owner_id) -> str | None:
|
||||
"""The saved tool's connection, kept only while the server is unchanged.
|
||||
|
||||
An edit that cannot resolve a connection of its own (the default
|
||||
encryption key blocks storing a new secret) must not carry the old
|
||||
one over to a different server (its key would be sent there), to a
|
||||
connection the caller does not own, or to one that signs in another way.
|
||||
connection the tool's owner does not own, or to one that signs in
|
||||
another way.
|
||||
"""
|
||||
from docsgpt.connectors import catalog
|
||||
|
||||
@@ -131,7 +141,7 @@ def _previous_connection(existing_doc, config, user) -> str | None:
|
||||
if not connection_id or not base:
|
||||
return None
|
||||
with db_readonly() as conn:
|
||||
row = ConnectorSessionsRepository(conn).get_for_user(str(connection_id), user)
|
||||
row = ConnectorSessionsRepository(conn).get_for_user(str(connection_id), owner_id)
|
||||
if row is None or catalog.base_url(row.get("server_url")) != base:
|
||||
return None
|
||||
wanted = {"oauth": "mcp_oauth", "none": "none"}.get(config.get("auth_type") or "none", "api_key")
|
||||
@@ -168,12 +178,97 @@ def _mcp_policy_error(config: dict):
|
||||
return None
|
||||
|
||||
|
||||
def _stored_mcp_credentials(existing_doc: dict, owner_id: str) -> dict:
|
||||
"""The secrets a saved MCP tool authenticates with, for reuse on an edit.
|
||||
|
||||
A legacy tool keeps them encrypted in its config; a connection-backed one
|
||||
on its key-based connection, read only while that connection is the
|
||||
owner's and was stored for the tool's own server.
|
||||
|
||||
Args:
|
||||
existing_doc: The stored ``user_tools`` row.
|
||||
owner_id: The tool's owner.
|
||||
|
||||
Returns:
|
||||
The stored credentials, or ``{}`` when there are none to reuse.
|
||||
"""
|
||||
from docsgpt.connectors import catalog, service
|
||||
|
||||
existing_config = existing_doc.get("config") or {}
|
||||
if existing_config.get("encrypted_credentials"):
|
||||
return decrypt_credentials(existing_config["encrypted_credentials"], owner_id)
|
||||
connection_id = existing_doc.get("connection_id")
|
||||
if not connection_id:
|
||||
return {}
|
||||
with db_readonly() as conn:
|
||||
row = ConnectorSessionsRepository(conn).get_for_user(str(connection_id), owner_id)
|
||||
if (
|
||||
row is None
|
||||
or (row.get("auth_kind") or "") != "api_key"
|
||||
or catalog.base_url(row.get("server_url")) != catalog.base_url(existing_config.get("server_url"))
|
||||
):
|
||||
return {}
|
||||
try:
|
||||
return service.get_credentials(row)
|
||||
except service.ConnectionUnavailable:
|
||||
return {}
|
||||
|
||||
|
||||
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 (on the tool, or on its key-based connection) 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)
|
||||
stored = {} if moved else _stored_mcp_credentials(existing_doc, ra.owner_id)
|
||||
if stored:
|
||||
credentials = {**stored, **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"
|
||||
),
|
||||
@@ -207,11 +302,14 @@ class TestMCPServerConfig(Resource):
|
||||
if policy_error is not None:
|
||||
return policy_error
|
||||
|
||||
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"):
|
||||
@@ -244,6 +342,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(
|
||||
@@ -306,16 +406,23 @@ class MCPServerSave(Resource):
|
||||
if policy_error is not None:
|
||||
return policy_error
|
||||
|
||||
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" and not config.get("oauth_task_id"):
|
||||
# No new handshake: the caller signed in to this server
|
||||
# before, so its stored tokens answer the discovery.
|
||||
# Only the owner reaches here for an existing server (see
|
||||
# ``_existing_mcp_context``). No new handshake: they signed in
|
||||
# to this server before, so its stored tokens answer the
|
||||
# discovery, and the save below still needs that connection.
|
||||
try:
|
||||
mcp_tool = MCPTool(config=mcp_config, user_id=user)
|
||||
mcp_tool = MCPTool(config=mcp_config, user_id=owner_id)
|
||||
mcp_tool.discover_tools()
|
||||
actions_metadata = mcp_tool.get_actions_metadata()
|
||||
except Exception:
|
||||
@@ -345,8 +452,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:
|
||||
@@ -355,30 +462,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",
|
||||
@@ -396,9 +483,11 @@ class MCPServerSave(Resource):
|
||||
)
|
||||
|
||||
display_name = data["displayName"]
|
||||
# The connection is the owner's: an editor's save stores the
|
||||
# secret on the owner's account, as the tool runs as its owner.
|
||||
connection_id = _mcp_connection(
|
||||
user, storage_config, auth_type, auth_credentials, display_name,
|
||||
) or _previous_connection(existing_doc, storage_config, user)
|
||||
owner_id, storage_config, auth_type, merged_credentials, display_name,
|
||||
) or _previous_connection(existing_doc, storage_config, owner_id)
|
||||
if auth_type == "oauth" and not connection_id:
|
||||
# A sign-in server's tokens live on its connection. Without one
|
||||
# (it was removed, and a client cached before that answered the
|
||||
@@ -415,56 +504,53 @@ class MCPServerSave(Resource):
|
||||
storage_config.pop("encrypted_credentials", None)
|
||||
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,
|
||||
"connection_id": connection_id,
|
||||
}
|
||||
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,
|
||||
# Fixed values the user set survive a re-save.
|
||||
"actions": carry_pins_between(existing_doc.get("actions"), transformed_actions),
|
||||
"status": status_bool,
|
||||
"connection_id": connection_id,
|
||||
},
|
||||
)
|
||||
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
|
||||
# Fixed values the owner set survive a re-save.
|
||||
fields_out["actions"] = carry_pins_between(existing_doc.get("actions"), transformed_actions)
|
||||
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": carry_pins_between(existing_by_name.get("actions"), transformed_actions),
|
||||
"status": status_bool,
|
||||
"connection_id": connection_id,
|
||||
},
|
||||
fields_out["actions"] = carry_pins_between(
|
||||
existing_by_name.get("actions"), transformed_actions,
|
||||
)
|
||||
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:
|
||||
@@ -486,18 +572,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(
|
||||
@@ -586,6 +663,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(
|
||||
@@ -606,8 +690,10 @@ class MCPAuthStatus(Resource):
|
||||
row = sessions_repo.get(str(tool["connection_id"]))
|
||||
elif auth_type == "oauth" and config.get("server_url"):
|
||||
parsed = urlparse(config["server_url"])
|
||||
# A team-shared server signs in as its owner.
|
||||
row = sessions_repo.get_by_user_provider(
|
||||
user, service.mcp_provider(f"{parsed.scheme}://{parsed.netloc}"),
|
||||
tool.get("user_id") or user,
|
||||
service.mcp_provider(f"{parsed.scheme}://{parsed.netloc}"),
|
||||
)
|
||||
if row is not None:
|
||||
connected = service.normalize_status(row) == service.STATUS_CONNECTED
|
||||
|
||||
@@ -1,9 +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,
|
||||
@@ -15,13 +18,22 @@ 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.tool_pins import merge_submitted_actions, PinChangeRefused
|
||||
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.connectors.catalog import definition_for_tool
|
||||
from docsgpt.connectors.service import account_tool_names
|
||||
from docsgpt.core.settings import settings
|
||||
@@ -31,6 +43,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
|
||||
@@ -171,6 +186,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.
|
||||
|
||||
@@ -293,6 +586,10 @@ class GetTools(Resource):
|
||||
shared_rows = tools_repo.list_by_ids(shared_ids)
|
||||
# "Telegram · Alerts bot" when the owner has several bots.
|
||||
account_names = account_tool_names(conn, [*rows, *shared_rows])
|
||||
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):
|
||||
@@ -315,16 +612,27 @@ class GetTools(Resource):
|
||||
# The secret lives on the connection; the form must not
|
||||
# ask for it again.
|
||||
tool_copy.setdefault("config", {})["has_encrypted_credentials"] = True
|
||||
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
|
||||
if str(row["id"]) in account_names:
|
||||
tool_copy["customName"] = tool_copy["displayName"] = account_names[str(row["id"])]
|
||||
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-
|
||||
@@ -335,6 +643,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"]))
|
||||
@@ -451,9 +760,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,
|
||||
@@ -619,41 +931,48 @@ 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 or bool(tool_doc.get("connection_id"))
|
||||
)
|
||||
@@ -674,6 +993,9 @@ class UpdateTool(Resource):
|
||||
400,
|
||||
)
|
||||
if tool_doc.get("connection_id"):
|
||||
# The connection may back the owner's other tools too,
|
||||
# so its secret stays the owner's to change even when
|
||||
# an editor may change the tool's own credentials.
|
||||
new_config = _update_connection_secrets(
|
||||
conn, user, tool_doc, data["config"], config_requirements,
|
||||
)
|
||||
@@ -684,28 +1006,27 @@ class UpdateTool(Resource):
|
||||
)
|
||||
data = {**data, "config": new_config}
|
||||
|
||||
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)
|
||||
@@ -750,12 +1071,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:
|
||||
@@ -787,11 +1110,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
|
||||
@@ -838,31 +1163,28 @@ class UpdateToolActions(Resource):
|
||||
)
|
||||
try:
|
||||
with db_session() as conn:
|
||||
repo = UserToolsRepository(conn)
|
||||
tool_doc = repo.get_any(data["id"], user)
|
||||
owner = user
|
||||
if tool_doc is None:
|
||||
# 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)
|
||||
tool_doc = repo.get_by_id(data["id"]) if owner else None
|
||||
if not tool_doc or tool_doc.get("user_id") != owner:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}),
|
||||
404,
|
||||
)
|
||||
# ``edit`` covers action on/off, descriptions and approval
|
||||
# (``require_approval``); actions carry no credentials.
|
||||
ra = require(conn, "tool", data["id"], user, "edit")
|
||||
tool_doc = _load_owned_row(conn, ra)
|
||||
if not tool_doc:
|
||||
return make_response(
|
||||
jsonify({"success": False, "message": "Tool not found"}), 404,
|
||||
)
|
||||
# The stored actions are the schema: nothing can be added,
|
||||
# and a fixed value is the owner's to set (an editor pinning
|
||||
# a Telegram chat would redirect the owner's bot).
|
||||
try:
|
||||
actions = merge_submitted_actions(
|
||||
_stored_actions(tool_doc), data["actions"], may_change_pins=owner == user,
|
||||
_stored_actions(tool_doc), data["actions"], may_change_pins=ra.access == "owner",
|
||||
)
|
||||
except PinChangeRefused as err:
|
||||
return make_response(jsonify({"success": False, "message": str(err)}), 403)
|
||||
except ValueError as err:
|
||||
return make_response(jsonify({"success": False, "message": str(err)}), 400)
|
||||
repo.update(str(tool_doc["id"]), owner, {"actions": actions})
|
||||
UserToolsRepository(conn).update(str(tool_doc["id"]), ra.owner_id, {"actions": 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
|
||||
@@ -918,16 +1240,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
|
||||
@@ -967,13 +1292,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)
|
||||
|
||||
Reference in new issue
Block a user