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>
177 lines
5.5 KiB
Python
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}
|