From 2aaa8f616f71bed7de1cd90c7630cc13e92bd02f Mon Sep 17 00:00:00 2001 From: pavsoss Date: Wed, 29 Jul 2026 14:03:52 -0400 Subject: [PATCH] Add admin audit query endpoint with retention pruning --- backend/.env.example | 14 +- backend/api.py | 76 ++++++++ backend/audit_store.py | 300 ++++++++++++++++++++++++++++++ backend/openapi_spec.py | 85 +++++++++ backend/tests/test_audit_query.py | 115 ++++++++++++ backend/tests/test_audit_store.py | 82 ++++++++ 6 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 backend/audit_store.py create mode 100644 backend/tests/test_audit_query.py create mode 100644 backend/tests/test_audit_store.py diff --git a/backend/.env.example b/backend/.env.example index 3ee87c01..cc6a9195 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -155,4 +155,16 @@ GROQ_API_KEY=your_groq_api_key_here GROQ_MODELS=["llama-3.1-8b-instant","llama-3.1-70b-versatile","mixtral-8x7b-32768","gemma2-9b-it"] # OR single model (backward compatible) -# GROQ_MODEL=llama-3.1-8b-instant \ No newline at end of file +# GROQ_MODEL=llama-3.1-8b-instant + +# -------------------------------------------- +# AUDIT TRAIL (issue #1023) +# -------------------------------------------- +# Location of the tamper-evident audit database written by the Flask ML API. +# Point this at a durable, backed-up volume in production. Defaults to +# audit_log.db beside api.py when unset. +# AUDIT_DB_PATH=/var/lib/spam-detection/audit_log.db +# Retention window (days) for the admin prune routine. Records older than this +# are deleted and the surviving chain is re-sealed. A non-positive value +# disables pruning. Defaults to 90. +# AUDIT_RETENTION_DAYS=90 \ No newline at end of file diff --git a/backend/api.py b/backend/api.py index 182e275b..c7b582ad 100644 --- a/backend/api.py +++ b/backend/api.py @@ -435,6 +435,23 @@ def decorated_function(*args, **kwargs): f"📝 [AUDIT] {action.__name__} - Status: {status} - User: {user}" ) + # Persist a tamper-evident record in addition to the log line + # (issue #1023). Fail-soft: a store outage must never turn an + # otherwise-successful request into an error, so any failure is + # logged and swallowed here. + try: + audit_store.append( + actor=user, + action=getattr(action, "__name__", str(action)), + resource=resource_type, + request_id=request_id, + status=status, + ) + except Exception as audit_error: + app.logger.warning( + f"⚠️ [AUDIT] failed to persist audit record: {audit_error}" + ) + return response return decorated_function @@ -1625,6 +1642,54 @@ def feedback_stats(): ) +# ============================================ +# AUDIT TRAIL QUERY (issue #1023) +# ============================================ + + +@app.route("/audit", methods=["GET"]) +@validate_request +@validate_internal_request +def get_audit_records(): + """Admin-only view over the tamper-evident audit trail (issue #1023). + + Gated by the same service-to-service internal secret as every other + privileged route; on top of that it requires the trusted backend to forward + the caller's authenticated identity (X-User-Username) and admin role + (X-User-Role), mirroring the Node admin gate. Supports exact-match filters + (actor, action, resource), an inclusive ISO-8601 time window (since, until) + and limit/offset pagination. The response also reports whether the stored + chain still verifies, so an operator sees integrity status alongside data. + """ + username = _require_username() + if not username: + raise ApiError( + ErrorCode.MISSING_USERNAME, "Missing X-User-Username header", 401 + ) + if not _is_admin_request(): + raise ApiError( + ErrorCode.FORBIDDEN, "Audit trail access requires the admin role", 403 + ) + + records = audit_store.query( + actor=request.args.get("actor"), + action=request.args.get("action"), + resource=request.args.get("resource"), + since=request.args.get("since"), + until=request.args.get("until"), + limit=request.args.get("limit", default=100, type=int), + offset=request.args.get("offset", default=0, type=int), + ) + return jsonify( + { + "success": True, + "count": len(records), + "records": records, + "chain_intact": audit_store.verify_chain(), + } + ) + + # ============================================ # EMAIL HEADER ANALYSIS # ============================================ @@ -1946,6 +2011,7 @@ def scan_emails_route(): imap_store.init_db() oauth_store.init_db() +audit_store.init_db() init_spam_words_db() scheduler = BackgroundScheduler() scheduler.start() @@ -2063,6 +2129,16 @@ def _require_username(): return request.headers.get("X-User-Username") +def _is_admin_request(): + """Whether the trusted backend forwarded an admin role for this caller. + + Reuses the existing X-User-* header convention and the ``admin`` role from + the published /api/roles vocabulary; the internal-secret gate upstream + guarantees the header can only originate from the trusted backend. + """ + return request.headers.get("X-User-Role", "").strip().lower() == "admin" + + @app.route("/imap/connect", methods=["POST"]) @validate_request @validate_internal_request diff --git a/backend/audit_store.py b/backend/audit_store.py new file mode 100644 index 00000000..617d48de --- /dev/null +++ b/backend/audit_store.py @@ -0,0 +1,300 @@ +"""Tamper-evident, append-only audit store for the Flask ML API (issue #1023). + +The ``audit_log`` decorator in ``api.py`` historically emitted only free-text +log lines, which are trivially editable after the fact and impossible to query. +This module persists each audited action as a row in a local SQLite database +and links the rows into a SHA-256 hash chain: every record commits to the hash +of its predecessor, so editing or deleting any row invalidates the hash of that +record and of every record that follows it. :func:`verify_chain` recomputes the +chain end to end and reports whether it is intact, giving operators a cheap +integrity check over the whole trail. + +Records are otherwise immutable and append-only. The one sanctioned mutation is +:func:`prune`, which enforces a retention window by deleting expired records and +then re-links the survivors from the genesis anchor so the retained trail keeps +verifying. :func:`query` exposes the trail for the admin-only ``GET /audit`` +endpoint with field filters, a time window and pagination. + +The store is deliberately dependency-free (stdlib ``sqlite3`` only) and is meant +to be called fail-soft on the write path: a write failure must never break the +request being audited (see ``api.audit_log``). The database location is +configurable via ``AUDIT_DB_PATH`` and the retention window via +``AUDIT_RETENTION_DAYS``. + +>>> import os, tempfile +>>> db = os.path.join(tempfile.mkdtemp(), "audit.db") +>>> _ = append("alice", "predict", "message", "req-1", 200, db_path=db) +>>> _ = append("bob", "reload_model", "model", "req-2", 200, db_path=db) +>>> verify_chain(db_path=db) +True +""" + +from datetime import datetime, timedelta, timezone +import hashlib +import json +import os +from pathlib import Path +import sqlite3 + +__all__ = [ + "GENESIS_HASH", + "DB_PATH", + "DEFAULT_RETENTION_DAYS", + "MAX_QUERY_LIMIT", + "get_db_connection", + "init_db", + "append", + "verify_chain", + "query", + "prune", +] + +# Default location for the audit database. Overridable so deployments can point +# the store at a durable, backed-up volume rather than the app directory. +DB_PATH = os.getenv( + "AUDIT_DB_PATH", + str(Path(__file__).resolve().parent / "audit_log.db"), +) + +# Age (in days) beyond which records are eligible for pruning when a caller does +# not pass an explicit window. A non-positive value disables pruning. +DEFAULT_RETENTION_DAYS = int(os.getenv("AUDIT_RETENTION_DAYS", "90")) + +# Upper bound on how many records a single query may return, so a caller cannot +# ask for an unbounded page. +MAX_QUERY_LIMIT = 1000 + +# prev_hash of the first record. A fixed, all-zero digest anchors the chain so +# the genesis record is verified with the same rule as every later one. +GENESIS_HASH = "0" * 64 + +# Fields committed to by ``record_hash``, in a fixed order. ``id`` is excluded +# because it is assigned by SQLite on insert and is not part of the signed +# payload; ordering/deletion is instead caught by the prev_hash linkage. +_HASHED_FIELDS = ("actor", "action", "resource", "request_id", "status", "timestamp") + +# Columns a caller may filter ``query`` on by exact match. +_FILTER_COLUMNS = ("actor", "action", "resource") + + +def get_db_connection(db_path=None): + path = db_path if db_path is not None else DB_PATH + conn = sqlite3.connect(path, timeout=5.0) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout=5000") + conn.execute("PRAGMA synchronous=NORMAL") + return conn + + +def init_db(db_path=None): + """Create the audit table if it does not exist. Idempotent.""" + with get_db_connection(db_path) as conn: + conn.execute("PRAGMA journal_mode=WAL") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS audit_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor TEXT NOT NULL, + action TEXT NOT NULL, + resource TEXT NOT NULL, + request_id TEXT NOT NULL, + status INTEGER NOT NULL, + timestamp TEXT NOT NULL, + prev_hash TEXT NOT NULL, + record_hash TEXT NOT NULL + ) + """ + ) + conn.commit() + + +def append(actor, action, resource, request_id, status, timestamp=None, db_path=None): + """Append one audited action to the chain and return the stored record. + + ``prev_hash`` is taken from the current tail of the chain (or + :data:`GENESIS_HASH` when the store is empty) and ``record_hash`` is the + SHA-256 of ``prev_hash`` concatenated with the canonical serialization of + the signed fields. The returned dict includes the assigned ``id`` and both + hashes. + """ + init_db(db_path) + record = { + "actor": str(actor), + "action": str(action), + "resource": str(resource), + "request_id": str(request_id), + "status": int(status), + "timestamp": timestamp or datetime.now(timezone.utc).isoformat(), + } + with get_db_connection(db_path) as conn: + tail = conn.execute( + "SELECT record_hash FROM audit_records ORDER BY id DESC LIMIT 1" + ).fetchone() + prev_hash = tail["record_hash"] if tail else GENESIS_HASH + record_hash = _compute_hash(prev_hash, record) + cursor = conn.execute( + """ + INSERT INTO audit_records + (actor, action, resource, request_id, status, timestamp, prev_hash, record_hash) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + record["actor"], + record["action"], + record["resource"], + record["request_id"], + record["status"], + record["timestamp"], + prev_hash, + record_hash, + ), + ) + conn.commit() + record["id"] = cursor.lastrowid + record["prev_hash"] = prev_hash + record["record_hash"] = record_hash + return record + + +def verify_chain(db_path=None): + """Recompute the hash chain and return whether it is intact. + + Walks the records in insertion order, checking that each row's stored + ``prev_hash`` matches the running hash and that its ``record_hash`` matches + a fresh recomputation. Any edited field, deleted row or reordering breaks + one of these checks, so the function returns ``False`` for a trail that has + been tampered with and ``True`` otherwise (including for an empty store). + """ + init_db(db_path) + with get_db_connection(db_path) as conn: + rows = conn.execute("SELECT * FROM audit_records ORDER BY id ASC").fetchall() + + running_prev = GENESIS_HASH + for row in rows: + if row["prev_hash"] != running_prev: + return False + expected = _compute_hash(running_prev, {k: row[k] for k in _HASHED_FIELDS}) + if expected != row["record_hash"]: + return False + running_prev = row["record_hash"] + return True + + +def query( + actor=None, + action=None, + resource=None, + since=None, + until=None, + limit=100, + offset=0, + db_path=None, +): + """Return stored records, newest first, matching the given filters. + + ``actor``/``action``/``resource`` are exact-match filters. ``since`` and + ``until`` bound the ``timestamp`` column (inclusive) and are compared as + ISO-8601 UTC strings, whose lexical order matches chronological order. + ``limit`` is clamped to :data:`MAX_QUERY_LIMIT` and ``offset`` to a + non-negative value. Each result is a plain ``dict`` of all columns. + """ + init_db(db_path) + + clauses = [] + params = [] + for column, value in ( + ("actor", actor), + ("action", action), + ("resource", resource), + ): + if value is not None: + clauses.append(f"{column} = ?") + params.append(str(value)) + if since is not None: + clauses.append("timestamp >= ?") + params.append(str(since)) + if until is not None: + clauses.append("timestamp <= ?") + params.append(str(until)) + + where = f"WHERE {' AND '.join(clauses)}" if clauses else "" + safe_limit = max(1, min(int(limit), MAX_QUERY_LIMIT)) + safe_offset = max(0, int(offset)) + params.extend([safe_limit, safe_offset]) + + with get_db_connection(db_path) as conn: + rows = conn.execute( + f""" + SELECT * FROM audit_records + {where} + ORDER BY id DESC + LIMIT ? OFFSET ? + """, + params, + ).fetchall() + return [dict(row) for row in rows] + + +def prune(retention_days=None, now=None, db_path=None): + """Delete records older than the retention window and re-seal the chain. + + ``retention_days`` defaults to :data:`DEFAULT_RETENTION_DAYS`; a + non-positive window disables pruning and returns ``0``. Records whose + ``timestamp`` predates ``now - retention_days`` are removed, then the + surviving records are re-linked from the genesis anchor so + :func:`verify_chain` continues to pass over the retained trail. Returns the + number of records deleted. + """ + days = DEFAULT_RETENTION_DAYS if retention_days is None else int(retention_days) + if days <= 0: + return 0 + + init_db(db_path) + reference = now or datetime.now(timezone.utc) + cutoff = (reference - timedelta(days=days)).isoformat() + + with get_db_connection(db_path) as conn: + cursor = conn.execute( + "DELETE FROM audit_records WHERE timestamp < ?", (cutoff,) + ) + deleted = cursor.rowcount + if deleted: + _reseal(conn) + conn.commit() + return deleted + + +def _reseal(conn): + """Recompute prev_hash/record_hash for every surviving record in order. + + Pruning removes the oldest links, which would otherwise strand the earliest + survivor's ``prev_hash``. Re-sealing walks the remaining rows from the + genesis anchor and rewrites their linkage so the retained trail is once + again a valid chain. This is the only place stored hashes are rewritten and + it runs only as part of sanctioned retention maintenance. + """ + rows = conn.execute("SELECT * FROM audit_records ORDER BY id ASC").fetchall() + running_prev = GENESIS_HASH + for row in rows: + record = {k: row[k] for k in _HASHED_FIELDS} + record_hash = _compute_hash(running_prev, record) + conn.execute( + "UPDATE audit_records SET prev_hash = ?, record_hash = ? WHERE id = ?", + (running_prev, record_hash, row["id"]), + ) + running_prev = record_hash + + +def _canonical(record): + """Deterministic serialization of the signed fields. + + ``sort_keys`` plus compact separators make the byte string reproducible + across processes and Python versions, so a hash computed at append time + recomputes identically during verification. + """ + payload = {field: record[field] for field in _HASHED_FIELDS} + return json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str) + + +def _compute_hash(prev_hash, record): + return hashlib.sha256((prev_hash + _canonical(record)).encode("utf-8")).hexdigest() diff --git a/backend/openapi_spec.py b/backend/openapi_spec.py index 90ad4bb8..8d27fcab 100644 --- a/backend/openapi_spec.py +++ b/backend/openapi_spec.py @@ -574,6 +574,55 @@ def _extended_paths(): }, } }, + "/audit": { + "get": { + "summary": "Query the tamper-evident audit trail (admin only)", + "operationId": "getAuditRecords", + "tags": ["System"], + "description": ( + "Returns persisted audit records, newest first. Requires " + "the internal secret plus an admin caller identified by the " + "X-User-Username and X-User-Role headers the trusted backend " + "forwards. Supports exact-match filters, an inclusive " + "ISO-8601 time window and limit/offset pagination." + ), + "parameters": [ + _query_param("actor", "Filter by the acting username."), + _query_param("action", "Filter by audited action name."), + _query_param("resource", "Filter by resource type."), + _query_param( + "since", + "Inclusive lower bound on the record timestamp " + "(ISO-8601 UTC).", + ), + _query_param( + "until", + "Inclusive upper bound on the record timestamp " + "(ISO-8601 UTC).", + ), + _query_param( + "limit", + "Maximum records to return (clamped to 1000).", + schema={"type": "integer", "default": 100}, + ), + _query_param( + "offset", + "Number of records to skip for pagination.", + schema={"type": "integer", "default": 0}, + ), + ], + "responses": { + "200": _json_response( + "Matching audit records plus chain-integrity status.", + {"$ref": "#/components/schemas/AuditQueryResponse"}, + ), + "401": _error_response("Missing X-User-Username header."), + "403": _error_response( + "Caller is not admin or lacks the internal secret." + ), + }, + } + }, "/api/wordcloud": { "get": { "summary": "Spam word frequencies for the word cloud", @@ -1017,6 +1066,42 @@ def _extended_schemas(): }, }, }, + "AuditRecord": { + "type": "object", + "description": ( + "One persisted audit entry. `prev_hash`/`record_hash` form the " + "SHA-256 chain that makes the trail tamper-evident." + ), + "properties": { + "id": {"type": "integer"}, + "actor": {"type": "string"}, + "action": {"type": "string"}, + "resource": {"type": "string"}, + "request_id": {"type": "string"}, + "status": {"type": "integer"}, + "timestamp": {"type": "string", "description": "UTC ISO-8601."}, + "prev_hash": {"type": "string"}, + "record_hash": {"type": "string"}, + }, + }, + "AuditQueryResponse": { + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "count": { + "type": "integer", + "description": "Number of records in this page.", + }, + "records": { + "type": "array", + "items": {"$ref": "#/components/schemas/AuditRecord"}, + }, + "chain_intact": { + "type": "boolean", + "description": "Whether the stored hash chain still verifies.", + }, + }, + }, "AuthUrlResponse": { "type": "object", "properties": {"auth_url": {"type": "string"}}, diff --git a/backend/tests/test_audit_query.py b/backend/tests/test_audit_query.py new file mode 100644 index 00000000..a4c1685b --- /dev/null +++ b/backend/tests/test_audit_query.py @@ -0,0 +1,115 @@ +"""Tests for audit trail querying and retention pruning (issue #1023).""" + +from datetime import datetime, timedelta, timezone +from pathlib import Path +import sys + +import pytest + +BASE_DIR = Path(__file__).resolve().parents[2] +BACKEND_DIR = BASE_DIR / "backend" + +sys.path.insert(0, str(BACKEND_DIR)) + +import audit_store + +NOW = datetime(2026, 7, 29, 12, 0, 0, tzinfo=timezone.utc) + + +def _ts(days_ago): + return (NOW - timedelta(days=days_ago)).isoformat() + + +@pytest.fixture +def store(tmp_path, monkeypatch): + db_path = tmp_path / "audit_query_test.db" + monkeypatch.setattr(audit_store, "DB_PATH", str(db_path)) + audit_store.init_db() + return audit_store + + +def test_query_returns_newest_first(store): + store.append("alice", "predict", "message", "req-1", 200) + store.append("bob", "predict", "message", "req-2", 200) + store.append("carol", "predict", "message", "req-3", 200) + + records = store.query() + + assert [r["actor"] for r in records] == ["carol", "bob", "alice"] + + +def test_filter_by_actor_action_and_resource(store): + store.append("alice", "predict", "message", "req-1", 200) + store.append("alice", "reload_model", "model", "req-2", 200) + store.append("bob", "predict", "message", "req-3", 200) + + assert {r["request_id"] for r in store.query(actor="alice")} == {"req-1", "req-2"} + assert {r["request_id"] for r in store.query(action="predict")} == { + "req-1", + "req-3", + } + assert {r["request_id"] for r in store.query(resource="model")} == {"req-2"} + # Filters compose (AND semantics). + assert {r["request_id"] for r in store.query(actor="alice", action="predict")} == { + "req-1" + } + + +def test_time_window_filters_are_inclusive(store): + store.append("alice", "predict", "message", "old", 200, timestamp=_ts(10)) + store.append("alice", "predict", "message", "mid", 200, timestamp=_ts(5)) + store.append("alice", "predict", "message", "new", 200, timestamp=_ts(1)) + + assert {r["request_id"] for r in store.query(since=_ts(5))} == {"mid", "new"} + assert {r["request_id"] for r in store.query(until=_ts(5))} == {"old", "mid"} + assert {r["request_id"] for r in store.query(since=_ts(5), until=_ts(5))} == {"mid"} + + +def test_pagination_limit_and_offset(store): + for i in range(5): + store.append("alice", "predict", "message", f"req-{i}", 200) + + first_page = store.query(limit=2) + second_page = store.query(limit=2, offset=2) + + assert [r["request_id"] for r in first_page] == ["req-4", "req-3"] + assert [r["request_id"] for r in second_page] == ["req-2", "req-1"] + + +def test_limit_is_clamped_to_sane_bounds(store): + for i in range(3): + store.append("alice", "predict", "message", f"req-{i}", 200) + + # Oversized limit returns everything without error; a zero limit still + # yields at least one row rather than an empty/invalid query. + assert len(store.query(limit=10_000)) == 3 + assert len(store.query(limit=0)) == 1 + + +def test_prune_deletes_records_older_than_window(store): + store.append("alice", "predict", "message", "ancient", 200, timestamp=_ts(120)) + store.append("alice", "predict", "message", "old", 200, timestamp=_ts(45)) + store.append("alice", "predict", "message", "recent", 200, timestamp=_ts(5)) + + deleted = store.prune(retention_days=30, now=NOW) + + assert deleted == 2 + remaining = {r["request_id"] for r in store.query()} + assert remaining == {"recent"} + + +def test_prune_reseals_so_chain_still_verifies(store): + store.append("alice", "predict", "message", "ancient", 200, timestamp=_ts(120)) + store.append("bob", "predict", "message", "old", 200, timestamp=_ts(45)) + store.append("carol", "predict", "message", "recent", 200, timestamp=_ts(5)) + + store.prune(retention_days=30, now=NOW) + + assert store.verify_chain() is True + + +def test_prune_is_a_noop_for_nonpositive_window(store): + store.append("alice", "predict", "message", "old", 200, timestamp=_ts(400)) + + assert store.prune(retention_days=0, now=NOW) == 0 + assert len(store.query()) == 1 diff --git a/backend/tests/test_audit_store.py b/backend/tests/test_audit_store.py new file mode 100644 index 00000000..01dc18c9 --- /dev/null +++ b/backend/tests/test_audit_store.py @@ -0,0 +1,82 @@ +"""Tests for the tamper-evident audit store (issue #1023).""" + +from pathlib import Path +import sqlite3 +import sys + +import pytest + +BASE_DIR = Path(__file__).resolve().parents[2] +BACKEND_DIR = BASE_DIR / "backend" + +sys.path.insert(0, str(BACKEND_DIR)) + +import audit_store + + +@pytest.fixture +def store(tmp_path, monkeypatch): + db_path = tmp_path / "audit_test.db" + monkeypatch.setattr(audit_store, "DB_PATH", str(db_path)) + audit_store.init_db() + return audit_store + + +def test_empty_chain_is_intact(store): + assert store.verify_chain() is True + + +def test_first_record_links_to_genesis(store): + record = store.append("alice", "predict", "message", "req-1", 200) + + assert record["prev_hash"] == audit_store.GENESIS_HASH + assert record["id"] == 1 + assert store.verify_chain() is True + + +def test_appended_records_chain_and_verify(store): + first = store.append("alice", "predict", "message", "req-1", 200) + second = store.append("bob", "reload_model", "model", "req-2", 500) + third = store.append("carol", "feedback", "label", "req-3", 201) + + # Each record commits to its predecessor's hash. + assert second["prev_hash"] == first["record_hash"] + assert third["prev_hash"] == second["record_hash"] + assert store.verify_chain() is True + + +def test_edited_row_is_detected(store): + store.append("alice", "predict", "message", "req-1", 200) + store.append("bob", "reload_model", "model", "req-2", 200) + + # Rewrite a persisted field directly, simulating a database-level edit that + # leaves the stored record_hash untouched. + with sqlite3.connect(store.DB_PATH) as conn: + conn.execute("UPDATE audit_records SET actor = ? WHERE id = 1", ("mallory",)) + conn.commit() + + assert store.verify_chain() is False + + +def test_edited_status_is_detected(store): + store.append("alice", "predict", "message", "req-1", 200) + + with sqlite3.connect(store.DB_PATH) as conn: + conn.execute("UPDATE audit_records SET status = ? WHERE id = 1", (403,)) + conn.commit() + + assert store.verify_chain() is False + + +def test_deleted_row_is_detected(store): + store.append("alice", "predict", "message", "req-1", 200) + store.append("bob", "reload_model", "model", "req-2", 200) + store.append("carol", "feedback", "label", "req-3", 200) + + # Removing an interior record orphans the prev_hash linkage of its + # successor, which verification must catch. + with sqlite3.connect(store.DB_PATH) as conn: + conn.execute("DELETE FROM audit_records WHERE id = 2") + conn.commit() + + assert store.verify_chain() is False