Files
GTI_GERENCIADOR_FISCAL/central/tests/test_rtc_security.py
T
gtiandClaude Sonnet 5 efc8b54bc7 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>
2026-09-28 11:22:02 -03:00

151 lines
9.5 KiB
Python

"""Security review of the centrally controlled cClassTrib write: who can change the mode, what the Agent can and cannot decide, tenant scoping, no secrets in audits."""
from sqlalchemy import select
from sqlalchemy.orm import Session
from central.app.models import AuditEvent, Installation, RtcCandidate, RtcWriteAudit
from central.tests.test_control_panel_pages import _panel_client, _seed_rtc
from central.tests.test_zero_touch_hub import H, OPERATIONAL, _client, age_last_sync, finish, hb
def test_new_rtc_routes_require_an_operator_session():
client, agent_id, _ = _panel_client()
client.cookies.clear()
for method, path in (("get", f"/agents/{agent_id}/rtc"), ("post", f"/agents/{agent_id}/rtc-mode"), ("post", f"/agents/{agent_id}/rtc-candidates"), ("post", f"/agents/{agent_id}/rtc-rollback")):
response = getattr(client, method)(path, follow_redirects=False)
assert response.status_code in (303, 401, 403), (path, response.status_code)
assert "login" in response.headers.get("location", "login") or response.status_code in (401, 403)
def test_default_mode_is_disabled_and_the_agent_cannot_choose_it():
client, engine = _client()
hb(client)
with Session(engine) as s:
assert s.scalars(select(Installation)).one().rtc_write_mode == "DISABLED"
job = client.get("/v1/jobs/next", headers=H).json()
assert job["authorizations"]["rtc_write_mode"] == "DISABLED"
# a forged result cannot change the mode, approve candidates or request rollbacks: none of those are read from the payload
forged = {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_write_mode": "ENABLED", "authorizations": {"rtc_write_mode": "ENABLED", "rtc_approved_codes": ["X"]},
"rtc_auto_apply": {"mode": "ENABLED", "hub_mode": "ENABLED", "preview": [{"product_code": "X", "proposed": "200003", "rule_id": "RTC-R3", "status": "APPROVED"}]}}
client.post(f"/v1/jobs/{job['job_id']}/complete", json={"result_summary": forged}, headers=H)
with Session(engine) as s:
assert s.scalars(select(Installation)).one().rtc_write_mode == "DISABLED"
assert s.scalars(select(RtcCandidate)).all() == [] # DISABLED stores no preview at all
def test_preview_from_the_agent_never_arrives_approved_and_never_approves_itself():
client, engine = _client()
hb(client)
with Session(engine) as s:
s.scalars(select(Installation)).one().rtc_write_mode = "APPROVAL_REQUIRED"
s.commit()
item = {"product_code": "X1", "description": "D", "ncm": "10063021", "current": "", "proposed": "200003", "cst": "200", "rule_id": "RTC-R3", "basis": "b", "status": "APPROVED"}
finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "fiscal_context": {"evaluated_all": True}, "rtc_auto_apply": {"preview": [item]}})
with Session(engine) as s:
assert [c.status for c in s.scalars(select(RtcCandidate)).all()] == ["PENDING"]
age_last_sync(engine, 4000); hb(client)
assert client.get("/v1/jobs/next", headers=H).json()["authorizations"]["rtc_approved_codes"] == []
def test_a_rollback_result_cannot_touch_another_installations_audit():
client, engine = _client()
hb(client)
with Session(engine) as s:
inst = s.scalars(select(Installation)).one()
foreign = RtcWriteAudit(tenant_id=inst.tenant_id, installation_id=inst.id + 999, agent_id=inst.agent_id, product_code="F", field="CCLASSTRIB", outcome="APPLIED", new_value="200003", action="APPLY", rollback_state="REQUESTED")
# the FK is not enforced by the SQLite test DB; the scoping is what is under test
s.add(foreign); s.commit(); foreign_id = foreign.id
done = {"outcome": "ROLLED_BACK", "product_code": "F", "field": "CCLASSTRIB", "old_value": "200003", "new_value": None, "action": "ROLLBACK", "rollback_of": foreign_id}
finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_auto_apply": {"rollbacks": [done]}})
with Session(engine) as s:
assert s.get(RtcWriteAudit, foreign_id).rollback_state == "REQUESTED" # untouched
def test_rollback_and_candidate_actions_are_scoped_to_the_operators_installation_and_audited():
client, agent_id, _ = _panel_client()
_seed_rtc(client, agent_id, n=3)
csrf = client.cookies.get("gti_csrf")
with client._session_factory() as s:
other = RtcWriteAudit(tenant_id=1, installation_id=987654, agent_id=agent_id, product_code="Z", field="CCLASSTRIB", outcome="APPLIED", new_value="200003", action="APPLY")
s.add(other); s.commit(); other_id = other.id
assert client.post(f"/agents/{agent_id}/rtc-rollback", data={"csrf_token": csrf, "audit_id": other_id, "confirm": "REVERTER"}).status_code == 409
client.post(f"/agents/{agent_id}/rtc-candidates?all=1", data={"csrf_token": csrf, "action": "approve"}, follow_redirects=False)
with client._session_factory() as s:
actions = [a.action for a in s.scalars(select(AuditEvent)).all()]
assert "RTC_CANDIDATES_APPROVE" in actions
def test_audit_rows_never_carry_credentials_or_tokens():
client, engine = _client()
hb(client)
applied = {"outcome": "APPLIED", "product_code": "10", "field": "CCLASSTRIB", "old_value": None, "new_value": "200003", "rule_id": "RTC-R3", "action": "APPLY", "password": "p", "token": "t",
"evidence": {"reason": "r"}}
finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_auto_apply": {"applied": [applied]}})
with Session(engine) as s:
row = s.scalars(select(RtcWriteAudit)).one()
blob = repr([getattr(row, c.name) for c in RtcWriteAudit.__table__.columns])
assert "password" not in blob.lower() and "'t'" not in blob and "Bearer" not in blob
assert not any("pass" in c.name or "token" in c.name or "secret" in c.name for c in RtcWriteAudit.__table__.columns)
def test_agent_can_reread_only_its_own_current_authorization_and_it_follows_the_operator():
client, engine = _client()
hb(client)
assert client.get("/v1/agents/me/authorizations").status_code in (401, 403) # requires the Agent credential
assert client.get("/v1/agents/me/authorizations", headers=H).json()["rtc_write_mode"] == "DISABLED"
with Session(engine) as s:
s.scalars(select(Installation)).one().rtc_write_mode = "ENABLED"
s.commit()
body = client.get("/v1/agents/me/authorizations", headers=H).json()
assert body["rtc_write_mode"] == "ENABLED" and set(body) == {"rtc_write_mode", "rtc_approved_codes", "rtc_rollbacks", "features", "cfop_ippt_write_mode"}
def _become(client, role):
"""Turn the logged-in test operator into a Gerenciador SSO collaborator with the given perfil (identity source of truth is the SSO)."""
from central.app.models import AdminUser
with client._session_factory() as s:
admin = s.scalars(select(AdminUser)).first()
admin.auth_source, admin.password_hash, admin.external_subject, admin.external_role = "GTI_GERENCIADOR", None, "sso-subject-1", role
s.commit()
def _post(client, path, **data):
return client.post(path, data={"csrf_token": client.cookies.get("gti_csrf"), **data}, follow_redirects=False)
def test_write_modes_are_restricted_to_gerenciador_admins_and_every_denial_is_audited():
client, agent_id, _ = _panel_client()
_become(client, "PADRAO")
for mode, extra in (("ENABLED", {"confirm": "PREENCHER"}), ("APPROVAL_REQUIRED", {})):
assert _post(client, f"/agents/{agent_id}/rtc-mode", mode=mode, **extra).status_code == 403
assert _post(client, f"/agents/{agent_id}/rtc-candidates", action="approve", all="1").status_code == 403
assert _post(client, f"/agents/{agent_id}/rtc-candidates", action="apply_approved").status_code == 403
assert _post(client, f"/agents/{agent_id}/rtc-rollback", audit_id="1", confirm="REVERTER").status_code == 403
with client._session_factory() as s:
assert s.scalars(select(Installation)).one().rtc_write_mode == "DISABLED"
assert len([e for e in s.scalars(select(AuditEvent)) if e.action == "RTC_DENIED"]) == 5
# read-only modes stay available to a normal collaborator, and the page tells them why the rest is disabled
assert _post(client, f"/agents/{agent_id}/rtc-mode", mode="DRY_RUN").status_code == 303
page = client.get(f"/agents/{agent_id}/rtc").text
assert "Somente leitura para o seu perfil" in page and "disabled" in page
def test_a_normal_collaborator_cannot_switch_off_a_privileged_mode_either():
client, agent_id, _ = _panel_client()
with client._session_factory() as s:
s.scalars(select(Installation)).one().rtc_write_mode = "ENABLED"
s.commit()
_become(client, "PADRAO")
assert _post(client, f"/agents/{agent_id}/rtc-mode", mode="DISABLED").status_code == 403
with client._session_factory() as s:
assert s.scalars(select(Installation)).one().rtc_write_mode == "ENABLED"
def test_gerenciador_admin_can_change_modes_and_the_operator_is_recorded():
client, agent_id, _ = _panel_client()
_become(client, "ADMIN")
assert _post(client, f"/agents/{agent_id}/rtc-mode", mode="ENABLED", confirm="PREENCHER").status_code == 303
with client._session_factory() as s:
assert s.scalars(select(Installation)).one().rtc_write_mode == "ENABLED"
event = [e for e in s.scalars(select(AuditEvent)) if e.action == "RTC_WRITE_MODE"][-1]
assert event.context["to"] == "ENABLED" and event.context["role"] == "ADMIN" and event.context["operator"]