The Quipu-Log Book
Part 7 · The write & read paths

32 · Permissions (RBAC), filters, and meta-audit

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.

In one sentence

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.

ActionWhat it allowsWho needs it
EmitWrite audit eventsAPI servers, batch jobs — anything that produces records
QueryRead recorded logsAuditors, compliance teams
AdministerSchema changes, DLQ redrive, retention runs, integrity verificationOperations 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 Skip decision. 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 Modified may 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.

root/logs/ main audit log Merkle chain + checkpoints independent retention policy root/access/ meta-audit table who accessed what and when its own hash chain handle.query(role, q) queries main log query append only (no self-reference)
The main log and the access log are separate tables. The access log is a pure append that bypasses the query path, making a self-referential loop structurally impossible.

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.
Security note

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.

Check yourself

① 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.