Articles · Operations & Administration
The audit log that notices
Try out the running example
An audit log that can be quietly rewritten is a liability with a retention policy. The attacker worth worrying about — a hostile DBA, a compromised admin account, your own future self under subpoena pressure — has write access to the database, so “we log admin actions to a table” protects exactly nothing. Sentinel’s admin ledger is built so that rewriting history is possible (it’s your database) but silent rewriting is not: every read re-verifies a hash chain and answers, in the response itself, whether the history you’re looking at is the history that happened.
Two ledgers, one article
Sentinel keeps a dual ledger. SecurityEvent rows are the user-visible activity stream
(“you signed in from Chrome on macOS”) — display-oriented, per-user, shreddable. This
article is about the other half: AdminAuditEntry, the per-realm chain of admin
mutations — who suspended whom, which role gained which grant, what the record looked
like before and after.
The anatomy of an entry
Each entry commits to what happened and to everything before it:
| Field | Meaning |
|---|---|
Sequence |
1-based, dense, per realm — a gap is evidence, not noise |
ActorId / Action / TargetType / TargetId |
who did what to what (user.suspended, role.grant_added, …) |
BeforeJson / AfterJson |
the mutation’s before/after images |
PreviousHash |
the previous entry’s EntryHash; entry 1 carries the 64-zero AdminAuditChain.GenesisHash |
EntryHash |
SHA-256 over a canonical pre-image of all of the above |
The subtle decision is in the pre-image: it includes the SHA-256 digests of the payloads, not the payload bytes. That one choice is what makes redaction possible later — hold that thought.
Appending is a side effect, not a discipline
Nothing asks admin code to “remember to audit”. The mutations in
SentinelAdminService — the same surface behind MapSentinelAdmin() from
article 005 — append as part of the
operation. The sample generates history with two ordinary HTTP calls:
// file: LedgerTestHost.cs
(await SendAsync(adminBearer, HttpMethod.Post,
$"/sentinel-admin/orgs/{LedgerWorld.OrgId}/users/{LedgerWorld.TargetId}/suspend"))
.EnsureSuccessStatusCode();
(await SendAsync(adminBearer, HttpMethod.Post,
$"/sentinel-admin/orgs/{LedgerWorld.OrgId}/users/{LedgerWorld.TargetId}/reactivate"))
.EnsureSuccessStatusCode();
Reading re-verifies — every time
GET /sentinel-admin/audit?fromSequence=1&limit=50
Authorization: Bearer <access token>
{
"chainIntact": true,
"firstBrokenSequence": null,
"entries": [
{ "sequence": 1, "action": "user.suspended", "actorId": "…", "targetId": "…",
"previousHash": "000…000", "entryHash": "9f2c…" },
{ "sequence": 2, "action": "user.reactivated", "previousHash": "9f2c…", "entryHash": "b71a…" }
]
}
chainIntact is not a stored flag — it’s computed by walking the returned window on
every read: recompute each hash from the stored fields, compare against EntryHash,
check each PreviousHash matches its ancestor, check the payload digests, check the
sequence is dense. Verification costing a few microseconds per entry is the price of
never trusting a cached verdict.
Reading the ledger is its own permission — sentinel:global:audit_read — distinct from
the power to mutate. The sample’s auditor persona can read everything and change
nothing: The_auditor_can_read_the_ledger_but_cannot_mutate pins the 200 on the ledger
and the 403 admin_scope on the mutation, from the same bearer.
Three ways to tamper, three detections
The sample plays the hostile DBA by reaching into the store and editing live entries.
Rewrite a chained field — change what happened:
// Rewriting_a_stored_entry_field_flips_chainIntact
var entries = await host.AuditStore.GetAdminEntriesAsync(LedgerWorld.RealmId, fromSequence: 1, limit: 10);
entries[0].Action = "role.grant_removed";
The recomputed hash no longer matches EntryHash: chainIntact: false,
firstBrokenSequence: 1.
Forge only a payload — the subtler attack. Leave every chained field alone and
rewrite AfterJson to say nothing happened. The chain hash still verifies (the
pre-image committed to the payload digest), and that’s exactly why the digest is
stored and re-checked separately: the forged payload no longer matches its recorded
digest. Rewriting_only_the_payload_is_caught_by_the_digest_not_the_chain_hash pins
firstBrokenSequence: 2.
Delete an entry — dense sequences make absence detectable. AdminAuditChain.Verify
is a pure function over any window, and handing it four entries with the third removed
returns the break at exactly that index
(Deleting_an_entry_is_detected_as_a_sequence_gap).
What a broken chain means is an operational question: the ledger can’t tell you what
the truth was, only that this isn’t it — and where it stops being it. That’s the
pager, the incident, and the reason firstBrokenSequence is in the payload rather than
a log line.
Redaction without amnesia
GDPR erasure and audit immutability look like a contradiction until you remember what the pre-image commits to. Redaction nulls the payloads and keeps everything else — sequence, hashes, digests:
// Redacting_payloads_preserves_the_chain_because_digests_survive
var redacted = await ((IRetentionStore)host.AuditStore)
.RedactAdminAuditPayloadsForUserAsync(LedgerWorld.TargetId);
The chain still verifies — the digests were the commitment, and they survive. The
before/after images are gone ("after": null in the response), but that the mutation
happened, by whom, in this order remains provable. And the door doesn’t swing back:
forging a payload into a redacted entry fails the digest check like any other forgery —
the same test proves both directions. The
compliance page covers how this composes with crypto-shredding.
Where this sits operationally
The chain is per-realm, so tenants’ histories verify independently. The entries land in
whatever IAuditStore you registered — in-memory in the sample, the EF adapter in real
deployments — and the guarantee holds because it doesn’t depend on the store being
trustworthy. For the events that want to leave the building as they happen (reuse
detection, lockouts, risk blocks), the webhook pipeline
is the outbound half; the operations page covers both in reference
form.