Repository navigation
fix(plugin-audit)!: an engine read of sys_activity returns only the rows whose parent record the caller can read - #21069
Conversation
… the plugin read path A real kernel, the real AuditPlugin, the real CRUD mirror and a real SQLite driver: a caller holding object-level sys_activity read is expected to get back only the rows whose parent record it can read, on find, findOne, count and aggregate, with one parent read per parent object. Red at this commit: eight of twelve cases fail because no gate narrows the stream yet. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
… the public data doors An org-bound boot, a member holding object-level sys_activity read through one explicit permission set, and plugin-audit's CRUD mirror writing the rows. The list, its total, the by-id read and both query shapes must answer only rows whose parent record the member can open. Red at this commit: four of six cases fail because no gate narrows the stream yet. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…ows whose parent record the caller can read AuditPlugin mounts a read middleware on sys_activity beside the sys_comment one. On find, findOne, count and aggregate it pre-scans the parent pairs (object_name, record_id) the query would touch under system context, asks which parents the caller can read, and ANDs one branch per parent object into the query. The readability answer is the comment gate's own: its per-object caller-scoped engine read is extracted into resolveReadableParentIds and both gates call it, so one derivation serves both. Rows naming no parent, a missing parent, an unknown object or sys_activity itself fail closed, as comment threads do. System-context and context-less reads are untouched. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…f erasing them The options bags handed to find, findOne, count, aggregate, insert, update and delete were cast to any, which grew the query-options-erasure test surface by fourteen sites. They are the declared EngineQueryOptions shapes, so they now type-check as written. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
…ead gate's early return The new sys_activity read middleware returns early on a system context, as the sys_comment one does. Row 45 now anchors it beside the comment gate, and the declared counts are regenerated from the census. Claude-Session: https://claude.ai/code/session_01XY5uCwTjZj7884yYtyur4H Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 41 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 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 ea536dd70752bd4094c1d5ee25e4efc2e4d54d08 && git checkout ea536dd70752bd4094c1d5ee25e4efc2e4d54d08
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6073bb96b878eb2980724568ee526157b29ab141 22cc56d6c36b90821525ba353da6bcda7e76355b && git checkout -B drift-repro 6073bb96b878eb2980724568ee526157b29ab141 && git merge --no-ff 22cc56d6c36b90821525ba353da6bcda7e76355b
node scripts/docs-audit/affected-docs.mjs --json 6073bb96b878eb2980724568ee526157b29ab141
|
Contract reviewServed-tier: Inputs: card #20833 (body and its 3 comments: the dispatch claim, the dev report ① Derived judgments
Positions, not measured, same class as the comment gate and not this card's: (a) a ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…ows about records the caller can read (objectstack-ai#21175) (objectstack-ai#21194) Fixes objectstack-ai#21175 Clause-②: no A read of the compliance ledger (`sys_audit_log`) now returns only the rows about records the caller can read. This implements triage's ruling A on the card (`5932888473`): the ledger takes the activity stream's parent-record read gate (objectstack-ai#20833, PR objectstack-ai#21069), and that gate reads the engine's own answer. **Status.** Draft. The product consequence for admins (see Acceptance notes) is awaiting the maintainer's decision on objectstack-ai#21175; this PR's behaviour is unchanged by patch round 1. ## Patch round 1: what changed since the first push - **Merged `origin/main` at `1ecb871b`** (merge commit `77d3cc05`; no rebase, no force-push). PR objectstack-ai#21179 (objectstack-ai#21154) is in that merge. Its `audit-plugin.ts` mount and this PR's mount sat in different hunks of the same block and merged without a conflict. The census page (`content/docs/permissions/system-context.mdx`) did conflict: row 45 now names both the query guard and the ledger read gate, and the declared counts were regenerated by `gen:system-context-census` (114 sites). - **Mount order.** Engine middleware runs in registration order. On `sys_audit_log` the order is now: (1) the query guard from objectstack-ai#21154, (2) the ledger field redaction from objectstack-ai#21155, (3) this PR's parent-record read gate. On `sys_activity` it is: (1) the query guard, (2) the activity read gate, (3) the activity field redaction. Each guarantee holds: - the guard judges a query before any read gate runs its pre-scan, so a refused query never pays it; - the read gate ANDs its WHERE before the read executes; - the redaction narrows the rows that come back, which are only the rows the gate kept. The mount comment in `audit-plugin.ts` states this order. - **Changeset, now in the activity gate's form** (objectstack-ai#20833 / PR objectstack-ai#21069): `minor`, a **BREAKING** paragraph, and `Clause-②: no (narrowing)`. The arm is there because this narrows what a read returns and no accept set widens; the PR line above keeps the claim's `no`. It carries the ADR-0087 `not-required (no-migration-prescription)` marker (`check-adr-0087-registration`: 1 declared-breaking changeset, carrying its disposition) and a Migration paragraph. - **CI red on the previous head.** The red was `Dogfood Regression Gate (3/3)`; reproduced locally on that head. The failing case was `admin-ledger-decision-metadata.dogfood.test.ts`, a pin objectstack-ai#21174 landed on `main` after this branch's first merge. Its readers could open only their own user row through the data door (measured: each reader got 404 on the subject user). Under the parent-record rule they are served none of the subject's ledger rows, so its armed check disarmed. **Old to new:** the fixture's reader sets now add view-all on the user object, a row-scope grant only. A new armed control asserts that every reader opens the subject through the data door (measured 200 for all five). No assertion changed: its armed check still measures that each reader is withheld exactly its field class, and all 8 cases pass. - **Docs.** `content/docs/permissions/record-view-auditing.mdx`, "Reading the trail": the sentence saying ledger queries go through the data service "like any other object" now states the read rule. A view row is served outside system context only to a caller who can read its record. Views of a record that has since been deleted stay stored and are served only to system-context reads. ## Measured first, on a real boot (classes only) The re-measure was private and its readings stay in the dispatch's scratch. It ran on `main` at `b9087d77` (PR objectstack-ai#21171 in), and again at this branch's first head. The stack was `bootStack`, org-bound, with the real `SecurityPlugin`, auth, REST and `AuditPlugin`. The rows were written by the CRUD mirror and the auth-event sink. The member holds the ledger read through one explicit permission set; the admin is the seeded admin. "Parent" means the same caller's read of the row's record through the data door. | door | caller | parent, through the data door | before | after | |:--|:--|:--|:--|:--| | list `GET /data/sys_audit_log` | member | 404: a private record's create, update and delete rows | returned | absent | | list | member | 404: other users' sessions (their `login` rows) | returned | absent | | list | member | 200 | returned | returned | | by id `GET /data/sys_audit_log/:id` | member | 404 | 200 | 404 | | list, by id | admin | 200: every existing record | returned | returned | | list | admin, member | the record no longer exists (`delete` rows, a deleted record's other rows, `logout` rows) | returned | absent | | list `total` | member | (any) | 51 | 42, the rows returned | | list `total` | admin | (any) | 51 | 47, the rows returned | ## What changed - `parent-record-read-gate.ts` (new): the activity gate's mechanism, moved out of `activity-read-visibility.ts` and parameterized per gate. It still asks `resolveReadableParentIds`, the one readability answer the comment and activity gates share. Nothing derives row scope a second way. - `activity-read-visibility.ts`: now a thin declaration over the shared module. Its exports (`parseActivityParentObject`, which the objectstack-ai#21154 guard imports, included), scan options, sentinel and log lines are unchanged, and its unit and integration pins pass unedited. - `audit-log-read-visibility.ts` (new): `installAuditLogReadVisibility`, the ledger's declaration, with the row classes below. - `audit-plugin.ts`: one mount with its order comment, plus the no-middleware-seam warning now names the ledger read gate. - `content/docs/permissions/system-context.mdx`, row 45: the census anchor for the new early return on system context. ## Row classes: the stated answer for rows the gate cannot judge Measured per writer: - **About a record** (`object_name` + `record_id`): the CRUD mirror's create, update and delete rows; record-view `read` rows; plugin-auth's administrative create and update on a user; and `login` / `logout`, which name the session. Judged by the record gate. - **About a record that no longer exists:** no caller can read the record, so these rows are excluded for every caller that is not system context, admins included. That covers every `delete` row, every `logout` row (sign-out deletes the session, measured), and a sign-in row whose session has since been removed. The rows stay stored. - **About no record** (no `record_id`, and an action that is not a record action): the run-level `import`, `config_change` and `platform_admin_standing_change` rows, and an auth event without a session id. These are outside the gate's class and are served under the ledger's own grant, as before. **Why this differs from the activity gate**, which excludes every row that names no parent: - the activity stream has no platform producer of such rows; - these rows have three producers, and a shipped consumer reads them through the data door (the `config_changes` view, pinned by the settings dogfood test as the admin); - none of them carries a record's field values (the per-writer measurement in `audit-log-field-redaction.ts`). - **Excluded, fail closed:** a record action (`create`, `read`, `update`, `delete`) that names no record, a record under an object the engine does not know, and a row naming the ledger itself. ## Pins - `audit-log-read-visibility.integration.test.ts` uses a real kernel, the real `AuditPlugin`, the real CRUD mirror and a real SQLite driver. It covers find, findOne, count, aggregate, a scoped query, the batching count, every row class, and an admin control. 12 tests. - `audit-log-read-visibility.test.ts` covers the class rule, the fail-closed branches, the scan bound and its order pass-through, and the inert seam. 13 tests. - `packages/qa/dogfood/test/audit-log-parent-read-gate.dogfood.test.ts` covers the public doors above on a real boot. Each returned row's record is opened through the same door as the same reader. It also covers a row about no record from its real producer (a settings write), a deleted record's rows, and the admin control. 8 tests. - **Composition consequences on other cards' pins (adapted, not weakened):** - objectstack-ai#21155's unit test now pins the redaction of rows no non-system door serves on `redactAuditLogRows` over the row at rest. Its field-values dogfood pin checks the live record's rows. - objectstack-ai#21174's admin-ledger pin is described above. ## Ablation: put the forbidden behaviour back, red, restore Both mutations ran from committed state through `scripts/ablation-replace.mjs`, and each restore was proven: blob equals HEAD and `git diff HEAD` is empty. - **Mount removed** (the one `installAuditLogReadVisibility(...)` call in `audit-plugin.ts`; anchor 1 → 0). - Leg A, source-resolved: the integration pin gave 8 failed, 4 passed of 12. - Leg B, dogfood, resolved from `dist/`: plugin-audit was rebuilt, and `ablation-dist-preflight --absent` proved the call gone. The dogfood pin then gave 5 failed, 3 passed of 8. - Restore: a rebuild, the preflight proved the call present and the tree clean, and the pin re-ran 8 of 8. - **The class rule widened** (a record action naming no record treated as outside the gate): the unit and integration pins gave 6 failed, 19 passed of 25. - **objectstack-ai#21174's pin without the reader view-all grant** (the previous head's fixture), on the merged build: its armed check disarmed. All five readers were served no mirror row about the subject. ## Verification at HEAD `d376985f` Every exit code was captured before any pipe. - `pnpm --filter @objectstack/plugin-audit test`: 35 files, 535 tests passed. `pnpm --filter @objectstack/plugin-audit typecheck`: exit 0, test layer included. `pnpm --filter @objectstack/dogfood typecheck`: exit 0. `plugin-approvals` `payload-predicate-guard.test.ts`: 20 passed. - **Dogfood shard 3/3** (`OS_TEST_SHARD=3/3`, CI's slice): 54 files passed, 1 skipped; 523 tests passed, 2 skipped. - Ledger and neighbour pins, 12 files: this PR's dogfood pin; objectstack-ai#21155's `audit-log-field-values`; objectstack-ai#21081's `activity-field-values`; objectstack-ai#21154's `activity-text-predicate` and `audit-log-admin-search`; `activity-parent-read-gate`; `auth-session-audit-trail`; `settings-config-change-audit`; `admin-identity-audit-trail`; objectstack-ai#21174's `admin-ledger-decision-metadata` (8 of 8 after the fixture change); `comments-permission-matrix`; `membership-actor-attribution`. All green: eleven ran at `b415f4d7`, whose tree differs from this head only in the admin-ledger pin, and that pin ran at this head. - `dispatch-gates --commands`: 94 families derived; 93 ran with exit 0 and 1 is NOT MEASURED. `spec check:skill-examples` exited 0 after the client SDK it reads was built. `check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET: it needs a whole-repo build, and CI runs it. `dispatch-gates --ran`: 94 accounted for, 0 unrun. `main` moved again after `1ecb871b`; this round merges `main` once, as dispatched. - Lint, a declared narrowing: `eslint --no-inline-config --format json` over the 10 changed TypeScript files reports 10 files linted, 0 errors and 0 warnings. An ignored file would report a warning. `eslint.config.mjs` sets no `parserOptions.project` and no typed rule, so this diff cannot move a verdict on an untouched file. The whole-repo `pnpm lint` runs in CI. ## Acceptance notes - **Admins lose the trail of deleted records and of sign-outs** on every door that is not system context, including Setup's Audit Logs. The rows stay stored. This is the consequence awaiting the maintainer's decision on objectstack-ai#21175. - **Interim mitigation.** `domain:engine` reports (`5936411631` on objectstack-ai#21175) that this gate shrinks objectstack-ai#21197's reach to admins: the gate stops serving a member the ledger rows about a key-material record that member cannot open. The admin half of objectstack-ai#21197 stands, and that card's own mechanism is separate. - **Scan bound.** The shared 2,000-row pre-scan bound now applies to the ledger, which is a larger table than the activity stream. A broad read past the bound fails closed and logs a warning: rows outside the window, and the `total` they would add, are omitted. A record timeline scopes by `object_name` and `record_id` and never reaches the bound. - Not measured: doors onto the ledger that answer outside the engine's middleware chain (`GET /search`, the analytics dataset over the ledger, export), and a boot with more than one organization. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Part of #20833
Clause-②: no (narrowing) — an engine read of one platform object returns fewer rows to a caller that is not system context. No schema, accept set, export or stored shape moves:
src/index.ts,package.jsonandsrc/objects/**are unchanged, and none of the new module-level names appears in the builtdist/index.d.ts/dist/index.d.mts(positive control:installCommentReadVisibilitydoes).What this changes
AuditPluginmounts a read middleware onsys_activity, beside the onesys_commentalready has. Onfind,findOne,countandaggregateit pre-scans the parent pairs (object_name,record_id) the query would touch under system context, asks which of those parent records the caller can read, and ANDs one branch per parent object into the query. A row is returned only when the caller can read the record it is about.comment-access-hooks.tsintoresolveReadableParentIds, and both gates call it. The parent object's own sharing, RLS and object-level CRUD decide. Nothing derives readability a second way.sys_activityitself.orderBy, so on a larger table the window it scans is the one the caller pages through, such as a feed sorted bytimestamp.Per door, by class
A real org-bound boot (
bootStack,orgContext: true) with plugin-audit's CRUD mirror writing the rows. The member holds object-levelsys_activityread through one explicit permission set. The admin is the seeded admin. "Parent readable" means the same caller's read of the parent record through the data door.GET /data/sys_activitytotalGET /data/sys_activity/:idPOST /data/sys_activity/query(where)POST /data/sys_activity/query(groupBy)GET /data/sys_activity/exportMeasured counts in that boot: member list 43 rows before, 38 after. Admin list 43 before, 41 after. The admin's two missing rows are both about one record deleted before the read.
Pins (committed red, before the fix)
packages/plugins/plugin-audit/src/activity-read-visibility.integration.test.ts, commit 97fdaa4. It uses a real kernel, the realAuditPlugin, the real CRUD mirror and a real SQLite driver, and it covers find, findOne, count, aggregate, a scoped query, the batching count, the fail-closed classes and an admin. At the pin commit: 8 failed, 4 passed of 12. After the fix: 12 of 12.packages/qa/dogfood/test/activity-parent-read-gate.dogfood.test.ts, commit 2e261af. It covers the public data doors above. Each returned row is checked by opening its parent through the same door as the same member. Against the pre-fix build: 4 failed, 2 passed of 6. After the fix: 6 of 6.activity-read-visibility.test.tsfor the fail-closed branches, the scan bound, the order pass-through and the envelope keys. It also adds a mounting assertion toaudit-plugin.test.ts.Ablation: gate off, red, restore (from the committed fix 2afc6a0)
The mutation deletes the one
installActivityReadVisibility(...)call inaudit-plugin.tsthroughscripts/ablation-replace.mjs: anchor count 1 → 0, blob952d943e→84a26115. A trap restores it by absolute path.audit-plugin.test.tsgave 10 failed, 12 passed of 22. The restore was proven: blob equals HEAD952d943eandgit diff HEADis empty. Re-run: 22 of 22.dist/): after the rebuild, the JS was emitted. The DTS step refused the now-unused import (TS6133), and the suite consumes the JS.ablation-dist-preflight --absentproved the call gone from all 4 built files. The dogfood pin then gave 4 failed, 2 passed of 6. The restore was proven, the rebuild exited 0, and the preflight proved the call present again. Re-run: 6 of 6.Verification
At HEAD 22cc56d, run as one locked script. Each exit code was captured before any pipe.
pnpm --filter @objectstack/plugin-audit test: 28 files, 392 tests passed.pnpm --filter @objectstack/plugin-audit typecheck: exit 0, test layer included (check:test-typecheckOK, 0 errors).activity-parent-read-gate.dogfood.test.tspassed 6 of 6, andcomments-permission-matrix.dogfood.test.tspassed 10 of 10. The comment gate is the one whose readability answer is now shared.dispatch-gates --commands(95): 91 ran green. 4 are NOT MEASURED, each refusing on its own PREREQUISITE NOT MET (exit 3):check:dual-build-cjs-loadsandcheck:type-check-debtneed a whole-repo build,check:i18nneeds the built CLI, andspec check:skill-examplesneeds the built client SDK.dispatch-gates --ran: 95 accounted for, 0 unrun.eslint --no-inline-config --format jsonover the 7 changed TypeScript files reports 7 files linted, with 0 errors and 0 warnings. An ignored file would report a warning, so none of them falls outside the config's population.eslint.config.mjssets noparserOptions.projectand no cross-file import rule, so this diff cannot move a verdict on an untouched file. The whole-repopnpm lintruns in CI.Acceptance notes
Part of. One read door of this object answers outside the engine's middleware chain, so an engine middleware does not reach it. That door was measured on the same boot and reported to the dispatching seat. It is not described here, and closing it is outside this package.sys_audit_log, which this gate does not touch. Whether admins should keep those rows is raised with the seat. The security service gives this object no per-object read-bypass answer to key on, measured on/auth/me/permissionsfor the seeded admin.content/docs/permissions/system-context.mdx, row 45.check-system-context-censusrequires everyisSystemread site to carry an anchor, and the new middleware's early return is one. The declared counts were regenerated bygen:system-context-census.GET /searchreturned nosys_activityhits for either principal, before or after, in this boot. That reading says nothing about the gate.check:dual-build-cjs-loads,check:type-check-debt,check:i18n(built CLI), andpnpm --filter @objectstack/dogfood typecheck. Its 40 errors locally are all TS2307 for unbuilt packages plus one downstream TS7006, and none is in the added file.Generated by Claude Code