Auditing CAM Data Access with Immutable Logs
When a tenant disputes a five-figure CAM true-up and asks who touched the recoverable pool between the estimate and the final statement, an ordinary database table is not proof: any principal with UPDATE on the log can rewrite history, and a deleted row leaves no scar. This page builds a tamper-evident access log for CAM reconciliation data — an append-only record where every entry is cryptographically chained to the one before it, so a single altered or removed row breaks the chain and is detectable in seconds. It is the audit counterpart to CAM reconciliation security and access controls: where that model decides whether an actor may read or mutate a lease’s expense records, this one records that the decision happened and makes the record impossible to quietly edit after the fact. The result is a ledger you can hand to an auditor, a disputing tenant, or opposing counsel with a one-line integrity claim behind it.
Context & When to Use This Approach
A plain audit table answers what we chose to log; a hash-chained log answers what actually happened, and nobody edited it afterward. The difference matters the moment your access record becomes evidence. CAM reconciliation produces exactly those moments: a tenant challenges a controllable-cap calculation, a landlord’s counsel needs to show the recoverable pool was never touched after the statement was cut, or a SOC 2 assessor asks you to demonstrate that log entries cannot be silently rewritten by an administrator. In all three, the credibility of the answer rests on the log being append-only and tamper-evident, not merely present.
Reach for this build when the audit trail protects something with real money or legal weight behind it — access to reconciled expense pools, overrides on a tenant’s pro-rata share, exports of a statement that will be billed. It layers directly on top of the enforcement layer described in setting up role-based access for CAM data: that layer produces an allow-or-deny verdict on every request, and this one is where each verdict — including the denials — is written so it cannot later be disowned. The chain does not replace database backups, an append-only storage tier, or write-once media; it is a cheap, self-contained integrity proof that makes tampering detectable even when an attacker has database write access. What it deliberately does not do is prevent a determined administrator from truncating the whole table — for that you still want off-box replication and periodic external anchoring, covered in the limitations below.
Step-by-Step Implementation
The build is one small, dependency-light module: a typed entry model, a hashing rule that binds each entry to its predecessor, an append operation that is the only way to add a row, a verifier that walks the chain, and a retention policy that trims without breaking the proof. Every hash uses hashlib SHA-256 over a canonical serialization, and every monetary field travels as Decimal so a logged dollar impact is exact.
Step 1 — Model an append-only access-log entry. Each entry captures who did what to which resource when, plus the two hash fields that make it a link in a chain. The model is frozen: once constructed, an entry cannot be mutated in place, which mirrors the append-only intent in code.
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from decimal import Decimal
from enum import Enum
class AuditAction(str, Enum):
READ_STATEMENT = "read_statement"
OVERRIDE_ALLOCATION = "override_allocation"
EXPORT_STATEMENT = "export_statement"
APPROVE_RECONCILIATION = "approve_reconciliation"
ACCESS_DENIED = "access_denied" # denials are logged too
@dataclass(frozen=True)
class AccessLogEntry:
"""One immutable record of access to a CAM reconciliation resource."""
sequence: int # 0 for genesis, then monotonic
actor_id: str # principal that acted
action: AuditAction # what was attempted
resource: str # e.g. "lease:LSE-4471/recoverable_pool"
property_id: str # lease scope touched
dollar_impact: Decimal # exact impact, 0.00 for reads
occurred_at: datetime # UTC timestamp of the event
prev_hash: str # digest of the previous entry
entry_hash: str = field(default="") # digest of THIS entry (set on append)
Step 2 — Hash-chain each entry with hashlib. The digest is taken over a canonical, ordered serialization of the entry’s fields plus the previous entry’s hash. Because prev_hash is an input, altering any earlier entry changes every hash that follows it. Serializing deterministically — fixed field order, ISO-8601 UTC timestamps, Decimal rendered as a plain string — is what makes a recomputed hash reproducible byte-for-byte.
import hashlib
def canonical_payload(entry: AccessLogEntry) -> str:
"""Deterministic string over the fields the hash must protect.
Order and formatting are fixed so verify() reproduces the exact bytes.
entry_hash itself is excluded — it is the output, not an input.
"""
return "\x1f".join([
str(entry.sequence),
entry.actor_id,
entry.action.value,
entry.resource,
entry.property_id,
f"{entry.dollar_impact:.2f}", # exact cents, no float
entry.occurred_at.astimezone(timezone.utc).isoformat(),
entry.prev_hash,
])
def compute_hash(entry: AccessLogEntry) -> str:
"""SHA-256 over the canonical payload, hex-encoded."""
return hashlib.sha256(canonical_payload(entry).encode("utf-8")).hexdigest()
Step 3 — Record actor, action, resource and timestamp on append. A single append method is the only supported way to grow the log. It reads the current tail’s hash, stamps a UTC timestamp, computes the new digest, and returns a fully sealed entry. Callers never set prev_hash or entry_hash themselves — that is what keeps the chain honest.
GENESIS_PREV = "0" * 64 # fixed seed so entry 0 is well-defined
class AccessLog:
"""An in-memory append-only log; back it with an append-only store."""
def __init__(self) -> None:
self._entries: list[AccessLogEntry] = []
@property
def tail_hash(self) -> str:
return self._entries[-1].entry_hash if self._entries else GENESIS_PREV
def append(
self,
actor_id: str,
action: AuditAction,
resource: str,
property_id: str,
dollar_impact: Decimal = Decimal("0.00"),
) -> AccessLogEntry:
"""Seal one access event onto the end of the chain."""
draft = AccessLogEntry(
sequence=len(self._entries),
actor_id=actor_id,
action=action,
resource=resource,
property_id=property_id,
dollar_impact=dollar_impact.quantize(Decimal("0.01")),
occurred_at=datetime.now(timezone.utc),
prev_hash=self.tail_hash,
)
sealed = AccessLogEntry(**{**draft.__dict__, "entry_hash": compute_hash(draft)})
self._entries.append(sealed)
return sealed
Wire this call into the enforcement layer’s decision point so that every verdict is recorded — an OVERRIDE_ALLOCATION that succeeded and an ACCESS_DENIED that was refused are equally interesting to an auditor. Logging the denials is what lets you later prove that an out-of-scope actor tried and failed, rather than leaving a silent gap.
Step 4 — Verify the chain to prove it is tamper-evident. Verification walks the entries in order, checks that each prev_hash matches the actual previous digest, and recomputes each entry_hash from scratch. The first entry whose recomputed hash differs from its stored value is the exact point where the log was altered, reordered, or had a row removed.
from typing import NamedTuple
class ChainStatus(NamedTuple):
intact: bool
broken_at: int | None # sequence of the first bad entry, or None
detail: str
def verify(entries: list[AccessLogEntry]) -> ChainStatus:
"""Recompute every link; report the first break, if any."""
expected_prev = GENESIS_PREV
for entry in entries:
if entry.prev_hash != expected_prev:
return ChainStatus(False, entry.sequence, "prev_hash link broken")
if compute_hash(entry) != entry.entry_hash:
return ChainStatus(False, entry.sequence, "entry content was altered")
expected_prev = entry.entry_hash
return ChainStatus(True, None, f"{len(entries)} entries verified")
Step 5 — Retain and anchor without breaking the proof. CAM records outlive a single reconciliation cycle, so retention has to trim old entries while keeping the surviving chain verifiable. The move is to segment by period rather than delete individual rows: seal each closed segment by publishing its final entry_hash as an external anchor, then start the next segment from that anchor instead of the zero seed. A verifier for a later segment only needs the trusted anchor of the one before it.
def seal_segment(log: AccessLog) -> str:
"""Return the tail hash to publish as this period's external anchor."""
status = verify(log._entries)
if not status.intact:
raise ValueError(f"refusing to seal a broken chain at {status.broken_at}")
return log.tail_hash # write to WORM storage / a notarized record
def start_next_segment(anchor: str) -> AccessLog:
"""Begin a fresh segment whose genesis prev_hash is the prior anchor."""
log = AccessLog()
log._entries.append(AccessLogEntry(
sequence=0,
actor_id="system",
action=AuditAction.READ_STATEMENT,
resource="segment:anchor",
property_id="-",
dollar_impact=Decimal("0.00"),
occurred_at=datetime.now(timezone.utc),
prev_hash=anchor, # chains to prior period
entry_hash="",
))
tail = log._entries[0]
log._entries[0] = AccessLogEntry(**{**tail.__dict__, "entry_hash": compute_hash(tail)})
return log
Publishing each segment’s anchor somewhere you cannot later edit — write-once storage, a signed release note, or a public timestamping service — is what stops the “just truncate the table” attack: an adversary can drop the rows, but they cannot forge a chain that ends at the anchor you already committed.
Gotchas & Known Limitations
- Detection, not prevention. The chain makes tampering visible; it does not stop a principal with storage access from deleting rows. Pair it with an append-only or write-once storage tier and off-box replication so the evidence survives the same actor you are auditing.
- Anchor externally or the chain is self-referential. A chain whose only copy lives in the same database an attacker controls can be recomputed wholesale — they rewrite an entry and every downstream hash to match. Publishing periodic anchors to storage the attacker cannot rewrite is what turns a hash chain into real evidence.
- Canonical serialization is load-bearing. If
verifyserializes fields in a different order, formats the timestamp differently, or lets aDecimalrender as10versus10.00, every recomputed hash misses and the log reports false tampering. Freezecanonical_payloadand cover it with a golden-vector test. - Never
floatfordollar_impact. A logged impact of1250.10stored through afloatcan serialize as1250.0999999999999, producing a hash that no honest verifier reproduces. Quantize to cents asDecimalbefore hashing, consistent with the Pythondecimalmodule guidance. - Concurrency at the tail. Two appends that both read the same
tail_hashwill fork the chain. Serialize appends behind a single writer or a per-log advisory lock, and make(sequence, prev_hash)a unique constraint so a fork fails loudly instead of silently branching. - Timestamps are attestations, not truth.
occurred_atis only as trustworthy as the host clock. Use a synchronized UTC source, and lean onsequenceandprev_hash— not the timestamp — for ordering.
Verification
Prove the chain does its job the way an attacker would test it: build a valid log, confirm it verifies, then mutate it and confirm the tamper is caught at the right position.
from decimal import Decimal
def build_log() -> AccessLog:
log = AccessLog()
log.append("acct-1", AuditAction.READ_STATEMENT,
"lease:LSE-4471/statement", "prop-A")
log.append("acct-1", AuditAction.OVERRIDE_ALLOCATION,
"lease:LSE-4471/recoverable_pool", "prop-A", Decimal("12500.10"))
log.append("svc-export", AuditAction.EXPORT_STATEMENT,
"lease:LSE-4471/statement", "prop-A")
return log
def test_intact_chain_verifies() -> None:
status = verify(build_log()._entries)
assert status.intact and status.broken_at is None
def test_altered_amount_is_detected() -> None:
log = build_log()
tampered = AccessLogEntry(**{
**log._entries[1].__dict__,
"dollar_impact": Decimal("125.10"), # silently shave the override
})
entries = list(log._entries)
entries[1] = tampered
status = verify(entries)
assert not status.intact and status.broken_at == 1
def test_deleted_entry_breaks_the_link() -> None:
log = build_log()
entries = [e for e in log._entries if e.sequence != 1] # drop the middle row
status = verify(entries)
assert not status.intact
The middle test is the one that matters: shaving a logged override from 12,500.10 to 125.10 — precisely the edit a bad actor would attempt — is caught at broken_at == 1 because the recomputed digest no longer matches the stored one. Run these against the frozen canonical_payload on every change, and add a golden-vector test that pins a known entry to a known hex digest so an accidental serialization change surfaces before it ships.
Built this way, the access log stops being a table you hope no one edited and becomes an integrity proof you can defend — the same records that feed tenant statement generation and dispute routing when a tenant challenges a charge. Return to CAM reconciliation security and access controls for the enforcement core whose every verdict this chain records, and continue to setting up role-based access for CAM data for the decision layer that produces those verdicts.
Related
- CAM reconciliation security and access controls — the parent model whose access decisions this tamper-evident log is designed to record and defend.
- Setting up role-based access for CAM data — the enforcement layer that produces the allow-and-deny verdicts written into the chain.
- Tenant statement generation and dispute routing — where a defensible access trail becomes evidence when a tenant disputes a reconciled charge.