From 6bdac245b908f2e27e1b6458a0148862f9faebc5 Mon Sep 17 00:00:00 2001 From: gti Date: Tue, 22 Sep 2026 16:23:51 -0300 Subject: [PATCH] fix: validate control panel runtime paths --- central/app/api.py | 11 +++++++++++ central/app/control_api.py | 8 +++++--- central/app/installation_identity.py | 6 +++--- central/app/web.py | 12 ++++++++---- central/scripts/generate_control_panel_fixture.py | 9 +++++---- central/templates/agent_detail.html | 2 +- central/templates/agents.html | 2 +- central/templates/base.html | 2 +- central/templates/blockers.html | 2 +- central/templates/jobs.html | 2 +- central/tests/test_control_panel_fixture.py | 3 ++- central/tests/test_control_plane.py | 3 +++ 12 files changed, 42 insertions(+), 20 deletions(-) diff --git a/central/app/api.py b/central/app/api.py index bacb32e..c1283b3 100644 --- a/central/app/api.py +++ b/central/app/api.py @@ -16,6 +16,8 @@ from .context_keys import validate_entry 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 +from .control_plane import list_jobs +from .human_auth import require_human logger = logging.getLogger("gti_fiscal_hub") @@ -331,6 +333,15 @@ def router(session_factory, bootstrap_token): return _job_view(job) finally: session.close() + @routes.get("/jobs/global") + def global_jobs(request: Request, page: int = 1, page_size: int = 50, status: str | None = None, kind: str | None = None): + session = session_factory() + try: + require_human(request, session) + return list_jobs(session, page=page, page_size=page_size, status=status, kind=kind) + finally: + session.close() + @routes.get("/jobs/{job_id}") def get_job(job_id: int, identity=Depends(principal)): session, agent = identity diff --git a/central/app/control_api.py b/central/app/control_api.py index 1819905..0834c13 100644 --- a/central/app/control_api.py +++ b/central/app/control_api.py @@ -8,7 +8,7 @@ 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 .models import Agent, AgentSnapshot, BlockedProduct, Installation, Job, Tenant from .jobs import create_job @@ -102,7 +102,8 @@ def router(session_factory): 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) + existing = next((candidate for candidate in session.scalars(select(Job).where(Job.agent_id == agent.id, Job.kind == kind, Job.status == "PENDING")).all() if candidate.payload == job_payload), None) + job = existing or 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} @@ -116,7 +117,8 @@ def router(session_factory): 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" + if blocker.resolved_at is None: + 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} diff --git a/central/app/installation_identity.py b/central/app/installation_identity.py index 450291b..c077e78 100644 --- a/central/app/installation_identity.py +++ b/central/app/installation_identity.py @@ -83,12 +83,12 @@ def compute_agent_health(snapshot, now: datetime | None = None, *, supplemental= 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" + 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" 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" diff --git a/central/app/web.py b/central/app/web.py index e92ae15..687b0a1 100644 --- a/central/app/web.py +++ b/central/app/web.py @@ -10,7 +10,7 @@ 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 .models import AdminUser, Installation, Agent, Job from .jobs import create_job from .audit import record_audit @@ -23,7 +23,8 @@ def router(session_factory): 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) + values = {"request": request, "csrf_token": request.cookies.get("gti_csrf", ""), **(context or {})} + return templates.TemplateResponse(request=request, name=template, context=values, status_code=status_code) @routes.get("/login", response_class=HTMLResponse) def login_page(request: Request): @@ -43,10 +44,12 @@ def router(session_factory): return response @routes.post("/logout") - def logout(request: Request): + async def logout(request: Request): with session_factory() as session: web_session, admin = current_session(request, session) if web_session: + values = await form_values(request) + require_csrf(web_session, values.get("csrf_token")) session.delete(web_session); session.commit() response = RedirectResponse("/login", status_code=303) response.delete_cookie(settings.web_cookie_name); response.delete_cookie("gti_csrf") @@ -99,7 +102,8 @@ def router(session_factory): 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", {}) + existing = next((candidate for candidate in session.scalars(select(Job).where(Job.agent_id == agent.id, Job.kind == "REPROCESS_ALL", Job.status == "PENDING")).all() if candidate.payload == {}), None) + job = existing or 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) diff --git a/central/scripts/generate_control_panel_fixture.py b/central/scripts/generate_control_panel_fixture.py index b4a7bfe..1c238d9 100644 --- a/central/scripts/generate_control_panel_fixture.py +++ b/central/scripts/generate_control_panel_fixture.py @@ -13,16 +13,17 @@ from sqlalchemy.orm import sessionmaker def generate_fixture(session, agent_count=100, seed=7): now = datetime.now(timezone.utc) - states = ["ONLINE"] * 55 + ["DEGRADED"] * 20 + ["OFFLINE"] * 15 + ["FAILED"] * 10 + states = ["ONLINE"] * 50 + ["DEGRADED"] * 20 + ["OFFLINE"] * 15 + ["FAILED"] * 10 + ["UNKNOWN"] * 5 created = {"agents": 0, "events": 0, "blockers": 0, "jobs": 0} for index in range(agent_count): tenant = Tenant(name=f"Fixture Client {index:03d}"); session.add(tenant); session.flush() - agent = Agent(tenant_id=tenant.id, name=f"fixture-agent-{index:03d}", token_hash=(f"fixture-{index:03d}".ljust(64, "0"))[:64], agent_version="1.0.0rc1"); session.add(agent); session.flush() + agent_version = None if index % 17 == 0 else ("0.9.0" if index % 5 == 0 else "1.0.0rc1") + agent = Agent(tenant_id=tenant.id, name=f"fixture-agent-{index:03d}", token_hash=(f"fixture-{index:03d}".ljust(64, "0"))[:64], agent_version=agent_version); session.add(agent); session.flush() state = states[index % len(states)] - last_seen = now - timedelta(seconds=10 if state in {"ONLINE", "DEGRADED"} else 10_000) + last_seen = None if state == "UNKNOWN" else now - timedelta(seconds=10 if state in {"ONLINE", "DEGRADED"} else 10_000) installation = Installation(agent_id=agent.id, tenant_id=tenant.id, installation_id=str(uuid.uuid5(uuid.NAMESPACE_DNS, f"fixture-{index}")), display_name=f"{tenant.name} - Servidor Principal", customer_code=f"F{index:03d}", hostname=f"srv-{index:03d}", company_name=f"Empresa {index:03d}", company_uf="BA", company_crt="1", last_seen_at=last_seen, last_status="IDLE") session.add(installation); session.flush() - snapshot = AgentSnapshot(installation_id=installation.id, total_products=5000 + index, fiscally_complete=4900 + index, safe_blocked=index % 9, failed_products=1 if state == "FAILED" else 0, coverage_percent=98.0, blockers={"CFOP": index % 7, "ST": index % 5}, agent_health=state, last_job_status="FAILED" if state in {"DEGRADED", "FAILED"} else "COMPLETED") + snapshot = AgentSnapshot(installation_id=installation.id, total_products=5000 + index, fiscally_complete=4900 + index, safe_blocked=index % 9, failed_products=1 if state == "FAILED" else 0, coverage_percent=98.0, blockers={"CFOP": index % 7, "ST": index % 5}, agent_health=state, version_drift="UNKNOWN" if agent_version is None else ("OUTDATED" if agent_version != "1.0.0rc1" else "CURRENT"), last_job_status="FAILED" if state in {"DEGRADED", "FAILED"} else "COMPLETED") session.add(snapshot); created["agents"] += 1 for event_index in range(5): session.add(AgentEvent(tenant_id=tenant.id, installation_id=installation.id, agent_id=agent.id, severity="ERROR" if state == "FAILED" and event_index == 0 else "INFO", event_type="JOB_FAILED" if state in {"DEGRADED", "FAILED"} and event_index == 0 else "HEARTBEAT", message="Fixture operational event", structured_context={"fixture": True}, occurred_at=now - timedelta(minutes=event_index))) diff --git a/central/templates/agent_detail.html b/central/templates/agent_detail.html index 38e6b5e..1dcee3a 100644 --- a/central/templates/agent_detail.html +++ b/central/templates/agent_detail.html @@ -5,5 +5,5 @@
Produtos{{ detail.total_products }}
Completos{{ detail.fiscally_complete }}
Safe blocked{{ detail.safe_blocked }}
Coverage{{ "%.1f"|format(detail.coverage_percent) }}%

Contexto da empresa

Empresa
{{ detail.company_name or "-" }}
UF / CRT
{{ detail.company_uf or "-" }} / {{ detail.company_crt or "-" }}
Firebird
{{ detail.firebird_version or "-" }} · ODS {{ detail.firebird_ods or "-" }}
Último heartbeat
{{ detail.last_seen_at or "-" }}
Último job
{{ detail.last_job_status or "-" }} · {{ detail.last_job_at or "-" }}

Eventos recentes

{% for event in events[:8] %}
{{ event.occurred_at }}{{ event.event_type }}{{ event.message }}{{ event.severity }}
{% else %}

Nenhum evento.

{% endfor %}
-

Blockers abertos

Ver todos
{% for item in blockers.items[:8] %}
{{ item.product_code }} · {{ item.field }}{{ item.reason }}{{ item.blocker_type }}
{% else %}

Nenhum blocker aberto.

{% endfor %}
+

Blockers abertos

Ver todos
{% for item in blockers["items"][:8] %}
{{ item.product_code }} · {{ item.field }}{{ item.reason }}{{ item.blocker_type }}
{% else %}

Nenhum blocker aberto.

{% endfor %}
{% endblock %} diff --git a/central/templates/agents.html b/central/templates/agents.html index 70905ed..9c3c0d2 100644 --- a/central/templates/agents.html +++ b/central/templates/agents.html @@ -1 +1 @@ -{% extends "base.html" %}{% block title %}Agents | GTI Fiscal{% endblock %}{% block content %}

INVENTÁRIO OPERACIONAL

Agents / Servidores

{% for item in result.items %}{% else %}{% endfor %}
Cliente / instalaçãoServidorEmpresaHealthVersãoProdutosCompleteBlockedLast seen
{{ item.client }}{{ item.display_name }}{{ item.hostname or "-" }}{{ item.company_name or item.company_code or "-" }}{{ item.company_uf or "" }} {{ item.company_crt or "" }}{{ item.health }}{{ item.agent_version or "UNKNOWN" }}{{ item.version_drift }}{{ item.total_products }}{{ item.fiscally_complete }}{{ item.safe_blocked }}{{ item.last_seen_at or "Never" }}
Nenhuma instalação encontrada.
{% endblock %} +{% extends "base.html" %}{% block title %}Agents | GTI Fiscal{% endblock %}{% block content %}

INVENTÁRIO OPERACIONAL

Agents / Servidores

{% for item in result["items"] %}{% else %}{% endfor %}
Cliente / instalaçãoServidorEmpresaHealthVersãoProdutosCompleteBlockedLast seen
{{ item.client }}{{ item.display_name }}{{ item.hostname or "-" }}{{ item.company_name or item.company_code or "-" }}{{ item.company_uf or "" }} {{ item.company_crt or "" }}{{ item.health }}{{ item.agent_version or "UNKNOWN" }}{{ item.version_drift }}{{ item.total_products }}{{ item.fiscally_complete }}{{ item.safe_blocked }}{{ item.last_seen_at or "Never" }}
Nenhuma instalação encontrada.
{% endblock %} diff --git a/central/templates/base.html b/central/templates/base.html index 7ee7ad2..2385cf1 100644 --- a/central/templates/base.html +++ b/central/templates/base.html @@ -2,7 +2,7 @@ {% block title %}GTI Fiscal Control Panel{% endblock %} -
GTI FISCAL{% if auth %}
{% endif %}
+
GTI FISCAL{% if auth %}
{% endif %}
{% block content %}{% endblock %}
diff --git a/central/templates/blockers.html b/central/templates/blockers.html index ae844c2..c0eb5de 100644 --- a/central/templates/blockers.html +++ b/central/templates/blockers.html @@ -1 +1 @@ -{% extends "base.html" %}{% block title %}Blockers | GTI Fiscal{% endblock %}{% block content %}

FILA FISCAL

Blockers globais

{% for item in result.items %}{% else %}{% endfor %}
ClienteProdutoCampoTipoMotivoÚltima ocorrênciaAção
{{ item.client or item.tenant_id }}{{ item.product_code }}{{ item.description or "" }}{{ item.field }}{{ item.blocker_type }}{{ item.reason }}{{ item.last_seen_at }}{{ item.retry_count }} retries
Nenhum blocker aberto.
{% endblock %} +{% extends "base.html" %}{% block title %}Blockers | GTI Fiscal{% endblock %}{% block content %}

FILA FISCAL

Blockers globais

{% for item in result["items"] %}{% else %}{% endfor %}
ClienteProdutoCampoTipoMotivoÚltima ocorrênciaAção
{{ item.client or item.tenant_id }}{{ item.product_code }}{{ item.description or "" }}{{ item.field }}{{ item.blocker_type }}{{ item.reason }}{{ item.last_seen_at }}{{ item.retry_count }} retries
Nenhum blocker aberto.
{% endblock %} diff --git a/central/templates/jobs.html b/central/templates/jobs.html index 447246b..f2b9ffb 100644 --- a/central/templates/jobs.html +++ b/central/templates/jobs.html @@ -1 +1 @@ -{% extends "base.html" %}{% block title %}Jobs | GTI Fiscal{% endblock %}{% block content %}

EXECUÇÕES

Jobs globais

{% for item in result.items %}{% else %}{% endfor %}
JobAgentKindStatusCreatedFinishedError
#{{ item.job_id }}{{ item.agent_id }}{{ item.kind }}{{ item.status }}{{ item.created_at }}{{ item.finished_at or "-" }}{{ item.error or "-" }}
Nenhum job encontrado.
{% endblock %} +{% extends "base.html" %}{% block title %}Jobs | GTI Fiscal{% endblock %}{% block content %}

EXECUÇÕES

Jobs globais

{% for item in result["items"] %}{% else %}{% endfor %}
JobAgentKindStatusCreatedFinishedError
#{{ item.job_id }}{{ item.agent_id }}{{ item.kind }}{{ item.status }}{{ item.created_at }}{{ item.finished_at or "-" }}{{ item.error or "-" }}
Nenhum job encontrado.
{% endblock %} diff --git a/central/tests/test_control_panel_fixture.py b/central/tests/test_control_panel_fixture.py index ff812bb..44f3b65 100644 --- a/central/tests/test_control_panel_fixture.py +++ b/central/tests/test_control_panel_fixture.py @@ -11,4 +11,5 @@ def test_fixture_creates_one_hundred_varied_installations_and_events(): assert session.query(Installation).count() == 100 assert session.query(AgentEvent).count() == 500 assert session.query(Job).count() == 100 - assert {row.agent_health for row in session.query(AgentSnapshot).all()} == {"ONLINE", "DEGRADED", "OFFLINE", "FAILED"} + assert {row.agent_health for row in session.query(AgentSnapshot).all()} == {"ONLINE", "DEGRADED", "OFFLINE", "FAILED", "UNKNOWN"} + assert {row.version_drift for row in session.query(AgentSnapshot).all()} == {"CURRENT", "OUTDATED", "UNKNOWN"} diff --git a/central/tests/test_control_plane.py b/central/tests/test_control_plane.py index 61a2146..7a02bb8 100644 --- a/central/tests/test_control_plane.py +++ b/central/tests/test_control_plane.py @@ -64,6 +64,7 @@ def test_human_login_and_dashboard_are_separate_from_agent_bearer(): response = client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False) assert response.status_code == 303 assert client.get("/v1/dashboard/summary").status_code == 200 + assert client.get("/v1/jobs/global").status_code == 200 assert TestClient(create_app(database_probe=lambda: True, session_factory=session_factory, bootstrap_token=None)).get("/v1/dashboard/summary", headers={"Authorization": "Bearer x"}).status_code == 401 assert client.get("/v1/agents").json()["items"][0]["installation_id"] csrf = client.cookies.get("gti_csrf") @@ -71,3 +72,5 @@ def test_human_login_and_dashboard_are_separate_from_agent_bearer(): assert action.status_code == 200 and action.json()["status"] == "PENDING" denied = client.post("/v1/agents/1/actions/reprocess", json={"confirm": True, "csrf_token": "wrong", "kind": "REPROCESS_ALL"}) assert denied.status_code == 403 + assert client.post("/logout").status_code == 403 + assert client.post("/logout", data={"csrf_token": csrf}, follow_redirects=False).status_code == 303