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.

Access-log entries chained by SHA-256 so any edit breaks the chain Four log blocks in a row. A genesis block seeds the chain. Each later block stores actor, action, resource and timestamp plus a SHA-256 hash taken over its payload and the previous block's hash. Arrows labelled hash carry each block's hash into the next block's previous-hash field. A verification pass along the bottom recomputes and compares every hash, flagging the first mismatch as the point the chain breaks. Genesis Entry 1 Entry 2 Entry 3 seed prev = 0 no payload prev_hash actor · action resource · ts prev_hash actor · action resource · ts prev_hash actor · action resource · ts sha256 sha256 sha256 sha256 hash hash hash verify(): recompute each sha256 over (prev_hash + payload) and compare to stored → first mismatch = chain broken
Every entry folds the previous entry's digest into its own SHA-256 hash, so the log is a chain rather than a heap of rows. Editing, reordering, or deleting any entry changes a downstream digest, and the bottom verification pass names the first block where the recomputed hash stops matching.

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 verify serializes fields in a different order, formats the timestamp differently, or lets a Decimal render as 10 versus 10.00, every recomputed hash misses and the log reports false tampering. Freeze canonical_payload and cover it with a golden-vector test.
  • Never float for dollar_impact. A logged impact of 1250.10 stored through a float can serialize as 1250.0999999999999, producing a hash that no honest verifier reproduces. Quantize to cents as Decimal before hashing, consistent with the Python decimal module guidance.
  • Concurrency at the tail. Two appends that both read the same tail_hash will 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_at is only as trustworthy as the host clock. Use a synchronized UTC source, and lean on sequence and prev_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.