Skip to content

Commit b348ac2

Browse files
docs(plugin-audit): the published README documents the record-view audit surface that shipped (#9517) (#9541)
PR #9531 corrected this README against the shipped surface while record-view auditing was still an unmerged draft, so it correctly refused to describe it. That work has since landed (#8992 via PR #9515), which made two of the page's statements false: "reads and views are not on the ledger", and that the plugin takes no configuration. The page now documents the surface that exists, each point measured against the source rather than against a description of it: the `read` action and its writer in the action table, the `record_views` list view, the record-detail discriminator (one materialized record plus a primary-key pin, `$or`/`$not` refused) that keeps list and search reads out of scope, the batched off-request-path writes with the view-instant `created_at` and the two loud once-only failure postures, and the two declared boundaries — a system-elevated read and a read with no principal both write no row. The opt-in is documented as what it is: an INSTALL-TIME list on the plugin constructor, explicitly not an `enable.auditReads` object-metadata key. That spelling was ruled against on #8992 for the reason this card exists — a declarable key can be set on an object in a deployment that never installs the plugin, producing metadata that reads as audited and writes nothing. Two things the page now says that only reading the code shows: `maxBufferedEvents` is a writer knob the plugin does not forward, and the shipped `record_views` view carries an `ip_address` column that is always empty on a `read` row because no read-path writer stamps it. Record-view auditing adds no enterprise dependency — this package's declared edition is `open` — so nothing new is annotated under that pattern; the two existing annotations are unchanged. A changeset is owed because the README ships in the package's `files` array: a docs-only correction with no version bump never reaches the npm package page. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 33d9b33 commit b348ac2

2 files changed

Lines changed: 217 additions & 13 deletions

File tree

.changeset/mighty-ducks-repeat.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/plugin-audit": patch
3+
---
4+
5+
Published README documents the record-view audit surface that actually shipped
6+
7+
The README was corrected against the shipped surface before record-view auditing landed,
8+
so it still told readers that "reads and views are not on the ledger" and that the plugin
9+
takes no configuration. Both became false when the `read` action, its writer and the
10+
`record_views` list view merged. This is a docs-only change; it needs a version bump
11+
because the README is in the package's published `files` array, and a correction with no
12+
release never reaches the npm package page at all.
13+
14+
What the page now documents, each point verified against the source rather than against a
15+
description of it:
16+
17+
- the `read` action and its writer, in the action table and in the shipped list views;
18+
- the per-object opt-in as what it is — an **install-time list** passed to the plugin
19+
constructor (`new AuditPlugin({ readAudit: { objects: [...] } })`), explicitly **not** an
20+
`enable.auditReads` object-metadata key. A declarable key can be set on an object in a
21+
deployment that never installs the plugin, producing metadata that reads as audited and
22+
writes nothing, which is the exact class of claim this page was corrected to remove;
23+
- the three settings the plugin forwards, and the one writer knob (`maxBufferedEvents`) it
24+
does not, which is reachable only by calling `installReadAuditWriter` directly;
25+
- the record-detail discriminator that keeps list and search reads out of scope, including
26+
the `$or` / `$not` refusal and the AND-composed predicate the security middleware leaves
27+
behind;
28+
- batched writes off the request path, the view-instant `created_at`, and the two loud
29+
once-only failure postures (buffer overflow, failed ledger write);
30+
- the two declared boundaries — a system-elevated read and a read with no principal both
31+
write no row;
32+
- that no field values are recorded, and therefore that the ledger cannot answer what a
33+
viewer actually saw;
34+
- that the shipped `record_views` view carries an `ip_address` column which is always empty
35+
on a `read` row, because no read-path writer stamps it.
36+
37+
Record-view auditing adds no enterprise dependency: this package's declared edition is
38+
`open`, and the opt-in is ordinary plugin configuration. The two enterprise-dependent
39+
behaviours already annotated on the page — the hierarchy resolver and the archive
40+
datasource — are unchanged.

packages/plugins/plugin-audit/README.md

Lines changed: 177 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -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

1010
1. **Registers three system objects**`sys_audit_log`, `sys_activity`, `sys_comment`.
1111
2. **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
1518
are 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
3034
import { AuditPlugin } from '@objectstack/plugin-audit';
3135

3236
await 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

3864
The plugin depends on the ObjectQL engine (`com.objectstack.engine.objectql`) and resolves
3965
it 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
83110
detection before masking is applied — so the audit trail of a secret change survives
84111
without 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:
110143
The exclusion list is one definition consumed on both the registration face and inside the
111144
handlers, 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

115257
Stated 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
198347
seam, `sys_comment` read visibility is not installed at all and the plugin logs a warning
199348
naming 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
208365
export { 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)
211375
export { createAuthEventAuditSink };
212376
export type {

0 commit comments

Comments
 (0)