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

1245 lines
46 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 Depends, FastAPI, Header, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from app import audit_store, auth, assist_store, push_service
from app.auth_routes import router as auth_router
from app.registration_routes import router as registration_router
from app.mfa_recovery_routes import router as mfa_recovery_router
from app.assist_routes import router as assist_router, process_escalation_webhook
from app.crm_routes import router as crm_router
from app import crm_leads, integration_health
from app.cloudflare_dns import fetch_domain_dns
from app.modules.routes import router as modules_router
from app.vm112_domains_routes import router as vm112_domains_router
from app.carbonio_release_routes import router as carbonio_release_router
from app.migration.router import router as migration_router
from app.billing_routes import router as billing_router
from app.security_routes import router as security_router
from app.collectors.base import run_audit
from app.permissions import (
can_assign_ticket,
can_list_webhook_events,
can_patch_ticket,
can_read_audit_overview,
can_read_audit_scorecard,
can_read_cloudflare_dns,
can_read_funnel,
can_read_session_timeline,
can_read_tickets,
can_run_audit,
should_mask_sensitive,
)
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,
"security": WEBHOOK_SECRET,
"wazuh": WAZUH_WEBHOOK_SECRET,
}
INTEGRATION_SOURCES = {
"onboard": "vm112-onboard",
"security": "vm112-security",
"wazuh": "wazuh",
}
TICKET_EVENTS_BY_SOURCE = {
# Ticket no início do onboarding (email+senha / criar servidor) — Roger 2026-06-10
"vm112-onboard": frozenset({"onboarding.started", "onboarding.failed"}),
"wazuh": frozenset({"wazuh.alert"}),
}
TENANT_BY_SOURCE = {
"vm112-onboard": 1,
"wazuh": 2,
}
ONBOARD_SOURCE = "vm112-onboard"
FUNNEL_EVENT_RANK = {
"session.started": 0,
"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({
"account.created",
"domain.validated",
"dns.applied",
"infra.synced",
"onboarding.completed",
"company.validated",
"webmail.released",
})
ASSIST_ESCALATION_EVENTS = frozenset({"onboarding.escalated", "onboarding.failed"})
ASSIST_LIFECYCLE_EVENTS = frozenset({"onboarding.assist.started", "onboarding.assist.ended"})
TICKET_ACTIVE_STATUSES = frozenset({"open", "escalated", "assisting", "resolved"})
app = FastAPI(title="Ligbox Ops Platform API", version="0.9.0-desk-assist")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
app.include_router(auth_router)
app.include_router(registration_router)
app.include_router(mfa_recovery_router)
app.include_router(assist_router)
app.include_router(crm_router)
app.include_router(modules_router)
app.include_router(vm112_domains_router)
app.include_router(security_router)
app.include_router(carbonio_release_router)
app.include_router(migration_router)
app.include_router(billing_router)
TICKET_COLUMNS = "id,tenant_id,subject,status,payload,created_at,assigned_to,assigned_at,session_id,assist_mode,assisted_by,assisted_at,client_paused"
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)
auth.init_auth_schema(conn)
assist_store.init_assist_schema(conn)
from app import carbonio_release_store
carbonio_release_store.init_schema(conn)
from app.migration import store as migration_store
from app import billing_store
migration_store.init_schema(conn)
billing_store.init_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 | None = None
assigned_to: str | None = None
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")
ticket["activation_url"] = data.get("activation_url")
ticket["desk_message"] = data.get("message")
ticket["registration_role"] = data.get("role")
ticket["assist_mode"] = ticket.get("assist_mode")
ticket["assisted_by"] = ticket.get("assisted_by")
ticket["assisted_at"] = ticket.get("assisted_at")
ticket["client_paused"] = bool(ticket.get("client_paused"))
ticket["crm_track"] = payload.get("crm_track")
ticket["lead_detected_at"] = payload.get("lead_detected_at")
ticket["lead_funnel_stage"] = payload.get("lead_funnel_stage")
ticket["account_email"] = payload.get("account_email") or data.get("email")
if not ticket.get("source"):
ticket["source"] = "wazuh" if ticket.get("event") == "wazuh.alert" else "vm112-onboard"
ticket["payload"] = payload
return ticket
def _visible_ticket(ticket: dict, user: auth.DeskUser) -> dict:
if should_mask_sensitive(user.role):
return auth.mask_ticket(ticket)
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
row = conn.execute(
"SELECT id FROM tickets WHERE session_id = ? ORDER BY id DESC LIMIT 1",
(sid,),
).fetchone()
if row:
return int(row["id"])
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
FUNNEL_BACKFILL_EVENTS = frozenset({
"domain.validated",
"dns.applied",
})
def _backfill_funnel_notes(conn, session_id: str, ticket_id: int) -> None:
"""Anexa etapas anteriores ao ticket criado no «Criar servidor»."""
sid = (session_id or "").strip()
if not sid:
return
row = conn.execute("SELECT payload FROM tickets WHERE id = ?", (ticket_id,)).fetchone()
if not row:
return
payload = _parse_payload(row["payload"])
notes = list(payload.get("funnel_notes") or [])
existing = {n.get("event") for n in notes}
rows = conn.execute(
"""
SELECT event_type, payload, created_at
FROM webhook_events
WHERE source = ?
ORDER BY id ASC
""",
(ONBOARD_SOURCE,),
).fetchall()
for ev_row in rows:
ev_payload = _parse_payload(ev_row["payload"])
if (ev_payload.get("session_id") or "").strip() != sid:
continue
event_type = ev_row["event_type"]
if event_type not in FUNNEL_BACKFILL_EVENTS or event_type in existing:
continue
notes.append({
"event": event_type,
"at": ev_row["created_at"],
"data": ev_payload.get("data") or {},
"backfilled": True,
})
existing.add(event_type)
if notes:
payload["funnel_notes"] = notes[-30:]
conn.execute(
"UPDATE tickets SET payload = ? WHERE id = ?",
(json.dumps(payload), ticket_id),
)
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 and not (session_id or "").strip():
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 == "account.created":
email = (body.data or {}).get("email")
if email:
payload["account_email"] = email
domain = body.domain or payload.get("domain") or "sem dominio"
conn.execute(
"UPDATE tickets SET subject = ? WHERE id = ?",
(f"[onboarding] {domain}{email}", tid),
)
if event == "onboarding.completed":
payload["ready_for_ops"] = True
payload["onboarding_outcome"] = "completed"
payload["crm_track"] = "onboarding_completed"
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_hours = crm_leads.ONBOARD_STALE_HOURS
stale_cutoff = (datetime.now(timezone.utc) - timedelta(hours=stale_hours)).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)
assist = assist_store.get_active_assist(conn, sid)
ticket_row = assist_store.find_ticket_by_session(conn, sid)
crm_track = None
if ticket_row:
crm_track = _parse_payload(ticket_row["payload"]).get("crm_track")
assist_status = "observing"
if assist and assist.get("status") == "active":
assist_status = "assisting"
elif ticket_row and ticket_row["status"] in ("escalated", "assisting"):
assist_status = ticket_row["status"]
meta = assist_store.session_funnel_meta(conn, sid, FUNNEL_EVENT_RANK, FUNNEL_STAGE_BY_RANK, ONBOARD_SOURCE)
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,
"crm_track": crm_track,
"is_lead": crm_track == "lead",
"assist_status": assist_status,
"can_escalate": meta.get("can_escalate", False),
"assisted_by": assist.get("initiated_by_user") if assist else (ticket_row["assigned_to"] if ticket_row else None),
})
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 body.event in ("onboarding.started", "account.created"):
if email:
return f"[onboarding] {domain}{email}"
return f"[onboarding] {domain}"
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 _client_ip_from_request(request: Request | None) -> str | None:
if request is None:
return None
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return None
def _process_ingress(source_key: str, body: WebhookPayload, client_ip: str | None = None) -> dict:
now = datetime.now(timezone.utc).isoformat()
stored = body.model_dump()
stored["source"] = source_key
if client_ip:
stored["ingress_client_ip"] = client_ip
data = stored.get("data")
if not isinstance(data, dict):
data = {}
if not data.get("client_ip"):
data["client_ip"] = client_ip
stored["data"] = data
payload = json.dumps(stored)
duplicate = False
ticket_created = False
ticket_id: int | None = None
webhook_event_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:
wh_cur = conn.execute(
"INSERT INTO webhook_events (event_type,source,payload,created_at) VALUES (?,?,?,?)",
(body.event, source_key, payload, now),
)
webhook_event_id = int(wh_cur.lastrowid)
if source_key == "vm112-security":
from app import security_store as sec_store
if body.event in sec_store.AUTO_TICKET_EVENTS:
domain_label = body.domain or "sem domínio"
subject = f"[security] {domain_label}{body.event.replace('security.', '')}"
session_id = (body.session_id or "").strip() or None
cur = conn.execute(
"""
INSERT INTO tickets (tenant_id, subject, status, payload, created_at, session_id)
VALUES (?, ?, 'escalated', ?, ?, ?)
""",
(sec_store.VM112_TENANT_ID, subject, payload, now, session_id),
)
ticket_created = True
ticket_id = int(cur.lastrowid)
elif _should_create_ticket(source_key, body):
session_id = (body.session_id or "").strip() or None
initial_status = "escalated" if body.event == "onboarding.failed" else "open"
ticket_payload = _parse_payload(payload)
if body.event == "onboarding.started":
ticket_payload["crm_track"] = "onboarding"
ticket_payload["funnel_notes"] = []
cur = conn.execute(
"""
INSERT INTO tickets
(tenant_id,subject,status,payload,created_at,session_id,assigned_to,assigned_at)
VALUES (?,?,?,?,?,?,NULL,NULL)
""",
(
tenant_id,
_ticket_subject(body, source_key),
initial_status,
json.dumps(ticket_payload),
now,
session_id,
),
)
ticket_created = True
ticket_id = int(cur.lastrowid)
if body.event == "onboarding.started" and session_id:
_backfill_funnel_notes(conn, session_id, ticket_id)
if body.event == "onboarding.failed" and session_id:
process_escalation_webhook(conn, body, now)
elif body.event in ASSIST_ESCALATION_EVENTS and (body.session_id or "").strip():
ticket_id = process_escalation_webhook(conn, body, now).get("ticket_id")
elif body.event == "onboarding.assist.started" and (body.session_id or "").strip():
from app.assist_routes import process_assist_started
ticket_id = process_assist_started(conn, body, now).get("ticket_id")
elif body.event == "onboarding.assist.ended" and (body.session_id or "").strip():
from app.assist_routes import process_assist_ended
ticket_id = process_assist_ended(conn, body, now).get("ticket_id")
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 in ("company.validated", "account.created"):
session_id = (body.session_id or "").strip() or None
fallback_payload = _parse_payload(payload)
if body.event == "account.created":
fallback_payload["crm_track"] = "onboarding"
fallback_payload["funnel_notes"] = []
cur = conn.execute(
"""
INSERT INTO tickets
(tenant_id,subject,status,payload,created_at,session_id,assigned_to,assigned_at)
VALUES (?,?,?,?,?,?,NULL,NULL)
""",
(
tenant_id,
_ticket_subject(body, source_key),
"open",
json.dumps(fallback_payload),
now,
session_id,
),
)
ticket_created = True
ticket_id = int(cur.lastrowid)
if body.event == "company.validated":
enriched = _parse_payload(payload)
enriched["billing_state"] = "awaiting_billing_validation"
conn.execute(
"UPDATE tickets SET payload = ? WHERE id = ?",
(json.dumps(enriched), ticket_id),
)
if source_key == ONBOARD_SOURCE:
from app import carbonio_release_store
tid = ticket_id
if not tid and (body.session_id or "").strip():
tid = _find_ticket_id_by_session(conn, body.session_id or "")
from app import billing_store
if body.event == "company.validated" and body.domain:
billing_store.upsert_from_company_validated(
conn,
domain=body.domain,
session_id=body.session_id,
ticket_id=tid,
data=body.data,
)
carbonio_release_store.upsert_from_webhook(
conn,
event=body.event,
domain=body.domain,
session_id=body.session_id,
data=body.data,
webhook_event_id=webhook_event_id,
ticket_id=tid,
)
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}")
if source_key == ONBOARD_SOURCE:
detail = (body.data or {}).get("email") or body.domain or body.session_id or ""
try:
push_service.notify_ops_event(body.event, domain=body.domain, detail=str(detail))
except Exception:
pass
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")
@app.get("/api/health")
def health():
redis.from_url(REDIS_URL).ping()
return {"status": "ok", "service": "ligbox-ops-api", "version": "0.9.6-spec019-023"}
@app.get("/api/v1/integrations")
def list_integrations(user: auth.DeskUser = Depends(auth.get_current_user)):
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/integrations/health")
def integrations_health(user: auth.DeskUser = Depends(auth.require_internal_or_user)):
with db() as conn:
return integration_health.build_health_report(conn)
@app.post("/api/v1/integrations/onboard/test")
def test_onboard_webhook(user: auth.DeskUser = Depends(auth.get_current_user)):
if user.role not in ("super_admin", "admin"):
raise HTTPException(403, "insufficient permissions")
session_id = f"desk-test-{int(datetime.now(timezone.utc).timestamp())}"
body = WebhookPayload(
event="integration.test",
domain="ops-healthcheck.ligbox",
session_id=session_id,
data={"triggered_by": user.username, "test": True},
)
result = _process_ingress(ONBOARD_SOURCE, body)
result["domain"] = body.domain
result["session_id"] = session_id
result["tested_at"] = datetime.now(timezone.utc).isoformat()
result["triggered_by"] = user.username
result["message"] = (
"Webhook processado com sucesso. O evento aparece no feed SOC e em Eventos."
if not result.get("duplicate")
else "Evento duplicado — o pipe está OK, mas este teste já existia na janela de deduplicação."
)
return result
@app.get("/api/v1/tenants")
def list_tenants(user: auth.DeskUser = Depends(auth.get_current_user)):
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),
user: auth.DeskUser = Depends(auth.get_current_user),
):
if not can_read_tickets(user.role):
raise HTTPException(403, "insufficient permissions")
with db() as conn:
query = f"SELECT {TICKET_COLUMNS} FROM tickets"
params: list[Any] = []
clauses = []
if status == "active":
clauses.append(f"status IN ({','.join('?' * len(TICKET_ACTIVE_STATUSES))})")
params.extend(sorted(TICKET_ACTIVE_STATUSES))
elif status in TICKET_ACTIVE_STATUSES or status == "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 = [_visible_ticket(_enrich_ticket(r), user) 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, user: auth.DeskUser = Depends(auth.get_current_user)):
if not can_read_tickets(user.role):
raise HTTPException(403, "insufficient permissions")
with db() as conn:
row = conn.execute(
f"SELECT {TICKET_COLUMNS} 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:
timeline = _session_timeline(conn, sid)
from app.funnel_timing import apply_module_timing
enriched, timing_meta = apply_module_timing(timeline)
ticket["timeline"] = enriched
ticket["related_events"] = enriched[-20:]
if timing_meta:
ticket["timing"] = timing_meta
else:
ticket["timeline"] = []
ticket["related_events"] = []
ticket["ready_for_ops"] = (ticket.get("payload") or {}).get("ready_for_ops", False)
return _visible_ticket(ticket, user)
@app.patch("/api/v1/desk/tickets/{ticket_id}")
def update_ticket(
ticket_id: int,
body: TicketStatusUpdate,
user: auth.DeskUser = Depends(auth.get_current_user),
):
if body.status is None and body.assigned_to is None:
raise HTTPException(400, "status or assigned_to required")
if body.status is not None and body.status not in ("open", "closed", "escalated", "assisting", "resolved"):
raise HTTPException(400, "status inválido")
now = datetime.now(timezone.utc).isoformat()
with db() as conn:
row = conn.execute(
f"SELECT {TICKET_COLUMNS} FROM tickets WHERE id = ?",
(ticket_id,),
).fetchone()
if not row:
raise HTTPException(404, "ticket not found")
ticket = _enrich_ticket(row)
if body.status is not None and not can_patch_ticket(user.role, ticket, user.username):
raise HTTPException(403, "insufficient permissions")
if body.assigned_to is not None and not can_assign_ticket(user.role, body.assigned_to, user.username):
raise HTTPException(403, "insufficient permissions")
if body.status is not None:
conn.execute("UPDATE tickets SET status = ? WHERE id = ?", (body.status, ticket_id))
if body.assigned_to is not None:
assignee = body.assigned_to.strip().lower() if body.assigned_to else None
if assignee == "root":
assignee = "root"
conn.execute(
"UPDATE tickets SET assigned_to = ?, assigned_at = ? WHERE id = ?",
(assignee, now if assignee else None, ticket_id),
)
conn.commit()
row = conn.execute(
f"SELECT {TICKET_COLUMNS} FROM tickets WHERE id = ?",
(ticket_id,),
).fetchone()
return {"ticket": _visible_ticket(_enrich_ticket(row), user)}
@app.get("/api/v1/desk/summary")
def desk_summary(user: auth.DeskUser = Depends(auth.get_current_user)):
with db() as conn:
open_count = conn.execute(
f"SELECT COUNT(*) c FROM tickets WHERE status IN ({','.join('?' * len(TICKET_ACTIVE_STATUSES))})",
tuple(sorted(TICKET_ACTIVE_STATUSES)),
).fetchone()["c"]
escalated_count = conn.execute("SELECT COUNT(*) c FROM tickets WHERE status = 'escalated'").fetchone()["c"]
assisting_count = conn.execute("SELECT COUNT(*) c FROM tickets WHERE status = 'assisting'").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(
f"SELECT {TICKET_COLUMNS} FROM tickets ORDER BY id DESC LIMIT 5"
).fetchall()
leads_count = crm_leads.count_leads(conn)
summary = {
"tickets_open": open_count,
"tickets_escalated": escalated_count,
"tickets_assisting": assisting_count,
"tickets_closed": closed_count,
"tickets_total": open_count + closed_count,
"leads_abandoned": leads_count,
"onboard_stale_hours": crm_leads.ONBOARD_STALE_HOURS,
"webhook_events": event_count,
"wazuh_events": wazuh_events,
"tenants": tenant_count,
"recent_tickets": [_enrich_ticket(r) for r in recent],
}
from app import billing_store
with db() as conn:
bs = billing_store.summary(conn)
summary.update({
"billing_pending": bs["billing_pending"],
"billing_active": bs["billing_active"],
"billing_total": bs["billing_total"],
})
if should_mask_sensitive(user.role):
return auth.mask_summary_for_noc(summary)
return summary
@app.get("/api/v1/webhooks/events")
def list_webhook_events(
session_id: str | None = Query(default=None),
source: str | None = Query(default=None),
user: auth.DeskUser = Depends(auth.get_current_user),
):
if user.role == "noc" and not source:
source = "wazuh"
if not can_list_webhook_events(user.role, source):
raise HTTPException(403, "insufficient permissions")
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),
user: auth.DeskUser = Depends(auth.get_current_user),
):
if not can_read_funnel(user.role):
raise HTTPException(403, "insufficient permissions")
with db() as conn:
data = _funnel_summary(conn, window_hours=window_hours)
if should_mask_sensitive(user.role):
data["active_sessions"] = [
{
"session_id": (s.get("session_id") or "")[:8] + "",
"domain": s.get("domain"),
"current_stage": s.get("current_stage"),
"last_event_at": s.get("last_event_at"),
"ticket_id": s.get("ticket_id"),
"stale": s.get("stale"),
}
for s in data.get("active_sessions", [])
]
return data
@app.get("/api/v1/onboard/sessions/{session_id}/timeline")
def onboard_session_timeline(session_id: str, user: auth.DeskUser = Depends(auth.get_current_user)):
if not can_read_session_timeline(user.role):
raise HTTPException(403, "insufficient permissions")
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)
result = {
"session_id": sid,
"domain": domain,
"ticket_id": ticket_id,
"events": timeline,
}
from app.modules import store as module_store
from app.funnel_timing import build_timing_report
from app.funnel_timing import apply_module_timing
if module_store.is_module_enabled("funnel-timing") and timeline:
enriched, timing_meta = apply_module_timing(timeline)
result["events"] = enriched
if timing_meta:
result["timing"] = timing_meta
return result
@app.get("/api/v1/audit/overview")
def audit_overview(user: auth.DeskUser = Depends(auth.get_current_user)):
if not can_read_audit_overview(user.role):
raise HTTPException(403, "insufficient permissions")
with db() as conn:
return audit_store.build_overview(conn)
@app.get("/api/v1/audit/tenants/{tenant_id}/details")
def audit_tenant_details(
tenant_id: int,
user: auth.DeskUser = Depends(auth.get_current_user),
):
if not can_read_audit_overview(user.role):
raise HTTPException(403, "insufficient permissions")
with db() as conn:
details = audit_store.tenant_details(conn, tenant_id)
if not details:
raise HTTPException(404, "tenant not found")
return details
@app.get("/api/v1/dns/cloudflare/records")
async def cloudflare_dns_records(
domain: str = Query(..., min_length=3),
email_service: bool | None = Query(default=None),
user: auth.DeskUser = Depends(auth.get_current_user),
):
if not can_read_cloudflare_dns(user.role):
raise HTTPException(403, "insufficient permissions")
domain = domain.lower().strip()
return await fetch_domain_dns(domain, email_service=email_service)
@app.get("/api/v1/audit/tenants/{tenant_id}/scorecard")
def audit_scorecard(
tenant_id: int,
domain: str = Query(...),
user: auth.DeskUser = Depends(auth.get_current_user),
):
if not can_read_audit_scorecard(user.role):
raise HTTPException(403, "insufficient permissions")
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(...),
user: auth.DeskUser = Depends(auth.get_current_user),
):
if not can_run_audit(user.role):
raise HTTPException(403, "insufficient permissions")
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(user: auth.DeskUser = Depends(auth.require_internal_or_user)):
if user.username not in ("worker", "system") and not can_run_audit(user.role):
raise HTTPException(403, "insufficient permissions")
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, _client_ip_from_request(request))
@app.post("/api/v1/webhooks/onboard")
def webhook_onboard(
body: WebhookPayload,
request: Request,
x_webhook_secret: str | None = Header(default=None),
):
_verify_secret("onboard", x_webhook_secret)
return _process_ingress("vm112-onboard", body, _client_ip_from_request(request))
@app.post("/api/v1/webhooks/security")
def webhook_security(
body: WebhookPayload,
request: Request,
x_webhook_secret: str | None = Header(default=None),
):
_verify_secret("security", x_webhook_secret)
if not body.event.startswith("security."):
raise HTTPException(400, "event must start with security.")
return _process_ingress("vm112-security", body, _client_ip_from_request(request))
@app.get("/api/v1/infra/vm112/status")
def vm112_status(user: auth.DeskUser = Depends(auth.get_current_user)):
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(user: auth.DeskUser = Depends(auth.get_current_user)):
try:
with httpx.Client(timeout=8.0, verify=False) as c:
r = c.get("https://10.10.10.104:55000/")
online = r.status_code in (200, 401)
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else r.text[:200]
return {
"wazuh_api": body,
"http_status": r.status_code,
"api_online": online,
}
except Exception as e:
return {"wazuh_api": None, "http_status": None, "api_online": False, "error": str(e)}