agnes-the-ai-analyst/app/api/metrics.py
ZdenekSrotyr e9d7af3cce feat(rbac+marketplace): RBAC v13 + Claude Code marketplace + #81/#83/#44 hardening
This squashes 13 commits from ma/staging plus a small docstring translation
into a single coherent unit. Three workstreams.

== RBAC v13 redesign ==
- Drops core.viewer/analyst/km_admin/admin hierarchy and the
  internal_roles / group_mappings / user_role_grants / plugin_access tables.
- Replaced by user_group_members + resource_grants. Atomic v12→v13 backfill
  wrapped in BEGIN/COMMIT; ROLLBACK leaves schema_version at 12 for retry.
- Two authorization primitives in app.auth.access:
    require_admin                        — Admin-group god-mode
    require_resource_access(rt, "{path}") — entity-scoped grants
  Single DB lookup per request; no session cache; no implies BFS.
- /admin/access UI (single page) replaces /admin/role-mapping +
  /admin/plugin-access. CLI `da admin group/grant *` replaces
  `da admin role/mapping/grant-role/revoke-role/effective-roles`.
- ResourceType.TABLE listing-only — admins can record table grants,
  runtime enforcement still flows through legacy dataset_permissions
  (migration plan in docs/TODO-rbac-data-enforcement.md).

== Claude Code marketplace ==
- Aggregated /marketplace.zip + /marketplace.git/* (PAT-gated,
  RBAC-filtered, content-addressed cache via dulwich).
- Admin god-mode dropped on the marketplace surface — admins curate
  their own view via grants like everyone else.
- Bare-repo cache materializes per RBAC-filtered ETag; stale entries
  not pruned in this iteration (disclaimed in git_backend.py docstring).

== #81 #83 #44 security/ops hardening ==
- #81 Group A — orchestrator ATTACH allow-listing (extension/url/alias).
- #81 Group B — Keboola extractor 3-state exit codes:
    0 success / 1 total fail / 2 PARTIAL fail
  Sync API logs PARTIAL FAILURE alert on exit 2. Operators with binary
  alerting must teach it the new partial signal.
- #81 Group C — schema v10 view_ownership; rejects silent overwrite
  of a prior connector's view name on collision.
- #81 Group D — extractor-side identifier validation.
- #83 — Jira webhook fail-closed when JIRA_WEBHOOK_SECRET unset
  + path-traversal fix.
- #44 — entire /api/scripts/* surface is admin-only (planted-script +
  sandbox-bypass risk closed).

== Web UI polish + deploy fix ==
- /admin/access: live grant-count badges (no stale snapshot revert),
  shared-header CSS link added to /catalog and /admin/{tables,permissions},
  per-resource-type colored stripes.
- docker-compose.host-mount.yml: bind,rbind so dual-disk hosts don't
  silently shadow sub-mounts and write state to the wrong disk.

== OSS vendor-neutralization (waves 1+2) ==
- scripts/grpn/ → scripts/ops/. Customer-specific identifiers
  (project IDs, internal hostnames, dev/prod VM IPs, brand names)
  replaced with placeholders across code, docs, Terraform, Caddyfile,
  OAuth probe, and planning docs. Downstream infra repos that copied
  scripts/grpn/agnes-tls-rotate.sh or agnes-auto-upgrade.sh must
  update the path.

== Translation ==
- src/repositories/user_groups.py::ensure_system docstring translated
  from Czech to English for codebase consistency.

Co-authored-by: Mina Rustamyan <mina@keboola.com>
2026-04-28 14:25:04 +02:00

177 lines
5.5 KiB
Python

"""Metrics API endpoints — CRUD for metric definitions stored in DuckDB."""
from typing import List, Optional
import duckdb
import yaml
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from pydantic import BaseModel
from app.auth.access import require_admin
from app.auth.dependencies import get_current_user, _get_db
from src.repositories.metrics import MetricRepository
router = APIRouter(tags=["metrics"])
class MetricCreate(BaseModel):
id: str
name: str
display_name: str
category: str
sql: str
description: Optional[str] = None
type: str = "sum"
unit: Optional[str] = None
grain: str = "monthly"
table_name: Optional[str] = None
tables: Optional[List[str]] = None
expression: Optional[str] = None
time_column: Optional[str] = None
dimensions: Optional[List[str]] = None
filters: Optional[List[str]] = None
synonyms: Optional[List[str]] = None
notes: Optional[List[str]] = None
sql_variants: Optional[dict] = None
validation: Optional[dict] = None
source: str = "manual"
@router.get("/api/metrics")
async def list_metrics(
category: Optional[str] = None,
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""List all metric definitions, optionally filtered by category."""
repo = MetricRepository(conn)
metrics = repo.list(category=category)
return {"metrics": metrics, "count": len(metrics)}
@router.get("/api/metrics/{metric_id:path}")
async def get_metric(
metric_id: str,
user: dict = Depends(get_current_user),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Get a single metric definition by ID."""
repo = MetricRepository(conn)
metric = repo.get(metric_id)
if metric is None:
raise HTTPException(status_code=404, detail=f"Metric '{metric_id}' not found")
return metric
@router.post("/api/admin/metrics", status_code=201)
async def create_or_update_metric(
body: MetricCreate,
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Create or update a metric definition (admin only)."""
repo = MetricRepository(conn)
metric = repo.create(
id=body.id,
name=body.name,
display_name=body.display_name,
category=body.category,
sql=body.sql,
description=body.description,
type=body.type,
unit=body.unit,
grain=body.grain,
table_name=body.table_name,
tables=body.tables,
expression=body.expression,
time_column=body.time_column,
dimensions=body.dimensions,
filters=body.filters,
synonyms=body.synonyms,
notes=body.notes,
sql_variants=body.sql_variants,
validation=body.validation,
source=body.source,
)
return metric
@router.delete("/api/admin/metrics/{metric_id:path}")
async def delete_metric(
metric_id: str,
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Delete a metric definition by ID (admin only)."""
repo = MetricRepository(conn)
deleted = repo.delete(metric_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Metric '{metric_id}' not found")
return {"status": "deleted", "id": metric_id}
@router.post("/api/admin/metrics/import", status_code=200)
async def import_metrics(
file: UploadFile = File(...),
user: dict = Depends(require_admin),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Import metrics from uploaded YAML file."""
content = await file.read()
try:
data = yaml.safe_load(content)
except yaml.YAMLError as e:
raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}")
if not data:
raise HTTPException(status_code=400, detail="Empty YAML file")
metric_list = data if isinstance(data, list) else [data]
repo = MetricRepository(conn)
count = 0
for metric in metric_list:
if not isinstance(metric, dict):
continue
name = metric.get("name")
category = metric.get("category")
if not name or not category:
raise HTTPException(
status_code=400,
detail="Each metric must have 'name' and 'category' fields",
)
metric_id = f"{category}/{name}"
table_name = metric.pop("table", None) or metric.get("table_name")
# Collect sql_by_* variants
sql_variants = {}
for key in list(metric.keys()):
if key.startswith("sql_by_"):
sql_variants[key[4:]] = metric.pop(key)
repo.create(
id=metric_id,
name=name,
display_name=metric.get("display_name", name),
category=category,
description=metric.get("description"),
type=metric.get("type", "sum"),
unit=metric.get("unit"),
grain=metric.get("grain", "monthly"),
table_name=table_name,
tables=metric.get("tables"),
expression=metric.get("expression"),
time_column=metric.get("time_column"),
dimensions=metric.get("dimensions"),
filters=metric.get("filters"),
synonyms=metric.get("synonyms"),
notes=metric.get("notes"),
sql=metric.get("sql", ""),
sql_variants=sql_variants if sql_variants else None,
validation=metric.get("validation"),
source="yaml_import",
)
count += 1
return {"status": "imported", "count": count}