Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-audit): the NULL-tenant audit guard reads the session key the engine emits - #9692
Conversation
…the engine emits `writeAudit`'s RLS fallback read `sess.tenantId`, a hook-session alias (#3280) removed repo-wide in the v11 major (#3290). `ObjectQL.buildSession` builds the session as a fixed key-set literal with no spread, so the arm resolved to `undefined` and the guard could never fire: an audit row on an object with no organization column, or one whose organization column is NULL, was stamped `organization_id: null` and hidden permanently by the SecurityPlugin's RLS predicate while the write succeeded. Both sites now read `sess.organizationId`. #8707's precedence is unchanged at the audit-row site (the record's own organization still wins); at the mention site only the key changes, the session-first order stands. Every fixture in audit-writers.test.ts hand-built its session with `tenantId`, a dialect the engine cannot produce, so the fallback cases passed because the TEST spoke the removed alias. Re-spelled to `organizationId`, plus a detector block pinning the non-null stamp and that the removed alias is not resolved. Fixes#9516
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ✅ What this run could not seeCoarse fallback — 6 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 eaa6b5372c76b30b582749f6ca3c20f3a8f3819b && git checkout eaa6b5372c76b30b582749f6ca3c20f3a8f3819b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 3b3f67d31073cb8fcd2f92b2fb642a0cfb6146c6 eb60bc99f83c151311c065872cac114e2404904d && git checkout -B drift-repro 3b3f67d31073cb8fcd2f92b2fb642a0cfb6146c6 && git merge --no-ff eb60bc99f83c151311c065872cac114e2404904d
node scripts/docs-audit/affected-docs.mjs --json 3b3f67d31073cb8fcd2f92b2fb642a0cfb6146c6 |
os-project-manager
commented
Aug 18, 2026
PM review — both open questions ruled, flipping to ready and arming auto-mergeQ1 — |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#9516
writeAudit's NULL-tenant guard read a session key the engine does not emit, so the guard could never fire. Both readers of the removed alias in this package now readsess.organizationId, and the drift is now pinned by tests instead of by prose.Verified on current
mainbefore editingObjectQL.buildSession(packages/objectql/src/engine.ts) builds the hook session as an object literal with a fixed key set and no spread, so no other key can arrive:Its own comment records the removal, and
packages/spec/src/data/hook.zod.tsagrees (it declaresorganizationIdand documents the alias as removed in the v11 major):The premise holds:
sess.tenantIdresolved toundefined, and the expression was exactlyrecordOrgId.What was broken
The block's own comment states the consequence it exists to prevent — an audit row must never be written with
organization_id = NULL, or the SecurityPlugin's RLS predicate hides it forever while writes keep succeeding. The two cases it names are precisely the ones where the record cannot supply an organization: an object with no organization column at all (single-tenant stacks, ADR-0066 platform-global objects) and a row whose organization column is NULL or empty. On both,recordOrgIdisundefined, the dead arm contributed nothing, and the row was stamped null.Nothing went red: every
sys_audit_logfield isreadonly: truesovalidateRecordskips it, and the write path is wrapped in swallow-and-report.The changes
1. Both sites now read
sess.organizationId.writeAudit—recordOrgId ?? sess.organizationId. Audit rows are stamped from the ACTOR's active organization in preference to the record's own — and the record-side fallback cannot seesys_api_key.active_organization_id#8707's precedence is unchanged: the audited record's own organization still wins, honouring the ruling on [finding] API keys carry no organization — under the isolated posture a minted key reads no org data at all (no leak, but the key surface is inert) #8287. Only which key the second arm reads has changed.writeCommentMentions—sess.organizationId ?? row.organization_id ?? null. Order deliberately unchanged here too.Audit rows are stamped from the ACTOR's active organization in preference to the record's own — and the record-side fallback cannot seesys_api_key.active_organization_id#8707's reasoning is about an audit row read through the record's own tenant wall; a mention notification is not that, so applying the flip to this site would be inventing a ruling for a different consumer. Only the key changed.2. The detector pin (
#9516block inaudit-writers.test.ts).The card's named assertion — an audit row on an object with no organization column, under a session that has an active organization, lands non-null — plus the NULL-column twin, a pin that the removed alias is not resolved, and the mention-path equivalent. Two further cases are green before and after on purpose, pinning that the fix did not disturb either site's precedence.
3. Every fixture in the file was re-spelled to
organizationId.This is the substantive half. All 19 session fixtures in
audit-writers.test.tshand-built their session as{ tenantId: ... }— a dialectbuildSessioncannot produce. The fallback cases passed because the test spoke the removed alias, not because the code worked. Re-spelled, they exercise the shape the engine actually emits.Reverse verification
Prediction was recorded before running anything.
main, original fixturesStep A is the measurement of why this drifted: 268 tests, every one green, with the dead arm live. Not one could see it.
Prediction versus observation: I predicted 10 red at step B and observed 11. The extra one is instructive rather than noise —
stamps organization_id on multi-tenant tables when the column exists(#1532) builds its engine fromMULTI_TENANT, which declares onlysys_audit_logandsys_activity. The audited objectcrm_leadis absent from the schema map, soresolveRecordOrgFieldreturns null andrecordOrgIdis undefined no matter what the result row carries — the case rode entirely on the session arm despite puttingorganization_id: 'org-9'on the record. So 7 pre-existing tests, not 6, were silently held up by a fixture-only key.Ablation markers removed and proved:
grep -c OS_ABLATION_9516= 0, repo-wideOS_ABLATION= 0, tree clean at the final commit.The repo-wide sweep — census
git grep -rn "session\.tenantId\|sess\.tenantId"across the whole repo. Live code readers of the removed alias, all repos:packages/plugins/plugin-audit/src/audit-writers.ts:1340, :1629packages/objectql/src/engine.test.ts:540, :553;packages/runtime/src/http-dispatcher.test.ts:3972expect(session.tenantId).toBeUndefined())packages/objectql/src/engine.ts:2762;packages/objectql/src/plugin.ts:943;packages/runtime/src/action-execution.ts:853, :865;packages/spec/src/data/hook.zod.ts:617CHANGELOG.md,packages/*/CHANGELOG.md,content/docs/**,.changeset/audit-row-record-organization-stamp.mdpackages/plugins/plugin-audit/src/read-audit.test.ts;comment-access-hooks.test.tsReadContext.tenantIdand theExecutionContextprincipal envelope, the driver-layer knob that legitimately staysobjectuiSo the two fixed here were the whole live population. No other dead reader to file.
Out-of-scope finding, filed not ridden
#9691 —
check-org-identifieris a hard-fail gate scoped topackages/whose header asserts "the scanned surfaces carry ZERO occurrences today". Its detector anchors on the literal receiver name:The shipped code binds the receiver to a local named
sess, so the gate scored 0 hits on the unfixed file that contained 2 live dead-alias reads (measured with the gate's own pattern over its ownmaskCommentsprojection). It printedOK (2053 author-facing source file(s), no removed session.tenantId alias)on every PR for the entire period. Distinct from #9444/#9496, which fixed this gate's comment-masking hole. Not fixed here — it lives inscripts/, outside this card's scope.Why this survived, and why the pin is the point
The comment above the first site is unusually careful about precedence and even carries a directive written on the assumption that the arm is live — "⛔ Do not flip this back to
sess.tenantId ?? recordOrgId". #8707 changed the order of the two arms and recorded its reasoning in full, but reordering two expressions does not evaluate either of them, so an arm that had already stopped resolving stayed invisible through a careful review of exactly this code.That is the shape worth naming: prose sitting next to a mechanism with nothing checking that they agree. The one-line key change is the small half. The pins, and the fixture re-spell that stops the tests from speaking a dialect the engine cannot produce, are what make the declaration enforceable.
Changeset
.changeset/audit-tenant-fallback-reads-organization-id.md, patch on@objectstack/plugin-audit. Owed because this changes behaviour on real deployments: single-tenant stacks, and any multi-tenant deployment auditing a platform-global object or a row with an empty organization column, stop accumulating unreadable audit rows. Rows already written with a NULL organization are not repaired by this change — noted explicitly in the changeset.Gates
Union re-derived after the final commit with
node scripts/pm/dispatch-gates.mjsoffgit merge-base(c07d6e8), run ateb60bc99fwith a clean tree.Green:
check:nul-bytes,check:org-identifier,check:changeset-gate-self-tests,check:objectui-changeset,check:test-source-alias,check:type-source-resolution,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,check-cross-package-test-inputs,check-affected-docs,check:query-options-erasure,check:engine-double-contract,check:where-matcher,check:type-check-coverage,check:type-check-debt --re-measure(33 ledger entries, none above its recorded number),check:i18n(plugin-audit bundles in sync), pluspnpm --filter @objectstack/plugin-audit testandtypecheck.check:i18nfirst reported a prerequisite failure, not drift — a fresh worktree has no built CLI. Built@objectstack/cliand re-ran; green, exit code read directly rather than through a pipe, as that gate's own message warns.Beyond the derived union I added
check:org-identifierbecause this diff is precisely about the alias it guards, andcheck:nul-bytesper standing policy.Coordination
Untouched, per the dispatch note:
read-audit.ts, andsys-audit-log.object.ts's view definitions (#9539 awaits a maintainer privacy ruling).Generated by Claude Code