feat(agent-superset): extend SupersetClient with agent-critical methods + DDL/DML guard + tests (126 passed)

Ported from mcp-superset research module, integrated into existing async SupersetClient:

New mixins (4 files):
- safety.py: DDL/DML guard (13 keywords), comment/string stripping, viz_type validation
- _sql_lab.py: execute_sql, format_sql, results, estimate, CSV export, query history
- _saved_queries.py: saved queries CRUD (5 methods)
- _audit.py: permissions audit matrix (user × dashboard × dataset × RLS)

Extended mixins (4 files):
- _dashboards_write.py: +create, +update (general), +copy, +publish, +unpublish
- _dashboards_crud.py: +standalone get_dashboard_charts/datasets, +get_dashboard
- _datasets.py: +create, +delete, +duplicate, +refresh_schema, +get_or_create, +export/import
- _databases.py: +update, +test_connection, +schemas/tables/catalogs, +validate_sql, +select_star, +table_metadata

FastAPI proxy (2 files):
- agent_superset.py (284L): SQL Lab + Dashboards/Datasets write endpoints
- agent_superset_explore.py (240L): DB explore + Audit + Saved Queries endpoints

Agent tools (2 files):
- tools.py: +7 LangChain @tools (24 total), intent-routing keywords
- _tool_resolver.py: updated SAFE/GUARDED/FAST_CONFIRM classification sets

Tests (3 files, 126 tests):
- test_superset_safety.py (51): DDL/DML bypass/legitimate/safe scenarios
- test_superset_extended.py (42): all mixin methods with mock AsyncAPIClient
- test_superset_tools.py (33): agent tool registration, intent matching, @tool .ainvoke()
This commit is contained in:
2026-06-30 17:26:51 +03:00
parent a0619ca049
commit 79d0f8f678
18 changed files with 3993 additions and 0 deletions

View File

@@ -23,6 +23,11 @@ _SAFE_AGENT_TOOLS = {
"list_llm_providers",
"get_llm_status",
"list_maintenance_events",
# NEW: read-only Superset tools
"superset_execute_sql",
"superset_explore_database",
"superset_audit_permissions",
"superset_format_sql",
}
_GUARDED_AGENT_TOOLS = {
"create_branch",
@@ -33,6 +38,10 @@ _GUARDED_AGENT_TOOLS = {
"run_llm_documentation",
"start_maintenance",
"end_maintenance",
# NEW: guarded Superset write operations
"superset_create_dashboard",
"superset_copy_dashboard",
"superset_create_dataset",
}
_DANGEROUS_AGENT_TOOLS = {
"deploy_dashboard",
@@ -44,6 +53,9 @@ _FAST_CONFIRM_TOOLS = {
"list_llm_providers",
"get_llm_status",
"list_maintenance_events",
"superset_explore_database",
"superset_audit_permissions",
"superset_format_sql",
}
# #endregion AgentChat.ToolResolver.Sets

View File

@@ -759,6 +759,272 @@ async def end_maintenance(
# #endregion AgentChat.Tools.EndMaintenance
# ═══════════════════════════════════════════════════════════════════
# NEW: Agent-critical Superset operations (Phase 4)
# ═══════════════════════════════════════════════════════════════════
# #region AgentChat.Tools.SupersetSql.Schema [C:1] [TYPE Class] [SEMANTICS agent-chat,tools,schema,superset,sql]
class ExecuteSupersetSqlInput(BaseModel):
environment_id: str = Field(description="Target Superset environment ID")
database_id: int = Field(description="Database connection ID")
sql: str = Field(description="SQL SELECT query to execute")
query_schema: str | None = Field(default=None, description="Optional schema for the query")
# #endregion AgentChat.Tools.SupersetSql.Schema
# #region AgentChat.Tools.SupersetSql [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,superset,sql]
# @ingroup AgentChat
# @BRIEF Execute a read-only SQL query in Superset SQL Lab (DDL/DML blocked).
# @PRE User authenticated via dual-identity JWT. Database exists in target environment.
# @POST Returns query results dict or error with blocked keyword.
# @SIDE_EFFECT HTTP POST to FastAPI /api/agent/superset/sqllab/execute.
@tool(args_schema=ExecuteSupersetSqlInput)
async def superset_execute_sql(environment_id: str, database_id: int, sql: str, query_schema: str | None = None) -> str:
"""Execute a read-only SQL query in Superset SQL Lab (SELECT only, DDL/DML blocked)."""
logger.reason("Execute Superset SQL", payload={"environment_id": environment_id, "database_id": database_id},
extra={"src": "AgentChat.Tools.SupersetSql"})
params: dict[str, Any] = {
"environment_id": environment_id,
"database_id": str(database_id),
"sql": sql,
}
if query_schema:
params["schema"] = query_schema
resp = await _post("/api/agent/superset/sqllab/execute", params=params)
result = _api_result(resp)
logger.reflect("Superset SQL executed", payload={"status": resp.status_code},
extra={"src": "AgentChat.Tools.SupersetSql"})
return result
# #endregion AgentChat.Tools.SupersetSql
# #region AgentChat.Tools.SupersetDatabases.Schema [C:1] [TYPE Class] [SEMANTICS agent-chat,tools,schema,superset,database]
class ListDatabaseSchemasInput(BaseModel):
environment_id: str = Field(description="Target Superset environment ID")
database_id: int = Field(description="Database connection ID")
schema_name: str | None = Field(default=None, description="Optional schema to list tables for")
table_name: str | None = Field(default=None, description="Optional table to get metadata/select_star for")
action: str = Field(default="schemas", description="Action: schemas, tables, table_metadata, select_star, validate_sql")
# #endregion AgentChat.Tools.SupersetDatabases.Schema
# #region AgentChat.Tools.SupersetDatabases [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,superset,database,explore]
# @ingroup AgentChat
# @BRIEF Explore database structure: list schemas, tables, get table metadata, generate SELECT *.
# @PRE User authenticated via dual-identity JWT. Database exists.
# @POST Returns schema/table/metadata info.
# @SIDE_EFFECT HTTP GET/POST to FastAPI /api/agent/superset/databases/{id}/...
@tool(args_schema=ListDatabaseSchemasInput)
async def superset_explore_database(
environment_id: str,
database_id: int,
schema_name: str | None = None,
table_name: str | None = None,
action: str = "schemas",
) -> str:
"""Explore database: list schemas, tables, get table metadata, or generate SELECT * template."""
logger.reason("Explore Superset database", payload={"environment_id": environment_id, "database_id": database_id, "action": action},
extra={"src": "AgentChat.Tools.SupersetDatabases"})
params: dict[str, Any] = {"environment_id": environment_id}
if action == "schemas":
path = f"/api/agent/superset/databases/{database_id}/schemas"
elif action == "tables":
if not schema_name:
return "Error: schema_name is required for tables action."
path = f"/api/agent/superset/databases/{database_id}/tables"
params["schema_name"] = schema_name
elif action == "table_metadata":
if not table_name:
return "Error: table_name is required for table_metadata action."
path = f"/api/agent/superset/databases/{database_id}/table_metadata"
params["table_name"] = table_name
if schema_name:
params["schema_name"] = schema_name
elif action == "select_star":
if not table_name:
return "Error: table_name is required for select_star action."
path = f"/api/agent/superset/databases/{database_id}/select_star"
params["table_name"] = table_name
if schema_name:
params["schema_name"] = schema_name
else:
return f"Error: unknown action '{action}'. Use: schemas, tables, table_metadata, select_star."
resp = await _get(path, params=params)
result = _api_result(resp)
logger.reflect("Database explored", payload={"status": resp.status_code, "action": action},
extra={"src": "AgentChat.Tools.SupersetDatabases"})
return result
# #endregion AgentChat.Tools.SupersetDatabases
# #region AgentChat.Tools.SupersetAudit.Schema [C:1] [TYPE Class] [SEMANTICS agent-chat,tools,schema,superset,audit]
class AuditPermissionsInput(BaseModel):
environment_id: str = Field(description="Target Superset environment ID")
username_filter: str | None = Field(default=None, description="Optional filter by username")
include_admin: bool = Field(default=False, description="Include Admin users in the audit")
# #endregion AgentChat.Tools.SupersetAudit.Schema
# #region AgentChat.Tools.SupersetAudit [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,superset,audit,permissions]
# @ingroup AgentChat
# @BRIEF Audit access rights: user × dashboard × dataset × RLS permission matrix.
# @PRE User authenticated via dual-identity JWT.
# @POST Returns audit report with per-user dashboard/dataset access and RLS regions.
# @SIDE_EFFECT HTTP GET to FastAPI /api/agent/superset/audit/permissions.
@tool(args_schema=AuditPermissionsInput)
async def superset_audit_permissions(
environment_id: str,
username_filter: str | None = None,
include_admin: bool = False,
) -> str:
"""Audit access rights: user × dashboard × dataset × RLS permission matrix."""
logger.reason("Audit Superset permissions", payload={"environment_id": environment_id},
extra={"src": "AgentChat.Tools.SupersetAudit"})
params: dict[str, Any] = {"environment_id": environment_id}
if username_filter:
params["username_filter"] = username_filter
if include_admin:
params["include_admin"] = "true"
resp = await _get("/api/agent/superset/audit/permissions", params=params)
result = _api_result(resp)
logger.reflect("Permissions audit completed", payload={"status": resp.status_code},
extra={"src": "AgentChat.Tools.SupersetAudit"})
return result
# #endregion AgentChat.Tools.SupersetAudit
# #region AgentChat.Tools.SupersetCreateDashboard.Schema [C:1] [TYPE Class] [SEMANTICS agent-chat,tools,schema,superset,dashboard,create]
class CreateSupersetDashboardInput(BaseModel):
environment_id: str = Field(description="Target Superset environment ID")
dashboard_title: str = Field(description="Dashboard title")
slug: str | None = Field(default=None, description="Optional URL slug")
published: bool = Field(default=False, description="Whether the dashboard is published")
# #endregion AgentChat.Tools.SupersetCreateDashboard.Schema
# #region AgentChat.Tools.SupersetCreateDashboard [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,superset,dashboard,create]
# @ingroup AgentChat
# @BRIEF Create a new dashboard in Superset.
# @PRE User authenticated via dual-identity JWT.
# @POST Returns created dashboard dict.
# @SIDE_EFFECT HTTP POST to FastAPI /api/agent/superset/dashboards.
@tool(args_schema=CreateSupersetDashboardInput)
async def superset_create_dashboard(
environment_id: str,
dashboard_title: str,
slug: str | None = None,
published: bool = False,
) -> str:
"""Create a new dashboard in Superset."""
logger.reason("Create Superset dashboard", payload={"title": dashboard_title, "environment_id": environment_id},
extra={"src": "AgentChat.Tools.SupersetCreateDashboard"})
params: dict[str, Any] = {
"environment_id": environment_id,
"dashboard_title": dashboard_title,
"published": "true" if published else "false",
}
if slug:
params["slug"] = slug
resp = await _post("/api/agent/superset/dashboards", params=params)
result = _api_result(resp, {200, 201})
logger.reflect("Dashboard created", payload={"status": resp.status_code},
extra={"src": "AgentChat.Tools.SupersetCreateDashboard"})
return result
# #endregion AgentChat.Tools.SupersetCreateDashboard
# #region AgentChat.Tools.SupersetCopyDashboard.Schema [C:1] [TYPE Class] [SEMANTICS agent-chat,tools,schema,superset,dashboard,copy]
class CopySupersetDashboardInput(BaseModel):
environment_id: str = Field(description="Target Superset environment ID")
dashboard_id: int = Field(description="Source dashboard ID to copy")
dashboard_title: str | None = Field(default=None, description="Optional title for the copy")
# #endregion AgentChat.Tools.SupersetCopyDashboard.Schema
# #region AgentChat.Tools.SupersetCopyDashboard [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,superset,dashboard,copy]
# @ingroup AgentChat
# @BRIEF Deep-copy a Superset dashboard including all charts.
# @PRE User authenticated via dual-identity JWT.
# @POST Returns copy result dict.
# @SIDE_EFFECT HTTP POST to FastAPI /api/agent/superset/dashboards/{id}/copy.
@tool(args_schema=CopySupersetDashboardInput)
async def superset_copy_dashboard(
environment_id: str,
dashboard_id: int,
dashboard_title: str | None = None,
) -> str:
"""Deep-copy a dashboard including all charts."""
logger.reason("Copy Superset dashboard", payload={"dashboard_id": dashboard_id, "environment_id": environment_id},
extra={"src": "AgentChat.Tools.SupersetCopyDashboard"})
params: dict[str, Any] = {"environment_id": environment_id}
if dashboard_title:
params["dashboard_title"] = dashboard_title
resp = await _post(f"/api/agent/superset/dashboards/{dashboard_id}/copy", params=params)
result = _api_result(resp, {200, 201})
logger.reflect("Dashboard copied", payload={"status": resp.status_code, "source_id": dashboard_id},
extra={"src": "AgentChat.Tools.SupersetCopyDashboard"})
return result
# #endregion AgentChat.Tools.SupersetCopyDashboard
# #region AgentChat.Tools.SupersetCreateDataset.Schema [C:1] [TYPE Class] [SEMANTICS agent-chat,tools,schema,superset,dataset,create]
class CreateSupersetDatasetInput(BaseModel):
environment_id: str = Field(description="Target Superset environment ID")
table_name: str = Field(description="Table or view name")
database: int = Field(description="Database connection ID")
schema_name: str | None = Field(default=None, description="Optional schema name")
# #endregion AgentChat.Tools.SupersetCreateDataset.Schema
# #region AgentChat.Tools.SupersetCreateDataset [C:3] [TYPE Function] [SEMANTICS agent-chat,tools,superset,dataset,create]
# @ingroup AgentChat
# @BRIEF Create a new dataset in Superset.
# @PRE User authenticated via dual-identity JWT.
# @POST Returns created dataset dict.
# @SIDE_EFFECT HTTP POST to FastAPI /api/agent/superset/datasets.
@tool(args_schema=CreateSupersetDatasetInput)
async def superset_create_dataset(
environment_id: str,
table_name: str,
database: int,
schema_name: str | None = None,
) -> str:
"""Create a new dataset in Superset."""
logger.reason("Create Superset dataset", payload={"table_name": table_name, "environment_id": environment_id},
extra={"src": "AgentChat.Tools.SupersetCreateDataset"})
params: dict[str, Any] = {
"environment_id": environment_id,
"table_name": table_name,
"database": str(database),
}
if schema_name:
params["schema_name"] = schema_name
resp = await _post("/api/agent/superset/datasets", params=params)
result = _api_result(resp, {200, 201})
logger.reflect("Dataset created", payload={"status": resp.status_code},
extra={"src": "AgentChat.Tools.SupersetCreateDataset"})
return result
# #endregion AgentChat.Tools.SupersetCreateDataset
# #region AgentChat.Tools.SupersetFormatSql [C:2] [TYPE Function] [SEMANTICS agent-chat,tools,superset,sql,format]
# @ingroup AgentChat
# @BRIEF Format/pretty-print a SQL query using Superset SQL Lab.
# @SIDE_EFFECT HTTP POST to FastAPI /api/agent/superset/sqllab/format.
@tool
async def superset_format_sql(environment_id: str, sql: str) -> str:
"""Format/pretty-print a SQL query using Superset SQL Lab."""
logger.reason("Format SQL", extra={"src": "AgentChat.Tools.SupersetFormatSql"})
resp = await _post("/api/agent/superset/sqllab/format", params={
"environment_id": environment_id,
"sql": sql,
})
result = _api_result(resp)
logger.reflect("SQL formatted", extra={"src": "AgentChat.Tools.SupersetFormatSql"})
return result
# #endregion AgentChat.Tools.SupersetFormatSql
# ═══════════════════════════════════════════════════════════════════
# Tool registry
# ═══════════════════════════════════════════════════════════════════
@@ -785,6 +1051,14 @@ def get_all_tools() -> list:
list_maintenance_events,
start_maintenance,
end_maintenance,
# NEW: Superset direct operations
superset_execute_sql,
superset_explore_database,
superset_audit_permissions,
superset_create_dashboard,
superset_copy_dashboard,
superset_create_dataset,
superset_format_sql,
]
# #endregion AgentChat.Tools.GetAll
@@ -842,6 +1116,29 @@ def get_tools_for_query(query: str, *, prefetch_available: bool = False) -> list
if any(word in text for word in ["maintenance", "обслуж", "баннер"]):
matched_intent = True
selected.extend([list_maintenance_events, start_maintenance, end_maintenance])
# NEW: Superset direct tools intent matching
if any(word in text for word in ["sql", "запрос", "select", "query"]):
matched_intent = True
selected.append(superset_execute_sql)
if any(word in text for word in ["форматировать sql", "format sql", "формат sql"]):
matched_intent = True
selected.append(superset_format_sql)
if any(word in text for word in ["схем", "schema", "таблиц", "table", "колонк", "column",
"select star", "метаданные", "metadata"]):
matched_intent = True
selected.append(superset_explore_database)
if any(word in text for word in ["аудит", "audit", "прав", "permission", "доступ", "access"]):
matched_intent = True
selected.append(superset_audit_permissions)
if any(word in text for word in ["создать дашборд", "create dashboard", "новый дашборд", "new dashboard"]):
matched_intent = True
selected.append(superset_create_dashboard)
if any(word in text for word in ["копировать дашборд", "copy dashboard", "дублировать дашборд"]):
matched_intent = True
selected.append(superset_copy_dashboard)
if any(word in text for word in ["создать датасет", "create dataset", "новый датасет", "new dataset"]):
matched_intent = True
selected.append(superset_create_dataset)
if len(selected) == 1 and not matched_intent:
selected.extend([search_dashboards, get_health_summary, list_environments, get_task_status])

View File

@@ -21,6 +21,9 @@
__all__ = [
"admin",
"admin_api_keys",
"agent_conversations",
"agent_superset",
"agent_superset_explore",
"assistant",
"clean_release",
"clean_release_v2",

View File

@@ -0,0 +1,284 @@
# #region AgentSupersetRoutes [C:4] [TYPE Module] [SEMANTICS api,agent,superset,sql,dashboard,dataset,crud]
# @defgroup Api Agent Superset proxy routes for the Gradio agent (write/mutate endpoints).
# @BRIEF FastAPI endpoints proxying SupersetClient write/mutate operations for the agent chat.
# @LAYER API
# @RELATION DEPENDS_ON -> [SupersetClient]
# @SIDE_EFFECT Асинхронные HTTP-вызовы к Superset API через SupersetClient.
# @RATIONALE The Gradio agent container has no direct SupersetClient — it reaches Superset
# through these proxy endpoints. Each endpoint resolves the target environment and delegates
# to the appropriate SupersetClient method. Dual-identity JWT (service + user) is enforced
# per FR-007/FR-019.
# @INVARIANT Module stays under 400 lines per INV_7. Read-only explore/audit endpoints live in
# agent_superset_explore.py.
from fastapi import APIRouter, HTTPException, Query
from src.core.config_models import Environment
from src.core.superset_client import SupersetClient
router = APIRouter(prefix="/api/agent/superset", tags=["Agent Superset"])
# ── Helper: resolve SupersetClient for an environment ───────────
async def _get_superset_client(environment_id: str) -> SupersetClient:
"""Resolve a SupersetClient for the given environment_id."""
from ..api.routes.environments import _get_environment_by_id as _resolve_env
env_data = await _resolve_env(environment_id)
if not env_data:
raise HTTPException(status_code=404, detail=f"Environment '{environment_id}' not found.")
env = Environment(**env_data)
return SupersetClient(env)
# ═══════════════════════════════════════════════════════════════════
# SQL Lab
# ═══════════════════════════════════════════════════════════════════
# #region AgentSuperset.SqlExecute [C:3] [TYPE Endpoint] [SEMANTICS api,agent,sql,execute]
# @ingroup Api
# @BRIEF Execute a read-only SQL query in Superset SQL Lab (DDL/DML blocked).
# @SIDE_EFFECT HTTP POST to Superset /api/v1/sqllab/execute/.
# @RELATION CALLS -> [SupersetSqlLab.ExecuteSql]
@router.post("/sqllab/execute")
async def agent_sqllab_execute(
environment_id: str = Query(..., description="Target Superset environment ID"),
database_id: int = Query(..., description="Database connection ID"),
sql: str = Query(..., description="SQL query (SELECT only)"),
schema: str | None = Query(None),
catalog: str | None = Query(None),
tab_name: str | None = Query(None),
template_params: str | None = Query(None),
) -> dict:
"""Execute a read-only SQL query in Superset SQL Lab."""
client = await _get_superset_client(environment_id)
try:
return await client.execute_sql(
database_id=database_id,
sql=sql,
schema=schema,
catalog=catalog,
tab_name=tab_name,
template_params=template_params,
)
finally:
await client.aclose()
# #endregion AgentSuperset.SqlExecute
# #region AgentSuperset.SqlFormat [C:2] [TYPE Endpoint] [SEMANTICS api,agent,sql,format]
# @ingroup Api
# @BRIEF Format/pretty-print a SQL query using Superset SQL Lab.
# @SIDE_EFFECT HTTP POST to Superset /api/v1/sqllab/format_sql/.
# @RELATION CALLS -> [SupersetSqlLab.FormatSql]
@router.post("/sqllab/format")
async def agent_sqllab_format(
environment_id: str = Query(...),
sql: str = Query(..., description="SQL to format"),
) -> dict:
"""Format/pretty-print a SQL query."""
client = await _get_superset_client(environment_id)
try:
formatted = await client.format_sql(sql)
return {"result": formatted}
finally:
await client.aclose()
# #endregion AgentSuperset.SqlFormat
# #region AgentSuperset.SqlEstimate [C:2] [TYPE Endpoint] [SEMANTICS api,agent,sql,estimate]
# @ingroup Api
# @BRIEF Estimate the cost of a SQL query via Superset SQL Lab.
# @SIDE_EFFECT HTTP POST to Superset /api/v1/sqllab/estimate/.
# @RELATION CALLS -> [SupersetSqlLab.EstimateCost]
@router.post("/sqllab/estimate")
async def agent_sqllab_estimate(
environment_id: str = Query(...),
database_id: int = Query(...),
sql: str = Query(...),
schema: str | None = Query(None),
) -> dict:
"""Estimate the cost of a SQL query."""
client = await _get_superset_client(environment_id)
try:
return await client.estimate_sql_cost(database_id=database_id, sql=sql, schema=schema)
finally:
await client.aclose()
# #endregion AgentSuperset.SqlEstimate
# ═══════════════════════════════════════════════════════════════════
# Dashboards — Write (HITL-guarded)
# ═══════════════════════════════════════════════════════════════════
# #region AgentSuperset.DashboardCreate [C:3] [TYPE Endpoint] [SEMANTICS api,agent,dashboard,create]
# @ingroup Api
# @BRIEF Create a new dashboard in Superset.
# @SIDE_EFFECT HTTP POST to Superset /api/v1/dashboard/.
# @RELATION CALLS -> [SupersetDashboardsWriteMixin.create_dashboard]
@router.post("/dashboards")
async def agent_dashboard_create(
environment_id: str = Query(...),
dashboard_title: str = Query(...),
slug: str | None = Query(None),
published: bool = Query(False),
json_metadata: str | None = Query(None),
css: str | None = Query(None),
position_json: str | None = Query(None),
) -> dict:
"""Create a new dashboard in Superset."""
client = await _get_superset_client(environment_id)
try:
return await client.create_dashboard(
dashboard_title=dashboard_title,
slug=slug,
published=published,
json_metadata=json_metadata,
css=css,
position_json=position_json,
)
finally:
await client.aclose()
# #endregion AgentSuperset.DashboardCreate
# #region AgentSuperset.DashboardCopy [C:3] [TYPE Endpoint] [SEMANTICS api,agent,dashboard,copy]
# @ingroup Api
# @BRIEF Deep-copy a dashboard including all charts.
# @SIDE_EFFECT HTTP POST to Superset /api/v1/dashboard/{id}/copy/.
# @RELATION CALLS -> [SupersetDashboardsWriteMixin.copy_dashboard]
@router.post("/dashboards/{dashboard_id}/copy")
async def agent_dashboard_copy(
dashboard_id: int,
environment_id: str = Query(...),
dashboard_title: str | None = Query(None),
) -> dict:
"""Deep-copy a dashboard including all charts."""
client = await _get_superset_client(environment_id)
try:
return await client.copy_dashboard(dashboard_id=dashboard_id, dashboard_title=dashboard_title)
finally:
await client.aclose()
# #endregion AgentSuperset.DashboardCopy
# #region AgentSuperset.DashboardUpdate [C:3] [TYPE Endpoint] [SEMANTICS api,agent,dashboard,update]
# @ingroup Api
# @BRIEF Update an existing dashboard's properties.
# @SIDE_EFFECT HTTP PUT to Superset /api/v1/dashboard/{id}.
# @RELATION CALLS -> [SupersetDashboardsWriteMixin.update_dashboard]
@router.put("/dashboards/{dashboard_id}")
async def agent_dashboard_update(
dashboard_id: int,
environment_id: str = Query(...),
dashboard_title: str | None = Query(None),
slug: str | None = Query(None),
published: bool | None = Query(None),
css: str | None = Query(None),
json_metadata: str | None = Query(None),
) -> dict:
"""Update an existing dashboard's properties."""
client = await _get_superset_client(environment_id)
try:
return await client.update_dashboard(
dashboard_id=dashboard_id,
dashboard_title=dashboard_title,
slug=slug,
published=published,
css=css,
json_metadata=json_metadata,
)
finally:
await client.aclose()
# #endregion AgentSuperset.DashboardUpdate
# ═══════════════════════════════════════════════════════════════════
# Datasets — Write (HITL-guarded)
# ═══════════════════════════════════════════════════════════════════
# #region AgentSuperset.DatasetCreate [C:3] [TYPE Endpoint] [SEMANTICS api,agent,dataset,create]
# @ingroup Api
# @BRIEF Create a new dataset in Superset.
# @SIDE_EFFECT HTTP POST to Superset /api/v1/dataset/.
# @RELATION CALLS -> [SupersetClient.CreateDataset]
@router.post("/datasets")
async def agent_dataset_create(
environment_id: str = Query(...),
table_name: str = Query(...),
database: int = Query(...),
schema_name: str | None = Query(None),
sql: str | None = Query(None),
) -> dict:
"""Create a new dataset in Superset."""
client = await _get_superset_client(environment_id)
try:
return await client.create_dataset(
table_name=table_name,
database=database,
schema_name=schema_name,
sql=sql,
)
finally:
await client.aclose()
# #endregion AgentSuperset.DatasetCreate
# #region AgentSuperset.DatasetDelete [C:3] [TYPE Endpoint] [SEMANTICS api,agent,dataset,delete]
# @ingroup Api
# @BRIEF Delete a dataset from Superset by ID.
# @SIDE_EFFECT HTTP DELETE to Superset /api/v1/dataset/{id}.
# @RELATION CALLS -> [SupersetClient.DeleteDataset]
@router.delete("/datasets/{dataset_id}")
async def agent_dataset_delete(
dataset_id: int,
environment_id: str = Query(...),
) -> dict:
"""Delete a dataset from Superset."""
client = await _get_superset_client(environment_id)
try:
return await client.delete_dataset(dataset_id=dataset_id)
finally:
await client.aclose()
# #endregion AgentSuperset.DatasetDelete
# #region AgentSuperset.DatasetDuplicate [C:3] [TYPE Endpoint] [SEMANTICS api,agent,dataset,duplicate]
# @ingroup Api
# @BRIEF Duplicate a dataset including columns and metrics.
# @SIDE_EFFECT HTTP POST to Superset /api/v1/dataset/duplicate.
# @RELATION CALLS -> [SupersetClient.DuplicateDataset]
@router.post("/datasets/{dataset_id}/duplicate")
async def agent_dataset_duplicate(
dataset_id: int,
environment_id: str = Query(...),
table_name: str = Query(...),
) -> dict:
"""Duplicate a dataset including columns and metrics."""
client = await _get_superset_client(environment_id)
try:
return await client.duplicate_dataset(base_model_id=dataset_id, table_name=table_name)
finally:
await client.aclose()
# #endregion AgentSuperset.DatasetDuplicate
# #region AgentSuperset.DatasetRefresh [C:3] [TYPE Endpoint] [SEMANTICS api,agent,dataset,refresh]
# @ingroup Api
# @BRIEF Rescan columns and types for a dataset from its source database.
# @SIDE_EFFECT HTTP PUT to Superset /api/v1/dataset/{id}/refresh.
# @RELATION CALLS -> [SupersetClient.RefreshDatasetSchema]
@router.post("/datasets/{dataset_id}/refresh")
async def agent_dataset_refresh(
dataset_id: int,
environment_id: str = Query(...),
) -> dict:
"""Rescan columns and types for a dataset from its source database."""
client = await _get_superset_client(environment_id)
try:
return await client.refresh_dataset_schema(dataset_id=dataset_id)
finally:
await client.aclose()
# #endregion AgentSuperset.DatasetRefresh
# #endregion AgentSupersetRoutes

View File

@@ -0,0 +1,240 @@
# #region AgentSupersetExploreRoutes [C:3] [TYPE Module] [SEMANTICS api,agent,superset,database,explore,audit]
# @defgroup Api Agent Superset read-only proxy routes (database exploration, audit, saved queries).
# @BRIEF FastAPI endpoints proxying SupersetClient read-only operations for the agent chat.
# @LAYER API
# @RELATION DEPENDS_ON -> [SupersetClient]
# @SIDE_EFFECT Асинхронные HTTP-вызовы к Superset API через SupersetClient.
# @RATIONALE Split from agent_superset.py to satisfy INV_7 (module < 400 lines).
# Write/mutate endpoints remain in agent_superset.py.
from fastapi import APIRouter, HTTPException, Query
from src.core.config_models import Environment
from src.core.superset_client import SupersetClient
router = APIRouter(prefix="/api/agent/superset", tags=["Agent Superset"])
async def _get_superset_client(environment_id: str) -> SupersetClient:
"""Resolve a SupersetClient for the given environment_id."""
from ..api.routes.environments import _get_environment_by_id as _resolve_env
env_data = await _resolve_env(environment_id)
if not env_data:
raise HTTPException(status_code=404, detail=f"Environment '{environment_id}' not found.")
env = Environment(**env_data)
return SupersetClient(env)
# ═══════════════════════════════════════════════════════════════════
# Databases — Read-only (schema/table exploration)
# ═══════════════════════════════════════════════════════════════════
# #region AgentSuperset.DatabaseSchemas [C:2] [TYPE Endpoint] [SEMANTICS api,agent,database,schemas]
# @ingroup Api
# @BRIEF List all schemas for a database.
# @SIDE_EFFECT HTTP GET to Superset /api/v1/database/{id}/schemas/.
# @RELATION CALLS -> [SupersetDatabasesMixin.get_database_schemas]
@router.get("/databases/{database_id}/schemas")
async def agent_database_schemas(
database_id: int,
environment_id: str = Query(...),
) -> list:
"""List all schemas for a database."""
client = await _get_superset_client(environment_id)
try:
return await client.get_database_schemas(database_id=database_id)
finally:
await client.aclose()
# #endregion AgentSuperset.DatabaseSchemas
# #region AgentSuperset.DatabaseTables [C:2] [TYPE Endpoint] [SEMANTICS api,agent,database,tables]
# @ingroup Api
# @BRIEF List tables/views for a database schema.
# @SIDE_EFFECT HTTP GET to Superset /api/v1/database/{id}/tables/.
# @RELATION CALLS -> [SupersetDatabasesMixin.get_database_tables]
@router.get("/databases/{database_id}/tables")
async def agent_database_tables(
database_id: int,
environment_id: str = Query(...),
schema_name: str | None = Query(None),
) -> list:
"""List tables/views for a database schema."""
client = await _get_superset_client(environment_id)
try:
return await client.get_database_tables(database_id=database_id, schema_name=schema_name)
finally:
await client.aclose()
# #endregion AgentSuperset.DatabaseTables
# #region AgentSuperset.DatabaseTableMetadata [C:2] [TYPE Endpoint] [SEMANTICS api,agent,database,metadata]
# @ingroup Api
# @BRIEF Get table metadata: columns, types, indexes, primary keys.
# @SIDE_EFFECT HTTP GET to Superset /api/v1/database/{id}/table_metadata/.
# @RELATION CALLS -> [SupersetDatabasesMixin.get_database_table_metadata]
@router.get("/databases/{database_id}/table_metadata")
async def agent_database_table_metadata(
database_id: int,
environment_id: str = Query(...),
table_name: str = Query(...),
schema_name: str | None = Query(None),
) -> dict:
"""Get table metadata: columns, types, indexes, primary keys."""
client = await _get_superset_client(environment_id)
try:
return await client.get_database_table_metadata(
database_id=database_id,
table_name=table_name,
schema_name=schema_name,
)
finally:
await client.aclose()
# #endregion AgentSuperset.DatabaseTableMetadata
# #region AgentSuperset.DatabaseSelectStar [C:2] [TYPE Endpoint] [SEMANTICS api,agent,database,select-star]
# @ingroup Api
# @BRIEF Generate a SELECT * query template for a table.
# @SIDE_EFFECT HTTP GET to Superset /api/v1/database/{id}/select_star/.
# @RELATION CALLS -> [SupersetDatabasesMixin.get_database_select_star]
@router.get("/databases/{database_id}/select_star")
async def agent_database_select_star(
database_id: int,
environment_id: str = Query(...),
table_name: str = Query(...),
schema_name: str | None = Query(None),
) -> dict:
"""Generate a SELECT * query template for a table."""
client = await _get_superset_client(environment_id)
try:
sql = await client.get_database_select_star(
database_id=database_id,
table_name=table_name,
schema_name=schema_name,
)
return {"sql": sql}
finally:
await client.aclose()
# #endregion AgentSuperset.DatabaseSelectStar
# #region AgentSuperset.DatabaseValidateSql [C:2] [TYPE Endpoint] [SEMANTICS api,agent,database,validate]
# @ingroup Api
# @BRIEF Validate SQL syntax for a database without executing.
# @SIDE_EFFECT HTTP POST to Superset /api/v1/database/{id}/validate_sql/.
# @RELATION CALLS -> [SupersetDatabasesMixin.validate_sql]
@router.post("/databases/{database_id}/validate_sql")
async def agent_database_validate_sql(
database_id: int,
environment_id: str = Query(...),
sql: str = Query(...),
schema: str | None = Query(None),
) -> dict:
"""Validate SQL syntax for a database without executing."""
client = await _get_superset_client(environment_id)
try:
return await client.validate_sql(database_id=database_id, sql=sql, schema=schema)
finally:
await client.aclose()
# #endregion AgentSuperset.DatabaseValidateSql
# #region AgentSuperset.DatabaseTestConnection [C:3] [TYPE Endpoint] [SEMANTICS api,agent,database,test]
# @ingroup Api
# @BRIEF Test a database connection URI without creating a database entry.
# @SIDE_EFFECT HTTP POST to Superset /api/v1/database/test_connection/.
# @RELATION CALLS -> [SupersetDatabasesMixin.test_database_connection]
@router.post("/databases/test_connection")
async def agent_database_test_connection(
environment_id: str = Query(...),
database_name: str = Query(...),
sqlalchemy_uri: str = Query(...),
extra: str | None = Query(None),
) -> dict:
"""Test a database connection URI without creating a database entry."""
client = await _get_superset_client(environment_id)
try:
return await client.test_database_connection(
database_name=database_name,
sqlalchemy_uri=sqlalchemy_uri,
extra=extra,
)
finally:
await client.aclose()
# #endregion AgentSuperset.DatabaseTestConnection
# ═══════════════════════════════════════════════════════════════════
# Audit
# ═══════════════════════════════════════════════════════════════════
# #region AgentSuperset.AuditPermissions [C:3] [TYPE Endpoint] [SEMANTICS api,agent,audit,permissions]
# @ingroup Api
# @BRIEF Audit access rights: user × dashboard × dataset × RLS permission matrix.
# @SIDE_EFFECT Multiple HTTP GETs to Superset security/dashboard/dataset APIs.
# @RELATION CALLS -> [SupersetAuditMixin.permissions_audit]
@router.get("/audit/permissions")
async def agent_audit_permissions(
environment_id: str = Query(...),
page: int = Query(0),
page_size: int = Query(20),
username_filter: str | None = Query(None),
include_admin: bool = Query(False),
) -> dict:
"""Audit access rights: user × dashboards × datasets × RLS matrix."""
client = await _get_superset_client(environment_id)
try:
return await client.permissions_audit(
page=page,
page_size=page_size,
username_filter=username_filter,
include_admin=include_admin,
)
finally:
await client.aclose()
# #endregion AgentSuperset.AuditPermissions
# ═══════════════════════════════════════════════════════════════════
# Saved Queries
# ═══════════════════════════════════════════════════════════════════
# #region AgentSuperset.SavedQueryList [C:2] [TYPE Endpoint] [SEMANTICS api,agent,saved-query,list]
# @ingroup Api
# @BRIEF List all saved SQL queries.
# @SIDE_EFFECT HTTP GET to Superset /api/v1/saved_query/.
# @RELATION CALLS -> [SupersetSavedQueriesMixin.get_saved_queries]
@router.get("/saved_queries")
async def agent_saved_query_list(
environment_id: str = Query(...),
) -> dict:
"""List all saved SQL queries."""
client = await _get_superset_client(environment_id)
try:
count, queries = await client.get_saved_queries()
return {"count": count, "queries": queries}
finally:
await client.aclose()
# #endregion AgentSuperset.SavedQueryList
# #region AgentSuperset.SavedQueryGet [C:2] [TYPE Endpoint] [SEMANTICS api,agent,saved-query,get]
# @ingroup Api
# @BRIEF Get a saved SQL query by ID.
# @SIDE_EFFECT HTTP GET to Superset /api/v1/saved_query/{id}.
# @RELATION CALLS -> [SupersetSavedQueriesMixin.get_saved_query]
@router.get("/saved_queries/{query_id}")
async def agent_saved_query_get(
query_id: int,
environment_id: str = Query(...),
) -> dict:
"""Get a saved SQL query by ID."""
client = await _get_superset_client(environment_id)
try:
return await client.get_saved_query(query_id=query_id)
finally:
await client.aclose()
# #endregion AgentSuperset.SavedQueryGet
# #endregion AgentSupersetExploreRoutes

View File

@@ -33,6 +33,8 @@ from .api.routes import (
admin,
admin_api_keys,
agent_conversations,
agent_superset,
agent_superset_explore,
assistant,
clean_release,
clean_release_v2,
@@ -403,6 +405,8 @@ app.include_router(reports.router)
app.include_router(assistant.router, prefix="/api/assistant", tags=["Assistant"])
app.include_router(agent_conversations.agent_router, tags=["Agent"])
app.include_router(agent_conversations.router, tags=["Assistant"])
app.include_router(agent_superset.router, tags=["Agent Superset"])
app.include_router(agent_superset_explore.router, tags=["Agent Superset"])
app.include_router(clean_release.router)
app.include_router(clean_release_v2.router)
app.include_router(profile.router)

View File

@@ -7,6 +7,9 @@
# @RELATION DEPENDS_ON -> [SupersetAPIError]
# @RELATION DEPENDS_ON -> [get_filename_from_headers]
# @RELATION DEPENDS_ON -> [SupersetDatasetsPreviewFiltersMixin]
# @RELATION DEPENDS_ON -> [SupersetSqlLabMixin]
# @RELATION DEPENDS_ON -> [SupersetSavedQueriesMixin]
# @RELATION DEPENDS_ON -> [SupersetAuditMixin]
#
# @INVARIANT All network operations must use the internal AsyncAPIClient instance.
# @PUBLIC_API SupersetClient
@@ -33,6 +36,10 @@ from ._datasets import SupersetDatasetsMixin
from ._datasets_preview import SupersetDatasetsPreviewMixin
from ._datasets_preview_filters import SupersetDatasetsPreviewFiltersMixin
from ._user_projection import SupersetUserProjectionMixin
# NEW: Agent-critical mixins
from ._sql_lab import SupersetSqlLabMixin
from ._saved_queries import SupersetSavedQueriesMixin
from ._audit import SupersetAuditMixin
# #region SupersetClient [C:3] [TYPE Class]
@@ -52,6 +59,9 @@ from ._user_projection import SupersetUserProjectionMixin
# @RELATION INHERITS -> [SupersetDatasetsPreviewFiltersMixin]
# @RELATION INHERITS -> [SupersetDatabasesMixin]
# @RELATION INHERITS -> [SupersetDashboardsWriteMixin]
# @RELATION INHERITS -> [SupersetSqlLabMixin]
# @RELATION INHERITS -> [SupersetSavedQueriesMixin]
# @RELATION INHERITS -> [SupersetAuditMixin]
# @SIDE_EFFECT Асинхронные HTTP-вызовы к Superset API через AsyncAPIClient.
class SupersetClient(
SupersetDatabasesMixin,
@@ -64,6 +74,10 @@ class SupersetClient(
SupersetDashboardsListMixin,
SupersetDashboardsWriteMixin,
SupersetUserProjectionMixin,
# NEW: Agent-critical mixins
SupersetSqlLabMixin,
SupersetSavedQueriesMixin,
SupersetAuditMixin,
SupersetClientBase,
):
"""Composed Superset REST API client.

View File

@@ -0,0 +1,358 @@
# #region SupersetAuditMixin [C:4] [TYPE Module] [SEMANTICS superset,audit,permissions,access-control]
# @defgroup Core Module group.
# @BRIEF Permissions audit mixin for SupersetClient — user × dashboard × dataset × RLS access matrix.
# @LAYER Infrastructure
# @RELATION DEPENDS_ON -> [SupersetClientBase]
# @SIDE_EFFECT Асинхронные HTTP-вызовы к Superset API для сбора данных аудита.
# @RATIONALE Ported from mcp-superset audit.py. Builds a comprehensive permissions matrix
# showing effective access for every user across dashboards and datasets, resolving
# direct roles + group-inherited roles. Handles 3 dashboard access states: full (1),
# hidden (0), visible_no_data (visible but datasource_access missing).
import re
from typing import Any, cast
from ..logger import belief_scope, logger as app_logger
app_logger = cast(Any, app_logger)
# #region SupersetAuditMixin [C:4] [TYPE Class]
# @defgroup Core Module group.
# @BRIEF Mixin providing comprehensive permissions audit across users, dashboards, datasets, and RLS.
# @RELATION DEPENDS_ON -> [SupersetClientBase]
# @SIDE_EFFECT Асинхронные HTTP-вызовы к Superset API.
class SupersetAuditMixin:
"""Mixin for Superset permissions audit operations."""
# #region SupersetAudit.PermissionsAudit [C:4] [TYPE Function]
# @ingroup Core
# @BRIEF Build a user × dashboard × dataset × RLS permission matrix.
# @PRE SupersetClient is authenticated.
# @POST Returns audit dict with users array, each containing dashboards/datasets access and RLS regions.
# @SIDE_EFFECT Множественные асинхронные HTTP-вызовы к Superset API.
async def permissions_audit(
self,
page: int = 0,
page_size: int = 20,
username_filter: str | None = None,
include_admin: bool = False,
) -> dict:
with belief_scope("SupersetAuditMixin.permissions_audit"):
app_logger.reason("Starting permissions audit")
page_size = min(page_size, 50)
# === 1. Collect all data ===
# Users
_, all_users = await self.get_all_resources("user") if False else (
await self._fetch_users()
)
# Groups
groups_resp = await self.client.request(method="GET", endpoint="/security/groups/")
all_groups_list = groups_resp.get("result", []) if isinstance(groups_resp, dict) else []
# Group details
groups_detail: dict[int, dict] = {}
for g in all_groups_list:
gid = g.get("id")
if gid:
try:
detail = await self.client.request(method="GET", endpoint=f"/api/v1/security/groups/{gid}")
groups_detail[gid] = detail.get("result", {}) if isinstance(detail, dict) else {}
except Exception:
groups_detail[gid] = g
# Dashboards
_, all_dashboards = await self.get_dashboards()
# Datasets
_, all_datasets = await self.get_datasets()
# Dashboard → datasets and roles
dashboard_datasets: dict[int, set[int]] = {}
dashboard_roles: dict[int, set[int]] = {}
for db in all_dashboards:
db_id = db["id"]
try:
ds_resp = await self.client.request(
method="GET", endpoint=f"/dashboard/{db_id}/datasets"
)
ds_ids = set()
for ds in ds_resp.get("result", []) if isinstance(ds_resp, dict) else []:
if isinstance(ds, dict) and "id" in ds:
ds_ids.add(ds["id"])
dashboard_datasets[db_id] = ds_ids
except Exception:
dashboard_datasets[db_id] = set()
roles_data = db.get("roles", None)
if roles_data is not None:
dashboard_roles[db_id] = {r["id"] for r in roles_data}
else:
try:
db_detail = await self.client.request(method="GET", endpoint=f"/dashboard/{db_id}")
roles_detail = db_detail.get("result", {}).get("roles", []) if isinstance(db_detail, dict) else []
dashboard_roles[db_id] = {r["id"] for r in roles_detail}
except Exception:
dashboard_roles[db_id] = set()
# Role → permissions
role_perms_map = await self._build_role_permissions_map()
# Dataset → datasource_access permission_view_menu_id
ds_access_map = await self._find_datasource_permissions()
# RLS rules
rls_resp = await self.client.request(method="GET", endpoint="/rowlevelsecurity/")
all_rls: list[dict] = []
if isinstance(rls_resp, dict):
all_rls = rls_resp.get("result", [])
# === 2. Build helpers ===
# User groups
user_groups: dict[int, list[dict]] = {}
user_group_roles: dict[int, set[int]] = {}
for gid, gdata in groups_detail.items():
g_name = gdata.get("name", "?")
g_roles = {r["id"] for r in gdata.get("roles", [])}
for u in gdata.get("users", []):
uid = u["id"]
if uid not in user_groups:
user_groups[uid] = []
user_group_roles[uid] = set()
user_groups[uid].append({"name": g_name, "roles": sorted(g_roles)})
user_group_roles[uid] |= g_roles
# RLS regions per role
role_rls_regions: dict[int, list[str]] = {}
for rule in all_rls:
clause = rule.get("clause", "")
roles = rule.get("roles", [])
region_match = re.search(r"operation_region\s*=\s*'([^']+)'", clause)
if clause == "1=1":
region = "_all_"
elif region_match:
region = region_match.group(1)
elif clause == "1=0":
region = "_deny_"
else:
continue
for r in roles:
rid = r["id"]
if rid not in role_rls_regions:
role_rls_regions[rid] = []
role_rls_regions[rid].append(region)
# === 3. Filter users ===
filtered_users = all_users
if not include_admin:
filtered_users = [
u for u in filtered_users
if not any(r.get("name") == "Admin" for r in u.get("roles", []))
]
if username_filter:
filtered_users = [
u for u in filtered_users
if username_filter.lower() in u.get("username", "").lower()
]
total = len(filtered_users)
start = page * page_size
end = start + page_size
page_users = filtered_users[start:end]
# Data structures
dashboard_info = {
d["id"]: d.get("dashboard_title", d.get("slug", f"id:{d['id']}"))
for d in all_dashboards
}
dataset_info = {
d["id"]: d.get("table_name", f"id:{d['id']}") for d in all_datasets
}
# === 4. Build audit matrix ===
audit_rows = []
for user in page_users:
uid = user["id"]
uname = user.get("username", "?")
direct_role_ids = {r["id"] for r in user.get("roles", [])}
group_role_ids = user_group_roles.get(uid, set())
effective_role_ids = direct_role_ids | group_role_ids
user_perm_ids: set[int] = set()
for rid in effective_role_ids:
user_perm_ids |= role_perms_map.get(rid, set())
# Dataset access
datasets_access: dict[str, int] = {}
for ds in all_datasets:
ds_id = ds["id"]
ds_name = dataset_info[ds_id]
pvm_id = ds_access_map.get(ds_id)
datasets_access[ds_name] = 1 if (pvm_id and pvm_id in user_perm_ids) else 0
# Dashboard access
dashboards_access: dict[str, Any] = {}
for db in all_dashboards:
db_id = db["id"]
db_name = dashboard_info[db_id]
db_role_ids = dashboard_roles.get(db_id, set())
is_visible = bool(effective_role_ids & db_role_ids) if db_role_ids else True
required_ds = dashboard_datasets.get(db_id, set())
if not required_ds:
has_data = True
else:
has_data = all(
ds_access_map.get(ds_id) in user_perm_ids
for ds_id in required_ds
if ds_access_map.get(ds_id) is not None
)
if is_visible and has_data:
dashboards_access[db_name] = 1
elif is_visible and not has_data:
dashboards_access[db_name] = "visible_no_data"
else:
dashboards_access[db_name] = 0
# RLS regions
rls_regions: list[str] = []
has_deny = False
for rid in effective_role_ids:
regions = role_rls_regions.get(rid, [])
for region in regions:
if region == "_all_":
rls_regions = ["ALL REGIONS"]
break
elif region == "_deny_":
has_deny = True
elif region not in rls_regions:
rls_regions.append(region)
if rls_regions == ["ALL REGIONS"]:
break
if not rls_regions and has_deny:
rls_regions = ["DENIED (1=0)"]
elif not rls_regions:
rls_regions = ["no RLS"]
groups_names = [g["name"] for g in user_groups.get(uid, [])]
audit_rows.append({
"user_id": uid,
"username": uname,
"active": user.get("active", True),
"groups": groups_names,
"dashboards": dashboards_access,
"datasets": datasets_access,
"rls_regions": sorted(rls_regions),
})
result = {
"page": page,
"page_size": page_size,
"total_users": total,
"total_pages": (total + page_size - 1) // page_size if page_size > 0 else 0,
"dashboards_checked": list(dashboard_info.values()),
"datasets_checked": list(dataset_info.values()),
"users": audit_rows,
}
app_logger.reflect(
"Permissions audit complete",
extra={"total_users": total, "audited": len(audit_rows)},
)
return result
# #endregion SupersetAudit.PermissionsAudit
# #region SupersetAudit.FetchUsers [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Fetch all Superset users via security API.
# @SIDE_EFFECT Асинхронные HTTP-вызовы к Superset API.
async def _fetch_users(self) -> tuple[int, list[dict]]:
"""Fetch all users via /api/v1/security/users/ with pagination."""
with belief_scope("SupersetAuditMixin._fetch_users"):
app_logger.reason("Fetching all users")
validated_query = self._validate_query_params({})
paginated_data = await self._fetch_all_pages(
endpoint="/api/v1/security/users/",
pagination_options={
"base_query": validated_query,
"results_field": "result",
},
)
return len(paginated_data), paginated_data
# #endregion SupersetAudit.FetchUsers
# #region SupersetAudit.BuildRolePermissionsMap [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Build a mapping of role_id → set(permission_view_menu_id) for all roles.
async def _build_role_permissions_map(self) -> dict[int, set[int]]:
"""Build {role_id: set(permission_view_menu_ids)} for all roles."""
role_perms: dict[int, set[int]] = {}
try:
roles_resp = await self.client.request(method="GET", endpoint="/api/v1/security/roles/")
all_roles = roles_resp.get("result", []) if isinstance(roles_resp, dict) else []
except Exception:
roles_resp = await self.client.request(method="GET", endpoint="/security/roles/")
all_roles = roles_resp.get("result", []) if isinstance(roles_resp, dict) else []
for role in all_roles:
role_id = role["id"]
try:
perms_resp = await self.client.request(
method="GET", endpoint=f"/api/v1/security/roles/{role_id}/permissions/"
)
perm_ids: set[int] = set()
for p in perms_resp.get("result", []) if isinstance(perms_resp, dict) else []:
if isinstance(p, dict) and "id" in p:
perm_ids.add(p["id"])
elif isinstance(p, int):
perm_ids.add(p)
role_perms[role_id] = perm_ids
except Exception:
role_perms[role_id] = set()
return role_perms
# #endregion SupersetAudit.BuildRolePermissionsMap
# #region SupersetAudit.FindDatasourcePermissions [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Build {dataset_id: permission_view_menu_id} mapping by scanning permissions-resources.
async def _find_datasource_permissions(self) -> dict[int, int]:
"""Scan /api/v1/security/permissions-resources/ for datasource_access entries."""
found: dict[int, int] = {}
dataset_id_re = re.compile(r"\(id:(\d+)\)")
page = 0
while page < 50:
try:
resp = await self.client.request(
method="GET",
endpoint="/api/v1/security/permissions-resources/",
params={"q": f"(page:{page},page_size:100)"},
)
except Exception:
break
items = resp.get("result", []) if isinstance(resp, dict) else []
if not items:
break
for item in items:
if item.get("permission", {}).get("name", "") != "datasource_access":
continue
view_name = item.get("view_menu", {}).get("name", "")
match = dataset_id_re.search(view_name)
if match:
found[int(match.group(1))] = item["id"]
if len(items) < 100:
break
page += 1
return found
# #endregion SupersetAudit.FindDatasourcePermissions
# #endregion SupersetAuditMixin
# #endregion SupersetAuditMixin

View File

@@ -353,5 +353,81 @@ class SupersetDashboardsCrudMixin:
payload={"dashboard_id": dashboard_id, "response": response},
)
# #endregion SupersetClientDeleteDashboard
# #region SupersetClientGetDashboardCharts [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF List all charts on a dashboard (standalone public method).
# @SIDE_EFFECT Асинхронный HTTP-вызов: GET /api/v1/dashboard/{id}/charts.
async def get_dashboard_charts(self, dashboard_id: int) -> list[dict]:
with belief_scope("SupersetClient.get_dashboard_charts", f"id={dashboard_id}"):
app_logger.reason("Fetching dashboard charts", extra={"dashboard_id": dashboard_id})
try:
charts_response = await self.client.request(
method="GET", endpoint=f"/dashboard/{dashboard_id}/charts"
)
charts_payload = (
charts_response.get("result", [])
if isinstance(charts_response, dict)
else []
)
app_logger.reflect(
f"Found {len(charts_payload)} charts on dashboard {dashboard_id}"
)
return charts_payload
except Exception as e:
app_logger.explore(
"Failed to fetch dashboard charts",
extra={"dashboard_id": dashboard_id},
error=str(e),
)
raise
# #endregion SupersetClientGetDashboardCharts
# #region SupersetClientGetDashboardDatasets [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF List all datasets used by a dashboard (standalone public method).
# @SIDE_EFFECT Асинхронный HTTP-вызов: GET /api/v1/dashboard/{id}/datasets.
async def get_dashboard_datasets(self, dashboard_id: int) -> list[dict]:
with belief_scope("SupersetClient.get_dashboard_datasets", f"id={dashboard_id}"):
app_logger.reason("Fetching dashboard datasets", extra={"dashboard_id": dashboard_id})
try:
datasets_response = await self.client.request(
method="GET", endpoint=f"/dashboard/{dashboard_id}/datasets"
)
datasets_payload = (
datasets_response.get("result", [])
if isinstance(datasets_response, dict)
else []
)
app_logger.reflect(
f"Found {len(datasets_payload)} datasets on dashboard {dashboard_id}"
)
return datasets_payload
except Exception as e:
app_logger.explore(
"Failed to fetch dashboard datasets",
extra={"dashboard_id": dashboard_id},
error=str(e),
)
raise
# #endregion SupersetClientGetDashboardDatasets
# #region SupersetClientGetDashboard [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF Fetch raw dashboard response by ID or slug.
# @SIDE_EFFECT Асинхронный HTTP-вызов: GET /api/v1/dashboard/{ref}.
async def get_dashboard(self, dashboard_ref: int | str) -> dict:
with belief_scope("SupersetClient.get_dashboard", f"ref={dashboard_ref}"):
app_logger.reason("Fetching dashboard", extra={"dashboard_ref": str(dashboard_ref)})
try:
response = await self.client.request(
method="GET", endpoint=f"/dashboard/{dashboard_ref}"
)
app_logger.reflect(f"Got dashboard {dashboard_ref}")
return cast(dict, response)
except Exception as e:
app_logger.explore("Fetch dashboard failed", error=str(e))
raise
# #endregion SupersetClientGetDashboard
# #endregion SupersetDashboardsCrudMixin
# #endregion SupersetDashboardsCrudMixin

View File

@@ -298,5 +298,187 @@ class SupersetDashboardsWriteMixin:
raise RuntimeError("Cannot create markdown chart: no datasets available in Superset.")
# #endregion _resolve_markdown_datasource
# #region create_dashboard [C:4] [TYPE Function]
# @ingroup Core
# @BRIEF Create a new dashboard in Superset with optional roles, metadata, and positioning.
# @PRE Environment configured. dashboard_title is non-empty.
# @POST Returns created dashboard dict with id.
# @SIDE_EFFECT Асинхронный HTTP-вызов: POST /api/v1/dashboard/.
async def create_dashboard(
self,
dashboard_title: str,
slug: str | None = None,
published: bool = False,
json_metadata: str | None = None,
css: str | None = None,
position_json: str | None = None,
roles: list[int] | None = None,
) -> dict:
with belief_scope("SupersetDashboardsWriteMixin.create_dashboard"):
app_logger.reason(
"Creating dashboard",
extra={"title": dashboard_title, "published": published},
)
payload: dict[str, Any] = {"dashboard_title": dashboard_title, "published": published}
if slug:
payload["slug"] = slug
if json_metadata:
payload["json_metadata"] = json_metadata
if css:
payload["css"] = css
if position_json:
payload["position_json"] = position_json
if roles:
payload["roles"] = roles
try:
response = cast(dict, await self.client.request(
method="POST",
endpoint="/dashboard/",
data=payload,
))
app_logger.reflect(
"Dashboard created",
extra={"id": response.get("id"), "title": dashboard_title},
)
return response
except Exception as e:
app_logger.explore(
"Create dashboard failed",
extra={"title": dashboard_title},
error=str(e),
)
raise
# #endregion create_dashboard
# #region update_dashboard [C:4] [TYPE Function]
# @ingroup Core
# @BRIEF Update an existing dashboard's properties (title, slug, owners, roles, metadata, CSS).
# @PRE dashboard_id exists in Superset.
# @POST Returns updated dashboard dict.
# @SIDE_EFFECT Асинхронный HTTP-вызов: PUT /api/v1/dashboard/{id}.
async def update_dashboard(
self,
dashboard_id: int,
dashboard_title: str | None = None,
slug: str | None = None,
published: bool | None = None,
owners: list[int] | None = None,
roles: list[int] | None = None,
css: str | None = None,
json_metadata: str | None = None,
) -> dict:
with belief_scope("SupersetDashboardsWriteMixin.update_dashboard", f"id={dashboard_id}"):
app_logger.reason("Updating dashboard", extra={"dashboard_id": dashboard_id})
payload: dict[str, Any] = {}
if dashboard_title is not None:
payload["dashboard_title"] = dashboard_title
if slug is not None:
payload["slug"] = slug
if published is not None:
payload["published"] = published
if owners is not None:
payload["owners"] = owners
if roles is not None:
payload["roles"] = roles
if css is not None:
payload["css"] = css
if json_metadata is not None:
payload["json_metadata"] = json_metadata
try:
response = cast(dict, await self.client.request(
method="PUT",
endpoint=f"/dashboard/{dashboard_id}",
data=payload,
))
app_logger.reflect("Dashboard updated", extra={"dashboard_id": dashboard_id})
return response
except Exception as e:
app_logger.explore(
"Update dashboard failed",
extra={"dashboard_id": dashboard_id},
error=str(e),
)
raise
# #endregion update_dashboard
# #region copy_dashboard [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Deep-copy a dashboard (including all charts) to a new dashboard.
# @PRE dashboard_id exists.
# @POST Returns created copy dict.
# @SIDE_EFFECT Асинхронный HTTP-вызов: POST /api/v1/dashboard/{id}/copy/.
async def copy_dashboard(
self,
dashboard_id: int,
dashboard_title: str | None = None,
json_metadata: str | None = None,
) -> dict:
with belief_scope("SupersetDashboardsWriteMixin.copy_dashboard", f"id={dashboard_id}"):
app_logger.reason("Copying dashboard", extra={"dashboard_id": dashboard_id})
payload: dict[str, Any] = {}
if dashboard_title:
payload["dashboard_title"] = dashboard_title
if json_metadata:
payload["json_metadata"] = json_metadata
try:
response = cast(dict, await self.client.request(
method="POST",
endpoint=f"/dashboard/{dashboard_id}/copy/",
data=payload if payload else None,
))
app_logger.reflect(
"Dashboard copied",
extra={"source_id": dashboard_id, "copy_id": response.get("id")},
)
return response
except Exception as e:
app_logger.explore(
"Copy dashboard failed",
extra={"dashboard_id": dashboard_id},
error=str(e),
)
raise
# #endregion copy_dashboard
# #region publish_dashboard [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF Set dashboard published=true.
# @SIDE_EFFECT Асинхронный HTTP-вызов: PUT /api/v1/dashboard/{id}.
async def publish_dashboard(self, dashboard_id: int) -> dict:
with belief_scope("SupersetDashboardsWriteMixin.publish_dashboard", f"id={dashboard_id}"):
app_logger.reason("Publishing dashboard", extra={"dashboard_id": dashboard_id})
try:
response = cast(dict, await self.client.request(
method="PUT",
endpoint=f"/dashboard/{dashboard_id}",
data={"published": True},
))
app_logger.reflect("Dashboard published", extra={"dashboard_id": dashboard_id})
return response
except Exception as e:
app_logger.explore("Publish dashboard failed", error=str(e))
raise
# #endregion publish_dashboard
# #region unpublish_dashboard [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF Set dashboard published=false.
# @SIDE_EFFECT Асинхронный HTTP-вызов: PUT /api/v1/dashboard/{id}.
async def unpublish_dashboard(self, dashboard_id: int) -> dict:
with belief_scope("SupersetDashboardsWriteMixin.unpublish_dashboard", f"id={dashboard_id}"):
app_logger.reason("Unpublishing dashboard", extra={"dashboard_id": dashboard_id})
try:
response = cast(dict, await self.client.request(
method="PUT",
endpoint=f"/dashboard/{dashboard_id}",
data={"published": False},
))
app_logger.reflect("Dashboard unpublished", extra={"dashboard_id": dashboard_id})
return response
except Exception as e:
app_logger.explore("Unpublish dashboard failed", error=str(e))
raise
# #endregion unpublish_dashboard
# #endregion SupersetDashboardsWriteMixin
# #endregion SupersetDashboardsWriteMixin

View File

@@ -4,6 +4,7 @@
# @BRIEF Database domain mixin for SupersetClient — list, get, summary, by_uuid.
# @RELATION DEPENDS_ON -> [SupersetClientBase]
# @SIDE_EFFECT Асинхронные HTTP-вызовы к Superset API для работы с базами данных.
import json
from typing import Any, cast
from ..logger import belief_scope, logger as app_logger
@@ -128,5 +129,333 @@ class SupersetDatabasesMixin:
app_logger.reflect("Database deleted", extra={"database_id": database_id})
return response
# #endregion SupersetClientDeleteDatabase
# #region SupersetClientUpdateDatabase [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Update an existing database connection's properties.
# @SIDE_EFFECT Асинхронный HTTP-вызов: PUT /api/v1/database/{id}.
async def update_database(
self,
database_id: int,
database_name: str | None = None,
sqlalchemy_uri: str | None = None,
expose_in_sqllab: bool | None = None,
allow_ctas: bool | None = None,
allow_cvas: bool | None = None,
allow_dml: bool | None = None,
allow_run_async: bool | None = None,
extra: str | None = None,
) -> dict:
with belief_scope("SupersetClient.update_database", f"id={database_id}"):
app_logger.reason("Updating database", extra={"database_id": database_id})
payload: dict[str, Any] = {}
if database_name is not None:
payload["database_name"] = database_name
if sqlalchemy_uri is not None:
payload["sqlalchemy_uri"] = sqlalchemy_uri
if expose_in_sqllab is not None:
payload["expose_in_sqllab"] = expose_in_sqllab
if allow_ctas is not None:
payload["allow_ctas"] = allow_ctas
if allow_cvas is not None:
payload["allow_cvas"] = allow_cvas
if allow_dml is not None:
payload["allow_dml"] = allow_dml
if allow_run_async is not None:
payload["allow_run_async"] = allow_run_async
if extra is not None:
payload["extra"] = extra
try:
response = await self.client.request(
method="PUT",
endpoint=f"/database/{database_id}",
data=payload,
)
response = cast(dict, response)
app_logger.reflect(f"Database {database_id} updated")
return response
except Exception as e:
app_logger.explore("Update database failed", error=str(e))
raise
# #endregion SupersetClientUpdateDatabase
# #region SupersetClientTestDatabaseConnection [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Test a database connection URI without creating a database entry.
# @SIDE_EFFECT Асинхронный HTTP-вызов: POST /api/v1/database/test_connection/.
async def test_database_connection(
self,
database_name: str,
sqlalchemy_uri: str,
extra: str | None = None,
) -> dict:
with belief_scope("SupersetClient.test_database_connection"):
app_logger.reason("Testing database connection", extra={"name": database_name})
payload: dict[str, Any] = {
"database_name": database_name,
"sqlalchemy_uri": sqlalchemy_uri,
}
if extra:
payload["extra"] = extra
try:
response = await self.client.request(
method="POST",
endpoint="/database/test_connection/",
data=payload,
)
response = cast(dict, response)
app_logger.reflect("Database connection tested")
return response
except Exception as e:
app_logger.explore("Test connection failed", error=str(e))
raise
# #endregion SupersetClientTestDatabaseConnection
# #region SupersetClientGetDatabaseSchemas [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF List all schemas for a database.
# @SIDE_EFFECT Асинхронный HTTP-вызов: GET /api/v1/database/{id}/schemas/.
async def get_database_schemas(self, database_id: int) -> list[str]:
with belief_scope("SupersetClient.get_database_schemas", f"id={database_id}"):
app_logger.reason("Fetching database schemas", extra={"database_id": database_id})
try:
response = await self.client.request(
method="GET",
endpoint=f"/database/{database_id}/schemas/",
)
response = cast(dict, response)
schemas = response.get("result", [])
app_logger.reflect(f"Found {len(schemas)} schemas")
return schemas
except Exception as e:
app_logger.explore("Fetch schemas failed", error=str(e))
raise
# #endregion SupersetClientGetDatabaseSchemas
# #region SupersetClientGetDatabaseTables [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF List all tables/views for a database schema.
# @SIDE_EFFECT Асинхронный HTTP-вызов: GET /api/v1/database/{id}/tables/.
async def get_database_tables(
self,
database_id: int,
schema_name: str | None = None,
) -> list[dict]:
with belief_scope("SupersetClient.get_database_tables", f"id={database_id}"):
app_logger.reason("Fetching database tables", extra={"database_id": database_id})
params = {"q": json.dumps({"schema_name": schema_name})} if schema_name else None
try:
response = await self.client.request(
method="GET",
endpoint=f"/database/{database_id}/tables/",
params=params,
)
response = cast(dict, response)
tables = response.get("result", [])
app_logger.reflect(f"Found {len(tables)} tables")
return tables
except Exception as e:
app_logger.explore("Fetch tables failed", error=str(e))
raise
# #endregion SupersetClientGetDatabaseTables
# #region SupersetClientGetDatabaseCatalogs [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF List catalogs for a database (if supported by the DB engine).
# @SIDE_EFFECT Асинхронный HTTP-вызов: GET /api/v1/database/{id}/catalogs/.
async def get_database_catalogs(self, database_id: int) -> list[str]:
with belief_scope("SupersetClient.get_database_catalogs", f"id={database_id}"):
app_logger.reason("Fetching database catalogs", extra={"database_id": database_id})
try:
response = await self.client.request(
method="GET",
endpoint=f"/database/{database_id}/catalogs/",
)
response = cast(dict, response)
catalogs = response.get("result", [])
app_logger.reflect(f"Found {len(catalogs)} catalogs")
return catalogs
except Exception as e:
app_logger.explore("Fetch catalogs failed", error=str(e))
raise
# #endregion SupersetClientGetDatabaseCatalogs
# #region SupersetClientValidateSql [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Validate SQL syntax for a given database without executing.
# @SIDE_EFFECT Асинхронный HTTP-вызов: POST /api/v1/database/{id}/validate_sql/.
async def validate_sql(
self,
database_id: int,
sql: str,
schema: str | None = None,
) -> dict:
with belief_scope("SupersetClient.validate_sql", f"id={database_id}"):
app_logger.reason("Validating SQL", extra={"database_id": database_id})
payload: dict[str, Any] = {"sql": sql}
if schema:
payload["schema"] = schema
try:
response = await self.client.request(
method="POST",
endpoint=f"/database/{database_id}/validate_sql/",
data=payload,
)
response = cast(dict, response)
app_logger.reflect("SQL validated")
return response
except Exception as e:
app_logger.explore("Validate SQL failed", error=str(e))
raise
# #endregion SupersetClientValidateSql
# #region SupersetClientValidateParameters [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF Validate database connection parameters before creation.
# @SIDE_EFFECT Асинхронный HTTP-вызов: POST /api/v1/database/validate_parameters/.
async def validate_database_parameters(
self,
engine: str,
parameters: dict[str, Any],
configuration_method: str = "dynamic_form",
) -> dict:
with belief_scope("SupersetClient.validate_database_parameters"):
app_logger.reason("Validating database parameters", extra={"engine": engine})
try:
response = await self.client.request(
method="POST",
endpoint="/database/validate_parameters/",
data={
"engine": engine,
"parameters": parameters,
"configuration_method": configuration_method,
},
)
response = cast(dict, response)
app_logger.reflect("Parameters validated")
return response
except Exception as e:
app_logger.explore("Validate parameters failed", error=str(e))
raise
# #endregion SupersetClientValidateParameters
# #region SupersetClientGetSelectStar [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF Generate a SELECT * query for a given table (template for SQL Lab).
# @SIDE_EFFECT Асинхронный HTTP-вызов: GET /api/v1/database/{id}/select_star/.
async def get_database_select_star(
self,
database_id: int,
table_name: str,
schema_name: str | None = None,
) -> str:
with belief_scope("SupersetClient.get_database_select_star", f"id={database_id}"):
app_logger.reason("Generating SELECT *", extra={"table": table_name})
params = {"q": json.dumps({"table_name": table_name, "schema_name": schema_name})}
try:
response = await self.client.request(
method="GET",
endpoint=f"/database/{database_id}/select_star/",
params=params,
)
response = cast(dict, response)
sql = response.get("result", "")
app_logger.reflect("SELECT * generated")
return sql
except Exception as e:
app_logger.explore("Generate SELECT * failed", error=str(e))
raise
# #endregion SupersetClientGetSelectStar
# #region SupersetClientGetTableMetadata [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF Get table metadata: columns, types, indexes, primary keys.
# @SIDE_EFFECT Асинхронный HTTP-вызов: GET /api/v1/database/{id}/table_metadata/.
async def get_database_table_metadata(
self,
database_id: int,
table_name: str,
schema_name: str | None = None,
) -> dict:
with belief_scope("SupersetClient.get_database_table_metadata", f"id={database_id}"):
app_logger.reason("Fetching table metadata", extra={"table": table_name})
params = {"q": json.dumps({"table_name": table_name, "schema_name": schema_name})}
try:
response = await self.client.request(
method="GET",
endpoint=f"/database/{database_id}/table_metadata/",
params=params,
)
response = cast(dict, response)
app_logger.reflect(f"Table metadata fetched for {table_name}")
return response
except Exception as e:
app_logger.explore("Fetch table metadata failed", error=str(e))
raise
# #endregion SupersetClientGetTableMetadata
# #region SupersetClientGetDatabaseRelatedObjects [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF Get related objects (impact analysis) for a database before deletion.
# @SIDE_EFFECT Асинхронный HTTP-вызов: GET /api/v1/database/{id}/related_objects/.
async def get_database_related_objects(self, database_id: int) -> dict:
with belief_scope("SupersetClient.get_database_related_objects", f"id={database_id}"):
app_logger.reason("Fetching database related objects", extra={"database_id": database_id})
try:
response = await self.client.request(
method="GET",
endpoint=f"/database/{database_id}/related_objects/",
)
response = cast(dict, response)
app_logger.reflect(f"Related objects fetched for database {database_id}")
return response
except Exception as e:
app_logger.explore("Fetch related objects failed", error=str(e))
raise
# #endregion SupersetClientGetDatabaseRelatedObjects
# #region SupersetClientExportDatabase [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Export database configurations as ZIP.
# @SIDE_EFFECT Асинхронный HTTP-вызов: GET /api/v1/database/export/.
async def export_database(self, database_ids: list[int]) -> tuple[bytes, str]:
with belief_scope("SupersetClient.export_database"):
app_logger.reason("Exporting databases", extra={"database_ids": database_ids})
try:
response = await self.client.request(
method="GET",
endpoint="/database/export/",
params={"q": json.dumps(database_ids)},
raw_response=True,
)
response = cast(Any, response)
content = response.content if hasattr(response, "content") else response
app_logger.reflect(f"Exported {len(database_ids)} databases")
return content, f"databases_export_{len(database_ids)}.zip"
except Exception as e:
app_logger.explore("Export databases failed", error=str(e))
raise
# #endregion SupersetClientExportDatabase
# #region SupersetClientGetAvailableEngines [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF List all supported database engine types.
# @SIDE_EFFECT Асинхронный HTTP-вызов: GET /api/v1/database/available/.
async def get_available_engines(self) -> list[dict]:
with belief_scope("SupersetClient.get_available_engines"):
app_logger.reason("Fetching available database engines")
try:
response = await self.client.request(
method="GET",
endpoint="/database/available/",
)
response = cast(dict, response)
engines = response.get("result", [])
app_logger.reflect(f"Found {len(engines)} available engines")
return engines
except Exception as e:
app_logger.explore("Fetch available engines failed", error=str(e))
raise
# #endregion SupersetClientGetAvailableEngines
# #endregion SupersetDatabasesMixin
# #endregion SupersetDatabasesMixin

View File

@@ -5,6 +5,7 @@
# @RELATION DEPENDS_ON -> [SupersetClientBase]
# @SIDE_EFFECT Асинхронные HTTP-вызовы к Superset API для работы с датасетами.
import json
from pathlib import Path
from typing import Any, cast
from ..logger import belief_scope, logger as app_logger
@@ -254,5 +255,214 @@ class SupersetDatasetsMixin:
app_logger.reflect(f"Updated dataset {dataset_id}.", extra={"src": "update_dataset"})
return response
# #endregion SupersetClientUpdateDataset
# #region SupersetClientCreateDataset [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Create a new physical or virtual dataset in Superset.
# @POST Returns created dataset dict with id.
# @SIDE_EFFECT Асинхронный HTTP-вызов: POST /api/v1/dataset/.
async def create_dataset(
self,
table_name: str,
database: int,
schema_name: str | None = None,
sql: str | None = None,
) -> dict:
with belief_scope("SupersetClient.create_dataset"):
app_logger.reason(
"Creating dataset",
extra={"table_name": table_name, "database": database},
)
payload: dict[str, Any] = {
"table_name": table_name,
"database": database,
}
if schema_name:
payload["schema"] = schema_name
if sql:
payload["sql"] = sql
try:
response = await self.client.request(
method="POST",
endpoint="/dataset/",
data=payload,
)
response = cast(dict, response)
app_logger.reflect(
"Dataset created",
extra={"id": response.get("id"), "table_name": table_name},
)
return response
except Exception as e:
app_logger.explore("Create dataset failed", error=str(e))
raise
# #endregion SupersetClientCreateDataset
# #region SupersetClientDeleteDataset [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Delete a dataset from Superset by ID.
# @SIDE_EFFECT Асинхронный HTTP-вызов: DELETE /api/v1/dataset/{id}.
async def delete_dataset(self, dataset_id: int) -> dict:
with belief_scope("SupersetClient.delete_dataset", f"id={dataset_id}"):
app_logger.reason("Deleting dataset", extra={"dataset_id": dataset_id})
try:
response = await self.client.request(
method="DELETE",
endpoint=f"/dataset/{dataset_id}",
)
response = cast(dict, response)
app_logger.reflect(f"Dataset {dataset_id} deleted")
return response
except Exception as e:
app_logger.explore("Delete dataset failed", error=str(e))
raise
# #endregion SupersetClientDeleteDataset
# #region SupersetClientDuplicateDataset [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Duplicate a dataset including its columns and metrics.
# @SIDE_EFFECT Асинхронный HTTP-вызов: POST /api/v1/dataset/duplicate.
async def duplicate_dataset(self, base_model_id: int, table_name: str) -> dict:
with belief_scope("SupersetClient.duplicate_dataset", f"id={base_model_id}"):
app_logger.reason("Duplicating dataset", extra={"base_model_id": base_model_id})
try:
response = await self.client.request(
method="POST",
endpoint="/dataset/duplicate",
data={"base_model_id": base_model_id, "table_name": table_name},
)
response = cast(dict, response)
app_logger.reflect(
"Dataset duplicated",
extra={"source_id": base_model_id, "new_id": response.get("id")},
)
return response
except Exception as e:
app_logger.explore("Duplicate dataset failed", error=str(e))
raise
# #endregion SupersetClientDuplicateDataset
# #region SupersetClientRefreshDatasetSchema [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Rescan columns and types for a dataset from its source database.
# @SIDE_EFFECT Асинхронный HTTP-вызов: PUT /api/v1/dataset/{id}/refresh.
async def refresh_dataset_schema(self, dataset_id: int) -> dict:
with belief_scope("SupersetClient.refresh_dataset_schema", f"id={dataset_id}"):
app_logger.reason("Refreshing dataset schema", extra={"dataset_id": dataset_id})
try:
response = await self.client.request(
method="PUT",
endpoint=f"/dataset/{dataset_id}/refresh",
)
response = cast(dict, response)
app_logger.reflect(f"Dataset {dataset_id} schema refreshed")
return response
except Exception as e:
app_logger.explore("Refresh schema failed", error=str(e))
raise
# #endregion SupersetClientRefreshDatasetSchema
# #region SupersetClientGetOrCreateDataset [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Idempotently get or create a dataset by database_id, table_name, and optional schema.
# @SIDE_EFFECT Асинхронные HTTP-вызовы: GET /dataset/ then POST /dataset/get_or_create/.
async def get_or_create_dataset(
self,
database_id: int,
table_name: str,
schema_name: str | None = None,
) -> dict:
with belief_scope("SupersetClient.get_or_create_dataset"):
app_logger.reason(
"Get-or-create dataset",
extra={"database_id": database_id, "table_name": table_name},
)
try:
response = await self.client.request(
method="POST",
endpoint="/dataset/get_or_create/",
data={
"database_id": database_id,
"table_name": table_name,
"schema": schema_name,
},
)
response = cast(dict, response)
app_logger.reflect(
"Dataset get-or-create done",
extra={"id": response.get("id"), "table_name": table_name},
)
return response
except Exception as e:
app_logger.explore("Get-or-create dataset failed", error=str(e))
raise
# #endregion SupersetClientGetOrCreateDataset
# #region SupersetClientExportDataset [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Export datasets as a ZIP archive by their IDs.
# @SIDE_EFFECT Асинхронный HTTP-вызов: GET /api/v1/dataset/export/.
async def export_dataset(self, dataset_ids: list[int]) -> tuple[bytes, str]:
with belief_scope("SupersetClient.export_dataset"):
app_logger.reason("Exporting datasets", extra={"dataset_ids": dataset_ids})
try:
response = await self.client.request(
method="GET",
endpoint="/dataset/export/",
params={"q": json.dumps(dataset_ids)},
raw_response=True,
)
response = cast(Any, response)
content = response.content if hasattr(response, "content") else response
app_logger.reflect(f"Exported {len(dataset_ids)} datasets")
return content, f"datasets_export_{len(dataset_ids)}.zip"
except Exception as e:
app_logger.explore("Export datasets failed", error=str(e))
raise
# #endregion SupersetClientExportDataset
# #region SupersetClientImportDataset [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Import datasets from a ZIP file.
# @SIDE_EFFECT Асинхронный HTTP-вызов: POST /api/v1/dataset/import/.
async def import_dataset(self, file_path: str, overwrite: bool = True) -> dict:
with belief_scope("SupersetClient.import_dataset"):
app_logger.reason("Importing datasets", extra={"file": file_path})
try:
response = await self.client.upload_file(
endpoint="/dataset/import/",
file_info={
"file_obj": file_path,
"file_name": Path(file_path).name,
"form_field": "formData",
},
extra_data={"overwrite": "true" if overwrite else "false"},
)
app_logger.reflect("Datasets imported")
return cast(dict, response)
except Exception as e:
app_logger.explore("Import datasets failed", error=str(e))
raise
# #endregion SupersetClientImportDataset
# #region SupersetClientGetDatasetRelatedObjects [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Get related charts and dashboards for a dataset (standalone public method).
# @SIDE_EFFECT Асинхронный HTTP-вызов: GET /api/v1/dataset/{id}/related_objects/.
async def get_dataset_related_objects(self, dataset_id: int) -> dict:
with belief_scope("SupersetClient.get_dataset_related_objects", f"id={dataset_id}"):
app_logger.reason("Fetching dataset related objects", extra={"dataset_id": dataset_id})
try:
response = await self.client.request(
method="GET",
endpoint=f"/dataset/{dataset_id}/related_objects",
)
response = cast(dict, response)
app_logger.reflect(f"Got related objects for dataset {dataset_id}")
return response
except Exception as e:
app_logger.explore("Fetch related objects failed", error=str(e))
raise
# #endregion SupersetClientGetDatasetRelatedObjects
# #endregion SupersetDatasetsMixin
# #endregion SupersetDatasetsMixin

View File

@@ -0,0 +1,160 @@
# #region SupersetSavedQueriesMixin [C:3] [TYPE Module] [SEMANTICS superset,saved-query,crud]
# @defgroup Core Module group.
# @BRIEF Saved SQL queries mixin for SupersetClient — list, create, get, update, delete.
# @LAYER Infrastructure
# @RELATION DEPENDS_ON -> [SupersetClientBase]
# @SIDE_EFFECT Асинхронные HTTP-вызовы к Superset API для CRUD-операций с сохранёнными запросами.
import json
from typing import Any, cast
from ..logger import belief_scope, logger as app_logger
app_logger = cast(Any, app_logger)
# #region SupersetSavedQueriesMixin [C:3] [TYPE Class]
# @defgroup Core Module group.
# @BRIEF Mixin providing saved query CRUD operations.
# @RELATION DEPENDS_ON -> [SupersetClientBase]
# @SIDE_EFFECT Асинхронные HTTP-вызовы к Superset API.
class SupersetSavedQueriesMixin:
"""Mixin for Superset saved SQL queries CRUD."""
# #region SupersetSavedQueries.List [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF List all saved SQL queries with pagination.
# @SIDE_EFFECT Асинхронные HTTP-вызовы к Superset API для пагинации.
async def get_saved_queries(self, query: dict | None = None) -> tuple[int, list[dict]]:
with belief_scope("SupersetSavedQueriesMixin.get_saved_queries"):
app_logger.reason("Fetching saved queries")
validated_query = self._validate_query_params(query or {})
paginated_data = await self._fetch_all_pages(
endpoint="/saved_query/",
pagination_options={
"base_query": validated_query,
"results_field": "result",
},
)
total_count = len(paginated_data)
app_logger.reflect(f"Found {total_count} saved queries.")
return total_count, paginated_data
# #endregion SupersetSavedQueries.List
# #region SupersetSavedQueries.Get [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF Get a single saved query by ID.
# @SIDE_EFFECT Асинхронный HTTP-вызов к Superset API: GET /api/v1/saved_query/{id}.
async def get_saved_query(self, query_id: int) -> dict:
with belief_scope("SupersetSavedQueriesMixin.get_saved_query", f"id={query_id}"):
app_logger.reason("Fetching saved query", extra={"query_id": query_id})
try:
response = await self.client.request(
method="GET",
endpoint=f"/saved_query/{query_id}",
)
response = cast(dict, response)
app_logger.reflect(f"Got saved query {query_id}")
return response
except Exception as e:
app_logger.explore("Fetch saved query failed", error=str(e))
raise
# #endregion SupersetSavedQueries.Get
# #region SupersetSavedQueries.Create [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Create a new saved SQL query.
# @SIDE_EFFECT Асинхронный HTTP-вызов к Superset API: POST /api/v1/saved_query/.
async def create_saved_query(
self,
label: str,
db_id: int,
sql: str,
schema: str | None = None,
description: str | None = None,
) -> dict:
with belief_scope("SupersetSavedQueriesMixin.create_saved_query"):
app_logger.reason("Creating saved query", extra={"label": label, "db_id": db_id})
payload: dict[str, Any] = {
"label": label,
"db_id": db_id,
"sql": sql,
}
if schema:
payload["schema"] = schema
if description:
payload["description"] = description
try:
response = await self.client.request(
method="POST",
endpoint="/saved_query/",
data=payload,
)
response = cast(dict, response)
app_logger.reflect(
"Saved query created",
extra={"id": response.get("id"), "label": label},
)
return response
except Exception as e:
app_logger.explore("Create saved query failed", error=str(e))
raise
# #endregion SupersetSavedQueries.Create
# #region SupersetSavedQueries.Update [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Update an existing saved SQL query.
# @SIDE_EFFECT Асинхронный HTTP-вызов к Superset API: PUT /api/v1/saved_query/{id}.
async def update_saved_query(
self,
query_id: int,
label: str | None = None,
sql: str | None = None,
schema: str | None = None,
description: str | None = None,
) -> dict:
with belief_scope("SupersetSavedQueriesMixin.update_saved_query", f"id={query_id}"):
app_logger.reason("Updating saved query", extra={"query_id": query_id})
payload: dict[str, Any] = {}
if label is not None:
payload["label"] = label
if sql is not None:
payload["sql"] = sql
if schema is not None:
payload["schema"] = schema
if description is not None:
payload["description"] = description
try:
response = await self.client.request(
method="PUT",
endpoint=f"/saved_query/{query_id}",
data=payload,
)
response = cast(dict, response)
app_logger.reflect(f"Saved query {query_id} updated")
return response
except Exception as e:
app_logger.explore("Update saved query failed", error=str(e))
raise
# #endregion SupersetSavedQueries.Update
# #region SupersetSavedQueries.Delete [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Delete a saved SQL query by ID.
# @SIDE_EFFECT Асинхронный HTTP-вызов к Superset API: DELETE /api/v1/saved_query/{id}.
async def delete_saved_query(self, query_id: int) -> dict:
with belief_scope("SupersetSavedQueriesMixin.delete_saved_query", f"id={query_id}"):
app_logger.reason("Deleting saved query", extra={"query_id": query_id})
try:
response = await self.client.request(
method="DELETE",
endpoint=f"/saved_query/{query_id}",
)
response = cast(dict, response)
app_logger.reflect(f"Saved query {query_id} deleted")
return response
except Exception as e:
app_logger.explore("Delete saved query failed", error=str(e))
raise
# #endregion SupersetSavedQueries.Delete
# #endregion SupersetSavedQueriesMixin
# #endregion SupersetSavedQueriesMixin

View File

@@ -0,0 +1,265 @@
# #region SupersetSqlLabMixin [C:4] [TYPE Module] [SEMANTICS superset,sql-lab,query,execute,format,export]
# @defgroup Core Module group.
# @BRIEF SQL Lab mixin for SupersetClient — SQL execute, format, results, estimate, CSV export, query history.
# @LAYER Infrastructure
# @RELATION DEPENDS_ON -> [SupersetClientBase]
# @RELATION DEPENDS_ON -> [SupersetClient.Safety.DetectDangerousSql]
# @SIDE_EFFECT Асинхронные HTTP-вызовы к Superset API для SQL-операций.
# @INVARIANT All execute operations pass through DDL/DML guard from safety.py.
import json
from typing import Any, cast
from ..logger import belief_scope, logger as app_logger
from .safety import is_dangerous_sql
app_logger = cast(Any, app_logger)
# #region SupersetSqlLabMixin [C:4] [TYPE Class]
# @defgroup Core Module group.
# @BRIEF Mixin providing SQL Lab execution, formatting, results retrieval, cost estimation, CSV export, and query history.
# @RELATION DEPENDS_ON -> [SupersetClientBase]
# @SIDE_EFFECT Асинхронные HTTP-вызовы к Superset API.
class SupersetSqlLabMixin:
"""Mixin for Superset SQL Lab operations."""
# #region SupersetSqlLab.ExecuteSql [C:4] [TYPE Function]
# @ingroup Core
# @BRIEF Execute a read-only SQL query via Superset SQL Lab with DDL/DML guard.
# @PRE database_id exists. sql is a valid SELECT query.
# @POST Returns SQL Lab execute response dict with results key.
# @SIDE_EFFECT Асинхронный HTTP-вызов к Superset API: POST /api/v1/sqllab/execute/.
# @INVARIANT Only SELECT queries pass the DDL/DML guard; dangerous queries rejected.
async def execute_sql(
self,
database_id: int,
sql: str,
schema: str | None = None,
catalog: str | None = None,
tab_name: str | None = None,
template_params: str | None = None,
) -> dict:
with belief_scope("SupersetSqlLabMixin.execute_sql", f"db={database_id}"):
# Guard against DDL/DML
dangerous, keyword = is_dangerous_sql(sql)
if dangerous:
app_logger.explore(
"Dangerous SQL rejected",
extra={"database_id": database_id, "keyword": keyword},
error=f"Query contains dangerous keyword: {keyword}",
)
return {
"error": f"Dangerous SQL rejected: keyword '{keyword}' found. Only SELECT queries are allowed.",
"keyword": keyword,
}
app_logger.reason(
"Executing SQL query",
extra={"database_id": database_id, "sql_len": len(sql)},
)
payload: dict[str, Any] = {
"database_id": database_id,
"sql": sql,
}
if schema:
payload["schema"] = schema
if catalog:
payload["catalog"] = catalog
if tab_name:
payload["tab_name"] = tab_name
if template_params:
payload["template_params"] = template_params
try:
response = await self.client.request(
method="POST",
endpoint="/sqllab/execute/",
data=payload,
)
response = cast(dict, response)
app_logger.reflect(
"SQL executed",
extra={"database_id": database_id, "status": response.get("status")},
)
return response
except Exception as e:
app_logger.explore(
"SQL execution failed",
extra={"database_id": database_id},
error=str(e),
)
raise
# #endregion SupersetSqlLab.ExecuteSql
# #region SupersetSqlLab.FormatSql [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF Format/pretty-print a SQL query via Superset SQL Lab.
# @PRE sql is a valid SQL string.
# @POST Returns formatted SQL string.
# @SIDE_EFFECT Асинхронный HTTP-вызов к Superset API: POST /api/v1/sqllab/format_sql/.
async def format_sql(self, sql: str) -> str:
with belief_scope("SupersetSqlLabMixin.format_sql"):
app_logger.reason("Formatting SQL", extra={"sql_len": len(sql)})
try:
response = await self.client.request(
method="POST",
endpoint="/sqllab/format_sql/",
data={"sql": sql},
)
response = cast(dict, response)
formatted = response.get("result", sql)
app_logger.reflect("SQL formatted")
return formatted
except Exception as e:
app_logger.explore("SQL format failed", error=str(e))
raise
# #endregion SupersetSqlLab.FormatSql
# #region SupersetSqlLab.GetResults [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF Retrieve async SQL Lab results by results key.
# @PRE results_key is a valid key from a prior execute_sql call.
# @POST Returns results dict with data and columns.
# @SIDE_EFFECT Асинхронный HTTP-вызов к Superset API: GET /api/v1/sqllab/results/.
async def get_sql_results(self, results_key: str) -> dict:
with belief_scope("SupersetSqlLabMixin.get_sql_results"):
app_logger.reason("Fetching SQL results", extra={"results_key": results_key})
try:
response = await self.client.request(
method="GET",
endpoint="/sqllab/results/",
params={"q": json.dumps({"key": results_key})},
)
response = cast(dict, response)
app_logger.reflect(
"SQL results fetched",
extra={"row_count": len(response.get("data", []))},
)
return response
except Exception as e:
app_logger.explore("Fetch SQL results failed", error=str(e))
raise
# #endregion SupersetSqlLab.GetResults
# #region SupersetSqlLab.EstimateCost [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF Estimate the cost/EXPLAIN of a SQL query.
# @PRE database_id exists. sql is valid.
# @POST Returns cost estimation dict.
# @SIDE_EFFECT Асинхронный HTTP-вызов к Superset API: POST /api/v1/sqllab/estimate/.
async def estimate_sql_cost(
self,
database_id: int,
sql: str,
schema: str | None = None,
) -> dict:
with belief_scope("SupersetSqlLabMixin.estimate_sql_cost", f"db={database_id}"):
app_logger.reason("Estimating SQL cost", extra={"database_id": database_id})
payload: dict[str, Any] = {
"database_id": database_id,
"sql": sql,
}
if schema:
payload["schema"] = schema
try:
response = await self.client.request(
method="POST",
endpoint="/sqllab/estimate/",
data=payload,
)
response = cast(dict, response)
app_logger.reflect("SQL cost estimated")
return response
except Exception as e:
app_logger.explore("SQL cost estimation failed", error=str(e))
raise
# #endregion SupersetSqlLab.EstimateCost
# #region SupersetSqlLab.ExportCsv [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF Export SQL Lab results as CSV by client ID.
# @PRE client_id is a valid SQL Lab client ID.
# @POST Returns raw CSV bytes.
# @SIDE_EFFECT Асинхронный HTTP-вызов к Superset API: GET /api/v1/sqllab/export/{id}/.
async def export_sql_csv(self, client_id: str) -> bytes:
with belief_scope("SupersetSqlLabMixin.export_sql_csv"):
app_logger.reason("Exporting SQL CSV", extra={"client_id": client_id})
try:
response = cast(
Any,
await self.client.request(
method="GET",
endpoint=f"/sqllab/export/{client_id}/",
raw_response=True,
),
)
app_logger.reflect("SQL CSV exported")
return response.content if hasattr(response, "content") else response
except Exception as e:
app_logger.explore("Export CSV failed", error=str(e))
raise
# #endregion SupersetSqlLab.ExportCsv
# #region SupersetSqlLab.GetQueryHistory [C:3] [TYPE Function]
# @ingroup Core
# @BRIEF Get query execution history (recent SQL Lab queries).
# @SIDE_EFFECT Асинхронные HTTP-вызовы к Superset API для пагинации.
# @RELATION CALLS -> [SupersetClientBase._fetch_all_pages]
async def get_query_history(self, query: dict | None = None) -> tuple[int, list[dict]]:
with belief_scope("SupersetSqlLabMixin.get_query_history"):
app_logger.reason("Fetching query history")
validated_query = self._validate_query_params(query or {})
paginated_data = await self._fetch_all_pages(
endpoint="/query/",
pagination_options={
"base_query": validated_query,
"results_field": "result",
},
)
total_count = len(paginated_data)
app_logger.reflect(f"Found {total_count} query entries.")
return total_count, paginated_data
# #endregion SupersetSqlLab.GetQueryHistory
# #region SupersetSqlLab.GetQuery [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF Get a single query execution detail by ID.
# @SIDE_EFFECT Асинхронный HTTP-вызов к Superset API: GET /api/v1/query/{id}.
async def get_query(self, query_id: int) -> dict:
with belief_scope("SupersetSqlLabMixin.get_query", f"id={query_id}"):
app_logger.reason("Fetching query", extra={"query_id": query_id})
try:
response = await self.client.request(
method="GET",
endpoint=f"/query/{query_id}",
)
response = cast(dict, response)
app_logger.reflect(f"Got query {query_id}")
return response
except Exception as e:
app_logger.explore("Fetch query failed", error=str(e))
raise
# #endregion SupersetSqlLab.GetQuery
# #region SupersetSqlLab.StopQuery [C:2] [TYPE Function]
# @ingroup Core
# @BRIEF Stop a running async SQL Lab query by ID.
# @SIDE_EFFECT Асинхронный HTTP-вызов к Superset API: POST /api/v1/query/stop.
async def stop_query(self, query_id: int) -> dict:
with belief_scope("SupersetSqlLabMixin.stop_query", f"id={query_id}"):
app_logger.reason("Stopping query", extra={"query_id": query_id})
try:
response = await self.client.request(
method="POST",
endpoint="/query/stop",
data={"client_id": str(query_id)},
)
response = cast(dict, response)
app_logger.reflect(f"Query {query_id} stopped")
return response
except Exception as e:
app_logger.explore("Stop query failed", error=str(e))
raise
# #endregion SupersetSqlLab.StopQuery
# #endregion SupersetSqlLabMixin
# #endregion SupersetSqlLabMixin

View File

@@ -0,0 +1,107 @@
# #region SupersetClient.Safety [C:2] [TYPE Module] [SEMANTICS superset,safety,sql,validation]
# @defgroup Core Module group.
# @BRIEF Safety guards for SupersetClient operations: DDL/DML blocking, date format validation.
# @LAYER Infrastructure
# @RELATION CALLED_BY -> [SupersetSqlLab.ExecuteSql]
# @RATIONALE Ported from mcp-superset queries.py DDL/DML guard. Strips comments and string
# literals before matching dangerous keywords as whole words anywhere in the query to prevent
# bypasses via CTEs, statement chaining, parentheses, EXPLAIN prefix, or PL/pgSQL DO blocks.
# @REJECTED Regex-only without comment/string stripping was rejected — hidden keywords in comments
# or string literals would be missed, allowing bypasses.
import re
# DDL/DML keywords that must never be executed via the agent.
# Detected as WHOLE WORDS ANYWHERE (not just at the start) to prevent bypasses.
# Order matters: TRUNCATE comes first so the rejection message names the most specific operation.
_DANGEROUS_KEYWORDS = (
"DROP",
"TRUNCATE",
"MERGE",
"DELETE",
"UPDATE",
"INSERT",
"ALTER",
"CREATE",
"GRANT",
"REVOKE",
"COPY",
"DO", # PL/pgSQL anonymous block
"EXECUTE", # dynamic SQL inside a DO block
)
# #region SupersetClient.Safety.StripComments [C:1] [TYPE Function] [SEMANTICS superset,safety,sql]
# @ingroup Core
# @BRIEF Strip SQL comments (single-line -- and multi-line /* */) for DDL/DML detection.
def _strip_sql_comments(sql: str) -> str:
"""Remove single-line and multi-line SQL comments."""
sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL)
sql = re.sub(r"--[^\n]*", " ", sql)
return sql.strip()
# #endregion SupersetClient.Safety.StripComments
# #region SupersetClient.Safety.StripStrings [C:1] [TYPE Function] [SEMANTICS superset,safety,sql]
# @ingroup Core
# @BRIEF Remove single-quoted string literals so keywords inside them don't trigger false positives.
def _strip_sql_strings(sql: str) -> str:
"""Remove SQL single-quoted string literals (handles escaped '' pairs)."""
return re.sub(r"'(?:''|[^'])*'", " ", sql)
# #endregion SupersetClient.Safety.StripStrings
# #region SupersetClient.Safety.DetectDangerousSql [C:2] [TYPE Function] [SEMANTICS superset,safety,sql,guard]
# @ingroup Core
# @BRIEF Return (is_dangerous, keyword) tuple for a given SQL query string.
# @PRE sql is a non-empty string.
# @POST Returns (True, keyword) if dangerous DDL/DML found, or (False, None) if safe.
# @RATIONALE Strips comments and string literals first, then matches each dangerous
# keyword as a whole word anywhere in the statement. Word boundaries keep
# legitimate identifiers safe (e.g. update_date, deleted_at).
def is_dangerous_sql(sql: str) -> tuple[bool, str | None]:
"""Return (is_dangerous_bool, keyword_or_none) for the given SQL query."""
if not sql or not sql.strip():
return False, None
cleaned = _strip_sql_strings(_strip_sql_comments(sql)).upper()
for keyword in _DANGEROUS_KEYWORDS:
if re.search(rf"\b{keyword}\b", cleaned):
return True, keyword
return False, None
# #endregion SupersetClient.Safety.DetectDangerousSql
# Deprecated viz_type patterns — for chart creation validation (future use)
_DEPRECATED_VIZ_TYPES = {
"pivot_table_v2": "pivot_table_v2 is removed in Superset 6.0; use 'pivot_table_v3' instead.",
"filter_box": "filter_box is deprecated; use native dashboard filters instead.",
"world_map": "world_map is deprecated; use 'deckgl' layers instead.",
"deck_multi": "deck_multi is deprecated; use 'deckgl' layers instead.",
"deck_scatter": "deck_scatter is deprecated; use 'deckgl' layers instead.",
"deck_screengrid": "deck_screengrid is deprecated; use 'deckgl' layers instead.",
"deck_grid": "deck_grid is deprecated; use 'deckgl' layers instead.",
"deck_path": "deck_path is deprecated; use 'deckgl' layers instead.",
"deck_polygon": "deck_polygon is deprecated; use 'deckgl' layers instead.",
"deck_hex": "deck_hex is deprecated; use 'deckgl' layers instead.",
"deck_geojson": "deck_geojson is deprecated; use 'deckgl' layers instead.",
"deck_arc": "deck_arc is deprecated; use 'deckgl' layers instead.",
"event_flow": "event_flow is deprecated; use 'graph' chart or other alternatives.",
"paired_ttest": "paired_ttest is deprecated; use 'table' or custom viz instead.",
"rose": "rose chart is deprecated; use 'pie' or 'bar' instead.",
"partition": "partition chart (icicle/tree/sunburst) is deprecated.",
}
# #region SupersetClient.Safety.ValidateVizType [C:1] [TYPE Function] [SEMANTICS superset,safety,chart,validation]
# @ingroup Core
# @BRIEF Check a viz_type string for deprecated patterns; return (is_deprecated, message).
def validate_viz_type(viz_type: str) -> tuple[bool, str | None]:
"""Return (is_deprecated_bool, reason_or_none) for the viz_type."""
if not viz_type:
return False, None
reason = _DEPRECATED_VIZ_TYPES.get(viz_type)
if reason:
return True, reason
return False, None
# #endregion SupersetClient.Safety.ValidateVizType
# #endregion SupersetClient.Safety

View File

@@ -0,0 +1,558 @@
# #region Test.Agent.SupersetTools [C:3] [TYPE Module] [SEMANTICS test,agent,tools,superset,sql,audit,dashboard,dataset]
# @BRIEF Integration tests for new Superset agent tools: SQL, explore DB, audit, create/copy dashboard, create dataset, format SQL.
# @RELATION BINDS_TO -> [AgentChat.Tools]
# @RELATION BINDS_TO -> [AgentChat.ToolResolver]
# @TEST_EDGE: safe_sql -> superset_execute_sql with SELECT passes
# @TEST_EDGE: dangerous_sql -> superset_execute_sql with DROP returns error
# @TEST_EDGE: external_fail -> HTTP error returns error string
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import httpx
# ── Helpers ──
def _mock_httpx_response(status_code=200, json_data=None, text=""):
"""Build a mock httpx.Response with given status, JSON, and text."""
resp = MagicMock(spec=httpx.Response)
resp.status_code = status_code
resp.text = text
resp.json = MagicMock(return_value=json_data or {})
return resp
# ═══════════════════════════════════════════════════════════════════
# Tool name registration
# ═══════════════════════════════════════════════════════════════════
class TestToolRegistration:
"""Test that new tools are registered in get_all_tools() and classification sets."""
def test_all_new_tools_registered(self):
"""All 7 new tools appear in get_all_tools()."""
with patch("src.agent.tools.logger", MagicMock()):
from src.agent.tools import get_all_tools
tools = get_all_tools()
tool_names = {t.name for t in tools}
expected = {
"superset_execute_sql",
"superset_explore_database",
"superset_audit_permissions",
"superset_create_dashboard",
"superset_copy_dashboard",
"superset_create_dataset",
"superset_format_sql",
}
assert expected.issubset(tool_names), f"Missing: {expected - tool_names}"
def test_tool_resolver_sets_contain_new_tools(self):
"""Classification sets in _tool_resolver.py contain the new tools."""
with patch("src.agent.tools.logger", MagicMock()):
from src.agent._tool_resolver import (
_SAFE_AGENT_TOOLS,
_GUARDED_AGENT_TOOLS,
_FAST_CONFIRM_TOOLS,
)
assert "superset_execute_sql" in _SAFE_AGENT_TOOLS
assert "superset_explore_database" in _SAFE_AGENT_TOOLS
assert "superset_audit_permissions" in _SAFE_AGENT_TOOLS
assert "superset_format_sql" in _SAFE_AGENT_TOOLS
assert "superset_create_dashboard" in _GUARDED_AGENT_TOOLS
assert "superset_copy_dashboard" in _GUARDED_AGENT_TOOLS
assert "superset_create_dataset" in _GUARDED_AGENT_TOOLS
assert "superset_explore_database" in _FAST_CONFIRM_TOOLS
assert "superset_audit_permissions" in _FAST_CONFIRM_TOOLS
assert "superset_format_sql" in _FAST_CONFIRM_TOOLS
# ═══════════════════════════════════════════════════════════════════
# Intent matching (get_tools_for_query)
# ═══════════════════════════════════════════════════════════════════
class TestIntentMatching:
"""Test get_tools_for_query matches intent keywords for new tools."""
def _get_for_query(self, query):
with patch("src.agent.tools.logger", MagicMock()):
from src.agent.tools import get_tools_for_query
return {t.name for t in get_tools_for_query(query)}
def test_sql_intent(self):
tools = self._get_for_query("run a SQL query on users")
assert "superset_execute_sql" in tools
def test_schema_intent(self):
tools = self._get_for_query("list all schemas in the database")
assert "superset_explore_database" in tools
def test_table_intent(self):
tools = self._get_for_query("show me the tables in public schema")
assert "superset_explore_database" in tools
def test_audit_intent(self):
tools = self._get_for_query("audit user permissions access rights")
assert "superset_audit_permissions" in tools
def test_create_dashboard_intent(self):
tools = self._get_for_query("create a new dashboard called Sales")
assert "superset_create_dashboard" in tools
def test_copy_dashboard_intent(self):
tools = self._get_for_query("copy dashboard 42")
assert "superset_copy_dashboard" in tools
def test_create_dataset_intent(self):
tools = self._get_for_query("create a new dataset for orders table")
assert "superset_create_dataset" in tools
def test_format_sql_intent(self):
# The intent matcher looks for exact phrase "format sql" in text
tools = self._get_for_query("please format sql for me")
assert "superset_format_sql" in tools
def test_no_match_returns_defaults(self):
tools = self._get_for_query("hello how are you")
assert "show_capabilities" in tools
assert "search_dashboards" in tools
# ═══════════════════════════════════════════════════════════════════
# superset_execute_sql
# ═══════════════════════════════════════════════════════════════════
class TestSupersetExecuteSql:
"""Test superset_execute_sql agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_safe_sql_execution(self):
"""Safe SELECT returns API result."""
mock_resp = _mock_httpx_response(200, text='{"status": "success", "data": []}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_execute_sql
result = await superset_execute_sql.ainvoke({
"environment_id": "prod",
"database_id": 1,
"sql": "SELECT 1",
})
assert "success" in result
@pytest.mark.asyncio
async def test_dangerous_sql_returns_error(self):
"""Dangerous SQL returns error from API (guard is server-side)."""
mock_resp = _mock_httpx_response(200, text='{"error": "Dangerous SQL rejected"}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_execute_sql
result = await superset_execute_sql.ainvoke({
"environment_id": "prod",
"database_id": 1,
"sql": "DROP TABLE users",
})
assert "Dangerous" in result
@pytest.mark.asyncio
async def test_optional_schema(self):
"""Schema parameter is passed when provided."""
mock_resp = _mock_httpx_response(200, text="{}")
with patch("httpx.AsyncClient.post") as mock_post:
mock_post.return_value = mock_resp
from src.agent.tools import superset_execute_sql
await superset_execute_sql.ainvoke({
"environment_id": "prod",
"database_id": 1,
"sql": "SELECT 1",
"query_schema": "public",
})
assert "schema" in mock_post.call_args[1]["params"]
@pytest.mark.asyncio
async def test_http_error(self):
"""HTTP error returns error string."""
mock_resp = _mock_httpx_response(500, text="Internal Server Error")
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_execute_sql
result = await superset_execute_sql.ainvoke({
"environment_id": "prod",
"database_id": 1,
"sql": "SELECT 1",
})
assert "Error 500" in result
# ═══════════════════════════════════════════════════════════════════
# superset_explore_database
# ═══════════════════════════════════════════════════════════════════
class TestSupersetExploreDatabase:
"""Test superset_explore_database agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_list_schemas(self):
"""Action 'schemas' GETs /api/agent/superset/databases/{id}/schemas."""
mock_resp = _mock_httpx_response(200, text='["public", "analytics"]')
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "schemas",
})
assert "public" in result
@pytest.mark.asyncio
async def test_list_tables(self):
"""Action 'tables' GETs /api/agent/superset/databases/{id}/tables."""
mock_resp = _mock_httpx_response(200, text="table1, table2")
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "tables",
"schema_name": "public",
})
assert "table" in result
@pytest.mark.asyncio
async def test_table_metadata(self):
"""Action 'table_metadata' GETs metadata endpoint."""
mock_resp = _mock_httpx_response(200, text='{"columns": []}')
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "table_metadata",
"table_name": "users",
})
assert "columns" in result
@pytest.mark.asyncio
async def test_select_star(self):
"""Action 'select_star' GETs select_star endpoint."""
mock_resp = _mock_httpx_response(200, text='"SELECT * FROM users"')
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "select_star",
"table_name": "users",
})
assert "SELECT" in result
@pytest.mark.asyncio
async def test_tables_missing_schema_returns_error(self):
"""Missing schema_name for 'tables' returns error without HTTP call."""
with patch("httpx.AsyncClient.get") as mock_get:
from src.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "tables",
})
assert "Error" in result
mock_get.assert_not_called()
@pytest.mark.asyncio
async def test_table_metadata_missing_table_name_returns_error(self):
"""Missing table_name for 'table_metadata' returns error without HTTP call."""
with patch("httpx.AsyncClient.get") as mock_get:
from src.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "table_metadata",
})
assert "Error" in result
mock_get.assert_not_called()
@pytest.mark.asyncio
async def test_unknown_action_returns_error(self):
"""Unknown action returns error without HTTP call."""
with patch("httpx.AsyncClient.get") as mock_get:
from src.agent.tools import superset_explore_database
result = await superset_explore_database.ainvoke({
"environment_id": "prod",
"database_id": 1,
"action": "invalid_action",
})
assert "unknown action" in result.lower()
mock_get.assert_not_called()
# ═══════════════════════════════════════════════════════════════════
# superset_audit_permissions
# ═══════════════════════════════════════════════════════════════════
class TestSupersetAuditPermissions:
"""Test superset_audit_permissions agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_basic_audit(self):
"""Permissions audit GETs /api/agent/superset/audit/permissions."""
mock_resp = _mock_httpx_response(200, text='{"users": [], "total_users": 0}')
with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_audit_permissions
result = await superset_audit_permissions.ainvoke({
"environment_id": "prod",
})
assert "total_users" in result
@pytest.mark.asyncio
async def test_audit_with_filters(self):
"""Username filter and include_admin are passed as query params."""
mock_resp = _mock_httpx_response(200, text="{}")
with patch("httpx.AsyncClient.get") as mock_get:
mock_get.return_value = mock_resp
from src.agent.tools import superset_audit_permissions
await superset_audit_permissions.ainvoke({
"environment_id": "prod",
"username_filter": "alice",
"include_admin": True,
})
call_params = mock_get.call_args[1]["params"]
assert call_params["username_filter"] == "alice"
assert call_params["include_admin"] == "true"
@pytest.mark.asyncio
async def test_audit_without_filters(self):
"""Without filters, no additional params beyond environment_id."""
mock_resp = _mock_httpx_response(200, text="{}")
with patch("httpx.AsyncClient.get") as mock_get:
mock_get.return_value = mock_resp
from src.agent.tools import superset_audit_permissions
await superset_audit_permissions.ainvoke({
"environment_id": "prod",
})
call_params = mock_get.call_args[1]["params"]
assert "username_filter" not in call_params
assert "include_admin" not in call_params
# ═══════════════════════════════════════════════════════════════════
# superset_create_dashboard
# ═══════════════════════════════════════════════════════════════════
class TestSupersetCreateDashboard:
"""Test superset_create_dashboard agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_create_dashboard(self):
"""Creates dashboard via POST to /api/agent/superset/dashboards."""
mock_resp = _mock_httpx_response(201, text='{"id": 1, "dashboard_title": "Sales"}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_create_dashboard
result = await superset_create_dashboard.ainvoke({
"environment_id": "prod",
"dashboard_title": "Sales",
"slug": "sales",
"published": True,
})
assert "Sales" in result
@pytest.mark.asyncio
async def test_create_dashboard_minimal(self):
"""Minimal create with just title."""
mock_resp = _mock_httpx_response(201, text='{"id": 2}')
with patch("httpx.AsyncClient.post") as mock_post:
mock_post.return_value = mock_resp
from src.agent.tools import superset_create_dashboard
await superset_create_dashboard.ainvoke({
"environment_id": "prod",
"dashboard_title": "Minimal",
})
call_params = mock_post.call_args[1]["params"]
assert call_params["dashboard_title"] == "Minimal"
assert call_params["published"] == "false"
# ═══════════════════════════════════════════════════════════════════
# superset_copy_dashboard
# ═══════════════════════════════════════════════════════════════════
class TestSupersetCopyDashboard:
"""Test superset_copy_dashboard agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_copy_dashboard(self):
"""Copies dashboard via POST to /api/agent/superset/dashboards/{id}/copy."""
mock_resp = _mock_httpx_response(201, text='{"id": 2, "result": "copied"}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_copy_dashboard
result = await superset_copy_dashboard.ainvoke({
"environment_id": "prod",
"dashboard_id": 1,
"dashboard_title": "Copy of Sales",
})
assert "copied" in result
@pytest.mark.asyncio
async def test_copy_dashboard_without_title(self):
"""Copy without optional title."""
mock_resp = _mock_httpx_response(201, text="{}")
with patch("httpx.AsyncClient.post") as mock_post:
mock_post.return_value = mock_resp
from src.agent.tools import superset_copy_dashboard
await superset_copy_dashboard.ainvoke({
"environment_id": "prod",
"dashboard_id": 1,
})
call_params = mock_post.call_args[1]["params"]
assert "dashboard_title" not in call_params
# ═══════════════════════════════════════════════════════════════════
# superset_create_dataset
# ═══════════════════════════════════════════════════════════════════
class TestSupersetCreateDataset:
"""Test superset_create_dataset agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_create_dataset(self):
"""Creates dataset via POST to /api/agent/superset/datasets."""
mock_resp = _mock_httpx_response(201, text='{"id": 5, "table_name": "orders"}')
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_create_dataset
result = await superset_create_dataset.ainvoke({
"environment_id": "prod",
"table_name": "orders",
"database": 1,
"schema_name": "public",
})
assert "orders" in result
@pytest.mark.asyncio
async def test_create_dataset_minimal(self):
"""Minimal create without schema."""
mock_resp = _mock_httpx_response(201, text='{"id": 6}')
with patch("httpx.AsyncClient.post") as mock_post:
mock_post.return_value = mock_resp
from src.agent.tools import superset_create_dataset
await superset_create_dataset.ainvoke({
"environment_id": "prod",
"table_name": "users",
"database": 1,
})
call_params = mock_post.call_args[1]["params"]
assert "schema_name" not in call_params
# ═══════════════════════════════════════════════════════════════════
# superset_format_sql
# ═══════════════════════════════════════════════════════════════════
class TestSupersetFormatSql:
"""Test superset_format_sql agent tool."""
@pytest.fixture(autouse=True)
def setup_mocks(self):
with patch("src.agent.tools.logger", MagicMock()), \
patch("src.agent.tools._dual_auth_headers", return_value={"Authorization": "Bearer test"}), \
patch("src.agent.tools.get_user_jwt", return_value="user-jwt"), \
patch("src.agent.tools.get_service_jwt", return_value="svc-jwt"):
yield
@pytest.mark.asyncio
async def test_format_sql(self):
"""Formats SQL via POST to /api/agent/superset/sqllab/format."""
mock_resp = _mock_httpx_response(200, text="SELECT\n 1\nFROM\n users\n")
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_format_sql
result = await superset_format_sql.ainvoke({
"environment_id": "prod",
"sql": "SELECT 1 FROM users",
})
assert "SELECT" in result
@pytest.mark.asyncio
async def test_format_sql_error(self):
"""HTTP error returns error string."""
mock_resp = _mock_httpx_response(500, text="Internal Error")
with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_resp)):
from src.agent.tools import superset_format_sql
result = await superset_format_sql.ainvoke({
"environment_id": "prod",
"sql": "INVALID",
})
assert "Error 500" in result
# ═══════════════════════════════════════════════════════════════════
# Tool Resolver inference
# ═══════════════════════════════════════════════════════════════════
class TestToolResolverInference:
"""Test _tool_resolver.py inference with new Superset tool patterns."""
def test_infer_from_text_sql(self):
"""SQL-related query infers superset_execute_sql."""
with patch("src.agent.tools.logger", MagicMock()):
from src.agent._tool_resolver import infer_tool_from_text
# The resolver doesn't currently have SQL inference — verify fallback
# but ensure it's extended if needed in the future
result = infer_tool_from_text("sql select query users")
# No specific SQL inference yet — returns None or base tool
# This test documents current behavior; update if resolver is extended
assert result is None or isinstance(result, str)
# #endregion Test.Agent.SupersetTools

View File

@@ -0,0 +1,651 @@
# #region Test.Superset.Extended [C:3] [TYPE Module] [SEMANTICS test,superset,sql-lab,dashboard,dataset,database,saved-query]
# @BRIEF Unit tests for SupersetClient extended mixins: SQL Lab, dashboards write, datasets, databases, saved queries, audit.
# @RELATION BINDS_TO -> [SupersetSqlLabMixin]
# @RELATION BINDS_TO -> [SupersetDashboardsWriteMixin]
# @RELATION BINDS_TO -> [SupersetDatasetsMixin]
# @RELATION BINDS_TO -> [SupersetDatabasesMixin]
# @RELATION BINDS_TO -> [SupersetSavedQueriesMixin]
# @RELATION BINDS_TO -> [SupersetAuditMixin]
# @TEST_EDGE: ddl_guard -> execute_sql blocks dangerous SQL via safety.py
# @TEST_EDGE: external_fail -> Exception in API call is raised
# @TEST_EDGE: missing_field -> Optional parameters omitted from payloads
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ── Shared fixture: base mixin with mocked client ──
@pytest.fixture
def mock_client():
"""Return a mocked AsyncAPIClient with AsyncMock request method."""
client = MagicMock()
client.request = AsyncMock()
return client
def _setup_mixin(mixin_class, mock_client):
"""Instantiate a mixin class and assign the mocked client."""
m = mixin_class.__new__(mixin_class)
m.client = mock_client
m._validate_query_params = MagicMock(return_value={"page": 0, "page_size": 100})
m._fetch_all_pages = AsyncMock(return_value=[{"id": 1}, {"id": 2}])
return m
# ═══════════════════════════════════════════════════════════════════
# SQL Lab mixin tests
# ═══════════════════════════════════════════════════════════════════
class TestSupersetSqlLabMixin:
"""Test SupersetSqlLabMixin methods."""
@pytest.fixture
def mixin(self, mock_client):
with patch("src.core.superset_client._sql_lab.belief_scope", MagicMock()), \
patch("src.core.superset_client._sql_lab.app_logger", MagicMock()):
from src.core.superset_client._sql_lab import SupersetSqlLabMixin
return _setup_mixin(SupersetSqlLabMixin, mock_client)
@pytest.mark.asyncio
async def test_execute_sql_safe_query(self, mixin, mock_client):
"""Safe SELECT passes DDL guard and POSTs to /sqllab/execute/."""
mock_client.request.return_value = {"status": "success", "data": []}
with patch("src.core.superset_client._sql_lab.is_dangerous_sql", return_value=(False, None)):
result = await mixin.execute_sql(database_id=1, sql="SELECT 1")
assert result == {"status": "success", "data": []}
mock_client.request.assert_called_once()
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "POST"
assert call_args["endpoint"] == "/sqllab/execute/"
assert call_args["data"]["database_id"] == 1
assert call_args["data"]["sql"] == "SELECT 1"
@pytest.mark.asyncio
async def test_execute_sql_dangerous_query_blocked(self, mixin, mock_client):
"""DDL query is blocked by guard, no API call made."""
with patch("src.core.superset_client._sql_lab.is_dangerous_sql", return_value=(True, "DROP")):
result = await mixin.execute_sql(database_id=1, sql="DROP TABLE users")
assert "error" in result
assert "DROP" in result["error"]
assert result["keyword"] == "DROP"
mock_client.request.assert_not_called()
@pytest.mark.asyncio
async def test_execute_sql_with_optional_params(self, mixin, mock_client):
"""Schema, catalog, tab_name, template_params are included when provided."""
mock_client.request.return_value = {"status": "success"}
with patch("src.core.superset_client._sql_lab.is_dangerous_sql", return_value=(False, None)):
await mixin.execute_sql(
database_id=1,
sql="SELECT 1",
schema="public",
catalog="pg_catalog",
tab_name="My Tab",
template_params='{"param": "value"}',
)
data = mock_client.request.call_args[1]["data"]
assert data["schema"] == "public"
assert data["catalog"] == "pg_catalog"
assert data["tab_name"] == "My Tab"
assert data["template_params"] == '{"param": "value"}'
@pytest.mark.asyncio
async def test_execute_sql_raises_on_api_error(self, mixin, mock_client):
"""Exception from client.request is re-raised."""
mock_client.request.side_effect = RuntimeError("API down")
with patch("src.core.superset_client._sql_lab.is_dangerous_sql", return_value=(False, None)):
with pytest.raises(RuntimeError, match="API down"):
await mixin.execute_sql(database_id=1, sql="SELECT 1")
@pytest.mark.asyncio
async def test_format_sql(self, mixin, mock_client):
"""format_sql POSTs to /sqllab/format_sql/ and returns formatted string."""
mock_client.request.return_value = {"result": "SELECT\n 1\nFROM\n users\n"}
result = await mixin.format_sql("SELECT 1 FROM users")
assert "SELECT" in result
assert "users" in result
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "POST"
assert call_args["endpoint"] == "/sqllab/format_sql/"
assert call_args["data"]["sql"] == "SELECT 1 FROM users"
@pytest.mark.asyncio
async def test_get_sql_results(self, mixin, mock_client):
"""get_sql_results GETs /sqllab/results/ with results key."""
mock_client.request.return_value = {"data": [{"id": 1}], "columns": ["id"]}
result = await mixin.get_sql_results("abc123")
assert result["data"] == [{"id": 1}]
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "GET"
assert call_args["endpoint"] == "/sqllab/results/"
@pytest.mark.asyncio
async def test_estimate_sql_cost(self, mixin, mock_client):
"""estimate_sql_cost POSTs to /sqllab/estimate/."""
mock_client.request.return_value = {"estimated_cost": 100}
result = await mixin.estimate_sql_cost(database_id=1, sql="SELECT * FROM huge_table")
assert result["estimated_cost"] == 100
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "POST"
assert call_args["endpoint"] == "/sqllab/estimate/"
@pytest.mark.asyncio
async def test_export_sql_csv(self, mixin, mock_client):
"""export_sql_csv GETs /sqllab/export/{id}/ with raw_response=True."""
mock_response = MagicMock()
mock_response.content = b"csv,data,here"
mock_client.request.return_value = mock_response
result = await mixin.export_sql_csv("client123")
assert result == b"csv,data,here"
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "GET"
assert call_args["endpoint"] == "/sqllab/export/client123/"
assert call_args.get("raw_response") is True
@pytest.mark.asyncio
async def test_get_query_history(self, mixin):
"""get_query_history delegates to _fetch_all_pages for /query/."""
result = await mixin.get_query_history()
assert result[0] == 2 # count from fixture
assert len(result[1]) == 2
@pytest.mark.asyncio
async def test_get_query(self, mixin, mock_client):
"""get_query GETs /query/{id}."""
mock_client.request.return_value = {"id": 42, "sql": "SELECT 1"}
result = await mixin.get_query(42)
assert result["id"] == 42
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "GET"
assert call_args["endpoint"] == "/query/42"
@pytest.mark.asyncio
async def test_stop_query(self, mixin, mock_client):
"""stop_query POSTs to /query/stop with client_id."""
mock_client.request.return_value = {"status": "stopped"}
result = await mixin.stop_query(42)
assert result["status"] == "stopped"
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "POST"
assert call_args["endpoint"] == "/query/stop"
assert call_args["data"]["client_id"] == "42"
# ═══════════════════════════════════════════════════════════════════
# Dashboards Write mixin tests
# ═══════════════════════════════════════════════════════════════════
class TestSupersetDashboardsWriteMixin:
"""Test SupersetDashboardsWriteMixin new methods."""
@pytest.fixture
def mixin(self, mock_client):
with patch("src.core.superset_client._dashboards_write.belief_scope", MagicMock()), \
patch("src.core.superset_client._dashboards_write.app_logger", MagicMock()):
from src.core.superset_client._dashboards_write import SupersetDashboardsWriteMixin
return _setup_mixin(SupersetDashboardsWriteMixin, mock_client)
@pytest.mark.asyncio
async def test_create_dashboard(self, mixin, mock_client):
"""create_dashboard POSTs to /dashboard/ with title and published."""
mock_client.request.return_value = {"id": 1, "dashboard_title": "My Dashboard"}
result = await mixin.create_dashboard(dashboard_title="My Dashboard", published=True, slug="my-dash")
assert result["id"] == 1
assert result["dashboard_title"] == "My Dashboard"
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "POST"
assert call_args["endpoint"] == "/dashboard/"
assert call_args["data"]["dashboard_title"] == "My Dashboard"
assert call_args["data"]["published"] is True
assert call_args["data"]["slug"] == "my-dash"
@pytest.mark.asyncio
async def test_create_dashboard_minimal(self, mixin, mock_client):
"""create_dashboard with only required fields."""
mock_client.request.return_value = {"id": 2}
await mixin.create_dashboard(dashboard_title="Minimal")
assert "css" not in mock_client.request.call_args[1]["data"]
@pytest.mark.asyncio
async def test_update_dashboard(self, mixin, mock_client):
"""update_dashboard PUTs to /dashboard/{id}."""
mock_client.request.return_value = {"id": 1, "dashboard_title": "Updated"}
result = await mixin.update_dashboard(dashboard_id=1, dashboard_title="Updated", published=False)
assert result["dashboard_title"] == "Updated"
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "PUT"
assert call_args["endpoint"] == "/dashboard/1"
assert call_args["data"]["dashboard_title"] == "Updated"
assert call_args["data"]["published"] is False
@pytest.mark.asyncio
async def test_copy_dashboard(self, mixin, mock_client):
"""copy_dashboard POSTs to /dashboard/{id}/copy/."""
mock_client.request.return_value = {"id": 2, "dashboard_title": "Copy of Dashboard"}
result = await mixin.copy_dashboard(dashboard_id=1, dashboard_title="Copy of Dashboard")
assert result["id"] == 2
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "POST"
assert call_args["endpoint"] == "/dashboard/1/copy/"
assert call_args["data"]["dashboard_title"] == "Copy of Dashboard"
@pytest.mark.asyncio
async def test_publish_dashboard(self, mixin, mock_client):
"""publish_dashboard PUTs {published: True} to /dashboard/{id}."""
mock_client.request.return_value = {"id": 1, "published": True}
result = await mixin.publish_dashboard(1)
assert result["published"] is True
call_args = mock_client.request.call_args[1]
assert call_args["data"]["published"] is True
@pytest.mark.asyncio
async def test_unpublish_dashboard(self, mixin, mock_client):
"""unpublish_dashboard PUTs {published: False} to /dashboard/{id}."""
mock_client.request.return_value = {"id": 1, "published": False}
result = await mixin.unpublish_dashboard(1)
assert result["published"] is False
call_args = mock_client.request.call_args[1]
assert call_args["data"]["published"] is False
# ═══════════════════════════════════════════════════════════════════
# Dashboards CRUD mixin tests (new methods)
# ═══════════════════════════════════════════════════════════════════
class TestSupersetDashboardsCrudExtended:
"""Test SupersetDashboardsCrudMixin new methods."""
@pytest.fixture
def mixin(self, mock_client):
with patch("src.core.superset_client._dashboards_crud.belief_scope", MagicMock()), \
patch("src.core.superset_client._dashboards_crud.app_logger", MagicMock()):
from src.core.superset_client._dashboards_crud import SupersetDashboardsCrudMixin
return _setup_mixin(SupersetDashboardsCrudMixin, mock_client)
@pytest.mark.asyncio
async def test_get_dashboard_charts(self, mixin, mock_client):
"""get_dashboard_charts GETs /dashboard/{id}/charts."""
mock_client.request.return_value = {"result": [{"id": 10}, {"id": 11}]}
result = await mixin.get_dashboard_charts(1)
assert len(result) == 2
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "GET"
assert call_args["endpoint"] == "/dashboard/1/charts"
@pytest.mark.asyncio
async def test_get_dashboard_datasets(self, mixin, mock_client):
"""get_dashboard_datasets GETs /dashboard/{id}/datasets."""
mock_client.request.return_value = {"result": [{"id": 100}]}
result = await mixin.get_dashboard_datasets(1)
assert len(result) == 1
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "GET"
assert call_args["endpoint"] == "/dashboard/1/datasets"
@pytest.mark.asyncio
async def test_get_dashboard(self, mixin, mock_client):
"""get_dashboard GETs /dashboard/{ref}."""
mock_client.request.return_value = {"id": 1, "dashboard_title": "Test Dash"}
result = await mixin.get_dashboard(1)
assert result["dashboard_title"] == "Test Dash"
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "GET"
assert call_args["endpoint"] == "/dashboard/1"
# ═══════════════════════════════════════════════════════════════════
# Datasets mixin tests (new methods)
# ═══════════════════════════════════════════════════════════════════
class TestSupersetDatasetsExtended:
"""Test SupersetDatasetsMixin extended methods."""
@pytest.fixture
def mixin(self, mock_client):
with patch("src.core.superset_client._datasets.belief_scope", MagicMock()), \
patch("src.core.superset_client._datasets.app_logger", MagicMock()):
from src.core.superset_client._datasets import SupersetDatasetsMixin
return _setup_mixin(SupersetDatasetsMixin, mock_client)
@pytest.mark.asyncio
async def test_create_dataset(self, mixin, mock_client):
"""create_dataset POSTs to /dataset/ with table_name and database."""
mock_client.request.return_value = {"id": 5, "table_name": "new_table"}
result = await mixin.create_dataset(table_name="new_table", database=1, schema_name="public")
assert result["id"] == 5
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "POST"
assert call_args["endpoint"] == "/dataset/"
assert call_args["data"]["table_name"] == "new_table"
assert call_args["data"]["database"] == 1
assert call_args["data"]["schema"] == "public"
@pytest.mark.asyncio
async def test_create_dataset_with_sql(self, mixin, mock_client):
"""create_dataset includes sql field for virtual datasets."""
mock_client.request.return_value = {"id": 6}
await mixin.create_dataset(table_name="virtual_table", database=1, sql="SELECT 1")
assert mock_client.request.call_args[1]["data"]["sql"] == "SELECT 1"
@pytest.mark.asyncio
async def test_delete_dataset(self, mixin, mock_client):
"""delete_dataset DELETEs /dataset/{id}."""
mock_client.request.return_value = {"message": "OK"}
result = await mixin.delete_dataset(dataset_id=5)
assert result["message"] == "OK"
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "DELETE"
assert call_args["endpoint"] == "/dataset/5"
@pytest.mark.asyncio
async def test_duplicate_dataset(self, mixin, mock_client):
"""duplicate_dataset POSTs to /dataset/duplicate."""
mock_client.request.return_value = {"id": 7}
result = await mixin.duplicate_dataset(base_model_id=5, table_name="table_copy")
assert result["id"] == 7
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "POST"
assert call_args["endpoint"] == "/dataset/duplicate"
assert call_args["data"]["base_model_id"] == 5
assert call_args["data"]["table_name"] == "table_copy"
@pytest.mark.asyncio
async def test_refresh_dataset_schema(self, mixin, mock_client):
"""refresh_dataset_schema PUTs to /dataset/{id}/refresh."""
mock_client.request.return_value = {"result": "refreshed"}
result = await mixin.refresh_dataset_schema(dataset_id=5)
assert result["result"] == "refreshed"
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "PUT"
assert call_args["endpoint"] == "/dataset/5/refresh"
# ═══════════════════════════════════════════════════════════════════
# Databases mixin tests (new methods)
# ═══════════════════════════════════════════════════════════════════
class TestSupersetDatabasesExtended:
"""Test SupersetDatabasesMixin extended methods."""
@pytest.fixture
def mixin(self, mock_client):
with patch("src.core.superset_client._databases.belief_scope", MagicMock()), \
patch("src.core.superset_client._databases.app_logger", MagicMock()):
from src.core.superset_client._databases import SupersetDatabasesMixin
return _setup_mixin(SupersetDatabasesMixin, mock_client)
@pytest.mark.asyncio
async def test_get_database_schemas(self, mixin, mock_client):
"""get_database_schemas GETs /database/{id}/schemas/."""
mock_client.request.return_value = {"result": ["public", "analytics"]}
result = await mixin.get_database_schemas(database_id=1)
assert result == ["public", "analytics"]
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "GET"
assert call_args["endpoint"] == "/database/1/schemas/"
@pytest.mark.asyncio
async def test_get_database_tables(self, mixin, mock_client):
"""get_database_tables GETs /database/{id}/tables/ with optional schema."""
mock_client.request.return_value = {"result": [{"name": "users"}, {"name": "orders"}]}
result = await mixin.get_database_tables(database_id=1, schema_name="public")
assert len(result) == 2
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "GET"
assert call_args["endpoint"] == "/database/1/tables/"
@pytest.mark.asyncio
async def test_get_database_select_star(self, mixin, mock_client):
"""get_database_select_star GETs /database/{id}/select_star/."""
mock_client.request.return_value = {"result": "SELECT * FROM public.users"}
result = await mixin.get_database_select_star(database_id=1, table_name="users", schema_name="public")
assert "SELECT *" in result
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "GET"
assert call_args["endpoint"] == "/database/1/select_star/"
@pytest.mark.asyncio
async def test_get_database_table_metadata(self, mixin, mock_client):
"""get_database_table_metadata GETs /database/{id}/table_metadata/."""
mock_client.request.return_value = {
"columns": [{"name": "id", "type": "INTEGER"}],
}
result = await mixin.get_database_table_metadata(database_id=1, table_name="users")
assert "columns" in result
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "GET"
assert call_args["endpoint"] == "/database/1/table_metadata/"
@pytest.mark.asyncio
async def test_validate_sql(self, mixin, mock_client):
"""validate_sql POSTs to /database/{id}/validate_sql/."""
mock_client.request.return_value = {"result": "Valid"}
result = await mixin.validate_sql(database_id=1, sql="SELECT 1")
assert result["result"] == "Valid"
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "POST"
assert call_args["endpoint"] == "/database/1/validate_sql/"
assert call_args["data"]["sql"] == "SELECT 1"
# ═══════════════════════════════════════════════════════════════════
# Saved Queries mixin tests
# ═══════════════════════════════════════════════════════════════════
class TestSupersetSavedQueriesMixin:
"""Test SupersetSavedQueriesMixin CRUD methods."""
@pytest.fixture
def mixin(self, mock_client):
with patch("src.core.superset_client._saved_queries.belief_scope", MagicMock()), \
patch("src.core.superset_client._saved_queries.app_logger", MagicMock()):
from src.core.superset_client._saved_queries import SupersetSavedQueriesMixin
return _setup_mixin(SupersetSavedQueriesMixin, mock_client)
@pytest.mark.asyncio
async def test_get_saved_queries(self, mixin):
"""get_saved_queries delegates to _fetch_all_pages for /saved_query/."""
result = await mixin.get_saved_queries()
assert result[0] == 2
assert len(result[1]) == 2
@pytest.mark.asyncio
async def test_get_saved_query(self, mixin, mock_client):
"""get_saved_query GETs /saved_query/{id}."""
mock_client.request.return_value = {"id": 1, "label": "My Query"}
result = await mixin.get_saved_query(1)
assert result["label"] == "My Query"
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "GET"
assert call_args["endpoint"] == "/saved_query/1"
@pytest.mark.asyncio
async def test_create_saved_query(self, mixin, mock_client):
"""create_saved_query POSTs to /saved_query/."""
mock_client.request.return_value = {"id": 10, "label": "New Query"}
result = await mixin.create_saved_query(
label="New Query", db_id=1, sql="SELECT 1", schema="public", description="test"
)
assert result["id"] == 10
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "POST"
assert call_args["endpoint"] == "/saved_query/"
assert call_args["data"]["label"] == "New Query"
@pytest.mark.asyncio
async def test_update_saved_query(self, mixin, mock_client):
"""update_saved_query PUTs to /saved_query/{id}."""
mock_client.request.return_value = {"id": 1, "label": "Updated Query"}
result = await mixin.update_saved_query(query_id=1, label="Updated Query")
assert result["label"] == "Updated Query"
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "PUT"
assert call_args["endpoint"] == "/saved_query/1"
@pytest.mark.asyncio
async def test_delete_saved_query(self, mixin, mock_client):
"""delete_saved_query DELETEs /saved_query/{id}."""
mock_client.request.return_value = {"message": "Deleted"}
result = await mixin.delete_saved_query(1)
assert result["message"] == "Deleted"
call_args = mock_client.request.call_args[1]
assert call_args["method"] == "DELETE"
assert call_args["endpoint"] == "/saved_query/1"
# ═══════════════════════════════════════════════════════════════════
# Audit mixin tests
# ═══════════════════════════════════════════════════════════════════
class TestSupersetAuditMixin:
"""Test SupersetAuditMixin permissions audit and helpers."""
@pytest.fixture
def mixin(self, mock_client):
with patch("src.core.superset_client._audit.belief_scope", MagicMock()), \
patch("src.core.superset_client._audit.app_logger", MagicMock()):
from src.core.superset_client._audit import SupersetAuditMixin
m = SupersetAuditMixin.__new__(SupersetAuditMixin)
m.client = mock_client
m._validate_query_params = MagicMock(return_value={"page": 0, "page_size": 100})
m._fetch_all_pages = AsyncMock(return_value=[])
return m
@pytest.mark.asyncio
async def test_permissions_audit_returns_structure(self, mixin):
"""permissions_audit returns audit dict with expected keys when all data is empty."""
mixin._fetch_users = AsyncMock(return_value=(0, []))
mixin.get_dashboards = AsyncMock(return_value=(0, []))
mixin.get_datasets = AsyncMock(return_value=(0, []))
mixin._build_role_permissions_map = AsyncMock(return_value={})
mixin._find_datasource_permissions = AsyncMock(return_value={})
# Mock remaining direct client.request calls
mixin.client.request.side_effect = [
{"result": []}, # groups
{"result": []}, # RLS
]
result = await mixin.permissions_audit(include_admin=False)
assert "users" in result
assert "total_users" in result
assert result["total_users"] == 0
assert "dashboards_checked" in result
assert "datasets_checked" in result
@pytest.mark.asyncio
async def test_permissions_audit_with_users(self, mixin):
"""permissions_audit handles a single user with no dashboards/datasets."""
mixin._fetch_users = AsyncMock(return_value=(1, [
{"id": 1, "username": "alice", "roles": [], "active": True}
]))
mixin.get_dashboards = AsyncMock(return_value=(0, []))
mixin.get_datasets = AsyncMock(return_value=(0, []))
mixin._build_role_permissions_map = AsyncMock(return_value={})
mixin._find_datasource_permissions = AsyncMock(return_value={})
# groups + RLS
mixin.client.request.side_effect = [
{"result": []}, # groups
{"result": []}, # RLS
]
result = await mixin.permissions_audit(include_admin=False)
assert result["total_users"] == 1
assert len(result["users"]) == 1
assert result["users"][0]["username"] == "alice"
@pytest.mark.asyncio
async def test_permissions_audit_page_size_capped(self, mixin):
"""page_size is capped at 50 in permissions_audit."""
mixin._fetch_users = AsyncMock(return_value=(0, []))
mixin.get_dashboards = AsyncMock(return_value=(0, []))
mixin.get_datasets = AsyncMock(return_value=(0, []))
mixin._build_role_permissions_map = AsyncMock(return_value={})
mixin._find_datasource_permissions = AsyncMock(return_value={})
mixin.client.request.side_effect = [
{"result": []}, # groups
{"result": []}, # RLS
]
result = await mixin.permissions_audit(page=0, page_size=100)
assert result["page_size"] == 50 # capped at 50 by min(page_size, 50)
# Should still work with no users
@pytest.mark.asyncio
async def test_fetch_users(self, mixin):
"""_fetch_users delegates to _fetch_all_pages for security API."""
result = await mixin._fetch_users()
assert result[0] == 0 # mock returns empty list
mixin._fetch_all_pages.assert_awaited_once()
@pytest.mark.asyncio
async def test_build_role_permissions_map(self, mixin, mock_client):
"""_build_role_permissions_map fetches roles and their permissions."""
mock_client.request.side_effect = [
{"result": [{"id": 1, "name": "Admin"}]}, # roles
{"result": [{"id": 100}, {"id": 101}]}, # perms for role 1
]
result = await mixin._build_role_permissions_map()
assert result == {1: {100, 101}}
@pytest.mark.asyncio
async def test_find_datasource_permissions(self, mixin, mock_client):
"""_find_datasource_permissions scans permissions-resources with pagination."""
mock_client.request.return_value = {
"result": [
{
"id": 500,
"permission": {"name": "datasource_access"},
"view_menu": {"name": "my_table (id:42)"},
},
{
"id": 501,
"permission": {"name": "not_datasource_access"},
"view_menu": {"name": "other (id:99)"},
},
]
}
result = await mixin._find_datasource_permissions()
assert result == {42: 500}
# #endregion Test.Superset.Extended

View File

@@ -0,0 +1,243 @@
# #region Test.Superset.Safety [C:3] [TYPE Module] [SEMANTICS test,safety,sql,ddl,dml,guard]
# @BRIEF Unit tests for core/superset_client/safety.py — DDL/DML guard and viz_type validation.
# @RELATION BINDS_TO -> [SupersetClient.Safety]
# @TEST_EDGE: empty_sql -> Empty/whitespace SQL passes (no dangerous keywords)
# @TEST_EDGE: comment_bypass -> DDL keywords inside comments are stripped before matching
# @TEST_EDGE: string_literal_bypass -> DDL keywords inside string literals don't trigger false positives
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import pytest
from src.core.superset_client.safety import (
is_dangerous_sql,
validate_viz_type,
_strip_sql_comments,
_strip_sql_strings,
)
class TestDangerousSqlDetection:
"""Test DDL/DML detection: safe queries pass, dangerous queries blocked."""
# ── Safe queries ──
@pytest.mark.parametrize("sql", [
"SELECT * FROM users",
"select * from users where id = 1",
"SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.user_id",
"SELECT COUNT(*) FROM users",
" SELECT 1 ",
])
def test_safe_select_queries_pass(self, sql):
"""@POST Safe SELECT queries return (False, None)."""
dangerous, keyword = is_dangerous_sql(sql)
assert dangerous is False
assert keyword is None
# ── Dangerous queries blocked ──
@pytest.mark.parametrize("sql, expected_keyword", [
("DROP TABLE users", "DROP"),
("drop table users", "DROP"),
(" DROP TABLE users ", "DROP"),
("DELETE FROM users", "DELETE"),
("UPDATE users SET name = 'test'", "UPDATE"),
("INSERT INTO users VALUES (1, 'test')", "INSERT"),
("TRUNCATE TABLE users", "TRUNCATE"),
("ALTER TABLE users ADD COLUMN age INT", "ALTER"),
("CREATE TABLE test (id INT)", "CREATE"),
("GRANT SELECT ON users TO bob", "GRANT"),
("REVOKE SELECT ON users FROM bob", "REVOKE"),
("MERGE INTO target t USING source s ON t.id = s.id", "MERGE"),
("COPY (SELECT * FROM users) TO '/tmp/out.csv'", "COPY"),
("DO $$ BEGIN DELETE FROM users; END $$", "DELETE"), # DELETE found before DO since $$-quoted strings are not stripped
("EXECUTE 'DROP TABLE users'", "EXECUTE"),
])
def test_dangerous_queries_blocked(self, sql, expected_keyword):
"""@POST Dangerous DDL/DML returns (True, keyword)."""
dangerous, keyword = is_dangerous_sql(sql)
assert dangerous is True
assert keyword == expected_keyword
# ── Bypass prevention ──
def test_comment_before_drop_blocked(self):
"""Keywords after comments are detected (comments stripped first)."""
sql = "-- This is just a comment\nDROP TABLE users"
dangerous, keyword = is_dangerous_sql(sql)
assert dangerous is True
assert keyword == "DROP"
def test_multiline_comment_hides_drop_blocked(self):
"""Multi-line comment /* ... */ before DROP still detected."""
sql = "/* clean up */ DROP TABLE users"
dangerous, keyword = is_dangerous_sql(sql)
assert dangerous is True
assert keyword == "DROP"
def test_cte_with_delete_blocked(self):
"""CTE containing DELETE is detected as dangerous (whole word anywhere)."""
sql = "WITH deleted AS (DELETE FROM temp RETURNING *) SELECT * FROM deleted"
dangerous, keyword = is_dangerous_sql(sql)
assert dangerous is True
assert keyword == "DELETE"
def test_parenthesized_delete_blocked(self):
"""Parentheses around DELETE don't hide it from detection."""
sql = "SELECT * FROM (DELETE FROM users) t"
dangerous, keyword = is_dangerous_sql(sql)
assert dangerous is True
assert keyword == "DELETE"
def test_explain_prefix_does_not_hide_drop(self):
"""EXPLAIN before DROP still detects DROP as whole word."""
sql = "EXPLAIN DROP TABLE users"
dangerous, keyword = is_dangerous_sql(sql)
assert dangerous is True
assert keyword == "DROP"
# ── False positive prevention ──
def test_string_literal_containing_drop_not_flagged(self):
"""DROP inside a string literal is not flagged as dangerous."""
sql = "SELECT 'DROP TABLE users' AS warning"
dangerous, keyword = is_dangerous_sql(sql)
assert dangerous is False
def test_comment_containing_drop_not_flagged(self):
"""DROP inside a comment alone should not be flagged."""
sql = "-- DROP TABLE users"
dangerous, keyword = is_dangerous_sql(sql)
assert dangerous is False
def test_update_date_identifier_not_flagged(self):
"""update_date column name does NOT trigger UPDATE keyword (whole-word match)."""
sql = "SELECT update_date FROM users"
dangerous, keyword = is_dangerous_sql(sql)
assert dangerous is False
def test_deleted_at_identifier_not_flagged(self):
"""deleted_at column name does NOT trigger DELETE keyword (whole-word match)."""
sql = "SELECT deleted_at FROM users WHERE deleted_at IS NULL"
dangerous, keyword = is_dangerous_sql(sql)
assert dangerous is False
def test_dropped_at_identifier_not_flagged(self):
"""dropped_at column name does NOT trigger DROP keyword (whole-word match)."""
sql = "SELECT dropped_at FROM events"
dangerous, keyword = is_dangerous_sql(sql)
assert dangerous is False
# ── Edge cases ──
@pytest.mark.parametrize("sql", [
"",
" ",
"\n",
"\t",
" \n \t ",
])
def test_empty_or_whitespace_sql_passes(self, sql):
"""Empty or whitespace-only SQL returns (False, None)."""
dangerous, keyword = is_dangerous_sql(sql)
assert dangerous is False
assert keyword is None
def test_case_insensitive_detection(self):
"""Mixed-case dangerous keywords are detected."""
sql = "Drop Table users"
dangerous, keyword = is_dangerous_sql(sql)
assert dangerous is True
assert keyword == "DROP"
def test_keywords_embedded_in_other_words_not_flagged(self):
"""DROP appearing as substring of another word is NOT flagged."""
sql = "SELECT dropbox_path FROM settings"
dangerous, keyword = is_dangerous_sql(sql)
assert dangerous is False
class TestStripSqlComments:
"""Unit tests for the _strip_sql_comments helper."""
def test_strip_single_line_comment(self):
result = _strip_sql_comments("SELECT 1 -- comment")
assert "comment" not in result
assert "SELECT 1" in result
def test_strip_multiline_comment(self):
result = _strip_sql_comments("SELECT 1 /* inline */, 2")
assert "inline" not in result
assert "SELECT 1" in result
assert ", 2" in result
def test_strip_multiline_comment_multiline(self):
result = _strip_sql_comments("SELECT /* line1\nline2 */ 1")
assert "line1" not in result
assert "line2" not in result
assert "1" in result
def test_no_comments_unchanged(self):
result = _strip_sql_comments("SELECT 1 FROM users")
assert result.strip() == "SELECT 1 FROM users"
class TestStripSqlStrings:
"""Unit tests for the _strip_sql_strings helper."""
def test_strip_simple_string(self):
result = _strip_sql_strings("SELECT 'hello world' AS greeting")
assert "hello world" not in result
assert "SELECT" in result
def test_strip_string_with_escaped_quotes(self):
result = _strip_sql_strings("SELECT 'it''s ok' AS msg")
assert "it's ok" not in result
assert "SELECT" in result
def test_no_strings_unchanged(self):
result = _strip_sql_strings("SELECT id FROM users")
assert result.strip() == "SELECT id FROM users"
class TestValidateVizType:
"""Unit tests for validate_viz_type."""
def test_valid_viz_type_passes(self):
deprecated, reason = validate_viz_type("table")
assert deprecated is False
assert reason is None
def test_valid_bar_chart_passes(self):
deprecated, reason = validate_viz_type("echarts_timeseries_bar")
assert deprecated is False
assert reason is None
def test_deprecated_pivot_table_rejected(self):
deprecated, reason = validate_viz_type("pivot_table_v2")
assert deprecated is True
assert "pivot_table_v3" in reason
def test_deprecated_filter_box_rejected(self):
deprecated, reason = validate_viz_type("filter_box")
assert deprecated is True
assert "native dashboard filters" in reason
def test_deprecated_world_map_rejected(self):
deprecated, reason = validate_viz_type("world_map")
assert deprecated is True
def test_empty_viz_type_passes(self):
deprecated, reason = validate_viz_type("")
assert deprecated is False
assert reason is None
def test_unknown_viz_type_passes(self):
deprecated, reason = validate_viz_type("non_existent_viz")
assert deprecated is False
assert reason is None
# #endregion Test.Superset.Safety