Skip to content

sys_http_delivery rows always land organization_id = NULL, so the deliberate cross-organization wall on redeliver() is reachable around for every row #13546

Description

@claude

Consequence first

sys_http_delivery is tenant-scoped, and the platform built a deliberate cross-organization wall on it. WebhookOutboxPlugin.registerAdminRoutes says so in as many words (packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts:364):

sys_http_delivery is tenant-scoped, and this is the one door on it a request can reach: an unscoped replay from here is an authenticated user reaching another organization's delivery row on a walled deployment. With the tenant threaded, a row outside the caller's organization is simply not found.

That wall is reachable around for 100% of rows, because 100% of rows are written with organization_id = NULL.

The driver's tenant term is (organization_id = :tenantId OR organization_id IS NULL)SqlDriver.applyTenantScope, packages/drivers/driver-sql/src/sql-driver.ts:11931. The OR ... IS NULL arm is a deliberate global-row fail-open so platform rows are not hidden from every tenant. A NULL-tenant row therefore belongs to no organization and is visible to every one. Since the enqueue door never stamps the column, every row of this object is in that arm, and the scoping repair on redeliver() — 200 lines up the same file — can never exclude anything.

The gap

SqlHttpOutbox.insert (packages/services/service-messaging/src/sql-http-outbox.ts:178, reached from enqueue() and recordUndeliverable()) builds its row and calls engine.insert(this.objectName, row):

  • no execution context, so ObjectQLEngine.buildDriverOptions produces no DriverOptions.tenantId;
  • no organization_id on the row, so SqlDriver.injectTenantOnInsert (sql-driver.ts:11944) has nothing to stamp and returns at its first guard;
  • and the object is in the platform namespace, so the engine's own resolveSystemInsertOrganization derivation returns undefined at packages/objectql/src/engine.ts:3765 (if (isPlatformNamespaceObject(object)) return undefined;) rather than deriving or refusing.

The gap is in the CONTRACT, not only the implementation. EnqueueHttpInput has no organizationId member at all, so no caller can supply one even when it has one to give. Measured by content on 50cf2940b9, with the sibling outbox as the control — the notification outbox received exactly this repair and the HTTP one did not:

file role occurrences of organizationId
packages/services/service-messaging/src/http-outbox.ts HTTP outbox interface 0
packages/services/service-messaging/src/sql-http-outbox.ts HTTP outbox implementation 0
packages/services/service-messaging/src/outbox.ts notification outbox interface 2
packages/services/service-messaging/src/sql-outbox.ts notification outbox implementation 2

SqlOutbox.enqueue (sql-outbox.ts:121) writes organization_id: input.organizationId ?? null onto its row. SqlHttpOutbox.insert has no equivalent line and no field to read it from. Both files exist and both were read, so the zero is a measurement rather than a missing file.

Producers, and why an HTTP middleware does not close this

The two producers of sys_http_delivery rows are:

  • packages/services/service-automation/src/builtin/http-nodes.ts:125 — an automation flow node;
  • the webhook auto-enqueuer, wired at packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts:302, which the same file's own diagram (:86) describes as running fire-and-forget, off the write path.

So even for an HTTP-originated write the row is inserted after the request has moved on, from a payload the service constructs itself. There are exactly four seams by which a tenant value can reach a write in this platform — ExecutionContext.tenantId, a DriverOptions.tenantId in the options bag, the tenant column set on the row, and the engine's own insert-side derivation — and all four are explicit at the call site. The engine's only AsyncLocalStorage (engine.ts:2225) carries transaction and scope, not a tenant, so there is no ambient slot an outer layer could fill.

Suggested shape, not a prescription

The narrowest repair that matches what the sibling already does: give EnqueueHttpInput an organizationId member, have the two producers pass the organization they are acting for, and stamp it on the row in SqlHttpOutbox.insert the way SqlOutbox.enqueue does. Whether existing NULL rows are backfilled is a separate decision with precedent on both sides in this area.

⚠️ Line numbers are as of 50cf2940b9 and rot. Re-locate by symbol — SqlHttpOutbox.insert, applyTenantScope, injectTenantOnInsert, resolveSystemInsertOrganization, registerAdminRoutes — rather than by line.

Measured while executing the stamper-gap measurement on #13497; recorded there as well, and filed separately so it does not live only in a comment. Not graded here: no priority or domain label, unassigned, for triage.

Generated by Claude Code


Generated by Claude Code

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions