Skip to content

Commit 5126e79

Browse files
feat(plugin-audit): record-view auditing — who viewed which record (#8992) (#9515)
* feat(plugin-audit): record-view auditing — the `read` action, its writer, and its view (#8992) `sys_audit_log` covered writes only: `actionFor()` maps exactly afterInsert/afterUpdate/afterDelete, and the shipped list views confirmed the scope. "Who viewed this customer record, and when?" was unanswerable. Adds the `read` action WRITER-FIRST — the emission point, its tests, and the `record_views` list view that surfaces it, in one stroke, which is the only way a value is allowed onto this enum (#8147 / #8315). Scope is the maintainer's 2026-08-16 ruling, and each pin is code: - record-detail views only — `extractDetailReadId` requires one materialized record AND a primary-key pin, so list/search reads produce nothing; - per-object opt-in, closed — one input, used as the narrow `afterFind` registration target, so a non-audited read costs no dispatch; - batched off the request path — the hook enqueues and returns; each row keeps the VIEW instant via the system-context `created_at` exemption (#4447). The row carries no field values: `afterFind` runs ahead of the security middleware's field masking, so `ctx.result` is pre-mask plaintext. Co-Authored-By: Claude <noreply@anthropic.com> * test(plugin-audit): give the harness objects their owning package id `registry.registerObject(schema, packageId)` requires the owner; the one-arg call ran fine but failed `tsc --noEmit`. Co-Authored-By: Claude <noreply@anthropic.com> * test(plugin-audit): type the engine query options instead of erasing them to `any` The new read-audit suite added 22 sites to the `query-options-erasure` test-surface ratchet (240 -> 262). Fixed at the source: every find/findOne/ insert options bag is now passed typed, and the shared read context is a named `ReadContext` alias off `EngineQueryOptions['context']`. The ratchet returns to 240 — flat, not raised. This is not count-satisfying hygiene here. The gate's #8210 caveat is that in a package whose tsconfig excludes test files, typing these buys no compiler guard today. plugin-audit does NOT exclude them, so `tsc` reads this file and a wrong options key is a real compile error rather than a silently dropped one (#4674). Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5aadce3 commit 5126e79

13 files changed

Lines changed: 1391 additions & 3 deletions

‎.changeset/wise-pugs-attend.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
"@objectstack/plugin-audit": minor
3+
---
4+
5+
Record-view auditing: `sys_audit_log` can now answer "who viewed which record"
6+
7+
`sys_audit_log` covered writes only, so the question every regulated-industry
8+
security review opens with — *who viewed this customer record, and when?* — had
9+
no answer short of custom work. The ledger now has a `read` action, its writer,
10+
and the `record_views` list view that surfaces it.
11+
12+
Scope is deliberately narrow (maintainer ruling 2026-08-16):
13+
14+
- **Record-detail views only.** A read qualifies when it materialized one record
15+
and its predicate pinned the primary key — the shape `GET /data/:object/:id`
16+
produces. List and search reads are not audited.
17+
- **Per-object opt-in, closed.** Nothing is recorded until a deployment names the
18+
objects: `new AuditPlugin({ readAudit: { objects: ['contact', 'account'] } })`.
19+
There is no global switch and no exception list, and an empty opt-in registers
20+
no hook at all, so the default posture costs a read nothing.
21+
- **Batched off the request path.** The hook buffers and returns; rows are
22+
persisted on a later tick, size- or timer-triggered, and flushed on shutdown.
23+
Each row keeps the instant the record was VIEWED, not the instant its batch
24+
drained.
25+
26+
The row records who, what and when — never field values. Read auditing runs
27+
inside the security middleware, ahead of its field masking, so the record it sees
28+
is pre-mask; copying values in would mint a plaintext copy of exactly what
29+
field-level security withholds, in the table compliance staff are granted broad
30+
access to.
31+
32+
Two boundaries are declared rather than left to be discovered: a system-elevated
33+
read (`api.sudo()`, formula recomputes, roll-ups) writes no row, and neither does
34+
a read with no principal to name.

‎packages/plugins/plugin-audit/src/audit-plugin.ts‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,44 @@ import { SysAuditLog, SysActivity, SysComment } from './objects/index.js';
1414
// @objectstack/service-storage for the same ownership reason (ADR-0052 §3: a
1515
// file↔record link belongs with storage, not the compliance ledger).
1616
import { installAuditWriters, type AuditI18nSurface, type MessagingEmitSurface } from './audit-writers.js';
17+
import { installReadAuditWriter, type ReadAuditWriterHandle } from './read-audit.js';
1718
import { createAuthEventAuditSink } from './auth-event-audit.js';
1819
import { installCommentAccessHooks, installCommentReadVisibility } from './comment-access-hooks.js';
1920

21+
/**
22+
* [#8992] Read/view audit configuration — the per-object opt-in, closed.
23+
*
24+
* Not a global flag with exceptions: the maintainer's 2026-08-16 ruling chose a
25+
* closed opt-in deliberately, because on a compliance surface the failure modes
26+
* of the two shapes are not symmetric. A global flag that forgets an exception
27+
* over-collects (noisy, expensive, and it buries the views an auditor is
28+
* looking for); an opt-in that forgets an object under-collects, which is
29+
* visible the moment anyone asks the question this capability exists to answer.
30+
*/
31+
export interface AuditPluginReadAuditOptions {
32+
/**
33+
* Objects whose RECORD-DETAIL views are recorded as `read` rows in
34+
* `sys_audit_log`. Absent or empty installs no hook at all — a deployment
35+
* that opts nothing in pays nothing on its read path.
36+
*
37+
* ⛔ Scope is record-detail views only (a read that materialized one record
38+
* and pinned its primary key). List and search results are NOT audited: that
39+
* is a deferred follow-up, and a deferral that leaked rows anyway would not
40+
* be one.
41+
*/
42+
objects?: readonly string[];
43+
/** Flush once this many views are buffered. Default 50. */
44+
maxBatchSize?: number;
45+
/** Flush this long after the first view of a batch. Default 2000ms. */
46+
flushIntervalMs?: number;
47+
}
48+
49+
/** Constructor options for {@link AuditPlugin}. */
50+
export interface AuditPluginOptions {
51+
/** [#8992] Record-view auditing. Off unless objects are named. */
52+
readAudit?: AuditPluginReadAuditOptions;
53+
}
54+
2055
/**
2156
* AuditPlugin
2257
*
@@ -39,6 +74,16 @@ export class AuditPlugin implements Plugin {
3974
*/
4075
providesServices = ['audit'];
4176

77+
/**
78+
* [#8992] The record-view writer's handle, held so `destroy()` can flush the
79+
* tail. A batched ledger that never flushes on shutdown loses its last batch
80+
* on every clean restart — silently, because the reads it describes all
81+
* succeeded.
82+
*/
83+
private readAuditWriter: ReadAuditWriterHandle | null = null;
84+
85+
constructor(private readonly options: AuditPluginOptions = {}) {}
86+
4287
async init(ctx: PluginContext): Promise<void> {
4388
// Register audit system objects via the manifest service.
4489
ctx.getService<{ register(m: any): void }>('manifest').register({
@@ -162,6 +207,28 @@ export class AuditPlugin implements Plugin {
162207
installAuditWriters(engine as any, this.name, { getMessaging, getI18n, getLocale });
163208
ctx.logger.info('AuditPlugin: audit + activity writers installed');
164209

210+
// [#8992] Record-view auditing — the `read` half of the ledger. Installed
211+
// only over the objects this deployment opted in, and returns null when
212+
// that set is empty, so the default posture costs a read exactly nothing.
213+
const readAuditObjects = this.options.readAudit?.objects ?? [];
214+
this.readAuditWriter = installReadAuditWriter(engine, {
215+
objects: readAuditObjects,
216+
packageId: this.name,
217+
logger: ctx.logger,
218+
...(this.options.readAudit?.maxBatchSize !== undefined
219+
? { maxBatchSize: this.options.readAudit.maxBatchSize }
220+
: {}),
221+
...(this.options.readAudit?.flushIntervalMs !== undefined
222+
? { flushIntervalMs: this.options.readAudit.flushIntervalMs }
223+
: {}),
224+
});
225+
if (this.readAuditWriter) {
226+
ctx.logger.info(
227+
`AuditPlugin: record-view auditing installed on ${this.readAuditWriter.auditedObjects.length} object(s) — `
228+
+ `${this.readAuditWriter.auditedObjects.join(', ')}`,
229+
);
230+
}
231+
165232
// #4630 — record-level authorization for sys_comment: a comment's access
166233
// derives from the record its `thread_id` names, exactly as an
167234
// attachment's derives from its parent (service-storage's
@@ -306,4 +373,20 @@ export class AuditPlugin implements Plugin {
306373
);
307374
}
308375
}
376+
377+
/**
378+
* [#8992] Flush the record-view tail on shutdown.
379+
*
380+
* Batching is what keeps the ledger write off the read path, and the price of
381+
* a buffer is that a clean shutdown can take the last batch with it. The
382+
* views in it already returned 200, so nothing else would ever report the
383+
* loss. `stop()` cancels the timer and drains what is left; it never throws
384+
* (the batcher's `persist` reports and swallows), so this can never turn a
385+
* clean shutdown into a failed one.
386+
*/
387+
async destroy(): Promise<void> {
388+
const writer = this.readAuditWriter;
389+
this.readAuditWriter = null;
390+
if (writer) await writer.stop();
391+
}
309392
}

‎packages/plugins/plugin-audit/src/audit-writers.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,10 @@ export interface AuditWriterOptions {
9595
* Skip rules avoid recursion and noise:
9696
* - Never audit the audit/activity tables themselves.
9797
* - Never audit session/presence/auth tables (high-frequency, low value).
98-
* - Read-only operations (`afterFind`) are never audited.
98+
* - Read-only operations (`afterFind`) are not audited BY THIS WRITER. Since
99+
* #8992 the `read` action has its own writer in `read-audit.ts`, installed
100+
* separately and only over the objects a deployment opts in: record-detail
101+
* views, batched off the request path. This writer stays write-only.
99102
*
100103
* All writes go through `ctx.api.sudo()` so they bypass record-level
101104
* permissions and always succeed regardless of the calling user's RBAC.
@@ -187,8 +190,15 @@ const SKIP_OBJECTS = new Set<string>([
187190
* are one list, so neither can drift from the other. The early return stays as
188191
* defence in depth — it is what protects every non-hook caller of these
189192
* handlers, and it keeps audit behaviour bit-for-bit conserved by this change.
193+
*
194+
* [#8992] EXPORTED because the read/view writer (`read-audit.ts`) needs the same
195+
* subtraction and must not keep a second copy of it. Every reason an object is
196+
* excluded from write auditing — recursion, auth/session noise, ADR-0057
197+
* telemetry plumbing — applies unchanged to auditing its READS, and two
198+
* hand-kept lists would disagree the day either is fixed. Same rule this
199+
* docblock already states one paragraph up, now across two files.
190200
*/
191-
const AUDIT_EXCLUDED_OBJECTS: string[] = [...SKIP_OBJECTS];
201+
export const AUDIT_EXCLUDED_OBJECTS: string[] = [...SKIP_OBJECTS];
192202

193203
/** Fields that are noise in diffs (always change, never user-meaningful). */
194204
const NOISE_FIELDS = new Set<string>([

‎packages/plugins/plugin-audit/src/index.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,21 @@
1010
export { AuditPlugin } from './audit-plugin.js';
1111
export { createFieldPresenceProbe, installAuditWriters } from './audit-writers.js';
1212
export { createAuthEventAuditSink } from './auth-event-audit.js';
13+
export {
14+
createReadAuditBatcher,
15+
extractDetailReadId,
16+
installReadAuditWriter,
17+
READ_AUDIT_ACTION,
18+
} from './read-audit.js';
19+
export type {
20+
ReadAuditBatcher,
21+
ReadAuditBatcherOptions,
22+
ReadAuditEvent,
23+
ReadAuditLogger,
24+
ReadAuditTimers,
25+
ReadAuditWriterHandle,
26+
ReadAuditWriterOptions,
27+
} from './read-audit.js';
1328
export type {
1429
AuthEventAuditLogger,
1530
AuthEventAuditSink,

‎packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const RETIRED_ACTIONS: ReadonlyArray<readonly [action: string, prescription: str
7272
*/
7373
const ACTIONS_WITH_WRITERS: ReadonlyArray<readonly [action: string, writer: string]> = [
7474
['create', 'plugin-audit/src/audit-writers.ts — actionFor(afterInsert)'],
75+
['read', 'plugin-audit/src/read-audit.ts — installReadAuditWriter afterFind hook (#8992)'],
7576
['update', 'plugin-audit/src/audit-writers.ts — actionFor(afterUpdate)'],
7677
['delete', 'plugin-audit/src/audit-writers.ts — actionFor(afterDelete)'],
7778
['login', 'plugin-audit/src/auth-event-audit.ts — createAuthEventAuditSink (#8144)'],

‎packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,27 @@ export const SysAuditLog = ObjectSchema.create({
7272
sort: [{ field: 'created_at', order: 'desc' }],
7373
pagination: { pageSize: 50 },
7474
},
75+
// [#8992] The `read` action's shipped surface. A ledger value with no view
76+
// is half of the empty-widget defect the 2026-08-12 ruling named; this card
77+
// adds the action and the screen that answers its question in one stroke.
78+
// `record_views` is the "who viewed this record" query as a list: actor
79+
// first, because that is the column an auditor scans.
80+
record_views: {
81+
type: 'grid',
82+
name: 'record_views',
83+
label: 'Record Views',
84+
data: { provider: 'object', object: 'sys_audit_log' },
85+
columns: ['created_at', 'user_id', 'object_name', 'record_id', 'ip_address'],
86+
filter: [{ field: 'action', operator: 'in', value: ['read'] }],
87+
sort: [{ field: 'created_at', order: 'desc' }],
88+
pagination: { pageSize: 50 },
89+
emptyState: {
90+
title: 'No record views recorded',
91+
message:
92+
'Record-view auditing is opt-in per object. Rows appear here once an object is added to the audit '
93+
+ "plugin's readAudit.objects list and someone opens one of its records.",
94+
},
95+
},
7596
config_changes: {
7697
type: 'grid',
7798
name: 'config_changes',
@@ -135,8 +156,17 @@ export const SysAuditLog = ObjectSchema.create({
135156
// and silently, because every field here is `readonly: true` and
136157
// `validateRecord` skips readonly fields, so nothing would ever go red.
137158
// See #8147 for the escalation.
159+
// [#8992, maintainer ruling 2026-08-16] `read` joins the enum WRITER-FIRST,
160+
// which is the only way a value is allowed back onto this surface (the
161+
// docblock in `sys-audit-log-retired-actions.test.ts` states the rule and
162+
// the pin enforces it). Its writer is `read-audit.ts`'s `afterFind` hook,
163+
// its shipped surface is the `record_views` list view above, and both
164+
// landed in the same PR as this line. Scope is the ruling's MVP:
165+
// record-detail views on per-object opt-in, batched off the request path —
166+
// so a deployment that opts nothing in never writes one, and the value is
167+
// narrow rather than absent (审计面宁窄勿谎).
138168
action: Field.select(
139-
['create', 'update', 'delete', 'login', 'logout', 'config_change', 'import'],
169+
['create', 'read', 'update', 'delete', 'login', 'logout', 'config_change', 'import'],
140170
{
141171
label: 'Action',
142172
required: true,

0 commit comments

Comments
 (0)