141 lines
4.4 KiB
Python
141 lines
4.4 KiB
Python
"""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"
|