feat(F.7b): tool access UI + unified keys page (v3.9.0)
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 21:00:42 +02:00
parent 3fc02b5734
commit d8a0805412
30 changed files with 2965 additions and 72 deletions

View File

@@ -37,7 +37,7 @@ logger = logging.getLogger(__name__)
_DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
# Schema version — increment when adding migrations
SCHEMA_VERSION = 5
SCHEMA_VERSION = 7
# Initial schema DDL
_SCHEMA_SQL = """\
@@ -67,6 +67,7 @@ CREATE TABLE IF NOT EXISTS sites (
status_msg TEXT,
last_health TEXT,
last_tested_at TEXT,
tool_scope TEXT NOT NULL DEFAULT 'admin',
created_at TEXT NOT NULL,
UNIQUE(user_id, alias)
);
@@ -100,6 +101,18 @@ CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
-- F.7b: per-site tool toggles (scope-based visibility overrides)
CREATE TABLE IF NOT EXISTS site_tool_toggles (
id TEXT PRIMARY KEY,
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
tool_name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
reason TEXT,
updated_at TEXT NOT NULL,
UNIQUE(site_id, tool_name)
);
CREATE INDEX IF NOT EXISTS idx_site_tool_toggles_site ON site_tool_toggles(site_id);
"""
# Migration registry: version -> SQL string
@@ -120,6 +133,38 @@ _MIGRATIONS: dict[int, str] = {
" updated_at TEXT NOT NULL DEFAULT (datetime('now'))\n"
");\n"
),
6: (
# F.7: per-user tool toggles for scope-based visibility & per-tool disable
"CREATE TABLE IF NOT EXISTS user_tool_toggles (\n"
" id TEXT PRIMARY KEY,\n"
" user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,\n"
" tool_name TEXT NOT NULL,\n"
" enabled INTEGER NOT NULL DEFAULT 1,\n"
" reason TEXT,\n"
" updated_at TEXT NOT NULL,\n"
" UNIQUE(user_id, tool_name)\n"
");\n"
"CREATE INDEX IF NOT EXISTS idx_user_tool_toggles_user "
"ON user_tool_toggles(user_id);\n"
),
7: (
# F.7b: move tool toggles from per-user to per-site and add a
# per-site preset column. user_tool_toggles was merged on Phase-1
# but never populated with real data — safe to drop.
"DROP TABLE IF EXISTS user_tool_toggles;\n"
"CREATE TABLE IF NOT EXISTS site_tool_toggles (\n"
" id TEXT PRIMARY KEY,\n"
" site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,\n"
" tool_name TEXT NOT NULL,\n"
" enabled INTEGER NOT NULL DEFAULT 1,\n"
" reason TEXT,\n"
" updated_at TEXT NOT NULL,\n"
" UNIQUE(site_id, tool_name)\n"
");\n"
"CREATE INDEX IF NOT EXISTS idx_site_tool_toggles_site "
"ON site_tool_toggles(site_id);\n"
"ALTER TABLE sites ADD COLUMN tool_scope TEXT NOT NULL DEFAULT 'admin';\n"
),
}
@@ -726,6 +771,121 @@ class Database:
(_utc_now(), key_id),
)
# ------------------------------------------------------------------
# Site tool toggles & tool_scope (F.7b)
# ------------------------------------------------------------------
async def get_site_tool_toggles(self, site_id: str) -> dict[str, bool]:
"""Get explicit tool toggle overrides for a site.
Only rows where a tool has been explicitly toggled are stored.
Tools not in the result are implicitly enabled.
Args:
site_id: Site UUID.
Returns:
Dict mapping ``tool_name`` → ``enabled`` (bool).
"""
rows = await self.fetchall(
"SELECT tool_name, enabled FROM site_tool_toggles WHERE site_id = ?",
(site_id,),
)
return {row["tool_name"]: bool(row["enabled"]) for row in rows}
async def set_site_tool_toggle(
self,
site_id: str,
tool_name: str,
enabled: bool,
reason: str | None = None,
) -> None:
"""Upsert a single tool toggle for a site.
Args:
site_id: Site UUID.
tool_name: Fully-qualified tool name (e.g. ``coolify_list_applications``).
enabled: Whether the tool should be visible on this site.
reason: Optional note.
"""
toggle_id = str(uuid.uuid4())
now = _utc_now()
await self.execute(
"INSERT INTO site_tool_toggles (id, site_id, tool_name, enabled, reason, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT(site_id, tool_name) DO UPDATE SET "
"enabled = excluded.enabled, reason = excluded.reason, updated_at = excluded.updated_at",
(toggle_id, site_id, tool_name, 1 if enabled else 0, reason, now),
)
async def delete_site_tool_toggle(self, site_id: str, tool_name: str) -> bool:
"""Delete a site's toggle for a tool (reverts to the default).
Args:
site_id: Site UUID.
tool_name: Fully-qualified tool name.
Returns:
True if a row was deleted.
"""
cursor = await self.execute(
"DELETE FROM site_tool_toggles WHERE site_id = ? AND tool_name = ?",
(site_id, tool_name),
)
return cursor.rowcount > 0
async def bulk_set_site_tool_toggles(
self,
site_id: str,
toggles: list[tuple[str, bool]],
reason: str | None = None,
) -> int:
"""Upsert multiple tool toggles for a site in one transaction.
Args:
site_id: Site UUID.
toggles: List of ``(tool_name, enabled)`` pairs.
reason: Optional shared reason applied to every row.
Returns:
Number of rows affected.
"""
if not toggles:
return 0
now = _utc_now()
rows = [
(str(uuid.uuid4()), site_id, tool_name, 1 if enabled else 0, reason, now)
for tool_name, enabled in toggles
]
await self.executemany(
"INSERT INTO site_tool_toggles (id, site_id, tool_name, enabled, reason, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT(site_id, tool_name) DO UPDATE SET "
"enabled = excluded.enabled, reason = excluded.reason, updated_at = excluded.updated_at",
rows,
)
return len(rows)
async def get_site_tool_scope(self, site_id: str) -> str:
"""Return the site's ``tool_scope`` preset (defaults to ``'admin'``)."""
row = await self.fetchone("SELECT tool_scope FROM sites WHERE id = ?", (site_id,))
if row is None:
return "admin"
return row["tool_scope"] or "admin"
async def set_site_tool_scope(self, site_id: str, scope: str) -> None:
"""Update the ``tool_scope`` preset for a site.
Args:
site_id: Site UUID.
scope: One of ``read``, ``read:sensitive``, ``deploy``,
``write``, ``admin``, ``custom``.
"""
await self.execute(
"UPDATE sites SET tool_scope = ? WHERE id = ?",
(scope, site_id),
)
# ======================================================================
# Module-level helpers