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>
151 lines
9.5 KiB
Python
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"]
|