LedgerLock is a secure, multi-tenant SaaS application designed to provide isolated tenant environments, secure authentication, role-based access control, and tamper-evident audit logging. The system integrates a hash-linked ledger and Merkle-based verification mechanisms to ensure data integrity and detect unauthorized modifications.
A multi-tenant SaaS application whose audit log is tamper-evident. Access control decides who may read a record; the ledger decides whether anyone can quietly rewrite the record of what happened. Those are different problems, and only the second one needs a chain.
Every SaaS product has an audit log, and every audit log has the same weakness: it lives in the provider's database, and the provider's own administrator can edit it. No amount of tenant isolation, RBAC, or encryption fixes that, because the attacker in this threat model is the person the access control answers to. Here, every security event is hashed, batched into a Merkle tree, and sealed into a block that commits to the hash of the block before it. Rewriting one row now breaks that block's Merkle root and every block hash after it, and the application will say exactly which event stopped matching.
pip install -r requirements.txt
python app.py- Application: http://127.0.0.1:8000
- Interactive API reference: http://127.0.0.1:8000/docs
Runs on macOS, Windows, and Linux. Pure Python plus SQLite, no external services, no network calls, no wallet, no gas.
pytest test_app.py -v67 tests. Each gets its own throwaway database, so the suite is safe to run repeatedly and in any order.
| Account | Workspace | Role |
|---|---|---|
alice@acme.com |
Acme Corporation | admin |
carol@acme.com |
Acme Corporation | user |
dave@globex.com |
Globex Inc. | admin |
bob@globex.com |
Globex Inc. | user |
Password for all four: 1234.
| Layer | What it does |
|---|---|
| Tenant isolation | Every query is filtered by the tenant_id inside the verified JWT, never by anything the client sends |
| Record ownership | You may edit what you created; admins may edit anything in their own workspace |
| RBAC | Deleting records, listing teammates, and sealing blocks are admin-only, and denials are themselves logged |
| Rate limiting | Per-IP and per-account throttling on sign-in and tenant creation |
| Input validation | Pydantic models reject bad input with 422 before it reaches the database |
| Verifiable ledger | Events are hashed, Merkle-batched, proof-of-worked, and hash-linked into blocks |
| Inclusion proofs | Any event yields a standalone proof that can be re-checked without this server |
| Notary | The browser hashes a file locally and sends only the digest, which is sealed into the ledger |
| Tamper detection | Verification recomputes every leaf and root from current data, and names the exact corrupted event |
Run through this in order. The last three steps are the ones that distinguish this from an ordinary audit table.
-
Sign in as
alice@acme.com. The sign-in page shows the real chain: block heights and hashes read live from/api/ledger/headers, an endpoint that needs no authentication because block headers carry no tenant data. -
Create a record. Watch it appear in the audit trail as a pending event with its leaf hash already computed.
-
Show isolation. Open a private window, sign in as
bob@globex.com, and confirm Acme's records are invisible. In/docs, try fetching Alice's record ID with Bob's token:404, not403, because the record does not exist as far as Bob's tenant is concerned. -
Show RBAC. Sign in as
carol@acme.comand click Team. She's refused, and the refusal is written to her own audit trail asRBAC_DENIED. -
Notarise a file. Drop any file onto the Notary screen. Point out that the file never uploads — the browser computes the SHA-256 with the Web Crypto API and only the 64-character digest is sent. Then edit one character of the file and check it again: no match.
-
Open a proof. Click any sealed event in the audit trail. The drawer shows the leaf hash, the sibling path, the Merkle root, and the block hash. Note that other tenants' leaves in the same block stay opaque hashes — a proof reveals membership and nothing else.
-
Break the log. On the Ledger screen, click Tamper with the log. This runs a plain
UPDATEagainstaudit_logsand deliberately does not touch the ledger — exactly what an administrator with database access would do. -
Catch it. The integrity pill in the header flips to Ledger broken and the overview names the event, the block, the hash that was sealed, and the hash the row produces now. Click Undo tampering to restore.
For step 7 you can also do it by hand, which is more convincing than a button:
sqlite3 saas.db "UPDATE audit_logs SET details='Payment declined' WHERE id=5;"Then re-verify in the app.
Leaf. Each audit event is serialised to canonical JSON (sorted keys, no incidental whitespace) and hashed with SHA-256. The row's autoincrement id is inside the hash, so two identical events never produce the same leaf.
Tree. Pending leaves are folded into a Merkle tree, pairing an odd node with itself at each level. The root commits to every leaf, so changing, inserting, or deleting any event changes the root.
Block. The block header holds the index, the previous block's hash, the Merkle root, the event count, the seal timestamp, and a nonce. It is mined until the hash starts with four zeroes. The timestamp is fixed before mining, so the nonce is the only free variable.
Chain. Every block commits to the previous block's hash, starting from a genesis block sealed at first startup. Rewriting block n breaks blocks n+1 onward.
Verification. /api/ledger/verify re-derives everything from the
current database: it recomputes each leaf from the row as it stands now,
rebuilds each Merkle root, re-hashes each header, and re-checks each
link. Nothing is read from a cached "valid" flag — that's what makes the
check meaningful.
A block stores hashes and nothing else. A tenant holding an inclusion
proof learns that their own event is in the tree and learns nothing about
the other leaves. That's why /api/ledger/blocks/{index} expands the
caller's own events and returns everyone else's as bare hashes, and why
block headers can be served unauthenticated.
Stating the gap is a strength in the write-up, not a weakness, so it is worth saying out loud in the presentation:
- It is a permissioned, single-writer ledger. One server writes blocks. Proof-of-work here demonstrates the mechanism and paces sealing; it is not a Sybil defence, because there are no competing miners.
- The last bit of trust has not been removed. An attacker who edits
audit_logsand re-seals every block from that point forward would produce a self-consistent chain. What stops that in a real deployment is publishing block hashes somewhere the provider does not control.chain.anchor_receipt()is the single function where that happens; it currently returns a local receipt and says so. The two real options are an RFC 3161 timestamp from an accredited authority (cheap, court-recognised, still trusts the authority) or a transaction writing the block hash to a public chain (no trusted third party, costs gas, needs key custody). Nothing else in the system changes either way. - It is a single process. The rate limiter is in-memory and SQLite is the store, so neither survives a restart or scales across workers. In production these would be a shared cache and a managed database.
- Auth is hand-rolled JWT plus PBKDF2 rather than a managed identity provider.
app.py FastAPI backend: auth, tenancy, records, notary, ledger API
chain.py The ledger: canonical hashing, Merkle trees, sealing, verification
templates/
login.html Sign-in and workspace creation, with the live chain spine
dashboard.html Overview, records, notary, audit trail, ledger explorer, team, account
test_app.py 67 tests across access control and the ledger
requirements.txt Pinned dependencies
chain.py has no dependency on FastAPI, SQLite, or the web layer. It is
pure functions over hashes, which is why the last seven tests in the
suite can exercise it directly.
| Method | Path | Notes |
|---|---|---|
POST |
/api/login |
Rate limited |
POST |
/api/register |
Creates a tenant and its first admin |
POST |
/api/auth/change-password |
|
GET |
/api/me |
|
GET |
/api/users |
Admin only |
GET |
/api/dashboard |
Tenant-scoped stats plus ledger status |
GET/POST |
/api/records |
|
GET/PUT/DELETE |
/api/records/{id} |
Edit needs ownership or admin; delete needs admin |
GET |
/api/audit |
Tenant-scoped, with leaf hashes and block numbers |
POST |
/api/notary/anchor |
Accepts a digest, never a file |
GET |
/api/notary/documents |
|
POST |
/api/notary/verify |
Returns the inclusion proof |
GET |
/api/ledger/status |
|
GET |
/api/ledger/headers |
Unauthenticated by design |
GET |
/api/ledger/blocks |
|
GET |
/api/ledger/blocks/{index} |
Own events expanded, others as hashes |
POST |
/api/ledger/seal |
Admin only |
GET |
/api/ledger/verify |
Recomputes the whole chain |
GET |
/api/ledger/proof/{event_id} |
Standalone inclusion proof |
POST |
/api/ledger/demo/tamper |
Admin only, demo |
POST |
/api/ledger/demo/restore |
Admin only, demo |
GET |
/api/health |
Unauthenticated |