Recipe 60: Tenant Audit Dashboard From the Typed Chain
Situation
A tenant operator wants a dashboard view of their workload’s audit trail without re-implementing the verification surface or trusting an upstream collector. They want to see:
- Counts by event kind — how many
LeaseAllocated,LeaseRevoked,Preempted,EdgeRewritten, etc., happened in this window. - Lease revoke transitions — for each fenced lease, the
ordered walk through
RevokeState(active → revoke_warning → grace_running → … → torndown / fenced). - Recent admissions — a capped tail of the most-recent lease lifecycle records so the dashboard table doesn’t grow without bound.
…and they want the dashboard to refuse to advance state if any record fails chain verification. No partial views on tampered streams.
In grafOS, the audit chain is one shared resource. A tenant dashboard filters records to its own tenant identity (skipping — not dropping — records belonging to other tenants), verifies the linkage against a persisted anchor, then projects the filtered records into the three views above.
What You Build
A TenantDashboard<A: AnchorStore> that:
- Owns the reference
Collectorfrom Recipe 55 for the verify + anchor-advance discipline; - Carries a
tenant_filter(the tenant name the dashboard represents); - Projects every chain record into a typed
DashboardSnapshot { counts_by_kind, revoke_transitions, edge_mutations, recent_admissions }; - Caps
recent_admissionsat a builder-configurable size (default 100) so the dashboard stays bounded under high churn; - Skips records carrying a different tenant identity from the projection — but those records ARE still verified, because the chain is a shared resource.
The compiled recipe lives in
cookbook/recipe-60-tenant-audit-dashboard.
Core grafOS API Path
use grafos_audit::{AuditEventData, AuditRecord};use grafos_audit_collector::Collector;use grafos_core::{AuditEventKind, RevokeState, WorkloadIdentity};
// For every record the collector verified:for record in &records { if record.identity.tenant != "acme" { continue; // not this dashboard's tenant }
// Count by typed kind. *counts.entry(record.kind).or_insert(0u64) += 1;
// Surface revoke-state transitions. if let Some(AuditEventData::RevokeStateTransition { lease_id, from, to }) = record.event_data.as_ref() { println!( "lease {lease_id:#034x}: {} -> {}", from.as_str(), // "active" / "revoke_warning" / "grace_running" / ... to.as_str(), ); }
// Surface edge mutations. if let Some(AuditEventData::EdgeRewritten { edge_id, .. }) = record.event_data.as_ref() { println!("edge_rewritten: edge_id={edge_id:#034x}"); }}Program
use cookbook_recipe_60_tenant_audit_dashboard::TenantDashboard;use grafos_audit::FileAnchorStore;use std::fs::File;use std::io::BufReader;
let anchor = FileAnchorStore::load_or_unanchored("/var/lib/dashboard/acme.anchor")?;let mut dashboard = TenantDashboard::new(anchor, "acme") .with_recent_admissions_cap(50);
let f = File::open("/var/log/audit/today.jsonl")?;dashboard.ingest_jsonl(BufReader::new(f))?;
let snap = dashboard.snapshot();for (kind, count) in &snap.counts_by_kind { println!("{}: {count}", kind.as_str());}for t in &snap.revoke_transitions { println!( "lease {:#034x} @ seq={}: {} -> {}", t.lease_id, t.sequence, t.from, t.to, );}for row in &snap.recent_admissions { println!("seq={} {}", row.sequence, row.kind.as_str());}# Ok::<(), Box<dyn std::error::Error>>(())Design
The dashboard separates two responsibilities the recipe author can adopt independently:
- Verification discipline — comes from the reference
Collector(Recipe 55 /crates/grafos-audit-collector). The dashboard does not re-implement chain walking; it delegates toCollector::ingest_recordsand inherits the fail-closed semantics (parse error / anchor mismatch / linkage failure all leave the anchor and the projection unchanged). - Projection — typed mapping from each
AuditRecordinto the three views. Lives inproject()and is the easy thing to fork. A different tenant might want different views (per-edge timelines, per-resource heat maps, geographic breakdowns).
The projection uses typed enums all the way down: the
counts_by_kind is keyed on AuditEventKind (not strings), the
revoke-transition from / to are sourced from RevokeState::as_str(),
and recent_admissions carries the typed kind for downstream
filters. A new variant added to AuditEventKind or RevokeState
in grafos-core flows through this recipe without code change
(except to surface the new variant explicitly in a UI).
The tenant filter is skip-not-fail. Records belonging to
other tenants verify (because the chain is one shared resource)
but don’t enter this dashboard’s projection. A multi-tenant
process running N dashboards builds N TenantDashboard instances
sharing a single chain stream and lets each one filter.
The capped recent_admissions uses ordered insert + trim-front
so iteration order matches source order. The default cap of 100
matches the scheduler-side admission-log ring buffer; production
callers tighten or loosen via with_recent_admissions_cap.
Failure Modes
- Chain linkage failure (tamper detected, anchor mismatch,
malformed JSONL): the
Collectorrejects the batch; the dashboard returnsDashboardError::Ingest(IngestError::*). The snapshot is unchanged and the anchor does NOT advance. - Records for the wrong tenant: skipped silently from the projection. They are still verified — chain integrity does not depend on which tenant a record belongs to.
- Recent-admissions cap exceeded: the dashboard trims front- most entries to stay at the cap. Trim is FIFO, preserving the latest-N invariant.
- Unknown event kind in a future build: the typed
AuditEventKindenum is closed-set at compile time. A future variant addition surfaces as an unmatched arm inproject()— the recipe explicitly enumerates the lease-lifecycle subset, so unknown kinds get the implicit_ => {}(no row added) until someone updates the projection.
Tests
Run it with:
cargo test -p cookbook-recipe-60-tenant-audit-dashboardFive tests cover counts-by-kind aggregating for the filter tenant
only (other tenants’ records verified but skipped from projection),
revoke transitions surfaced in source order with snake_case from /
to strings, edge-mutation rows surfaced with their edge_id, the
recent-admissions cap applied with the most-recent-N retained, and
chain-tamper rejection leaving the snapshot untouched.
Adaptation Notes
- Other projected views: this recipe shows three views; the
pattern extends. Add a
per_edge_timeline: HashMap<u128, Vec<...>>if you want per-edge mutation history, or aper_resource_heatmap: HashMap<ResourceKind, ...>for a resource-pressure dashboard. Each new view is a few lines inproject(). - Multi-tenant operator console: spawn N
TenantDashboardinstances and route the same chain stream to each. The collector dedup is per-instance — be aware that one unverified record blocks all dashboards reading the same chain (this is correct: the chain is a shared resource). - Persistence: the recipe doesn’t persist the snapshot itself — only the anchor. Restart-time recovery rebuilds the snapshot from the chain. Production callers wanting fast restart persist the snapshot to a side store keyed by anchor hash; on restart, load the snapshot iff the anchor matches.
- Decoded EdgeRecord: this recipe surfaces only the
edge_idfromEdgeRewritten. Pair with Recipe 55’sEdgeRecord::decodeto surface the full typed fields (src_port, dst_port, protocol, features, etc.) in the edge-mutation view. - Live tail: this recipe ingests batches. A live-tail mode
using
BufReaderover an open append-only file works for small streams; for high volume use a tailing library that reads incrementally and callingest_jsonlper new chunk.
See also:
- Recipe 55 — Consuming the Audit Chain (the underlying pipeline).
crates/grafos-audit/src/lib.rs—AuditRecord,AuditEventKind,AuditEventData,RevokeState.crates/grafos-audit-collector— the reference collector.docs/operations/siem-vocabulary-cookbook.md— SIEM queries for the kinds + revoke states surfaced here.docs/spec/audit-chain-canonical-bytes.md— authoritative wire shape for the records the dashboard consumes.