Skip to content

Commit e4cceb7

Browse files
os-steveclaude
andauthored
docs(permissions): document record-view auditing, the read action's shipped surface (#9540) (#9860)
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.<name>` 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. Claude-Session: https://claude.ai/code/session_01XqDQYVU5smx29ts9pAErja Co-authored-by: Claude <noreply@anthropic.com>
1 parent 94c58b3 commit e4cceb7

6 files changed

Lines changed: 210 additions & 1 deletion

File tree

‎.claude/workflows/docs-accuracy-audit.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ const ALL_HANDWRITTEN = [
154154
"content/docs/permissions/permissions-matrix.mdx",
155155
"content/docs/permissions/positions.mdx",
156156
"content/docs/permissions/profiles.mdx",
157+
"content/docs/permissions/record-view-auditing.mdx",
157158
"content/docs/permissions/rls.mdx",
158159
"content/docs/permissions/sharing-rules.mdx",
159160
"content/docs/permissions/sso.mdx",

‎content/docs/capabilities/permissions.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ People sit in a **business-unit tree** and hold **positions**; reporting lines d
2020
## Safe by default, explainable always
2121

2222
- Every object must declare its sharing posture explicitly — "forgot to configure, so everyone can see it" cannot happen.
23-
- **Audit** records who changed what, when, with old and new values; tracked objects expose a history timeline on the record.
23+
- **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)).
2424
- **Explain** answers "why can this person see this record?" layer by layer — permission questions get diagnosed, not guessed at.
2525
- Sign-in hardening for the enterprise tier: SSO, enforced MFA, password policy and breach checks, lockout throttling.
2626

‎content/docs/kernel/runtime-services/audit-service.mdx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ Record-level `create` / `update` / `delete` rows do **not** travel through this
1717
same plugin writes those from ObjectQL lifecycle hooks, with no service call involved, so
1818
resolving this service is only ever necessary for events the CRUD lifecycle cannot see.
1919

20+
Record **views** do not travel through it either. `@objectstack/plugin-audit` records those
21+
as `read` rows from an `afterFind` hook, under a per-object opt-in given at construction —
22+
there is no service slot to resolve for them. See
23+
[Record-View Auditing](/docs/permissions/record-view-auditing) for that surface.
24+
2025
## Method
2126

2227
The slot's entire surface is one method:

‎content/docs/permissions/index.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ agent access exactly as they bound users ([Actions as Tools](/docs/ai/actions-as
5959
- [Field-Level Security](/docs/permissions/field-level-security)
6060
- [Permission Metadata](/docs/permissions/permission-metadata)
6161
- [Security Permissions Matrix](/docs/permissions/permissions-matrix)
62+
- [Record-View Auditing](/docs/permissions/record-view-auditing) - who opened which record, and when
6263
- [Access Recipes](/docs/permissions/access-recipes)
6364

6465
Spec: [Security & Access Control](/docs/protocol/objectql/security) ·

‎content/docs/permissions/meta.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"permissions-matrix",
2020
"access-matrix",
2121
"explain",
22+
"record-view-auditing",
2223
"access-recipes"
2324
]
2425
}
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
---
2+
title: Record-View Auditing
3+
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."
4+
---
5+
6+
# Record-View Auditing
7+
8+
Every other layer in this module answers *who is allowed to see this record*.
9+
This page answers the question that comes after it, and that every regulated
10+
industry review opens with: **who actually opened it, and when?**
11+
12+
ObjectStack records that as a `read` row in `sys_audit_log`, written by
13+
`@objectstack/plugin-audit`. The capability is **off until a deployment names
14+
the objects it wants recorded** — there is no global switch — and its scope has
15+
real edges. This page states them, because a compliance evaluation that reads
16+
"audited" and gets something narrower than it assumed is worse than one that
17+
finds nothing at all.
18+
19+
<Callout type="info">
20+
**Scope of this page.** Record *views*. The `create` / `update` / `delete` rows
21+
the same plugin writes from ObjectQL lifecycle hooks are the write side of the
22+
same ledger and are not described here; `login` / `logout` rows arrive through a
23+
different surface entirely, the
24+
[`services.audit` slot](/docs/kernel/runtime-services/audit-service).
25+
</Callout>
26+
27+
## Turning it on
28+
29+
The audited set is a **constructor argument**, given once where the plugin is
30+
installed:
31+
32+
```typescript
33+
import { AuditPlugin } from '@objectstack/plugin-audit';
34+
35+
await kernel.use(
36+
new AuditPlugin({
37+
readAudit: {
38+
objects: ['contact', 'account'],
39+
// maxBatchSize: 50, // flush once this many views are buffered
40+
// flushIntervalMs: 2000, // flush this long after a batch's first view
41+
},
42+
}),
43+
);
44+
```
45+
46+
`readAudit` is the only key `AuditPluginOptions` declares, and it accepts
47+
exactly those three:
48+
49+
| Key | Default | Meaning |
50+
| --- | --- | --- |
51+
| `readAudit.objects` | `[]` | The closed per-object opt-in. Empty registers no hook at all |
52+
| `readAudit.maxBatchSize` | `50` | Flush once this many views are buffered |
53+
| `readAudit.flushIntervalMs` | `2000` | Flush this long after the first view of a batch |
54+
55+
The writer filters the list before it registers anything: duplicates and blanks
56+
are dropped, and names on the plugin's audit exclusion list (the ledger's own
57+
tables, auth/session objects, and ADR-0057 telemetry plumbing such as `sys_job`
58+
/ `sys_job_run` / `sys_job_queue`) are refused **with a warning naming the
59+
object** rather than silently accepted. An empty — or fully excluded — set
60+
registers no hook, so a deployment that opts nothing in pays nothing on its read
61+
path.
62+
63+
<Callout type="warn">
64+
**There is no object-metadata key and no environment variable.** `enable.auditReads`
65+
does not exist, and neither does a global "audit all reads" flag. The shape is
66+
deliberate: a declarable metadata key can be set on an object in a deployment
67+
that never installs this plugin, producing metadata that *reads* as audited and
68+
records nothing — and on a compliance surface, a declaration a reviewer mistakes
69+
for coverage is worse than an absent feature.
70+
71+
The practical consequence is that this is configured **where you compose the
72+
kernel**. The CLI's `os serve` registers `AuditPlugin` with no options, so a
73+
stack served that way has record-view auditing off and no knob to turn it on.
74+
</Callout>
75+
76+
## What counts as a record view
77+
78+
A read is recorded when **both** hold:
79+
80+
1. **It materialized exactly one record** — a `findOne` that returned a record.
81+
An array, `null` or `undefined` result is never a detail view, so `find`
82+
never qualifies.
83+
2. **Its predicate pinned the primary key.** `GET /data/:object/:id` reaches the
84+
engine as `findOne(object, { where: { id } })`, which is the record-detail
85+
surface. A `findOne` carrying any other predicate is "give me *a* matching
86+
record" — an internal lookup, not a person opening a record.
87+
88+
The predicate walk tolerates what the security middleware leaves behind: an `id`
89+
equality AND-composed with a tenant or RLS clause still counts, and the explicit
90+
`{ id: { $eq: ... } }` spelling is accepted. **`$or` or `$not` anywhere on the
91+
path disqualifies the read** — the row may have matched through the other arm,
92+
so the id equality no longer proves the read was *for* that record. Nesting is
93+
walked to a fixed depth of 8.
94+
95+
<Callout type="warn">
96+
**List and search reads are never recorded** — including a list read that
97+
happened to return exactly one record. List auditing is a deferred follow-up,
98+
and a deferral that leaked rows anyway would not be one. A coverage matrix
99+
should read this as "record-detail views", not "reads".
100+
</Callout>
101+
102+
## What a view row records
103+
104+
| Column | Value on a `read` row |
105+
| --- | --- |
106+
| `action` | `read` |
107+
| `created_at` | The instant the record was **viewed**, not the instant its batch drained |
108+
| `user_id` | The `sys_user` subject that opened it |
109+
| `actor` | The principal label, falling back to `user_id` (stamped only where the column exists) |
110+
| `object_name` | The object whose record was opened |
111+
| `record_id` | The record's id |
112+
| `tenant_id` / `organization_id` | The **viewed record's** own organization, falling back to the viewer's session organization |
113+
| `old_value` / `new_value` | Always `null` — see below |
114+
115+
The tenant stamp comes from the record rather than the viewer deliberately: a
116+
row about an org-A record stamped with the viewer's active org B would land
117+
behind org B's tenant wall, invisible to the one tenant administrator the row
118+
concerns.
119+
120+
### What it deliberately does not record
121+
122+
- **No field values, ever.** `old_value` and `new_value` stay `null`. The
123+
`afterFind` hook runs *inside* the security middleware, **ahead of its field
124+
masking**, so the record it sees is pre-mask plaintext. Copying values in
125+
would mint a plaintext copy of exactly what
126+
[field-level security](/docs/permissions/field-level-security) withholds,
127+
inside the one table compliance staff are granted broad access to.
128+
- **Not what the viewer actually saw.** It follows from the above: a `read` row
129+
says someone opened a record, never which fields were visible to them after
130+
masking.
131+
- **No IP address and no user agent.** Those are stamped on `login` / `logout`
132+
rows, not on view rows.
133+
- **Nothing about refused or failed reads.** The `afterFind` hook is reached
134+
only by a read that succeeded, so a denied read leaves no trace here.
135+
136+
### Two boundaries, declared rather than discovered
137+
138+
- **A system-elevated read writes no row.** Anything carrying
139+
[`session.isSystem`](/docs/permissions/system-context) — an `api.sudo()` path,
140+
a formula recompute, a roll-up, a trigger — is the platform reading for its
141+
own bookkeeping, not a person opening a record. Note that `sudo()` **keeps the
142+
caller's user id**, so this flag is the only thing separating the two.
143+
- **A read with no principal writes no row.** With neither a user id nor an
144+
actor there is no answer to "who", and a row naming nobody only adds noise to
145+
the one query this capability exists to serve.
146+
147+
## Reading the trail
148+
149+
`sys_audit_log` ships a **Record Views** list view — filtered to `action: read`,
150+
newest first — reachable in the Setup app under Diagnostics, via the
151+
[Audit Logs entry](/docs/ui/setup-app) the plugin contributes.
152+
153+
The object is append-only and exposes only `get` and `list` on the data API;
154+
every field is `readonly`, so the ledger is never written through a form.
155+
Programmatic queries go through `services.data` against `sys_audit_log` like any
156+
other object. Rows carry the ADR-0057 `audit` lifecycle class: retained hot for
157+
90 days, then archived for seven years where an `archive` datasource is
158+
registered.
159+
160+
## Failure posture
161+
162+
The hook **enqueues and returns** — it awaits nothing, and an audit failure never
163+
turns a valid read into an error. Rows are persisted on a later tick, flushed
164+
whichever comes first: `maxBatchSize` views buffered, or `flushIntervalMs` since
165+
the batch's first view. The plugin's `destroy()` drains the tail, so a clean
166+
shutdown does not take the last batch with it.
167+
168+
Both degradations are reported **once per process** and never retried — a retry
169+
storm against an unreachable table turns a degradation into an outage:
170+
171+
- **Buffer overflow.** Past a fixed ceiling of 10,000 buffered views the
172+
**oldest** are dropped and a `warn` is logged once.
173+
- **A failed ledger write.** The batch is lost and the failure is reported once,
174+
at `error` where the host's logger provides one and at `warn` where it does
175+
not — never dropped into silence.
176+
177+
<Callout type="warn">
178+
**Both failures are invisible from everywhere else.** The reads themselves
179+
succeeded and returned 200, so the API, the screens and every counter read
180+
clean; only the rows recording who opened those records never landed. The
181+
resulting query returns a confident, wrong, **short** answer. The usual cause of
182+
a failing write is a datasource split rather than a broken table:
183+
`sys_audit_log`'s `audit` lifecycle class routes it to a dedicated `telemetry`
184+
datasource whenever one is registered, and `os dev` provisions one by default as
185+
a sibling SQLite file.
186+
</Callout>
187+
188+
## Not this
189+
190+
- **[`services.audit`](/docs/kernel/runtime-services/audit-service)** is the
191+
write ingress for audit events the CRUD lifecycle cannot see — `login` and
192+
`logout` today. Record views do **not** travel through it; they are written by
193+
an `afterFind` hook, with no service call involved, and there is no
194+
`services.recordViewAudit` slot to resolve.
195+
- **The write side of the ledger** — `create` / `update` / `delete` rows, with
196+
before-and-after field values — is written by the same plugin from ObjectQL
197+
lifecycle hooks and is on by default, subject to the same exclusion list.
198+
199+
Canonical source: `packages/plugins/plugin-audit/src/read-audit.ts`. The
200+
plugin's [README](https://github.com/objectstack-ai/objectstack/blob/main/packages/plugins/plugin-audit/README.md)
201+
carries the same surface for readers working from the package.

0 commit comments

Comments
 (0)