Files
PacketStation/server/packetstation/audit.py
T

141 lines
4.4 KiB
Python
Raw Normal View History

"""Append-only audit log with a hash chain: row_hash = sha256(prev_hash || canonical json of the row)."""
from __future__ import annotations
import hashlib
import ipaddress
import json
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
AUDIT_LOCK = 7311
FIELDS = (
"at",
"user_id",
"session_id",
"role_snapshot",
"action",
"subject_type",
"subject_id",
"packet_id",
"detail",
"it_reason",
"ip",
)
def _norm(name: str, value: Any) -> Any:
if value is None:
return None
if name == "at":
if not isinstance(value, datetime):
raise TypeError("at must be a datetime")
return value.astimezone(UTC).isoformat(timespec="microseconds")
if name in ("user_id", "session_id"):
return str(uuid.UUID(str(value)))
if name == "ip":
return str(ipaddress.ip_address(str(value).split("/")[0]))
if name == "packet_id":
return int(value)
if name == "detail":
# Round-trip through JSON so the value hashes the same before insert and after reading jsonb
return json.loads(json.dumps(value, default=str))
return str(value)
def canonical(fields: dict[str, Any]) -> bytes:
"""Sorted keys, no spaces, UTF-8; missing fields count as null."""
body = {k: _norm(k, fields.get(k)) for k in FIELDS}
return json.dumps(body, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def row_hash(prev_hash: bytes | None, fields: dict[str, Any]) -> bytes:
return hashlib.sha256((prev_hash or b"") + canonical(fields)).digest()
@dataclass
class Appended:
id: int
row_hash: bytes
def append(
conn: Any,
*,
action: str,
user_id: Any = None,
session_id: Any = None,
role_snapshot: str | None = None,
subject_type: str | None = None,
subject_id: Any = None,
packet_id: int | None = None,
detail: Any = None,
it_reason: str | None = None,
ip: str | None = None,
) -> Appended:
"""Insert one audit row chained to the previous one. Serialised by pg_advisory_xact_lock(7311)."""
from psycopg.types.json import Jsonb
with conn.transaction():
conn.execute("SELECT pg_advisory_xact_lock(%s)", (AUDIT_LOCK,))
last = conn.execute("SELECT row_hash FROM audit_events ORDER BY id DESC LIMIT 1").fetchone()
prev = bytes(last[0]) if last and last[0] is not None else None
at = conn.execute("SELECT now()").fetchone()[0]
fields = {
"at": at,
"user_id": user_id,
"session_id": session_id,
"role_snapshot": role_snapshot,
"action": action,
"subject_type": subject_type,
"subject_id": None if subject_id is None else str(subject_id),
"packet_id": packet_id,
"detail": detail,
"it_reason": it_reason,
"ip": ip,
}
h = row_hash(prev, fields)
row = conn.execute(
"INSERT INTO audit_events (at, prev_hash, row_hash, user_id, session_id, role_snapshot, action,"
" subject_type, subject_id, packet_id, detail, it_reason, ip)"
" VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id",
(
at,
prev,
h,
user_id,
session_id,
role_snapshot,
action,
subject_type,
fields["subject_id"],
packet_id,
None if detail is None else Jsonb(detail),
it_reason,
ip,
),
).fetchone()
return Appended(int(row[0]), h)
def verify_chain(conn: Any) -> tuple[bool, int, str]:
"""Walk the chain from the start: (ok, rows checked, detail)."""
cols = ", ".join(FIELDS)
cur = conn.execute(f"SELECT id, prev_hash, row_hash, {cols} FROM audit_events ORDER BY id") # noqa: S608
prev: bytes | None = None
n = 0
for rec in cur:
rid, p, h = rec[0], rec[1], rec[2]
p = bytes(p) if p is not None else None
if p != prev:
return False, n, f"row {rid}: prev_hash does not match row before it"
fields = dict(zip(FIELDS, rec[3:], strict=True))
if row_hash(prev, fields) != bytes(h):
return False, n, f"row {rid}: row_hash does not match its content"
prev = bytes(h)
n += 1
return True, n, f"{n} rows verified"