Skip to main content

Inventory. Revenue. Vendors. Supply chain. Cash flow. Cross-system failures are hiding across all of it. Sign up — your critical intelligence tabs are waiting.

All Articles
Security & Compliance 9 min read 2026-07-07

SOC 2 Hash-Chained Audit Logs: How to Prove ERP Writeback Integrity

DuluthPath Platform Team

Published 2026-07-07

# SOC 2 Hash-Chained Audit Logs: How to Prove ERP Writeback Integrity

Every buyer who's ever survived a SOC 2 audit knows the drill: the auditor asks who did what, when, and can you prove nobody edited the log afterwards. For a platform that reads from a customer's ERP, "we logged it" is usually enough. The moment your platform can *write* to that ERP — post a PO, change payment terms, close a duplicate vendor — the bar changes. "We logged it" becomes "prove it's the exact record that was written 90 days ago."

The append-only fiction

Most audit logs are a `created_at` column, an actor, an action, and a payload — dumped into whatever table happens to be handy. Query it, sort it, export it, done. The problem: nothing in that shape is actually tamper-evident. A DBA with write access can `UPDATE` a row, backfill a `created_at`, or delete a whole slice. The log looks fine. The auditor's only recourse is to trust the DBA.

We wanted something better: a log where any tampering is mathematically obvious, even to an auditor who doesn't know MongoDB.

The hash chain

The pattern is 40-year-old computer science — Merkle trees, blockchains, git — but its implementation for an ERP writeback log is small enough to fit in a single file. Each `action_audit_logs` document carries five append-time fields the writer never gets to touch:

``` { tenant_id, actor_email, action, resource_type, resource_id, payload, timestamp, prev_hash, # sha256 of the previous row in this tenant's chain current_hash, # sha256(prev_hash + canonical(payload) + timestamp) } ```

The math has two parts:

  1. 1`canonical(payload)` — a deterministic JSON serialization: keys sorted lexicographically, no floats coerced through Python's default repr, timestamps as ISO-8601. Same input always produces the same string. This matters because `sha256({"a":1,"b":2})` and `sha256({"b":2,"a":1})` are different hashes, and Python dicts pre-3.7 didn't guarantee insertion order.
  2. 2`current_hash = sha256(prev_hash + canonical + timestamp)` — chains this row to the previous one. Change any byte anywhere in the chain and every subsequent hash breaks.

We store the hashes as hex strings — indexable, human-readable, and small enough that Mongo doesn't blink at millions of rows.

Why this actually works for auditors

Auditors don't want to trust your platform. They want a verifier they can run themselves. The chain gives them one. Take the exported PDF, re-hash every row, compare against `current_hash`. Mismatch = tampered. No mismatch = clean.

We ship this verifier alongside the PDF export. It's 30 lines of Python. An auditor can compile it on their laptop, run it against our export, and walk away certain that nothing between the `created_at` on row 1 and the `current_hash` on row N,847,203 has been touched.

Failure modes we hit early

Clock drift. If two processes write to the same tenant's chain and one has a slower clock, the row with the "later" hash can land first. Our fix: an atomic `find_one_and_update` that pulls the last row's `current_hash`, computes the new one, and inserts — all in one Mongo transaction. If two writers race, one loses and retries. Nobody skips a link.

Payload floats. JavaScript's `JSON.stringify({a: 0.1 + 0.2})` returns `"0.30000000000000004"`. Python's returns `"0.3"`. Round-trip through both and you break the chain. Fix: coerce numbers to strings with fixed precision *before* hashing. Ugly, deterministic.

Retroactive schema changes. Adding a field to `payload` months later would change the canonical serialization of old rows if we naively re-canonicalized on read. Fix: hashes are computed once at write time and *never* re-hashed. If we need to add a field, it goes on rows created after the schema change — old rows keep their original hashes forever.

What we log

Every ERP writeback lands in the chain:

  • Inventory transfers — NetSuite `inventoryTransfer`, SAP `A_MaterialDocument` movement type 311, QBO fallback PO
  • AP terms changes — QBO `Vendor` sparse-update, NetSuite `Vendor` PATCH
  • Supplier corrective actions (SCARs) — the workflow object doesn't hit the ERP but the audit chain still records who initiated it
  • All admin writes — role changes, key rotations, tenant configuration updates

The full envelope of what the writer sent, what the ERP responded with, and the exact user (or agent) who authorized it — every field, hashed.

What we intentionally don't log

Read paths. A read has no side effect, and logging every SELECT would drown the useful signal in a swamp of noise. The industry norm here is fine.

Payloads that carry PII. Customer names on a PO are fine; social security numbers on a healthcare record aren't. Where we can't guarantee downstream handling, we hash the payload separately and store only the hash — the auditor can prove the chain is intact without seeing the underlying data.

The PDF export

The final piece: a `reportlab`-generated PDF that renders the chain as human-readable rows *and* embeds the hashes as a machine-readable JSON block at the end. The verifier reads the JSON, walks the chain, prints "clean" or "tampered at row K." Simple enough that a CFO can hand it to a Big 4 auditor without a meeting.

Every one of our subscribers with the `compliance` module gets this export on demand from `/compliance` — no ticket, no email, no wait. Two clicks, one PDF, one clean audit trail.

SOC 2AuditHash ChainERP WritebackCompliance

See DuluthPath in action

Book a personalized demo and explore how AI-powered decision intelligence transforms enterprise operations.