From ae67ad0063f5d5918908bd2754dcc9ceadcc1a56 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 05:38:22 +0000 Subject: [PATCH] docs(permissions): document record-view auditing, the `read` action's shipped surface (#9540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record-view auditing shipped with #8992 / PR #9515 and was documented only in `packages/plugins/plugin-audit/README.md` — i.e. on npm and GitHub, never on the docs site. A coverage evaluation run from the docs site got silence on exactly the edges that decide the answer: record-detail views only, per-object opt-in, no field values recorded, system-elevated reads excluded. Adds `content/docs/permissions/record-view-auditing.mdx`, verified line by line against `packages/plugins/plugin-audit/src/read-audit.ts` and `audit-plugin.ts` rather than against a neighbouring page. NOT a page in `content/docs/kernel/runtime-services/`. That chapter publishes one page per `services.` slot and its index gate holds every page's declared `Registry slot` to a real production `registerService` call. Record-view auditing has no slot: it is an `afterFind` hook installed from `AuditPlugin`'s constructor. Putting it there would have meant inventing a registry slot to satisfy check #5. It goes in Permissions & Identity instead, the chapter `capabilities/permissions.mdx` already routes readers to for "the full model" including audit. Also: - extends the `capabilities/permissions.mdx` audit bullet, which described the write side only, with the read half and a link; - adds a three-line pointer on `runtime-services/audit-service.mdx` so a reader on the slot page is not left assuming the slot covers reads — the same disambiguation that page already does for the settings audit sink; - regenerates `.claude/workflows/docs-accuracy-audit.js` via the gate's own `--write`, one line, because `check:docs-audit-scope` derives ALL_HANDWRITTEN from `content/docs/` and a new page that is not listed falls out of every "FULL audit" run silently. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja --- .claude/workflows/docs-accuracy-audit.js | 1 + content/docs/capabilities/permissions.mdx | 2 +- .../kernel/runtime-services/audit-service.mdx | 5 + content/docs/permissions/index.mdx | 1 + content/docs/permissions/meta.json | 1 + .../docs/permissions/record-view-auditing.mdx | 201 ++++++++++++++++++ 6 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 content/docs/permissions/record-view-auditing.mdx diff --git a/.claude/workflows/docs-accuracy-audit.js b/.claude/workflows/docs-accuracy-audit.js index 3800f5a953..849c878e9c 100644 --- a/.claude/workflows/docs-accuracy-audit.js +++ b/.claude/workflows/docs-accuracy-audit.js @@ -154,6 +154,7 @@ const ALL_HANDWRITTEN = [ "content/docs/permissions/permissions-matrix.mdx", "content/docs/permissions/positions.mdx", "content/docs/permissions/profiles.mdx", + "content/docs/permissions/record-view-auditing.mdx", "content/docs/permissions/rls.mdx", "content/docs/permissions/sharing-rules.mdx", "content/docs/permissions/sso.mdx", diff --git a/content/docs/capabilities/permissions.mdx b/content/docs/capabilities/permissions.mdx index 848d20747f..a612597dd9 100644 --- a/content/docs/capabilities/permissions.mdx +++ b/content/docs/capabilities/permissions.mdx @@ -20,7 +20,7 @@ People sit in a **business-unit tree** and hold **positions**; reporting lines d ## Safe by default, explainable always - Every object must declare its sharing posture explicitly — "forgot to configure, so everyone can see it" cannot happen. -- **Audit** records who changed what, when, with old and new values; tracked objects expose a history timeline on the record. +- **Audit** records who changed what, when, with old and new values; tracked objects expose a history timeline on the record. Record *views* can be recorded too, opt-in per object, so "who opened this customer record, and when?" has an answer ([Record-View Auditing](/docs/permissions/record-view-auditing)). - **Explain** answers "why can this person see this record?" layer by layer — permission questions get diagnosed, not guessed at. - Sign-in hardening for the enterprise tier: SSO, enforced MFA, password policy and breach checks, lockout throttling. diff --git a/content/docs/kernel/runtime-services/audit-service.mdx b/content/docs/kernel/runtime-services/audit-service.mdx index 88841bc725..94cf5fd581 100644 --- a/content/docs/kernel/runtime-services/audit-service.mdx +++ b/content/docs/kernel/runtime-services/audit-service.mdx @@ -17,6 +17,11 @@ Record-level `create` / `update` / `delete` rows do **not** travel through this same plugin writes those from ObjectQL lifecycle hooks, with no service call involved, so resolving this service is only ever necessary for events the CRUD lifecycle cannot see. +Record **views** do not travel through it either. `@objectstack/plugin-audit` records those +as `read` rows from an `afterFind` hook, under a per-object opt-in given at construction — +there is no service slot to resolve for them. See +[Record-View Auditing](/docs/permissions/record-view-auditing) for that surface. + ## Method The slot's entire surface is one method: diff --git a/content/docs/permissions/index.mdx b/content/docs/permissions/index.mdx index 3d3f3db02b..0364de701c 100644 --- a/content/docs/permissions/index.mdx +++ b/content/docs/permissions/index.mdx @@ -59,6 +59,7 @@ agent access exactly as they bound users ([Actions as Tools](/docs/ai/actions-as - [Field-Level Security](/docs/permissions/field-level-security) - [Permission Metadata](/docs/permissions/permission-metadata) - [Security Permissions Matrix](/docs/permissions/permissions-matrix) +- [Record-View Auditing](/docs/permissions/record-view-auditing) - who opened which record, and when - [Access Recipes](/docs/permissions/access-recipes) Spec: [Security & Access Control](/docs/protocol/objectql/security) · diff --git a/content/docs/permissions/meta.json b/content/docs/permissions/meta.json index 9c8f3e045e..964435bbd8 100644 --- a/content/docs/permissions/meta.json +++ b/content/docs/permissions/meta.json @@ -19,6 +19,7 @@ "permissions-matrix", "access-matrix", "explain", + "record-view-auditing", "access-recipes" ] } diff --git a/content/docs/permissions/record-view-auditing.mdx b/content/docs/permissions/record-view-auditing.mdx new file mode 100644 index 0000000000..fd4f4c6e99 --- /dev/null +++ b/content/docs/permissions/record-view-auditing.mdx @@ -0,0 +1,201 @@ +--- +title: Record-View Auditing +description: "Who viewed this record, and when — the `read` action in sys_audit_log: its per-object opt-in, the four edges of its scope, and what a view row deliberately does not carry." +--- + +# Record-View Auditing + +Every other layer in this module answers *who is allowed to see this record*. +This page answers the question that comes after it, and that every regulated +industry review opens with: **who actually opened it, and when?** + +ObjectStack records that as a `read` row in `sys_audit_log`, written by +`@objectstack/plugin-audit`. The capability is **off until a deployment names +the objects it wants recorded** — there is no global switch — and its scope has +real edges. This page states them, because a compliance evaluation that reads +"audited" and gets something narrower than it assumed is worse than one that +finds nothing at all. + + +**Scope of this page.** Record *views*. The `create` / `update` / `delete` rows +the same plugin writes from ObjectQL lifecycle hooks are the write side of the +same ledger and are not described here; `login` / `logout` rows arrive through a +different surface entirely, the +[`services.audit` slot](/docs/kernel/runtime-services/audit-service). + + +## Turning it on + +The audited set is a **constructor argument**, given once where the plugin is +installed: + +```typescript +import { AuditPlugin } from '@objectstack/plugin-audit'; + +await kernel.use( + new AuditPlugin({ + readAudit: { + objects: ['contact', 'account'], + // maxBatchSize: 50, // flush once this many views are buffered + // flushIntervalMs: 2000, // flush this long after a batch's first view + }, + }), +); +``` + +`readAudit` is the only key `AuditPluginOptions` declares, and it accepts +exactly those three: + +| Key | Default | Meaning | +| --- | --- | --- | +| `readAudit.objects` | `[]` | The closed per-object opt-in. Empty registers no hook at all | +| `readAudit.maxBatchSize` | `50` | Flush once this many views are buffered | +| `readAudit.flushIntervalMs` | `2000` | Flush this long after the first view of a batch | + +The writer filters the list before it registers anything: duplicates and blanks +are dropped, and names on the plugin's audit exclusion list (the ledger's own +tables, auth/session objects, and ADR-0057 telemetry plumbing such as `sys_job` +/ `sys_job_run` / `sys_job_queue`) are refused **with a warning naming the +object** rather than silently accepted. An empty — or fully excluded — set +registers no hook, so a deployment that opts nothing in pays nothing on its read +path. + + +**There is no object-metadata key and no environment variable.** `enable.auditReads` +does not exist, and neither does a global "audit all reads" flag. The shape is +deliberate: a declarable metadata key can be set on an object in a deployment +that never installs this plugin, producing metadata that *reads* as audited and +records nothing — and on a compliance surface, a declaration a reviewer mistakes +for coverage is worse than an absent feature. + +The practical consequence is that this is configured **where you compose the +kernel**. The CLI's `os serve` registers `AuditPlugin` with no options, so a +stack served that way has record-view auditing off and no knob to turn it on. + + +## What counts as a record view + +A read is recorded when **both** hold: + +1. **It materialized exactly one record** — a `findOne` that returned a record. + An array, `null` or `undefined` result is never a detail view, so `find` + never qualifies. +2. **Its predicate pinned the primary key.** `GET /data/:object/:id` reaches the + engine as `findOne(object, { where: { id } })`, which is the record-detail + surface. A `findOne` carrying any other predicate is "give me *a* matching + record" — an internal lookup, not a person opening a record. + +The predicate walk tolerates what the security middleware leaves behind: an `id` +equality AND-composed with a tenant or RLS clause still counts, and the explicit +`{ id: { $eq: ... } }` spelling is accepted. **`$or` or `$not` anywhere on the +path disqualifies the read** — the row may have matched through the other arm, +so the id equality no longer proves the read was *for* that record. Nesting is +walked to a fixed depth of 8. + + +**List and search reads are never recorded** — including a list read that +happened to return exactly one record. List auditing is a deferred follow-up, +and a deferral that leaked rows anyway would not be one. A coverage matrix +should read this as "record-detail views", not "reads". + + +## What a view row records + +| Column | Value on a `read` row | +| --- | --- | +| `action` | `read` | +| `created_at` | The instant the record was **viewed**, not the instant its batch drained | +| `user_id` | The `sys_user` subject that opened it | +| `actor` | The principal label, falling back to `user_id` (stamped only where the column exists) | +| `object_name` | The object whose record was opened | +| `record_id` | The record's id | +| `tenant_id` / `organization_id` | The **viewed record's** own organization, falling back to the viewer's session organization | +| `old_value` / `new_value` | Always `null` — see below | + +The tenant stamp comes from the record rather than the viewer deliberately: a +row about an org-A record stamped with the viewer's active org B would land +behind org B's tenant wall, invisible to the one tenant administrator the row +concerns. + +### What it deliberately does not record + +- **No field values, ever.** `old_value` and `new_value` stay `null`. The + `afterFind` hook runs *inside* the security middleware, **ahead of its field + masking**, so the record it sees is pre-mask plaintext. Copying values in + would mint a plaintext copy of exactly what + [field-level security](/docs/permissions/field-level-security) withholds, + inside the one table compliance staff are granted broad access to. +- **Not what the viewer actually saw.** It follows from the above: a `read` row + says someone opened a record, never which fields were visible to them after + masking. +- **No IP address and no user agent.** Those are stamped on `login` / `logout` + rows, not on view rows. +- **Nothing about refused or failed reads.** The `afterFind` hook is reached + only by a read that succeeded, so a denied read leaves no trace here. + +### Two boundaries, declared rather than discovered + +- **A system-elevated read writes no row.** Anything carrying + [`session.isSystem`](/docs/permissions/system-context) — an `api.sudo()` path, + a formula recompute, a roll-up, a trigger — is the platform reading for its + own bookkeeping, not a person opening a record. Note that `sudo()` **keeps the + caller's user id**, so this flag is the only thing separating the two. +- **A read with no principal writes no row.** With neither a user id nor an + actor there is no answer to "who", and a row naming nobody only adds noise to + the one query this capability exists to serve. + +## Reading the trail + +`sys_audit_log` ships a **Record Views** list view — filtered to `action: read`, +newest first — reachable in the Setup app under Diagnostics, via the +[Audit Logs entry](/docs/ui/setup-app) the plugin contributes. + +The object is append-only and exposes only `get` and `list` on the data API; +every field is `readonly`, so the ledger is never written through a form. +Programmatic queries go through `services.data` against `sys_audit_log` like any +other object. Rows carry the ADR-0057 `audit` lifecycle class: retained hot for +90 days, then archived for seven years where an `archive` datasource is +registered. + +## Failure posture + +The hook **enqueues and returns** — it awaits nothing, and an audit failure never +turns a valid read into an error. Rows are persisted on a later tick, flushed +whichever comes first: `maxBatchSize` views buffered, or `flushIntervalMs` since +the batch's first view. The plugin's `destroy()` drains the tail, so a clean +shutdown does not take the last batch with it. + +Both degradations are reported **once per process** and never retried — a retry +storm against an unreachable table turns a degradation into an outage: + +- **Buffer overflow.** Past a fixed ceiling of 10,000 buffered views the + **oldest** are dropped and a `warn` is logged once. +- **A failed ledger write.** The batch is lost and the failure is reported once, + at `error` where the host's logger provides one and at `warn` where it does + not — never dropped into silence. + + +**Both failures are invisible from everywhere else.** The reads themselves +succeeded and returned 200, so the API, the screens and every counter read +clean; only the rows recording who opened those records never landed. The +resulting query returns a confident, wrong, **short** answer. The usual cause of +a failing write is a datasource split rather than a broken table: +`sys_audit_log`'s `audit` lifecycle class routes it to a dedicated `telemetry` +datasource whenever one is registered, and `os dev` provisions one by default as +a sibling SQLite file. + + +## Not this + +- **[`services.audit`](/docs/kernel/runtime-services/audit-service)** is the + write ingress for audit events the CRUD lifecycle cannot see — `login` and + `logout` today. Record views do **not** travel through it; they are written by + an `afterFind` hook, with no service call involved, and there is no + `services.recordViewAudit` slot to resolve. +- **The write side of the ledger** — `create` / `update` / `delete` rows, with + before-and-after field values — is written by the same plugin from ObjectQL + lifecycle hooks and is on by default, subject to the same exclusion list. + +Canonical source: `packages/plugins/plugin-audit/src/read-audit.ts`. The +plugin's [README](https://github.com/objectstack-ai/objectstack/blob/main/packages/plugins/plugin-audit/README.md) +carries the same surface for readers working from the package.