|
| 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