feat: RBAC for RTC write modes (Gerenciador ADMIN only, denials audited), central backpressure for heavy jobs (HEAVY_JOB_CONCURRENCY_LIMIT), concurrency simulation evidence

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-25 09:46:21 -03:00
co-authored by Claude Sonnet 5
parent 7063c8c161
commit af5d9b4436
10 changed files with 274 additions and 13 deletions
+2
View File
@@ -27,6 +27,8 @@ 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"))
# Backpressure: how many FULL jobs (INITIAL_SYNC / REPROCESS_ALL: whole-database classification) may run at once across ALL Agents. Others wait as PENDING. <=0 disables the limit.
heavy_job_concurrency_limit: int = int(os.getenv("HEAVY_JOB_CONCURRENCY_LIMIT", "3"))
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")
+19 -5
View File
@@ -6,8 +6,9 @@ REPROCESS_ALL, REPROCESS_PRODUCTS); automation never authorizes Firebird writes.
"""
from datetime import datetime, timedelta, timezone
from sqlalchemy import select, update
from sqlalchemy import select, update, func, text
from .config import settings
from .models import Installation, Job
FULL_KINDS = ("INITIAL_SYNC", "REPROCESS_ALL") # read every product, reclassify all
@@ -65,13 +66,26 @@ def claim_next_job(session, agent):
recover_stale_running(session, agent)
if session.scalar(select(Job.id).where(Job.agent_id == agent.id, Job.status == "RUNNING").limit(1)) is not None:
return None
candidate = session.scalar(
select(Job.id)
pending = session.execute(
select(Job.id, Job.kind)
.where(Job.agent_id == agent.id, Job.status == "PENDING")
.order_by(Job.created_at.asc(), Job.id.asc())
.limit(1)
)
.limit(10)
).all()
if not pending:
return None
limit = settings.heavy_job_concurrency_limit
heavy_slot_free = True
if limit > 0 and any(kind in FULL_KINDS for _, kind in pending):
if session.bind is not None and session.bind.dialect.name == "postgresql":
session.execute(text("SELECT pg_advisory_xact_lock(7305001)")) # serialize slot accounting across concurrent pollers; released at commit
running_heavy = session.scalar(select(func.count()).select_from(Job).where(Job.status == "RUNNING", Job.kind.in_(FULL_KINDS),
Job.started_at > datetime.now(timezone.utc) - STALE_RUNNING_AFTER)) or 0
heavy_slot_free = running_heavy < limit
# oldest job this Agent may run now: a light job is never held behind a heavy one that is waiting for a slot
candidate = next((job_id for job_id, kind in pending if kind not in FULL_KINDS or heavy_slot_free), None)
if candidate is None:
session.rollback() # releases the advisory lock; the heavy job stays PENDING (queue) and the Agent keeps polling
return None
now = datetime.now(timezone.utc)
result = session.execute(
+23 -5
View File
@@ -319,6 +319,16 @@ def router(session_factory):
return RedirectResponse(f"/agents/{agent_id}?tab=jobs", status_code=303)
finally: session.close()
def _is_privileged(admin) -> bool:
"""Who may change what the Agent is allowed to WRITE. The identity comes from the Gerenciador SSO (`perfil`): only ADMIN. LOCAL break-glass accounts are administrators by construction.
A normal (PADRAO) collaborator can look at the control page and use DISABLED/DRY_RUN (read-only) but cannot approve, enable, apply or roll back."""
return admin.auth_source == "LOCAL" or (admin.external_role or "").upper() == "ADMIN"
def _deny(session, admin, tenant_id, action, target):
record_audit(session, admin_user_id=admin.id, tenant_id=tenant_id, action="RTC_DENIED", target_type="installation", target_id=target, context={"attempted": action, "role": admin.external_role or admin.auth_source}, severity="WARNING")
session.commit()
return HTMLResponse("Ação restrita a administradores do GTI Gerenciador (perfil ADMIN).", status_code=403)
def _rtc_target(session, agent_id):
agent = session.get(Agent, agent_id)
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) if agent else None
@@ -344,7 +354,7 @@ def router(session_factory):
audits = list(session.scalars(select(RtcWriteAudit).where(RtcWriteAudit.installation_id == installation.id, RtcWriteAudit.outcome.in_(("APPLIED", "ROLLED_BACK"))).order_by(RtcWriteAudit.id.desc()).limit(25)))
ops = _ops_view(session, installation, agent)
return render(request, "rtc_control.html", {"agent_id": agent_id, "installation": installation, "mode": installation.rtc_write_mode, "modes": RTC_MODE_LABELS, "explanation": RTC_MODE_EXPLANATION, "ops": ops,
"rows": rows, "counts": counts, "status": status, "page": page, "pages": max(1, -(-total // size)), "total": total, "audits": audits,
"privileged": _is_privileged(auth_data[1]), "rows": rows, "counts": counts, "status": status, "page": page, "pages": max(1, -(-total // size)), "total": total, "audits": audits,
"L": {"status": candidate_status_label, "outcome": audit_outcome_label, "reason": abort_reason_label, "rule": rtc_rule_label, "catalog": catalog_state_label}})
finally: session.close()
@@ -361,13 +371,17 @@ def router(session_factory):
mode = (values.get("mode") or "").strip().upper()
if mode not in RTC_MODE_LABELS:
return HTMLResponse("modo inválido", status_code=422)
if mode not in ("DISABLED", "DRY_RUN") and not _is_privileged(admin):
return _deny(session, admin, installation.tenant_id, f"rtc-mode:{mode}", installation.installation_id)
if installation.rtc_write_mode not in ("DISABLED", "DRY_RUN") and not _is_privileged(admin):
return _deny(session, admin, installation.tenant_id, f"rtc-mode:leave:{installation.rtc_write_mode}", installation.installation_id)
if mode == "ENABLED" and (values.get("confirm") or "").strip().upper() != "PREENCHER":
return HTMLResponse("Digite PREENCHER para confirmar o modo automático", status_code=422)
previous = installation.rtc_write_mode
installation.rtc_write_mode = mode
installation.rtc_auto_apply_authorized = mode == "ENABLED"
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_WRITE_MODE", target_type="installation", target_id=installation.installation_id,
context={"from": previous, "to": mode}, severity="WARNING")
context={"from": previous, "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}/rtc", status_code=303)
finally: session.close()
@@ -382,6 +396,8 @@ def router(session_factory):
agent, installation = _rtc_target(session, agent_id)
if not agent or not installation: return HTMLResponse("Agent not found", status_code=404)
action = values.get("action")
if not _is_privileged(admin):
return _deny(session, admin, installation.tenant_id, f"rtc-candidates:{action}", installation.installation_id)
scope = RtcCandidate.installation_id == installation.id
ids = [int(x) for x in parse_qs((await request.body()).decode()).get("ids", []) if x.isdigit()]
select_all = values.get("all") == "1" or request.query_params.get("all") == "1"
@@ -390,14 +406,14 @@ def router(session_factory):
new, old = ("APPROVED", ("PENDING",)) if action == "approve" else ("REJECTED", ("PENDING", "APPROVED"))
cond = scope & RtcCandidate.status.in_(old) & (RtcCandidate.id.in_(ids) if not select_all else RtcCandidate.status.in_(old))
changed = session.execute(update(RtcCandidate).where(cond).values(status=new)).rowcount
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action=f"RTC_CANDIDATES_{action.upper()}", target_type="installation", target_id=installation.installation_id, context={"changed": changed, "all": select_all}, severity="WARNING")
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action=f"RTC_CANDIDATES_{action.upper()}", target_type="installation", target_id=installation.installation_id, context={"changed": changed, "all": select_all, "operator": admin.display_name or admin.username}, severity="WARNING")
elif action == "apply_approved":
if installation.rtc_write_mode != "APPROVAL_REQUIRED":
return HTMLResponse("Disponível apenas no modo Aprovação manual", status_code=409)
codes = list(session.scalars(select(RtcCandidate.product_code).where(scope, RtcCandidate.status == "APPROVED").order_by(RtcCandidate.product_code).limit(500)))
if codes:
job = create_job(session, agent, "REPROCESS_PRODUCTS", {"codigos": codes, "reason": "RTC_APPLY_APPROVED", "requested_by": admin.id})
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_APPLY_APPROVED", target_type="installation", target_id=installation.installation_id, context={"job_id": job.id, "products": len(codes)}, severity="WARNING")
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_APPLY_APPROVED", target_type="installation", target_id=installation.installation_id, context={"job_id": job.id, "products": len(codes), "operator": admin.display_name or admin.username}, severity="WARNING")
else:
return HTMLResponse("ação inválida", status_code=422)
session.commit()
@@ -414,6 +430,8 @@ def router(session_factory):
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, "rtc-rollback", installation.installation_id)
if (values.get("confirm") or "").strip().upper() != "REVERTER":
return HTMLResponse("Digite REVERTER para confirmar", status_code=422)
audit = session.get(RtcWriteAudit, int(values.get("audit_id") or 0))
@@ -422,7 +440,7 @@ def router(session_factory):
audit.rollback_state = "REQUESTED"
job = create_job(session, agent, "REPROCESS_PRODUCTS", {"codigos": [audit.product_code], "reason": "RTC_ROLLBACK", "requested_by": admin.id})
record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_ROLLBACK_REQUESTED", target_type="installation", target_id=installation.installation_id,
context={"audit_id": audit.id, "product_code": audit.product_code, "job_id": job.id}, severity="WARNING")
context={"audit_id": audit.id, "product_code": audit.product_code, "job_id": job.id, "operator": admin.display_name or admin.username}, severity="WARNING")
session.commit()
return RedirectResponse(f"/agents/{agent_id}/rtc", status_code=303)
finally: session.close()