Enforcing Immutable Audit Fields with Validators
Teams reach for a $jsonSchema validator expecting it to freeze an audit record after insertion, then discover during a penetration test or an assessor walkthrough that a principal with update rights quietly rewrote an actor field and nothing stopped them. This page resolves that exact gap. Sitting under Audit Log Schema Contracts within the Schema Validation for Compliance & Audit Readiness framework, it explains the honest nuance a validator’s marketing glosses over — a validator constrains a document’s shape, not its mutability — and gives you the three cooperating controls that together deliver true immutability: a validator that guarantees the required fields are present and well-typed on insert, a least-privilege role that denies update and remove, and an optional hash chain that makes any out-of-band tampering detectable. The outcome is an audit collection whose fields cannot be silently altered, with a verification you can run on demand and a clear failure and rollback path when a control is misconfigured.
Operational Mechanics and Write-Path Impact
The core mechanic to internalize is that MongoDB evaluates a validator against the resulting document image, never against the delta that produced it. When an update runs, the engine applies the modification, then checks whether the post-update document satisfies $jsonSchema. An $set that rewrites actor: "user:8842" to actor: "user:0001" yields a document that is still perfectly valid — every required field is present, every type is correct — so validation passes and the audit trail is now a lie. No arrangement of required, bsonType, enum, or additionalProperties can express “this field may be written once and never changed,” because those keywords describe a state, and immutability is a property of the transition. The Draft 4 keyword surface MongoDB implements has no per-field write-once constraint, so trying to encode immutability in the schema is a category error.
Immutability is therefore enforced one layer out, at authorization. If the runtime principal that appends events physically lacks the update and remove privileges on the collection, then no valid-looking rewrite is possible in the first place — the mutation is refused with OperationFailure code 13 (Unauthorized) before the validator is even consulted. The validator and the role divide the work cleanly: the validator guarantees that whatever is inserted is structurally complete and correctly typed, and the role guarantees that what is inserted can never afterward be changed or deleted. The three controls stack as follows.
| Control | Layer | What it guarantees | What it cannot do |
|---|---|---|---|
$jsonSchema validator |
Storage-engine write path | Required immutable fields present and correctly typed on every insert | Forbid an update to an existing field |
| Least-privilege role (insert only) | Authorization | No update or remove can execute, so records are physically append-only |
Detect tampering done with elevated privileges |
prev_hash/hash chain |
Application | Any post-hoc edit or deletion breaks chain verification and is detectable | Prevent the edit; only reveal it after the fact |
The validator runs on the write path with the usual cost — a small CPU tax per insert — while the role check is essentially free, resolved from cached privilege metadata. The hash chain adds one indexed find_one per append to fetch the tip hash, which is why the ordering index on the audit collection matters. None of the three depends on the others to function, but only all three together make “immutable audit field” a claim you can defend to an auditor.
Exact Diagnostic Fingerprints and Fast Resolution
Each control fails with a distinct, recognizable signature. Matching on the exact code and message routes the incident to the right fix immediately.
| Signature | Where it appears | Root cause | Resolution |
|---|---|---|---|
OperationFailure code 13 (Unauthorized) on update_one/delete_one |
Any principal attempting a mutation | The append-only role correctly denied the write | Expected and healthy — investigate why a mutation was attempted, do not grant the privilege |
A rewrite of actor or timestamp succeeds silently |
Application, with no error | The runtime role still holds update; the validator passed the valid result |
Revoke update/remove from the role; the validator will never catch this |
WriteError code 121 on insert |
Writer | The event omits a required immutable field or mistypes one | Fix the emitter; read errInfo.details.schemaRulesNotSatisfied for the exact keyword |
Chain verification reports a break at an _id |
Offline verifier | A record was edited or removed out-of-band by a privileged principal | Treat as a tamper event; the broken link localizes exactly which record changed |
To confirm the authorization control is actually the thing stopping mutations — rather than assuming it — attempt a mutation with the runtime principal and assert the refusal:
from pymongo import MongoClient
from pymongo.errors import OperationFailure
# Connect as the least-privilege runtime writer (insert + find only).
client = MongoClient("mongodb://audit_writer:secret@localhost:27017/compliance")
events = client.compliance.audit_events
target = events.find_one(sort=[("timestamp", -1)])
try:
events.update_one({"_id": target["_id"]}, {"$set": {"actor": "user:0001"}})
raise AssertionError("immutability NOT enforced: update succeeded")
except OperationFailure as exc:
assert exc.code == 13 # Unauthorized
print("immutable confirmed: update refused with code 13 before validation.")
Step-by-Step Playbook
The sequence provisions all three controls in dependency order: schema first so inserts are well-formed, role second so those inserts become permanent, chain verification last as the detective backstop.
1. Attach the validator that pins the required immutable fields. This guarantees the fields you intend to freeze actually exist and are typed on every insert — you cannot make a missing field immutable:
db.runCommand({
collMod: "audit_events",
validator: { $jsonSchema: {
bsonType: "object",
required: ["actor", "action", "resource", "timestamp", "prev_hash", "hash"],
properties: {
actor: { bsonType: "string", minLength: 1 },
action: { enum: ["create", "read", "update", "delete", "login", "logout", "export"] },
resource: { bsonType: "string", minLength: 1 },
timestamp: { bsonType: "date" },
prev_hash: { bsonType: "string", pattern: "^[0-9a-f]{64}$" },
hash: { bsonType: "string", pattern: "^[0-9a-f]{64}$" }
},
additionalProperties: false
}},
validationLevel: "strict",
validationAction: "error"
})
// Expected output: { ok: 1 }
2. Create a least-privilege role that grants insert and find but denies update and remove. This is the control that actually makes the records append-only. Grant it to the runtime writer principal only:
db.getSiblingDB("compliance").runCommand({
createRole: "audit_appender",
privileges: [{
resource: { db: "compliance", collection: "audit_events" },
actions: ["insert", "find"] // deliberately no update, no remove
}],
roles: []
})
db.getSiblingDB("compliance").runCommand({
createUser: "audit_writer",
pwd: "secret",
roles: [{ role: "audit_appender", db: "compliance" }]
})
// Expected output: { ok: 1 } for each command
3. Verify the hash chain to catch any tampering done outside those controls. A privileged operator could still edit the collection with an admin role; the chain makes that edit detectable. Recompute each digest in insertion order and confirm every link:
import hashlib, json
def verify_chain(collection) -> bool:
prev = "0" * 64 # genesis
for ev in collection.find(sort=[("timestamp", 1), ("_id", 1)]):
payload = {
"actor": ev["actor"], "action": ev["action"], "resource": ev["resource"],
"timestamp": ev["timestamp"].isoformat(), "request_id": ev["request_id"],
"prev_hash": ev["prev_hash"], "metadata": ev.get("metadata", {}),
}
digest = hashlib.sha256(
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
).hexdigest()
if ev["prev_hash"] != prev or digest != ev["hash"]:
print("tamper detected at _id", ev["_id"])
return False
prev = ev["hash"]
print("chain intact")
return True
Failure Modes & Rollback
Each control has a distinct way to be misconfigured, and each has a bounded recovery.
- The role still holds
update(step 2 done wrong). This is the silent failure that defeats the whole design — mutations succeed with no error, because the validator passes the valid result. There is no diagnostic signature; you must audit the role’sprivilegesarray directly withdb.getRole("audit_appender", { showPrivileges: true })and confirm neitherupdatenorremoveappears. Recovery:revokePrivilegesFromRolefor those actions, effective immediately for new connections. - A legitimate producer is blocked by code 121 (step 1 too tight). If the schema requires a field a valid emitter does not yet send, appends fail and audit coverage silently drops. Do not delete the validator — soft-loosen to
validationAction: "warn"so events still record while you fix the emitter, then restoreerror. - Chain verification reports a break (step 3). A break means a record was altered or removed by something outside the two preventive controls — almost always a human with an admin role. Preserve the collection state, snapshot it for forensics, and treat the localized
_idas the tamper origin; do not “repair” the chain, because that destroys the evidence.
The rollback for an over-tight validator is a single metadata-only collMod, and it never touches data:
// Soft loosen — keep the contract logging while you fix a blocked producer.
db.runCommand({ collMod: "audit_events", validationAction: "warn" })
// Restore enforcement once the emitter is corrected.
db.runCommand({ collMod: "audit_events", validationAction: "error" })
Both propagate through the oplog, so time-to-recover is the lag of your slowest secondary — seconds in a healthy replica set. The authorization control has no rollback command because it should never be relaxed: if a mutation is genuinely required, it belongs in a new corrective audit event, not an edit to an existing one.
Frequently Asked Questions
If the validator already checks the fields, why do I still need a restricted role?
Because the validator only tests the resulting document, not the change. An update that overwrites actor with another valid string produces a document that still satisfies every keyword, so validation passes and the edit succeeds. The only way to prevent the edit is to remove the update and remove privileges from the principal, so the mutation is refused with code 13 before validation is ever reached. The validator and the role guard different things and neither substitutes for the other.
Can I express a write-once or immutable field directly in $jsonSchema?
No. MongoDB's $jsonSchema implements JSON Schema Draft 4 semantics, which describe the allowed state of a document — required fields, types, enums, additionalProperties — but have no keyword for constraining a transition such as write-once. Immutability is a property of the update, not of any single document image, so it must be enforced by authorization rather than schema. Attempting to encode it in the validator is a category error.
What does the hash chain add if the role already blocks updates?
The role stops the normal runtime principal, but a database administrator or a compromised superuser still holds privileges that bypass it. The prev_hash to hash chain does not prevent such an edit — it makes it detectable, because recomputing the digests in order will reveal a broken link exactly at the altered or deleted record. That turns silent tampering by a privileged actor into a verifiable, localizable event, which is what an assessor wants to see.
Related
- Audit Log Schema Contracts — the parent guide to the append-only collection contract and the hash-chained writer this page secures.
- Schema Validation for Compliance & Audit Readiness — the framework tying validation contracts to audit and compliance evidence.
- Security Boundaries in Schema Design — the broader least-privilege and injection-hardening posture this role grant is part of.
- SOC 2 Evidence from Validation Telemetry — turning the rejection and authorization signals from these controls into audit evidence.