feat: add autonomous fiscal control panel
This commit is contained in:
+60
-5
@@ -1,5 +1,6 @@
|
||||
"""Authenticated version-one Hub routes."""
|
||||
import hashlib, json, logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
import csv
|
||||
@@ -12,7 +13,9 @@ from .datasets.service import DatasetService
|
||||
from .jobs import claim_next_job, complete_job, create_job, fail_job
|
||||
from .blocked_products import list_blockers, resolve_blocker, resolve_product_blockers, upsert_blocker
|
||||
from .context_keys import validate_entry
|
||||
from .models import BlockedProduct, FiscalContextEntry, ClassificationCache, ClassificationRequest, ClassificationResult, Dataset, Job
|
||||
from .models import AgentSnapshot, BlockedProduct, FiscalContextEntry, ClassificationCache, ClassificationRequest, ClassificationResult, Dataset, Installation, Job
|
||||
from .events import record_event
|
||||
from .installation_identity import compute_agent_health, compute_version_drift, sanitize_registration
|
||||
|
||||
logger = logging.getLogger("gti_fiscal_hub")
|
||||
|
||||
@@ -158,15 +161,67 @@ def router(session_factory, bootstrap_token):
|
||||
def heartbeat(payload: dict, identity=Depends(principal)):
|
||||
session, agent = identity
|
||||
try:
|
||||
agent.last_seen_at = datetime.now(timezone.utc)
|
||||
agent.last_status = payload.get("status")
|
||||
try:
|
||||
supplied = sanitize_registration(payload)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise HTTPException(422, f"invalid registration metadata: {error}") from error
|
||||
now = datetime.now(timezone.utc)
|
||||
agent.last_seen_at = now
|
||||
agent.last_status = supplied.get("status") or payload.get("status")
|
||||
if payload.get("schema_fingerprint"):
|
||||
agent.schema_fingerprint = payload["schema_fingerprint"]
|
||||
if payload.get("agent_version"):
|
||||
agent.agent_version = payload["agent_version"]
|
||||
session.add(agent); session.commit()
|
||||
installation_key = supplied.get("installation_id") or str(uuid.uuid4())
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
if installation is not None and installation.installation_id != installation_key:
|
||||
raise HTTPException(409, "installation_id conflicts with registered Agent")
|
||||
if installation is None:
|
||||
installation = Installation(agent_id=agent.id, tenant_id=agent.tenant_id, installation_id=installation_key, display_name=supplied.get("display_name") or agent.name)
|
||||
session.add(installation); session.flush()
|
||||
fields = {"display_name": supplied.get("display_name"), "customer_code": supplied.get("customer_code"), "notes": supplied.get("notes"), "hostname": supplied.get("hostname"), "windows_version": supplied.get("windows_version"), "machine_fingerprint": supplied.get("machine_fingerprint"), "company_code": supplied.get("company_code"), "company_name": supplied.get("company_name"), "company_cnpj_masked": supplied.get("company_cnpj_masked"), "company_uf": supplied.get("company_uf"), "company_crt": supplied.get("company_crt"), "firebird_version": supplied.get("firebird_version"), "firebird_ods": supplied.get("firebird_ods"), "database_identifier": supplied.get("database_identifier"), "capabilities": supplied.get("capabilities"), "fiscal_engine_version": supplied.get("fiscal_engine_version"), "dataset_versions": supplied.get("dataset_versions"), "last_status": supplied.get("status") or payload.get("status"), "last_seen_at": now}
|
||||
for key, value in fields.items():
|
||||
if value is not None: setattr(installation, key, value)
|
||||
if supplied.get("started_at"):
|
||||
try:
|
||||
installation.started_at = datetime.fromisoformat(str(supplied["started_at"]).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
raise HTTPException(422, "started_at must be ISO-8601")
|
||||
snapshot = session.scalar(select(AgentSnapshot).where(AgentSnapshot.installation_id == installation.id))
|
||||
if snapshot is None:
|
||||
snapshot = AgentSnapshot(installation_id=installation.id)
|
||||
session.add(snapshot)
|
||||
if payload.get("coverage"):
|
||||
coverage = payload["coverage"]
|
||||
snapshot.total_products = int(coverage.get("total_products", coverage.get("total", snapshot.total_products)) or 0)
|
||||
snapshot.fiscally_complete = int(coverage.get("fiscally_complete", coverage.get("complete", snapshot.fiscally_complete)) or 0)
|
||||
snapshot.safe_blocked = int(coverage.get("safe_blocked", snapshot.safe_blocked) or 0)
|
||||
snapshot.failed_products = int(coverage.get("failed_products", snapshot.failed_products) or 0)
|
||||
snapshot.coverage_percent = float(coverage.get("coverage_percent", snapshot.coverage_percent) or 0)
|
||||
snapshot.blockers = coverage.get("blockers", snapshot.blockers) or {}
|
||||
snapshot.version_drift = compute_version_drift(agent.agent_version)
|
||||
snapshot.agent_health, snapshot.health_reason = compute_agent_health(installation, supplemental=snapshot)
|
||||
session.add(agent); session.flush()
|
||||
record_event(session, tenant_id=agent.tenant_id, installation_id=installation.id, agent_id=agent.id, severity="INFO", event_type="HEARTBEAT", message="Agent heartbeat", context={"status": agent.last_status, "agent_version": agent.agent_version})
|
||||
session.commit()
|
||||
logger.info("heartbeat agent_id=%s status=%s last_job_id=%s", agent.id, agent.last_status, payload.get("last_job_id"))
|
||||
return {"agent_id": agent.id, "last_seen_at": agent.last_seen_at.isoformat(), "enabled": agent.enabled}
|
||||
return {"agent_id": agent.id, "installation_id": installation.installation_id, "last_seen_at": agent.last_seen_at.isoformat(), "enabled": agent.enabled}
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/register")
|
||||
def register_agent(payload: dict, identity=Depends(principal)):
|
||||
return heartbeat({**payload, "status": payload.get("status", "IDLE")}, identity)
|
||||
|
||||
@routes.post("/agents/events")
|
||||
def agent_event(payload: dict, identity=Depends(principal)):
|
||||
session, agent = identity
|
||||
try:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
if installation is None:
|
||||
raise HTTPException(409, "Agent must heartbeat/register before sending events")
|
||||
event = record_event(session, tenant_id=agent.tenant_id, installation_id=installation.id, agent_id=agent.id, severity=payload.get("severity", "INFO"), event_type=payload.get("event_type", "HEARTBEAT"), message=payload.get("message", ""), context=payload.get("context") or {}, job_id=payload.get("job_id"), product_code=payload.get("product_code"), blocker_id=payload.get("blocker_id"))
|
||||
session.commit()
|
||||
return {"event_id": event.id}
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents/me")
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Immutable audit events for human control-plane actions."""
|
||||
|
||||
from .events import sanitize_event
|
||||
from .models import AuditEvent
|
||||
|
||||
|
||||
def record_audit(session, *, admin_user_id: int | None, action: str, target_type: str, target_id: str, context: dict | None = None, tenant_id: int | None = None, severity: str = "INFO"):
|
||||
_, safe_context = sanitize_event(action, context or {})
|
||||
event = AuditEvent(admin_user_id=admin_user_id, tenant_id=tenant_id, action=action, target_type=target_type, target_id=str(target_id), context=safe_context, severity=severity)
|
||||
session.add(event)
|
||||
return event
|
||||
@@ -27,6 +27,15 @@ class Settings:
|
||||
base_url: str = os.getenv("BASE_URL", "http://localhost:8000")
|
||||
# An Agent is considered ONLINE if a heartbeat arrived within this many seconds.
|
||||
agent_online_threshold_seconds: int = int(os.getenv("AGENT_ONLINE_THRESHOLD_SECONDS", "120"))
|
||||
agent_heartbeat_interval_seconds: int = int(os.getenv("AGENT_HEARTBEAT_INTERVAL_SECONDS", "40"))
|
||||
agent_offline_after_seconds: int = int(os.getenv("AGENT_OFFLINE_AFTER_SECONDS", "0"))
|
||||
agent_server_version: str = os.getenv("CURRENT_SERVER_VERSION", "1.0.0rc1")
|
||||
agent_event_retention_days: int = int(os.getenv("AGENT_EVENT_RETENTION_DAYS", "90"))
|
||||
web_session_seconds: int = int(os.getenv("WEB_SESSION_SECONDS", "28800"))
|
||||
web_cookie_secure: bool = os.getenv("WEB_COOKIE_SECURE", "0") == "1"
|
||||
web_cookie_name: str = os.getenv("WEB_COOKIE_NAME", "gti_admin_session")
|
||||
web_max_event_message: int = int(os.getenv("WEB_MAX_EVENT_MESSAGE", "2000"))
|
||||
web_max_event_context_bytes: int = int(os.getenv("WEB_MAX_EVENT_CONTEXT_BYTES", "12000"))
|
||||
ncm_dataset_url: str = os.getenv("NCM_DATASET_URL", "https://portalunico.siscomex.gov.br/classif/api/publico/nomenclatura/download/json")
|
||||
cest_dataset_url: str = os.getenv("CEST_DATASET_URL", "https://www.confaz.fazenda.gov.br/legislacao/convenios/2018/CV142_18")
|
||||
dataset_storage_path: str = os.getenv("DATASET_STORAGE_PATH", "./data/datasets")
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Human-authenticated JSON API for the control panel."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
|
||||
from .audit import record_audit
|
||||
from .control_plane import dashboard_summary, installation_view, list_blockers, list_installations, list_jobs, list_panel_events
|
||||
from .human_auth import require_csrf, require_human
|
||||
from .models import Agent, AgentSnapshot, BlockedProduct, Installation, Tenant
|
||||
from .jobs import create_job
|
||||
|
||||
|
||||
def router(session_factory):
|
||||
routes = APIRouter(prefix="/v1")
|
||||
|
||||
def identity(request: Request):
|
||||
session = session_factory()
|
||||
try:
|
||||
web_session, admin = require_human(request, session)
|
||||
return session, web_session, admin
|
||||
except Exception:
|
||||
session.close(); raise
|
||||
|
||||
@routes.get("/dashboard/summary")
|
||||
def summary(identity=Depends(identity)):
|
||||
session, _, _ = identity
|
||||
try: return dashboard_summary(session)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents")
|
||||
def agents(page: int = 1, page_size: int = 50, client: str | None = None, health: str | None = None, version: str | None = None, search: str | None = None, last_seen_before: str | None = None, identity=Depends(identity)):
|
||||
session, _, _ = identity
|
||||
try: return list_installations(session, page=page, page_size=page_size, client=client, health=health, version=version, search=search, last_seen_before=datetime.fromisoformat(last_seen_before) if last_seen_before else None)
|
||||
finally: session.close()
|
||||
|
||||
def get_installation(session, agent_id: int):
|
||||
row = session.execute(select(Installation, Agent, Tenant, AgentSnapshot).join(Agent, Agent.id == Installation.agent_id).join(Tenant, Tenant.id == Installation.tenant_id).outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id).where(Agent.id == agent_id)).first()
|
||||
if not row: raise HTTPException(404, "Agent installation not found")
|
||||
return row
|
||||
|
||||
@routes.get("/agents/{agent_id}")
|
||||
def agent_detail(agent_id: int, identity=Depends(identity)):
|
||||
session, _, _ = identity
|
||||
try:
|
||||
installation, agent, tenant, snapshot = get_installation(session, agent_id)
|
||||
return installation_view(installation, agent, tenant, snapshot)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents/{agent_id}/health")
|
||||
def agent_health(agent_id: int, identity=Depends(identity)):
|
||||
return agent_detail(agent_id, identity)
|
||||
|
||||
@routes.get("/agents/{agent_id}/coverage")
|
||||
def agent_coverage(agent_id: int, identity=Depends(identity)):
|
||||
detail = agent_detail(agent_id, identity)
|
||||
return {key: detail[key] for key in ("total_products", "fiscally_complete", "safe_blocked", "failed_products", "coverage_percent", "blockers")}
|
||||
|
||||
@routes.get("/agents/{agent_id}/blockers")
|
||||
def agent_blockers(agent_id: int, page: int = 1, page_size: int = 50, blocker_type: str | None = None, field: str | None = None, identity=Depends(identity)):
|
||||
session, _, _ = identity
|
||||
try:
|
||||
installation, agent, _, _ = get_installation(session, agent_id)
|
||||
return list_blockers(session, tenant_id=installation.tenant_id, page=page, page_size=page_size, blocker_type=blocker_type, field=field)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents/{agent_id}/jobs")
|
||||
def agent_jobs(agent_id: int, page: int = 1, page_size: int = 50, status: str | None = None, kind: str | None = None, identity=Depends(identity)):
|
||||
session, _, _ = identity
|
||||
try: return list_jobs(session, agent_id=agent_id, page=page, page_size=page_size, status=status, kind=kind)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents/{agent_id}/events")
|
||||
def agent_events(agent_id: int, page: int = 1, page_size: int = 50, severity: str | None = None, event_type: str | None = None, since: str | None = None, until: str | None = None, identity=Depends(identity)):
|
||||
session, _, _ = identity
|
||||
try:
|
||||
installation, _, _, _ = get_installation(session, agent_id)
|
||||
return {"page": max(1, page), "page_size": min(100, max(1, page_size)), "items": list_panel_events(session, installation.id, page=page, page_size=page_size, severity=severity, event_type=event_type, since=datetime.fromisoformat(since) if since else None, until=datetime.fromisoformat(until) if until else None)}
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/blockers/global")
|
||||
def global_blockers(page: int = 1, page_size: int = 50, blocker_type: str | None = None, field: str | None = None, identity=Depends(identity)):
|
||||
session, _, _ = identity
|
||||
try: return list_blockers(session, page=page, page_size=page_size, blocker_type=blocker_type, field=field)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/jobs/global")
|
||||
def global_jobs(page: int = 1, page_size: int = 50, status: str | None = None, kind: str | None = None, since: str | None = None, until: str | None = None, identity=Depends(identity)):
|
||||
session, _, _ = identity
|
||||
try: return list_jobs(session, page=page, page_size=page_size, status=status, kind=kind, since=datetime.fromisoformat(since) if since else None, until=datetime.fromisoformat(until) if until else None)
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/{agent_id}/actions/reprocess")
|
||||
def reprocess(agent_id: int, payload: dict, request: Request, identity=Depends(identity)):
|
||||
session, web_session, admin = identity
|
||||
try:
|
||||
require_csrf(web_session, payload.get("csrf_token"))
|
||||
if payload.get("confirm") is not True: raise HTTPException(400, "explicit confirmation required")
|
||||
installation, agent, _, _ = get_installation(session, agent_id)
|
||||
kind = payload.get("kind", "REPROCESS_ALL")
|
||||
if kind not in {"REPROCESS_ALL", "REPROCESS_PRODUCTS"}: raise HTTPException(400, "unsupported action")
|
||||
job_payload = payload.get("payload") or {}
|
||||
if kind == "REPROCESS_PRODUCTS" and not job_payload.get("codigos"): raise HTTPException(400, "codigos required")
|
||||
job = create_job(session, agent, kind, job_payload)
|
||||
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action=kind, target_type="installation", target_id=installation.installation_id, context={"job_id": job.id, "request_ip": request.client.host if request.client else None})
|
||||
session.commit()
|
||||
return {"job_id": job.id, "status": job.status}
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/blockers/{blocker_id}/resolve-human")
|
||||
def resolve_blocker(blocker_id: int, payload: dict, request: Request, identity=Depends(identity)):
|
||||
session, web_session, admin = identity
|
||||
try:
|
||||
require_csrf(web_session, payload.get("csrf_token"))
|
||||
if payload.get("confirm") is not True or not str(payload.get("context", "")).strip(): raise HTTPException(400, "confirmation and explicit context required")
|
||||
blocker = session.get(BlockedProduct, blocker_id)
|
||||
if not blocker: raise HTTPException(404, "blocker not found")
|
||||
blocker.resolved_at = datetime.now(timezone.utc); blocker.resolution_source = "HUMAN_PANEL"
|
||||
record_audit(session, admin_user_id=admin.id, tenant_id=blocker.tenant_id, action="RESOLVE_BLOCKER", target_type="blocker", target_id=str(blocker_id), context={"context": payload["context"]})
|
||||
session.commit()
|
||||
return {"resolved": True, "blocker_id": blocker_id}
|
||||
finally: session.close()
|
||||
|
||||
return routes
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Bounded control-panel queries backed by installation snapshots."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
|
||||
from .config import settings
|
||||
from .installation_identity import compute_agent_health
|
||||
from .models import Agent, AgentEvent, AgentSnapshot, BlockedProduct, Installation, Job, Tenant
|
||||
|
||||
|
||||
def _page(page: int, page_size: int):
|
||||
return max(1, page), min(100, max(1, page_size))
|
||||
|
||||
|
||||
def _iso(value):
|
||||
return value.isoformat() if value else None
|
||||
|
||||
|
||||
def installation_view(installation, agent, tenant, snapshot=None):
|
||||
snapshot = snapshot or AgentSnapshot(installation_id=installation.id)
|
||||
health, reason = compute_agent_health(installation, supplemental=snapshot)
|
||||
return {
|
||||
"agent_id": agent.id, "installation_id": installation.installation_id, "installation_db_id": installation.id,
|
||||
"client": tenant.name if tenant else None, "tenant_id": installation.tenant_id, "display_name": installation.display_name,
|
||||
"hostname": installation.hostname, "company_name": installation.company_name, "company_code": installation.company_code,
|
||||
"company_cnpj": installation.company_cnpj_masked, "company_uf": installation.company_uf, "company_crt": installation.company_crt,
|
||||
"agent_version": agent.agent_version, "version_drift": snapshot.version_drift, "health": health, "health_reason": reason,
|
||||
"last_seen_at": _iso(installation.last_seen_at), "started_at": _iso(installation.started_at), "last_status": installation.last_status,
|
||||
"total_products": snapshot.total_products, "fiscally_complete": snapshot.fiscally_complete, "safe_blocked": snapshot.safe_blocked,
|
||||
"failed_products": snapshot.failed_products, "coverage_percent": snapshot.coverage_percent, "blockers": snapshot.blockers or {},
|
||||
"last_job_status": snapshot.last_job_status, "last_job_at": _iso(snapshot.last_job_at), "last_successful_job_at": _iso(snapshot.last_successful_job_at),
|
||||
"firebird_version": installation.firebird_version, "firebird_ods": installation.firebird_ods, "database_identifier": installation.database_identifier,
|
||||
"capabilities": installation.capabilities or {}, "fiscal_engine_version": installation.fiscal_engine_version, "dataset_versions": installation.dataset_versions or {},
|
||||
}
|
||||
|
||||
|
||||
def list_installations(session, *, page=1, page_size=50, client=None, health=None, version=None, search=None, last_seen_before=None):
|
||||
page, page_size = _page(page, page_size)
|
||||
query = select(Installation, Agent, Tenant, AgentSnapshot).join(Agent, Agent.id == Installation.agent_id).join(Tenant, Tenant.id == Installation.tenant_id).outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id)
|
||||
if client: query = query.where(or_(Tenant.name.ilike(f"%{client}%"), Installation.customer_code.ilike(f"%{client}%")))
|
||||
if version: query = query.where(Agent.agent_version == version)
|
||||
if search: query = query.where(or_(Installation.display_name.ilike(f"%{search}%"), Installation.hostname.ilike(f"%{search}%"), Tenant.name.ilike(f"%{search}%")))
|
||||
if last_seen_before: query = query.where(or_(Installation.last_seen_at.is_(None), Installation.last_seen_at < last_seen_before))
|
||||
ordered = query.order_by(Installation.display_name.asc(), Installation.id.asc())
|
||||
rows = session.execute(ordered).all() if health else session.execute(ordered.offset((page - 1) * page_size).limit(page_size)).all()
|
||||
items = [installation_view(i, a, t, s) for i, a, t, s in rows]
|
||||
total = None
|
||||
if health:
|
||||
items = [item for item in items if item["health"] == health]
|
||||
total = len(items)
|
||||
items = items[(page - 1) * page_size:page * page_size]
|
||||
return {"page": page, "page_size": page_size, "items": items, "total": total if total is not None else len(items), "health_filter_applied": health}
|
||||
|
||||
|
||||
def dashboard_summary(session):
|
||||
query = select(Installation, Agent, Tenant, AgentSnapshot).join(Agent, Agent.id == Installation.agent_id).join(Tenant, Tenant.id == Installation.tenant_id).outerjoin(AgentSnapshot, AgentSnapshot.installation_id == Installation.id).order_by(Installation.id.asc())
|
||||
items = [installation_view(i, a, t, s) for i, a, t, s in session.execute(query).all()]
|
||||
counts = {state: sum(item["health"] == state for item in items) for state in ("ONLINE", "DEGRADED", "OFFLINE", "FAILED", "UNKNOWN")}
|
||||
totals = {key: sum(int(item[key] or 0) for item in items) for key in ("total_products", "fiscally_complete", "safe_blocked", "failed_products")}
|
||||
blocker_counts = {}
|
||||
for item in items:
|
||||
for key, value in (item["blockers"] or {}).items(): blocker_counts[key] = blocker_counts.get(key, 0) + int(value or 0)
|
||||
return {"agents_total": len(items), **{key.lower(): value for key, value in counts.items()}, **totals, "blockers": blocker_counts, "stale_agents": [item for item in items if item["health"] == "OFFLINE"], "outdated_agents": [item for item in items if item["version_drift"] == "OUTDATED"]}
|
||||
|
||||
|
||||
def list_jobs(session, *, agent_id=None, page=1, page_size=50, status=None, kind=None, since=None, until=None):
|
||||
page, page_size = _page(page, page_size)
|
||||
query = select(Job).order_by(Job.created_at.desc(), Job.id.desc())
|
||||
if agent_id: query = query.where(Job.agent_id == agent_id)
|
||||
if status: query = query.where(Job.status == status)
|
||||
if kind: query = query.where(Job.kind == kind)
|
||||
if since: query = query.where(Job.created_at >= since)
|
||||
if until: query = query.where(Job.created_at <= until)
|
||||
rows = session.scalars(query.offset((page - 1) * page_size).limit(page_size)).all()
|
||||
return {"page": page, "page_size": page_size, "items": [{"job_id": row.id, "agent_id": row.agent_id, "kind": row.kind, "status": row.status, "created_at": _iso(row.created_at), "started_at": _iso(row.started_at), "finished_at": _iso(row.finished_at), "attempts": row.attempts, "result_summary": row.result_summary, "error": row.error} for row in rows], "total": len(rows)}
|
||||
|
||||
|
||||
def list_blockers(session, *, tenant_id=None, agent_id=None, page=1, page_size=50, blocker_type=None, field=None, resolved=False):
|
||||
page, page_size = _page(page, page_size)
|
||||
query = select(BlockedProduct, Tenant).join(Tenant, Tenant.id == BlockedProduct.tenant_id).where(BlockedProduct.resolved_at.is_not(None) if resolved else BlockedProduct.resolved_at.is_(None)).order_by(BlockedProduct.last_seen_at.desc(), BlockedProduct.id.desc())
|
||||
if tenant_id: query = query.where(BlockedProduct.tenant_id == tenant_id)
|
||||
if blocker_type: query = query.where(BlockedProduct.blocker_type == blocker_type)
|
||||
if field: query = query.where(BlockedProduct.field == field)
|
||||
rows = session.execute(query.offset((page - 1) * page_size).limit(page_size)).all()
|
||||
return {"page": page, "page_size": page_size, "items": [{"id": row.id, "tenant_id": row.tenant_id, "client": tenant.name, "product_code": row.product_code, "description": row.description, "field": row.field, "blocker_type": row.blocker_type, "reason": row.reason_code, "evidence_summary": row.evidence_summary, "first_seen_at": _iso(row.first_seen_at), "last_seen_at": _iso(row.last_seen_at), "retry_count": row.retry_count, "resolved_at": _iso(row.resolved_at)} for row, tenant in rows], "total": len(rows)}
|
||||
|
||||
|
||||
def list_panel_events(session, installation_db_id, **filters):
|
||||
from .events import list_events
|
||||
rows = list_events(session, installation_db_id, **filters)
|
||||
return [{"id": row.id, "severity": row.severity, "event_type": row.event_type, "message": row.message, "context": row.structured_context, "job_id": row.job_id, "product_code": row.product_code, "occurred_at": _iso(row.occurred_at)} for row in rows]
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Structured operational events with defensive redaction and retention."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from .config import settings
|
||||
from .models import AgentEvent
|
||||
|
||||
EVENT_TYPES = {
|
||||
"AGENT_STARTED", "AGENT_STOPPED", "HEARTBEAT", "PREFLIGHT_PASS", "PREFLIGHT_FAIL", "JOB_CLAIMED", "JOB_STARTED",
|
||||
"JOB_COMPLETED", "JOB_FAILED", "PRODUCT_BLOCKED", "BLOCKER_RESOLVED", "DATASET_CHANGED", "COMPANY_CONTEXT_CHANGED",
|
||||
"SCHEMA_CHANGED", "WRITE_PLANNED", "WRITE_COMPLETED", "WRITE_FAILED", "CONNECTION_ERROR", "RETRY", "VERSION_CHANGED",
|
||||
"ADMIN_ACTION",
|
||||
}
|
||||
SEVERITIES = {"INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||
SECRET_RE = re.compile(r"(?i)(password|fb_password|token|api[_-]?key|secret|authorization|connection[_-]?string)\s*[:=]\s*[^,;\s]+")
|
||||
|
||||
|
||||
def _redact(value):
|
||||
if isinstance(value, dict):
|
||||
return {str(key): "[REDACTED]" if str(key).lower() in {"password", "fb_password", "token", "api_key", "secret", "authorization", "connection_string"} else _redact(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_redact(item) for item in value[:100]]
|
||||
if isinstance(value, str):
|
||||
return SECRET_RE.sub(lambda match: f"{match.group(1)}=[REDACTED]", value)
|
||||
return value
|
||||
|
||||
|
||||
def sanitize_event(message: str, context: dict | None = None) -> tuple[str, dict]:
|
||||
safe_message = _redact(str(message or ""))[: settings.web_max_event_message]
|
||||
safe_context = _redact(context or {})
|
||||
encoded = json.dumps(safe_context, ensure_ascii=False, default=str)
|
||||
if len(encoded.encode()) > settings.web_max_event_context_bytes:
|
||||
encoded = encoded.encode()[: settings.web_max_event_context_bytes].decode("utf-8", errors="ignore")
|
||||
safe_context = {"truncated": True, "payload": encoded}
|
||||
return safe_message, safe_context
|
||||
|
||||
|
||||
def record_event(session, *, tenant_id: int, installation_id: int, agent_id: int, severity: str, event_type: str, message: str, context: dict | None = None, job_id: int | None = None, product_code: str | None = None, blocker_id: int | None = None, is_audit: bool = False, occurred_at: datetime | None = None):
|
||||
severity = severity.upper()
|
||||
event_type = event_type.upper()
|
||||
if severity not in SEVERITIES:
|
||||
raise ValueError("invalid event severity")
|
||||
if event_type not in EVENT_TYPES:
|
||||
raise ValueError("invalid event type")
|
||||
safe_message, safe_context = sanitize_event(message, context)
|
||||
event = AgentEvent(tenant_id=tenant_id, installation_id=installation_id, agent_id=agent_id, severity=severity, event_type=event_type, message=safe_message, structured_context=safe_context, job_id=job_id, product_code=product_code, blocker_id=blocker_id, is_audit=is_audit, occurred_at=occurred_at or datetime.now(timezone.utc))
|
||||
session.add(event)
|
||||
return event
|
||||
|
||||
|
||||
def purge_expired_events(session, now: datetime | None = None, retention_days: int | None = None) -> int:
|
||||
cutoff = (now or datetime.now(timezone.utc)) - timedelta(days=retention_days if retention_days is not None else settings.agent_event_retention_days)
|
||||
result = session.execute(delete(AgentEvent).where(AgentEvent.occurred_at < cutoff, AgentEvent.is_audit.is_(False), AgentEvent.severity != "CRITICAL"))
|
||||
session.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
def list_events(session, installation_db_id: int, *, page: int = 1, page_size: int = 50, severity: str | None = None, event_type: str | None = None, job_id: int | None = None, product_code: str | None = None, since: datetime | None = None, until: datetime | None = None):
|
||||
page = max(1, page); page_size = min(100, max(1, page_size))
|
||||
query = select(AgentEvent).where(AgentEvent.installation_id == installation_db_id)
|
||||
for column, value in ((AgentEvent.severity, severity), (AgentEvent.event_type, event_type), (AgentEvent.job_id, job_id), (AgentEvent.product_code, product_code)):
|
||||
if value is not None:
|
||||
query = query.where(column == value)
|
||||
if since: query = query.where(AgentEvent.occurred_at >= since)
|
||||
if until: query = query.where(AgentEvent.occurred_at <= until)
|
||||
query = query.order_by(AgentEvent.occurred_at.desc(), AgentEvent.id.desc())
|
||||
rows = session.scalars(query.offset((page - 1) * page_size).limit(page_size)).all()
|
||||
return rows
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Separate human authentication backed by hashed passwords and sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
|
||||
from .config import settings
|
||||
from .models import AdminUser, WebSession
|
||||
|
||||
|
||||
def _digest(value: str) -> str:
|
||||
return hashlib.sha256(value.encode()).hexdigest()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = os.urandom(16)
|
||||
encoded = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 240_000)
|
||||
return "pbkdf2_sha256$240000$%s$%s" % (base64.urlsafe_b64encode(salt).decode(), base64.urlsafe_b64encode(encoded).decode())
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
algorithm, rounds, salt, digest = stored.split("$", 3)
|
||||
candidate = hashlib.pbkdf2_hmac(algorithm.removeprefix("pbkdf2_"), password.encode(), base64.urlsafe_b64decode(salt), int(rounds))
|
||||
return hmac.compare_digest(base64.urlsafe_b64encode(candidate).decode(), digest)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def create_admin(session, username: str, password: str):
|
||||
username = username.strip().lower()
|
||||
if len(username) < 3 or len(password) < 12:
|
||||
raise ValueError("username must have 3 characters and password 12 characters")
|
||||
if session.scalar(select(AdminUser).where(AdminUser.username == username)):
|
||||
raise ValueError("username already exists")
|
||||
admin = AdminUser(username=username, password_hash=hash_password(password))
|
||||
session.add(admin); session.commit(); return admin
|
||||
|
||||
|
||||
def create_session(session, admin: AdminUser, request: Request):
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
raw_csrf = secrets.token_urlsafe(32)
|
||||
now = datetime.now(timezone.utc)
|
||||
row = WebSession(admin_user_id=admin.id, token_hash=_digest(raw_token), csrf_hash=_digest(raw_csrf), expires_at=now + timedelta(seconds=settings.web_session_seconds), user_agent=request.headers.get("user-agent", "")[:512], remote_addr=request.client.host if request.client else None)
|
||||
session.add(row); admin.last_login_at = now; session.commit()
|
||||
return raw_token, raw_csrf
|
||||
|
||||
|
||||
def current_session(request: Request, session):
|
||||
token = request.cookies.get(settings.web_cookie_name)
|
||||
if not token: return None, None
|
||||
row = session.scalar(select(WebSession).where(WebSession.token_hash == _digest(token)))
|
||||
if not row or row.expires_at.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc): return None, None
|
||||
admin = session.get(AdminUser, row.admin_user_id)
|
||||
if not admin or not admin.is_active: return None, None
|
||||
return row, admin
|
||||
|
||||
|
||||
def require_human(request: Request, session):
|
||||
web_session, admin = current_session(request, session)
|
||||
if not admin: raise HTTPException(401, "human login required")
|
||||
return web_session, admin
|
||||
|
||||
|
||||
def require_csrf(web_session: WebSession, csrf_token: str | None):
|
||||
if not csrf_token or not hmac.compare_digest(web_session.csrf_hash, _digest(csrf_token)):
|
||||
raise HTTPException(403, "invalid CSRF token")
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Stable installation identity and safe operational metadata helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from .config import settings
|
||||
|
||||
SECRET_KEYS = {"password", "fb_password", "token", "agent_token", "api_key", "secret", "authorization", "connection_string"}
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def ensure_installation_id(state_path: str, *, new_installation: bool = False) -> str:
|
||||
"""Persist a UUID beside the local Agent state without using hostname/IP."""
|
||||
path = Path(state_path)
|
||||
identity_path = path.with_suffix(path.suffix + ".installation_id")
|
||||
if identity_path.exists() and not new_installation:
|
||||
value = identity_path.read_text(encoding="utf-8").strip()
|
||||
try:
|
||||
return str(uuid.UUID(value))
|
||||
except ValueError:
|
||||
pass
|
||||
value = str(uuid.uuid4())
|
||||
identity_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = identity_path.with_suffix(identity_path.suffix + ".tmp")
|
||||
temporary.write_text(value + "\n", encoding="utf-8")
|
||||
temporary.replace(identity_path)
|
||||
return value
|
||||
|
||||
|
||||
def mask_cnpj(value: str | None) -> str | None:
|
||||
digits = re.sub(r"\D", "", value or "")
|
||||
if len(digits) != 14:
|
||||
return None if not value else "***"
|
||||
return f"{digits[:2]}.***.***/{digits[8:12]}-{digits[12:]}"
|
||||
|
||||
|
||||
def sanitize_database_path(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
normalized = str(value).replace("\\", "/")
|
||||
return normalized.rsplit("/", 1)[-1][:255]
|
||||
|
||||
|
||||
def _safe_value(key: str, value):
|
||||
if key.lower() in SECRET_KEYS:
|
||||
return "[REDACTED]"
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _safe_value(str(k), v) for k, v in value.items() if str(k).lower() not in SECRET_KEYS}
|
||||
if isinstance(value, list):
|
||||
return [_safe_value(key, item) for item in value[:100]]
|
||||
return value
|
||||
|
||||
|
||||
def sanitize_registration(payload: dict) -> dict:
|
||||
allowed = {
|
||||
"installation_id", "display_name", "customer_code", "notes", "hostname", "windows_version", "machine_fingerprint",
|
||||
"company_code", "company_name", "company_cnpj", "company_uf", "company_crt", "firebird_version", "firebird_ods",
|
||||
"database_identifier", "database_path", "capabilities", "fiscal_engine_version", "dataset_versions", "started_at",
|
||||
"status", "schema_fingerprint", "agent_version", "last_job_id", "coverage", "health", "event_type", "message", "context",
|
||||
}
|
||||
result = {key: _safe_value(key, value) for key, value in payload.items() if key in allowed}
|
||||
if "company_cnpj" in result:
|
||||
result["company_cnpj_masked"] = mask_cnpj(result.pop("company_cnpj"))
|
||||
if "database_path" in result:
|
||||
result["database_identifier"] = sanitize_database_path(result.pop("database_path"))
|
||||
if result.get("installation_id"):
|
||||
result["installation_id"] = str(uuid.UUID(str(result["installation_id"])))
|
||||
return result
|
||||
|
||||
|
||||
def compute_agent_health(snapshot, now: datetime | None = None, *, supplemental=None) -> tuple[str, str]:
|
||||
now = now or utcnow()
|
||||
last_seen = getattr(snapshot, "last_seen_at", None)
|
||||
if last_seen is None:
|
||||
return "UNKNOWN", "no heartbeat received"
|
||||
if last_seen.tzinfo is None:
|
||||
last_seen = last_seen.replace(tzinfo=timezone.utc)
|
||||
offline_after = settings.agent_offline_after_seconds or settings.agent_heartbeat_interval_seconds * 3
|
||||
if (now - last_seen).total_seconds() > offline_after:
|
||||
return "OFFLINE", f"last heartbeat exceeds {offline_after}s"
|
||||
source = supplemental or snapshot
|
||||
if any(getattr(candidate, "agent_health", None) == "FAILED" or getattr(candidate, "last_status", None) == "FAILED" for candidate in (snapshot, supplemental) if candidate is not None):
|
||||
return "FAILED", "persistent technical failure"
|
||||
if getattr(source, "failed_products", 0) or getattr(source, "last_job_status", "") == "FAILED":
|
||||
return "DEGRADED", "recent technical or job failure"
|
||||
return "ONLINE", "recent heartbeat without critical technical failure"
|
||||
|
||||
|
||||
def compute_version_drift(agent_version: str | None) -> str:
|
||||
if not agent_version:
|
||||
return "UNKNOWN"
|
||||
return "CURRENT" if agent_version == settings.agent_server_version else "OUTDATED"
|
||||
|
||||
|
||||
def machine_fingerprint(parts: list[str]) -> str:
|
||||
return hashlib.sha256("|".join(parts).encode()).hexdigest()
|
||||
@@ -4,9 +4,12 @@ import logging
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from .config import settings
|
||||
from .db import SessionLocal, database_is_available
|
||||
from .api import router
|
||||
from .control_api import router as control_api_router
|
||||
from .web import router as web_router
|
||||
from .version import GTI_FISCAL_VERSION
|
||||
|
||||
logging.basicConfig(level=getattr(logging, settings.log_level.upper(), logging.INFO))
|
||||
@@ -33,6 +36,9 @@ def create_app(database_probe: Callable[[], bool] = database_is_available, sessi
|
||||
return {"version": GTI_FISCAL_VERSION, "api_compat": API_COMPAT_MAJOR_MINOR, "engine_version": ENGINE_VERSION, "decision_version": DECISION_VERSION}
|
||||
|
||||
app.include_router(router(session_factory, settings.agent_dev_token if bootstrap_token is None else bootstrap_token))
|
||||
app.include_router(control_api_router(session_factory))
|
||||
app.include_router(web_router(session_factory))
|
||||
app.mount("/static", StaticFiles(directory="central/static"), name="static")
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -235,6 +235,109 @@ class Agent(Base):
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class Installation(Base):
|
||||
__tablename__ = "installations"
|
||||
__table_args__ = (UniqueConstraint("installation_id", name="uq_installation_identity"), UniqueConstraint("agent_id", name="uq_installation_agent"))
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
agent_id: Mapped[int] = mapped_column(ForeignKey("agents.id"), index=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
installation_id: Mapped[str] = mapped_column(String(36), index=True)
|
||||
display_name: Mapped[str] = mapped_column(String(160), default="", server_default="")
|
||||
customer_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
hostname: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
windows_version: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
machine_fingerprint: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
company_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
company_name: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
company_cnpj_masked: Mapped[str | None] = mapped_column(String(24), nullable=True)
|
||||
company_uf: Mapped[str | None] = mapped_column(String(2), nullable=True)
|
||||
company_crt: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
firebird_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
firebird_ods: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
database_identifier: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
capabilities: Mapped[dict] = mapped_column(JSON, default=dict, server_default="{}")
|
||||
fiscal_engine_version: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
dataset_versions: Mapped[dict] = mapped_column(JSON, default=dict, server_default="{}")
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||
last_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class AgentSnapshot(Base):
|
||||
__tablename__ = "agent_snapshots"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
installation_id: Mapped[int] = mapped_column(ForeignKey("installations.id"), unique=True, index=True)
|
||||
total_products: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
fiscally_complete: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
safe_blocked: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
failed_products: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
coverage_percent: Mapped[float] = mapped_column(default=0.0, server_default="0")
|
||||
blockers: Mapped[dict] = mapped_column(JSON, default=dict, server_default="{}")
|
||||
last_job_status: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
last_job_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_successful_job_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
agent_health: Mapped[str] = mapped_column(String(16), default="UNKNOWN", server_default="UNKNOWN", index=True)
|
||||
health_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
version_drift: Mapped[str] = mapped_column(String(16), default="UNKNOWN", server_default="UNKNOWN", index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), index=True)
|
||||
|
||||
|
||||
class AgentEvent(Base):
|
||||
__tablename__ = "agent_events"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||
installation_id: Mapped[int] = mapped_column(ForeignKey("installations.id"), index=True)
|
||||
agent_id: Mapped[int] = mapped_column(ForeignKey("agents.id"), index=True)
|
||||
severity: Mapped[str] = mapped_column(String(16), index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(48), index=True)
|
||||
message: Mapped[str] = mapped_column(Text)
|
||||
structured_context: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
job_id: Mapped[int | None] = mapped_column(ForeignKey("jobs.id"), nullable=True, index=True)
|
||||
product_code: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
blocker_id: Mapped[int | None] = mapped_column(ForeignKey("blocked_products.id"), nullable=True, index=True)
|
||||
is_audit: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", index=True)
|
||||
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
|
||||
class AdminUser(Base):
|
||||
__tablename__ = "admin_users"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class WebSession(Base):
|
||||
__tablename__ = "web_sessions"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
admin_user_id: Mapped[int] = mapped_column(ForeignKey("admin_users.id"), index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
csrf_hash: Mapped[str] = mapped_column(String(64))
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
remote_addr: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
|
||||
class AuditEvent(Base):
|
||||
__tablename__ = "audit_events"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
admin_user_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True, index=True)
|
||||
tenant_id: Mapped[int | None] = mapped_column(ForeignKey("tenants.id"), nullable=True, index=True)
|
||||
action: Mapped[str] = mapped_column(String(64), index=True)
|
||||
target_type: Mapped[str] = mapped_column(String(64))
|
||||
target_id: Mapped[str] = mapped_column(String(128))
|
||||
context: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
severity: Mapped[str] = mapped_column(String(16), default="INFO", server_default="INFO")
|
||||
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
|
||||
class Job(Base):
|
||||
__tablename__ = "jobs"
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Server-rendered operator pages for the GTI Fiscal control panel."""
|
||||
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
|
||||
from .config import settings
|
||||
from .control_plane import dashboard_summary, list_blockers, list_installations, list_jobs, list_panel_events
|
||||
from .human_auth import create_session, current_session, require_csrf, verify_password
|
||||
from .models import AdminUser, Installation, Agent
|
||||
from .jobs import create_job
|
||||
from .audit import record_audit
|
||||
|
||||
|
||||
def router(session_factory):
|
||||
routes = APIRouter()
|
||||
templates = Jinja2Templates(directory="central/templates")
|
||||
|
||||
def auth(request, session):
|
||||
return current_session(request, session)
|
||||
|
||||
def render(request, template, context=None, status_code=200):
|
||||
return templates.TemplateResponse(template, {"request": request, **(context or {})}, status_code=status_code)
|
||||
|
||||
@routes.get("/login", response_class=HTMLResponse)
|
||||
def login_page(request: Request):
|
||||
return render(request, "login.html", {"error": None})
|
||||
|
||||
@routes.post("/login")
|
||||
async def login(request: Request):
|
||||
values = {key: items[0] for key, items in parse_qs((await request.body()).decode()).items() if items}
|
||||
with session_factory() as session:
|
||||
admin = session.scalar(select(AdminUser).where(AdminUser.username == values.get("username", "").strip().lower()))
|
||||
if not admin or not admin.is_active or not verify_password(values.get("password", ""), admin.password_hash):
|
||||
return render(request, "login.html", {"error": "Credenciais inválidas"}, 401)
|
||||
token, csrf = create_session(session, admin, request)
|
||||
response = RedirectResponse("/", status_code=303)
|
||||
response.set_cookie(settings.web_cookie_name, token, max_age=settings.web_session_seconds, httponly=True, secure=settings.web_cookie_secure, samesite="lax")
|
||||
response.set_cookie("gti_csrf", csrf, max_age=settings.web_session_seconds, httponly=False, secure=settings.web_cookie_secure, samesite="lax")
|
||||
return response
|
||||
|
||||
@routes.post("/logout")
|
||||
def logout(request: Request):
|
||||
with session_factory() as session:
|
||||
web_session, admin = current_session(request, session)
|
||||
if web_session:
|
||||
session.delete(web_session); session.commit()
|
||||
response = RedirectResponse("/login", status_code=303)
|
||||
response.delete_cookie(settings.web_cookie_name); response.delete_cookie("gti_csrf")
|
||||
return response
|
||||
|
||||
def page_auth(request):
|
||||
session = session_factory()
|
||||
web_session, admin = current_session(request, session)
|
||||
if not admin:
|
||||
session.close(); return None, None, RedirectResponse("/login", status_code=303)
|
||||
return session, (web_session, admin), None
|
||||
|
||||
async def form_values(request):
|
||||
return {key: items[0] for key, items in parse_qs((await request.body()).decode()).items() if items}
|
||||
|
||||
@routes.get("/", response_class=HTMLResponse)
|
||||
def dashboard(request: Request):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try: return render(request, "dashboard.html", {"summary": dashboard_summary(session), "auth": auth_data})
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents", response_class=HTMLResponse)
|
||||
def agents(request: Request, page: int = 1, search: str | None = None, health: str | None = None):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try: return render(request, "agents.html", {"result": list_installations(session, page=page, page_size=50, search=search, health=health), "auth": auth_data, "search": search or "", "health_filter": health or ""})
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/agents/{agent_id}", response_class=HTMLResponse)
|
||||
def agent_detail(request: Request, agent_id: int, tab: str = "overview"):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
result = list_installations(session, page=1, page_size=100)
|
||||
detail = next((item for item in result["items"] if item["agent_id"] == agent_id), None)
|
||||
if detail is None: return HTMLResponse("Agent not found", status_code=404)
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
return render(request, "agent_detail.html", {"detail": detail, "tab": tab, "blockers": list_blockers(session, tenant_id=installation.tenant_id, page=1, page_size=25), "jobs": list_jobs(session, agent_id=agent_id, page=1, page_size=25), "events": list_panel_events(session, installation.id, page=1, page_size=25), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", "")})
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/{agent_id}/reprocess")
|
||||
async def reprocess_agent(request: Request, agent_id: int):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
values = await form_values(request); web_session, admin = auth_data
|
||||
require_csrf(web_session, values.get("csrf_token"))
|
||||
if values.get("confirm") != "yes": return HTMLResponse("confirmation required", status_code=400)
|
||||
agent = session.get(Agent, agent_id)
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
|
||||
job = create_job(session, agent, "REPROCESS_ALL", {})
|
||||
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="REPROCESS_ALL", target_type="installation", target_id=installation.installation_id, context={"job_id": job.id})
|
||||
session.commit()
|
||||
return RedirectResponse(f"/agents/{agent_id}?tab=jobs", status_code=303)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/blockers", response_class=HTMLResponse)
|
||||
def blockers(request: Request, page: int = 1, blocker_type: str | None = None):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try: return render(request, "blockers.html", {"result": list_blockers(session, page=page, page_size=50, blocker_type=blocker_type), "auth": auth_data})
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/jobs", response_class=HTMLResponse)
|
||||
def jobs(request: Request, page: int = 1, status: str | None = None):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try: return render(request, "jobs.html", {"result": list_jobs(session, page=page, page_size=50, status=status), "auth": auth_data})
|
||||
finally: session.close()
|
||||
|
||||
return routes
|
||||
Reference in New Issue
Block a user