"""Audit service: the only writer of audit entries (AD-4, AD-19).""" from __future__ import annotations from typing import Any from django.db import connection, transaction from .models import AuditEntry _FORBIDDEN_FRAGMENTS = ("password", "passwd", "pwd", "token", "secret") class AuditOutsideTransaction(RuntimeError): """`record` was called without an open transaction, so it cannot share the change's fate.""" class AuditInsideTransaction(RuntimeError): """`record_failure` was called in an open transaction, where a rollback would erase it.""" def _check_detail(value: Any, path: str = "detail") -> None: if isinstance(value, dict): for key, inner in value.items(): lowered = str(key).lower() if any(fragment in lowered for fragment in _FORBIDDEN_FRAGMENTS): raise ValueError(f"{path}: key {key!r} may hold a secret and is not allowed") _check_detail(inner, f"{path}.{key}") elif isinstance(value, list | tuple): for item in value: _check_detail(item, path) def _build(actor: str, action: str, target: str, ip: str, detail: dict | None) -> AuditEntry: detail = detail or {} _check_detail(detail) return AuditEntry( actor=(actor or "").lower(), action=action, target=target or "", ip=ip or "", detail=detail, ) def record( actor: str, action: str, *, target: str = "", ip: str = "", detail: dict | None = None, ) -> AuditEntry: """Write an entry inside the caller's open transaction; it rolls back with the change.""" entry = _build(actor, action, target, ip, detail) if not connection.in_atomic_block: raise AuditOutsideTransaction("audit.record() must run inside transaction.atomic()") entry.save() return entry def record_failure( actor: str, action: str, *, target: str = "", ip: str = "", detail: dict | None = None, ) -> AuditEntry: """Write a failure entry in its own transaction, after the failed change was rolled back.""" entry = _build(actor, action, target, ip, detail) if connection.in_atomic_block: raise AuditInsideTransaction( "audit.record_failure() must run after the failed change was rolled back, " "outside any open transaction" ) with transaction.atomic(): entry.save() return entry