Files
OPNexus/backend/app/services/audit_service.py
T
Julian Boldt 295555cfd6
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Release v1.22.0
- **Standort-Variablen**: `{{NAME}}`-Platzhalter in Regeln, Aliasen, Portweiterleitungen, 1:1- und Outbound-NAT, je Host mit eigenem Wert
  (Host-Seite › Standort-Variablen, listet die verwendeten Platzhalter automatisch). Je Host aufgelöst in Vorschau, Ausrollen, Zählern und
  Audit; fehlt ein Wert, wird nichts gesendet (Vorschau „Variable fehlt“, Ausrollen wird vor jedem Schreiben abgelehnt, zusätzlich eine
  Sperre im API-Client). Neue Tabelle `host_variables` (Migration `0016`).
2026-10-20 01:42:30 +02:00

133 lines
6.8 KiB
Python

"""HTTP attempts plus transactionally recorded deployment state transitions.
Requests are committed before executing an operation, so an interrupted
request remains visible. Deployment events commit with the deployment state.
No credentials, cookies or arbitrary request bodies are recorded.
"""
import copy
import uuid
from contextvars import ContextVar
from sqlalchemy import event, inspect
from sqlalchemy.orm import Session
from app.database import SessionLocal
from app.models.audit import AuditEvent
context = ContextVar("audit_context", default=None)
# Column widths of audit_events. entity_id/host_id/action are taken from the
# request path BEFORE the router validated it, so a junk segment (300 chars,
# or a NUL byte which Postgres text cannot store) used to turn into a 500 or a
# dropped connection instead of a plain 404/422 (found by fuzzing, 2026-10-09).
_LIMITS = {"correlation_id": 36, "actor": 150, "kind": 32, "action": 40, "status": 32,
"entity_id": 36, "entity_name": 255, "host_id": 36, "host_name": 255}
def _clean(key, value):
if not isinstance(value, str):
return value
value = value.replace("\x00", "")
limit = _LIMITS.get(key)
return value[:limit] if limit else value
def make_event(**values):
ctx = context.get() or {}
values = {k: _clean(k, v) for k, v in values.items()}
return AuditEvent(actor=_clean("actor", ctx.get("actor", "system:rollback")),
correlation_id=_clean("correlation_id", ctx.get("id", str(uuid.uuid4()))), **values)
async def persist(**values):
async with SessionLocal() as db:
db.add(make_event(**values))
await db.commit()
@event.listens_for(Session, "before_flush")
def deployment_history(session, flush_context, instances):
from app.models import (FirewallRule, FirewallAlias, RuleDeployment, AliasDeployment,
PortForwardRule, PortForwardDeployment, OneToOneRule, OneToOneDeployment,
OutboundNatRule, OutboundNatDeployment, OpnsenseHost,
PolicyHierarchyRule, PolicyHierarchyRuleDeployment)
mapping = {
RuleDeployment: ("rule", FirewallRule, "rule_id", "opnsense_rule_uuid"),
AliasDeployment: ("alias", FirewallAlias, "alias_id", "opnsense_alias_uuid"),
PortForwardDeployment: ("port_forward", PortForwardRule, "port_forward_id", "opnsense_port_forward_uuid"),
OneToOneDeployment: ("one_to_one", OneToOneRule, "one_to_one_id", "opnsense_one_to_one_uuid"),
OutboundNatDeployment: ("outbound_nat", OutboundNatRule, "outbound_nat_id", "opnsense_outbound_nat_uuid"),
PolicyHierarchyRuleDeployment: ("policy_hierarchy", PolicyHierarchyRule, "policy_hierarchy_id", "opnsense_policy_hierarchy_uuid"),
}
for dep in list(session.new) + list(session.dirty) + list(session.deleted):
if type(dep) not in mapping or getattr(dep, "_skip_audit", False):
continue
is_new, deleted = dep in session.new, dep in session.deleted
if not is_new and not deleted and not session.is_modified(dep, include_collections=False):
continue
kind, model, id_attr, uuid_attr = mapping[type(dep)]
entity_id = getattr(dep, id_attr)
entity = session.get(model, entity_id)
host = session.get(OpnsenseHost, dep.host_id)
state = inspect(dep)
old_status = state.attrs.status.history.deleted
old_payload = state.attrs.last_payload.history.deleted
ctx = context.get() or {}
action = ctx.get("action", "rollback")
if action == "confirm" and dep.status != "success":
action = "state_change"
details = {
"remote_uuid": getattr(dep, uuid_attr),
"before_status": old_status[0] if old_status else (None if is_new else dep.status),
"before_payload": copy.deepcopy(old_payload[0] if old_payload else (None if is_new else dep.last_payload)),
"after_payload": None if deleted else copy.deepcopy(dep.last_payload),
"rollback_payload": copy.deepcopy(dep.previous_payload),
"revert_deadline": dep.revert_deadline.isoformat() if dep.revert_deadline else None,
}
# A failed push may never have populated last_payload. Preserve the
# intended payload separately from the last known applied state.
if entity is not None and action == "deploy":
from importlib import import_module
builder = import_module(f"app.services.{kind}_deploy_service")._build_payload
try:
details["requested_payload"] = builder(entity, host) if host is not None else builder(entity)
except Exception: # a missing variable must not break the audit write; record the unresolved intent instead
details["requested_payload"] = builder(entity)
session.add(make_event(kind=kind, action=action, status="removed" if deleted else dep.status,
entity_id=str(entity_id), entity_name=(getattr(entity, "name", None) or getattr(entity, "description", None)),
host_id=str(dep.host_id), host_name=host.name if host else None,
message=dep.message, details=details))
def tracked_rollback(kind):
"""Persist worker attempts before contacting a firewall, including crashes.
Flush the resulting state while its correlation context is still active.
"""
from functools import wraps
def decorate(function):
@wraps(function)
async def wrapped(db, deployment):
from app.models import OpnsenseHost
token = None
if context.get() is None:
token = context.set({"actor": "system:rollback", "id": str(uuid.uuid4()), "action": "rollback"})
host = await db.get(OpnsenseHost, deployment.host_id)
values = {"kind": kind, "action": (context.get() or {}).get("action", "rollback"),
"entity_id": str(getattr(deployment, f"{kind}_id")),
"host_id": str(deployment.host_id), "host_name": host.name if host else None}
try:
await persist(**values, status="requested", details={
"remote_uuid": getattr(deployment, f"opnsense_{kind}_uuid"),
"rollback_payload": copy.deepcopy(deployment.previous_payload),
"before_payload": copy.deepcopy(deployment.last_payload)})
result = await function(db, deployment)
await db.flush()
return result
except Exception:
await persist(**values, status="interrupted", message="Rollback-Ausgang unklar; gespeicherten und tatsächlichen Stand prüfen.")
raise
finally:
if token is not None:
context.reset(token)
return wrapped
return decorate