feat(dataflow): live Data Flow graph API + PII catalog
api/dataflow.py: node-link landscape (generators->sources->CDC/Kafka->sinks,
HDFS->Iceberg, sources->curated_masked) with live overlays (movement run state,
CDC volume, Trino counts) and PII overlay. api/pii_catalog.py: column PII
classification via Trino information_schema (OM-ready). Endpoints /api/dataflow,
/api/dataflow/{id}/run, /api/pii.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
"""PII catalog for the Command Center Data Flow PII overlay.
|
||||
|
||||
Classifies columns of the known datasets into PII categories. Primary source is
|
||||
OpenMetadata tags (when available, Phase 3); falls back to a name-based
|
||||
heuristic over the live table columns (via Trino information_schema). Reports
|
||||
per dataset which columns are PII and whether they are masked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(prefix="/api/pii", tags=["pii"])
|
||||
|
||||
TRINO_URL = os.getenv("TRINO_URL", "http://10.0.21.50:8089").rstrip("/")
|
||||
TRINO_USER = os.getenv("TRINO_USER", "mo")
|
||||
OPENMETADATA_URL = os.getenv("OPENMETADATA_URL", "").rstrip("/")
|
||||
OPENMETADATA_TOKEN = os.getenv("OPENMETADATA_TOKEN", "")
|
||||
|
||||
# Datasets we surface in the PII overlay. node_id matches dataflow.py node ids.
|
||||
DATASETS = [
|
||||
{"key": "postgres", "node_id": "postgres", "label": "PostgreSQL sales_orders",
|
||||
"table": "postgres_sales.public.sales_orders", "table_name": "sales_orders", "catalog": "postgres_sales"},
|
||||
{"key": "mysql", "node_id": "mysql", "label": "MySQL employee_events",
|
||||
"table": "mysql_hr.hr.employee_events", "table_name": "employee_events", "catalog": "mysql_hr"},
|
||||
{"key": "mongodb", "node_id": "mongodb", "label": "MongoDB events",
|
||||
"table": "mongodb_supplychain.supplychain.events", "table_name": "events", "catalog": "mongodb_supplychain"},
|
||||
{"key": "curated", "node_id": "iceberg_curated", "label": "Iceberg curated_masked",
|
||||
"table": "iceberg.curated_masked.sales_orders_masked", "table_name": "sales_orders_masked", "catalog": "iceberg",
|
||||
"schema": "curated_masked", "masked_layer": True},
|
||||
]
|
||||
|
||||
# name fragment -> PII category
|
||||
PII_RULES: list[tuple[str, str]] = [
|
||||
(r"email|e_mail", "EMAIL"),
|
||||
(r"phone|mobile|msisdn|tel", "PHONE"),
|
||||
(r"iban|account_no|bank|card|credit", "FINANCIAL"),
|
||||
(r"ssn|bsn|national_id|passport|tax_id", "NATIONAL_ID"),
|
||||
(r"first_name|last_name|full_name|customer_name|employee_name|contact_name|^name$", "NAME"),
|
||||
(r"address|street|city|zip|postal|postcode", "ADDRESS"),
|
||||
(r"dob|birth|date_of_birth", "DOB"),
|
||||
(r"ip_addr|ip_address", "IP"),
|
||||
]
|
||||
|
||||
_cache: dict[str, Any] = {"ts": 0.0, "data": None}
|
||||
_TTL = 120.0
|
||||
|
||||
|
||||
def _classify(col: str) -> str | None:
|
||||
c = col.lower()
|
||||
for pat, cat in PII_RULES:
|
||||
if re.search(pat, c):
|
||||
return cat
|
||||
return None
|
||||
|
||||
|
||||
def _is_masked(col: str, dataset_masked_layer: bool) -> bool:
|
||||
c = col.lower()
|
||||
return dataset_masked_layer or c.endswith("_masked") or c.endswith("_hash") or c.endswith("_token")
|
||||
|
||||
|
||||
def _trino_columns(catalog: str, schema: str | None, table_name: str) -> list[str]:
|
||||
sql = (
|
||||
f"SELECT column_name FROM {catalog}.information_schema.columns "
|
||||
f"WHERE table_name = '{table_name}'"
|
||||
)
|
||||
if schema:
|
||||
sql += f" AND table_schema = '{schema}'"
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
r = client.post(f"{TRINO_URL}/v1/statement", content=sql.encode(), headers={"X-Trino-User": TRINO_USER})
|
||||
d = r.json()
|
||||
rows: list[Any] = d.get("data") or []
|
||||
nxt = d.get("nextUri")
|
||||
while nxt:
|
||||
dd = client.get(nxt).json()
|
||||
rows += dd.get("data") or []
|
||||
if dd.get("error"):
|
||||
break
|
||||
nxt = dd.get("nextUri")
|
||||
return [row[0] for row in rows]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _build() -> dict[str, Any]:
|
||||
datasets_out = []
|
||||
total_pii = 0
|
||||
total_masked = 0
|
||||
for ds in DATASETS:
|
||||
cols = _trino_columns(ds["catalog"], ds.get("schema"), ds["table_name"])
|
||||
pii_cols = []
|
||||
for c in cols:
|
||||
cat = _classify(c)
|
||||
if cat:
|
||||
masked = _is_masked(c, ds.get("masked_layer", False))
|
||||
pii_cols.append({"name": c, "category": cat, "masked": masked})
|
||||
total_pii += 1
|
||||
if masked:
|
||||
total_masked += 1
|
||||
datasets_out.append({
|
||||
"key": ds["key"], "node_id": ds["node_id"], "label": ds["label"], "table": ds["table"],
|
||||
"exists": bool(cols), "masked_layer": ds.get("masked_layer", False),
|
||||
"pii_columns": pii_cols, "pii_count": len(pii_cols),
|
||||
"has_pii": bool(pii_cols),
|
||||
"all_masked": bool(pii_cols) and all(c["masked"] for c in pii_cols),
|
||||
})
|
||||
return {
|
||||
"ok": True, "source": "heuristic" if not OPENMETADATA_URL else "openmetadata+heuristic",
|
||||
"datasets": datasets_out,
|
||||
"summary": {"datasets": len(datasets_out), "pii_columns": total_pii, "masked_columns": total_masked,
|
||||
"unmasked_columns": total_pii - total_masked},
|
||||
}
|
||||
|
||||
|
||||
def get_pii(use_cache: bool = True) -> dict[str, Any]:
|
||||
now = time.time()
|
||||
if use_cache and _cache["data"] and now - _cache["ts"] < _TTL:
|
||||
return _cache["data"]
|
||||
data = _build()
|
||||
_cache["data"] = data
|
||||
_cache["ts"] = now
|
||||
return data
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def pii_overview(refresh: bool = False) -> JSONResponse:
|
||||
return JSONResponse(get_pii(use_cache=not refresh))
|
||||
Reference in New Issue
Block a user