feat(F.7+F.17): v3.11.0 — Coolify plugin (67 tools) + tool access overhaul
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>
This commit is contained in:
@@ -1,23 +1,17 @@
|
||||
"""Tool access manager — site-scoped visibility and per-site toggles (F.7b).
|
||||
"""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. **Scope → category mapping.** Every ``ToolDefinition`` carries a
|
||||
``category`` field (e.g. ``read``, ``lifecycle``, ``crud``, ``system``).
|
||||
An API key's declared scopes **and** the site's stored ``tool_scope``
|
||||
preset each map to a set of allowed categories via
|
||||
:data:`SCOPE_TO_CATEGORIES`. A tool is visible only if its category is in
|
||||
the intersection — the narrower of the two layers wins.
|
||||
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 via the ``site_tool_toggles`` table. Only overrides are stored —
|
||||
tools without an entry are enabled by default.
|
||||
|
||||
Tools whose ``category`` is not in :data:`KNOWN_CATEGORIES` are **always
|
||||
visible** (backward compatibility — legacy plugins that have not been
|
||||
annotated yet default to ``category="read"``, which belongs to the ``read``
|
||||
scope set anyway, but an unknown value would be preserved).
|
||||
|
||||
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.
|
||||
@@ -44,9 +38,17 @@ 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.
|
||||
# Used for BOTH API-key scopes and per-site ``tool_scope`` presets.
|
||||
# Scopes are additive: presenting multiple scopes yields the union.
|
||||
SCOPE_TO_CATEGORIES: dict[str, set[str]] = {
|
||||
"read": {"read"},
|
||||
"read:sensitive": {"read", "read_sensitive", "backup"},
|
||||
@@ -63,8 +65,7 @@ SCOPE_TO_CATEGORIES: dict[str, set[str]] = {
|
||||
},
|
||||
}
|
||||
|
||||
# All known categories — any tool whose category is outside this set is
|
||||
# treated as "always visible" for backward compatibility.
|
||||
# All known Coolify categories.
|
||||
KNOWN_CATEGORIES: set[str] = {
|
||||
"read",
|
||||
"read_sensitive",
|
||||
@@ -78,6 +79,9 @@ KNOWN_CATEGORIES: set[str] = {
|
||||
# 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.
|
||||
@@ -94,6 +98,197 @@ def scopes_to_categories(scopes: list[str]) -> set[str]:
|
||||
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."""
|
||||
|
||||
@@ -101,24 +296,35 @@ class ToolAccessManager:
|
||||
self,
|
||||
tools: list[ToolDefinition],
|
||||
scopes: list[str],
|
||||
plugin_type: str | None = None,
|
||||
) -> list[ToolDefinition]:
|
||||
"""Drop tools whose category is not allowed by the presented scopes.
|
||||
"""Drop tools not allowed by the presented scopes.
|
||||
|
||||
Tools with an unknown category (e.g. legacy plugins not yet annotated)
|
||||
are always kept — backward compatibility.
|
||||
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:
|
||||
# No recognised scopes — preserve legacy behaviour and return
|
||||
# only tools with unknown categories.
|
||||
return [t for t in tools if t.category not in KNOWN_CATEGORIES]
|
||||
|
||||
result: list[ToolDefinition] = []
|
||||
@@ -185,7 +391,7 @@ class ToolAccessManager:
|
||||
registry = get_tool_registry()
|
||||
tools = registry.get_by_plugin_type(plugin_type)
|
||||
|
||||
tools = self.apply_scope_filter(tools, key_scopes)
|
||||
tools = self.apply_scope_filter(tools, key_scopes, plugin_type=plugin_type)
|
||||
|
||||
try:
|
||||
db = get_database()
|
||||
@@ -194,7 +400,7 @@ class ToolAccessManager:
|
||||
site_scope = "admin"
|
||||
|
||||
if site_scope and site_scope != SCOPE_CUSTOM:
|
||||
tools = self.apply_scope_filter(tools, [site_scope])
|
||||
tools = self.apply_scope_filter(tools, [site_scope], plugin_type=plugin_type)
|
||||
|
||||
tools = await self.apply_site_toggles(tools, site_id)
|
||||
return tools
|
||||
|
||||
Reference in New Issue
Block a user