Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/wise-pugs-attend.md
Original file line numberDiff line numberDiff line change
@@ -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.
83 changes: 83 additions & 0 deletions packages/plugins/plugin-audit/src/audit-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
*
Expand All@@ -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<void> {
// Register audit system objects via the manifest service.
ctx.getService<{ register(m: any): void }>('manifest').register({
Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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<void> {
const writer = this.readAuditWriter;
this.readAuditWriter = null;
if (writer) await writer.stop();
}
}
14 changes: 12 additions & 2 deletions packages/plugins/plugin-audit/src/audit-writers.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand DownExpand Up@@ -187,8 +190,15 @@ const SKIP_OBJECTS = new Set<string>([
* 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<string>([
Expand Down
15 changes: 15 additions & 0 deletions packages/plugins/plugin-audit/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -72,6 +72,7 @@ const RETIRED_ACTIONS: ReadonlyArray<readonly [action: string, prescription: str
*/
const ACTIONS_WITH_WRITERS: ReadonlyArray<readonly [action: string, writer: string]> = [
['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)'],
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand DownExpand Up@@ -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,
Expand Down
Loading
Loading