Controlling "who can write records, and who can read them" is the job of permission management. And one step further — "should we also record who read the records?" is the meta-audit question. This chapter covers both.
RBAC controls emit/query/administer per role, and read access itself is recorded in a separate table — reading the audit log is itself audited.
RBAC: start with deny-by-default
Quipu-Log's permission model is concise: three Actions.
| Action | What it allows | Who needs it |
|---|---|---|
Emit | Write audit events | API servers, batch jobs — anything that produces records |
Query | Read recorded logs | Auditors, compliance teams |
Administer | Schema changes, DLQ redrive, retention runs, integrity verification | Operations teams, admins |
These three actions are mapped to Roles. The key design choice is deny-by-default — a role that hasn't been explicitly granted a permission can do nothing. The opposite, allow-by-default, is convenient for development but is not recommended in production.
crates/quipu-middleware/src/permissions.rslet policy = PermissionPolicy::deny_by_default()
.grant(Role::new("auditor"), &[Action::Query])
.grant(Role::new("service"), &[Action::Emit])
.grant(Role::new("admin"), &[Action::Emit, Action::Query, Action::Administer]);
// Unknown roles can do nothing
assert!(!policy.is_allowed(&Role::new("intruder"), Action::Query));
PermissionPolicy is configured once when the pipeline starts, and every subsequent AuditHandle method checks it automatically — no extra calls needed.
pre/post filters: exempt requests or enrich events
If permissions control "who," filters control "what" at a finer grain. Two kinds of filters are registered in a FilterSet.
- pre-filter: runs before the request reaches the inner service. Exempts requests that don't need auditing (health checks, internal pings) with a
Skipdecision. Because it runs before the body is read, the cost is low. - post-filter: runs after the response is produced. Can decide based on the response status code (
304 Not Modifiedmay not need auditing), or can directly mutate the event struct to add fields (event enrichment).
crates/quipu-middleware/src/filter.rs// post-filter: enrich or exempt based on the response
pub type PostFilter =
Box<dyn Fn(&RequestInfo, &ResponseInfo, &mut AuditEvent) -> FilterDecision + Send + Sync>;
The key point is that the post-filter receives &mut AuditEvent. You can look at the response and attach fields to the event — for example, "if the response is 403, tag a custom field with 'forbidden'."
Meta-audit: recording who read the audit log
HIPAA and financial regulations often require auditing "who accessed patient records." Quipu-Log supports this through meta-audit.
Two key design decisions in meta-audit are worth remembering.
① A separate table. Access records accumulate in root/access/. Because it's independent of the main log table (root/logs/), it can have its own retention policy and it's invisible to the main log query API.
② No self-referential loop. If "recording an access" triggered another "access event," you'd have infinite recursion. Quipu-Log blocks this structurally — record_access is a plain append that never goes through the query path. Reading the access log is recorded as an access event, but that recording in turn does not produce another access event.
crates/quipu-core/src/access.rs (comment excerpt)// No self-reference loop: recording an access is a plain table append —
// it never goes through a query path, so it can never trigger another
// access record. Querying the access log *is* an access and is recorded
// (exactly one record per query), but that recording again is just an
// append: growth is strictly one record per externally-initiated
// operation, never recursive.
Search query values (probe values) are never stored in the access record. When searching a field protected by HMAC or RSA, having that query value appear in plain text in the access log would defeat the field protection entirely. The access log records only the shape of the search — which field, which matching mode, which time range — never the value itself.
Querying the access log requires Action::Administer — because "who accessed what" is itself sensitive information.
① Why is deny-by-default safer than allow-by-default for production?
② Why are search query values never stored in access records?
③ Explain why the access log must be in a separate table from the main log, from a retention policy perspective.