obsidian-vault/ligbox-ops-platform/app/audit_store.py
2026-06-19 17:26:42 +00:00

256 lines
7.6 KiB
Python

"""SQLite persistence for audit domains and checks."""
from __future__ import annotations
import json
import sqlite3
from datetime import datetime, timezone
from typing import Any
from app.collectors.base import CHECK_LABELS
ONBOARD_DOMAIN_EVENTS = frozenset({"account.created", "onboarding.completed"})
TENANT_ONBOARD = 1
STATUS_RANK = {"pass": 0, "skip": 1, "warn": 2, "error": 3, "fail": 4}
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_payload(raw: str | None) -> dict:
if not raw:
return {}
try:
return json.loads(raw)
except json.JSONDecodeError:
return {}
def init_audit_schema(conn: sqlite3.Connection) -> None:
conn.executescript("""
CREATE TABLE IF NOT EXISTS audit_domains (
id INTEGER PRIMARY KEY,
tenant_id INTEGER NOT NULL,
domain TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'onboarding',
created_at TEXT NOT NULL,
UNIQUE(tenant_id, domain)
);
CREATE TABLE IF NOT EXISTS audit_checks (
id INTEGER PRIMARY KEY,
tenant_id INTEGER NOT NULL,
domain TEXT NOT NULL,
check_id TEXT NOT NULL,
status TEXT NOT NULL,
message TEXT,
evidence TEXT,
checked_at TEXT NOT NULL,
UNIQUE(tenant_id, domain, check_id)
);
""")
def sync_domains_from_webhooks(conn: sqlite3.Connection) -> int:
rows = conn.execute(
"""
SELECT event_type, payload FROM webhook_events
WHERE source = 'vm112-onboard'
ORDER BY id DESC LIMIT 500
"""
).fetchall()
added = 0
now = _now()
seen: set[tuple[int, str]] = set()
for row in rows:
if row["event_type"] not in ONBOARD_DOMAIN_EVENTS:
continue
payload = _parse_payload(row["payload"])
domain = (payload.get("domain") or "").strip().lower()
if not domain or len(domain) < 3:
continue
key = (TENANT_ONBOARD, domain)
if key in seen:
continue
seen.add(key)
cur = conn.execute(
"""
INSERT OR IGNORE INTO audit_domains (tenant_id, domain, source, created_at)
VALUES (?, ?, 'onboarding', ?)
""",
(TENANT_ONBOARD, domain, now),
)
if cur.rowcount:
added += 1
conn.commit()
return added
def list_audit_domains(conn: sqlite3.Connection, tenant_id: int | None = None) -> list[dict]:
if tenant_id:
rows = conn.execute(
"SELECT tenant_id, domain, source, created_at FROM audit_domains WHERE tenant_id = ? ORDER BY domain",
(tenant_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT tenant_id, domain, source, created_at FROM audit_domains ORDER BY tenant_id, domain"
).fetchall()
return [dict(r) for r in rows]
def upsert_check(
conn: sqlite3.Connection,
tenant_id: int,
domain: str,
check_id: str,
status: str,
message: str,
evidence: dict | None,
checked_at: str | None = None,
) -> None:
conn.execute(
"""
INSERT INTO audit_checks (tenant_id, domain, check_id, status, message, evidence, checked_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(tenant_id, domain, check_id) DO UPDATE SET
status = excluded.status,
message = excluded.message,
evidence = excluded.evidence,
checked_at = excluded.checked_at
""",
(
tenant_id,
domain.lower(),
check_id,
status,
message,
json.dumps(evidence or {}),
checked_at or _now(),
),
)
def get_checks(conn: sqlite3.Connection, tenant_id: int, domain: str) -> list[dict]:
rows = conn.execute(
"""
SELECT check_id, status, message, evidence, checked_at
FROM audit_checks WHERE tenant_id = ? AND domain = ?
ORDER BY check_id
""",
(tenant_id, domain.lower()),
).fetchall()
out = []
for row in rows:
item = dict(row)
item["label"] = CHECK_LABELS.get(item["check_id"], item["check_id"])
item["evidence"] = _parse_payload(item.get("evidence"))
out.append(item)
return out
def aggregate_score(checks: list[dict]) -> dict[str, Any]:
total = len(CHECK_LABELS)
counts = {"pass": 0, "warn": 0, "fail": 0, "error": 0, "skip": 0}
worst = "pass"
for c in checks:
st = c.get("status") or "skip"
counts[st] = counts.get(st, 0) + 1
if STATUS_RANK.get(st, 0) > STATUS_RANK.get(worst, 0):
worst = st
if worst in ("fail", "error"):
overall = "critical"
elif worst == "warn":
overall = "degraded"
elif checks:
overall = "healthy"
else:
overall = "unknown"
return {
"pass": counts.get("pass", 0),
"warn": counts.get("warn", 0),
"fail": counts.get("fail", 0),
"error": counts.get("error", 0),
"skip": counts.get("skip", 0),
"total": total,
"overall_status": overall,
}
def tenant_overview(conn: sqlite3.Connection, tenant_id: int, name: str, ip: str) -> dict:
domains = list_audit_domains(conn, tenant_id)
if not domains:
return {
"tenant_id": tenant_id,
"name": name,
"ip": ip,
"status": "unknown",
"score": {"pass": 0, "warn": 0, "fail": 0, "total": 8},
"domains_count": 0,
"last_audit_at": None,
"top_issues": [],
}
all_checks: list[dict] = []
last_audit = None
top_issues: list[dict] = []
domain_scores: list[dict] = []
for d in domains:
checks = get_checks(conn, tenant_id, d["domain"])
if not checks:
continue
all_checks.extend(checks)
domain_scores.append(aggregate_score(checks))
for c in checks:
if c["checked_at"] and (not last_audit or c["checked_at"] > last_audit):
last_audit = c["checked_at"]
if c["status"] in ("fail", "error", "warn"):
top_issues.append({
"domain": d["domain"],
"check_id": c["check_id"],
"status": c["status"],
"message": c.get("message"),
})
if domain_scores:
worst = max(domain_scores, key=lambda s: STATUS_RANK.get(s["overall_status"], 0))
score = worst
else:
score = aggregate_score(all_checks)
return {
"tenant_id": tenant_id,
"name": name,
"ip": ip,
"status": score["overall_status"],
"score": {
"pass": score["pass"],
"warn": score["warn"],
"fail": score["fail"] + score["error"],
"total": score["total"],
},
"domains_count": len(domains),
"last_audit_at": last_audit,
"top_issues": top_issues[:5],
}
def build_overview(conn: sqlite3.Connection) -> dict:
tenants = conn.execute("SELECT id, name, ip FROM tenants ORDER BY id").fetchall()
return {
"generated_at": _now(),
"tenants": [tenant_overview(conn, t["id"], t["name"], t["ip"]) for t in tenants],
}
def scorecard(conn: sqlite3.Connection, tenant_id: int, domain: str) -> dict:
domain = domain.lower().strip()
checks = get_checks(conn, tenant_id, domain)
score = aggregate_score(checks)
return {
"tenant_id": tenant_id,
"domain": domain,
"checked_at": max((c["checked_at"] for c in checks), default=None),
"overall_status": score["overall_status"],
"checks": checks,
}