Repository navigation
fix(plugin-audit): an activity row serves a parent field's value only to a reader the security service serves that field (#21081) - #21152
Conversation
… to a reader the security service serves that field The CRUD mirror composes each activity row once, as the system, and its summary, record label and recorded change can carry parent field values. The read gate keeps a row for every reader who can read the parent record, so a reader served a field masked, gated off by a capability, or not granted it by its sets was served the stored value through the row. Read time, keyed on the reading caller: the recorded change is narrowed key by key, and each text column is served whole or dropped by the writer's declaration of which parent fields it was composed from. The fields served unmasked are the security contract's read projection intersected with its query-side answer; nothing derives masking here. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…g reader as the control, on the activity stream A real boot (security, REST, auth and the audit plugin) writes activity rows through the REST door about a record holding one field of each class, and reads them back through the list and by-id doors as a reader of each class. The scene is armed before anything is measured: the rows at rest carry every class, and the data plane serves each reader its own class as the class says. Plus the patch changeset for the plugin-audit fix. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…tivity-field-values
… redaction's elevation read; test options typed The activity field redaction skips system-context reads, which is one more elevation read site; its row on the census page joins the sibling read gate's, and the page's counts are regenerated from the census. The redaction test's engine query options are typed instead of erased, so the query-options erasure ratchet's test surface does not grow. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…tivity-field-values
📓 Docs Drift CheckThis PR changes 1 package(s): 20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 9 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9e004d49db904b26de8de3c1db377345de480659 && git checkout 9e004d49db904b26de8de3c1db377345de480659
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e952cff578cc936daab3241570c4c2c36804bbd6 eec3115faa14f9dbbca11aca3cbf3488b1031cd2 && git checkout -B drift-repro e952cff578cc936daab3241570c4c2c36804bbd6 && git merge --no-ff eec3115faa14f9dbbca11aca3cbf3488b1031cd2
node scripts/docs-audit/affected-docs.mjs --json e952cff578cc936daab3241570c4c2c36804bbd6
|
…ve a parent field only to a reader the security service serves that field (objectstack-ai#21155) (objectstack-ai#21171) Fixes objectstack-ai#21155 Clause-②: no The compliance ledger's create, update and delete rows now serve a parent record's field value only to a reader the security service serves that field unmasked. The full row stays at rest. The narrowing is applied at read time, keyed on the reading caller, through the same served-fields answer the activity stream's redaction uses. This implements triage's ruling on the card (`5929919761`): ledger readers are not field-unrestricted by default, and an auditor who must see every field is granted it by a set that unmasks those fields. The audit-capability question stays out of scope, as ruled. ## Measured first, on a real boot Re-measured on `main` at `bafb8c94`, with PR objectstack-ai#21152 in, privately. The stack was `bootStack` with the real `SecurityPlugin`, `ObjectQL`, SQL driver, REST and auth layers, plus `AuditPlugin`. One synthetic object carried one field of each class. Rows were written by the CRUD mirror from REST writes: a create, an update of all three fields, and the delete of a second record. One reader per class held exactly what makes its own class apply, plus the ledger read. The unmasking reader was the control. A fifth reader took its ledger read from the platform read-only set's wildcard, beside a set withholding one field: that is the card's measured door. Readings stay private to the dispatch. This table gives classes only. | Reader class | Data plane (reference) | Ledger rows before (list, by-id, projected list) | Ledger rows after | |---|---|---|---| | Field served masked | replaced | stored value in the create, update and delete snapshots | absent | | Capability-gated field (no mask) | key absent | stored value in all three | absent | | Field not granted by the reader's sets | key absent | stored value in all three | absent | | Platform read-only wildcard plus a withholding set (the card's door) | replaced, key absent, key absent | stored value of every class | absent | | Unmasking reader (control) | stored | stored | stored, byte-identical to the row at rest | In the "after" column each reader is still served the values of the classes that do not apply to it. The card named the create and update snapshots. The delete row's before-snapshot is the same column on the third record-write action and carried the same values, so it is narrowed too. ## What changed - `packages/plugins/plugin-audit/src/audit-log-field-redaction.ts` (new): `installAuditLogFieldRedaction`, a `sys_audit_log` read middleware on `find` / `findOne` under a non-system context. On a row of a record-write action, each snapshot key the reader is not served is dropped, key by key. The record-write actions are create, update and delete: the vocabulary the mirror's `actionFor` returns, which is now typed by the same exported list. A reader served every key gets the stored bytes untouched. - `packages/plugins/plugin-audit/src/served-fields.ts` (new): the served-unmasked composition, now one helper both read seams use. `resolveServedFields` is the read projection intersected with the query-side answer, failing closed when the query-side answer is missing. The helper also holds the per-read memo, the key-by-key drop and the projection augmentation. ⛔ Nothing in `plugin-audit` derives masking. - `packages/plugins/plugin-audit/src/activity-field-redaction.ts`: moved onto that helper. Its exported names (`resolveServedFields` and its types) are unchanged. Behaviour is unchanged: its 19-case unit pin and 10-case dogfood pin stay green, and the log line names its seam. - `packages/plugins/plugin-audit/src/audit-plugin.ts`: mounts the ledger seam beside the activity one. One resolver looks up the security service on every read, for both seams. The no-middleware-seam warning names both. - `packages/plugins/plugin-audit/src/audit-writers.ts`: `actionFor` is typed by the shared record-write list, so a new record-write action does not compile until the read side judges it. - `content/docs/permissions/system-context.mdx`: row 45 names the new system-context early return. The census counts move 110 to 111, which `check-system-context-census` requires. - `.changeset/21155-ledger-snapshot-fields.md`: `patch` for `@objectstack/plugin-audit`. ### Fail closed - A record-write row's snapshot that is not a JSON object is dropped whole. - A record-write row that names no parent object loses both snapshots. - A row whose action cannot be read loses both snapshots. - An unexpected failure strips both snapshots from every row of the read. - A reader the service cannot answer masking for is served no field of the parent. - The security service's own "no answer" passes rows through, as the activity stream, the approval snapshot and the data plane do. That covers no service wired, and an object the service cannot resolve. ### Every ledger column judged, by writer - **Before/after snapshots on create, update and delete rows (the CRUD mirror):** the parent record's field map. Narrowed. - **Snapshots on `read`, `login`, `logout` and `import` rows:** written empty. Nothing to narrow. - **Snapshot on `config_change` rows (`service-settings`):** a digest of the setting, never its value. Served as written. - **Snapshots on `platform_admin_standing_change` rows (`plugin-security`):** the deployment's administrator roster, not a parent record's field map. Served as written. - **The metadata column on mirror rows:** the delegated-write attribution only. Served as written. - **The metadata column on `plugin-auth`'s admin create/update rows:** the admin's decisions, which include values of the created user's fields. Not narrowed here; see the acceptance notes (measured; producer-side; outside this card's file surface). - **The client-fingerprint columns on `login` / `logout` rows:** the ledger's own columns, governed by field-level security on the ledger object itself. - **Pointer, actor and tenant columns:** ids, the ledger's own. ### Read doors Every product door onto the ledger reads through the engine's generic data path: - the Setup "Audit Logs" object view; - the console's audit-log browser, which calls the generic list door (objectui `apps/console/src/pages/system/AuditLogPage.tsx`); - a record page's history tab, which calls the generic list door projected to the snapshot columns (objectui `packages/app-shell/src/views/RecordDetailView.tsx`). Both objectui files were read at the pinned `.objectui-sha`. No server route in this repository reads the ledger outside the engine. All three door shapes were measured: list, by-id and projected list. ## Pins - `packages/plugins/plugin-audit/src/audit-log-field-redaction.test.ts`, 15 cases. It uses the real `ObjectKernel`, `ObjectQLPlugin`, SQLite driver and the real `AuditPlugin` mount. The security-service double answers both contract members per reader. Rows are written by the real mirror (create, update, delete), plus rows in the other writers' shapes. The suite covers each class, the control's byte identity, the by-id and projected doors, the other actions served as written, each fail-closed branch, the no-answer pass-through and a system read. - `packages/qa/dogfood/test/audit-log-field-values.dogfood.test.ts`, 8 cases, on a real boot with the real security plugin, which is the only thing that distinguishes capability-gated from not granted. It has one reader per class plus the read-only-wildcard reader and the control. It is armed in `beforeAll` by `assertArmed`: the rows at rest carry every class, and the data plane serves each reader its class as declared. Each negative case reads the list, by-id and projected list doors. No test title states a value. ## Ablations: every negative pin, put back and shown red All legs went through `scripts/ablation-replace.mjs`. Each anchor hit once and landed by count and blob. Each restore was proven by blob equal to HEAD and an empty `git diff HEAD`. Every leg turned only its own cases red; all others stayed green. Unit legs, on the ledger file (15 cases) plus the activity file (19 cases): | Mutation | Red | |---|---| | mount removed | 9 of 15 ledger; 0 activity | | query-side intersection removed (shared helper) | 3 ledger (masked class) + 7 activity | | key narrowing off | 6 | | action discriminator opened | 1 (other actions served as written) | | non-object snapshot kept | 1 | | parentless row kept | 1 | | failure catch strips nothing | 1 | | missing query-side answer read as "nothing masked" (shared helper) | 1 ledger + 2 activity | | projection augmentation off | 1 | | added-column removal off | 1 | | untouched bytes re-serialised | 1 (control byte identity) | | no-answer pass-through closed | 1 | Dogfood legs, resolved through `dist`. Each leg rebuilt `plugin-audit`. `scripts/ablation-dist-preflight.mjs` proved the marker present in `dist`. The restore leg rebuilt again and proved the marker absent with `--absent` on a clean whole tree, then went 18/18 green: | Mutation | Red | |---|---| | mount removed | 4 of 8 (the three per-class negatives and the wildcard negative); the 3 preservation cases and the control stay green; the 10 activity cases stay green | | query-side intersection removed | 2 of 8 (masked class and the wildcard) + 2 activity (masked class) | ## Verification at head `f5289158` - `pnpm --filter @objectstack/plugin-audit test`: 30 files, 426 tests passed (411 before this change, plus 15). - `pnpm --filter @objectstack/plugin-audit typecheck`: exit 0, and `check:test-typecheck` OK. - Dogfood `vitest --project isolated`, the new pin plus the activity sibling: 2 files, 18 tests passed. Dogfood typecheck: exit 0. - Consumer suites that read the ledger as callers: `admin-identity-audit-trail`, `auth-session-audit-trail`, `membership-actor-attribution`, `settings-config-change-audit` and `activity-parent-read-gate` (dogfood, 24 tests), plus `service-settings` `config-change-audit.test.ts` (16 tests). All green. - Gates: `dispatch-gates --repo objectstack-ai/objectstack --commands`, with no paths, derived 95 families. All 95 were run with exit codes captured before any pipe, and all exited 0. `--ran` reads 95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN. Two families first answered PREREQUISITE NOT MET because packages had no `dist`: `check:skill-examples` and `check:dual-build-cjs-loads`. The packages they named were built, and both re-ran to exit 0. `check-system-context-census` was red until the census row landed. - Lint, a proven narrowing: `eslint --no-inline-config --format json` over the 7 changed TS files reported 7 files, none ignored, 0 errors and 0 warnings. `eslint.config.mjs` enables no type-aware linting (0 `parserOptions.project` / `projectService`), so no untouched file's verdict can move. The repo-wide lint is CI's. - Size: 1155 changed lines (+1032 / -123) across 9 files against merge base `e35c40a5`. No governed surface. ## Acceptance notes - **Parent-record visibility on the ledger (measured, a policy question).** A reader whose sets grant the ledger read was served the ledger rows about a record the data plane answers 404 to that reader. After this change, the snapshots in those rows still carry every field the reader is not withheld at field level. The activity stream has a parent-record read gate; the ledger has none. Whether ledger reads take the parent's record gate, or the separate audit-capability narrowing triage named, is a ruling, not this card. It is reported to the seat. - **`plugin-auth`'s admin create-user row (measured).** Its decision metadata carries the created user's field values. A reader withheld one of those fields (and refused the record itself) was served that value through the row's metadata. After this change, the mirror's own create row about the same user no longer carries it. The producer is `plugin-auth/src/admin-user-endpoints.ts`, outside this card's surface. Narrowing it here would need a second derivation of which decision keys are field values. It is reported to the seat. - **Predicate over the stored snapshot text (measured, after this change).** As the withheld reader, a filter on a snapshot column answers by row presence: one row on a hit, none on a miss. The predicate is evaluated at rest, before any read-time redaction. This is the family of objectstack-ai#21154, the activity stream's same question, and it is reported for folding there. A group or aggregate over the snapshot columns is the same family and was not measured. - **The cross-plugin hoist.** The approval snapshot (`plugin-approvals/src/payload-redaction.ts`) still carries its own copy of the served-unmasked composition. Hoisting it beside `served-fields.ts` is objectstack-ai#21081's noted follow-up, not this card's. `plugin-approvals` is untouched. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21081
Clause-②: no
An activity row now serves a parent record's field value only to a reader the security service serves that field unmasked. The full row stays at rest; the redaction is applied at read time, keyed on the reading caller.
Measured first, on a real boot
The measurement used
bootStackwith the realSecurityPlugin,ObjectQL, SQL driver, REST and auth layers, plusAuditPlugin. One synthetic object carried one field of each class, each tracked on the timeline and each named by a milestone template. Three more synthetic objects each had one label-candidate field, one per class. Rows were written by the CRUD mirror from REST writes. One reader per class was granted exactly what makes its own class apply, and the unmasking reader was the control. Readings stay private to the dispatch. This table gives classes only.In the "after" column, each reader is still served the values of the two other classes. A summary composed only from served fields is kept.
So the card's suspicion is confirmed: the value is served, for all three classes and at every position (tracked-change summary, milestone summary, record label and recorded change). Under the triage's raise rule this is the seat's p1 call; the measurement is in the dev report.
Why read time, not composition time
plugin-approvals/src/payload-redaction.ts).getReadableFieldsintersected withgetQueryableFields, which the contract defines as differing by exactly the masked-for-this-caller fields. Nothing inplugin-auditderives masking.What changed
packages/plugins/plugin-audit/src/activity-field-redaction.ts(new) holdsinstallActivityFieldRedaction, a read middleware on the activity stream. It runs on find and by-id reads, under a non-system context.packages/plugins/plugin-audit/src/audit-writers.ts: each row now declares which parent fields its summary and record label carry.packages/plugins/plugin-audit/src/activity-read-visibility.ts:parseActivityParentObjectis extracted, so both read seams share one definition of the parent a row names.packages/plugins/plugin-audit/src/audit-plugin.ts: the redaction is mounted beside the read gate. Thesecurityservice is resolved on every read, because the security plugin may register later..changeset/21081-activity-field-values.md:patch.Outside the claimed file surface, and why
packages/qa/dogfood/test/activity-field-values.dogfood.test.tsis the per-class pin on a real boot. Only the real security plugin tells a capability-gated field from a field no set grants; a double cannot. This follows the approval precedent's dogfood pin.content/docs/permissions/system-context.mdx: the new middleware skips system-context reads, which is one more elevation read site.check-system-context-censusrequires its row, so it joins the sibling read gate's row, and the counts are regenerated bygen:system-context-census.Pins
packages/plugins/plugin-audit/src/activity-field-redaction.test.tshas 19 cases. It uses a real engine, a real SQLite driver and the realAuditPluginwiring. A security-service double answers both contract members per reader.resolveServedFields: its fail-closed branches.packages/qa/dogfood/test/activity-field-values.dogfood.test.tshas 10 cases: three per class plus the control.beforeAllbefore anything is believed. The rows at rest carry every class, and the data plane serves each reader its class as the class says.Ablations (lane commitment: every negative pin)
scripts/ablation-replace.mjs. The anchor must hit once, and the mutation is proven landed by anchor count and blob hash. The restore is proven by blob equal to HEAD and an emptygit diff HEAD.eec3115f.dist/). Each leg rebuilt and proved the marker live indist/withscripts/ablation-dist-preflight.mjs. The restore leg rebuilt and proved it absent with--absent, on a clean whole tree.Verification (at
eec3115f, after merging currentmain)pnpm --filter @objectstack/plugin-audit test: 29 files, 411 tests passed.pnpm --filter @objectstack/plugin-audit typecheck: clean, includingcheck:test-typecheckOK.activity-field-valuesand the siblingactivity-parent-read-gatepin, 16/16.pnpm --filter @objectstack/dogfood typecheckis clean.node scripts/pm/dispatch-gates.mjs --commandsderived 95 families on this head, and all 95 exited 0. The--ranreconciliation reads 95 derived, 95 run, 0 NOT-MEASURED, with every exit code recorded.check-system-context-censuswanted the new row.check:query-options-erasurehad grown by four cast sites in the new test; the options are now typed and the ratchet is back at its ceiling.eslint --no-inline-config --format jsonover the six changed TypeScript files reported all six, none ignored, with 0 errors and 0 warnings.eslint.config.mjsenables no type-aware linting (noparserOptions.projectorprojectService), so this diff cannot move a verdict on an untouched file.Acceptance notes
main, aturbo-backed command run by an agent writes a managed block intoAGENTS.md. It happened once in this worktree during the gate union, and was restored to HEAD and proven, not committed. It is reported to the seat.Generated by Claude Code