ee9357aa31
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.
136 lines
5.1 KiB
Python
136 lines
5.1 KiB
Python
"""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))
|