ADR-0030: The audit log, read by type and by actor¶
Status: Accepted Date: 2026-10-12 Deciders: Alex Nodeland
Context¶
portalr's admin shows a tenant's audit log, filtered by what changed and by who changed it (lattice RFC-0002). grantr's reads of the log took only a sequence number to read after and a page size. A filter in the browser would see only the pages it had loaded, so it would page through a busy log to find one person's changes, and could show an empty page while matching entries waited further on. The maintainer chose to filter in grantr.
Decision¶
- A read of the audit log takes an
AuditFilter:- the event types to keep, or every type when it names none;
by, the one subject whose changes to keep, or anyone's when it's None.- It keeps paging by
after_seq: a page's last sequence number reads on from there, with the same filter.
- The subject is the subject itself, as an entry's
byholds it: a user, a service principal, a rule principal by its workspace and rule, or the agent. An entry is kept when its maker equals it, so a person and a principal with the same id stay apart, and so do two rules whose workspaces and names would read alike joined together, such as (a,b/c) and (a/b,c). The first operator's grant, which no one made, is kept only by a filter that names no one. AuditTypenames every event'stype, and a test holds it to the events.- Every surface takes it:
Grants.audit(keep=);- the storage port's
audit, and both adapters; - on
GET /auditandGET /tenants/{tenant}/audit:type=, which may repeat, and the subject in structured parameters:actor_kind=(user,service_principal,ruleoragent),actor=(a user's or principal's id, or a rule's name), andworkspace=(a rule's). - The parameters must name one subject. A kind without what it needs, with more than it takes, an id a principal can't have, or an unknown type is a 422.
- In SQL, the type and the maker are columns. The maker is stored as a grant's subject is:
actor_kind,actor_workspaceandactor_id, with""for a part it doesn't have, and""in all three for no one.- They are indexed after the tenant and before the sequence number:
ix_grantr_audit_event_type, andix_grantr_audit_actorover the three. - A single type, or a single subject, is one index's range; a type and a subject together are an index's range and a filter.
appendfills them from the event and its maker.
- They are indexed after the tenant and before the sequence number:
- Migration 0004 adds the columns and fills them from each entry's JSON.
- It works a thousand entries at a time, one batch of updates each, after the last chunk's sequence number, so no cursor stays open while it updates.
- It keeps its own copy of how makers were stored, so it fills what it filled then, whatever the code becomes. It refuses a maker of a kind no entry could have had.
- It runs as the tables' owner, which row-level security doesn't hold, so every tenant's entries are filled.
- It holds
grantr_auditexclusively until it commits, so changes to access wait for it.
- The tests run on memory, SQLite, PostgreSQL and row-level security, as the storage contract does. Every kind of maker is appended and found again. The migration's fill, its refusal and its downgrade with entries present are tested on SQLite and PostgreSQL, in CI's Check job, which has PostgreSQL.
Options considered¶
| Option | For | Against |
|---|---|---|
| Filter in grantr, by indexed columns (chosen) | A page is what was asked for, however busy the log | Four columns, two indexes, and a migration |
| Filter in the browser, over loaded pages | Nothing changes in grantr | A filter sees only what was loaded; a page can come back empty |
| Filter in grantr, inside the JSON | No migration | No index for it; JSON queries differ between SQLite and PostgreSQL |
The maker in a subject's columns, and a Subject to filter by (chosen) |
Unambiguous, as grants are stored; a filter is the subject itself | Three parameters to name one on the API |
One string per maker, such as rule:<workspace>/<rule> |
One parameter | Workspaces and rule names are free strings, so two rules can read alike, and the encoding is frozen into rows |
Consequences¶
- Easier: portalr's admin, and any reader of the log, asks for one type of change or one subject's changes and pages through only those.
- Harder: an event type added later needs its name in
AuditType, which the test enforces. - Harder: upgrading a deployment with a long audit log holds that table for as long as migration 0004 takes.