feat: add autonomous fiscal control panel

This commit is contained in:
gti
2026-09-22 15:36:12 -03:00
parent 636ed5228b
commit 301aac112e
34 changed files with 1252 additions and 9 deletions
+60 -5
View File
@@ -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")
+11
View File
@@ -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
+9
View File
@@ -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")
+125
View File
@@ -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
+94
View File
@@ -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]
+74
View File
@@ -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
+75
View File
@@ -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")
+104
View File
@@ -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()
+6
View File
@@ -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
+103
View File
@@ -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)
+122
View File
@@ -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