Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-sharing): guard the record-share $in against a nullish record_id before String() coerces it - #13590
Conversation
…d_id before String() coercion Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…r shifted lines Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 8 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 2006f7a49731d11be213da37167c0f41a3abd5df && git checkout 2006f7a49731d11be213da37167c0f41a3abd5df
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9c120f0308d5aed3c26f53c406054a95a631d156 374de16c863049f7ef841340a845d478b747a7d7 && git checkout -B drift-repro 9c120f0308d5aed3c26f53c406054a95a631d156 && git merge --no-ff 374de16c863049f7ef841340a845d478b747a7d7
node scripts/docs-audit/affected-docs.mjs --json 9c120f0308d5aed3c26f53c406054a95a631d156
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#13551
What was wrong
buildReadFilterand the bulk-write half ofbuildWriteFiltereach turned the caller'ssys_record_sharerows into the members of a security predicate,{ id: { $in: [...] } }, with the same expression:.filter(Boolean)reads as "drop rows whoserecord_idis nullish". It cannot:String(null)is'null',String(undefined)is'undefined', and both are truthy. The only value the filter could drop was the empty string, so the guard was dead for exactly the case its spelling advertised, and a row with a nullishrecord_idput the literal string'null'into the emitted$in.Direction: a dead guard, not an open bypass
The card's direction analysis holds, and this PR is not a bypass fix. The emitted member is a bogus id matching no row on any backend, and both sites are positive polarity — an OR-ed branch beside the owner match, never negated — so a corrupt row lost its grant rather than widening anyone's scope. It also took an already-corrupt row to reach at all. What was broken is the guard's honesty: a reader, or an audit asking which security paths already handle nullish ids, would have counted these two sites as covered when they provably were not.
The repair
Both sites now call one module-private
grantedRecordIds()that tests the raw column value first and coerces after. That order is the whole content of the change. Factoring it into a single helper is deliberate: the expression stood in two places, and repairing one would have left the other advertising a guarantee it does not keep.The shape matches the guards audited alongside these rather than inventing a third — in this package,
sharing-rule-service.ts:915(if (rid != null && rid !== '') granted.add(String(rid))) andprimary-bu-projection.ts:74; across the fence and read-only here,core/src/security/resolve-authz-context.ts,plugin-security's controlled-by-parentmasterIds, andobjectql's master-detail parent resolution. All of them share one invariant: test beforeString().The non-null path is unchanged. Every non-nullish value still stringifies exactly as before — a driver-numeric primary key still becomes its decimal string — and the empty string, the one value the old spelling really did drop, is still dropped by the trailing
!== ''. The only behavioural difference is that rows with a nullishrecord_idcontribute no member; when they are the only grants, the filter collapses to the plain owner match instead of OR-ing in a branch that matched nothing.The map now reads
g?.record_idrather thang.record_id, matching the sibling guards: a null element in the row array contributes nothing instead of throwing.The escalation gate triage asked the implementer to answer first
Triage asked whether any object's record id can equal the string
null, which would turn this from a dead guard into a real over-grant. Measured: it is not excluded by construction. Drivers mint an id only when the caller supplies none —sql-driver.tswritestoInsert.id = nanoid(...)only in thetoInsert.id === undefinedarm, andmongodb-driver.tsguards the same way — so a caller-supplied or importedidis stored verbatim, and no format validation stands between it and the column. Federated objects carry the remote primary key verbatim as well.Two things that does not change. It still takes an already-corrupt share row and such a record to coincide, so nothing here demonstrates a live over-grant; and this PR closes the path either way, because the emitted set can no longer contain any member derived from a nullish
record_id. I did not demonstrate that either row exists. Priority and labels are left untouched — that call is triage's.The PM's mechanism assumptions, measured
access_level, the write site restricts toWRITE_ACCESS_LEVELSand selects onlyrecord_id— but both read the same column of the same object, both feed a positive-polarity$orbranch, and both collapse the empty case to the owner match. Same nullability, same polarity.if (grantedIds.length === 0) return ownerMatch, so "no grants" and "grants that resolved to nothing" already produce the identical return value at both sites. No consumer can distinguish them, and a sentinel would have to invent a distinction the call sites deliberately do not make.record_idor the string'null', and a repo-wide scan for a$incarrying'null'returned only unrelated table-driven test labels. No pin had to be edited to keep this green.Verification
Run at
374de16.pnpm --filter @objectstack/plugin-sharing test— 30 files, 683 tests passed;pnpm --filter @objectstack/plugin-sharing typecheckclean.sharing-service.test.ts, at both construction sites: a nullishrecord_idcontributes no member while the real grant survives; when every grant is nullish the share branch disappears entirely rather than OR-ing in a branch that matches nothing; and the over-denial control — an ordinary grant set still produces exactly its ids, on both filters. A fifth pins the unchanged non-null path: a driver-numeric id still stringifies, an empty string is still dropped. Rows are seeded straight into the fake table becausegrant()refuses a nullishrecordIdat the front door, so writing the row directly is the only way to stand up the already-corrupt state the guard exists for.AssertionError: expected [ 'a1', 'null', 'undefined' ] to deeply equal [ 'a1' ]. The mutation was confirmed on disk before measuring (the injected line greps to 1, the removed guard to 0, and the blob hash differs from the HEAD blob), and the restore leg was confirmed by the file's blob hash returning to the HEAD blob exactly. No rebuild leg was needed and none is claimed: the test imports the subject by relative path inside the package, so vitest resolves it from source with noexportsordisthop.Gate families were re-derived from the actual diff with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which is also what caught the docs page below — the first derivation, before that page was in the diff, named 20 fewer families. All derived families were run except four, each declared:check:i18nandcheck:skill-examplesboth refused with an explicit PREREQUISITE NOT MET (an unbuilt workspace CLI and an unbuiltclient-react/dist) and state that nothing was checked, so they are NOT MEASURED here rather than green or red;check:type-check-debt --re-measureandcheck:dual-build-cjs-loadsneed the built workspace closure. CI builds the workspace and runs all four.check:test-completenessandcheck:pm/half-statesexited 3, which both scripts define as NOT MEASURED — they need a saved turbo test log and a GitHub token respectively.Surface beyond the dispatched file list, declared
content/docs/permissions/system-context.mdx— 8 line anchors, all in theplugin-sharing/src/sharing-service.tsrows, shifted by+29.This is collateral from this PR, not a drive-by. The helper adds a net 29 lines near the top of the file, which moves every elevation-read site below it, and
check:system-context-censuswent red with 16 problems. The shift is provable rather than judged: every reported site sits exactly 29 lines below the anchor that used to name it (654/625, 920/891, 1007/978, 1208/1179, 1286/1257, 1338/1309, 1597/1568), and the eighth anchor — the "guard at" citation in row 34 — lands on theNON_READ_ANCHORSneedle's new line, 1311.The gate's own
--fixcould not do this repair on my base, and the reason is already known and already fixed: it classified non-read anchors by comparing them to the needle's currently-resolved line, so the stale:1282citation counted as a read anchor, the per-file tally read 8 anchors against 7 census sites, and a pure shift was rejected as a population change (NOT fixable: ... the POPULATION changed, this is not a shift). That is #13490 exactly, which PR #13575 repaired; it merged intomainas9c120f0after this branch was cut — so nothing is filed here, and no gate was weakened. The hand-written shift is the same end state that--fixnow produces;content/docs/permissions/system-context.mdxhas not moved onmainsince this base, so it conflicts with nothing.check:system-context-censusis green after the shift:109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read.No other file in
plugin-sharingwas touched, per the fence around the in-flight batch-6 slice.Generated by Claude Code