Skip to content

Commit 7ceb416

Browse files
os-trumpclaude
andauthored
fix(plugin-webhooks): match webhook subscriptions on the organization dimension — closes the cross-organization fan-out on both paths (#13566) (#16700)
* fix(plugin-webhooks): match webhook subscriptions on the organization 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 * test(plugin-webhooks): re-feed the #13565 stamp pins with an event naming 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 * fix(plugin-webhooks): keep issue ids out of the enqueuer's runtime message 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4f5ca0d commit 7ceb416

3 files changed

Lines changed: 579 additions & 5 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/plugin-webhooks": patch
3+
---
4+
5+
Webhook fan-out now matches subscriptions on the organization dimension, closing a cross-organization delivery on walled deployments (`OS_TENANCY_POSTURE=isolated|group`).
6+
7+
`AutoEnqueuer` selected the subscriptions to deliver to by object name and trigger only, and every organization's `sys_webhook` rows live in one cache — so organization A's record events reached organization B's webhook endpoint, signed with B's secret, on first delivery. Both the per-record (`data.record.*`) and the bulk (`data.records.*`) fan-out paths now compare the subscription's own organization (`sys_webhook.organization_id`) with the organization the engine stamps on the event (`DataEvent.organizationId`, `BulkDataEvent.organizationId`): one equality per candidate, no lookup on the hot path.
8+
9+
What changes for a subscription:
10+
11+
- **Owned by organization A** — receives only events stamped A. An event that names no organization (an environment-wide row or an object outside the wall on the per-record path; a batch the tenant wall could not attribute to one organization on the bulk path) is not delivered inside the wall — fail-closed — and the first such refusal is logged once with the reason.
12+
- **With no organization** (`organization_id` NULL — for example a package-declared webhook on a walled deployment) — no longer receives any organization-stamped event; the refusal is logged once per subscription. It still receives events that name no organization. On a `single`-posture deployment nothing stamps either side, so delivery there is unchanged.
13+
14+
An event whose `organizationId` is present but not a non-empty string is dropped loudly as off-contract, delivering to nobody.

0 commit comments

Comments
 (0)