@@ -5,11 +5,14 @@ System audit-trail objects for ObjectStack: the immutable `sys_audit_log` ledger
55
66## What this package actually does
77
8- ` AuditPlugin ` does two things when the kernel starts it:
8+ ` AuditPlugin ` does three things when the kernel starts it:
99
10101 . ** Registers three system objects** — ` sys_audit_log ` , ` sys_activity ` , ` sys_comment ` .
11112 . ** Installs ObjectQL hook subscribers** that write ledger and activity rows on data
1212 mutations, plus record-level access gates for ` sys_comment ` .
13+ 3 . ** Installs the record-view writer** — an ` afterFind ` hook that records ` read ` rows for
14+ the objects a deployment opted in, and is not installed at all when nothing is opted
15+ in. See [ Record-view auditing] ( #record-view-auditing--the-read-action ) .
1316
1417⚠️ ** There is no audit service you call to log a record change.** Record-level audit rows
1518are produced by ` afterInsert ` / ` afterUpdate ` / ` afterDelete ` hooks, not by application
@@ -24,16 +27,39 @@ pnpm add @objectstack/plugin-audit
2427
2528## Usage
2629
27- The plugin is a class, registered on the kernel. It takes no configuration:
30+ The plugin is a class, registered on the kernel. Write and activity auditing take no
31+ configuration at all:
2832
2933``` typescript
3034import { AuditPlugin } from ' @objectstack/plugin-audit' ;
3135
3236await kernel .use (new AuditPlugin ());
3337```
3438
35- That is the whole setup surface. Coverage is not configured per object — see
36- [ Coverage] ( #coverage-subtraction-not-an-allow-list ) .
39+ Write coverage is not configured per object — see
40+ [ Coverage] ( #coverage-subtraction-not-an-allow-list ) . The one thing that * is* configured is
41+ ** record-view auditing** , which records nothing until objects are named:
42+
43+ ``` typescript
44+ await kernel .use (
45+ new AuditPlugin ({
46+ readAudit: { objects: [' contact' , ' account' ] },
47+ }),
48+ );
49+ ```
50+
51+ ` readAudit ` is the only key ` AuditPluginOptions ` declares, and it accepts exactly three
52+ settings, no others:
53+
54+ | Option | Default | Meaning |
55+ | ---| ---| ---|
56+ | ` readAudit.objects ` | ` [] ` | The closed per-object opt-in. Empty installs no hook |
57+ | ` readAudit.maxBatchSize ` | ` 50 ` | Flush once this many views are buffered |
58+ | ` readAudit.flushIntervalMs ` | ` 2000 ` | Flush this long after the first view of a batch |
59+
60+ ⚠️ The writer itself has one more knob — ` maxBufferedEvents ` (default ` 10000 ` ) — that the
61+ plugin does ** not** forward. Setting it requires calling ` installReadAuditWriter ` directly
62+ against the engine; there is no plugin-level spelling for it.
3763
3864The plugin depends on the ObjectQL engine (` com.objectstack.engine.objectql ` ) and resolves
3965it at ` kernel:ready ` . If no engine is available it logs a warning and installs no writers.
@@ -46,6 +72,7 @@ these are the only values that are ever written:
4672| ` action ` | Written by | On |
4773| ---| ---| ---|
4874| ` create ` | ` installAuditWriters ` (this package) | ` afterInsert ` |
75+ | ` read ` | ` installReadAuditWriter ` (this package) | ` afterFind ` , on opted-in objects only, and only for record-detail views |
4976| ` update ` | ` installAuditWriters ` (this package) | ` afterUpdate ` , only when the diff is non-empty |
5077| ` delete ` | ` installAuditWriters ` (this package) | ` afterDelete ` |
5178| ` login ` | ` createAuthEventAuditSink ` (this package), called by ` @objectstack/plugin-auth ` | session start |
@@ -65,7 +92,7 @@ written only by internal system hooks running under `sudo()`, never through UI f
6592| ---| ---| ---|
6693| ` id ` | text | Audit log entry id |
6794| ` created_at ` | datetime | When the action occurred (` NOW() ` default) |
68- | ` action ` | select | One of the seven values above |
95+ | ` action ` | select | One of the eight values above |
6996| ` user_id ` | lookup → ` sys_user ` | Null for non-user / service actions |
7097| ` actor ` | text | Principal label: a user id, ` svc:<name> ` , or null. Attributes service-token writes that ` user_id ` structurally cannot hold |
7198| ` object_name ` | text | Target object, e.g. ` sys_user ` |
@@ -83,9 +110,15 @@ A credential *rotation* still produces a row — the raw values are compared for
83110detection before masking is applied — so the audit trail of a secret change survives
84111without the secret itself reaching the ledger.
85112
86- ** ` ip_address ` / ` user_agent ` are populated on auth events only.** The record-level writer
87- does not stamp them: a ` create ` / ` update ` / ` delete ` row records who and what, not from
88- where. Do not read a null client fingerprint on a CRUD row as "the request had none".
113+ ** ` ip_address ` / ` user_agent ` are populated on auth events only.** Neither the
114+ record-level writer nor the record-view writer stamps them: a ` create ` / ` update ` /
115+ ` delete ` / ` read ` row records who and what, not from where. Do not read a null client
116+ fingerprint on such a row as "the request had none". ⚠️ The shipped ` record_views ` list
117+ view carries an ` ip_address ` column, and on a ` read ` row that column is ** always empty**
118+ for this reason.
119+
120+ ** ` old_value ` / ` new_value ` are null on every ` read ` row** , deliberately and not as an
121+ omission — see [ Record-view auditing] ( #record-view-auditing--the-read-action ) .
89122
90123## Coverage: subtraction, not an allow list
91124
@@ -110,16 +143,131 @@ Excluded objects fall into two groups:
110143The exclusion list is one definition consumed on both the registration face and inside the
111144handlers, so the two cannot drift.
112145
146+ ⚠️ ** Read coverage is the opposite shape** — a closed opt-in, not subtraction. The two are
147+ not inconsistent: for writes, an object nobody remembered to list is one whose changes go
148+ unrecorded, so the safe default is "audited"; for reads, the same default would record
149+ every record anyone opens on every object in the system, burying the views an auditor is
150+ actually looking for and charging every read for it. Read coverage is therefore
151+ enumerated, and the exclusion list applies on top of it — an excluded object cannot be
152+ opted in.
153+
154+ ## Record-view auditing — the ` read ` action
155+
156+ Answers "who viewed this record, and when?". Nothing is recorded until a deployment names
157+ the objects it wants recorded.
158+
159+ ### The opt-in is an install-time list, not a metadata key
160+
161+ ⛔ There is no ` enable.auditReads ` object-metadata key and no global switch. The audited
162+ set is a constructor argument, given once at the place the plugin is installed:
163+
164+ ``` typescript
165+ new AuditPlugin ({ readAudit: { objects: [' contact' , ' account' ] } });
166+ ```
167+
168+ That shape is deliberate. A declarable metadata key can be set on an object in a
169+ deployment that never installs this plugin — producing a metadata file that * reads* as
170+ audited and writes nothing. A declaration a compliance reviewer mistakes for coverage is
171+ worse than an absent feature, and one input at the point of installation cannot make that
172+ claim.
173+
174+ ` installReadAuditWriter ` filters the list before it registers anything:
175+
176+ - names on the audit exclusion list above are ** dropped with a warning** naming the object,
177+ not silently accepted — the list is derived from the write-side exclusions rather than
178+ re-typed, so the two cannot disagree;
179+ - duplicates and blanks are removed, and the returned handle reports ` auditedObjects ` ,
180+ i.e. what was actually registered rather than what was asked for;
181+ - an empty (or fully excluded) set registers ** no hook at all** , so a deployment that opts
182+ nothing in pays nothing on its read path.
183+
184+ ### Only record-detail views produce a row
185+
186+ A read is recorded when ** both** hold:
187+
188+ 1 . it materialized exactly one record — ` findOne ` returning a record, not ` find ` returning
189+ an array (an array, ` null ` or ` undefined ` result is never a detail view); and
190+ 2 . its predicate ** pinned the primary key** . ` GET /data/:object/:id ` reaches the engine as
191+ ` findOne(object, { where: { id } }) ` , which is the record-detail surface. A ` findOne `
192+ carrying any other predicate is "give me * a* matching record" — an internal lookup, not
193+ someone opening a record.
194+
195+ The predicate walk tolerates what the security middleware leaves behind: an ` id ` equality
196+ AND-composed with an RLS/tenant clause still counts, and the explicit ` { id: { $eq: … } } `
197+ spelling is accepted. ` $or ` or ` $not ` anywhere on the path ** disqualifies** the read — the
198+ row may have matched through the other arm, so the id equality no longer proves the read
199+ was for that record. Nesting is walked to a fixed depth of 8.
200+
201+ ⇒ ** List and search reads are never recorded** , including a list read that happened to
202+ return exactly one record. List auditing is a deferred follow-up, and a deferral that
203+ leaked rows anyway would not be one.
204+
205+ ### Ledger writes happen off the request path
206+
207+ The hook ** enqueues and returns** ; it awaits nothing. Rows are persisted on a later tick by
208+ a batcher, flushed whichever comes first — ` maxBatchSize ` views buffered (default 50) or
209+ ` flushIntervalMs ` since the batch's first view (default 2000ms) — and the plugin's
210+ ` destroy() ` drains the tail so a clean shutdown does not take the last batch with it.
211+
212+ ` created_at ` on each row is the instant the record was ** viewed** , not the instant its
213+ batch drained. Batching would otherwise stamp a whole batch with one flush timestamp, and a
214+ ledger that answers "when did they look?" with the time its own buffer emptied is wrong by
215+ up to the flush interval.
216+
217+ Two failure postures, both loud once and never retried:
218+
219+ - ** Buffer overflow.** Past ` maxBufferedEvents ` (default 10000) the ** oldest** buffered
220+ views are dropped and a ` warn ` is logged once. The reads all still succeeded and returned
221+ 200, so nothing else reports the hole.
222+ - ** A failed ledger write.** The batch is lost, an ` error ` is logged once, and the read is
223+ unaffected — an audit write must never turn a valid read into an error, and retrying in a
224+ loop against an unreachable table turns a degradation into an outage.
225+
226+ ### What the row contains — and what it deliberately does not
227+
228+ A ` read ` row carries ` action: 'read' ` , the view instant on ` created_at ` , ` user_id ` ,
229+ ` actor ` , ` object_name ` , ` record_id ` , and ` tenant_id ` — the viewed record's own
230+ organization, falling back to the viewer's session organization, so a row about an org-A
231+ record does not land behind org B's tenant wall. In multi-tenant mode, where the platform
232+ injects an ` organization_id ` column onto ` sys_audit_log ` , the same value is stamped there
233+ too: that column is what the row-level tenant wall gates on, and an unstamped row is one
234+ non-admin members can never see.
235+
236+ ⛔ ** No field values are ever recorded.** ` old_value ` and ` new_value ` stay ` null ` . The
237+ ` afterFind ` hook runs * inside* the security middleware, ahead of its field masking, so the
238+ record it sees is pre-mask plaintext; copying values in would mint a plaintext copy of
239+ exactly what field-level security withholds, inside the one table compliance staff are
240+ granted broad access to. It follows that the ledger does not record * what the viewer
241+ actually saw* — only that they opened the record.
242+
243+ Two boundaries are declared rather than left to be discovered:
244+
245+ - ** A system-elevated read writes no row.** Anything carrying ` session.isSystem ` — an
246+ ` api.sudo() ` path, a formula recompute, a roll-up, a trigger — is the platform reading
247+ for its own bookkeeping, not a person opening a record. Note ` sudo() ` keeps the caller's
248+ user id, so this flag is the only thing separating the two.
249+ - ** A read with no principal writes no row.** With neither a user id nor an actor there is
250+ no answer to "who", and a row naming nobody only adds noise to the one query this
251+ capability exists to serve.
252+
253+ Rows surface in the shipped ` record_views ` list view.
254+
113255## What the ledger does not record
114256
115257Stated explicitly, because a gap in an audit surface is easily mistaken for coverage:
116258
117- - ** Reads and views are not on the ledger.** No writer emits a read action, and the record
118- writers subscribe only to ` after* ` write events. ` sys_audit_log ` answers "who changed
119- this record", not "who looked at it".
259+ - ** Reads are on the ledger only where they were opted in, and only as record-detail
260+ views.** An object absent from ` readAudit.objects ` produces no ` read ` row at all, and no
261+ list or search read produces one on any object. See
262+ [ Record-view auditing] ( #record-view-auditing--the-read-action ) for the full scope.
263+ - ** What a viewer actually saw is not on the ledger.** A ` read ` row records who opened
264+ which record; it carries no field values, so it cannot answer which fields were visible
265+ to that viewer after masking.
120266- ** Failed operations are not on the ledger.** There is no success/failure column, and the
121- writers fire only on ` after* ` events — that is, only on operations that succeeded. A
122- failed write is not distinguishable from an absent one here.
267+ write hooks fire only on ` after* ` events — that is, only on operations that succeeded. A
268+ failed write is not distinguishable from an absent one here. The same holds for reads:
269+ the ` afterFind ` hook is reached only by a read that succeeded, so a refused read leaves
270+ no trace.
123271- ** Field-level read access is not on the ledger.**
124272
125273## Reading audit rows
@@ -133,6 +281,7 @@ the standard object API or `services.data`, and through the shipped list views:
133281| ` recent ` | Everything, newest first |
134282| ` writes_only ` | ` create ` / ` update ` / ` delete ` |
135283| ` auth_events ` | ` login ` / ` logout ` |
284+ | ` record_views ` | ` read ` — who opened which record |
136285| ` config_changes ` | ` config_change ` / ` import ` |
137286| ` all_events ` | Everything, larger page size |
138287
@@ -198,6 +347,14 @@ those checks degrade to parent-record read visibility. If the engine exposes no
198347seam, ` sys_comment ` read visibility is not installed at all and the plugin logs a warning
199348naming the consequence.
200349
350+ ** Record-view auditing adds no enterprise dependency.** The platform capability registry
351+ declares this package's edition as ` open ` ; ` read ` rows are written by this package, and the
352+ opt-in is ordinary plugin configuration, so nothing about the capability degrades on an
353+ open build. The boundary above still applies to ` read ` rows the same way it applies to
354+ every other row — they are read through the same permission system, so a grant written with
355+ a hierarchy-relative scope shows a manager only their own record-view rows without the
356+ enterprise resolver.
357+
201358## Exports
202359
203360``` typescript
@@ -207,6 +364,13 @@ export { AuditPlugin };
207364// Writer installation + test probe
208365export { installAuditWriters , createFieldPresenceProbe };
209366
367+ // Record-view auditing (the `read` action)
368+ export { installReadAuditWriter , createReadAuditBatcher , extractDetailReadId , READ_AUDIT_ACTION };
369+ export type {
370+ ReadAuditBatcher , ReadAuditBatcherOptions , ReadAuditEvent , ReadAuditLogger ,
371+ ReadAuditTimers , ReadAuditWriterHandle , ReadAuditWriterOptions ,
372+ };
373+
210374// Auth-event ingress (the `audit` service slot)
211375export { createAuthEventAuditSink };
212376export type {
0 commit comments