refactor: decompose monolith — app.py 10,600→143 lines, 30+ modules

Fase 0 complete: extract all business logic, auth, database, and 176
endpoints from monolithic app.py into dedicated modules.

Structure:
- app.py: FastAPI hub (CORS, routers, startup/shutdown)
- models.py: 13 Pydantic request models
- utils.py: shared utilities (embed status, upload validation, process registries)
- config.py: constants, env vars, model catalogs
- auth/: crypto, jwt_auth, oidc, rate_limit
- database/: db(), init_db(), schema DDL
- services/: genai, compliance, chat, terraform, embeddings, cis_reports, mcp
- routes/: 13 APIRouter modules (auth, users, oci_config, oci_explorer,
  genai, mcp, adb, embeddings, reports, chat, terraform, settings, cis_engine)

Also: README updated with OCIR auth instructions for manual docker run.

86 tests passing.
This commit is contained in:
nogueiraguh
2026-04-06 15:20:10 -03:00
parent 426bde563e
commit 1135e9d6a9
36 changed files with 11179 additions and 10514 deletions

569
backend/routes/reports.py Normal file
View File

@@ -0,0 +1,569 @@
"""Report routes — CIS report CRUD, compliance report, OCI health/services."""
import asyncio
import io
import json
import os
import subprocess
import tempfile
import time
import uuid
import zipfile
from functools import partial
from pathlib import Path
from fastapi import (
APIRouter, HTTPException, Depends, Query, BackgroundTasks, Request,
)
from fastapi.responses import FileResponse, HTMLResponse, Response
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from config import REPORTS, VERSION, _chat_executor, log
from database import db
from auth.jwt_auth import (
current_user, _user_from_token, require, _audit,
_verify_config_access, _verify_report_access,
)
from models import RunReportReq
from utils import running_reports
from services.genai import _get_adb_connection
from services.cis_reports import _exec_report
from services.compliance_gen import (
_extract_section,
_extract_remediation_section,
_extract_audit_section,
_extract_rationale_section,
_extract_description_section,
_format_remediation_html,
_search_cis_remediation,
_build_findings_table,
_check_oci_services,
_OCI_SERVICE_DESCRIPTIONS,
_build_compliance_html,
_generate_compliance_report,
)
from services.compliance_docx import (
_generate_camo_strip,
_add_floating_image_to_header,
_generate_compliance_docx,
)
router = APIRouter()
# ── Helper ────────────────────────────────────────────────────────────────────
# ── Report CRUD ───────────────────────────────────────────────────────────────
@router.post("/api/reports/run")
async def run_report(req: RunReportReq, bg: BackgroundTasks, u=Depends(require("admin","user"))):
if req.level not in (1, 2): raise HTTPException(400, "Level must be 1 or 2")
_verify_config_access("oci", req.config_id, u)
with db() as c:
cfg = c.execute("SELECT * FROM oci_configs WHERE id=?",(req.config_id,)).fetchone()
if not cfg: raise HTTPException(404, "Config não encontrada")
rid = str(uuid.uuid4())
c.execute("INSERT INTO reports (id,user_id,tenancy_name,config_id,level,obp_checks,raw_data,redact_output,status) VALUES (?,?,?,?,?,?,?,?,?)",
(rid, u["id"], cfg["tenancy_name"], req.config_id, req.level, int(req.obp), int(req.raw), int(req.redact_output), "running"))
bg.add_task(_exec_report, rid, dict(cfg), req.regions, req.level, req.obp, req.raw, req.redact_output)
_audit(u["id"], u["username"], "run_report", rid)
return {"report_id": rid, "status": "running"}
@router.get("/api/reports")
async def list_reports(skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), u=Depends(current_user)):
with db() as c:
q = "SELECT id,user_id,tenancy_name,config_id,status,progress,level,obp_checks,raw_data,redact_output,created_at,completed_at,error_msg FROM reports"
rows = c.execute(q+" WHERE user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?",(u["id"], limit, skip)).fetchall()
return [dict(r) for r in rows]
@router.get("/api/reports/{rid}/progress")
async def get_report_progress(rid: str, u=Depends(current_user)):
_verify_report_access(rid, u)
with db() as c:
r = c.execute("SELECT status,progress,created_at,completed_at,error_msg FROM reports WHERE id=?", (rid,)).fetchone()
if not r: raise HTTPException(404)
return dict(r)
@router.post("/api/reports/{rid}/cancel")
async def cancel_report(rid: str, u=Depends(require("admin", "user"))):
_verify_report_access(rid, u)
with db() as c:
r = c.execute("SELECT status, worker_pid FROM reports WHERE id=?", (rid,)).fetchone()
if not r: raise HTTPException(404)
if r["status"] != "running":
raise HTTPException(400, "Report is not running")
proc = running_reports.get(rid)
if proc:
try:
proc.terminate()
try:
await asyncio.wait_for(proc.wait(), timeout=5)
except asyncio.TimeoutError:
proc.kill()
except ProcessLookupError:
pass
running_reports.pop(rid, None)
elif r["worker_pid"]:
import signal
try:
os.kill(r["worker_pid"], signal.SIGTERM)
except (ProcessLookupError, OSError):
pass
with db() as c:
c.execute("UPDATE reports SET status='cancelled',error_msg='Cancelled by user',completed_at=datetime('now') WHERE id=?", (rid,))
_audit(u["id"], u["username"], "cancel_report", rid)
return {"ok": True, "status": "cancelled"}
@router.get("/api/reports/{rid}")
async def get_report(rid, u=Depends(current_user)):
with db() as c: r=c.execute("SELECT * FROM reports WHERE id=?",(rid,)).fetchone()
if not r: raise HTTPException(404)
if u["role"]!="admin" and r["user_id"]!=u["id"]: raise HTTPException(403)
return dict(r)
@router.delete("/api/reports/{rid}")
async def delete_report(rid: str, u=Depends(current_user)):
with db() as c:
r = c.execute("SELECT id, user_id, status, json_path, html_path FROM reports WHERE id=?", (rid,)).fetchone()
if not r: raise HTTPException(404)
if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403)
if r["status"] == "running": raise HTTPException(400, "Cannot delete a running report")
# Remove associated files
for path_col in ("json_path", "html_path"):
p = r[path_col]
if p and Path(p).exists():
try: Path(p).unlink()
except OSError: pass
c.execute("DELETE FROM reports WHERE id=?", (rid,))
_audit(u["id"], u["username"], "delete_report", rid)
return {"ok": True}
@router.get("/api/reports/{rid}/summary")
async def report_summary(rid: str, u=Depends(current_user)):
with db() as c:
r = c.execute("SELECT user_id,json_path,tenancy_name,level,created_at FROM reports WHERE id=? AND status='completed'", (rid,)).fetchone()
if not r: raise HTTPException(404)
if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403)
jp = r["json_path"]
if not jp or not Path(jp).exists():
raise HTTPException(400, "Report JSON not found")
data = json.loads(Path(jp).read_text())
total = passed = failed = 0
sections = {}
total_findings = 0
total_compliant = 0
# Format: list of checks with Compliant=Yes/No, Section, Findings, Compliant Items, Total
checks = data if isinstance(data, list) else data.get("findings", [])
if isinstance(checks, dict):
checks = list(checks.values())
for check in checks:
total += 1
compliant = str(check.get("Compliant", check.get("status", ""))).strip().lower()
is_pass = compliant in ("yes", "pass")
if is_pass:
passed += 1
else:
failed += 1
fi = str(check.get("Findings", "0")).strip()
ci = str(check.get("Compliant Items", "0")).strip()
total_findings += int(fi) if fi.isdigit() else 0
total_compliant += int(ci) if ci.isdigit() else 0
sec = check.get("Section", check.get("section", "Other"))
s = sections.setdefault(sec, {"passed": 0, "failed": 0, "total": 0})
s["total"] += 1
if is_pass:
s["passed"] += 1
else:
s["failed"] += 1
score = round((passed / total * 100), 1) if total else 0
return {
"tenancy": data.get("tenancy", r["tenancy_name"]) if isinstance(data, dict) else r["tenancy_name"],
"level": r["level"] or 2,
"regions": data.get("regions", []) if isinstance(data, dict) else [],
"generated_at": r["created_at"],
"total": total, "passed": passed, "failed": failed,
"total_findings": total_findings, "total_compliant": total_compliant,
"score": score,
"sections": sections
}
@router.get("/api/reports/{rid}/files")
async def list_report_files(rid: str, u=Depends(current_user)):
with db() as c:
r = c.execute("SELECT user_id FROM reports WHERE id=?", (rid,)).fetchone()
if not r: raise HTTPException(404)
if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403)
files = c.execute(
"SELECT id,file_name,file_type,file_category,file_size FROM report_files WHERE report_id=? ORDER BY file_category,file_name",
(rid,)
).fetchall()
return [dict(f) for f in files]
@router.get("/api/reports/{rid}/files/{fid}/download")
async def download_report_file(rid: str, fid: str, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
if not u: raise HTTPException(401, "Not authenticated")
with db() as c:
r = c.execute("SELECT user_id FROM reports WHERE id=?", (rid,)).fetchone()
if not r: raise HTTPException(404)
if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403)
f = c.execute("SELECT * FROM report_files WHERE id=? AND report_id=?", (fid, rid)).fetchone()
if not f: raise HTTPException(404)
p = Path(f["file_path"])
if not p.exists(): raise HTTPException(404, "File not found on disk")
return FileResponse(p, filename=f["file_name"])
@router.api_route("/api/reports/{rid}/html", methods=["GET", "HEAD"])
async def report_html(rid, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
if not u: raise HTTPException(401, "Not authenticated")
_verify_report_access(rid, u)
with db() as c: r=c.execute("SELECT html_path FROM reports WHERE id=?",(rid,)).fetchone()
if not r or not r["html_path"] or not Path(r["html_path"]).exists(): raise HTTPException(404)
return FileResponse(r["html_path"], media_type="text/html")
# ═══════════════════════════════════════════════════════════════════════
# OCI Health & Services
# ═══════════════════════════════════════════════════════════════════════
@router.get("/api/oci-health")
async def oci_health(u=Depends(current_user)):
"""Proxy Oracle Cloud Infrastructure public status API."""
import httpx
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.get("https://ocistatus.oraclecloud.com/api/v2/components.json")
r.raise_for_status()
return r.json()
except Exception as e:
log.warning(f"OCI Health API failed: {e}")
raise HTTPException(502, f"Failed to fetch OCI status: {e}")
_OCI_SERVICE_NAMES = ["Vulnerability Scanning Service", "Data Safe", "Cloud Guard", "Bastion", "Security Zones", "Vault"]
@router.get("/api/oci-services/{config_id}")
async def get_oci_services_status(config_id: str, detect: int = Query(1), u=Depends(current_user)):
"""Get OCI services status (auto-detected + user overrides). Use detect=0 to skip auto-detect."""
_verify_config_access("oci", config_id, u)
import json as _json
# Load user overrides
with db() as c:
row = c.execute("SELECT value FROM app_settings WHERE key=?", (f"oci_services_{config_id}",)).fetchone()
overrides = _json.loads(row["value"]) if row else {}
# Auto-detect (skip if detect=0)
auto = {}
if detect:
try:
detected = _check_oci_services(config_id)
for svc in detected:
auto[svc["service"]] = {"status": svc["status"], "description": svc["description"]}
except Exception as e:
log.warning(f"OCI services auto-detect failed: {e}")
# Merge: override wins
result = []
for name in _OCI_SERVICE_NAMES:
ov = overrides.get(name, {})
au = auto.get(name, {"status": "WARNING", "description": "Unable to verify"})
result.append({
"service": name,
"auto_status": au["status"],
"auto_description": au["description"],
"status": ov.get("status", au["status"]),
"description": ov.get("description", au["description"]),
"overridden": bool(ov),
})
return result
@router.put("/api/oci-services/{config_id}")
async def set_oci_services_status(config_id: str, request: Request, u=Depends(current_user)):
"""Save user overrides for OCI services status."""
_verify_config_access("oci", config_id, u)
import json as _json
body = await request.json()
overrides = {}
for svc in body:
name = svc.get("service", "")
if name in _OCI_SERVICE_NAMES and svc.get("overridden"):
overrides[name] = {"status": svc.get("status", "OK"), "description": svc.get("description", "")}
with db() as c:
c.execute("INSERT INTO app_settings (key,value,updated_at) VALUES (?,?,datetime('now')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
(f"oci_services_{config_id}", _json.dumps(overrides)))
_audit(u["id"], u["username"], "set_oci_services", config_id)
return {"ok": True}
# ═══════════════════════════════════════════════════════════════════════
# LAD A-Team CIS Compliance Report
# ═══════════════════════════════════════════════════════════════════════
@router.api_route("/api/reports/{rid}/compliance-report", methods=["GET", "HEAD"])
async def compliance_report(rid: str, regen: int = Query(0), token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
"""Serve cached LAD A-Team CIS Compliance Report, or return 404 if not generated yet."""
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
if not u: raise HTTPException(401, "Not authenticated")
_verify_report_access(rid, u)
rdir = REPORTS / rid
cache_path = rdir / "compliance_report.html"
if regen == 1 and cache_path.exists():
cache_path.unlink()
if cache_path.exists():
return FileResponse(cache_path, media_type="text/html",
headers={"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache", "Expires": "0"})
# Return a friendly HTML page instead of JSON 404 (shown in iframe)
marker = rdir / ".compliance_generating"
if marker.exists():
msg = "Generating compliance report... Please wait."
else:
msg = "Compliance report not generated yet."
return HTMLResponse(
f'<html><body style="display:flex;align-items:center;justify-content:center;height:100vh;margin:0;font-family:Calibri,sans-serif;color:#666">'
f'<div style="text-align:center"><p style="font-size:14px">{msg}</p></div></body></html>',
status_code=200,
headers={"Cache-Control": "no-cache"})
@router.post("/api/reports/{rid}/compliance-report/generate")
async def generate_compliance_report(rid: str, bg: BackgroundTasks, u=Depends(current_user)):
"""Start generating the compliance report in background."""
_verify_report_access(rid, u)
with db() as c:
report = c.execute("SELECT * FROM reports WHERE id=?", (rid,)).fetchone()
if not report: raise HTTPException(404, "Report not found")
rdir = REPORTS / rid
csv_path = rdir / "cis_summary_report.csv"
if not csv_path.exists():
raise HTTPException(404, "cis_summary_report.csv not found")
# Reject if already generating
marker = rdir / ".compliance_generating"
if marker.exists():
age = time.time() - marker.stat().st_mtime
if age < 600:
return {"status": "already_generating", "has_rag": False}
# Mark as generating BEFORE deleting cache
marker.write_text(str(uuid.uuid4()), encoding="utf-8")
# Delete existing cache
cache_path = rdir / "compliance_report.html"
if cache_path.exists():
cache_path.unlink()
has_adb = False
with db() as c:
adb_cfgs = c.execute("SELECT * FROM adb_vector_configs WHERE user_id=? AND is_active=1", (u["id"],)).fetchall()
if adb_cfgs:
has_adb = True
# Pre-check ADB connection before starting (fail fast instead of timeout loop)
if has_adb:
adb_cfg = dict(adb_cfgs[0])
try:
conn = _get_adb_connection(adb_cfg)
conn.ping()
conn.close()
log.info(f"ADB connection OK for compliance report {rid}")
except Exception as e:
marker.unlink(missing_ok=True)
log.warning(f"ADB connection failed for compliance report {rid}: {e}")
raise HTTPException(503, f"Conexao com ADB falhou. Verifique a whitelist e a configuracao do ADB. Erro: {e}")
user_id = u["id"]
log.info(f"Compliance report generation started for {rid} (has_adb={has_adb})")
def _bg_generate():
try:
log.info(f"Compliance report thread running for {rid}")
_generate_compliance_report(rid, user_id, force_rag=has_adb)
log.info(f"Compliance report generation completed for {rid}")
except Exception as e:
log.error(f"Compliance report generation failed for {rid}: {e}", exc_info=True)
finally:
# Remove generating marker
m = REPORTS / rid / ".compliance_generating"
if m.exists():
m.unlink(missing_ok=True)
_chat_executor.submit(_bg_generate)
return {"status": "generating", "has_rag": has_adb}
@router.get("/api/reports/{rid}/compliance-report/status")
async def compliance_report_status(rid: str, u=Depends(current_user)):
"""Check compliance report status: ready, generating, or not started."""
_verify_report_access(rid, u)
rdir = REPORTS / rid
cache_path = rdir / "compliance_report.html"
marker = rdir / ".compliance_generating"
if cache_path.exists():
# Clean stale marker if report is ready
if marker.exists():
marker.unlink(missing_ok=True)
return {"ready": True, "generating": False}
if marker.exists():
age = time.time() - marker.stat().st_mtime
if age > 600:
marker.unlink(missing_ok=True)
log.warning(f"Expired stale compliance marker for {rid} (age={age:.0f}s)")
return {"ready": False, "generating": False}
# Read progress from marker file
try:
import json as _json
progress = _json.loads(marker.read_text(encoding="utf-8"))
except Exception:
progress = {}
return {"ready": False, "generating": True,
"current": progress.get("current", 0),
"total": progress.get("total", 0),
"step": progress.get("step", ""),
"rec": progress.get("rec", "")}
return {"ready": False, "generating": False}
@router.get("/api/reports/{rid}/compliance-report/docx")
async def compliance_report_docx(rid: str, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
"""Download DOCX compliance report directly."""
import re as _re
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
if not u: raise HTTPException(401, "Not authenticated")
_verify_report_access(rid, u)
with db() as c:
report = c.execute("SELECT tenancy_name FROM reports WHERE id=?", (rid,)).fetchone()
if not report: raise HTTPException(404)
files = c.execute("SELECT file_name, file_path FROM report_files WHERE report_id=?", (rid,)).fetchall()
tenancy = report["tenancy_name"] or "report"
safe_name = _re.sub(r'[^\w\-]', '_', tenancy)
try:
file_map = {f["file_name"]: f["file_path"] for f in files}
docx_bytes = _generate_compliance_docx(rid, tenancy, file_map)
if not docx_bytes:
raise HTTPException(500, "DOCX generation failed")
log.info(f"DOCX direct download: {len(docx_bytes)} bytes")
except HTTPException:
raise
except Exception as e:
log.error(f"DOCX generation failed: {e}", exc_info=True)
raise HTTPException(500, f"DOCX generation failed: {e}")
return Response(
content=docx_bytes,
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
headers={"Content-Disposition": f'attachment; filename="CIS_Compliance_Report_{safe_name}.docx"'}
)
@router.get("/api/reports/{rid}/compliance-report/download")
async def compliance_report_download(rid: str, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
"""Download ZIP with compliance report PDF (Chromium headless) + CSV files."""
import re as _re
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
if not u: raise HTTPException(401, "Not authenticated")
_verify_report_access(rid, u)
rdir = REPORTS / rid
html_path = rdir / "compliance_report.html"
if not html_path.exists():
raise HTTPException(404, "Compliance report not generated yet")
with db() as c:
report = c.execute("SELECT tenancy_name FROM reports WHERE id=?", (rid,)).fetchone()
if not report: raise HTTPException(404)
files = c.execute("SELECT file_name, file_path FROM report_files WHERE report_id=?", (rid,)).fetchall()
tenancy = report["tenancy_name"] or "report"
safe_name = _re.sub(r'[^\w\-]', '_', tenancy)
file_map = {f["file_name"]: f["file_path"] for f in files}
# Prepare HTML for PDF: rewrite CSV links to relative paths
html = html_path.read_text(encoding="utf-8")
html = _re.sub(r'<button class="print-btn[^"]*"[^>]*>.*?</button>', '', html, flags=_re.DOTALL)
html = _re.sub(r'<script>.*?</script>', '', html, flags=_re.DOTALL)
for fname in file_map:
html = _re.sub(
r'href="[^"]*?/download[^"]*?"([^>]*>)\s*\[CSV\]\s*' + _re.escape(fname),
f'href="csv/{fname}"\\1[CSV] {fname}',
html
)
# Generate PDF via Chromium headless (same engine as browser print)
pdf_bytes = None
try:
with tempfile.TemporaryDirectory() as tmpdir:
tmp_html = Path(tmpdir) / "report.html"
tmp_pdf = Path(tmpdir) / "report.pdf"
tmp_html.write_text(html, encoding="utf-8")
loop = asyncio.get_event_loop()
result = await asyncio.wait_for(
loop.run_in_executor(None, partial(
subprocess.run, [
"chromium", "--headless", "--no-sandbox", "--disable-gpu",
"--disable-software-rasterizer", "--run-all-compositor-stages-before-draw",
f"--print-to-pdf={tmp_pdf}", "--no-pdf-header-footer",
f"file://{tmp_html}"
], capture_output=True, timeout=120)),
timeout=120)
if tmp_pdf.exists():
pdf_bytes = tmp_pdf.read_bytes()
log.info(f"Chromium PDF generated: {len(pdf_bytes)} bytes")
else:
log.error(f"Chromium PDF failed: {result.stderr.decode()[:500]}")
except Exception as e:
log.error(f"PDF generation failed: {e}", exc_info=True)
if not pdf_bytes:
raise HTTPException(500, "PDF generation failed")
# Generate DOCX
docx_bytes = None
try:
docx_bytes = _generate_compliance_docx(rid, tenancy, file_map)
if docx_bytes:
log.info(f"DOCX generated: {len(docx_bytes)} bytes")
except Exception as e:
log.warning(f"DOCX generation failed (non-fatal): {e}")
# Build ZIP: PDF + DOCX + CSVs
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr(f"CIS_Compliance_Report_{safe_name}.pdf", pdf_bytes)
if docx_bytes:
zf.writestr(f"CIS_Compliance_Report_{safe_name}.docx", docx_bytes)
for fname, fpath in file_map.items():
p = Path(fpath)
if p.exists() and fname.lower().endswith('.csv'):
zf.write(str(p), f"csv/{fname}")
buf.seek(0)
return Response(
content=buf.getvalue(),
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="CIS_Compliance_Report_{safe_name}.zip"'}
)
@router.get("/api/reports/{rid}/download")
async def report_dl(rid, fmt: str = Query("json"), token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
if not u: raise HTTPException(401, "Not authenticated")
_verify_report_access(rid, u)
with db() as c: r=c.execute("SELECT * FROM reports WHERE id=?",(rid,)).fetchone()
if not r: raise HTTPException(404)
p = r["json_path"] if fmt=="json" else r["html_path"]
if not p or not Path(p).exists(): raise HTTPException(404)
return FileResponse(p, filename=f"cis_{r['tenancy_name']}_{rid[:8]}.{fmt}")