Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/http-outbox-organization-stamp.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
---
"@objectstack/service-messaging": minor
"@objectstack/service-automation": patch
"@objectstack/plugin-webhooks": patch
---

fix(service-messaging,service-automation,plugin-webhooks): stamp `organization_id` on `sys_http_delivery` rows so the cross-organization wall on `redeliver()` actually excludes other tenants' rows (#13546)

`sys_http_delivery` is tenant-scoped and `redeliver()` — the one
request-reachable door on it — deliberately scopes by the caller's
organization (#10740). But the enqueue door never stamped the
`organization_id` column, and the SQL driver's tenant term is
`(organization_id = :tenantId OR organization_id IS NULL)` — a deliberate
global-row fail-open — so 100% of delivery rows landed in the NULL arm:
visible to, and replayable by, every organization on a walled deployment.

The repair mirrors the notification outbox's existing convention
(`EnqueueDeliveryInput.organizationId`), end to end:

- `EnqueueHttpInput` gains an **optional** `organizationId` member (inherited
by `UndeliverableHttpInput`, so parked rows are tenant-stamped too), and
`HttpDelivery` surfaces it on read-back. `SqlHttpOutbox.insert` writes
`organization_id: input.organizationId ?? null` exactly like
`SqlOutbox.enqueue`; `MemoryHttpOutbox` stores the same field and — now
that its rows carry a tenant — applies `RedeliverOptions.tenantId` in
`redeliver()` with the driver's exact semantics (another organization's row
is invisible/`RESOURCE_NOT_FOUND`; an org-less row stays a global row; a
tenant-less caller stays unscoped).
- The flow `http` node (durable mode) threads its run's acting organization
(`AutomationContext.tenantId` — the same source as the `notify` node's
#11303 repair) and warns loudly when a multi-org run has none to thread.
- The webhook auto-enqueuer stamps each delivery with its subscription's own
organization (`sys_webhook.organization_id`); org-less subscriptions
enqueue org-less, unchanged.

Forward-stamping only: existing NULL rows are untouched (their disposition is
a separate decision). Producers with genuinely no organization — a
`single`-posture deployment, a stack before its first organization — keep
working unchanged; their rows land NULL, which is the honest global-row shape.
61 changes: 61 additions & 0 deletions packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -222,6 +222,48 @@ describe('AutoEnqueuer', () => {
await ae.stop();
});

// [#13546] The delivery row belongs to the SUBSCRIPTION's organization —
// `sys_webhook` is organization-scoped (#8554), the enqueuer runs
// fire-and-forget off the write path with no request context, so the
// subscription row is the one honest tenant source. Without the stamp the
// row lands `organization_id = NULL` — the driver's global-row arm — and
// the redeliver() cross-organization wall (#10740) excludes nothing.
it("stamps the subscription's organization onto the enqueue input (#13546)", async () => {
const engine = new FakeEngine({
sys_webhook: [webhook({ organization_id: 'org_pin_alpha' })],
});
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(event('created', 'contact', { id: 'c-1' }));
await flush();

expect(calls).toHaveLength(1);
// Verbatim from the sys_webhook row — threaded, never fabricated.
expect(calls[0].organizationId).toBe('org_pin_alpha');
await ae.stop();
});

it('an org-less subscription enqueues with NO organization (the honest global-row shape, #13546)', async () => {
// The over-denial control: a `single`-posture install has org-less
// sys_webhook rows, and their events must still deliver — org-less,
// never refused, never stamped with a guess.
const engine = new FakeEngine({ sys_webhook: [webhook()] });
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(event('created', 'contact', { id: 'c-1' }));
await flush();

expect(calls).toHaveLength(1);
expect(calls[0].organizationId).toBeUndefined();
await ae.stop();
});

it('[#4626] drops an off-contract data event instead of enqueuing it as "unknown"', async () => {
// Pre-#4626 the enqueuer read `recordId ?? id ?? after?.id ?? 'unknown'`,
// so a payload that named no record still produced a delivery whose
Expand DownExpand Up@@ -583,6 +625,25 @@ describe('AutoEnqueuer — bulk data events (#4639)', () => {
await ae.stop();
});

it("the bulk path stamps the subscription's organization too (#13546)", async () => {
// Same tenant seam as the per-record path — a bulk delivery for an
// organization-owned subscription must not land as a global row either.
const engine = new FakeEngine({
sys_webhook: [webhook({ triggers: 'bulk_update', organization_id: 'org_pin_alpha' })],
});
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(bulkEvent('updated', 'contact', 3));
await flush();

expect(calls).toHaveLength(1);
expect(calls[0].organizationId).toBe('org_pin_alpha');
await ae.stop();
});

it('does NOT deliver a bulk event to a per-record update subscriber', async () => {
// The opt-in half of the decision: an existing `update` webhook keeps
// receiving only bodies shaped the way it already reads them.
Expand Down
28 changes: 28 additions & 0 deletions packages/plugins/plugin-webhooks/src/auto-enqueuer.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -115,6 +115,21 @@ interface CachedSubscription {
headers?: Record<string, string>;
secret?: string;
timeoutMs?: number;
/**
* [#13546] The subscription's own organization — `sys_webhook` is
* organization-scoped (#8554), so each row carries the tenant that authored
* it. Stamped onto every delivery row this subscription produces
* (`EnqueueHttpInput.organizationId`), which is what makes the
* cross-organization wall on `redeliver()` (#10740) actually exclude other
* tenants' rows: a row enqueued without it lands `organization_id = NULL`,
* the driver's global-row arm, visible to every organization. There is no
* request context to read here — the enqueuer runs fire-and-forget off the
* write path — so the subscription row is the one honest source. Absent
* when the row itself carries no organization (a `single`-posture install):
* the delivery then lands NULL, which is honest for a subscription that
* belongs to no organization. Threaded, never fabricated (#11303's rule).
*/
organizationId?: string;
/**
* [#8069] Set when a credential this subscription needs could not be
* recovered. The subscription stays CACHED — that is the change — but every
Expand DownExpand Up@@ -783,6 +798,10 @@ export class AutoEnqueuer {
// from their encrypted columns, NOT read off the row — see #7799
// (secret) and #7986 (headers).
timeoutMs: defn.timeoutMs,
// [#13546] The tenant column the kernel provisions on sys_webhook.
// This cache read is a dispatcher-side unscoped find, so the column
// comes back for every organization's rows.
organizationId: row.organization_id ? String(row.organization_id) : undefined,
};
}

Expand DownExpand Up@@ -867,6 +886,12 @@ export class AutoEnqueuer {
// subscription, so the delivery path is byte-identical to before.
undeliverableReason: sub.parkedReason,
timeoutMs: sub.timeoutMs,
// [#13546] The delivery row belongs to the SUBSCRIPTION's
// organization — the one honest tenant in scope on this
// fire-and-forget path (no request context exists here).
// Absent for an org-less subscription; the row then lands
// NULL, the global-row shape.
organizationId: sub.organizationId,
// [#3946] Envelope keys are written LAST so the event payload
// cannot rewrite them. Behaviour-neutral for the engine's own
// publishers — since #4626 a `data.record.*` payload is a
Expand DownExpand Up@@ -960,6 +985,9 @@ export class AutoEnqueuer {
// an undeliverable row instead of enqueuing a delivery.
undeliverableReason: sub.parkedReason,
timeoutMs: sub.timeoutMs,
// [#13546] See the per-record path — the subscription's own
// organization, absent for an org-less subscription.
organizationId: sub.organizationId,
// [#3946] Envelope keys last so the payload cannot rewrite them.
payload: {
...payload,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,6 +110,78 @@ describe('http (canonical node)', () => {
expect(result.success).toBe(true);
expect(fetchMock).toHaveBeenCalledOnce();
});

// [#13546] The producer pin: `sys_http_delivery` rows must carry the
// organization of the run that caused them, or they land in the
// driver's `organization_id IS NULL` global-row arm — visible to and
// replayable by every organization through redeliver() (#10740). The
// organization is THREADED from the run's own acting context
// (`AutomationContext.tenantId`) — the same source, and the same
// no-fallback rule, as the notify node's #11303 repair.
it("threads the run's acting organization onto the enqueue input (#13546)", async () => {
const enqueued: any[] = [];
const messaging: HttpSurface = {
isHttpDeliveryReady: () => true,
async enqueueHttp(input) {
enqueued.push(input);
return 'dlv_1';
},
};
const engine = new AutomationEngine(createTestLogger());
registerHttpNodes(engine, createCtx(messaging));
engine.registerFlow(
'http_flow',
httpFlow('http', { url: 'https://example.test/hook', durable: true }),
);

const result = await engine.execute('http_flow', { tenantId: 'org_pin_alpha' } as any);

expect(result.success).toBe(true);
expect(enqueued).toHaveLength(1);
// Verbatim — the acting tenant, not a derived or defaulted value.
expect(enqueued[0].organizationId).toBe('org_pin_alpha');
});

it('with NO organization in scope: still enqueues, passes NO organizationId key, and says so out loud (#13546)', async () => {
// The over-denial control (the notify suite's PIN C shape): a
// `single`-posture install and a stack before its first
// organization legitimately have no tenant to thread, and a
// durable callout there must still enqueue — org-less, loudly,
// never refused and never guessed.
const warnings: string[] = [];
const logger: any = {
info: () => {}, error: () => {}, debug: () => {},
warn: (...args: unknown[]) => { warnings.push(args.map(String).join(' ')); },
};
logger.child = () => logger;
const enqueued: any[] = [];
const messaging: HttpSurface = {
isHttpDeliveryReady: () => true,
async enqueueHttp(input) {
enqueued.push(input);
return 'dlv_1';
},
};
const engine = new AutomationEngine(logger);
registerHttpNodes(engine, {
logger,
getService: (name: string) => (name === 'messaging' ? messaging : undefined),
} as any);
engine.registerFlow(
'http_flow',
httpFlow('http', { url: 'https://example.test/hook', durable: true }),
);

const result = await engine.execute('http_flow');

expect(result.success).toBe(true);
expect(enqueued).toHaveLength(1);
// Absent, not null and not '' — the outbox normalizes a missing
// value to NULL exactly once, at its insert.
expect('organizationId' in enqueued[0]).toBe(false);
// Fail-LOUD: the org-less durable callout is a visible event.
expect(warnings.some((w) => w.includes('organization_id = NULL'))).toBe(true);
});
});

describe('request/response mode (default)', () => {
Expand Down
41 changes: 41 additions & 0 deletions packages/services/service-automation/src/builtin/http-nodes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,11 @@ interface MessagingHttpSurface {
signingSecret?: string;
timeoutMs?: number;
payload: unknown;
/**
* [#13546] Organization the delivery row belongs to — the tenant
* column the `redeliver()` cross-organization wall scopes by (#10740).
*/
organizationId?: string;
}): Promise<string>;
}

Expand DownExpand Up@@ -121,6 +126,38 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext):
if (durable) {
const messaging = getMessaging();
if (messaging?.isHttpDeliveryReady?.() && messaging.enqueueHttp) {
// [#13546] The organization this delivery belongs to,
// THREADED from the run's own acting context — never
// fabricated. Same source and same no-fallback rule as the
// `notify` node's #11303 repair one file over:
// `AutomationContext.tenantId` is the acting run's
// organization, and a wrong value is worse than a null (a
// null is visibly missing; a wrong one is silently
// authoritative). Without it the sys_http_delivery row
// lands `organization_id = NULL` — the driver's global-row
// arm — visible to and replayable by EVERY organization
// through the redeliver() door (#10740).
const organizationId =
typeof context.tenantId === 'string' && context.tenantId !== ''
? context.tenantId
: undefined;
if (!organizationId) {
// Fail-LOUD, not fail-guess, not fail-closed (#11303's
// triage): a `single`-posture install and a stack before
// its first organization legitimately have none, and a
// durable callout there must still enqueue.
// (Issue anchor lives in these comments, not in the
// runtime string — operators cannot resolve a tracker
// id; see check:doc-authoring.)
ctx.logger.warn(
`[http] node '${node.id}': no organization in scope for this durable callout — its ` +
`sys_http_delivery row will carry organization_id = NULL, which is a global row ` +
`every organization's redeliver door can reach on a walled deployment. ` +
`On a multi-organization install the triggering context lost its tenant: give the ` +
`flow's trigger an acting organization (AutomationContext.tenantId). On a ` +
`single-organization install this is expected and can be ignored.`,
);
}
try {
const deliveryId = await messaging.enqueueHttp({
source: 'flow',
Expand All@@ -133,6 +170,10 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext):
signingSecret,
timeoutMs,
payload: body ?? {},
// [#13546] Absent (not null) when the run has no
// organization; the outbox normalizes a missing
// value to NULL exactly once, at the insert.
...(organizationId ? { organizationId } : {}),
});
// #4354 — the outbox row IS a durable effect this run
// caused, but it is NOT a countable one (#7882). What
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(service-messaging): stamp organization_id on sys_http_delivery rows so the redeliver() cross-organization wall excludes other tenants' rows by os-steve · Pull Request #13565 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/http-outbox-organization-stamp.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
---
"@objectstack/service-messaging": minor
"@objectstack/service-automation": patch
"@objectstack/plugin-webhooks": patch
---

fix(service-messaging,service-automation,plugin-webhooks): stamp `organization_id` on `sys_http_delivery` rows so the cross-organization wall on `redeliver()` actually excludes other tenants' rows (#13546)

`sys_http_delivery` is tenant-scoped and `redeliver()` — the one
request-reachable door on it — deliberately scopes by the caller's
organization (#10740). But the enqueue door never stamped the
`organization_id` column, and the SQL driver's tenant term is
`(organization_id = :tenantId OR organization_id IS NULL)` — a deliberate
global-row fail-open — so 100% of delivery rows landed in the NULL arm:
visible to, and replayable by, every organization on a walled deployment.

The repair mirrors the notification outbox's existing convention
(`EnqueueDeliveryInput.organizationId`), end to end:

- `EnqueueHttpInput` gains an **optional** `organizationId` member (inherited
by `UndeliverableHttpInput`, so parked rows are tenant-stamped too), and
`HttpDelivery` surfaces it on read-back. `SqlHttpOutbox.insert` writes
`organization_id: input.organizationId ?? null` exactly like
`SqlOutbox.enqueue`; `MemoryHttpOutbox` stores the same field and — now
that its rows carry a tenant — applies `RedeliverOptions.tenantId` in
`redeliver()` with the driver's exact semantics (another organization's row
is invisible/`RESOURCE_NOT_FOUND`; an org-less row stays a global row; a
tenant-less caller stays unscoped).
- The flow `http` node (durable mode) threads its run's acting organization
(`AutomationContext.tenantId` — the same source as the `notify` node's
#11303 repair) and warns loudly when a multi-org run has none to thread.
- The webhook auto-enqueuer stamps each delivery with its subscription's own
organization (`sys_webhook.organization_id`); org-less subscriptions
enqueue org-less, unchanged.

Forward-stamping only: existing NULL rows are untouched (their disposition is
a separate decision). Producers with genuinely no organization — a
`single`-posture deployment, a stack before its first organization — keep
working unchanged; their rows land NULL, which is the honest global-row shape.
61 changes: 61 additions & 0 deletions packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -222,6 +222,48 @@ describe('AutoEnqueuer', () => {
await ae.stop();
});

// [#13546] The delivery row belongs to the SUBSCRIPTION's organization —
// `sys_webhook` is organization-scoped (#8554), the enqueuer runs
// fire-and-forget off the write path with no request context, so the
// subscription row is the one honest tenant source. Without the stamp the
// row lands `organization_id = NULL` — the driver's global-row arm — and
// the redeliver() cross-organization wall (#10740) excludes nothing.
it("stamps the subscription's organization onto the enqueue input (#13546)", async () => {
const engine = new FakeEngine({
sys_webhook: [webhook({ organization_id: 'org_pin_alpha' })],
});
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(event('created', 'contact', { id: 'c-1' }));
await flush();

expect(calls).toHaveLength(1);
// Verbatim from the sys_webhook row — threaded, never fabricated.
expect(calls[0].organizationId).toBe('org_pin_alpha');
await ae.stop();
});

it('an org-less subscription enqueues with NO organization (the honest global-row shape, #13546)', async () => {
// The over-denial control: a `single`-posture install has org-less
// sys_webhook rows, and their events must still deliver — org-less,
// never refused, never stamped with a guess.
const engine = new FakeEngine({ sys_webhook: [webhook()] });
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(event('created', 'contact', { id: 'c-1' }));
await flush();

expect(calls).toHaveLength(1);
expect(calls[0].organizationId).toBeUndefined();
await ae.stop();
});

it('[#4626] drops an off-contract data event instead of enqueuing it as "unknown"', async () => {
// Pre-#4626 the enqueuer read `recordId ?? id ?? after?.id ?? 'unknown'`,
// so a payload that named no record still produced a delivery whose
Expand DownExpand Up@@ -583,6 +625,25 @@ describe('AutoEnqueuer — bulk data events (#4639)', () => {
await ae.stop();
});

it("the bulk path stamps the subscription's organization too (#13546)", async () => {
// Same tenant seam as the per-record path — a bulk delivery for an
// organization-owned subscription must not land as a global row either.
const engine = new FakeEngine({
sys_webhook: [webhook({ triggers: 'bulk_update', organization_id: 'org_pin_alpha' })],
});
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(bulkEvent('updated', 'contact', 3));
await flush();

expect(calls).toHaveLength(1);
expect(calls[0].organizationId).toBe('org_pin_alpha');
await ae.stop();
});

it('does NOT deliver a bulk event to a per-record update subscriber', async () => {
// The opt-in half of the decision: an existing `update` webhook keeps
// receiving only bodies shaped the way it already reads them.
Expand Down
28 changes: 28 additions & 0 deletions packages/plugins/plugin-webhooks/src/auto-enqueuer.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -115,6 +115,21 @@ interface CachedSubscription {
headers?: Record<string, string>;
secret?: string;
timeoutMs?: number;
/**
* [#13546] The subscription's own organization — `sys_webhook` is
* organization-scoped (#8554), so each row carries the tenant that authored
* it. Stamped onto every delivery row this subscription produces
* (`EnqueueHttpInput.organizationId`), which is what makes the
* cross-organization wall on `redeliver()` (#10740) actually exclude other
* tenants' rows: a row enqueued without it lands `organization_id = NULL`,
* the driver's global-row arm, visible to every organization. There is no
* request context to read here — the enqueuer runs fire-and-forget off the
* write path — so the subscription row is the one honest source. Absent
* when the row itself carries no organization (a `single`-posture install):
* the delivery then lands NULL, which is honest for a subscription that
* belongs to no organization. Threaded, never fabricated (#11303's rule).
*/
organizationId?: string;
/**
* [#8069] Set when a credential this subscription needs could not be
* recovered. The subscription stays CACHED — that is the change — but every
Expand DownExpand Up@@ -783,6 +798,10 @@ export class AutoEnqueuer {
// from their encrypted columns, NOT read off the row — see #7799
// (secret) and #7986 (headers).
timeoutMs: defn.timeoutMs,
// [#13546] The tenant column the kernel provisions on sys_webhook.
// This cache read is a dispatcher-side unscoped find, so the column
// comes back for every organization's rows.
organizationId: row.organization_id ? String(row.organization_id) : undefined,
};
}

Expand DownExpand Up@@ -867,6 +886,12 @@ export class AutoEnqueuer {
// subscription, so the delivery path is byte-identical to before.
undeliverableReason: sub.parkedReason,
timeoutMs: sub.timeoutMs,
// [#13546] The delivery row belongs to the SUBSCRIPTION's
// organization — the one honest tenant in scope on this
// fire-and-forget path (no request context exists here).
// Absent for an org-less subscription; the row then lands
// NULL, the global-row shape.
organizationId: sub.organizationId,
// [#3946] Envelope keys are written LAST so the event payload
// cannot rewrite them. Behaviour-neutral for the engine's own
// publishers — since #4626 a `data.record.*` payload is a
Expand DownExpand Up@@ -960,6 +985,9 @@ export class AutoEnqueuer {
// an undeliverable row instead of enqueuing a delivery.
undeliverableReason: sub.parkedReason,
timeoutMs: sub.timeoutMs,
// [#13546] See the per-record path — the subscription's own
// organization, absent for an org-less subscription.
organizationId: sub.organizationId,
// [#3946] Envelope keys last so the payload cannot rewrite them.
payload: {
...payload,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,6 +110,78 @@ describe('http (canonical node)', () => {
expect(result.success).toBe(true);
expect(fetchMock).toHaveBeenCalledOnce();
});

// [#13546] The producer pin: `sys_http_delivery` rows must carry the
// organization of the run that caused them, or they land in the
// driver's `organization_id IS NULL` global-row arm — visible to and
// replayable by every organization through redeliver() (#10740). The
// organization is THREADED from the run's own acting context
// (`AutomationContext.tenantId`) — the same source, and the same
// no-fallback rule, as the notify node's #11303 repair.
it("threads the run's acting organization onto the enqueue input (#13546)", async () => {
const enqueued: any[] = [];
const messaging: HttpSurface = {
isHttpDeliveryReady: () => true,
async enqueueHttp(input) {
enqueued.push(input);
return 'dlv_1';
},
};
const engine = new AutomationEngine(createTestLogger());
registerHttpNodes(engine, createCtx(messaging));
engine.registerFlow(
'http_flow',
httpFlow('http', { url: 'https://example.test/hook', durable: true }),
);

const result = await engine.execute('http_flow', { tenantId: 'org_pin_alpha' } as any);

expect(result.success).toBe(true);
expect(enqueued).toHaveLength(1);
// Verbatim — the acting tenant, not a derived or defaulted value.
expect(enqueued[0].organizationId).toBe('org_pin_alpha');
});

it('with NO organization in scope: still enqueues, passes NO organizationId key, and says so out loud (#13546)', async () => {
// The over-denial control (the notify suite's PIN C shape): a
// `single`-posture install and a stack before its first
// organization legitimately have no tenant to thread, and a
// durable callout there must still enqueue — org-less, loudly,
// never refused and never guessed.
const warnings: string[] = [];
const logger: any = {
info: () => {}, error: () => {}, debug: () => {},
warn: (...args: unknown[]) => { warnings.push(args.map(String).join(' ')); },
};
logger.child = () => logger;
const enqueued: any[] = [];
const messaging: HttpSurface = {
isHttpDeliveryReady: () => true,
async enqueueHttp(input) {
enqueued.push(input);
return 'dlv_1';
},
};
const engine = new AutomationEngine(logger);
registerHttpNodes(engine, {
logger,
getService: (name: string) => (name === 'messaging' ? messaging : undefined),
} as any);
engine.registerFlow(
'http_flow',
httpFlow('http', { url: 'https://example.test/hook', durable: true }),
);

const result = await engine.execute('http_flow');

expect(result.success).toBe(true);
expect(enqueued).toHaveLength(1);
// Absent, not null and not '' — the outbox normalizes a missing
// value to NULL exactly once, at its insert.
expect('organizationId' in enqueued[0]).toBe(false);
// Fail-LOUD: the org-less durable callout is a visible event.
expect(warnings.some((w) => w.includes('organization_id = NULL'))).toBe(true);
});
});

describe('request/response mode (default)', () => {
Expand Down
41 changes: 41 additions & 0 deletions packages/services/service-automation/src/builtin/http-nodes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,11 @@ interface MessagingHttpSurface {
signingSecret?: string;
timeoutMs?: number;
payload: unknown;
/**
* [#13546] Organization the delivery row belongs to — the tenant
* column the `redeliver()` cross-organization wall scopes by (#10740).
*/
organizationId?: string;
}): Promise<string>;
}

Expand DownExpand Up@@ -121,6 +126,38 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext):
if (durable) {
const messaging = getMessaging();
if (messaging?.isHttpDeliveryReady?.() && messaging.enqueueHttp) {
// [#13546] The organization this delivery belongs to,
// THREADED from the run's own acting context — never
// fabricated. Same source and same no-fallback rule as the
// `notify` node's #11303 repair one file over:
// `AutomationContext.tenantId` is the acting run's
// organization, and a wrong value is worse than a null (a
// null is visibly missing; a wrong one is silently
// authoritative). Without it the sys_http_delivery row
// lands `organization_id = NULL` — the driver's global-row
// arm — visible to and replayable by EVERY organization
// through the redeliver() door (#10740).
const organizationId =
typeof context.tenantId === 'string' && context.tenantId !== ''
? context.tenantId
: undefined;
if (!organizationId) {
// Fail-LOUD, not fail-guess, not fail-closed (#11303's
// triage): a `single`-posture install and a stack before
// its first organization legitimately have none, and a
// durable callout there must still enqueue.
// (Issue anchor lives in these comments, not in the
// runtime string — operators cannot resolve a tracker
// id; see check:doc-authoring.)
ctx.logger.warn(
`[http] node '${node.id}': no organization in scope for this durable callout — its ` +
`sys_http_delivery row will carry organization_id = NULL, which is a global row ` +
`every organization's redeliver door can reach on a walled deployment. ` +
`On a multi-organization install the triggering context lost its tenant: give the ` +
`flow's trigger an acting organization (AutomationContext.tenantId). On a ` +
`single-organization install this is expected and can be ignored.`,
);
}
try {
const deliveryId = await messaging.enqueueHttp({
source: 'flow',
Expand All@@ -133,6 +170,10 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext):
signingSecret,
timeoutMs,
payload: body ?? {},
// [#13546] Absent (not null) when the run has no
// organization; the outbox normalizes a missing
// value to NULL exactly once, at the insert.
...(organizationId ? { organizationId } : {}),
});
// #4354 — the outbox row IS a durable effect this run
// caused, but it is NOT a countable one (#7882). What
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(service-messaging): stamp organization_id on sys_http_delivery rows so the redeliver() cross-organization wall excludes other tenants' rows by os-steve · Pull Request #13565 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/http-outbox-organization-stamp.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
---
"@objectstack/service-messaging": minor
"@objectstack/service-automation": patch
"@objectstack/plugin-webhooks": patch
---

fix(service-messaging,service-automation,plugin-webhooks): stamp `organization_id` on `sys_http_delivery` rows so the cross-organization wall on `redeliver()` actually excludes other tenants' rows (#13546)

`sys_http_delivery` is tenant-scoped and `redeliver()` — the one
request-reachable door on it — deliberately scopes by the caller's
organization (#10740). But the enqueue door never stamped the
`organization_id` column, and the SQL driver's tenant term is
`(organization_id = :tenantId OR organization_id IS NULL)` — a deliberate
global-row fail-open — so 100% of delivery rows landed in the NULL arm:
visible to, and replayable by, every organization on a walled deployment.

The repair mirrors the notification outbox's existing convention
(`EnqueueDeliveryInput.organizationId`), end to end:

- `EnqueueHttpInput` gains an **optional** `organizationId` member (inherited
by `UndeliverableHttpInput`, so parked rows are tenant-stamped too), and
`HttpDelivery` surfaces it on read-back. `SqlHttpOutbox.insert` writes
`organization_id: input.organizationId ?? null` exactly like
`SqlOutbox.enqueue`; `MemoryHttpOutbox` stores the same field and — now
that its rows carry a tenant — applies `RedeliverOptions.tenantId` in
`redeliver()` with the driver's exact semantics (another organization's row
is invisible/`RESOURCE_NOT_FOUND`; an org-less row stays a global row; a
tenant-less caller stays unscoped).
- The flow `http` node (durable mode) threads its run's acting organization
(`AutomationContext.tenantId` — the same source as the `notify` node's
#11303 repair) and warns loudly when a multi-org run has none to thread.
- The webhook auto-enqueuer stamps each delivery with its subscription's own
organization (`sys_webhook.organization_id`); org-less subscriptions
enqueue org-less, unchanged.

Forward-stamping only: existing NULL rows are untouched (their disposition is
a separate decision). Producers with genuinely no organization — a
`single`-posture deployment, a stack before its first organization — keep
working unchanged; their rows land NULL, which is the honest global-row shape.
61 changes: 61 additions & 0 deletions packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -222,6 +222,48 @@ describe('AutoEnqueuer', () => {
await ae.stop();
});

// [#13546] The delivery row belongs to the SUBSCRIPTION's organization —
// `sys_webhook` is organization-scoped (#8554), the enqueuer runs
// fire-and-forget off the write path with no request context, so the
// subscription row is the one honest tenant source. Without the stamp the
// row lands `organization_id = NULL` — the driver's global-row arm — and
// the redeliver() cross-organization wall (#10740) excludes nothing.
it("stamps the subscription's organization onto the enqueue input (#13546)", async () => {
const engine = new FakeEngine({
sys_webhook: [webhook({ organization_id: 'org_pin_alpha' })],
});
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(event('created', 'contact', { id: 'c-1' }));
await flush();

expect(calls).toHaveLength(1);
// Verbatim from the sys_webhook row — threaded, never fabricated.
expect(calls[0].organizationId).toBe('org_pin_alpha');
await ae.stop();
});

it('an org-less subscription enqueues with NO organization (the honest global-row shape, #13546)', async () => {
// The over-denial control: a `single`-posture install has org-less
// sys_webhook rows, and their events must still deliver — org-less,
// never refused, never stamped with a guess.
const engine = new FakeEngine({ sys_webhook: [webhook()] });
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(event('created', 'contact', { id: 'c-1' }));
await flush();

expect(calls).toHaveLength(1);
expect(calls[0].organizationId).toBeUndefined();
await ae.stop();
});

it('[#4626] drops an off-contract data event instead of enqueuing it as "unknown"', async () => {
// Pre-#4626 the enqueuer read `recordId ?? id ?? after?.id ?? 'unknown'`,
// so a payload that named no record still produced a delivery whose
Expand DownExpand Up@@ -583,6 +625,25 @@ describe('AutoEnqueuer — bulk data events (#4639)', () => {
await ae.stop();
});

it("the bulk path stamps the subscription's organization too (#13546)", async () => {
// Same tenant seam as the per-record path — a bulk delivery for an
// organization-owned subscription must not land as a global row either.
const engine = new FakeEngine({
sys_webhook: [webhook({ triggers: 'bulk_update', organization_id: 'org_pin_alpha' })],
});
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(bulkEvent('updated', 'contact', 3));
await flush();

expect(calls).toHaveLength(1);
expect(calls[0].organizationId).toBe('org_pin_alpha');
await ae.stop();
});

it('does NOT deliver a bulk event to a per-record update subscriber', async () => {
// The opt-in half of the decision: an existing `update` webhook keeps
// receiving only bodies shaped the way it already reads them.
Expand Down
28 changes: 28 additions & 0 deletions packages/plugins/plugin-webhooks/src/auto-enqueuer.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -115,6 +115,21 @@ interface CachedSubscription {
headers?: Record<string, string>;
secret?: string;
timeoutMs?: number;
/**
* [#13546] The subscription's own organization — `sys_webhook` is
* organization-scoped (#8554), so each row carries the tenant that authored
* it. Stamped onto every delivery row this subscription produces
* (`EnqueueHttpInput.organizationId`), which is what makes the
* cross-organization wall on `redeliver()` (#10740) actually exclude other
* tenants' rows: a row enqueued without it lands `organization_id = NULL`,
* the driver's global-row arm, visible to every organization. There is no
* request context to read here — the enqueuer runs fire-and-forget off the
* write path — so the subscription row is the one honest source. Absent
* when the row itself carries no organization (a `single`-posture install):
* the delivery then lands NULL, which is honest for a subscription that
* belongs to no organization. Threaded, never fabricated (#11303's rule).
*/
organizationId?: string;
/**
* [#8069] Set when a credential this subscription needs could not be
* recovered. The subscription stays CACHED — that is the change — but every
Expand DownExpand Up@@ -783,6 +798,10 @@ export class AutoEnqueuer {
// from their encrypted columns, NOT read off the row — see #7799
// (secret) and #7986 (headers).
timeoutMs: defn.timeoutMs,
// [#13546] The tenant column the kernel provisions on sys_webhook.
// This cache read is a dispatcher-side unscoped find, so the column
// comes back for every organization's rows.
organizationId: row.organization_id ? String(row.organization_id) : undefined,
};
}

Expand DownExpand Up@@ -867,6 +886,12 @@ export class AutoEnqueuer {
// subscription, so the delivery path is byte-identical to before.
undeliverableReason: sub.parkedReason,
timeoutMs: sub.timeoutMs,
// [#13546] The delivery row belongs to the SUBSCRIPTION's
// organization — the one honest tenant in scope on this
// fire-and-forget path (no request context exists here).
// Absent for an org-less subscription; the row then lands
// NULL, the global-row shape.
organizationId: sub.organizationId,
// [#3946] Envelope keys are written LAST so the event payload
// cannot rewrite them. Behaviour-neutral for the engine's own
// publishers — since #4626 a `data.record.*` payload is a
Expand DownExpand Up@@ -960,6 +985,9 @@ export class AutoEnqueuer {
// an undeliverable row instead of enqueuing a delivery.
undeliverableReason: sub.parkedReason,
timeoutMs: sub.timeoutMs,
// [#13546] See the per-record path — the subscription's own
// organization, absent for an org-less subscription.
organizationId: sub.organizationId,
// [#3946] Envelope keys last so the payload cannot rewrite them.
payload: {
...payload,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,6 +110,78 @@ describe('http (canonical node)', () => {
expect(result.success).toBe(true);
expect(fetchMock).toHaveBeenCalledOnce();
});

// [#13546] The producer pin: `sys_http_delivery` rows must carry the
// organization of the run that caused them, or they land in the
// driver's `organization_id IS NULL` global-row arm — visible to and
// replayable by every organization through redeliver() (#10740). The
// organization is THREADED from the run's own acting context
// (`AutomationContext.tenantId`) — the same source, and the same
// no-fallback rule, as the notify node's #11303 repair.
it("threads the run's acting organization onto the enqueue input (#13546)", async () => {
const enqueued: any[] = [];
const messaging: HttpSurface = {
isHttpDeliveryReady: () => true,
async enqueueHttp(input) {
enqueued.push(input);
return 'dlv_1';
},
};
const engine = new AutomationEngine(createTestLogger());
registerHttpNodes(engine, createCtx(messaging));
engine.registerFlow(
'http_flow',
httpFlow('http', { url: 'https://example.test/hook', durable: true }),
);

const result = await engine.execute('http_flow', { tenantId: 'org_pin_alpha' } as any);

expect(result.success).toBe(true);
expect(enqueued).toHaveLength(1);
// Verbatim — the acting tenant, not a derived or defaulted value.
expect(enqueued[0].organizationId).toBe('org_pin_alpha');
});

it('with NO organization in scope: still enqueues, passes NO organizationId key, and says so out loud (#13546)', async () => {
// The over-denial control (the notify suite's PIN C shape): a
// `single`-posture install and a stack before its first
// organization legitimately have no tenant to thread, and a
// durable callout there must still enqueue — org-less, loudly,
// never refused and never guessed.
const warnings: string[] = [];
const logger: any = {
info: () => {}, error: () => {}, debug: () => {},
warn: (...args: unknown[]) => { warnings.push(args.map(String).join(' ')); },
};
logger.child = () => logger;
const enqueued: any[] = [];
const messaging: HttpSurface = {
isHttpDeliveryReady: () => true,
async enqueueHttp(input) {
enqueued.push(input);
return 'dlv_1';
},
};
const engine = new AutomationEngine(logger);
registerHttpNodes(engine, {
logger,
getService: (name: string) => (name === 'messaging' ? messaging : undefined),
} as any);
engine.registerFlow(
'http_flow',
httpFlow('http', { url: 'https://example.test/hook', durable: true }),
);

const result = await engine.execute('http_flow');

expect(result.success).toBe(true);
expect(enqueued).toHaveLength(1);
// Absent, not null and not '' — the outbox normalizes a missing
// value to NULL exactly once, at its insert.
expect('organizationId' in enqueued[0]).toBe(false);
// Fail-LOUD: the org-less durable callout is a visible event.
expect(warnings.some((w) => w.includes('organization_id = NULL'))).toBe(true);
});
});

describe('request/response mode (default)', () => {
Expand Down
41 changes: 41 additions & 0 deletions packages/services/service-automation/src/builtin/http-nodes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,11 @@ interface MessagingHttpSurface {
signingSecret?: string;
timeoutMs?: number;
payload: unknown;
/**
* [#13546] Organization the delivery row belongs to — the tenant
* column the `redeliver()` cross-organization wall scopes by (#10740).
*/
organizationId?: string;
}): Promise<string>;
}

Expand DownExpand Up@@ -121,6 +126,38 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext):
if (durable) {
const messaging = getMessaging();
if (messaging?.isHttpDeliveryReady?.() && messaging.enqueueHttp) {
// [#13546] The organization this delivery belongs to,
// THREADED from the run's own acting context — never
// fabricated. Same source and same no-fallback rule as the
// `notify` node's #11303 repair one file over:
// `AutomationContext.tenantId` is the acting run's
// organization, and a wrong value is worse than a null (a
// null is visibly missing; a wrong one is silently
// authoritative). Without it the sys_http_delivery row
// lands `organization_id = NULL` — the driver's global-row
// arm — visible to and replayable by EVERY organization
// through the redeliver() door (#10740).
const organizationId =
typeof context.tenantId === 'string' && context.tenantId !== ''
? context.tenantId
: undefined;
if (!organizationId) {
// Fail-LOUD, not fail-guess, not fail-closed (#11303's
// triage): a `single`-posture install and a stack before
// its first organization legitimately have none, and a
// durable callout there must still enqueue.
// (Issue anchor lives in these comments, not in the
// runtime string — operators cannot resolve a tracker
// id; see check:doc-authoring.)
ctx.logger.warn(
`[http] node '${node.id}': no organization in scope for this durable callout — its ` +
`sys_http_delivery row will carry organization_id = NULL, which is a global row ` +
`every organization's redeliver door can reach on a walled deployment. ` +
`On a multi-organization install the triggering context lost its tenant: give the ` +
`flow's trigger an acting organization (AutomationContext.tenantId). On a ` +
`single-organization install this is expected and can be ignored.`,
);
}
try {
const deliveryId = await messaging.enqueueHttp({
source: 'flow',
Expand All@@ -133,6 +170,10 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext):
signingSecret,
timeoutMs,
payload: body ?? {},
// [#13546] Absent (not null) when the run has no
// organization; the outbox normalizes a missing
// value to NULL exactly once, at the insert.
...(organizationId ? { organizationId } : {}),
});
// #4354 — the outbox row IS a durable effect this run
// caused, but it is NOT a countable one (#7882). What
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(service-messaging): stamp organization_id on sys_http_delivery rows so the redeliver() cross-organization wall excludes other tenants' rows by os-steve · Pull Request #13565 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/http-outbox-organization-stamp.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
---
"@objectstack/service-messaging": minor
"@objectstack/service-automation": patch
"@objectstack/plugin-webhooks": patch
---

fix(service-messaging,service-automation,plugin-webhooks): stamp `organization_id` on `sys_http_delivery` rows so the cross-organization wall on `redeliver()` actually excludes other tenants' rows (#13546)

`sys_http_delivery` is tenant-scoped and `redeliver()` — the one
request-reachable door on it — deliberately scopes by the caller's
organization (#10740). But the enqueue door never stamped the
`organization_id` column, and the SQL driver's tenant term is
`(organization_id = :tenantId OR organization_id IS NULL)` — a deliberate
global-row fail-open — so 100% of delivery rows landed in the NULL arm:
visible to, and replayable by, every organization on a walled deployment.

The repair mirrors the notification outbox's existing convention
(`EnqueueDeliveryInput.organizationId`), end to end:

- `EnqueueHttpInput` gains an **optional** `organizationId` member (inherited
by `UndeliverableHttpInput`, so parked rows are tenant-stamped too), and
`HttpDelivery` surfaces it on read-back. `SqlHttpOutbox.insert` writes
`organization_id: input.organizationId ?? null` exactly like
`SqlOutbox.enqueue`; `MemoryHttpOutbox` stores the same field and — now
that its rows carry a tenant — applies `RedeliverOptions.tenantId` in
`redeliver()` with the driver's exact semantics (another organization's row
is invisible/`RESOURCE_NOT_FOUND`; an org-less row stays a global row; a
tenant-less caller stays unscoped).
- The flow `http` node (durable mode) threads its run's acting organization
(`AutomationContext.tenantId` — the same source as the `notify` node's
#11303 repair) and warns loudly when a multi-org run has none to thread.
- The webhook auto-enqueuer stamps each delivery with its subscription's own
organization (`sys_webhook.organization_id`); org-less subscriptions
enqueue org-less, unchanged.

Forward-stamping only: existing NULL rows are untouched (their disposition is
a separate decision). Producers with genuinely no organization — a
`single`-posture deployment, a stack before its first organization — keep
working unchanged; their rows land NULL, which is the honest global-row shape.
61 changes: 61 additions & 0 deletions packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -222,6 +222,48 @@ describe('AutoEnqueuer', () => {
await ae.stop();
});

// [#13546] The delivery row belongs to the SUBSCRIPTION's organization —
// `sys_webhook` is organization-scoped (#8554), the enqueuer runs
// fire-and-forget off the write path with no request context, so the
// subscription row is the one honest tenant source. Without the stamp the
// row lands `organization_id = NULL` — the driver's global-row arm — and
// the redeliver() cross-organization wall (#10740) excludes nothing.
it("stamps the subscription's organization onto the enqueue input (#13546)", async () => {
const engine = new FakeEngine({
sys_webhook: [webhook({ organization_id: 'org_pin_alpha' })],
});
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(event('created', 'contact', { id: 'c-1' }));
await flush();

expect(calls).toHaveLength(1);
// Verbatim from the sys_webhook row — threaded, never fabricated.
expect(calls[0].organizationId).toBe('org_pin_alpha');
await ae.stop();
});

it('an org-less subscription enqueues with NO organization (the honest global-row shape, #13546)', async () => {
// The over-denial control: a `single`-posture install has org-less
// sys_webhook rows, and their events must still deliver — org-less,
// never refused, never stamped with a guess.
const engine = new FakeEngine({ sys_webhook: [webhook()] });
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(event('created', 'contact', { id: 'c-1' }));
await flush();

expect(calls).toHaveLength(1);
expect(calls[0].organizationId).toBeUndefined();
await ae.stop();
});

it('[#4626] drops an off-contract data event instead of enqueuing it as "unknown"', async () => {
// Pre-#4626 the enqueuer read `recordId ?? id ?? after?.id ?? 'unknown'`,
// so a payload that named no record still produced a delivery whose
Expand DownExpand Up@@ -583,6 +625,25 @@ describe('AutoEnqueuer — bulk data events (#4639)', () => {
await ae.stop();
});

it("the bulk path stamps the subscription's organization too (#13546)", async () => {
// Same tenant seam as the per-record path — a bulk delivery for an
// organization-owned subscription must not land as a global row either.
const engine = new FakeEngine({
sys_webhook: [webhook({ triggers: 'bulk_update', organization_id: 'org_pin_alpha' })],
});
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(bulkEvent('updated', 'contact', 3));
await flush();

expect(calls).toHaveLength(1);
expect(calls[0].organizationId).toBe('org_pin_alpha');
await ae.stop();
});

it('does NOT deliver a bulk event to a per-record update subscriber', async () => {
// The opt-in half of the decision: an existing `update` webhook keeps
// receiving only bodies shaped the way it already reads them.
Expand Down
28 changes: 28 additions & 0 deletions packages/plugins/plugin-webhooks/src/auto-enqueuer.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -115,6 +115,21 @@ interface CachedSubscription {
headers?: Record<string, string>;
secret?: string;
timeoutMs?: number;
/**
* [#13546] The subscription's own organization — `sys_webhook` is
* organization-scoped (#8554), so each row carries the tenant that authored
* it. Stamped onto every delivery row this subscription produces
* (`EnqueueHttpInput.organizationId`), which is what makes the
* cross-organization wall on `redeliver()` (#10740) actually exclude other
* tenants' rows: a row enqueued without it lands `organization_id = NULL`,
* the driver's global-row arm, visible to every organization. There is no
* request context to read here — the enqueuer runs fire-and-forget off the
* write path — so the subscription row is the one honest source. Absent
* when the row itself carries no organization (a `single`-posture install):
* the delivery then lands NULL, which is honest for a subscription that
* belongs to no organization. Threaded, never fabricated (#11303's rule).
*/
organizationId?: string;
/**
* [#8069] Set when a credential this subscription needs could not be
* recovered. The subscription stays CACHED — that is the change — but every
Expand DownExpand Up@@ -783,6 +798,10 @@ export class AutoEnqueuer {
// from their encrypted columns, NOT read off the row — see #7799
// (secret) and #7986 (headers).
timeoutMs: defn.timeoutMs,
// [#13546] The tenant column the kernel provisions on sys_webhook.
// This cache read is a dispatcher-side unscoped find, so the column
// comes back for every organization's rows.
organizationId: row.organization_id ? String(row.organization_id) : undefined,
};
}

Expand DownExpand Up@@ -867,6 +886,12 @@ export class AutoEnqueuer {
// subscription, so the delivery path is byte-identical to before.
undeliverableReason: sub.parkedReason,
timeoutMs: sub.timeoutMs,
// [#13546] The delivery row belongs to the SUBSCRIPTION's
// organization — the one honest tenant in scope on this
// fire-and-forget path (no request context exists here).
// Absent for an org-less subscription; the row then lands
// NULL, the global-row shape.
organizationId: sub.organizationId,
// [#3946] Envelope keys are written LAST so the event payload
// cannot rewrite them. Behaviour-neutral for the engine's own
// publishers — since #4626 a `data.record.*` payload is a
Expand DownExpand Up@@ -960,6 +985,9 @@ export class AutoEnqueuer {
// an undeliverable row instead of enqueuing a delivery.
undeliverableReason: sub.parkedReason,
timeoutMs: sub.timeoutMs,
// [#13546] See the per-record path — the subscription's own
// organization, absent for an org-less subscription.
organizationId: sub.organizationId,
// [#3946] Envelope keys last so the payload cannot rewrite them.
payload: {
...payload,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,6 +110,78 @@ describe('http (canonical node)', () => {
expect(result.success).toBe(true);
expect(fetchMock).toHaveBeenCalledOnce();
});

// [#13546] The producer pin: `sys_http_delivery` rows must carry the
// organization of the run that caused them, or they land in the
// driver's `organization_id IS NULL` global-row arm — visible to and
// replayable by every organization through redeliver() (#10740). The
// organization is THREADED from the run's own acting context
// (`AutomationContext.tenantId`) — the same source, and the same
// no-fallback rule, as the notify node's #11303 repair.
it("threads the run's acting organization onto the enqueue input (#13546)", async () => {
const enqueued: any[] = [];
const messaging: HttpSurface = {
isHttpDeliveryReady: () => true,
async enqueueHttp(input) {
enqueued.push(input);
return 'dlv_1';
},
};
const engine = new AutomationEngine(createTestLogger());
registerHttpNodes(engine, createCtx(messaging));
engine.registerFlow(
'http_flow',
httpFlow('http', { url: 'https://example.test/hook', durable: true }),
);

const result = await engine.execute('http_flow', { tenantId: 'org_pin_alpha' } as any);

expect(result.success).toBe(true);
expect(enqueued).toHaveLength(1);
// Verbatim — the acting tenant, not a derived or defaulted value.
expect(enqueued[0].organizationId).toBe('org_pin_alpha');
});

it('with NO organization in scope: still enqueues, passes NO organizationId key, and says so out loud (#13546)', async () => {
// The over-denial control (the notify suite's PIN C shape): a
// `single`-posture install and a stack before its first
// organization legitimately have no tenant to thread, and a
// durable callout there must still enqueue — org-less, loudly,
// never refused and never guessed.
const warnings: string[] = [];
const logger: any = {
info: () => {}, error: () => {}, debug: () => {},
warn: (...args: unknown[]) => { warnings.push(args.map(String).join(' ')); },
};
logger.child = () => logger;
const enqueued: any[] = [];
const messaging: HttpSurface = {
isHttpDeliveryReady: () => true,
async enqueueHttp(input) {
enqueued.push(input);
return 'dlv_1';
},
};
const engine = new AutomationEngine(logger);
registerHttpNodes(engine, {
logger,
getService: (name: string) => (name === 'messaging' ? messaging : undefined),
} as any);
engine.registerFlow(
'http_flow',
httpFlow('http', { url: 'https://example.test/hook', durable: true }),
);

const result = await engine.execute('http_flow');

expect(result.success).toBe(true);
expect(enqueued).toHaveLength(1);
// Absent, not null and not '' — the outbox normalizes a missing
// value to NULL exactly once, at its insert.
expect('organizationId' in enqueued[0]).toBe(false);
// Fail-LOUD: the org-less durable callout is a visible event.
expect(warnings.some((w) => w.includes('organization_id = NULL'))).toBe(true);
});
});

describe('request/response mode (default)', () => {
Expand Down
41 changes: 41 additions & 0 deletions packages/services/service-automation/src/builtin/http-nodes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,11 @@ interface MessagingHttpSurface {
signingSecret?: string;
timeoutMs?: number;
payload: unknown;
/**
* [#13546] Organization the delivery row belongs to — the tenant
* column the `redeliver()` cross-organization wall scopes by (#10740).
*/
organizationId?: string;
}): Promise<string>;
}

Expand DownExpand Up@@ -121,6 +126,38 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext):
if (durable) {
const messaging = getMessaging();
if (messaging?.isHttpDeliveryReady?.() && messaging.enqueueHttp) {
// [#13546] The organization this delivery belongs to,
// THREADED from the run's own acting context — never
// fabricated. Same source and same no-fallback rule as the
// `notify` node's #11303 repair one file over:
// `AutomationContext.tenantId` is the acting run's
// organization, and a wrong value is worse than a null (a
// null is visibly missing; a wrong one is silently
// authoritative). Without it the sys_http_delivery row
// lands `organization_id = NULL` — the driver's global-row
// arm — visible to and replayable by EVERY organization
// through the redeliver() door (#10740).
const organizationId =
typeof context.tenantId === 'string' && context.tenantId !== ''
? context.tenantId
: undefined;
if (!organizationId) {
// Fail-LOUD, not fail-guess, not fail-closed (#11303's
// triage): a `single`-posture install and a stack before
// its first organization legitimately have none, and a
// durable callout there must still enqueue.
// (Issue anchor lives in these comments, not in the
// runtime string — operators cannot resolve a tracker
// id; see check:doc-authoring.)
ctx.logger.warn(
`[http] node '${node.id}': no organization in scope for this durable callout — its ` +
`sys_http_delivery row will carry organization_id = NULL, which is a global row ` +
`every organization's redeliver door can reach on a walled deployment. ` +
`On a multi-organization install the triggering context lost its tenant: give the ` +
`flow's trigger an acting organization (AutomationContext.tenantId). On a ` +
`single-organization install this is expected and can be ignored.`,
);
}
try {
const deliveryId = await messaging.enqueueHttp({
source: 'flow',
Expand All@@ -133,6 +170,10 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext):
signingSecret,
timeoutMs,
payload: body ?? {},
// [#13546] Absent (not null) when the run has no
// organization; the outbox normalizes a missing
// value to NULL exactly once, at the insert.
...(organizationId ? { organizationId } : {}),
});
// #4354 — the outbox row IS a durable effect this run
// caused, but it is NOT a countable one (#7882). What
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(service-messaging): stamp organization_id on sys_http_delivery rows so the redeliver() cross-organization wall excludes other tenants' rows by os-steve · Pull Request #13565 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/http-outbox-organization-stamp.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
---
"@objectstack/service-messaging": minor
"@objectstack/service-automation": patch
"@objectstack/plugin-webhooks": patch
---

fix(service-messaging,service-automation,plugin-webhooks): stamp `organization_id` on `sys_http_delivery` rows so the cross-organization wall on `redeliver()` actually excludes other tenants' rows (#13546)

`sys_http_delivery` is tenant-scoped and `redeliver()` — the one
request-reachable door on it — deliberately scopes by the caller's
organization (#10740). But the enqueue door never stamped the
`organization_id` column, and the SQL driver's tenant term is
`(organization_id = :tenantId OR organization_id IS NULL)` — a deliberate
global-row fail-open — so 100% of delivery rows landed in the NULL arm:
visible to, and replayable by, every organization on a walled deployment.

The repair mirrors the notification outbox's existing convention
(`EnqueueDeliveryInput.organizationId`), end to end:

- `EnqueueHttpInput` gains an **optional** `organizationId` member (inherited
by `UndeliverableHttpInput`, so parked rows are tenant-stamped too), and
`HttpDelivery` surfaces it on read-back. `SqlHttpOutbox.insert` writes
`organization_id: input.organizationId ?? null` exactly like
`SqlOutbox.enqueue`; `MemoryHttpOutbox` stores the same field and — now
that its rows carry a tenant — applies `RedeliverOptions.tenantId` in
`redeliver()` with the driver's exact semantics (another organization's row
is invisible/`RESOURCE_NOT_FOUND`; an org-less row stays a global row; a
tenant-less caller stays unscoped).
- The flow `http` node (durable mode) threads its run's acting organization
(`AutomationContext.tenantId` — the same source as the `notify` node's
#11303 repair) and warns loudly when a multi-org run has none to thread.
- The webhook auto-enqueuer stamps each delivery with its subscription's own
organization (`sys_webhook.organization_id`); org-less subscriptions
enqueue org-less, unchanged.

Forward-stamping only: existing NULL rows are untouched (their disposition is
a separate decision). Producers with genuinely no organization — a
`single`-posture deployment, a stack before its first organization — keep
working unchanged; their rows land NULL, which is the honest global-row shape.
61 changes: 61 additions & 0 deletions packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -222,6 +222,48 @@ describe('AutoEnqueuer', () => {
await ae.stop();
});

// [#13546] The delivery row belongs to the SUBSCRIPTION's organization —
// `sys_webhook` is organization-scoped (#8554), the enqueuer runs
// fire-and-forget off the write path with no request context, so the
// subscription row is the one honest tenant source. Without the stamp the
// row lands `organization_id = NULL` — the driver's global-row arm — and
// the redeliver() cross-organization wall (#10740) excludes nothing.
it("stamps the subscription's organization onto the enqueue input (#13546)", async () => {
const engine = new FakeEngine({
sys_webhook: [webhook({ organization_id: 'org_pin_alpha' })],
});
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(event('created', 'contact', { id: 'c-1' }));
await flush();

expect(calls).toHaveLength(1);
// Verbatim from the sys_webhook row — threaded, never fabricated.
expect(calls[0].organizationId).toBe('org_pin_alpha');
await ae.stop();
});

it('an org-less subscription enqueues with NO organization (the honest global-row shape, #13546)', async () => {
// The over-denial control: a `single`-posture install has org-less
// sys_webhook rows, and their events must still deliver — org-less,
// never refused, never stamped with a guess.
const engine = new FakeEngine({ sys_webhook: [webhook()] });
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(event('created', 'contact', { id: 'c-1' }));
await flush();

expect(calls).toHaveLength(1);
expect(calls[0].organizationId).toBeUndefined();
await ae.stop();
});

it('[#4626] drops an off-contract data event instead of enqueuing it as "unknown"', async () => {
// Pre-#4626 the enqueuer read `recordId ?? id ?? after?.id ?? 'unknown'`,
// so a payload that named no record still produced a delivery whose
Expand DownExpand Up@@ -583,6 +625,25 @@ describe('AutoEnqueuer — bulk data events (#4639)', () => {
await ae.stop();
});

it("the bulk path stamps the subscription's organization too (#13546)", async () => {
// Same tenant seam as the per-record path — a bulk delivery for an
// organization-owned subscription must not land as a global row either.
const engine = new FakeEngine({
sys_webhook: [webhook({ triggers: 'bulk_update', organization_id: 'org_pin_alpha' })],
});
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(bulkEvent('updated', 'contact', 3));
await flush();

expect(calls).toHaveLength(1);
expect(calls[0].organizationId).toBe('org_pin_alpha');
await ae.stop();
});

it('does NOT deliver a bulk event to a per-record update subscriber', async () => {
// The opt-in half of the decision: an existing `update` webhook keeps
// receiving only bodies shaped the way it already reads them.
Expand Down
28 changes: 28 additions & 0 deletions packages/plugins/plugin-webhooks/src/auto-enqueuer.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -115,6 +115,21 @@ interface CachedSubscription {
headers?: Record<string, string>;
secret?: string;
timeoutMs?: number;
/**
* [#13546] The subscription's own organization — `sys_webhook` is
* organization-scoped (#8554), so each row carries the tenant that authored
* it. Stamped onto every delivery row this subscription produces
* (`EnqueueHttpInput.organizationId`), which is what makes the
* cross-organization wall on `redeliver()` (#10740) actually exclude other
* tenants' rows: a row enqueued without it lands `organization_id = NULL`,
* the driver's global-row arm, visible to every organization. There is no
* request context to read here — the enqueuer runs fire-and-forget off the
* write path — so the subscription row is the one honest source. Absent
* when the row itself carries no organization (a `single`-posture install):
* the delivery then lands NULL, which is honest for a subscription that
* belongs to no organization. Threaded, never fabricated (#11303's rule).
*/
organizationId?: string;
/**
* [#8069] Set when a credential this subscription needs could not be
* recovered. The subscription stays CACHED — that is the change — but every
Expand DownExpand Up@@ -783,6 +798,10 @@ export class AutoEnqueuer {
// from their encrypted columns, NOT read off the row — see #7799
// (secret) and #7986 (headers).
timeoutMs: defn.timeoutMs,
// [#13546] The tenant column the kernel provisions on sys_webhook.
// This cache read is a dispatcher-side unscoped find, so the column
// comes back for every organization's rows.
organizationId: row.organization_id ? String(row.organization_id) : undefined,
};
}

Expand DownExpand Up@@ -867,6 +886,12 @@ export class AutoEnqueuer {
// subscription, so the delivery path is byte-identical to before.
undeliverableReason: sub.parkedReason,
timeoutMs: sub.timeoutMs,
// [#13546] The delivery row belongs to the SUBSCRIPTION's
// organization — the one honest tenant in scope on this
// fire-and-forget path (no request context exists here).
// Absent for an org-less subscription; the row then lands
// NULL, the global-row shape.
organizationId: sub.organizationId,
// [#3946] Envelope keys are written LAST so the event payload
// cannot rewrite them. Behaviour-neutral for the engine's own
// publishers — since #4626 a `data.record.*` payload is a
Expand DownExpand Up@@ -960,6 +985,9 @@ export class AutoEnqueuer {
// an undeliverable row instead of enqueuing a delivery.
undeliverableReason: sub.parkedReason,
timeoutMs: sub.timeoutMs,
// [#13546] See the per-record path — the subscription's own
// organization, absent for an org-less subscription.
organizationId: sub.organizationId,
// [#3946] Envelope keys last so the payload cannot rewrite them.
payload: {
...payload,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,6 +110,78 @@ describe('http (canonical node)', () => {
expect(result.success).toBe(true);
expect(fetchMock).toHaveBeenCalledOnce();
});

// [#13546] The producer pin: `sys_http_delivery` rows must carry the
// organization of the run that caused them, or they land in the
// driver's `organization_id IS NULL` global-row arm — visible to and
// replayable by every organization through redeliver() (#10740). The
// organization is THREADED from the run's own acting context
// (`AutomationContext.tenantId`) — the same source, and the same
// no-fallback rule, as the notify node's #11303 repair.
it("threads the run's acting organization onto the enqueue input (#13546)", async () => {
const enqueued: any[] = [];
const messaging: HttpSurface = {
isHttpDeliveryReady: () => true,
async enqueueHttp(input) {
enqueued.push(input);
return 'dlv_1';
},
};
const engine = new AutomationEngine(createTestLogger());
registerHttpNodes(engine, createCtx(messaging));
engine.registerFlow(
'http_flow',
httpFlow('http', { url: 'https://example.test/hook', durable: true }),
);

const result = await engine.execute('http_flow', { tenantId: 'org_pin_alpha' } as any);

expect(result.success).toBe(true);
expect(enqueued).toHaveLength(1);
// Verbatim — the acting tenant, not a derived or defaulted value.
expect(enqueued[0].organizationId).toBe('org_pin_alpha');
});

it('with NO organization in scope: still enqueues, passes NO organizationId key, and says so out loud (#13546)', async () => {
// The over-denial control (the notify suite's PIN C shape): a
// `single`-posture install and a stack before its first
// organization legitimately have no tenant to thread, and a
// durable callout there must still enqueue — org-less, loudly,
// never refused and never guessed.
const warnings: string[] = [];
const logger: any = {
info: () => {}, error: () => {}, debug: () => {},
warn: (...args: unknown[]) => { warnings.push(args.map(String).join(' ')); },
};
logger.child = () => logger;
const enqueued: any[] = [];
const messaging: HttpSurface = {
isHttpDeliveryReady: () => true,
async enqueueHttp(input) {
enqueued.push(input);
return 'dlv_1';
},
};
const engine = new AutomationEngine(logger);
registerHttpNodes(engine, {
logger,
getService: (name: string) => (name === 'messaging' ? messaging : undefined),
} as any);
engine.registerFlow(
'http_flow',
httpFlow('http', { url: 'https://example.test/hook', durable: true }),
);

const result = await engine.execute('http_flow');

expect(result.success).toBe(true);
expect(enqueued).toHaveLength(1);
// Absent, not null and not '' — the outbox normalizes a missing
// value to NULL exactly once, at its insert.
expect('organizationId' in enqueued[0]).toBe(false);
// Fail-LOUD: the org-less durable callout is a visible event.
expect(warnings.some((w) => w.includes('organization_id = NULL'))).toBe(true);
});
});

describe('request/response mode (default)', () => {
Expand Down
41 changes: 41 additions & 0 deletions packages/services/service-automation/src/builtin/http-nodes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,11 @@ interface MessagingHttpSurface {
signingSecret?: string;
timeoutMs?: number;
payload: unknown;
/**
* [#13546] Organization the delivery row belongs to — the tenant
* column the `redeliver()` cross-organization wall scopes by (#10740).
*/
organizationId?: string;
}): Promise<string>;
}

Expand DownExpand Up@@ -121,6 +126,38 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext):
if (durable) {
const messaging = getMessaging();
if (messaging?.isHttpDeliveryReady?.() && messaging.enqueueHttp) {
// [#13546] The organization this delivery belongs to,
// THREADED from the run's own acting context — never
// fabricated. Same source and same no-fallback rule as the
// `notify` node's #11303 repair one file over:
// `AutomationContext.tenantId` is the acting run's
// organization, and a wrong value is worse than a null (a
// null is visibly missing; a wrong one is silently
// authoritative). Without it the sys_http_delivery row
// lands `organization_id = NULL` — the driver's global-row
// arm — visible to and replayable by EVERY organization
// through the redeliver() door (#10740).
const organizationId =
typeof context.tenantId === 'string' && context.tenantId !== ''
? context.tenantId
: undefined;
if (!organizationId) {
// Fail-LOUD, not fail-guess, not fail-closed (#11303's
// triage): a `single`-posture install and a stack before
// its first organization legitimately have none, and a
// durable callout there must still enqueue.
// (Issue anchor lives in these comments, not in the
// runtime string — operators cannot resolve a tracker
// id; see check:doc-authoring.)
ctx.logger.warn(
`[http] node '${node.id}': no organization in scope for this durable callout — its ` +
`sys_http_delivery row will carry organization_id = NULL, which is a global row ` +
`every organization's redeliver door can reach on a walled deployment. ` +
`On a multi-organization install the triggering context lost its tenant: give the ` +
`flow's trigger an acting organization (AutomationContext.tenantId). On a ` +
`single-organization install this is expected and can be ignored.`,
);
}
try {
const deliveryId = await messaging.enqueueHttp({
source: 'flow',
Expand All@@ -133,6 +170,10 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext):
signingSecret,
timeoutMs,
payload: body ?? {},
// [#13546] Absent (not null) when the run has no
// organization; the outbox normalizes a missing
// value to NULL exactly once, at the insert.
...(organizationId ? { organizationId } : {}),
});
// #4354 — the outbox row IS a durable effect this run
// caused, but it is NOT a countable one (#7882). What
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(service-messaging): stamp organization_id on sys_http_delivery rows so the redeliver() cross-organization wall excludes other tenants' rows by os-steve · Pull Request #13565 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/http-outbox-organization-stamp.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
---
"@objectstack/service-messaging": minor
"@objectstack/service-automation": patch
"@objectstack/plugin-webhooks": patch
---

fix(service-messaging,service-automation,plugin-webhooks): stamp `organization_id` on `sys_http_delivery` rows so the cross-organization wall on `redeliver()` actually excludes other tenants' rows (#13546)

`sys_http_delivery` is tenant-scoped and `redeliver()` — the one
request-reachable door on it — deliberately scopes by the caller's
organization (#10740). But the enqueue door never stamped the
`organization_id` column, and the SQL driver's tenant term is
`(organization_id = :tenantId OR organization_id IS NULL)` — a deliberate
global-row fail-open — so 100% of delivery rows landed in the NULL arm:
visible to, and replayable by, every organization on a walled deployment.

The repair mirrors the notification outbox's existing convention
(`EnqueueDeliveryInput.organizationId`), end to end:

- `EnqueueHttpInput` gains an **optional** `organizationId` member (inherited
by `UndeliverableHttpInput`, so parked rows are tenant-stamped too), and
`HttpDelivery` surfaces it on read-back. `SqlHttpOutbox.insert` writes
`organization_id: input.organizationId ?? null` exactly like
`SqlOutbox.enqueue`; `MemoryHttpOutbox` stores the same field and — now
that its rows carry a tenant — applies `RedeliverOptions.tenantId` in
`redeliver()` with the driver's exact semantics (another organization's row
is invisible/`RESOURCE_NOT_FOUND`; an org-less row stays a global row; a
tenant-less caller stays unscoped).
- The flow `http` node (durable mode) threads its run's acting organization
(`AutomationContext.tenantId` — the same source as the `notify` node's
#11303 repair) and warns loudly when a multi-org run has none to thread.
- The webhook auto-enqueuer stamps each delivery with its subscription's own
organization (`sys_webhook.organization_id`); org-less subscriptions
enqueue org-less, unchanged.

Forward-stamping only: existing NULL rows are untouched (their disposition is
a separate decision). Producers with genuinely no organization — a
`single`-posture deployment, a stack before its first organization — keep
working unchanged; their rows land NULL, which is the honest global-row shape.
61 changes: 61 additions & 0 deletions packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -222,6 +222,48 @@ describe('AutoEnqueuer', () => {
await ae.stop();
});

// [#13546] The delivery row belongs to the SUBSCRIPTION's organization —
// `sys_webhook` is organization-scoped (#8554), the enqueuer runs
// fire-and-forget off the write path with no request context, so the
// subscription row is the one honest tenant source. Without the stamp the
// row lands `organization_id = NULL` — the driver's global-row arm — and
// the redeliver() cross-organization wall (#10740) excludes nothing.
it("stamps the subscription's organization onto the enqueue input (#13546)", async () => {
const engine = new FakeEngine({
sys_webhook: [webhook({ organization_id: 'org_pin_alpha' })],
});
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(event('created', 'contact', { id: 'c-1' }));
await flush();

expect(calls).toHaveLength(1);
// Verbatim from the sys_webhook row — threaded, never fabricated.
expect(calls[0].organizationId).toBe('org_pin_alpha');
await ae.stop();
});

it('an org-less subscription enqueues with NO organization (the honest global-row shape, #13546)', async () => {
// The over-denial control: a `single`-posture install has org-less
// sys_webhook rows, and their events must still deliver — org-less,
// never refused, never stamped with a guess.
const engine = new FakeEngine({ sys_webhook: [webhook()] });
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(event('created', 'contact', { id: 'c-1' }));
await flush();

expect(calls).toHaveLength(1);
expect(calls[0].organizationId).toBeUndefined();
await ae.stop();
});

it('[#4626] drops an off-contract data event instead of enqueuing it as "unknown"', async () => {
// Pre-#4626 the enqueuer read `recordId ?? id ?? after?.id ?? 'unknown'`,
// so a payload that named no record still produced a delivery whose
Expand DownExpand Up@@ -583,6 +625,25 @@ describe('AutoEnqueuer — bulk data events (#4639)', () => {
await ae.stop();
});

it("the bulk path stamps the subscription's organization too (#13546)", async () => {
// Same tenant seam as the per-record path — a bulk delivery for an
// organization-owned subscription must not land as a global row either.
const engine = new FakeEngine({
sys_webhook: [webhook({ triggers: 'bulk_update', organization_id: 'org_pin_alpha' })],
});
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(bulkEvent('updated', 'contact', 3));
await flush();

expect(calls).toHaveLength(1);
expect(calls[0].organizationId).toBe('org_pin_alpha');
await ae.stop();
});

it('does NOT deliver a bulk event to a per-record update subscriber', async () => {
// The opt-in half of the decision: an existing `update` webhook keeps
// receiving only bodies shaped the way it already reads them.
Expand Down
28 changes: 28 additions & 0 deletions packages/plugins/plugin-webhooks/src/auto-enqueuer.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -115,6 +115,21 @@ interface CachedSubscription {
headers?: Record<string, string>;
secret?: string;
timeoutMs?: number;
/**
* [#13546] The subscription's own organization — `sys_webhook` is
* organization-scoped (#8554), so each row carries the tenant that authored
* it. Stamped onto every delivery row this subscription produces
* (`EnqueueHttpInput.organizationId`), which is what makes the
* cross-organization wall on `redeliver()` (#10740) actually exclude other
* tenants' rows: a row enqueued without it lands `organization_id = NULL`,
* the driver's global-row arm, visible to every organization. There is no
* request context to read here — the enqueuer runs fire-and-forget off the
* write path — so the subscription row is the one honest source. Absent
* when the row itself carries no organization (a `single`-posture install):
* the delivery then lands NULL, which is honest for a subscription that
* belongs to no organization. Threaded, never fabricated (#11303's rule).
*/
organizationId?: string;
/**
* [#8069] Set when a credential this subscription needs could not be
* recovered. The subscription stays CACHED — that is the change — but every
Expand DownExpand Up@@ -783,6 +798,10 @@ export class AutoEnqueuer {
// from their encrypted columns, NOT read off the row — see #7799
// (secret) and #7986 (headers).
timeoutMs: defn.timeoutMs,
// [#13546] The tenant column the kernel provisions on sys_webhook.
// This cache read is a dispatcher-side unscoped find, so the column
// comes back for every organization's rows.
organizationId: row.organization_id ? String(row.organization_id) : undefined,
};
}

Expand DownExpand Up@@ -867,6 +886,12 @@ export class AutoEnqueuer {
// subscription, so the delivery path is byte-identical to before.
undeliverableReason: sub.parkedReason,
timeoutMs: sub.timeoutMs,
// [#13546] The delivery row belongs to the SUBSCRIPTION's
// organization — the one honest tenant in scope on this
// fire-and-forget path (no request context exists here).
// Absent for an org-less subscription; the row then lands
// NULL, the global-row shape.
organizationId: sub.organizationId,
// [#3946] Envelope keys are written LAST so the event payload
// cannot rewrite them. Behaviour-neutral for the engine's own
// publishers — since #4626 a `data.record.*` payload is a
Expand DownExpand Up@@ -960,6 +985,9 @@ export class AutoEnqueuer {
// an undeliverable row instead of enqueuing a delivery.
undeliverableReason: sub.parkedReason,
timeoutMs: sub.timeoutMs,
// [#13546] See the per-record path — the subscription's own
// organization, absent for an org-less subscription.
organizationId: sub.organizationId,
// [#3946] Envelope keys last so the payload cannot rewrite them.
payload: {
...payload,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,6 +110,78 @@ describe('http (canonical node)', () => {
expect(result.success).toBe(true);
expect(fetchMock).toHaveBeenCalledOnce();
});

// [#13546] The producer pin: `sys_http_delivery` rows must carry the
// organization of the run that caused them, or they land in the
// driver's `organization_id IS NULL` global-row arm — visible to and
// replayable by every organization through redeliver() (#10740). The
// organization is THREADED from the run's own acting context
// (`AutomationContext.tenantId`) — the same source, and the same
// no-fallback rule, as the notify node's #11303 repair.
it("threads the run's acting organization onto the enqueue input (#13546)", async () => {
const enqueued: any[] = [];
const messaging: HttpSurface = {
isHttpDeliveryReady: () => true,
async enqueueHttp(input) {
enqueued.push(input);
return 'dlv_1';
},
};
const engine = new AutomationEngine(createTestLogger());
registerHttpNodes(engine, createCtx(messaging));
engine.registerFlow(
'http_flow',
httpFlow('http', { url: 'https://example.test/hook', durable: true }),
);

const result = await engine.execute('http_flow', { tenantId: 'org_pin_alpha' } as any);

expect(result.success).toBe(true);
expect(enqueued).toHaveLength(1);
// Verbatim — the acting tenant, not a derived or defaulted value.
expect(enqueued[0].organizationId).toBe('org_pin_alpha');
});

it('with NO organization in scope: still enqueues, passes NO organizationId key, and says so out loud (#13546)', async () => {
// The over-denial control (the notify suite's PIN C shape): a
// `single`-posture install and a stack before its first
// organization legitimately have no tenant to thread, and a
// durable callout there must still enqueue — org-less, loudly,
// never refused and never guessed.
const warnings: string[] = [];
const logger: any = {
info: () => {}, error: () => {}, debug: () => {},
warn: (...args: unknown[]) => { warnings.push(args.map(String).join(' ')); },
};
logger.child = () => logger;
const enqueued: any[] = [];
const messaging: HttpSurface = {
isHttpDeliveryReady: () => true,
async enqueueHttp(input) {
enqueued.push(input);
return 'dlv_1';
},
};
const engine = new AutomationEngine(logger);
registerHttpNodes(engine, {
logger,
getService: (name: string) => (name === 'messaging' ? messaging : undefined),
} as any);
engine.registerFlow(
'http_flow',
httpFlow('http', { url: 'https://example.test/hook', durable: true }),
);

const result = await engine.execute('http_flow');

expect(result.success).toBe(true);
expect(enqueued).toHaveLength(1);
// Absent, not null and not '' — the outbox normalizes a missing
// value to NULL exactly once, at its insert.
expect('organizationId' in enqueued[0]).toBe(false);
// Fail-LOUD: the org-less durable callout is a visible event.
expect(warnings.some((w) => w.includes('organization_id = NULL'))).toBe(true);
});
});

describe('request/response mode (default)', () => {
Expand Down
41 changes: 41 additions & 0 deletions packages/services/service-automation/src/builtin/http-nodes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,11 @@ interface MessagingHttpSurface {
signingSecret?: string;
timeoutMs?: number;
payload: unknown;
/**
* [#13546] Organization the delivery row belongs to — the tenant
* column the `redeliver()` cross-organization wall scopes by (#10740).
*/
organizationId?: string;
}): Promise<string>;
}

Expand DownExpand Up@@ -121,6 +126,38 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext):
if (durable) {
const messaging = getMessaging();
if (messaging?.isHttpDeliveryReady?.() && messaging.enqueueHttp) {
// [#13546] The organization this delivery belongs to,
// THREADED from the run's own acting context — never
// fabricated. Same source and same no-fallback rule as the
// `notify` node's #11303 repair one file over:
// `AutomationContext.tenantId` is the acting run's
// organization, and a wrong value is worse than a null (a
// null is visibly missing; a wrong one is silently
// authoritative). Without it the sys_http_delivery row
// lands `organization_id = NULL` — the driver's global-row
// arm — visible to and replayable by EVERY organization
// through the redeliver() door (#10740).
const organizationId =
typeof context.tenantId === 'string' && context.tenantId !== ''
? context.tenantId
: undefined;
if (!organizationId) {
// Fail-LOUD, not fail-guess, not fail-closed (#11303's
// triage): a `single`-posture install and a stack before
// its first organization legitimately have none, and a
// durable callout there must still enqueue.
// (Issue anchor lives in these comments, not in the
// runtime string — operators cannot resolve a tracker
// id; see check:doc-authoring.)
ctx.logger.warn(
`[http] node '${node.id}': no organization in scope for this durable callout — its ` +
`sys_http_delivery row will carry organization_id = NULL, which is a global row ` +
`every organization's redeliver door can reach on a walled deployment. ` +
`On a multi-organization install the triggering context lost its tenant: give the ` +
`flow's trigger an acting organization (AutomationContext.tenantId). On a ` +
`single-organization install this is expected and can be ignored.`,
);
}
try {
const deliveryId = await messaging.enqueueHttp({
source: 'flow',
Expand All@@ -133,6 +170,10 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext):
signingSecret,
timeoutMs,
payload: body ?? {},
// [#13546] Absent (not null) when the run has no
// organization; the outbox normalizes a missing
// value to NULL exactly once, at the insert.
...(organizationId ? { organizationId } : {}),
});
// #4354 — the outbox row IS a durable effect this run
// caused, but it is NOT a countable one (#7882). What
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); fix(service-messaging): stamp organization_id on sys_http_delivery rows so the redeliver() cross-organization wall excludes other tenants' rows by os-steve · Pull Request #13565 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/http-outbox-organization-stamp.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
---
"@objectstack/service-messaging": minor
"@objectstack/service-automation": patch
"@objectstack/plugin-webhooks": patch
---

fix(service-messaging,service-automation,plugin-webhooks): stamp `organization_id` on `sys_http_delivery` rows so the cross-organization wall on `redeliver()` actually excludes other tenants' rows (#13546)

`sys_http_delivery` is tenant-scoped and `redeliver()` — the one
request-reachable door on it — deliberately scopes by the caller's
organization (#10740). But the enqueue door never stamped the
`organization_id` column, and the SQL driver's tenant term is
`(organization_id = :tenantId OR organization_id IS NULL)` — a deliberate
global-row fail-open — so 100% of delivery rows landed in the NULL arm:
visible to, and replayable by, every organization on a walled deployment.

The repair mirrors the notification outbox's existing convention
(`EnqueueDeliveryInput.organizationId`), end to end:

- `EnqueueHttpInput` gains an **optional** `organizationId` member (inherited
by `UndeliverableHttpInput`, so parked rows are tenant-stamped too), and
`HttpDelivery` surfaces it on read-back. `SqlHttpOutbox.insert` writes
`organization_id: input.organizationId ?? null` exactly like
`SqlOutbox.enqueue`; `MemoryHttpOutbox` stores the same field and — now
that its rows carry a tenant — applies `RedeliverOptions.tenantId` in
`redeliver()` with the driver's exact semantics (another organization's row
is invisible/`RESOURCE_NOT_FOUND`; an org-less row stays a global row; a
tenant-less caller stays unscoped).
- The flow `http` node (durable mode) threads its run's acting organization
(`AutomationContext.tenantId` — the same source as the `notify` node's
#11303 repair) and warns loudly when a multi-org run has none to thread.
- The webhook auto-enqueuer stamps each delivery with its subscription's own
organization (`sys_webhook.organization_id`); org-less subscriptions
enqueue org-less, unchanged.

Forward-stamping only: existing NULL rows are untouched (their disposition is
a separate decision). Producers with genuinely no organization — a
`single`-posture deployment, a stack before its first organization — keep
working unchanged; their rows land NULL, which is the honest global-row shape.
61 changes: 61 additions & 0 deletions packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -222,6 +222,48 @@ describe('AutoEnqueuer', () => {
await ae.stop();
});

// [#13546] The delivery row belongs to the SUBSCRIPTION's organization —
// `sys_webhook` is organization-scoped (#8554), the enqueuer runs
// fire-and-forget off the write path with no request context, so the
// subscription row is the one honest tenant source. Without the stamp the
// row lands `organization_id = NULL` — the driver's global-row arm — and
// the redeliver() cross-organization wall (#10740) excludes nothing.
it("stamps the subscription's organization onto the enqueue input (#13546)", async () => {
const engine = new FakeEngine({
sys_webhook: [webhook({ organization_id: 'org_pin_alpha' })],
});
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(event('created', 'contact', { id: 'c-1' }));
await flush();

expect(calls).toHaveLength(1);
// Verbatim from the sys_webhook row — threaded, never fabricated.
expect(calls[0].organizationId).toBe('org_pin_alpha');
await ae.stop();
});

it('an org-less subscription enqueues with NO organization (the honest global-row shape, #13546)', async () => {
// The over-denial control: a `single`-posture install has org-less
// sys_webhook rows, and their events must still deliver — org-less,
// never refused, never stamped with a guess.
const engine = new FakeEngine({ sys_webhook: [webhook()] });
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(event('created', 'contact', { id: 'c-1' }));
await flush();

expect(calls).toHaveLength(1);
expect(calls[0].organizationId).toBeUndefined();
await ae.stop();
});

it('[#4626] drops an off-contract data event instead of enqueuing it as "unknown"', async () => {
// Pre-#4626 the enqueuer read `recordId ?? id ?? after?.id ?? 'unknown'`,
// so a payload that named no record still produced a delivery whose
Expand DownExpand Up@@ -583,6 +625,25 @@ describe('AutoEnqueuer — bulk data events (#4639)', () => {
await ae.stop();
});

it("the bulk path stamps the subscription's organization too (#13546)", async () => {
// Same tenant seam as the per-record path — a bulk delivery for an
// organization-owned subscription must not land as a global row either.
const engine = new FakeEngine({
sys_webhook: [webhook({ triggers: 'bulk_update', organization_id: 'org_pin_alpha' })],
});
const realtime = new FakeRealtime();
const { enqueue, calls } = makeRecorder();
const ae = new AutoEnqueuer(engine, realtime, enqueue, { refreshIntervalMs: 0 });
await ae.start();

await realtime.publish(bulkEvent('updated', 'contact', 3));
await flush();

expect(calls).toHaveLength(1);
expect(calls[0].organizationId).toBe('org_pin_alpha');
await ae.stop();
});

it('does NOT deliver a bulk event to a per-record update subscriber', async () => {
// The opt-in half of the decision: an existing `update` webhook keeps
// receiving only bodies shaped the way it already reads them.
Expand Down
28 changes: 28 additions & 0 deletions packages/plugins/plugin-webhooks/src/auto-enqueuer.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -115,6 +115,21 @@ interface CachedSubscription {
headers?: Record<string, string>;
secret?: string;
timeoutMs?: number;
/**
* [#13546] The subscription's own organization — `sys_webhook` is
* organization-scoped (#8554), so each row carries the tenant that authored
* it. Stamped onto every delivery row this subscription produces
* (`EnqueueHttpInput.organizationId`), which is what makes the
* cross-organization wall on `redeliver()` (#10740) actually exclude other
* tenants' rows: a row enqueued without it lands `organization_id = NULL`,
* the driver's global-row arm, visible to every organization. There is no
* request context to read here — the enqueuer runs fire-and-forget off the
* write path — so the subscription row is the one honest source. Absent
* when the row itself carries no organization (a `single`-posture install):
* the delivery then lands NULL, which is honest for a subscription that
* belongs to no organization. Threaded, never fabricated (#11303's rule).
*/
organizationId?: string;
/**
* [#8069] Set when a credential this subscription needs could not be
* recovered. The subscription stays CACHED — that is the change — but every
Expand DownExpand Up@@ -783,6 +798,10 @@ export class AutoEnqueuer {
// from their encrypted columns, NOT read off the row — see #7799
// (secret) and #7986 (headers).
timeoutMs: defn.timeoutMs,
// [#13546] The tenant column the kernel provisions on sys_webhook.
// This cache read is a dispatcher-side unscoped find, so the column
// comes back for every organization's rows.
organizationId: row.organization_id ? String(row.organization_id) : undefined,
};
}

Expand DownExpand Up@@ -867,6 +886,12 @@ export class AutoEnqueuer {
// subscription, so the delivery path is byte-identical to before.
undeliverableReason: sub.parkedReason,
timeoutMs: sub.timeoutMs,
// [#13546] The delivery row belongs to the SUBSCRIPTION's
// organization — the one honest tenant in scope on this
// fire-and-forget path (no request context exists here).
// Absent for an org-less subscription; the row then lands
// NULL, the global-row shape.
organizationId: sub.organizationId,
// [#3946] Envelope keys are written LAST so the event payload
// cannot rewrite them. Behaviour-neutral for the engine's own
// publishers — since #4626 a `data.record.*` payload is a
Expand DownExpand Up@@ -960,6 +985,9 @@ export class AutoEnqueuer {
// an undeliverable row instead of enqueuing a delivery.
undeliverableReason: sub.parkedReason,
timeoutMs: sub.timeoutMs,
// [#13546] See the per-record path — the subscription's own
// organization, absent for an org-less subscription.
organizationId: sub.organizationId,
// [#3946] Envelope keys last so the payload cannot rewrite them.
payload: {
...payload,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -110,6 +110,78 @@ describe('http (canonical node)', () => {
expect(result.success).toBe(true);
expect(fetchMock).toHaveBeenCalledOnce();
});

// [#13546] The producer pin: `sys_http_delivery` rows must carry the
// organization of the run that caused them, or they land in the
// driver's `organization_id IS NULL` global-row arm — visible to and
// replayable by every organization through redeliver() (#10740). The
// organization is THREADED from the run's own acting context
// (`AutomationContext.tenantId`) — the same source, and the same
// no-fallback rule, as the notify node's #11303 repair.
it("threads the run's acting organization onto the enqueue input (#13546)", async () => {
const enqueued: any[] = [];
const messaging: HttpSurface = {
isHttpDeliveryReady: () => true,
async enqueueHttp(input) {
enqueued.push(input);
return 'dlv_1';
},
};
const engine = new AutomationEngine(createTestLogger());
registerHttpNodes(engine, createCtx(messaging));
engine.registerFlow(
'http_flow',
httpFlow('http', { url: 'https://example.test/hook', durable: true }),
);

const result = await engine.execute('http_flow', { tenantId: 'org_pin_alpha' } as any);

expect(result.success).toBe(true);
expect(enqueued).toHaveLength(1);
// Verbatim — the acting tenant, not a derived or defaulted value.
expect(enqueued[0].organizationId).toBe('org_pin_alpha');
});

it('with NO organization in scope: still enqueues, passes NO organizationId key, and says so out loud (#13546)', async () => {
// The over-denial control (the notify suite's PIN C shape): a
// `single`-posture install and a stack before its first
// organization legitimately have no tenant to thread, and a
// durable callout there must still enqueue — org-less, loudly,
// never refused and never guessed.
const warnings: string[] = [];
const logger: any = {
info: () => {}, error: () => {}, debug: () => {},
warn: (...args: unknown[]) => { warnings.push(args.map(String).join(' ')); },
};
logger.child = () => logger;
const enqueued: any[] = [];
const messaging: HttpSurface = {
isHttpDeliveryReady: () => true,
async enqueueHttp(input) {
enqueued.push(input);
return 'dlv_1';
},
};
const engine = new AutomationEngine(logger);
registerHttpNodes(engine, {
logger,
getService: (name: string) => (name === 'messaging' ? messaging : undefined),
} as any);
engine.registerFlow(
'http_flow',
httpFlow('http', { url: 'https://example.test/hook', durable: true }),
);

const result = await engine.execute('http_flow');

expect(result.success).toBe(true);
expect(enqueued).toHaveLength(1);
// Absent, not null and not '' — the outbox normalizes a missing
// value to NULL exactly once, at its insert.
expect('organizationId' in enqueued[0]).toBe(false);
// Fail-LOUD: the org-less durable callout is a visible event.
expect(warnings.some((w) => w.includes('organization_id = NULL'))).toBe(true);
});
});

describe('request/response mode (default)', () => {
Expand Down
41 changes: 41 additions & 0 deletions packages/services/service-automation/src/builtin/http-nodes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -48,6 +48,11 @@ interface MessagingHttpSurface {
signingSecret?: string;
timeoutMs?: number;
payload: unknown;
/**
* [#13546] Organization the delivery row belongs to — the tenant
* column the `redeliver()` cross-organization wall scopes by (#10740).
*/
organizationId?: string;
}): Promise<string>;
}

Expand DownExpand Up@@ -121,6 +126,38 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext):
if (durable) {
const messaging = getMessaging();
if (messaging?.isHttpDeliveryReady?.() && messaging.enqueueHttp) {
// [#13546] The organization this delivery belongs to,
// THREADED from the run's own acting context — never
// fabricated. Same source and same no-fallback rule as the
// `notify` node's #11303 repair one file over:
// `AutomationContext.tenantId` is the acting run's
// organization, and a wrong value is worse than a null (a
// null is visibly missing; a wrong one is silently
// authoritative). Without it the sys_http_delivery row
// lands `organization_id = NULL` — the driver's global-row
// arm — visible to and replayable by EVERY organization
// through the redeliver() door (#10740).
const organizationId =
typeof context.tenantId === 'string' && context.tenantId !== ''
? context.tenantId
: undefined;
if (!organizationId) {
// Fail-LOUD, not fail-guess, not fail-closed (#11303's
// triage): a `single`-posture install and a stack before
// its first organization legitimately have none, and a
// durable callout there must still enqueue.
// (Issue anchor lives in these comments, not in the
// runtime string — operators cannot resolve a tracker
// id; see check:doc-authoring.)
ctx.logger.warn(
`[http] node '${node.id}': no organization in scope for this durable callout — its ` +
`sys_http_delivery row will carry organization_id = NULL, which is a global row ` +
`every organization's redeliver door can reach on a walled deployment. ` +
`On a multi-organization install the triggering context lost its tenant: give the ` +
`flow's trigger an acting organization (AutomationContext.tenantId). On a ` +
`single-organization install this is expected and can be ignored.`,
);
}
try {
const deliveryId = await messaging.enqueueHttp({
source: 'flow',
Expand All@@ -133,6 +170,10 @@ export function registerHttpNodes(engine: AutomationEngine, ctx: PluginContext):
signingSecret,
timeoutMs,
payload: body ?? {},
// [#13546] Absent (not null) when the run has no
// organization; the outbox normalizes a missing
// value to NULL exactly once, at the insert.
...(organizationId ? { organizationId } : {}),
});
// #4354 — the outbox row IS a durable effect this run
// caused, but it is NOT a countable one (#7882). What
Expand Down
Loading
Loading