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)}
|