Catch-up sync spanning v3.7.0 → v3.11.0 of the internal repo. Platform - Total tools: 565 → 633 (+68) across 10 plugins (Coolify added) - Tests: 481 → 828 passing New plugin: Coolify (67 tools, Track F.17) - Applications (17): CRUD, lifecycle, logs, env vars - Deployments (5): list/get/cancel/deploy, app history - Servers (8): CRUD, resources, domains, validation - Projects (8), Databases (16, 6 DB types + backups), Services (13) Tool access system (Track F.7 → F.7d) - Scope → category mapping with per-tool `category` + `sensitivity` - Schema v7: `site_tool_toggles(site_id)` + `sites.tool_scope` column - Schema v8: per-site API keys (`api_keys.site_id`) - Plugin-specific access-level presets (WP / WC / Gitea / OpenPanel / Coolify 5-tier) - Credential-requirement notice tailored per plugin and tier - Admin Tools count card on service page - Dropped redundant `write` tier on WP / WP Advanced / WooCommerce (admin-scope tool count = 0 → identical to admin tier) Dashboard - Unified site manage page (Connection / Tool Access / Connect) - /dashboard/keys unified (was /api-keys and /connect) - CSRF interceptor via meta-tag; removed conflicting cookie reader - Tailwind: pre-built CSS (scripts/build-css.sh) replaces CDN Docs - README / DOCKER_README / CLAUDE updated to 633 tools / 10 plugins - CHANGELOG entries for v3.7.0 → v3.11.0 - FastMCP compatibility note updated to 3.x (post-v3.5 upgrade) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
533 lines
18 KiB
Python
533 lines
18 KiB
Python
"""Tool access manager — site-scoped visibility and per-site toggles (F.7c).
|
|
|
|
Provides a central pipeline that filters the set of MCP tools presented for
|
|
a user endpoint based on:
|
|
|
|
1. **Universal scope tiers.** A 3-level system (``read`` / ``write`` /
|
|
``admin``) that works across ALL plugins using the tool's
|
|
``required_scope`` field. For Coolify (which has fine-grained
|
|
``category`` annotations) the legacy category mapping is kept as an
|
|
overlay for the ``custom`` preset.
|
|
2. **Per-site tool toggles.** Site owners may explicitly disable specific
|
|
tools via the ``site_tool_toggles`` table. Only overrides are stored —
|
|
tools without an entry are enabled by default.
|
|
|
|
The ``tool_scope`` value ``"custom"`` is a sentinel meaning "do not apply a
|
|
site-level preset filter" — in that case only the per-tool toggles and the
|
|
key scope are considered.
|
|
|
|
Usage::
|
|
|
|
from core.tool_access import get_tool_access_manager
|
|
|
|
mgr = get_tool_access_manager()
|
|
visible = await mgr.get_visible_tools(
|
|
site_id=site["id"],
|
|
key_scopes=["read"],
|
|
plugin_type="coolify",
|
|
)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from core.tool_registry import ToolDefinition
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ── Universal 3-tier scope system (F.7c) ─────────────────────────────
|
|
# Maps a scope tier to the set of ``required_scope`` values it may access.
|
|
# Works for ALL plugins because every tool has ``required_scope``.
|
|
UNIVERSAL_SCOPE_TIERS: dict[str, set[str]] = {
|
|
"read": {"read"},
|
|
"write": {"read", "write"},
|
|
"admin": {"read", "write", "admin"},
|
|
}
|
|
|
|
# ── Legacy Coolify category mapping (kept for ``custom`` overlay) ─────
|
|
# Mapping from scope → set of tool categories that scope may see.
|
|
SCOPE_TO_CATEGORIES: dict[str, set[str]] = {
|
|
"read": {"read"},
|
|
"read:sensitive": {"read", "read_sensitive", "backup"},
|
|
"deploy": {"read", "lifecycle"},
|
|
"write": {"read", "lifecycle", "crud", "env"},
|
|
"admin": {
|
|
"read",
|
|
"read_sensitive",
|
|
"lifecycle",
|
|
"crud",
|
|
"env",
|
|
"backup",
|
|
"system",
|
|
},
|
|
}
|
|
|
|
# All known Coolify categories.
|
|
KNOWN_CATEGORIES: set[str] = {
|
|
"read",
|
|
"read_sensitive",
|
|
"lifecycle",
|
|
"crud",
|
|
"env",
|
|
"backup",
|
|
"system",
|
|
}
|
|
|
|
# Sentinel meaning "no site-level preset filter — use per-tool toggles only".
|
|
SCOPE_CUSTOM = "custom"
|
|
|
|
# Plugins that have fine-grained category annotations.
|
|
_CATEGORY_PLUGINS: set[str] = {"coolify"}
|
|
|
|
|
|
def scopes_to_categories(scopes: list[str]) -> set[str]:
|
|
"""Return the union of categories allowed by the given scope list.
|
|
|
|
Args:
|
|
scopes: List of scope strings as presented on the API key / token.
|
|
|
|
Returns:
|
|
Set of category names the scopes collectively allow.
|
|
"""
|
|
allowed: set[str] = set()
|
|
for scope in scopes:
|
|
allowed |= SCOPE_TO_CATEGORIES.get(scope.strip(), set())
|
|
return allowed
|
|
|
|
|
|
def _scopes_to_required(scopes: list[str]) -> set[str]:
|
|
"""Return the union of ``required_scope`` values allowed by universal tiers."""
|
|
allowed: set[str] = set()
|
|
for scope in scopes:
|
|
allowed |= UNIVERSAL_SCOPE_TIERS.get(scope.strip(), set())
|
|
return allowed
|
|
|
|
|
|
def get_scope_presets_for_plugin(plugin_type: str) -> list[dict[str, str]]:
|
|
"""Return the appropriate scope presets for a plugin type (F.7d).
|
|
|
|
Each preset is a dict with ``value`` (the canonical scope key persisted
|
|
to ``sites.tool_scope``), ``label`` / ``label_fa`` (button title), and
|
|
``hint`` / ``hint_fa`` (one-line description shown under the title).
|
|
|
|
The valid scope values are constrained by ``_VALID_TOOL_SCOPES`` in
|
|
``core.dashboard.routes`` — currently:
|
|
``{"read", "read:sensitive", "deploy", "write", "admin", "custom"}``.
|
|
"""
|
|
custom = {
|
|
"value": "custom",
|
|
"label": "Custom",
|
|
"label_fa": "سفارشی",
|
|
"hint": "Per-tool toggles",
|
|
"hint_fa": "هر ابزار جداگانه",
|
|
}
|
|
|
|
if plugin_type == "coolify":
|
|
# 5 fine-grained Coolify tiers + custom (matches SCOPE_TO_CATEGORIES).
|
|
return [
|
|
{
|
|
"value": "read",
|
|
"label": "Read",
|
|
"label_fa": "خواندن",
|
|
"hint": "List/inspect resources",
|
|
"hint_fa": "مشاهده و فهرست منابع",
|
|
},
|
|
{
|
|
"value": "read:sensitive",
|
|
"label": "Read + Secrets",
|
|
"label_fa": "خواندن + اسرار",
|
|
"hint": "Includes env vars and backups",
|
|
"hint_fa": "شامل متغیرهای محیطی و بکاپ",
|
|
},
|
|
{
|
|
"value": "deploy",
|
|
"label": "Deploy",
|
|
"label_fa": "استقرار",
|
|
"hint": "Read + lifecycle (start/stop/restart)",
|
|
"hint_fa": "مشاهده + راهاندازی/توقف/ریستارت",
|
|
},
|
|
{
|
|
"value": "write",
|
|
"label": "Write",
|
|
"label_fa": "نوشتن",
|
|
"hint": "Read + lifecycle + CRUD + env",
|
|
"hint_fa": "مشاهده + لایفسایکل + CRUD + env",
|
|
},
|
|
{
|
|
"value": "admin",
|
|
"label": "Root",
|
|
"label_fa": "روت",
|
|
"hint": "Everything including system commands",
|
|
"hint_fa": "همه چیز شامل دستورات سیستم",
|
|
},
|
|
custom,
|
|
]
|
|
|
|
if plugin_type == "openpanel":
|
|
return [
|
|
{
|
|
"value": "read",
|
|
"label": "Read",
|
|
"label_fa": "خواندن",
|
|
"hint": "Export current project only",
|
|
"hint_fa": "خروجی پروژه فعلی",
|
|
},
|
|
{
|
|
"value": "write",
|
|
"label": "Write",
|
|
"label_fa": "نوشتن",
|
|
"hint": "Default ingestion (track events)",
|
|
"hint_fa": "ارسال رویداد (ingestion)",
|
|
},
|
|
{
|
|
"value": "admin",
|
|
"label": "Root",
|
|
"label_fa": "روت",
|
|
"hint": "Export any project",
|
|
"hint_fa": "خروجی هر پروژه",
|
|
},
|
|
custom,
|
|
]
|
|
|
|
if plugin_type == "woocommerce":
|
|
# WooCommerce has no admin-scope tools (read=14, write=14, admin=0),
|
|
# so Write and "Read + Write" tiers are identical. Present a single
|
|
# full-access tier instead of two duplicates.
|
|
return [
|
|
{
|
|
"value": "read",
|
|
"label": "Read Only",
|
|
"label_fa": "فقط خواندن",
|
|
"hint": "Browse products, orders, customers",
|
|
"hint_fa": "مشاهده محصولات، سفارشها و مشتریان",
|
|
},
|
|
{
|
|
"value": "admin",
|
|
"label": "Read + Write",
|
|
"label_fa": "خواندن + نوشتن",
|
|
"hint": "Full store management (all 28 tools)",
|
|
"hint_fa": "مدیریت کامل فروشگاه (همه ۲۸ ابزار)",
|
|
},
|
|
custom,
|
|
]
|
|
|
|
if plugin_type in {"wordpress", "wordpress_advanced"}:
|
|
# WordPress has no admin-scope tools (read=27, write=40, admin=0).
|
|
# SEO + plugin/theme tools require the Airano MCP SEO Bridge plugin
|
|
# to be installed on the WP site itself. Present 2 tiers + custom.
|
|
return [
|
|
{
|
|
"value": "read",
|
|
"label": "Read Only",
|
|
"label_fa": "فقط خواندن",
|
|
"hint": "View posts, pages, media",
|
|
"hint_fa": "مشاهده نوشتهها، صفحات و رسانه",
|
|
},
|
|
{
|
|
"value": "admin",
|
|
"label": "Full Access",
|
|
"label_fa": "دسترسی کامل",
|
|
"hint": "All tools (CRUD + SEO via add-on)",
|
|
"hint_fa": "همه ابزارها (CRUD و SEO با افزونه)",
|
|
},
|
|
custom,
|
|
]
|
|
|
|
if plugin_type == "gitea":
|
|
return [
|
|
{
|
|
"value": "read",
|
|
"label": "Read",
|
|
"label_fa": "خواندن",
|
|
"hint": "Browse repos, issues, users",
|
|
"hint_fa": "مشاهده مخازن، ایشوها، کاربران",
|
|
},
|
|
{
|
|
"value": "write",
|
|
"label": "Read + Write",
|
|
"label_fa": "خواندن + نوشتن",
|
|
"hint": "Create issues, PRs, branches",
|
|
"hint_fa": "ایجاد ایشو، PR و شاخه",
|
|
},
|
|
{
|
|
"value": "admin",
|
|
"label": "Admin",
|
|
"label_fa": "مدیر",
|
|
"hint": "Repo + org + user admin",
|
|
"hint_fa": "مدیریت مخزن، سازمان و کاربر",
|
|
},
|
|
custom,
|
|
]
|
|
|
|
# Universal default for all other plugins.
|
|
return [
|
|
{
|
|
"value": "read",
|
|
"label": "Read",
|
|
"label_fa": "فقط خواندن",
|
|
"hint": "View only",
|
|
"hint_fa": "فقط مشاهده",
|
|
},
|
|
{
|
|
"value": "write",
|
|
"label": "Read + Write",
|
|
"label_fa": "خواندن + نوشتن",
|
|
"hint": "CRUD ops",
|
|
"hint_fa": "عملیات CRUD",
|
|
},
|
|
{
|
|
"value": "admin",
|
|
"label": "Full Access",
|
|
"label_fa": "دسترسی کامل",
|
|
"hint": "All tools",
|
|
"hint_fa": "همه ابزارها",
|
|
},
|
|
custom,
|
|
]
|
|
|
|
|
|
class ToolAccessManager:
|
|
"""Central manager for scope-based visibility and per-site tool toggles."""
|
|
|
|
def apply_scope_filter(
|
|
self,
|
|
tools: list[ToolDefinition],
|
|
scopes: list[str],
|
|
plugin_type: str | None = None,
|
|
) -> list[ToolDefinition]:
|
|
"""Drop tools not allowed by the presented scopes.
|
|
|
|
For plugins with category annotations (Coolify) the legacy
|
|
category-based filter is used. For all other plugins the universal
|
|
3-tier filter based on ``required_scope`` is applied.
|
|
|
|
Args:
|
|
tools: Candidate tool list.
|
|
scopes: Scopes presented on the API key (or a single-element list
|
|
containing a site's ``tool_scope`` preset).
|
|
plugin_type: Plugin type hint. When provided and the plugin is
|
|
NOT in ``_CATEGORY_PLUGINS``, the universal tier filter is
|
|
used.
|
|
|
|
Returns:
|
|
Filtered tool list.
|
|
"""
|
|
# Try universal tiers first (works for all plugins)
|
|
allowed_scopes = _scopes_to_required(scopes)
|
|
|
|
if allowed_scopes and (plugin_type is None or plugin_type not in _CATEGORY_PLUGINS):
|
|
# Universal filter: match tool.required_scope against allowed tiers
|
|
return [t for t in tools if t.required_scope in allowed_scopes]
|
|
|
|
# Fallback: legacy category-based filter for Coolify / custom scopes
|
|
allowed = scopes_to_categories(scopes)
|
|
if not allowed:
|
|
return [t for t in tools if t.category not in KNOWN_CATEGORIES]
|
|
|
|
result: list[ToolDefinition] = []
|
|
for tool in tools:
|
|
if tool.category not in KNOWN_CATEGORIES:
|
|
result.append(tool)
|
|
continue
|
|
if tool.category in allowed:
|
|
result.append(tool)
|
|
return result
|
|
|
|
async def apply_site_toggles(
|
|
self,
|
|
tools: list[ToolDefinition],
|
|
site_id: str,
|
|
) -> list[ToolDefinition]:
|
|
"""Drop tools the site owner has explicitly disabled.
|
|
|
|
Args:
|
|
tools: Candidate tool list.
|
|
site_id: Site UUID.
|
|
|
|
Returns:
|
|
Filtered tool list.
|
|
"""
|
|
from core.database import get_database
|
|
|
|
try:
|
|
db = get_database()
|
|
except RuntimeError:
|
|
return tools
|
|
|
|
toggles = await db.get_site_tool_toggles(site_id)
|
|
if not toggles:
|
|
return tools
|
|
return [t for t in tools if toggles.get(t.name, True)]
|
|
|
|
async def get_visible_tools(
|
|
self,
|
|
site_id: str,
|
|
key_scopes: list[str],
|
|
plugin_type: str,
|
|
) -> list[ToolDefinition]:
|
|
"""Return the visible tool list for a site on a given plugin.
|
|
|
|
Pipeline:
|
|
1. ``ToolRegistry.get_by_plugin_type``
|
|
2. Key-scope filter (API key's declared scopes)
|
|
3. Site-scope filter (site's stored ``tool_scope`` preset,
|
|
skipped when it is ``custom``)
|
|
4. Per-site toggle filter (``site_tool_toggles``)
|
|
|
|
Args:
|
|
site_id: Site UUID (the MCP endpoint alias resolves to this).
|
|
key_scopes: Scopes presented on the API key / token.
|
|
plugin_type: Plugin type (e.g. ``coolify``).
|
|
|
|
Returns:
|
|
List of visible ``ToolDefinition`` objects.
|
|
"""
|
|
from core.database import get_database
|
|
from core.tool_registry import get_tool_registry
|
|
|
|
registry = get_tool_registry()
|
|
tools = registry.get_by_plugin_type(plugin_type)
|
|
|
|
tools = self.apply_scope_filter(tools, key_scopes, plugin_type=plugin_type)
|
|
|
|
try:
|
|
db = get_database()
|
|
site_scope = await db.get_site_tool_scope(site_id)
|
|
except RuntimeError:
|
|
site_scope = "admin"
|
|
|
|
if site_scope and site_scope != SCOPE_CUSTOM:
|
|
tools = self.apply_scope_filter(tools, [site_scope], plugin_type=plugin_type)
|
|
|
|
tools = await self.apply_site_toggles(tools, site_id)
|
|
return tools
|
|
|
|
async def toggle_tool(
|
|
self,
|
|
site_id: str,
|
|
tool_name: str,
|
|
enabled: bool,
|
|
reason: str | None = None,
|
|
) -> None:
|
|
"""Enable or disable a single tool for a site.
|
|
|
|
Args:
|
|
site_id: Site UUID.
|
|
tool_name: Fully-qualified tool name.
|
|
enabled: True to enable, False to disable.
|
|
reason: Optional note.
|
|
"""
|
|
from core.database import get_database
|
|
|
|
db = get_database()
|
|
await db.set_site_tool_toggle(site_id, tool_name, enabled, reason)
|
|
logger.info(
|
|
"site %s toggled %s → %s",
|
|
site_id,
|
|
tool_name,
|
|
"enabled" if enabled else "disabled",
|
|
)
|
|
|
|
async def bulk_toggle_by_scope(
|
|
self,
|
|
site_id: str,
|
|
scope_name: str,
|
|
enabled: bool,
|
|
plugin_type: str | None = None,
|
|
) -> int:
|
|
"""Toggle every tool whose category belongs to the given scope.
|
|
|
|
Only the *exclusive* category set of the scope is affected — i.e.
|
|
the categories explicitly listed under ``SCOPE_TO_CATEGORIES[scope_name]``.
|
|
Tools outside those categories are left unchanged.
|
|
|
|
Args:
|
|
site_id: Site UUID.
|
|
scope_name: Scope key (``"read"``, ``"deploy"``, ...).
|
|
enabled: True to enable, False to disable.
|
|
plugin_type: Optional filter — only affect tools from this plugin.
|
|
When ``None`` every plugin's tools in that category are touched.
|
|
|
|
Returns:
|
|
Number of tools affected.
|
|
"""
|
|
from core.database import get_database
|
|
from core.tool_registry import get_tool_registry
|
|
|
|
categories = SCOPE_TO_CATEGORIES.get(scope_name)
|
|
if categories is None:
|
|
raise ValueError(f"Unknown scope '{scope_name}'")
|
|
|
|
registry = get_tool_registry()
|
|
candidates = registry.get_all()
|
|
if plugin_type is not None:
|
|
candidates = [t for t in candidates if t.plugin_type == plugin_type]
|
|
affected = [t.name for t in candidates if t.category in categories]
|
|
|
|
if not affected:
|
|
return 0
|
|
|
|
db = get_database()
|
|
await db.bulk_set_site_tool_toggles(
|
|
site_id,
|
|
[(name, enabled) for name in affected],
|
|
reason=f"bulk:{scope_name}",
|
|
)
|
|
return len(affected)
|
|
|
|
async def list_tools_for_site(
|
|
self,
|
|
site_id: str,
|
|
plugin_type: str,
|
|
) -> list[dict[str, Any]]:
|
|
"""Return every tool for a plugin, annotated with per-site toggle state.
|
|
|
|
Used by the dashboard API to present the per-site management view.
|
|
Does not apply scope filters — the UI decides what to show.
|
|
|
|
Args:
|
|
site_id: Site UUID.
|
|
plugin_type: Plugin type.
|
|
|
|
Returns:
|
|
List of dicts with tool metadata + ``enabled`` flag.
|
|
"""
|
|
from core.database import get_database
|
|
from core.tool_registry import get_tool_registry
|
|
|
|
try:
|
|
db = get_database()
|
|
toggles = await db.get_site_tool_toggles(site_id)
|
|
except RuntimeError:
|
|
toggles = {}
|
|
|
|
registry = get_tool_registry()
|
|
tools = registry.get_by_plugin_type(plugin_type)
|
|
return [
|
|
{
|
|
"name": t.name,
|
|
"description": t.description,
|
|
"plugin_type": t.plugin_type,
|
|
"category": t.category,
|
|
"sensitivity": t.sensitivity,
|
|
"required_scope": t.required_scope,
|
|
"enabled": toggles.get(t.name, True),
|
|
}
|
|
for t in tools
|
|
]
|
|
|
|
|
|
# Singleton
|
|
_manager: ToolAccessManager | None = None
|
|
|
|
|
|
def get_tool_access_manager() -> ToolAccessManager:
|
|
"""Return the singleton :class:`ToolAccessManager`."""
|
|
global _manager
|
|
if _manager is None:
|
|
_manager = ToolAccessManager()
|
|
return _manager
|