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.