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