feat(F.7b): tool access UI + unified keys page (v3.9.0)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
326
core/tool_access.py
Normal file
326
core/tool_access.py
Normal file
@@ -0,0 +1,326 @@
|
||||
"""Tool access manager — site-scoped visibility and per-site toggles (F.7b).
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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__)
|
||||
|
||||
|
||||
# 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"},
|
||||
"deploy": {"read", "lifecycle"},
|
||||
"write": {"read", "lifecycle", "crud", "env"},
|
||||
"admin": {
|
||||
"read",
|
||||
"read_sensitive",
|
||||
"lifecycle",
|
||||
"crud",
|
||||
"env",
|
||||
"backup",
|
||||
"system",
|
||||
},
|
||||
}
|
||||
|
||||
# All known categories — any tool whose category is outside this set is
|
||||
# treated as "always visible" for backward compatibility.
|
||||
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"
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
class ToolAccessManager:
|
||||
"""Central manager for scope-based visibility and per-site tool toggles."""
|
||||
|
||||
def apply_scope_filter(
|
||||
self,
|
||||
tools: list[ToolDefinition],
|
||||
scopes: list[str],
|
||||
) -> list[ToolDefinition]:
|
||||
"""Drop tools whose category is not allowed by the presented scopes.
|
||||
|
||||
Tools with an unknown category (e.g. legacy plugins not yet annotated)
|
||||
are always kept — backward compatibility.
|
||||
|
||||
Args:
|
||||
tools: Candidate tool list.
|
||||
scopes: Scopes presented on the API key (or a single-element list
|
||||
containing a site's ``tool_scope`` preset).
|
||||
|
||||
Returns:
|
||||
Filtered tool list.
|
||||
"""
|
||||
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] = []
|
||||
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)
|
||||
|
||||
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])
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user