fix(plugin-webhooks): match webhook subscriptions on the organization dimension — closes the cross-organization fan-out on both paths (#13566) - #16700
Conversation
… dimension (#13566) `AutoEnqueuer.handleEvent` / `handleBulkEvent` selected subscriptions by object name and trigger only, from one cache holding every organization's `sys_webhook` rows — so on a walled deployment organization A's record events reached organization B's endpoint, signed with B's secret. Both fan-out paths now compare the subscription's own organization (`CachedSubscription.organizationId`, #13546) with the organization the producer stamped on the event (`DataEvent.organizationId`, #14970; `BulkDataEvent.organizationId`, #15225 / #15813): one equality per candidate, no lookup on the hot path. A subscription with no organization ownership does not receive an organization-walled event (loud refusal, said once per subscription); an organization-owned subscription receives only its own organization's events and is fail-closed on an event that names none, on both paths. Nothing stamps either side on a `single` posture, so delivery there is unchanged. A present-but-off-contract `organizationId` drops the event loudly, delivering to nobody. Pins assert on which subscriptions the enqueuer selected (the enqueue seam), never on delivery rows — #13565 stamps a delivery with the SUBSCRIPTION's organization, so a leaked delivery reads as natively owned by the receiver. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…ming the subscription's organization; add the changeset The two #13565 pins fed an organization-owned subscription an event that named no organization — the leniency #13566 removes. The pinned assertion (the enqueue input carries the SUBSCRIPTION's organization) is unchanged; the event now names that same organization, so the delivery it is pinned on still happens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
…ssage prose `check:doc-authoring` refuses a tracker id inside customer-facing string prose (maintainer ruling 2026-08-12); the ids stay in the code comments, the warn/debug texts name the rule and the remedy without them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
📓 Docs Drift CheckThis PR changes 1 package(s): 1 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 — 4 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 074fb21f7c3abbcc4d178cba077fadb067a160e6 && git checkout 074fb21f7c3abbcc4d178cba077fadb067a160e6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin b3ef687c043c96e928e1fde09ba973bb69e3dff8 62b1c51a2edd6f46b6b9141855adf634ca61e4ef && git checkout -B drift-repro b3ef687c043c96e928e1fde09ba973bb69e3dff8 && git merge --no-ff 62b1c51a2edd6f46b6b9141855adf634ca61e4ef
node scripts/docs-audit/affected-docs.mjs --json b3ef687c043c96e928e1fde09ba973bb69e3dff8
|
Fixes #13566
Which fan-out path this closes
Both. The per-record path (
data.record.*,AutoEnqueuer.handleEvent) and the bulk path (data.records.*,AutoEnqueuer.handleBulkEvent) now match subscriptions on the organization dimension. Neither is left open on the consumer side. What remains outside this PR is producer-side and named below (carve-out objects), not a fan-out path.The defect
AutoEnqueuerselected the subscriptions to deliver to by object name and trigger only, from one cache holding every organization'ssys_webhookrows. On a walled deployment (OS_TENANCY_POSTURE=isolated|group) organization A's record events reached organization B's webhook URL, signed with B's secret, on first delivery. The census on the card (5479460377) established that a tenant's ownowner/admincan create such a row through the REST data door, so this is a live cross-organization leak, p0.The fix — one comparison, no lookup
The subscription half was already cached (
CachedSubscription.organizationId, #13546); the event half is stamped by the producers —DataEvent.organizationIdfrom the record's own tenant column (#14970 / PR #15220) andBulkDataEvent.organizationIdfrom the Layer 0 wall's recorded verdict (#15225 / PR #15687, #15813 / PR #15878).admitsOrganizationcompares the two per candidate, after the trigger check and before the parked branch (a parked subscription records the payload on a deadsys_http_deliveryrow, so a foreign payload must not reach that either):singleposture)Ruling, verbatim: a subscription with no organisation ownership does not fan out — loud refusal, never a silent cross-organisation delivery.
Measured before writing the filter (what the producers actually stamp,
packages/objectql/src/engine.tson the merged tree):eventOrganizationIdreads the ROW's tenant column throughresolveTenantFieldName— neverexecCtx.tenantId— and returns absent for: object not tenant-scoped, no row in hand, column absent/null/empty, malformed value. So on this path absent can also be a producer that had no row, published absent rather than substituting the caller's organization. The filter therefore treats absent as fail-closed for an organization-owned subscription on this path too, not only on the bulk path.bulkEventOrganizationIdreads only the recordedTenantLayer0Verdict:organizationor a one-elementorganizations⇒ that organization;none,deny, a multi-membership set, no recorded verdict, or junk ⇒ absent. Absent is a routine value there (every system sweep, every multi-membershipgroupwrite), and it is fail-closed: an organization-owned subscription does not take it; an organization-less (deployment-wide) subscription may, which is the consumer the spec names.Both refusals are said once per subscription (same ledger shape as
droppedForSecret, pruned to live rows on refresh), naming the consequence and the remedy, so a refused row is not dead while looking armed. AnorganizationIdthat is present but not a non-empty string drops the whole event loudly as off-contract, delivering to nobody — the schema refuses that at the publish site, so its arrival means a producer that did not validate.Verified on the fan-out, never on delivery rows
Every new pin asserts on the
refIds handed to the enqueue seam — which subscriptions the enqueuer selected. #13565 stamps a delivery with the SUBSCRIPTION's organization, so a leaked delivery reads as natively owned by the receiver; a test oversys_http_deliveryrows passes on a live leak.Reverse verification, from the committed state (
9e6d20c79, HEAD blob ofauto-enqueuer.ts=2158854eb…):admitsOrganizationmutated toreturn true(the pre-fix leak; on-disk marker count 1, blob42d7b740…≠ HEAD) ⇒ 8 failed | 30 passed — the leak pin, both loud-refusal pins, both fail-closed pins, the'*'pin and the say-once-ledger pin, on both paths; restored withgit checkout HEAD -- PATH, marker count 0,git diff HEADempty,git hash-object= HEAD blob (MATCH). The subject is resolved from source by the same-package test (./auto-enqueuer.js), nodist/on the resolution path, so no dist preflight applies.Ordered first measurement — does released 17.3.0 carry this leak?
Yes, on both fan-out paths. 17.3.0 was cut at
8a1bad8b8(chore: version packages (#11336), 2026-09-04T10:20Z; npm@objectstack/plugin-webhooks@17.3.0published 2026-09-04T10:53Z). Read at that commit, each zero with a control on the same tree and command shape:auto-enqueuer.ts:834-835and:934-935select onsubscriptions.get(event.object)+get('*')only;organizationIdinside a comparison: 0 (control on this branch's HEAD, same grep: 3 hits atadmitsOrganization). The 5organizationIdmentions in that file at 17.3.0 are the fix(service-messaging): stamp organization_id on sys_http_delivery rows so the redeliver() cross-organization wall excludes other tenants' rows #13565 stamp, none a match term.sys_webhooknamed inplugin-security/src: 0 files (controlsys_userindefault-permission-sets.ts: 35);auto-org-admin-grant.tsposture switch present (postureEnforcesWallat :85);sys-webhook.object.tshas noaccess:/sharingModel(controlenable:2) so the plain wildcard covers it; the Five more instances of the #8323 class: admin- and user-authored names on tenant-scoped objects still carry installation-wide unique indexes #8554 organization-unique pin (tenant-authored rows underisolated) present.c393b56fc) IS an ancestor (exit 0) —publishDataEventstamps the key (3 mentions); the bulk producer (fix(objectql): a published BulkDataEvent names the one organization the tenant wall named for the batch #15687,4b3955ead) and Implement the ruled seam (i):plugin-securityrecords its Layer 0 verdict on the operation, and the bulk-event publish site reads it instead of re-deriving the wall #15813 are NOT (exit 1 each; control fix(service-messaging): stamp organization_id on sys_http_delivery rows so the redeliver() cross-organization wall excludes other tenants' rows #1356599d23b1ecexit 0) —publishBulkDataEventstamps nothing (0). Either way the consumer ignored the key, so both paths leak in 17.3.0.e7d2cc67f, 2026-08-23) carries the same chain (auto-org-admin-grant present, Five more instances of the #8323 class: admin- and user-authored names on tenant-scoped objects still carry installation-wide unique indexes #8554 pin present,sys_webhookin plugin-security 0 files, same two match sites at :815 / :909). The leak predates 17.3.0; the first affected release was not bisected here.⛔ No release note is written here and
content/docs/releases/is untouched — the disclosure call is the maintainer's; this measurement is its input.Clause ② — re-derived from the export surface: no
Judged from the built
dist/of@objectstack/plugin-webhooksbefore and after the change (hashes in the report), not from a path test: the set of exported declarations inindex.d.tsis identical (same digest), the public members ofAutoEnqueuerare identical,schema.d.tsis byte-identical; the only.d.tsdelta is twoprivatemember lines and comments. The change narrows the delivery set and consumes a key the producers already publish — it widens no accept set and no public surface (SKILL.md's Clause ② criterion), and a runtime security-behaviour change is by that skill's own negative boundary not Clause ②. So noneeds:contract-review.Changeset —
patchon@objectstack/plugin-webhooksJudged against AGENTS.md's rule verbatim (
skip-changesetis only for a diff that publishes nothing from any released package): this diff changes the published runtime of a released package, so it takes apatchchangeset (.changeset/webhook-fanout-organization-dimension.md), which states the behaviour change for organization-less subscriptions on walled deployments.Known producer-side edge, named and left open (not widened here)
The
platformGlobalObjectscarve-out (#12699): #15813 landed (PR #15878, ancestor of this base) for the BULK producer — it reads the wall's recorded verdict, so a carve-out object yieldsnone⇒ absent ⇒ fail-closed here. For the PER-RECORD producer the carve-out is still invisible to the engine (the deployment declaration is not on the schema), so an exempted object's row may carry a stamped column value. With this filter a wrong-but-present key can only UNDER-deliver (that organization's subscriptions only; organization-less subscriptions refused loudly) — it cannot produce a cross-organization delivery — and an absent key is fail-closed for organization-owned subscriptions. Stated as reasoning from the filter's matrix, not as a measurement of a carve-out deployment; nothing here assumes the stamped key is trustworthy for carve-out objects.验收备注
plugin-securityrecords its Layer 0 verdict on the operation, and the bulk-event publish site reads it instead of re-deriving the wall #15813 lands"; Implement the ruled seam (i):plugin-securityrecords its Layer 0 verdict on the operation, and the bulk-event publish site reads it instead of re-deriving the wall #15813 had landed as PR feat(security,objectql): the Layer 0 wall records its verdict on the operation, and the bulk data-event producer reads it instead of re-deriving the wall (#15813) #15878 (f9a3c3216, 2026-09-05) before the dispatch base — a stale premise in the dispatch text, not in the card.check:doc-authoringcaught issue ids in the runtime warn strings on the first round; removed (ids stay in comments).service-realtime's in-memory adapter carries no organization term andIRealtimeService.subscribehas no tenant-facing caller in non-test sources (only the enqueuer subscribes), so the other tenant-scoped consumer the spec names has no reachable surface today; nothing to reproduce.Verification (merged tree
62b1c51a2, baseorigin/mainba5284e60)pnpm --filter '@objectstack/plugin-webhooks^...' build, 51 package builds, lock verdict command-exit 0), thenpnpm --filter @objectstack/plugin-webhooks build && test && typecheckchained with&&: 12 files / 148 tests passed,tsc --noEmit+ scripts tsconfig +check:test-typecheckOK. Target file alone: 38 tests (11 new pins). The public surface is byte-unchanged in every exported declaration, so no downstream consumer (pnpm --filter '...@objectstack/plugin-webhooks'direction) owes a re-run.node scripts/pm/dispatch-gates.mjs --commands(no paths; derived off the merge base, identical before and after the merge): 59 families run,--ranreconciles 59 derived, 59 run, 0 unrun. 56 green. Three exited 3 (prerequisite refusal, NOT MEASURED locally, CI owns them):check:dual-build-cjs-loadsandcheck:type-check-debtwant the whole tree built (47 / 15 more packages);check:i18nwants a 10-package closure. Declared narrowing in their place: the finaldist/index.cjsanddist/index.jsof this package both load undernodeand exportAutoEnqueuer(with the new filter present, 4 hits each); this diff touches no object definition or translation source, so the extractor's input for this package is unchanged; the.d.tsdelta is two private members, so no ledgered package's debt can move.check:doc-authoringwent red on the first round (issue ids inside runtime warn strings) and is green after removing them: sibling-package prose ids hold the baseline, 829 pinned sites, no growth.--no-inline-config --format json: 2 files, 0 errors, 0 warnings, at62b1c51a2. Invariance:eslint.config.mjsnever enables type-aware linting for any file (its own comment at lines 326-330; the onlyparserOptions.project/projectServicemention in the config is that comment), so this diff cannot move any untouched file's verdict; the whole-repopnpm lintis CI's run.grep -naPover the three changed files exits 1 (clean);check:nul-bytesgreen.🤖 Generated with Claude Code
https://claude.ai/code/session_012zTkyNHJ7TkuN2oXtP5x37
Generated by Claude Code