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:
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% block title %}cClassTrib | GTI Fiscal{% endblock %}
|
||||
{% block content %}
|
||||
<div class="detail-head"><div><p class="eyebrow"><a href="/agents/{{ agent_id }}">← Instalação</a> / REFORMA TRIBUTÁRIA</p><h1>Preenchimento de cClassTrib</h1>
|
||||
<p class="muted">{{ explanation }}</p></div></div>
|
||||
<p class="muted">{{ explanation }}</p>{% if not privileged %}<p class="muted"><strong>Somente leitura para o seu perfil:</strong> alterar para aprovação/automático, aprovar, aplicar e reverter exige administrador do GTI Gerenciador.</p>{% endif %}</div></div>
|
||||
|
||||
{% set rtc = ops.rtc %}
|
||||
{% if rtc and rtc.products %}
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
<section class="panel"><div class="panel-title"><div><h2>Modo de preenchimento</h2><p class="muted">Decidido centralmente para esta instalação. Padrão: Desativada. Nenhuma configuração local no servidor do cliente.</p></div></div>
|
||||
<form method="post" action="/agents/{{ agent_id }}/rtc-mode"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{% for key, label in modes.items() %}<label style="display:block"><input type="radio" name="mode" value="{{ key }}" {% if key == mode %}checked{% endif %}> {{ label }}
|
||||
{% for key, label in modes.items() %}<label style="display:block"><input type="radio" name="mode" value="{{ key }}" {% if key == mode %}checked{% endif %} {% if not privileged and key not in ("DISABLED", "DRY_RUN") %}disabled{% endif %}> {{ label }}
|
||||
{% if key == "DISABLED" %}— apenas informa{% elif key == "DRY_RUN" %}— mostra os candidatos, não escreve{% elif key == "APPROVAL_REQUIRED" %}— escreve só o que for aprovado abaixo{% else %}— preenche os candidatos determinísticos{% endif %}</label>{% endfor %}
|
||||
<p><input type="text" name="confirm" placeholder="digite PREENCHER (modo automático)" aria-label="Confirmar modo automático" size="30"> <button type="submit" class="primary-action">Salvar modo</button></p></form></section>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
{% for c in rows %}<tr><td><input type="checkbox" name="ids" value="{{ c.id }}" aria-label="Selecionar {{ c.product_code }}"></td><td>{{ c.product_code }}</td><td>{{ c.description or "" }}</td><td>{{ c.ncm }}</td><td>{{ c.current_value or "vazio" }}</td><td>{{ c.proposed }}</td><td>{{ c.cst_ibscbs }}</td><td>{{ L.rule(c.rule_id) }}</td><td>{{ c.basis }}</td><td>{{ L.catalog("MATCH_SAFE") }}</td><td>{{ L.status(c.status) }}</td></tr>
|
||||
{% else %}<tr><td colspan="10" class="muted">Nenhum candidato neste filtro.</td></tr>{% endfor %}</tbody></table>
|
||||
<p>Página {{ page }} de {{ pages }}{% if page > 1 %} · <a href="?status={{ status }}&page={{ page - 1 }}">anterior</a>{% endif %}{% if page < pages %} · <a href="?status={{ status }}&page={{ page + 1 }}">próxima</a>{% endif %}</p>
|
||||
<p><button type="submit" name="action" value="approve" class="secondary">Aprovar selecionados</button> <button type="submit" name="action" value="reject" class="caution">Rejeitar selecionados</button>
|
||||
<p><button type="submit" name="action" value="approve" class="secondary" {% if not privileged %}disabled{% endif %}>Aprovar selecionados</button> <button type="submit" name="action" value="reject" class="caution">Rejeitar selecionados</button>
|
||||
<button type="submit" name="action" value="approve" class="secondary" formaction="/agents/{{ agent_id }}/rtc-candidates?all=1">Aprovar todos os pendentes</button>
|
||||
{% if mode == "APPROVAL_REQUIRED" %}<button type="submit" name="action" value="apply_approved" class="primary-action">Aplicar aprovados agora</button>{% endif %}</p></form></section>
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Backpressure: at most HEAVY_JOB_CONCURRENCY_LIMIT whole-database jobs run at once across all Agents; the rest queue as PENDING."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app import jobs
|
||||
from central.app.models import Agent, Job, Tenant
|
||||
from central.tests.test_control_plane import setup_db
|
||||
|
||||
|
||||
def _agents(session, n):
|
||||
tenant = Tenant(name="Backpressure")
|
||||
session.add(tenant)
|
||||
session.flush()
|
||||
out = []
|
||||
for i in range(n):
|
||||
agent = Agent(tenant_id=tenant.id, name=f"a{i}", token_hash=f"t{i}".ljust(64, "x"), agent_version="1.2.1")
|
||||
session.add(agent)
|
||||
out.append(agent)
|
||||
session.flush()
|
||||
return out
|
||||
|
||||
|
||||
def _limit(monkeypatch, value):
|
||||
monkeypatch.setattr(jobs, "settings", SimpleNamespace(heavy_job_concurrency_limit=value))
|
||||
|
||||
|
||||
def test_only_the_configured_number_of_full_jobs_run_at_once_and_the_rest_wait(monkeypatch):
|
||||
_limit(monkeypatch, 2)
|
||||
with setup_db()() as session:
|
||||
agents = _agents(session, 4)
|
||||
queued = [jobs.create_job(session, a, "INITIAL_SYNC") for a in agents]
|
||||
claimed = [jobs.claim_next_job(session, a) for a in agents]
|
||||
assert [c is not None for c in claimed] == [True, True, False, False] # 2 running, 2 queued (backpressure)
|
||||
assert [session.get(Job, q.id).status for q in queued] == ["RUNNING", "RUNNING", "PENDING", "PENDING"]
|
||||
jobs.complete_job(session, session.get(Job, queued[0].id), {}) # a slot frees up
|
||||
assert jobs.claim_next_job(session, agents[2]) is not None and jobs.claim_next_job(session, agents[3]) is None
|
||||
|
||||
|
||||
def test_a_light_incremental_job_is_never_held_behind_a_queued_full_job(monkeypatch):
|
||||
_limit(monkeypatch, 1)
|
||||
with setup_db()() as session:
|
||||
busy, waiting = _agents(session, 2)
|
||||
jobs.create_job(session, busy, "REPROCESS_ALL")
|
||||
assert jobs.claim_next_job(session, busy) is not None
|
||||
full = jobs.create_job(session, waiting, "REPROCESS_ALL")
|
||||
light = jobs.create_job(session, waiting, "REPROCESS_PRODUCTS", {"codigos": ["1"]})
|
||||
claimed = jobs.claim_next_job(session, waiting)
|
||||
assert claimed is not None and claimed.id == light.id and session.get(Job, full.id).status == "PENDING"
|
||||
|
||||
|
||||
def test_limit_zero_disables_backpressure_and_a_stale_running_job_does_not_hold_a_slot(monkeypatch):
|
||||
from datetime import datetime, timedelta, timezone
|
||||
_limit(monkeypatch, 0)
|
||||
with setup_db()() as session:
|
||||
agents = _agents(session, 3)
|
||||
for a in agents:
|
||||
jobs.create_job(session, a, "INITIAL_SYNC")
|
||||
assert all(jobs.claim_next_job(session, a) is not None for a in agents)
|
||||
_limit(monkeypatch, 1)
|
||||
with setup_db()() as session:
|
||||
dead, fresh = _agents(session, 2)
|
||||
stale = jobs.create_job(session, dead, "INITIAL_SYNC")
|
||||
session.execute(Job.__table__.update().where(Job.id == stale.id).values(status="RUNNING", started_at=datetime.now(timezone.utc) - timedelta(hours=3)))
|
||||
session.commit()
|
||||
jobs.create_job(session, fresh, "INITIAL_SYNC")
|
||||
assert jobs.claim_next_job(session, fresh) is not None # the dead Agent's 3 h old RUNNING job does not block the queue
|
||||
@@ -97,3 +97,54 @@ def test_agent_can_reread_only_its_own_current_authorization_and_it_follows_the_
|
||||
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"}
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
Reference in New Issue
Block a user