Implement AUDIT-WP-0007 hash-chain integrity.
Accept now extends a single-schema chain. Verify walks it; a rewritten payload_hash is a break. Tamper evidence is that detector plus an external chain-head attestation, not WORM.
This commit is contained in:
parent
5faede18fc
commit
5fd04e2095
17 changed files with 696 additions and 29 deletions
|
|
@ -33,6 +33,14 @@ from audit_core.interface import (
|
|||
validate_event,
|
||||
)
|
||||
from audit_core.credentials import CredentialDirectory
|
||||
from audit_core.integrity import (
|
||||
CHAIN_LOCK_KEY,
|
||||
GENESIS,
|
||||
ChainRow,
|
||||
attestation_from_report,
|
||||
chain_link,
|
||||
verify_rows,
|
||||
)
|
||||
from audit_core.redaction import Finding
|
||||
|
||||
try: # pragma: no cover - import guard
|
||||
|
|
@ -134,6 +142,13 @@ MIGRATIONS: list[tuple[str, str]] = [
|
|||
$grant$;
|
||||
""",
|
||||
),
|
||||
(
|
||||
"0006-chain",
|
||||
"""
|
||||
ALTER TABLE {schema}.events ADD COLUMN IF NOT EXISTS chain_hash text;
|
||||
ALTER TABLE {schema}.events ADD COLUMN IF NOT EXISTS chain_prev text;
|
||||
""",
|
||||
),
|
||||
]
|
||||
|
||||
# Rejection reasons whose payload must never be persisted — storing the body of
|
||||
|
|
@ -255,6 +270,7 @@ class PostgresAuditBackend:
|
|||
(migration_id,),
|
||||
)
|
||||
applied.append(migration_id)
|
||||
self._backfill_chain(conn)
|
||||
except psycopg.Error as exc:
|
||||
raise BackendUnavailableError(f"migration failed: {exc}") from exc
|
||||
return applied
|
||||
|
|
@ -272,9 +288,10 @@ class PostgresAuditBackend:
|
|||
``immutable`` is True because migration 0002 installs a trigger that
|
||||
rejects UPDATE and DELETE, so no consumer credential can alter a stored
|
||||
record. It is not a claim against the database owner or a superuser,
|
||||
who can drop the trigger; ``tamper_evidence`` is correspondingly False,
|
||||
because nothing here would *prove* they had. Hash-chaining or external
|
||||
anchoring would be needed for that, and is not implemented.
|
||||
who can drop the trigger. ``tamper_evidence`` is True because a
|
||||
hash chain plus verify detects a rewritten payload, and a chain-head
|
||||
attestation outside this database detects a suffix rewrite that
|
||||
stays inside Postgres. It is not WORM or ``data.archive``.
|
||||
|
||||
``custody_class`` is ``operational``, not ``archive``. This store is
|
||||
durable append-only Postgres recovered through the platform
|
||||
|
|
@ -286,7 +303,7 @@ class PostgresAuditBackend:
|
|||
custody_class="operational",
|
||||
retention_days=self.retention_days,
|
||||
immutable=True,
|
||||
tamper_evidence=False,
|
||||
tamper_evidence=True,
|
||||
durable=True,
|
||||
recoverable_days=self.recoverable_days,
|
||||
recoverable_source=self.recoverable_source,
|
||||
|
|
@ -302,13 +319,20 @@ class PostgresAuditBackend:
|
|||
details = event.details if isinstance(event.details, dict) else {}
|
||||
record = event.as_record()
|
||||
try:
|
||||
with self.pool.connection() as conn:
|
||||
with self.pool.connection() as conn, conn.transaction():
|
||||
conn.execute("SELECT pg_advisory_xact_lock(%s)", (CHAIN_LOCK_KEY,))
|
||||
head = conn.execute(
|
||||
f"SELECT chain_hash FROM {self._events} "
|
||||
"ORDER BY accepted_at DESC, event_id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
previous = head[0] if head and head[0] else GENESIS
|
||||
link = chain_link(previous, payload_hash, event.event_id)
|
||||
inserted = conn.execute(
|
||||
f"""
|
||||
INSERT INTO {self._events}
|
||||
(event_id, payload_hash, observed_at, tenant, correlation_id,
|
||||
source, action, record)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
source, action, record, chain_hash, chain_prev)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (event_id) DO NOTHING
|
||||
RETURNING event_id
|
||||
""",
|
||||
|
|
@ -321,6 +345,8 @@ class PostgresAuditBackend:
|
|||
event.source,
|
||||
event.action,
|
||||
json.dumps(record, sort_keys=True),
|
||||
link,
|
||||
previous,
|
||||
),
|
||||
).fetchone()
|
||||
if inserted is not None:
|
||||
|
|
@ -443,9 +469,69 @@ class PostgresAuditBackend:
|
|||
|
||||
# --- lifecycle ---------------------------------------------------------
|
||||
|
||||
def verify_chain(self, attestation: dict | None = None):
|
||||
rows = self._query(
|
||||
f"SELECT event_id, payload_hash, chain_hash, chain_prev, accepted_at "
|
||||
f"FROM {self._events} ORDER BY accepted_at, event_id",
|
||||
(),
|
||||
)
|
||||
return verify_rows(
|
||||
[
|
||||
ChainRow(
|
||||
event_id=r[0],
|
||||
payload_hash=r[1],
|
||||
chain_hash=r[2] or "",
|
||||
chain_prev=r[3] or "",
|
||||
accepted_at=_iso(r[4]),
|
||||
)
|
||||
for r in rows
|
||||
],
|
||||
attestation=attestation,
|
||||
)
|
||||
|
||||
def attest_chain(self) -> dict:
|
||||
return attestation_from_report(self.verify_chain())
|
||||
|
||||
def health(self) -> None:
|
||||
self._query("SELECT 1", ())
|
||||
|
||||
def _backfill_chain(self, conn) -> None:
|
||||
"""Fill chain columns on rows accepted before migration 0006.
|
||||
|
||||
The append-only trigger must be disabled for this UPDATE. A
|
||||
database owner can do that; this is a one-time migrate, not a
|
||||
runtime path.
|
||||
"""
|
||||
missing = conn.execute(
|
||||
f"SELECT count(*) FROM {self._events} "
|
||||
"WHERE chain_hash IS NULL OR chain_prev IS NULL"
|
||||
).fetchone()[0]
|
||||
if missing:
|
||||
conn.execute(
|
||||
f'ALTER TABLE {self._events} DISABLE TRIGGER events_append_only'
|
||||
)
|
||||
prev = GENESIS
|
||||
for event_id, payload_hash in conn.execute(
|
||||
f"SELECT event_id, payload_hash FROM {self._events} "
|
||||
"ORDER BY accepted_at, event_id"
|
||||
).fetchall():
|
||||
link = chain_link(prev, payload_hash, event_id)
|
||||
conn.execute(
|
||||
f"UPDATE {self._events} SET chain_prev = %s, chain_hash = %s "
|
||||
"WHERE event_id = %s",
|
||||
(prev, link, event_id),
|
||||
)
|
||||
prev = link
|
||||
conn.execute(
|
||||
f'ALTER TABLE {self._events} ENABLE TRIGGER events_append_only'
|
||||
)
|
||||
conn.execute(
|
||||
f"ALTER TABLE {self._events} ALTER COLUMN chain_hash SET NOT NULL"
|
||||
)
|
||||
conn.execute(
|
||||
f"ALTER TABLE {self._events} ALTER COLUMN chain_prev SET NOT NULL"
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self.pool.close()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue