Specs stay at repo root (cross-VM). Move deploy and code into logical projects with README per domain, updated manifest.yaml, and symlinks at legacy paths for VM122 backward compatibility.
774 lines
26 KiB
Python
774 lines
26 KiB
Python
import json
|
|
import os
|
|
import sqlite3
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import redis
|
|
from fastapi import FastAPI, Header, HTTPException, Query, Request
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from pydantic import BaseModel
|
|
|
|
from app import audit_store
|
|
from app.collectors.base import run_audit
|
|
|
|
DB_PATH = Path(os.getenv("SQLITE_PATH", "/data/ops.db"))
|
|
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
|
|
VM112_API = os.getenv("VM112_API_URL", "http://10.10.10.112:8090")
|
|
MAIL_PUBLIC_IP = os.getenv("MAIL_PUBLIC_IP", "")
|
|
AUDIT_INTERVAL_SEC = int(os.getenv("AUDIT_INTERVAL_SEC", "600"))
|
|
WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET", "ligbox-ops-dev-secret")
|
|
WAZUH_WEBHOOK_SECRET = os.getenv("WAZUH_WEBHOOK_SECRET", "ligbox-wazuh-dev-secret")
|
|
WAZUH_MIN_TICKET_LEVEL = int(os.getenv("WAZUH_MIN_TICKET_LEVEL", "10"))
|
|
|
|
INTEGRATION_SECRETS = {
|
|
"onboard": WEBHOOK_SECRET,
|
|
"wazuh": WAZUH_WEBHOOK_SECRET,
|
|
}
|
|
|
|
INTEGRATION_SOURCES = {
|
|
"onboard": "vm112-onboard",
|
|
"wazuh": "wazuh",
|
|
}
|
|
|
|
TICKET_EVENTS_BY_SOURCE = {
|
|
"vm112-onboard": frozenset({"account.created", "onboarding.failed"}),
|
|
"wazuh": frozenset({"wazuh.alert"}),
|
|
}
|
|
|
|
TENANT_BY_SOURCE = {
|
|
"vm112-onboard": 1,
|
|
"wazuh": 2,
|
|
}
|
|
|
|
ONBOARD_SOURCE = "vm112-onboard"
|
|
|
|
FUNNEL_EVENT_RANK = {
|
|
"onboarding.started": 1,
|
|
"domain.validated": 2,
|
|
"dns.applied": 3,
|
|
"account.created": 4,
|
|
"infra.synced": 5,
|
|
"onboarding.completed": 6,
|
|
"company.validated": 7,
|
|
"webmail.released": 8,
|
|
"onboarding.failed": 99,
|
|
}
|
|
|
|
FUNNEL_STAGE_BY_RANK = {
|
|
1: "started",
|
|
2: "domain_validated",
|
|
3: "dns_applied",
|
|
4: "account_created",
|
|
5: "infra_synced",
|
|
6: "completed",
|
|
7: "company_validated",
|
|
8: "webmail_released",
|
|
99: "failed",
|
|
}
|
|
|
|
FUNNEL_NOTE_EVENTS = frozenset({
|
|
"domain.validated",
|
|
"dns.applied",
|
|
"infra.synced",
|
|
"onboarding.completed",
|
|
"company.validated",
|
|
"webmail.released",
|
|
})
|
|
|
|
app = FastAPI(title="Ligbox Ops Platform API", version="0.5.0-company-gate")
|
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
|
|
|
|
|
def db():
|
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
|
|
def init_db():
|
|
with db() as conn:
|
|
conn.executescript("""
|
|
CREATE TABLE IF NOT EXISTS tenants (
|
|
id INTEGER PRIMARY KEY, name TEXT NOT NULL, ip TEXT NOT NULL,
|
|
role TEXT NOT NULL, created_at TEXT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS tickets (
|
|
id INTEGER PRIMARY KEY, tenant_id INTEGER, subject TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'open', payload TEXT, created_at TEXT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS webhook_events (
|
|
id INTEGER PRIMARY KEY, event_type TEXT NOT NULL, source TEXT NOT NULL,
|
|
payload TEXT, created_at TEXT NOT NULL);
|
|
""")
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
defaults = [
|
|
(1, "VM112 Ligbox Onboard", "10.10.10.112", "onboarding_portal"),
|
|
(2, "VM104 Wazuh SOC", "10.10.10.104", "security_monitoring"),
|
|
]
|
|
for tid, name, ip, role in defaults:
|
|
if conn.execute("SELECT COUNT(*) c FROM tenants WHERE id = ?", (tid,)).fetchone()["c"] == 0:
|
|
conn.execute(
|
|
"INSERT INTO tenants (id,name,ip,role,created_at) VALUES (?,?,?,?,?)",
|
|
(tid, name, ip, role, now),
|
|
)
|
|
audit_store.init_audit_schema(conn)
|
|
conn.commit()
|
|
|
|
|
|
def _run_audit_for_domain(tenant_id: int, domain: str) -> dict:
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
results = run_audit(
|
|
tenant_id,
|
|
domain,
|
|
vm112_api=VM112_API,
|
|
mail_public_ip=MAIL_PUBLIC_IP or None,
|
|
)
|
|
with db() as conn:
|
|
for check_id, item in results.items():
|
|
audit_store.upsert_check(
|
|
conn,
|
|
tenant_id,
|
|
domain,
|
|
check_id,
|
|
item.get("status", "error"),
|
|
item.get("message", ""),
|
|
item.get("evidence"),
|
|
now,
|
|
)
|
|
conn.commit()
|
|
return {"tenant_id": tenant_id, "domain": domain, "checks": len(results), "checked_at": now}
|
|
|
|
|
|
def _audit_cycle() -> dict:
|
|
with db() as conn:
|
|
added = audit_store.sync_domains_from_webhooks(conn)
|
|
domains = audit_store.list_audit_domains(conn)
|
|
ran = []
|
|
for d in domains:
|
|
ran.append(_run_audit_for_domain(d["tenant_id"], d["domain"]))
|
|
return {"domains_synced": added, "audits_run": len(ran), "details": ran}
|
|
|
|
|
|
class WebhookPayload(BaseModel):
|
|
event: str
|
|
domain: str | None = None
|
|
session_id: str | None = None
|
|
data: dict | None = None
|
|
|
|
|
|
class TicketStatusUpdate(BaseModel):
|
|
status: str
|
|
|
|
|
|
def _parse_payload(raw: str | None) -> dict:
|
|
if not raw:
|
|
return {}
|
|
try:
|
|
return json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
|
|
|
|
def _enrich_ticket(row: sqlite3.Row) -> dict:
|
|
ticket = dict(row)
|
|
payload = _parse_payload(ticket.get("payload"))
|
|
data = payload.get("data") or {}
|
|
ticket["event"] = payload.get("event")
|
|
ticket["domain"] = payload.get("domain")
|
|
ticket["session_id"] = payload.get("session_id")
|
|
ticket["source"] = payload.get("source") or data.get("source")
|
|
ticket["email"] = data.get("email")
|
|
ticket["account_verified"] = data.get("account_verified")
|
|
ticket["needs_review"] = data.get("needs_review")
|
|
ticket["dns_mode"] = data.get("dns_mode")
|
|
ticket["severity"] = data.get("level")
|
|
ticket["rule_id"] = data.get("rule_id")
|
|
ticket["description"] = data.get("description")
|
|
ticket["agent"] = data.get("agent")
|
|
ticket["billing_state"] = payload.get("billing_state") or data.get("billing_state")
|
|
ticket["webmail_released"] = payload.get("webmail_released")
|
|
ticket["company_profile"] = payload.get("company_profile") or data.get("company_profile")
|
|
if not ticket.get("source"):
|
|
ticket["source"] = "wazuh" if ticket.get("event") == "wazuh.alert" else "vm112-onboard"
|
|
ticket["payload"] = payload
|
|
return ticket
|
|
|
|
|
|
def _enrich_event(row: sqlite3.Row) -> dict:
|
|
ev = dict(row)
|
|
payload = _parse_payload(ev.get("payload"))
|
|
data = payload.get("data") or {}
|
|
ev["payload"] = payload
|
|
ev["domain"] = payload.get("domain")
|
|
ev["session_id"] = payload.get("session_id")
|
|
ev["severity"] = data.get("level")
|
|
return ev
|
|
|
|
|
|
def _funnel_stage_for_event(event_type: str) -> str | None:
|
|
rank = FUNNEL_EVENT_RANK.get(event_type)
|
|
if rank is None:
|
|
return None
|
|
return FUNNEL_STAGE_BY_RANK.get(rank)
|
|
|
|
|
|
def _session_timeline(conn, session_id: str) -> list[dict]:
|
|
sid = (session_id or "").strip()
|
|
if not sid:
|
|
return []
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT id, event_type, source, payload, created_at
|
|
FROM webhook_events
|
|
WHERE source = ?
|
|
ORDER BY id ASC
|
|
LIMIT 500
|
|
""",
|
|
(ONBOARD_SOURCE,),
|
|
).fetchall()
|
|
timeline = []
|
|
for row in rows:
|
|
payload = _parse_payload(row["payload"])
|
|
if (payload.get("session_id") or "").strip() != sid:
|
|
continue
|
|
timeline.append({
|
|
"id": row["id"],
|
|
"event_type": row["event_type"],
|
|
"stage": _funnel_stage_for_event(row["event_type"]),
|
|
"domain": payload.get("domain"),
|
|
"data": payload.get("data") or {},
|
|
"created_at": row["created_at"],
|
|
})
|
|
return timeline
|
|
|
|
|
|
def _find_ticket_id_by_session(conn, session_id: str) -> int | None:
|
|
sid = (session_id or "").strip()
|
|
if not sid:
|
|
return None
|
|
rows = conn.execute(
|
|
"SELECT id, payload FROM tickets ORDER BY id DESC LIMIT 300"
|
|
).fetchall()
|
|
for row in rows:
|
|
payload = _parse_payload(row["payload"])
|
|
if (payload.get("session_id") or "").strip() == sid:
|
|
return int(row["id"])
|
|
return None
|
|
|
|
|
|
def _find_ticket_id_by_domain(conn, domain: str) -> int | None:
|
|
dom = (domain or "").strip().lower()
|
|
if not dom:
|
|
return None
|
|
rows = conn.execute(
|
|
"SELECT id, payload FROM tickets ORDER BY id DESC LIMIT 300"
|
|
).fetchall()
|
|
for row in rows:
|
|
payload = _parse_payload(row["payload"])
|
|
if (payload.get("domain") or "").strip().lower() == dom:
|
|
return int(row["id"])
|
|
return None
|
|
|
|
|
|
def _attach_funnel_note(
|
|
conn,
|
|
session_id: str,
|
|
event: str,
|
|
body: WebhookPayload,
|
|
now: str,
|
|
) -> int | None:
|
|
tid = _find_ticket_id_by_session(conn, session_id)
|
|
if not tid and body.domain:
|
|
tid = _find_ticket_id_by_domain(conn, body.domain)
|
|
if not tid:
|
|
return None
|
|
row = conn.execute("SELECT payload FROM tickets WHERE id = ?", (tid,)).fetchone()
|
|
payload = _parse_payload(row["payload"])
|
|
notes = list(payload.get("funnel_notes") or [])
|
|
notes.append({"event": event, "at": now, "data": body.data or {}})
|
|
payload["funnel_notes"] = notes[-30:]
|
|
if event == "onboarding.completed":
|
|
payload["ready_for_ops"] = True
|
|
if event == "company.validated":
|
|
payload["billing_state"] = (body.data or {}).get("billing_state") or "awaiting_billing_validation"
|
|
if body.data and body.data.get("company_profile"):
|
|
payload["company_profile"] = body.data["company_profile"]
|
|
if event == "webmail.released":
|
|
payload["webmail_released"] = True
|
|
payload["webmail_released_at"] = (body.data or {}).get("webmail_released_at")
|
|
conn.execute(
|
|
"UPDATE tickets SET payload = ? WHERE id = ?",
|
|
(json.dumps(payload), tid),
|
|
)
|
|
return tid
|
|
|
|
|
|
def _funnel_summary(conn, window_hours: int = 48) -> dict:
|
|
from datetime import timedelta
|
|
|
|
cutoff = (datetime.now(timezone.utc) - timedelta(hours=window_hours)).isoformat()
|
|
rows = conn.execute(
|
|
"""
|
|
SELECT event_type, payload, created_at
|
|
FROM webhook_events
|
|
WHERE source = ? AND created_at >= ?
|
|
ORDER BY id ASC
|
|
""",
|
|
(ONBOARD_SOURCE, cutoff),
|
|
).fetchall()
|
|
|
|
sessions: dict[str, dict] = {}
|
|
for row in rows:
|
|
payload = _parse_payload(row["payload"])
|
|
sid = (payload.get("session_id") or "").strip()
|
|
if not sid:
|
|
continue
|
|
rank = FUNNEL_EVENT_RANK.get(row["event_type"], 0)
|
|
sess = sessions.setdefault(
|
|
sid,
|
|
{
|
|
"session_id": sid,
|
|
"domain": payload.get("domain"),
|
|
"max_rank": 0,
|
|
"last_event_at": row["created_at"],
|
|
"failed": False,
|
|
},
|
|
)
|
|
if payload.get("domain"):
|
|
sess["domain"] = payload.get("domain")
|
|
if row["created_at"] >= sess["last_event_at"]:
|
|
sess["last_event_at"] = row["created_at"]
|
|
if row["event_type"] == "onboarding.failed":
|
|
sess["failed"] = True
|
|
sess["max_rank"] = max(sess["max_rank"], 99)
|
|
elif rank > sess["max_rank"] and not sess["failed"]:
|
|
sess["max_rank"] = rank
|
|
|
|
stage_counts = {label: 0 for label in FUNNEL_STAGE_BY_RANK.values()}
|
|
stale_cutoff = (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
|
|
active_sessions = []
|
|
|
|
for sid, sess in sessions.items():
|
|
if sess["failed"]:
|
|
stage = "failed"
|
|
else:
|
|
stage = FUNNEL_STAGE_BY_RANK.get(sess["max_rank"], "started")
|
|
stage_counts[stage] = stage_counts.get(stage, 0) + 1
|
|
ticket_id = _find_ticket_id_by_session(conn, sid)
|
|
stale = sess["last_event_at"] < stale_cutoff and stage not in ("completed", "failed")
|
|
active_sessions.append({
|
|
"session_id": sid,
|
|
"domain": sess.get("domain"),
|
|
"current_stage": stage,
|
|
"last_event_at": sess["last_event_at"],
|
|
"ticket_id": ticket_id,
|
|
"stale": stale,
|
|
})
|
|
|
|
active_sessions.sort(key=lambda x: x["last_event_at"], reverse=True)
|
|
return {
|
|
"window_hours": window_hours,
|
|
"stages": stage_counts,
|
|
"active_sessions": active_sessions[:50],
|
|
"sessions_total": len(sessions),
|
|
}
|
|
|
|
|
|
def _normalize_wazuh_alert(alert: dict[str, Any]) -> WebhookPayload:
|
|
rule = alert.get("rule") or {}
|
|
agent = alert.get("agent") or {}
|
|
data_field = alert.get("data") if isinstance(alert.get("data"), dict) else {}
|
|
level = rule.get("level", 0)
|
|
return WebhookPayload(
|
|
event="wazuh.alert",
|
|
domain=agent.get("name") or "unknown-agent",
|
|
session_id=str(alert.get("id") or alert.get("uuid") or ""),
|
|
data={
|
|
"level": level,
|
|
"rule_id": rule.get("id"),
|
|
"description": rule.get("description"),
|
|
"agent": agent.get("name"),
|
|
"agent_ip": agent.get("ip"),
|
|
"srcip": data_field.get("srcip"),
|
|
"source": "wazuh",
|
|
"raw_rule_groups": rule.get("groups"),
|
|
},
|
|
)
|
|
|
|
|
|
def _ticket_subject(body: WebhookPayload, source_key: str) -> str:
|
|
if source_key == "wazuh":
|
|
data = body.data or {}
|
|
level = data.get("level", "?")
|
|
agent = data.get("agent") or body.domain or "agent"
|
|
desc = (data.get("description") or "alerta")[:80]
|
|
return f"[wazuh L{level}] {agent} — {desc}"
|
|
if body.event == "company.validated":
|
|
domain = body.domain or "sem dominio"
|
|
profile = (body.data or {}).get("company_profile") or {}
|
|
legal = (profile.get("legal_name") or domain)[:60]
|
|
return f"[billing-validation] {domain} — {legal}"
|
|
domain = body.domain or "sem dominio"
|
|
email = (body.data or {}).get("email")
|
|
if email:
|
|
return f"[{body.event}] {domain} — {email}"
|
|
return f"[{body.event}] {domain}"
|
|
|
|
|
|
def _should_create_ticket(source_key: str, body: WebhookPayload) -> bool:
|
|
if body.event not in TICKET_EVENTS_BY_SOURCE.get(source_key, frozenset()):
|
|
return False
|
|
if source_key == "wazuh":
|
|
level = (body.data or {}).get("level") or 0
|
|
return int(level) >= WAZUH_MIN_TICKET_LEVEL
|
|
return True
|
|
|
|
|
|
def _is_duplicate_event(
|
|
conn,
|
|
source_key: str,
|
|
event: str,
|
|
session_id: str | None,
|
|
domain: str | None,
|
|
) -> bool:
|
|
sid = (session_id or "").strip()
|
|
dom = (domain or "").strip().lower()
|
|
if not sid:
|
|
return False
|
|
rows = conn.execute(
|
|
"SELECT payload FROM webhook_events WHERE event_type = ? AND source = ? ORDER BY id DESC LIMIT 300",
|
|
(event, source_key),
|
|
).fetchall()
|
|
for row in rows:
|
|
payload = _parse_payload(row["payload"])
|
|
row_sid = (payload.get("session_id") or "").strip()
|
|
row_dom = (payload.get("domain") or "").strip().lower()
|
|
if row_sid == sid and (not dom or row_dom == dom):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _process_ingress(source_key: str, body: WebhookPayload) -> dict:
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
stored = body.model_dump()
|
|
stored["source"] = source_key
|
|
payload = json.dumps(stored)
|
|
duplicate = False
|
|
ticket_created = False
|
|
ticket_id: int | None = None
|
|
tenant_id = TENANT_BY_SOURCE.get(source_key, 1)
|
|
|
|
with db() as conn:
|
|
duplicate = _is_duplicate_event(conn, source_key, body.event, body.session_id, body.domain)
|
|
if not duplicate:
|
|
conn.execute(
|
|
"INSERT INTO webhook_events (event_type,source,payload,created_at) VALUES (?,?,?,?)",
|
|
(body.event, source_key, payload, now),
|
|
)
|
|
if _should_create_ticket(source_key, body):
|
|
cur = conn.execute(
|
|
"INSERT INTO tickets (tenant_id,subject,status,payload,created_at) VALUES (?,?,?,?,?)",
|
|
(tenant_id, _ticket_subject(body, source_key), "open", payload, now),
|
|
)
|
|
ticket_created = True
|
|
ticket_id = int(cur.lastrowid)
|
|
elif (
|
|
source_key == ONBOARD_SOURCE
|
|
and body.event in FUNNEL_NOTE_EVENTS
|
|
and ((body.session_id or "").strip() or (body.domain or "").strip())
|
|
):
|
|
ticket_id = _attach_funnel_note(conn, body.session_id or "", body.event, body, now)
|
|
if not ticket_id and body.event == "company.validated":
|
|
cur = conn.execute(
|
|
"INSERT INTO tickets (tenant_id,subject,status,payload,created_at) VALUES (?,?,?,?,?)",
|
|
(tenant_id, _ticket_subject(body, source_key), "open", payload, now),
|
|
)
|
|
ticket_created = True
|
|
ticket_id = int(cur.lastrowid)
|
|
enriched = _parse_payload(payload)
|
|
enriched["billing_state"] = "awaiting_billing_validation"
|
|
conn.execute(
|
|
"UPDATE tickets SET payload = ? WHERE id = ?",
|
|
(json.dumps(enriched), ticket_id),
|
|
)
|
|
conn.commit()
|
|
elif source_key == ONBOARD_SOURCE and (body.session_id or "").strip():
|
|
ticket_id = _find_ticket_id_by_session(conn, body.session_id or "")
|
|
|
|
if not duplicate:
|
|
redis.from_url(REDIS_URL).lpush("ops:events", f"{source_key}:{body.event}")
|
|
|
|
return {
|
|
"accepted": True,
|
|
"status": "accepted",
|
|
"event": body.event,
|
|
"source": source_key,
|
|
"duplicate": duplicate,
|
|
"ticket_created": ticket_created,
|
|
"ticket_id": ticket_id,
|
|
}
|
|
|
|
|
|
def _verify_secret(integration: str, provided: str | None) -> None:
|
|
expected = INTEGRATION_SECRETS.get(integration)
|
|
if not expected or provided != expected:
|
|
raise HTTPException(401, "invalid webhook secret")
|
|
|
|
|
|
@app.on_event("startup")
|
|
def startup():
|
|
init_db()
|
|
try:
|
|
with db() as conn:
|
|
audit_store.sync_domains_from_webhooks(conn)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
redis.from_url(REDIS_URL).ping()
|
|
return {"status": "ok", "service": "ligbox-ops-api", "version": "0.5.0-company-gate"}
|
|
|
|
|
|
@app.get("/api/v1/integrations")
|
|
def list_integrations():
|
|
return {
|
|
"integrations": [
|
|
{"id": "onboard", "source": "vm112-onboard", "tenant_id": 1, "description": "Portal onboarding VM112"},
|
|
{"id": "wazuh", "source": "wazuh", "tenant_id": 2, "description": "Wazuh SOC VM104", "min_ticket_level": WAZUH_MIN_TICKET_LEVEL},
|
|
]
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/tenants")
|
|
def list_tenants():
|
|
with db() as conn:
|
|
rows = conn.execute("SELECT id,name,ip,role,created_at FROM tenants ORDER BY id").fetchall()
|
|
return {"tenants": [dict(r) for r in rows]}
|
|
|
|
|
|
@app.get("/api/v1/desk/tickets")
|
|
def list_tickets(status: str | None = Query(default=None), source: str | None = Query(default=None)):
|
|
with db() as conn:
|
|
query = "SELECT id,tenant_id,subject,status,payload,created_at FROM tickets"
|
|
params: list[Any] = []
|
|
clauses = []
|
|
if status in ("open", "closed"):
|
|
clauses.append("status = ?")
|
|
params.append(status)
|
|
if clauses:
|
|
query += " WHERE " + " AND ".join(clauses)
|
|
query += " ORDER BY id DESC LIMIT 100"
|
|
rows = conn.execute(query, params).fetchall()
|
|
tickets = [_enrich_ticket(r) for r in rows]
|
|
if source:
|
|
tickets = [
|
|
t for t in tickets
|
|
if t.get("source") == source
|
|
or (t.get("payload") or {}).get("source") == source
|
|
]
|
|
return {"tickets": tickets}
|
|
|
|
|
|
@app.get("/api/v1/desk/tickets/{ticket_id}")
|
|
def get_ticket(ticket_id: int):
|
|
with db() as conn:
|
|
row = conn.execute(
|
|
"SELECT id,tenant_id,subject,status,payload,created_at FROM tickets WHERE id = ?",
|
|
(ticket_id,),
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "ticket not found")
|
|
ticket = _enrich_ticket(row)
|
|
sid = ticket.get("session_id")
|
|
if sid:
|
|
ticket["timeline"] = _session_timeline(conn, sid)
|
|
ticket["related_events"] = ticket["timeline"][-20:]
|
|
else:
|
|
ticket["timeline"] = []
|
|
ticket["related_events"] = []
|
|
ticket["ready_for_ops"] = (ticket.get("payload") or {}).get("ready_for_ops", False)
|
|
return ticket
|
|
|
|
|
|
@app.patch("/api/v1/desk/tickets/{ticket_id}")
|
|
def update_ticket(ticket_id: int, body: TicketStatusUpdate):
|
|
if body.status not in ("open", "closed"):
|
|
raise HTTPException(400, "status must be open or closed")
|
|
with db() as conn:
|
|
cur = conn.execute("UPDATE tickets SET status = ? WHERE id = ?", (body.status, ticket_id))
|
|
conn.commit()
|
|
if cur.rowcount == 0:
|
|
raise HTTPException(404, "ticket not found")
|
|
row = conn.execute(
|
|
"SELECT id,tenant_id,subject,status,payload,created_at FROM tickets WHERE id = ?",
|
|
(ticket_id,),
|
|
).fetchone()
|
|
return {"ticket": _enrich_ticket(row)}
|
|
|
|
|
|
@app.get("/api/v1/desk/summary")
|
|
def desk_summary():
|
|
with db() as conn:
|
|
open_count = conn.execute("SELECT COUNT(*) c FROM tickets WHERE status = 'open'").fetchone()["c"]
|
|
closed_count = conn.execute("SELECT COUNT(*) c FROM tickets WHERE status = 'closed'").fetchone()["c"]
|
|
event_count = conn.execute("SELECT COUNT(*) c FROM webhook_events").fetchone()["c"]
|
|
wazuh_events = conn.execute("SELECT COUNT(*) c FROM webhook_events WHERE source = 'wazuh'").fetchone()["c"]
|
|
tenant_count = conn.execute("SELECT COUNT(*) c FROM tenants").fetchone()["c"]
|
|
recent = conn.execute(
|
|
"SELECT id,tenant_id,subject,status,payload,created_at FROM tickets ORDER BY id DESC LIMIT 5"
|
|
).fetchall()
|
|
return {
|
|
"tickets_open": open_count,
|
|
"tickets_closed": closed_count,
|
|
"tickets_total": open_count + closed_count,
|
|
"webhook_events": event_count,
|
|
"wazuh_events": wazuh_events,
|
|
"tenants": tenant_count,
|
|
"recent_tickets": [_enrich_ticket(r) for r in recent],
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/webhooks/events")
|
|
def list_webhook_events(
|
|
session_id: str | None = Query(default=None),
|
|
source: str | None = Query(default=None),
|
|
):
|
|
with db() as conn:
|
|
if source:
|
|
rows = conn.execute(
|
|
"SELECT id,event_type,source,payload,created_at FROM webhook_events WHERE source = ? ORDER BY id DESC LIMIT 100",
|
|
(source,),
|
|
).fetchall()
|
|
else:
|
|
rows = conn.execute(
|
|
"SELECT id,event_type,source,payload,created_at FROM webhook_events ORDER BY id DESC LIMIT 100"
|
|
).fetchall()
|
|
if session_id:
|
|
sid = session_id.strip()
|
|
rows = [
|
|
r for r in rows
|
|
if (_parse_payload(r["payload"]).get("session_id") or "").strip() == sid
|
|
]
|
|
return {"events": [_enrich_event(r) for r in rows[:50]]}
|
|
|
|
|
|
@app.get("/api/v1/onboard/funnel")
|
|
def onboard_funnel(window_hours: int = Query(default=48, ge=1, le=168)):
|
|
with db() as conn:
|
|
return _funnel_summary(conn, window_hours=window_hours)
|
|
|
|
|
|
@app.get("/api/v1/onboard/sessions/{session_id}/timeline")
|
|
def onboard_session_timeline(session_id: str):
|
|
sid = session_id.strip()
|
|
if not sid:
|
|
raise HTTPException(400, "session_id required")
|
|
with db() as conn:
|
|
timeline = _session_timeline(conn, sid)
|
|
domain = timeline[-1]["domain"] if timeline else None
|
|
if not domain:
|
|
for row in timeline:
|
|
if row.get("domain"):
|
|
domain = row["domain"]
|
|
break
|
|
ticket_id = _find_ticket_id_by_session(conn, sid)
|
|
return {
|
|
"session_id": sid,
|
|
"domain": domain,
|
|
"ticket_id": ticket_id,
|
|
"events": timeline,
|
|
}
|
|
|
|
|
|
@app.get("/api/v1/audit/overview")
|
|
def audit_overview():
|
|
with db() as conn:
|
|
return audit_store.build_overview(conn)
|
|
|
|
|
|
@app.get("/api/v1/audit/tenants/{tenant_id}/scorecard")
|
|
def audit_scorecard(tenant_id: int, domain: str = Query(...)):
|
|
domain = domain.lower().strip()
|
|
if not domain:
|
|
raise HTTPException(400, "domain query param required")
|
|
with db() as conn:
|
|
row = conn.execute("SELECT id FROM tenants WHERE id = ?", (tenant_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "tenant not found")
|
|
return audit_store.scorecard(conn, tenant_id, domain)
|
|
|
|
|
|
@app.post("/api/v1/audit/run/{tenant_id}")
|
|
def audit_run(tenant_id: int, domain: str = Query(...)):
|
|
domain = domain.lower().strip()
|
|
if not domain:
|
|
raise HTTPException(400, "domain query param required")
|
|
with db() as conn:
|
|
row = conn.execute("SELECT id FROM tenants WHERE id = ?", (tenant_id,)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, "tenant not found")
|
|
conn.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO audit_domains (tenant_id, domain, source, created_at)
|
|
VALUES (?, ?, 'manual', ?)
|
|
""",
|
|
(tenant_id, domain, datetime.now(timezone.utc).isoformat()),
|
|
)
|
|
conn.commit()
|
|
result = _run_audit_for_domain(tenant_id, domain)
|
|
return {"status": "completed", **result}
|
|
|
|
|
|
@app.post("/api/v1/audit/cycle")
|
|
def audit_cycle():
|
|
return _audit_cycle()
|
|
|
|
|
|
@app.post("/api/v1/webhooks/ingress/{integration}")
|
|
async def webhook_ingress(
|
|
integration: str,
|
|
request: Request,
|
|
x_webhook_secret: str | None = Header(default=None),
|
|
):
|
|
if integration not in INTEGRATION_SOURCES:
|
|
raise HTTPException(404, f"unknown integration: {integration}")
|
|
_verify_secret(integration, x_webhook_secret)
|
|
source_key = INTEGRATION_SOURCES[integration]
|
|
raw = await request.json()
|
|
|
|
if integration == "wazuh" and isinstance(raw, dict) and "rule" in raw:
|
|
body = _normalize_wazuh_alert(raw)
|
|
else:
|
|
body = WebhookPayload.model_validate(raw)
|
|
|
|
return _process_ingress(source_key, body)
|
|
|
|
|
|
@app.post("/api/v1/webhooks/onboard")
|
|
def webhook_onboard(body: WebhookPayload, x_webhook_secret: str | None = Header(default=None)):
|
|
_verify_secret("onboard", x_webhook_secret)
|
|
return _process_ingress("vm112-onboard", body)
|
|
|
|
|
|
@app.get("/api/v1/infra/vm112/status")
|
|
def vm112_status():
|
|
try:
|
|
with httpx.Client(timeout=8.0) as c:
|
|
r = c.get(f"{VM112_API}/api/onboarding/health")
|
|
return {"vm112": r.json(), "http_status": r.status_code}
|
|
except Exception as e:
|
|
return {"vm112": None, "error": str(e)}
|
|
|
|
|
|
@app.get("/api/v1/infra/wazuh/status")
|
|
def wazuh_status():
|
|
try:
|
|
with httpx.Client(timeout=8.0) as c:
|
|
r = c.get("https://10.10.10.104:55000/", verify=False)
|
|
return {"wazuh_api": r.json() if r.headers.get("content-type", "").startswith("application/json") else r.text[:200], "http_status": r.status_code}
|
|
except Exception as e:
|
|
return {"wazuh_api": None, "error": str(e)}
|