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.
1245 lines
46 KiB
Python
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)}
|