From 31a39bff294e136be8ddbaa63942bac2e56c9987 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 06:01:43 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(plugin-audit):=20record-view=20auditin?= =?UTF-8?q?g=20=E2=80=94=20the=20`read`=20action,=20its=20writer,=20and=20?= =?UTF-8?q?its=20view=20(#8992)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- .changeset/wise-pugs-attend.md | 34 + .../plugins/plugin-audit/src/audit-plugin.ts | 83 +++ .../plugins/plugin-audit/src/audit-writers.ts | 14 +- packages/plugins/plugin-audit/src/index.ts | 15 + .../sys-audit-log-retired-actions.test.ts | 1 + .../src/objects/sys-audit-log.object.ts | 32 +- .../plugin-audit/src/read-audit.test.ts | 549 ++++++++++++++++ .../plugins/plugin-audit/src/read-audit.ts | 615 ++++++++++++++++++ .../src/translations/en.objects.generated.ts | 8 + .../translations/es-ES.objects.generated.ts | 8 + .../translations/ja-JP.objects.generated.ts | 8 + .../translations/zh-CN.objects.generated.ts | 8 + ...check-durability-degradation-log-level.mjs | 4 + 13 files changed, 1376 insertions(+), 3 deletions(-) create mode 100644 .changeset/wise-pugs-attend.md create mode 100644 packages/plugins/plugin-audit/src/read-audit.test.ts create mode 100644 packages/plugins/plugin-audit/src/read-audit.ts diff --git a/.changeset/wise-pugs-attend.md b/.changeset/wise-pugs-attend.md new file mode 100644 index 0000000000..1df9ab9310 --- /dev/null +++ b/.changeset/wise-pugs-attend.md @@ -0,0 +1,34 @@ +--- +"@objectstack/plugin-audit": minor +--- + +Record-view auditing: `sys_audit_log` can now answer "who viewed which record" + +`sys_audit_log` covered writes only, so the question every regulated-industry +security review opens with — *who viewed this customer record, and when?* — had +no answer short of custom work. The ledger now has a `read` action, its writer, +and the `record_views` list view that surfaces it. + +Scope is deliberately narrow (maintainer ruling 2026-08-16): + +- **Record-detail views only.** A read qualifies when it materialized one record + and its predicate pinned the primary key — the shape `GET /data/:object/:id` + produces. List and search reads are not audited. +- **Per-object opt-in, closed.** Nothing is recorded until a deployment names the + objects: `new AuditPlugin({ readAudit: { objects: ['contact', 'account'] } })`. + There is no global switch and no exception list, and an empty opt-in registers + no hook at all, so the default posture costs a read nothing. +- **Batched off the request path.** The hook buffers and returns; rows are + persisted on a later tick, size- or timer-triggered, and flushed on shutdown. + Each row keeps the instant the record was VIEWED, not the instant its batch + drained. + +The row records who, what and when — never field values. Read auditing runs +inside the security middleware, ahead of its field masking, so the record it sees +is pre-mask; copying values in would mint a plaintext copy of exactly what +field-level security withholds, in the table compliance staff are granted broad +access to. + +Two boundaries are declared rather than left to be discovered: a system-elevated +read (`api.sudo()`, formula recomputes, roll-ups) writes no row, and neither does +a read with no principal to name. diff --git a/packages/plugins/plugin-audit/src/audit-plugin.ts b/packages/plugins/plugin-audit/src/audit-plugin.ts index 3ed9d7a702..8a37d64980 100644 --- a/packages/plugins/plugin-audit/src/audit-plugin.ts +++ b/packages/plugins/plugin-audit/src/audit-plugin.ts @@ -14,9 +14,44 @@ import { SysAuditLog, SysActivity, SysComment } from './objects/index.js'; // @objectstack/service-storage for the same ownership reason (ADR-0052 §3: a // file↔record link belongs with storage, not the compliance ledger). import { installAuditWriters, type AuditI18nSurface, type MessagingEmitSurface } from './audit-writers.js'; +import { installReadAuditWriter, type ReadAuditWriterHandle } from './read-audit.js'; import { createAuthEventAuditSink } from './auth-event-audit.js'; import { installCommentAccessHooks, installCommentReadVisibility } from './comment-access-hooks.js'; +/** + * [#8992] Read/view audit configuration — the per-object opt-in, closed. + * + * Not a global flag with exceptions: the maintainer's 2026-08-16 ruling chose a + * closed opt-in deliberately, because on a compliance surface the failure modes + * of the two shapes are not symmetric. A global flag that forgets an exception + * over-collects (noisy, expensive, and it buries the views an auditor is + * looking for); an opt-in that forgets an object under-collects, which is + * visible the moment anyone asks the question this capability exists to answer. + */ +export interface AuditPluginReadAuditOptions { + /** + * Objects whose RECORD-DETAIL views are recorded as `read` rows in + * `sys_audit_log`. Absent or empty installs no hook at all — a deployment + * that opts nothing in pays nothing on its read path. + * + * ⛔ Scope is record-detail views only (a read that materialized one record + * and pinned its primary key). List and search results are NOT audited: that + * is a deferred follow-up, and a deferral that leaked rows anyway would not + * be one. + */ + objects?: readonly string[]; + /** Flush once this many views are buffered. Default 50. */ + maxBatchSize?: number; + /** Flush this long after the first view of a batch. Default 2000ms. */ + flushIntervalMs?: number; +} + +/** Constructor options for {@link AuditPlugin}. */ +export interface AuditPluginOptions { + /** [#8992] Record-view auditing. Off unless objects are named. */ + readAudit?: AuditPluginReadAuditOptions; +} + /** * AuditPlugin * @@ -39,6 +74,16 @@ export class AuditPlugin implements Plugin { */ providesServices = ['audit']; + /** + * [#8992] The record-view writer's handle, held so `destroy()` can flush the + * tail. A batched ledger that never flushes on shutdown loses its last batch + * on every clean restart — silently, because the reads it describes all + * succeeded. + */ + private readAuditWriter: ReadAuditWriterHandle | null = null; + + constructor(private readonly options: AuditPluginOptions = {}) {} + async init(ctx: PluginContext): Promise { // Register audit system objects via the manifest service. ctx.getService<{ register(m: any): void }>('manifest').register({ @@ -162,6 +207,28 @@ export class AuditPlugin implements Plugin { installAuditWriters(engine as any, this.name, { getMessaging, getI18n, getLocale }); ctx.logger.info('AuditPlugin: audit + activity writers installed'); + // [#8992] Record-view auditing — the `read` half of the ledger. Installed + // only over the objects this deployment opted in, and returns null when + // that set is empty, so the default posture costs a read exactly nothing. + const readAuditObjects = this.options.readAudit?.objects ?? []; + this.readAuditWriter = installReadAuditWriter(engine, { + objects: readAuditObjects, + packageId: this.name, + logger: ctx.logger, + ...(this.options.readAudit?.maxBatchSize !== undefined + ? { maxBatchSize: this.options.readAudit.maxBatchSize } + : {}), + ...(this.options.readAudit?.flushIntervalMs !== undefined + ? { flushIntervalMs: this.options.readAudit.flushIntervalMs } + : {}), + }); + if (this.readAuditWriter) { + ctx.logger.info( + `AuditPlugin: record-view auditing installed on ${this.readAuditWriter.auditedObjects.length} object(s) — ` + + `${this.readAuditWriter.auditedObjects.join(', ')}`, + ); + } + // #4630 — record-level authorization for sys_comment: a comment's access // derives from the record its `thread_id` names, exactly as an // attachment's derives from its parent (service-storage's @@ -306,4 +373,20 @@ export class AuditPlugin implements Plugin { ); } } + + /** + * [#8992] Flush the record-view tail on shutdown. + * + * Batching is what keeps the ledger write off the read path, and the price of + * a buffer is that a clean shutdown can take the last batch with it. The + * views in it already returned 200, so nothing else would ever report the + * loss. `stop()` cancels the timer and drains what is left; it never throws + * (the batcher's `persist` reports and swallows), so this can never turn a + * clean shutdown into a failed one. + */ + async destroy(): Promise { + const writer = this.readAuditWriter; + this.readAuditWriter = null; + if (writer) await writer.stop(); + } } diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index 6ac38ca86d..49caa20103 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -95,7 +95,10 @@ export interface AuditWriterOptions { * Skip rules avoid recursion and noise: * - Never audit the audit/activity tables themselves. * - Never audit session/presence/auth tables (high-frequency, low value). - * - Read-only operations (`afterFind`) are never audited. + * - Read-only operations (`afterFind`) are not audited BY THIS WRITER. Since + * #8992 the `read` action has its own writer in `read-audit.ts`, installed + * separately and only over the objects a deployment opts in: record-detail + * views, batched off the request path. This writer stays write-only. * * All writes go through `ctx.api.sudo()` so they bypass record-level * permissions and always succeed regardless of the calling user's RBAC. @@ -187,8 +190,15 @@ const SKIP_OBJECTS = new Set([ * are one list, so neither can drift from the other. The early return stays as * defence in depth — it is what protects every non-hook caller of these * handlers, and it keeps audit behaviour bit-for-bit conserved by this change. + * + * [#8992] EXPORTED because the read/view writer (`read-audit.ts`) needs the same + * subtraction and must not keep a second copy of it. Every reason an object is + * excluded from write auditing — recursion, auth/session noise, ADR-0057 + * telemetry plumbing — applies unchanged to auditing its READS, and two + * hand-kept lists would disagree the day either is fixed. Same rule this + * docblock already states one paragraph up, now across two files. */ -const AUDIT_EXCLUDED_OBJECTS: string[] = [...SKIP_OBJECTS]; +export const AUDIT_EXCLUDED_OBJECTS: string[] = [...SKIP_OBJECTS]; /** Fields that are noise in diffs (always change, never user-meaningful). */ const NOISE_FIELDS = new Set([ diff --git a/packages/plugins/plugin-audit/src/index.ts b/packages/plugins/plugin-audit/src/index.ts index 760ffd3faa..671eb51766 100644 --- a/packages/plugins/plugin-audit/src/index.ts +++ b/packages/plugins/plugin-audit/src/index.ts @@ -10,6 +10,21 @@ export { AuditPlugin } from './audit-plugin.js'; export { createFieldPresenceProbe, installAuditWriters } from './audit-writers.js'; export { createAuthEventAuditSink } from './auth-event-audit.js'; +export { + createReadAuditBatcher, + extractDetailReadId, + installReadAuditWriter, + READ_AUDIT_ACTION, +} from './read-audit.js'; +export type { + ReadAuditBatcher, + ReadAuditBatcherOptions, + ReadAuditEvent, + ReadAuditLogger, + ReadAuditTimers, + ReadAuditWriterHandle, + ReadAuditWriterOptions, +} from './read-audit.js'; export type { AuthEventAuditLogger, AuthEventAuditSink, diff --git a/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts b/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts index 60b9e9a6aa..762a6233d7 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-audit-log-retired-actions.test.ts @@ -72,6 +72,7 @@ const RETIRED_ACTIONS: ReadonlyArray = [ ['create', 'plugin-audit/src/audit-writers.ts — actionFor(afterInsert)'], + ['read', 'plugin-audit/src/read-audit.ts — installReadAuditWriter afterFind hook (#8992)'], ['update', 'plugin-audit/src/audit-writers.ts — actionFor(afterUpdate)'], ['delete', 'plugin-audit/src/audit-writers.ts — actionFor(afterDelete)'], ['login', 'plugin-audit/src/auth-event-audit.ts — createAuthEventAuditSink (#8144)'], diff --git a/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts b/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts index 42c602a6e1..37601eb1a7 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts @@ -72,6 +72,27 @@ export const SysAuditLog = ObjectSchema.create({ sort: [{ field: 'created_at', order: 'desc' }], pagination: { pageSize: 50 }, }, + // [#8992] The `read` action's shipped surface. A ledger value with no view + // is half of the empty-widget defect the 2026-08-12 ruling named; this card + // adds the action and the screen that answers its question in one stroke. + // `record_views` is the "who viewed this record" query as a list: actor + // first, because that is the column an auditor scans. + record_views: { + type: 'grid', + name: 'record_views', + label: 'Record Views', + data: { provider: 'object', object: 'sys_audit_log' }, + columns: ['created_at', 'user_id', 'object_name', 'record_id', 'ip_address'], + filter: [{ field: 'action', operator: 'in', value: ['read'] }], + sort: [{ field: 'created_at', order: 'desc' }], + pagination: { pageSize: 50 }, + emptyState: { + title: 'No record views recorded', + message: + 'Record-view auditing is opt-in per object. Rows appear here once an object is added to the audit ' + + "plugin's readAudit.objects list and someone opens one of its records.", + }, + }, config_changes: { type: 'grid', name: 'config_changes', @@ -135,8 +156,17 @@ export const SysAuditLog = ObjectSchema.create({ // and silently, because every field here is `readonly: true` and // `validateRecord` skips readonly fields, so nothing would ever go red. // See #8147 for the escalation. + // [#8992, maintainer ruling 2026-08-16] `read` joins the enum WRITER-FIRST, + // which is the only way a value is allowed back onto this surface (the + // docblock in `sys-audit-log-retired-actions.test.ts` states the rule and + // the pin enforces it). Its writer is `read-audit.ts`'s `afterFind` hook, + // its shipped surface is the `record_views` list view above, and both + // landed in the same PR as this line. Scope is the ruling's MVP: + // record-detail views on per-object opt-in, batched off the request path — + // so a deployment that opts nothing in never writes one, and the value is + // narrow rather than absent (审计面宁窄勿谎). action: Field.select( - ['create', 'update', 'delete', 'login', 'logout', 'config_change', 'import'], + ['create', 'read', 'update', 'delete', 'login', 'logout', 'config_change', 'import'], { label: 'Action', required: true, diff --git a/packages/plugins/plugin-audit/src/read-audit.test.ts b/packages/plugins/plugin-audit/src/read-audit.test.ts new file mode 100644 index 0000000000..caaece7566 --- /dev/null +++ b/packages/plugins/plugin-audit/src/read-audit.test.ts @@ -0,0 +1,549 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8992] Record-view auditing — ENFORCEMENT, not declaration. + * + * The failure mode this card can most easily ship is an "audit this object" + * surface that parses and causes no row to be written. On an audit surface that + * is worse than a missing feature: a compliance reviewer reads the declaration + * as coverage. So every assertion below runs against a REAL {@link ObjectQL} + * engine over a minimal stub driver — never a hand-mocked hook dispatcher. + * + * That choice is the point. The three pins the ruling cares about are all + * properties of the ENGINE's behaviour, and a fake engine would let this file + * assert them against its own mock of the thing under test: + * + * - the per-object opt-in is enforced by a NARROW hook registration + * (`{ object: [...] }`), so "an object that is not opted in produces no + * row" has to be the engine's real dispatch declining to call us; + * - "record-detail views only" turns on the real shapes `find` and `findOne` + * leave on `ctx.result` and `ctx.input.ast.where`; + * - the row keeps the VIEW instant, which depends on the real engine's + * `created_at` strip and its system-context exemption (#4447). + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { installReadAuditWriter, extractDetailReadId, type ReadAuditTimers } from './read-audit.js'; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +/** A minimal in-memory driver — enough for find/findOne/insert on the engine. */ +function makeStubDriver() { + const stores = new Map>>(); + const storeFor = (obj: string) => { + let s = stores.get(obj); + if (!s) { s = new Map(); stores.set(obj, s); } + return s; + }; + let nextId = 0; + /** Handles the shapes the engine actually produces: `{f: v}`, `{f:{$eq}}`, `$and`. */ + const matches = (row: Record, where: any): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where)) { + if (k === '$and') { + if (!(v as any[]).every((m) => matches(row, m))) return false; + continue; + } + if (k === '$or') { + if (!(v as any[]).some((m) => matches(row, m))) return false; + continue; + } + if (k.startsWith('$')) continue; + const expected = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v; + if ((row[k] ?? null) !== (expected ?? null)) return false; + } + return true; + }; + const driver: any = { + name: 'memory', version: '0.0.0', supports: {} as any, + async connect() {}, async disconnect() {}, async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast: any) { + return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + }, + async findOne(object: string, ast: any) { + for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r; + return null; + }, + async create(object: string, data: Record) { + nextId += 1; + const id = (data.id as string) ?? `r_${nextId}`; + const row: Record = { ...data, id }; + storeFor(object).set(id, row); + return row; + }, + async update(object: string, id: string, data: Record) { + const s = storeFor(object); + const cur = s.get(id); + if (!cur) return null; + const updated = { ...cur, ...data, id }; + s.set(id, updated); + return updated; + }, + async upsert(object: string, data: Record) { + const id = data.id as string | undefined; + if (id && storeFor(object).has(id)) return this.update(object, id, data); + return this.create(object, data); + }, + async delete(object: string, id: string) { return storeFor(object).delete(id); }, + async count(object: string, ast: any) { return (await this.find(object, ast)).length; }, + async bulkCreate(object: string, rows: Record[]) { + return Promise.all(rows.map((r) => this.create(object, r))); + }, + async bulkUpdate() { return []; }, + async bulkDelete() {}, + async updateMany() { return 0; }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, async rollback() {}, + }; + return { driver, stores }; +} + +const contactObject = { + name: 'contact', + label: 'Contact', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + full_name: { name: 'full_name', label: 'Name', type: 'text' as const }, + id_number: { name: 'id_number', label: 'ID number', type: 'text' as const }, + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, + }, +}; + +/** A second object, deliberately NOT opted in. */ +const invoiceObject = { + ...contactObject, + name: 'invoice', + label: 'Invoice', +}; + +/** The ledger, declared with the columns the writer conditionally stamps. */ +const auditLogObject = { + name: 'sys_audit_log', + label: 'Audit Log', + fields: { + id: { name: 'id', label: 'ID', type: 'text' as const, primaryKey: true }, + created_at: { name: 'created_at', label: 'At', type: 'datetime' as const }, + action: { name: 'action', label: 'Action', type: 'text' as const }, + user_id: { name: 'user_id', label: 'User', type: 'text' as const }, + actor: { name: 'actor', label: 'Actor', type: 'text' as const }, + object_name: { name: 'object_name', label: 'Object', type: 'text' as const }, + record_id: { name: 'record_id', label: 'Record', type: 'text' as const }, + old_value: { name: 'old_value', label: 'Old', type: 'textarea' as const }, + new_value: { name: 'new_value', label: 'New', type: 'textarea' as const }, + tenant_id: { name: 'tenant_id', label: 'Tenant', type: 'text' as const }, + organization_id: { name: 'organization_id', label: 'Org', type: 'text' as const }, + }, +}; + +/** A timer seam the tests drive by hand — no wall clock, no fake-timer globals. */ +function makeManualTimers(): ReadAuditTimers & { run(): void; armed(): boolean } { + let pending: (() => void) | null = null; + return { + set(fn) { pending = fn; return 1; }, + clear() { pending = null; }, + armed() { return pending !== null; }, + run() { const f = pending; pending = null; f?.(); }, + }; +} + +async function makeEngine() { + const engine = new ObjectQL(); + const { driver } = makeStubDriver(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(contactObject as any); + engine.registry.registerObject(invoiceObject as any); + engine.registry.registerObject(auditLogObject as any); + return engine; +} + +/** Every ledger row, oldest first. */ +async function ledgerRows(engine: ObjectQL): Promise { + return (await engine.find('sys_audit_log', {} as any)) as any[]; +} + +/** An ordinary authenticated caller — a person, not the platform. */ +const viewerCtx = { userId: 'u_alice', tenantId: 'org_a' }; + +describe('#8992 record-view auditing — the opt-in is enforced, not declared', () => { + let engine: ObjectQL; + + beforeEach(async () => { + engine = await makeEngine(); + await engine.insert( + 'contact', + { id: 'c1', full_name: 'Wei Zhang', id_number: '310101199001010011', organization_id: 'org_a' }, + { context: { isSystem: true } } as any, + ); + await engine.insert( + 'invoice', + { id: 'i1', full_name: 'INV-1', organization_id: 'org_a' }, + { context: { isSystem: true } } as any, + ); + }); + + it('an OPTED-IN object produces a `read` row on a record-detail view', async () => { + const writer = installReadAuditWriter(engine, { objects: ['contact'] })!; + expect(writer).not.toBeNull(); + + await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + await writer.flush(); + + const rows = await ledgerRows(engine); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + action: 'read', + user_id: 'u_alice', + actor: 'u_alice', + object_name: 'contact', + record_id: 'c1', + }); + }); + + it('an object that is NOT opted in produces NO row — the engine never dispatches to us', async () => { + const writer = installReadAuditWriter(engine, { objects: ['contact'] })!; + + await engine.findOne('invoice', { where: { id: 'i1' }, context: viewerCtx } as any); + // Nothing was even buffered: the narrow `{ object: ['contact'] }` registration + // is the enforcement, so a non-audited read costs no dispatch at all. + expect(writer.pending()).toBe(0); + await writer.flush(); + + expect(await ledgerRows(engine)).toHaveLength(0); + }); + + it('an EMPTY opt-in installs nothing at all', async () => { + expect(installReadAuditWriter(engine, { objects: [] })).toBeNull(); + + await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + expect(await ledgerRows(engine)).toHaveLength(0); + }); + + it('an object on the audit exclusion list is refused from the opt-in, loudly', async () => { + const warnings: string[] = []; + const writer = installReadAuditWriter(engine, { + objects: ['contact', 'sys_audit_log', 'sys_session'], + logger: { warn: (m: string) => warnings.push(m) }, + })!; + + expect(writer.auditedObjects).toEqual(['contact']); + // Silence here would be the dead surface one layer down: a configuration + // that names an object and gets no rows, with nothing saying why. + expect(warnings).toHaveLength(2); + expect(warnings[0]).toContain('sys_audit_log'); + expect(warnings[0]).toContain('will NOT have its record views recorded'); + }); +}); + +describe('#8992 the write is OFF the request path', () => { + let engine: ObjectQL; + + beforeEach(async () => { + engine = await makeEngine(); + await engine.insert('contact', { id: 'c1', full_name: 'Wei Zhang' }, { context: { isSystem: true } } as any); + }); + + /** + * The pin that makes the whole design honest. Reads vastly outnumber writes + * and the RFI behind this card carries a 2s record-open budget, so the read + * must return with the ledger INSERT still unmade. If this ever goes green + * by finding the row already present, the batching has collapsed into a + * synchronous write and the capability is taxing every record view. + */ + it('the read resolves with the ledger row NOT yet written', async () => { + const timers = makeManualTimers(); + const writer = installReadAuditWriter(engine, { objects: ['contact'], timers })!; + + const record = await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + expect(record).toMatchObject({ id: 'c1' }); + + // The read is DONE. The view is buffered and the ledger is still empty. + expect(writer.pending()).toBe(1); + expect(await ledgerRows(engine)).toHaveLength(0); + + await writer.flush(); + expect(writer.pending()).toBe(0); + expect(await ledgerRows(engine)).toHaveLength(1); + }); + + it('the flush timer is what drains a batch below the size threshold', async () => { + const timers = makeManualTimers(); + const writer = installReadAuditWriter(engine, { objects: ['contact'], timers, maxBatchSize: 50 })!; + + await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + expect(timers.armed()).toBe(true); + expect(await ledgerRows(engine)).toHaveLength(0); + + timers.run(); + await writer.flush(); + expect(await ledgerRows(engine)).toHaveLength(1); + }); + + it('reaching maxBatchSize flushes without waiting for the timer', async () => { + const timers = makeManualTimers(); + const writer = installReadAuditWriter(engine, { objects: ['contact'], timers, maxBatchSize: 3 })!; + + for (let i = 0; i < 3; i += 1) { + await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + } + // The size-triggered flush was started by the third enqueue; join it + // without arming or running the timer, which is still unarmed. + await writer.flush(); + expect(await ledgerRows(engine)).toHaveLength(3); + }); + + it('a ledger write failure never reaches the read, and reports once at `error`', async () => { + const errors: string[] = []; + const debugs: string[] = []; + const writer = installReadAuditWriter(engine, { + objects: ['contact'], + timers: makeManualTimers(), + logger: { error: (m: string) => errors.push(m), debug: (m: string) => debugs.push(m) }, + })!; + // Break the ledger AFTER install, so the probe has already run. + (engine as any).insert = async () => { throw new Error('no such table: sys_audit_log'); }; + + // The read still succeeds — an audit write must never turn a valid read + // into an error. + await expect( + engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any), + ).resolves.toMatchObject({ id: 'c1' }); + await expect(writer.flush()).resolves.toBeUndefined(); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('Read-audit write FAILED'); + expect(errors[0]).toContain('who viewed this record'); + }); +}); + +describe('#8992 record-detail views ONLY — the deferral of list auditing is real', () => { + let engine: ObjectQL; + let writer: ReturnType; + + beforeEach(async () => { + engine = await makeEngine(); + await engine.insert('contact', { id: 'c1', full_name: 'A', organization_id: 'org_a' }, { context: { isSystem: true } } as any); + await engine.insert('contact', { id: 'c2', full_name: 'B', organization_id: 'org_a' }, { context: { isSystem: true } } as any); + writer = installReadAuditWriter(engine, { objects: ['contact'], timers: makeManualTimers() }); + }); + + it('a LIST read produces no row, even one that returns a single record', async () => { + const many = await engine.find('contact', { where: { organization_id: 'org_a' }, context: viewerCtx } as any); + expect(many).toHaveLength(2); + const one = await engine.find('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + expect(one).toHaveLength(1); + + await writer!.flush(); + // `find` is the deferred surface. A single-row list result is still a list + // result — the deferral holds on result SHAPE, not on row count. + expect(await ledgerRows(engine)).toHaveLength(0); + }); + + it('a findOne WITHOUT a primary-key pin produces no row', async () => { + // "Give me a contact called A" is an internal lookup, not someone opening + // a record — and the platform makes many of them. + await engine.findOne('contact', { where: { full_name: 'A' }, context: viewerCtx } as any); + await writer!.flush(); + expect(await ledgerRows(engine)).toHaveLength(0); + }); + + it('a findOne that matched NOTHING produces no row', async () => { + await engine.findOne('contact', { where: { id: 'nope' }, context: viewerCtx } as any); + await writer!.flush(); + expect(await ledgerRows(engine)).toHaveLength(0); + }); +}); + +describe('#8992 the predicate walk survives the middleware that rewrites it', () => { + // The security middleware AND-composes its RLS predicates onto `ast.where` + // before the driver runs, so the detector never sees the caller's own + // spelling. These cases are that rewrite, unit-level. + it('accepts an id pin AND-composed with a tenant wall', () => { + expect( + extractDetailReadId({ $and: [{ id: 'c1' }, { organization_id: 'org_a' }] }, { id: 'c1' }), + ).toBe('c1'); + }); + + it('accepts the explicit $eq spelling', () => { + expect(extractDetailReadId({ id: { $eq: 'c1' } }, { id: 'c1' })).toBe('c1'); + }); + + it('REFUSES an id under $or — the row may have matched the other arm', () => { + expect( + extractDetailReadId({ $or: [{ id: 'c1' }, { full_name: 'A' }] }, { id: 'c1' }), + ).toBeNull(); + }); + + it('REFUSES an id pin nested under $not', () => { + expect(extractDetailReadId({ $not: { id: 'c1' } }, { id: 'c1' })).toBeNull(); + }); + + it('REFUSES an array result — that is a list read', () => { + expect(extractDetailReadId({ id: 'c1' }, [{ id: 'c1' }])).toBeNull(); + expect(extractDetailReadId({ id: 'c1' }, [])).toBeNull(); + }); + + it('REFUSES a null result — nothing was viewed', () => { + expect(extractDetailReadId({ id: 'c1' }, null)).toBeNull(); + }); + + it('REFUSES an $in over ids — a two-record fetch is not a record-detail view', () => { + expect(extractDetailReadId({ id: { $in: ['c1', 'c2'] } }, { id: 'c1' })).toBeNull(); + }); +}); + +describe('#8992 who the row names — and who it deliberately does not', () => { + let engine: ObjectQL; + + beforeEach(async () => { + engine = await makeEngine(); + await engine.insert( + 'contact', + { id: 'c1', full_name: 'Wei Zhang', organization_id: 'org_a' }, + { context: { isSystem: true } } as any, + ); + }); + + it('a SYSTEM-elevated read produces no row — the declared boundary', async () => { + const writer = installReadAuditWriter(engine, { objects: ['contact'], timers: makeManualTimers() })!; + // `api.sudo()` is `{ ...ctx, isSystem: true }` — it KEEPS the caller's + // userId, so without this check every formula recompute and roll-up would + // land in the ledger as "alice viewed this record". + await engine.findOne( + 'contact', + { where: { id: 'c1' }, context: { ...viewerCtx, isSystem: true } } as any, + ); + await writer.flush(); + expect(await ledgerRows(engine)).toHaveLength(0); + }); + + it('a read with NO principal produces no row', async () => { + const writer = installReadAuditWriter(engine, { objects: ['contact'], timers: makeManualTimers() })!; + await engine.findOne('contact', { where: { id: 'c1' } } as any); + await writer.flush(); + expect(await ledgerRows(engine)).toHaveLength(0); + }); + + it('a service principal is attributable on `actor` with a null user_id', async () => { + const writer = installReadAuditWriter(engine, { objects: ['contact'], timers: makeManualTimers() })!; + await engine.findOne( + 'contact', + { where: { id: 'c1' }, context: { actor: 'svc:export-worker', tenantId: 'org_a' } } as any, + ); + await writer.flush(); + + const rows = await ledgerRows(engine); + expect(rows).toHaveLength(1); + expect(rows[0].actor).toBe('svc:export-worker'); + expect(rows[0].user_id ?? null).toBeNull(); + }); + + it("the row is stamped with the RECORD's organization, not just the viewer's", async () => { + const writer = installReadAuditWriter(engine, { objects: ['contact'], timers: makeManualTimers() })!; + // #8287's ruling, carried onto the read row: stamped with the VIEWER's + // active org, a row about an org_a record would land behind org_b's wall — + // invisible to the one tenant admin it concerns. + await engine.findOne( + 'contact', + { where: { id: 'c1' }, context: { userId: 'u_bob', tenantId: 'org_b' } } as any, + ); + await writer.flush(); + + const rows = await ledgerRows(engine); + expect(rows[0].tenant_id).toBe('org_a'); + expect(rows[0].organization_id).toBe('org_a'); + }); +}); + +describe('#8992 what the row must NOT contain, and when it says it happened', () => { + let engine: ObjectQL; + + beforeEach(async () => { + engine = await makeEngine(); + await engine.insert( + 'contact', + { id: 'c1', full_name: 'Wei Zhang', id_number: '310101199001010011' }, + { context: { isSystem: true } } as any, + ); + }); + + /** + * `afterFind` runs INSIDE the security middleware, before its field masking + * (`security-plugin.ts` masks after `next()`), so `ctx.result` is pre-mask + * plaintext. Copying values into the ledger would mint a plaintext copy of + * exactly the data field-level security withholds — inside the one table + * compliance staff get broad access to. This asserts the values are absent, + * and asserts the sensitive value is nowhere in the serialized row at all. + */ + it('records NO field values — the row is who/what/when, never the data', async () => { + const writer = installReadAuditWriter(engine, { objects: ['contact'], timers: makeManualTimers() })!; + await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + await writer.flush(); + + const rows = await ledgerRows(engine); + expect(rows[0].old_value ?? null).toBeNull(); + expect(rows[0].new_value ?? null).toBeNull(); + expect(JSON.stringify(rows[0])).not.toContain('310101199001010011'); + expect(JSON.stringify(rows[0])).not.toContain('Wei Zhang'); + }); + + /** + * Batching moves the INSERT off the request path, which is exactly what makes + * `created_at`'s `NOW()` default wrong here: it would stamp the whole batch + * with the moment the buffer drained. The engine strips a client-supplied + * `created_at` from ordinary writes (#4447) and exempts system-context writes + * — the writer relies on that exemption, so this pins both halves. + */ + it('records the VIEW instant, not the flush instant', async () => { + const viewedAt = new Date('2026-08-18T09:15:00.000Z'); + const writer = installReadAuditWriter(engine, { + objects: ['contact'], + timers: makeManualTimers(), + now: () => viewedAt, + })!; + + await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + // Drain much later than the view. + await new Promise((r) => setTimeout(r, 25)); + await writer.flush(); + + const rows = await ledgerRows(engine); + const stored = new Date(rows[0].created_at as string).toISOString(); + expect(stored).toBe(viewedAt.toISOString()); + }); + + it('two views of the same record are two rows — a view is an event, not a state', async () => { + const writer = installReadAuditWriter(engine, { objects: ['contact'], timers: makeManualTimers() })!; + await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + await engine.findOne('contact', { where: { id: 'c1' }, context: { userId: 'u_bob' } } as any); + await writer.flush(); + + const rows = await ledgerRows(engine); + expect(rows).toHaveLength(2); + expect(rows.map((r) => r.user_id).sort()).toEqual(['u_alice', 'u_bob']); + }); +}); + +describe('#8992 shutdown drains the tail', () => { + it('stop() flushes what is buffered and disarms the timer', async () => { + const engine = await makeEngine(); + await engine.insert('contact', { id: 'c1', full_name: 'A' }, { context: { isSystem: true } } as any); + const timers = makeManualTimers(); + const writer = installReadAuditWriter(engine, { objects: ['contact'], timers })!; + + await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + expect(writer.pending()).toBe(1); + + await writer.stop(); + expect(timers.armed()).toBe(false); + expect(await ledgerRows(engine)).toHaveLength(1); + + // After stop the writer is inert — a late read cannot resurrect the buffer. + await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + expect(writer.pending()).toBe(0); + }); +}); diff --git a/packages/plugins/plugin-audit/src/read-audit.ts b/packages/plugins/plugin-audit/src/read-audit.ts new file mode 100644 index 0000000000..7ce03450e8 --- /dev/null +++ b/packages/plugins/plugin-audit/src/read-audit.ts @@ -0,0 +1,615 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#8992] Read/view audit — the `read` action in the compliance ledger. + * + * ## What this closes + * + * `sys_audit_log` covered WRITES only. `actionFor()` in `audit-writers.ts` maps + * exactly `afterInsert`/`afterUpdate`/`afterDelete`, and the shipped list views + * confirmed the scope, so the platform could not answer the one question every + * regulated-industry security review opens with: **"who viewed this customer + * record, and when?"** The only honest answer on a coverage matrix was + * "requires customisation". + * + * ## The ruling this implements (maintainer, 2026-08-16, ruled jointly with + * #8993 as the enterprise compliance bundle) + * + * Option A, scoped MVP. The scope pins are binding, and each one is a line of + * code in this file rather than a sentence about it: + * + * - **Record-detail views only.** {@link extractDetailReadId} is the whole + * definition: a read qualifies when it materialized ONE record and its + * predicate pinned the primary key. A list/search read never produces a + * row — list auditing is deferred to a follow-up on measured pull, and a + * deferral that leaks rows anyway is not a deferral. + * - **Per-object opt-in, closed.** {@link installReadAuditWriter} takes the + * opted-in object names and registers on exactly those. There is no global + * flag and no exception list. + * - **Async batched writes off the request path.** The hook ENQUEUES and + * returns; {@link createReadAuditBatcher} persists later. The read that + * produced the row never awaits its write. + * + * ## Why the opt-in is an INSTALL-TIME LIST and not an object-metadata key + * + * The neutral seam belongs in this package (open runtime); the policy — + * *which* objects a deployment audits — belongs to the caller, which in the + * enterprise packaging is `@objectstack/security-enterprise` composing on top. + * A list is the whole seam: the enterprise policy engine computes it and hands + * it over, and the open edition wires it from plugin config. + * + * ⛔ It is deliberately ONE input, not a registration target plus a separate + * runtime predicate. Those would be two surfaces that can disagree, and the + * disagreement is silent in the worst direction: a policy naming an object the + * registration never targeted declares coverage that produces no row. On an + * audit surface that is worse than a missing feature — a compliance reviewer + * reads the declaration as coverage (ADR-0049 dead surface; the same argument + * `audit-writers.ts` makes for deriving `AUDIT_EXCLUDED_OBJECTS` from + * `SKIP_OBJECTS` rather than re-typing it). + * + * An object-metadata key (`enable.auditReads`) is the natural ObjectStack + * spelling and may well be the right follow-up, but `ObjectCapabilities` is a + * `strictObject` in `packages/spec` — a spec-side key, which routes to the spec + * seat rather than landing here. + * + * ## ⛔ The row records NO field values, and that is load-bearing + * + * `SecurityPlugin` masks fields in MIDDLEWARE, after `next()` — i.e. **after** + * the `afterFind` hooks this writer runs in (`security-plugin.ts`, step 4 of + * the read middleware). So `ctx.result` here is the PRE-MASK record: it still + * holds the plaintext of every field the caller was about to have masked or + * deleted, including the #8993 `maskingRule` channel's partial masks. + * + * Copying values into `old_value` / `new_value` would therefore mint a + * plaintext copy of exactly the data field-level security exists to withhold, + * inside the one table compliance staff are granted broad access to. Both stay + * `null`, and the row records WHO looked at WHICH record — which is the + * question the card asks. + * + * This is also why the trail does not derive "what did this viewer actually + * see". That answer exists and has ONE implementation — + * `SecurityPlugin.computeReadPartialMaskRules`, which #9127 lifted so explain's + * fls layer, result masking and `getReadableFields` all read it — and a second + * derivation minted here would drift from it. Recording the viewer's masking + * state is a follow-up that must READ that lifted implementation, never + * re-derive it. + * + * ## Declared boundary: system-elevated reads produce no row + * + * A read carrying `session.isSystem` is the platform reading for its own + * bookkeeping — a formula recompute, a roll-up, a trigger, any `api.sudo()` + * path (`sudo()` is `{ ...ctx, isSystem: true }`, so it keeps the caller's + * `userId`). Those are not "a person opened this record", and recording them + * would bury the human views this ledger exists to make findable. Skipping + * them is a NARROWING, and it is declared here and pinned in + * `read-audit.test.ts` rather than left to be discovered — 审计面宁窄勿谎, but + * the narrow edge has to be visible to be honest. + */ + +import type { HookContext } from '@objectstack/spec/data'; +import type { IDataEngine } from '@objectstack/spec/contracts'; +// DERIVED, never re-typed — the same rule `audit-writers.ts` states for its own +// two faces. An object excluded from write auditing (recursion, auth/session +// noise, ADR-0057 telemetry plumbing) is excluded from read auditing for the +// identical reasons, and a second hand-kept list would disagree on the day +// either is fixed. +import { AUDIT_EXCLUDED_OBJECTS, createFieldPresenceProbe } from './audit-writers.js'; + +/** + * The ledger action this writer emits. + * + * Declared as a const rather than spelled inline at the insert: every + * `sys_audit_log` field is `readonly: true` and `validateRecord` skips readonly + * fields, so the `action` enum validates NOTHING in either direction — a + * misspelled action is accepted silently and no test that watches for a throw + * can see it. The closed-literal posture is the only structural protection + * available, and it is the same one `AuthSessionAuditAction` takes. + * `objects/sys-audit-log-retired-actions.test.ts` pins that this value is + * declared by the enum and that the enum declares nothing without a writer. + */ +export const READ_AUDIT_ACTION = 'read'; + +/** Minimal logger surface — structurally the kernel `ctx.logger` (`ILogger`). */ +export interface ReadAuditLogger { + error?(msg: string, err?: Error, meta?: Record): void; + warn?(msg: string, meta?: Record): void; + debug?(msg: string, meta?: Record): void; +} + +/** + * One record-detail view, in the vocabulary of what HAPPENED rather than of + * what to store — the same posture `AuthSessionAuditEvent` takes, and for the + * same reason: plugin-audit owns the `sys_audit_log` row shape, so callers hand + * over an event and never assemble a row. + */ +export interface ReadAuditEvent { + /** Object whose record was opened. */ + objectName: string; + /** The record's id. */ + recordId: string; + /** When it was opened — the VIEW instant, not the flush instant. */ + viewedAt: Date; + /** The `sys_user` subject that opened it, when there is one. */ + userId?: string; + /** Principal label (a user id, or `svc:`) — ADR-0014 D2. */ + actor?: string; + /** Tenant context: the record's own organization, else the session's. */ + organizationId?: string; +} + +/** Handle returned by {@link createReadAuditBatcher}. */ +export interface ReadAuditBatcher { + /** Record a view. Returns immediately — never awaits the ledger write. */ + enqueue(event: ReadAuditEvent): void; + /** Persist everything buffered right now. */ + flush(): Promise; + /** How many views are buffered and not yet persisted. */ + pending(): number; + /** Cancel the timer and flush what is buffered. */ + stop(): Promise; +} + +/** Timer seam — injectable so the interval path is testable without wall time. */ +export interface ReadAuditTimers { + set(fn: () => void, ms: number): unknown; + clear(handle: unknown): void; +} + +const DEFAULT_TIMERS: ReadAuditTimers = { + set(fn, ms) { + const t = setTimeout(fn, ms); + // Never hold the process open for an audit flush: `stop()` is what + // guarantees the tail lands on a clean shutdown. + (t as unknown as { unref?: () => void }).unref?.(); + return t; + }, + clear(handle) { + clearTimeout(handle as ReturnType); + }, +}; + +export interface ReadAuditBatcherOptions { + /** Persist one drained batch. Must not be called concurrently with itself. */ + persist(events: ReadAuditEvent[]): Promise; + /** Flush as soon as this many views are buffered. Default 50. */ + maxBatchSize?: number; + /** Flush this long after the first view of a batch. Default 2000ms. */ + flushIntervalMs?: number; + /** + * Hard ceiling on the buffer. Beyond it the OLDEST buffered views are + * dropped, loudly, once. A ledger that consumes unbounded memory during a + * database outage takes the whole process with it, which loses far more than + * the views it was protecting. Default 10000. + */ + maxBufferedEvents?: number; + logger?: ReadAuditLogger; + timers?: ReadAuditTimers; +} + +/** + * Buffer record-detail views and persist them in batches, off the request path. + * + * Reads vastly outnumber writes, and the RFI that produced this card carries a + * 2s record-open budget — so the read path must not pay for a ledger INSERT. + * `enqueue` is synchronous and allocation-only; the write happens on a later + * tick, from the timer or from a size-triggered flush. + * + * Failure posture matches `auth-event-audit.ts`: a lost batch is reported ONCE + * per process and dropped, never retried in a loop. An audit write must never + * turn a valid read into an error, and a retry storm against an unreachable + * table is the shape that turns a degradation into an outage. + */ +export function createReadAuditBatcher(opts: ReadAuditBatcherOptions): ReadAuditBatcher { + const { + persist, + maxBatchSize = 50, + flushIntervalMs = 2000, + maxBufferedEvents = 10_000, + logger, + timers = DEFAULT_TIMERS, + } = opts; + + let buffer: ReadAuditEvent[] = []; + let timer: unknown = null; + /** Serializes flushes so two never interleave against the same driver. */ + let inFlight: Promise = Promise.resolve(); + let stopped = false; + + let overflowReported = false; + const reportOverflow = (dropped: number): void => { + if (overflowReported) { + logger?.debug?.('Read-audit buffer overflow (already reported)', { dropped }); + return; + } + overflowReported = true; + logger?.warn?.( + 'Read-audit buffer OVERFLOWED — record-view rows are being DROPPED, so the compliance trail now has ' + + 'holes that no error anywhere else will show: every read still succeeded and returned 200. The buffer ' + + 'only grows this far when ledger writes are not draining (an unreachable `sys_audit_log`, or a view rate ' + + 'above what the datasource can absorb). This is reported ONCE — raise the log level to `debug` to see ' + + 'the rest. Fix: confirm `sys_audit_log` is writable from this process, then lower `flushIntervalMs` or ' + + 'raise `maxBatchSize` so batches drain faster than views arrive.', + { dropped, maxBufferedEvents }, + ); + }; + + const cancelTimer = (): void => { + if (timer !== null) { + timers.clear(timer); + timer = null; + } + }; + + const drainOnce = async (): Promise => { + if (buffer.length === 0) return; + const batch = buffer; + buffer = []; + await persist(batch); + }; + + const runFlush = (): Promise => { + cancelTimer(); + // Chain onto whatever is already running rather than racing it. The catch + // is `persist`'s own contract: it reports and swallows, so this chain can + // never be left rejected and poison every later flush. + inFlight = inFlight.then(drainOnce, drainOnce); + return inFlight; + }; + + const armTimer = (): void => { + if (stopped || timer !== null) return; + timer = timers.set(() => { + timer = null; + void runFlush(); + }, flushIntervalMs); + }; + + return { + enqueue(event: ReadAuditEvent): void { + if (stopped) return; + buffer.push(event); + if (buffer.length > maxBufferedEvents) { + const dropped = buffer.length - maxBufferedEvents; + buffer.splice(0, dropped); + reportOverflow(dropped); + } + if (buffer.length >= maxBatchSize) { + void runFlush(); + return; + } + armTimer(); + }, + flush(): Promise { + return runFlush(); + }, + pending(): number { + return buffer.length; + }, + async stop(): Promise { + stopped = true; + cancelTimer(); + await runFlush(); + }, + }; +} + +/** + * The record-detail discriminator — the MVP scope pin, as code. + * + * A read is a record-detail view when BOTH hold: + * + * 1. it materialized ONE record rather than a collection. `find()` sets + * `ctx.result` to an array on every path (`[]` when nothing matched); + * `findOne()` sets it to the record or `null`. One `afterFind` event covers + * both verbs (#3195), so the result's shape is the only structural signal + * of which one ran — and it is a reliable one. + * 2. its predicate PINNED the primary key. `GET /data/:object/:id` reaches the + * engine as `findOne(object, { where: { id } })` (`protocol.ts` `getData`), + * which is the record-detail surface. A `findOne` with any other predicate + * is "give me *a* matching record" — an internal lookup, not someone + * opening a record. + * + * Requiring (2) rather than accepting every `findOne` is what keeps the + * deferral of list/search auditing real, and keeps the platform's own by-name + * lookups out of a ledger that is supposed to answer a question about people. + * + * The predicate walk tolerates the shape the read middleware actually leaves + * behind: `SecurityPlugin` AND-composes its RLS predicates onto `ast.where` + * BEFORE the driver runs, so by the time this sees it, `{ id: 'x' }` has often + * become `{ $and: [{ id: 'x' }, { organization_id: 'o' }] }`. `$or` and `$not` + * are refused outright — under either, the id equality no longer proves the + * read was FOR that record. + * + * @returns the pinned record id, or `null` when this read is not a detail view. + */ +export function extractDetailReadId(where: unknown, result: unknown): string | null { + // (1) One materialized record. + if (Array.isArray(result) || result === null || result === undefined) return null; + if (typeof result !== 'object') return null; + const resultId = (result as Record).id; + if (typeof resultId !== 'string' && typeof resultId !== 'number') return null; + + // (2) A primary-key pin somewhere in the AND-closure of the predicate. + const pinned = findIdPin(where, 0); + if (pinned === null) return null; + + return String(resultId); +} + +/** Max `$and` nesting walked — a guard against a pathological/cyclic predicate. */ +const MAX_PREDICATE_DEPTH = 8; + +function findIdPin(node: unknown, depth: number): string | null { + if (depth > MAX_PREDICATE_DEPTH) return null; + if (!node || typeof node !== 'object' || Array.isArray(node)) return null; + const obj = node as Record; + + // `$or` / `$not` anywhere on the path breaks the proof: the row may have + // matched through the other arm, so an `id` equality below one of them does + // not mean the caller asked for THIS record. + if ('$or' in obj || '$not' in obj) return null; + + const idClause = obj.id; + if (typeof idClause === 'string' || typeof idClause === 'number') return String(idClause); + if (idClause && typeof idClause === 'object' && !Array.isArray(idClause)) { + const eq = (idClause as Record).$eq; + if (typeof eq === 'string' || typeof eq === 'number') return String(eq); + } + + const and = obj.$and; + if (Array.isArray(and)) { + for (const member of and) { + const found = findIdPin(member, depth + 1); + if (found !== null) return found; + } + } + return null; +} + +/** Handle returned by {@link installReadAuditWriter}. */ +export interface ReadAuditWriterHandle { + /** The objects this writer is installed on — the closed opt-in set. */ + readonly auditedObjects: readonly string[]; + /** Persist every buffered view now. */ + flush(): Promise; + /** Views buffered and not yet persisted. */ + pending(): number; + /** Cancel the timer and flush the tail. */ + stop(): Promise; +} + +export interface ReadAuditWriterOptions { + /** + * The per-object opt-in — the closed set of objects whose record-detail views + * are recorded. Empty (or all-excluded) installs nothing at all: no hook is + * registered, so a deployment that opts nothing in pays nothing. + */ + objects: readonly string[]; + packageId?: string; + logger?: ReadAuditLogger; + maxBatchSize?: number; + flushIntervalMs?: number; + maxBufferedEvents?: number; + timers?: ReadAuditTimers; + /** Clock seam — the view instant stamped on the row. Defaults to `Date`. */ + now?: () => Date; +} + +/** + * Install the record-view writer on the engine. + * + * Registers ONE `afterFind` hook, targeted at exactly the opted-in objects, so + * a read of any other object never dispatches into this package at all. That + * narrow registration is the per-object opt-in's enforcement — not a wildcard + * hook with an early return, which would pay a dispatch on every read in the + * system to answer "no". + */ +export function installReadAuditWriter( + engine: IDataEngine, + opts: ReadAuditWriterOptions, +): ReadAuditWriterHandle | null { + const eng = engine as unknown as { + registerHook?: (event: string, handler: (ctx: HookContext) => unknown, options?: unknown) => unknown; + }; + if (!engine || typeof eng.registerHook !== 'function') return null; + + const { packageId = 'com.objectstack.audit', logger, now = () => new Date() } = opts; + + const excluded = new Set(AUDIT_EXCLUDED_OBJECTS); + // De-duplicated, exclusion-filtered, and order-stable so the handle reports + // exactly what was registered rather than what was asked for. + const auditedObjects = [...new Set(opts.objects ?? [])].filter((name) => { + if (typeof name !== 'string' || name.trim().length === 0) return false; + if (excluded.has(name)) { + logger?.warn?.( + `Read audit: '${name}' is on the audit exclusion list (recursion / auth-session noise / ADR-0057 ` + + 'telemetry plumbing) and will NOT have its record views recorded. Remove it from the read-audit ' + + 'opt-in so the configuration stops claiming coverage this writer does not provide.', + { object: name }, + ); + return false; + } + return true; + }); + + if (auditedObjects.length === 0) return null; + + const objectHasField = createFieldPresenceProbe(engine); + + /** + * Write one batch of ledger rows. + * + * Extracted as a NAMED callee so `pnpm check:durability-log-level` can anchor + * on it — it is declared in that gate's `DURABILITY_CRITICAL_CALLEES` in the + * same PR, so a future edit cannot quietly walk the failure report back down + * to `warn`. A bare `.insert()` is far too generic a name for a repo-wide + * vocabulary. Same reasoning as `persistAuditTrailRow` / `persistAuthEventAuditRow`. + */ + const persistReadAuditRows = async (rows: Record[]): Promise => { + // `sys_audit_log` exposes only `get`/`list` on the API and every field is + // `readonly`, so a user-context write would be refused. The system context + // is also what lets the row keep its VIEW timestamp — see `buildRow`. + await engine.insert('sys_audit_log', rows as any, { context: { isSystem: true } } as any); + }; + + let failureReported = false; + const reportReadAuditWriteFailure = (count: number, err: unknown): void => { + const detail = String((err as any)?.message ?? err); + try { + if (failureReported) { + logger?.debug?.('Read-audit write failed (already reported)', { count, err: detail }); + return; + } + failureReported = true; + logger?.error?.( + `Read-audit write FAILED — ${count} record-view row(s) were LOST and the compliance trail is now ` + + 'INCOMPLETE. The reads themselves SUCCEEDED and returned 200, so the API, the screens and every ' + + 'counter read clean; only the `sys_audit_log` rows recording WHO opened those records never landed, ' + + 'and nothing retries them. Every subsequent batch is likely lost the same way (this is reported ONCE ' + + '— raise the log level to `debug` to see the rest). The whole point of this capability is answering ' + + '"who viewed this record" for an auditor, so the failure mode is a query that returns a confident, ' + + 'wrong, SHORT answer. Fix: confirm `sys_audit_log` is reachable from the connection this write ran ' + + 'on — its ADR-0057 §3.6 lifecycle class routes it to the dedicated `telemetry` datasource whenever ' + + 'one is registered (`os dev` provisions one by default as a SIBLING SQLite file), so a "no such ' + + 'table" here usually means the write executed against a DIFFERENT datasource than the one the table ' + + 'was created in. Set `OS_TELEMETRY_DB=0` to keep every lifecycle-classed object on the primary ' + + 'datasource.', + err instanceof Error ? err : new Error(detail), + { count }, + ); + } catch { + /* logging must never break the read */ + } + }; + + const buildRow = (event: ReadAuditEvent): Record => { + const tenantId = event.organizationId ?? null; + const row: Record = { + action: READ_AUDIT_ACTION, + // ⛔ The VIEW instant, not the flush instant. Batching moves the INSERT + // off the request path by design, so `created_at`'s `NOW()` default would + // stamp every row in a batch with one flush timestamp up to + // `flushIntervalMs` after the fact — a ledger that answers "when did they + // look?" with the time its own buffer drained. `created_at` is + // engine-owned and stripped from ordinary writes (#4447), and a + // system-context write is the declared exemption (pinned by + // `engine-audit-anchor-write.test.ts`: "a system-context write is still + // exempt"), which is exactly the context `persistReadAuditRows` uses. + created_at: event.viewedAt, + user_id: event.userId ?? null, + object_name: event.objectName, + record_id: event.recordId, + // ⛔ Both stay null — see this module's header. `afterFind` runs INSIDE + // the security middleware, before its field masking, so `ctx.result` here + // is pre-mask plaintext. A "view" is not a field diff either: recording + // `{}` would be a claim about a record that never changed. + old_value: null, + new_value: null, + tenant_id: tenantId, + }; + // Both columns are conditionally present — see `createFieldPresenceProbe`. + // `organization_id` is what the SecurityPlugin's RLS predicate gates on, so + // an unstamped row is a row non-admin members can never see. + if (objectHasField('sys_audit_log', 'organization_id')) { + row.organization_id = tenantId; + } + if (objectHasField('sys_audit_log', 'actor')) { + row.actor = event.actor ?? event.userId ?? null; + } + return row; + }; + + const batcher = createReadAuditBatcher({ + async persist(events) { + try { + await persistReadAuditRows(events.map(buildRow)); + } catch (err) { + reportReadAuditWriteFailure(events.length, err); + } + }, + ...(opts.maxBatchSize !== undefined ? { maxBatchSize: opts.maxBatchSize } : {}), + ...(opts.flushIntervalMs !== undefined ? { flushIntervalMs: opts.flushIntervalMs } : {}), + ...(opts.maxBufferedEvents !== undefined ? { maxBufferedEvents: opts.maxBufferedEvents } : {}), + ...(logger !== undefined ? { logger } : {}), + ...(opts.timers !== undefined ? { timers: opts.timers } : {}), + }); + + const recordView = (ctx: HookContext): void => { + const session = ((ctx as any).session ?? {}) as Record; + // Declared boundary — see this module's header. `sudo()` keeps the caller's + // `userId`, so this flag is the ONLY thing separating "a person opened this + // record" from "the platform read it while doing something else". + if (session.isSystem === true) return; + + const userId = typeof session.userId === 'string' && session.userId ? session.userId : undefined; + const actor = + typeof session.actor === 'string' && session.actor.trim() ? session.actor.trim() : undefined; + // No principal, no answer to "who". A row naming nobody adds noise to the + // one query this capability exists to serve. + if (!userId && !actor) return; + + const recordId = extractDetailReadId((ctx as any).input?.ast?.where, ctx.result); + if (recordId === null) return; + + const organizationId = + readRecordOrganization(ctx.result) ?? + (typeof session.organizationId === 'string' && session.organizationId + ? session.organizationId + : undefined); + + batcher.enqueue({ + objectName: ctx.object, + recordId, + viewedAt: now(), + ...(userId !== undefined ? { userId } : {}), + ...(actor !== undefined ? { actor } : {}), + ...(organizationId !== undefined ? { organizationId } : {}), + }); + }; + + /** + * The hook. Synchronous by design and wrapped whole: it must add no awaited + * work to the read it observes, and an audit failure must never turn a valid + * read into an error. + */ + const auditRead = (ctx: HookContext): void => { + try { + recordView(ctx); + } catch (err) { + logger?.debug?.('Read-audit hook skipped a read', { + object: ctx?.object, + err: String((err as any)?.message ?? err), + }); + } + }; + + eng.registerHook('afterFind', auditRead, { object: [...auditedObjects], packageId }); + + return { + auditedObjects, + flush: () => batcher.flush(), + pending: () => batcher.pending(), + stop: () => batcher.stop(), + }; +} + +/** + * The record's own organization, when it carries one. + * + * Deliberately the same precedence the CRUD writer settled on under the #8287 + * ruling: the audited RECORD'S organization wins, the acting session's active + * organization is the fallback. An audit row is read through `sys_audit_log`'s + * own tenant wall, so a row about an org-A record stamped with the viewer's + * active org B lands behind B's wall — invisible to the one tenant admin the + * row concerns. + * + * Read straight off the returned record rather than through + * `resolveRecordOrganizationField`: a read result is the materialized row, so + * the column is either on it or it is not, and the platform-default + * `organization_id` is the only spelling `sys_audit_log`'s own wall gates on. + */ +function readRecordOrganization(record: unknown): string | undefined { + if (!record || typeof record !== 'object' || Array.isArray(record)) return undefined; + const v = (record as Record).organization_id; + return typeof v === 'string' && v.length > 0 ? v : undefined; +} diff --git a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts index 72a74a97d4..31dca5e80b 100644 --- a/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/en.objects.generated.ts @@ -22,6 +22,7 @@ export const enObjects: NonNullable = { help: "Action type (snake_case)", options: { create: "create", + read: "read", update: "update", delete: "delete", login: "login", @@ -86,6 +87,13 @@ export const enObjects: NonNullable = { auth_events: { label: "Auth" }, + record_views: { + label: "Record Views", + emptyState: { + title: "No record views recorded", + message: "Record-view auditing is opt-in per object. Rows appear here once an object is added to the audit plugin's readAudit.objects list and someone opens one of its records." + } + }, config_changes: { label: "Config" }, diff --git a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts index adbc7f6b8f..fffa53d40e 100644 --- a/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/es-ES.objects.generated.ts @@ -22,6 +22,7 @@ export const esESObjects: NonNullable = { help: "Tipo de acción (snake_case).", options: { create: "Crear", + read: "Lectura", update: "Actualizar", delete: "Eliminar", login: "Inicio de sesión", @@ -86,6 +87,13 @@ export const esESObjects: NonNullable = { auth_events: { label: "Autenticación" }, + record_views: { + label: "Vistas de registro", + emptyState: { + title: "No hay vistas de registro", + message: "La auditoría de vistas de registro se activa por objeto. Las filas aparecen aquí cuando se añade un objeto a la lista readAudit.objects del plugin de auditoría y alguien abre uno de sus registros." + } + }, config_changes: { label: "Configuración" }, diff --git a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts index 7125e80552..8eaf186cfd 100644 --- a/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/ja-JP.objects.generated.ts @@ -22,6 +22,7 @@ export const jaJPObjects: NonNullable = { help: "アクションタイプ(snake_case)", options: { create: "作成", + read: "閲覧", update: "更新", delete: "削除", login: "ログイン", @@ -86,6 +87,13 @@ export const jaJPObjects: NonNullable = { auth_events: { label: "認証" }, + record_views: { + label: "レコード閲覧", + emptyState: { + title: "レコードの閲覧記録はありません", + message: "レコード閲覧の監査はオブジェクトごとのオプトインです。監査プラグインの readAudit.objects にオブジェクトを追加すると、そのレコードが開かれた際にここに表示されます。" + } + }, config_changes: { label: "構成変更" }, diff --git a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts index 2732f28dc3..efafe9487e 100644 --- a/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-audit/src/translations/zh-CN.objects.generated.ts @@ -22,6 +22,7 @@ export const zhCNObjects: NonNullable = { help: "操作类型(snake_case)", options: { create: "创建", + read: "查看", update: "更新", delete: "删除", login: "登录", @@ -86,6 +87,13 @@ export const zhCNObjects: NonNullable = { auth_events: { label: "认证" }, + record_views: { + label: "记录查看", + emptyState: { + title: "暂无记录查看日志", + message: "记录查看审计按对象开启。将对象加入审计插件的 readAudit.objects 列表后,有人打开其记录时便会显示在这里。" + } + }, config_changes: { label: "配置" }, diff --git a/scripts/check-durability-degradation-log-level.mjs b/scripts/check-durability-degradation-log-level.mjs index f537667a21..6732725ddd 100644 --- a/scripts/check-durability-degradation-log-level.mjs +++ b/scripts/check-durability-degradation-log-level.mjs @@ -187,6 +187,10 @@ const DURABILITY_CRITICAL_CALLEES = new Map([ 'persistAuditTrailRow', 'The compliance audit row was never written — the audited write itself succeeded and returned 200, so the API, the data and every counter read clean, while the `sys_audit_log` entry that records WHO did it is simply absent and nothing retries it. The gap surfaces, if ever, to an auditor who cannot connect it back to the write (#5226, the #4420 shape on the compliance ledger).', ], + [ + 'persistReadAuditRows', + 'A batch of compliance record-VIEW rows was never written — the reads themselves succeeded and returned 200, so the API, the screens and every counter read clean, while the `sys_audit_log` rows recording WHO opened those records are simply absent and nothing retries them. Worse than the write-side shape it mirrors: these rows are written from a BUFFER off the request path, so there is no in-flight request left to notice, and the shipped `record_views` list view answers "who viewed this record" with a confident, wrong, SHORT list (#8992, the #5226 shape on the read seam).', + ], [ 'persistAuthEventAuditRow', 'The compliance audit row for a sign-in / sign-out was never written — the auth request itself succeeded and the user holds a valid session, so the API, the cookie and every counter read clean, while the `sys_audit_log` row recording WHO signed in is simply absent and nothing retries it. The shipped `auth_events` list view and the system-overview widgets read exactly those rows, so the screen an operator checks stays empty and healthy-looking (#8144, the #5226 shape on the auth seam).', From cd67bd26bc485b757435bdcff583662a04548a34 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 06:07:47 +0000 Subject: [PATCH 2/3] 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 --- packages/plugins/plugin-audit/src/read-audit.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-audit/src/read-audit.test.ts b/packages/plugins/plugin-audit/src/read-audit.test.ts index caaece7566..3e4dde026c 100644 --- a/packages/plugins/plugin-audit/src/read-audit.test.ts +++ b/packages/plugins/plugin-audit/src/read-audit.test.ts @@ -150,14 +150,17 @@ function makeManualTimers(): ReadAuditTimers & { run(): void; armed(): boolean } }; } +/** Owning package for the harness objects — `registerObject` requires one. */ +const HARNESS_PACKAGE = 'com.objectstack.audit.test'; + async function makeEngine() { const engine = new ObjectQL(); const { driver } = makeStubDriver(); engine.registerDriver(driver, true); await engine.init(); - engine.registry.registerObject(contactObject as any); - engine.registry.registerObject(invoiceObject as any); - engine.registry.registerObject(auditLogObject as any); + engine.registry.registerObject(contactObject as any, HARNESS_PACKAGE); + engine.registry.registerObject(invoiceObject as any, HARNESS_PACKAGE); + engine.registry.registerObject(auditLogObject as any, HARNESS_PACKAGE); return engine; } From 23b2f70b639f8b2339391afb3cf5848228b966ce Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 06:25:53 +0000 Subject: [PATCH 3/3] test(plugin-audit): type the engine query options instead of erasing them to `any` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../plugin-audit/src/read-audit.test.ts | 74 +++++++++++-------- 1 file changed, 43 insertions(+), 31 deletions(-) diff --git a/packages/plugins/plugin-audit/src/read-audit.test.ts b/packages/plugins/plugin-audit/src/read-audit.test.ts index 3e4dde026c..32add0e33b 100644 --- a/packages/plugins/plugin-audit/src/read-audit.test.ts +++ b/packages/plugins/plugin-audit/src/read-audit.test.ts @@ -24,6 +24,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; +import type { EngineQueryOptions } from '@objectstack/spec/data'; import { installReadAuditWriter, extractDetailReadId, type ReadAuditTimers } from './read-audit.js'; // --------------------------------------------------------------------------- @@ -166,11 +167,22 @@ async function makeEngine() { /** Every ledger row, oldest first. */ async function ledgerRows(engine: ObjectQL): Promise { - return (await engine.find('sys_audit_log', {} as any)) as any[]; + return (await engine.find('sys_audit_log', {})) as any[]; } +/** + * The execution context an engine read carries. + * + * Named and typed rather than erased with `as any` at each call site: the + * engine's options schemas are not `.strict()`, so an unknown key is silently + * DROPPED rather than rejected, and `tsc` is the only channel that catches it + * for an internal caller (#4674). plugin-audit's tsconfig does not exclude + * its test files, so that channel is live over this one. + */ +type ReadContext = NonNullable; + /** An ordinary authenticated caller — a person, not the platform. */ -const viewerCtx = { userId: 'u_alice', tenantId: 'org_a' }; +const viewerCtx: ReadContext = { userId: 'u_alice', tenantId: 'org_a' }; describe('#8992 record-view auditing — the opt-in is enforced, not declared', () => { let engine: ObjectQL; @@ -180,12 +192,12 @@ describe('#8992 record-view auditing — the opt-in is enforced, not declared', await engine.insert( 'contact', { id: 'c1', full_name: 'Wei Zhang', id_number: '310101199001010011', organization_id: 'org_a' }, - { context: { isSystem: true } } as any, + { context: { isSystem: true } }, ); await engine.insert( 'invoice', { id: 'i1', full_name: 'INV-1', organization_id: 'org_a' }, - { context: { isSystem: true } } as any, + { context: { isSystem: true } }, ); }); @@ -193,7 +205,7 @@ describe('#8992 record-view auditing — the opt-in is enforced, not declared', const writer = installReadAuditWriter(engine, { objects: ['contact'] })!; expect(writer).not.toBeNull(); - await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx }); await writer.flush(); const rows = await ledgerRows(engine); @@ -210,7 +222,7 @@ describe('#8992 record-view auditing — the opt-in is enforced, not declared', it('an object that is NOT opted in produces NO row — the engine never dispatches to us', async () => { const writer = installReadAuditWriter(engine, { objects: ['contact'] })!; - await engine.findOne('invoice', { where: { id: 'i1' }, context: viewerCtx } as any); + await engine.findOne('invoice', { where: { id: 'i1' }, context: viewerCtx }); // Nothing was even buffered: the narrow `{ object: ['contact'] }` registration // is the enforcement, so a non-audited read costs no dispatch at all. expect(writer.pending()).toBe(0); @@ -222,7 +234,7 @@ describe('#8992 record-view auditing — the opt-in is enforced, not declared', it('an EMPTY opt-in installs nothing at all', async () => { expect(installReadAuditWriter(engine, { objects: [] })).toBeNull(); - await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx }); expect(await ledgerRows(engine)).toHaveLength(0); }); @@ -247,7 +259,7 @@ describe('#8992 the write is OFF the request path', () => { beforeEach(async () => { engine = await makeEngine(); - await engine.insert('contact', { id: 'c1', full_name: 'Wei Zhang' }, { context: { isSystem: true } } as any); + await engine.insert('contact', { id: 'c1', full_name: 'Wei Zhang' }, { context: { isSystem: true } }); }); /** @@ -261,7 +273,7 @@ describe('#8992 the write is OFF the request path', () => { const timers = makeManualTimers(); const writer = installReadAuditWriter(engine, { objects: ['contact'], timers })!; - const record = await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + const record = await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx }); expect(record).toMatchObject({ id: 'c1' }); // The read is DONE. The view is buffered and the ledger is still empty. @@ -277,7 +289,7 @@ describe('#8992 the write is OFF the request path', () => { const timers = makeManualTimers(); const writer = installReadAuditWriter(engine, { objects: ['contact'], timers, maxBatchSize: 50 })!; - await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx }); expect(timers.armed()).toBe(true); expect(await ledgerRows(engine)).toHaveLength(0); @@ -291,7 +303,7 @@ describe('#8992 the write is OFF the request path', () => { const writer = installReadAuditWriter(engine, { objects: ['contact'], timers, maxBatchSize: 3 })!; for (let i = 0; i < 3; i += 1) { - await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx }); } // The size-triggered flush was started by the third enqueue; join it // without arming or running the timer, which is still unarmed. @@ -313,7 +325,7 @@ describe('#8992 the write is OFF the request path', () => { // The read still succeeds — an audit write must never turn a valid read // into an error. await expect( - engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any), + engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx }), ).resolves.toMatchObject({ id: 'c1' }); await expect(writer.flush()).resolves.toBeUndefined(); @@ -329,15 +341,15 @@ describe('#8992 record-detail views ONLY — the deferral of list auditing is re beforeEach(async () => { engine = await makeEngine(); - await engine.insert('contact', { id: 'c1', full_name: 'A', organization_id: 'org_a' }, { context: { isSystem: true } } as any); - await engine.insert('contact', { id: 'c2', full_name: 'B', organization_id: 'org_a' }, { context: { isSystem: true } } as any); + await engine.insert('contact', { id: 'c1', full_name: 'A', organization_id: 'org_a' }, { context: { isSystem: true } }); + await engine.insert('contact', { id: 'c2', full_name: 'B', organization_id: 'org_a' }, { context: { isSystem: true } }); writer = installReadAuditWriter(engine, { objects: ['contact'], timers: makeManualTimers() }); }); it('a LIST read produces no row, even one that returns a single record', async () => { - const many = await engine.find('contact', { where: { organization_id: 'org_a' }, context: viewerCtx } as any); + const many = await engine.find('contact', { where: { organization_id: 'org_a' }, context: viewerCtx }); expect(many).toHaveLength(2); - const one = await engine.find('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + const one = await engine.find('contact', { where: { id: 'c1' }, context: viewerCtx }); expect(one).toHaveLength(1); await writer!.flush(); @@ -349,13 +361,13 @@ describe('#8992 record-detail views ONLY — the deferral of list auditing is re it('a findOne WITHOUT a primary-key pin produces no row', async () => { // "Give me a contact called A" is an internal lookup, not someone opening // a record — and the platform makes many of them. - await engine.findOne('contact', { where: { full_name: 'A' }, context: viewerCtx } as any); + await engine.findOne('contact', { where: { full_name: 'A' }, context: viewerCtx }); await writer!.flush(); expect(await ledgerRows(engine)).toHaveLength(0); }); it('a findOne that matched NOTHING produces no row', async () => { - await engine.findOne('contact', { where: { id: 'nope' }, context: viewerCtx } as any); + await engine.findOne('contact', { where: { id: 'nope' }, context: viewerCtx }); await writer!.flush(); expect(await ledgerRows(engine)).toHaveLength(0); }); @@ -407,7 +419,7 @@ describe('#8992 who the row names — and who it deliberately does not', () => { await engine.insert( 'contact', { id: 'c1', full_name: 'Wei Zhang', organization_id: 'org_a' }, - { context: { isSystem: true } } as any, + { context: { isSystem: true } }, ); }); @@ -418,7 +430,7 @@ describe('#8992 who the row names — and who it deliberately does not', () => { // land in the ledger as "alice viewed this record". await engine.findOne( 'contact', - { where: { id: 'c1' }, context: { ...viewerCtx, isSystem: true } } as any, + { where: { id: 'c1' }, context: { ...viewerCtx, isSystem: true } }, ); await writer.flush(); expect(await ledgerRows(engine)).toHaveLength(0); @@ -426,7 +438,7 @@ describe('#8992 who the row names — and who it deliberately does not', () => { it('a read with NO principal produces no row', async () => { const writer = installReadAuditWriter(engine, { objects: ['contact'], timers: makeManualTimers() })!; - await engine.findOne('contact', { where: { id: 'c1' } } as any); + await engine.findOne('contact', { where: { id: 'c1' } }); await writer.flush(); expect(await ledgerRows(engine)).toHaveLength(0); }); @@ -435,7 +447,7 @@ describe('#8992 who the row names — and who it deliberately does not', () => { const writer = installReadAuditWriter(engine, { objects: ['contact'], timers: makeManualTimers() })!; await engine.findOne( 'contact', - { where: { id: 'c1' }, context: { actor: 'svc:export-worker', tenantId: 'org_a' } } as any, + { where: { id: 'c1' }, context: { actor: 'svc:export-worker', tenantId: 'org_a' } }, ); await writer.flush(); @@ -452,7 +464,7 @@ describe('#8992 who the row names — and who it deliberately does not', () => { // invisible to the one tenant admin it concerns. await engine.findOne( 'contact', - { where: { id: 'c1' }, context: { userId: 'u_bob', tenantId: 'org_b' } } as any, + { where: { id: 'c1' }, context: { userId: 'u_bob', tenantId: 'org_b' } }, ); await writer.flush(); @@ -470,7 +482,7 @@ describe('#8992 what the row must NOT contain, and when it says it happened', () await engine.insert( 'contact', { id: 'c1', full_name: 'Wei Zhang', id_number: '310101199001010011' }, - { context: { isSystem: true } } as any, + { context: { isSystem: true } }, ); }); @@ -484,7 +496,7 @@ describe('#8992 what the row must NOT contain, and when it says it happened', () */ it('records NO field values — the row is who/what/when, never the data', async () => { const writer = installReadAuditWriter(engine, { objects: ['contact'], timers: makeManualTimers() })!; - await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx }); await writer.flush(); const rows = await ledgerRows(engine); @@ -509,7 +521,7 @@ describe('#8992 what the row must NOT contain, and when it says it happened', () now: () => viewedAt, })!; - await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx }); // Drain much later than the view. await new Promise((r) => setTimeout(r, 25)); await writer.flush(); @@ -521,8 +533,8 @@ describe('#8992 what the row must NOT contain, and when it says it happened', () it('two views of the same record are two rows — a view is an event, not a state', async () => { const writer = installReadAuditWriter(engine, { objects: ['contact'], timers: makeManualTimers() })!; - await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); - await engine.findOne('contact', { where: { id: 'c1' }, context: { userId: 'u_bob' } } as any); + await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx }); + await engine.findOne('contact', { where: { id: 'c1' }, context: { userId: 'u_bob' } }); await writer.flush(); const rows = await ledgerRows(engine); @@ -534,11 +546,11 @@ describe('#8992 what the row must NOT contain, and when it says it happened', () describe('#8992 shutdown drains the tail', () => { it('stop() flushes what is buffered and disarms the timer', async () => { const engine = await makeEngine(); - await engine.insert('contact', { id: 'c1', full_name: 'A' }, { context: { isSystem: true } } as any); + await engine.insert('contact', { id: 'c1', full_name: 'A' }, { context: { isSystem: true } }); const timers = makeManualTimers(); const writer = installReadAuditWriter(engine, { objects: ['contact'], timers })!; - await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx }); expect(writer.pending()).toBe(1); await writer.stop(); @@ -546,7 +558,7 @@ describe('#8992 shutdown drains the tail', () => { expect(await ledgerRows(engine)).toHaveLength(1); // After stop the writer is inert — a late read cannot resurrect the buffer. - await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx } as any); + await engine.findOne('contact', { where: { id: 'c1' }, context: viewerCtx }); expect(writer.pending()).toBe(0); }); });