feat: Hub-side plumbing for CFOP/IPPT write capability (flag DEFAULT OFF)

Missão 4 / Bloco E.1-E.2, on feature/cfop-ippt-write only (never main):

- Migration 0033_cfop_ippt_write_mode: Installation.cfop_ippt_write_mode
  (DISABLED|DRY_RUN|APPROVAL_REQUIRED|ENABLED, DEFAULT 'DISABLED' for every
  installation, existing and new) + append-only cfop_ippt_write_audits table.
- central/app/cfop_ippt_audit.py mirrors rtc_audit.py for this narrower
  capability: job_authorizations(installation) (delivered inside the
  authenticated job/heartbeat authorizations, never a local Agent switch) and
  record_cfop_ippt_write_audits (persists applied/aborted cells; only CFOP/
  IPPT are ever accepted, everything else silently ignored).
- central/app/web.py: POST /agents/{id}/cfop-ippt-write - ADMIN only (SSO
  Gerenciador), CSRF, typing GRAVAR required for any mode other than
  DISABLED, every change audited (CFOP_IPPT_WRITE_MODE). Small toggle UI in
  agent_detail.html next to Contexto Fiscal.
- Wired into the existing job/heartbeat authorization payload and job
  completion handling in central/app/api.py, alongside (never mixed with)
  the RTC wiring.
- 18 new tests (central/tests/test_cfop_ippt_write_plumbing.py): default
  DISABLED everywhere (job payload AND /v1/agents/me/authorizations); a
  forged completion payload can never change the mode (read fresh from the
  Installation row, never from Agent-supplied JSON); PADRAO role gets 403
  with no audit row; ADMIN + wrong/missing CSRF is refused; ADMIN without
  typing GRAVAR is refused (422); disabling never requires GRAVAR; invalid
  mode is refused; the audit recorder only accepts CFOP/IPPT, silently
  drops any other field (e.g. a stray CCLASSTRIB row).
- Two existing RTC tests updated for the additive authorizations-dict key
  (cfop_ippt_write_mode) they now also carry.

Still NOT wired into agent/scripts/run_agent_service.py::execute_job - no
Agent code calls select_candidates/apply_candidates yet, so nothing writes
to Firebird from this branch. That wiring, an Agent version bump, a new
installer and Windows Sandbox validation are the next step, deliberately
left for after review (see docs/CFOP_IPPT_WRITE_CAPABILITY_V1.md) - this
mission explicitly asked not to publish this as an active release yet.

FULL_SUITE on this branch: 1 known, expected failure -
test_manifest_records_the_real_migration_head - because this branch adds
migration 0033 that was never merged to master/deployed; RELEASE_MANIFEST.json
intentionally still describes master/production (head 0032). Every other
test passes, including all CFOP/IPPT and RTC-adjacent tests.

REAL_PRODUCT_FISCAL_WRITES=0 (no write path is reachable from any Agent
code on any branch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-28 11:22:02 -03:00
co-authored by Claude Sonnet 5
parent a2bad050f9
commit efc8b54bc7
11 changed files with 266 additions and 8 deletions
+28 -1
View File
@@ -293,7 +293,7 @@ def router(session_factory):
event["occurred_display"] = format_ui_datetime(datetime.fromisoformat(event["occurred_at"]) if event["occurred_at"] else None)
agent_blockers = list_blockers(session, tenant_id=installation.tenant_id, agent_id=agent_id, page=1, page_size=25, scope="all")
fiscal_context = build_fiscal_context_view(session, installation.tenant_id, _company_key(None, installation, None))
return render(request, "agent_detail.html", {"detail": detail, "tab": tab, "blockers": agent_blockers, "blocker_breakdown": blocker_filter_summary(session, tenant_id=installation.tenant_id, agent_id=agent_id, status="OPEN", scope="all")["blocker_breakdown"], "jobs": list_jobs(session, agent_id=agent_id, page=1, page_size=25, scope="all"), "events": events, "event_page": max(1, event_page), "has_more": has_more, "severity": severity or "", "event_type": event_type or "", "job_id": job_id or "", "product_code": product_code or "", "since": since or "", "until": until or "", "event_query_base": query_base(tab=tab, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code, since=since, until=until), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", ""), "ops": _ops_view(session, installation, agent), "features": _features_view(session, installation, enrich_code), "enrich_code": enrich_code or "", "is_admin": _is_privileged(auth_data[1]), "fiscal_context": fiscal_context})
return render(request, "agent_detail.html", {"detail": detail, "tab": tab, "blockers": agent_blockers, "blocker_breakdown": blocker_filter_summary(session, tenant_id=installation.tenant_id, agent_id=agent_id, status="OPEN", scope="all")["blocker_breakdown"], "jobs": list_jobs(session, agent_id=agent_id, page=1, page_size=25, scope="all"), "events": events, "event_page": max(1, event_page), "has_more": has_more, "severity": severity or "", "event_type": event_type or "", "job_id": job_id or "", "product_code": product_code or "", "since": since or "", "until": until or "", "event_query_base": query_base(tab=tab, severity=severity, event_type=event_type, job_id=job_id, product_code=product_code, since=since, until=until), "auth": auth_data, "csrf_token": request.cookies.get("gti_csrf", ""), "ops": _ops_view(session, installation, agent), "features": _features_view(session, installation, enrich_code), "enrich_code": enrich_code or "", "is_admin": _is_privileged(auth_data[1]), "fiscal_context": fiscal_context, "cfop_ippt_write_mode": installation.cfop_ippt_write_mode})
finally: session.close()
def _features_view(session, installation, enrich_code=None):
@@ -372,6 +372,33 @@ def router(session_factory):
return RedirectResponse(f"/agents/{agent_id}#contexto-fiscal", status_code=303)
finally: session.close()
@routes.post("/agents/{agent_id}/cfop-ippt-write")
async def set_cfop_ippt_write_mode(request: Request, agent_id: int):
"""Missão 4 / Bloco E.1: per-installation toggle for the CFOP_IPPT_WRITE_V1 capability
(ADMIN only, CSRF, audited). DISABLED by default; the Agent has no local switch."""
from .cfop_ippt_audit import WRITE_MODES as CFOP_IPPT_MODES
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"))
agent, installation = _rtc_target(session, agent_id)
if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
if not _is_privileged(admin):
return _deny(session, admin, installation.tenant_id, "cfop_ippt_write:set", installation.installation_id)
mode = (values.get("mode") or "").strip().upper()
if mode not in CFOP_IPPT_MODES:
return HTMLResponse(f"modo inválido (use um de {CFOP_IPPT_MODES})", status_code=422)
if mode != "DISABLED" and (values.get("confirm") or "").strip().upper() != "GRAVAR":
return HTMLResponse("Digite GRAVAR para confirmar um modo que pode escrever no ERP", status_code=422)
before = installation.cfop_ippt_write_mode
installation.cfop_ippt_write_mode = mode
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="CFOP_IPPT_WRITE_MODE", target_type="installation", target_id=installation.installation_id,
context={"from": before, "to": mode, "operator": admin.display_name or admin.username, "role": admin.external_role or admin.auth_source}, severity="WARNING")
session.commit()
return RedirectResponse(f"/agents/{agent_id}#contexto-fiscal", status_code=303)
finally: session.close()
@routes.post("/agents/{agent_id}/enrichment/reevaluate")
async def reevaluate_enrichment(request: Request, agent_id: int):
"""Explicit, idempotent 'evaluate again' for one product (code=...) or every tracked product that is not enriched yet. KB/cache first; providers only when really needed."""