Uh oh!
There was an error while loading. Please reload this page.
fix(service-messaging): stamp organization_id on sys_http_delivery rows so the redeliver() cross-organization wall excludes other tenants' rows - #13565
Conversation
…ws (#13546) Part of #13546 — the enqueue door never wrote the tenant column, so every row landed in the SQL driver's (organization_id IS NULL) global-row arm and the cross-organization wall on redeliver() (#10740) excluded nothing. Mirrors the notification outbox's existing repair (EnqueueDeliveryInput): - EnqueueHttpInput gains an optional organizationId (inherited by UndeliverableHttpInput, so parked rows are stamped too); HttpDelivery surfaces it on read-back. - SqlHttpOutbox.insert writes organization_id: input.organizationId ?? null, the same line SqlOutbox.enqueue writes. - MemoryHttpOutbox stores the field and, now that its rows carry a tenant, applies RedeliverOptions.tenantId in redeliver() with the driver's exact semantics (other org invisible; org-less row global; tenant-less caller unscoped). - The flow http node (durable mode) threads AutomationContext.tenantId — the notify node's #11303 source — and warns loudly when a run has none. - The webhook auto-enqueuer stamps each delivery with its subscription's own organization (sys_webhook.organization_id). Forward-stamping only; existing NULL rows are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
…string check:doc-authoring red: operators cannot resolve #NNNN. The anchor stays in the adjacent code comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
📓 Docs Drift CheckThis PR changes 3 package(s): 28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 214ce1a01bdf23e06de3ef9a877c3a36013531fd && git checkout 214ce1a01bdf23e06de3ef9a877c3a36013531fd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 51ecb2fdd2035a8924603b497a6b7a2ee8277fb0 479e6315071c3b8cef710d46da7e64b4d3a160c1 && git checkout -B drift-repro 51ecb2fdd2035a8924603b497a6b7a2ee8277fb0 && git merge --no-ff 479e6315071c3b8cef710d46da7e64b4d3a160c1
node scripts/docs-audit/affected-docs.mjs --json 51ecb2fdd2035a8924603b497a6b7a2ee8277fb0
|
…tp-outbox-organization-id
… ledger check-engine-double-contract --write: 1 row added (findOne, pinned: 1), 0 lost — additive ratchet only, per the gate's own instruction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
Uh oh!
There was an error while loading. Please reload this page.
Fixes#13546
What breaks today
sys_http_deliveryis tenant-scoped andredeliver()— its one request-reachable door (POST /api/v1/webhooks/redeliver, open to any authenticated user) — deliberately scopes by the caller's organization (#10740). But the enqueue door never stampedorganization_id, 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 rows landed in the NULL arm: visible to, and replayable by, every organization on a walled deployment. Declared, not enforced; this PR restores the declared contract. The driver's tenant term is untouched — removing theIS NULLarm would hide true global rows from every tenant, a different defect.The repair — mirror of the sibling that already received it
The notification outbox got exactly this fix (
EnqueueDeliveryInput.organizationId,SqlOutbox.enqueuewritingorganization_id: input.organizationId ?? null). This PR gives the HTTP outbox the same shape, end to end:http-outbox.ts):EnqueueHttpInputgains anorganizationId?: stringmember — inherited byUndeliverableHttpInput, so parked rows are tenant-stamped too — andHttpDeliverysurfaces it on read-back.sql-http-outbox.ts): the sharedinsert()(reached from bothenqueue()andrecordUndeliverable()) writesorganization_id: input.organizationId ?? null, the same line the sibling writes;toDelivery()maps it back (NULL→ absent).memory-http-outbox.ts): stores the same field — and, now that its rows carry a tenant, itsredeliver()honors the obligation its own tenant-audit, theupdatehalf:ackis a dispatcher sweep butredeliveris request-reachable — two sites on one object with OPPOSITE classifications #10740 note recorded ("a future memory implementation that DOES store a tenant owes the predicate here"), with the driver's exact semantics: another organization's row is invisible (RESOURCE_NOT_FOUND, never an existence oracle), an org-less row stays a global row any tenant may replay, a tenant-less caller stays unscoped.httpnode (http-nodes.ts, durable mode): threads the run's acting organization,AutomationContext.tenantId— the same source and the same no-fallback rule as thenotifynode's [finding] sys_inbox_message/sys_notification/sys_email 等平台表从不写 organization_id(存量与新增行 100% null)——请确认多组织语义是否设计如此 #11303 repair in the same directory. When a run has none, it warns loudly (fail-loud, not fail-closed, not fail-guess) and enqueues org-less.auto-enqueuer.ts): stamps each delivery with its subscription's own organization (sys_webhookis organization-scoped, Five more instances of the #8323 class: admin- and user-authored names on tenant-scoped objects still carry installation-wide unique indexes #8554, so the row carries the tenant that authored it). There is no request context on this fire-and-forget path; the subscription row is the one honest source. Org-less subscriptions enqueue org-less, silently — a per-event warn here would fire on every record write of a single-posture install.Forward-stamping only. Existing NULL rows are untouched; their disposition is the separate decision the card names. No backfill, no driver changes, no schema changes (
organization_idis kernel-provisioned on every object).Optional vs required — argued, not assumed
The new member is optional (
organizationId?: string). Weighed explicitly against the required-but-undefined-able shape this same file uses forRedeliverOptions.tenantId:EnqueueDeliveryInput.organizationIdandEmitInput.organizationId— one convention across the two outboxes of one package (a second convention here would be this card's defect repeated). It is non-breaking for the published surface. And org-less enqueues are legitimate: asingle-posture install and every stack before its first organization must keep enqueuing.organizationId: string | undefined): nothing tells a future producer it forgot one — the exact gap that produced this bug. That is a real cost. The mitigations shipped here: the producer population of this seam is enumerated (two, both repaired in this PR), the flow node warns loudly on an org-less multi-org run, and the driver'sauditMissingTenantline still reports unscoped writes on multi-tenant boots.RedeliverOptions.tenantIdwent required because its callers are arbitrary request routes; this seam's callers are the two service-internal producers.If the contract reviewer prefers the required shape, the change is mechanical (both producers already always spell the key or spread it conditionally) but it becomes a compile-breaking widening of a published input.
Write-surface enumeration (the triage's must-answer)
Is
SqlHttpOutbox.insertthe only door? Enumerated, not exemplified, atorigin/main(ff37576):IHttpOutbox: exactly one —messaging-service-plugin.ts:310(new SqlHttpOutbox(engine, ...), default object name).MemoryHttpOutboxis constructed only by tests.SqlHttpOutbox.insert, reached fromenqueue()andrecordUndeliverable()— both covered by this stamp. NobulkCreate/upsert/seed writes anywhere (grep -rn "insert(SYS_HTTP_DELIVERY|'sys_http_delivery'"overpackages/ apps/: only the four test files that build their own rows).claim/ack/redeliverUPDATEs) create no rows and never touch the tenant column;userActionsdeclares create/edit/delete false and the object ismanagedBy: 'engine-owned'.Verification
packages/services/service-messaging/src/http-outbox-organization.test.ts(9 — SQL stamp verbatim / explicit-NULL normalization / parked-row stamp / read-back mapping; memory parity + all three arms of the redeliver predicate, ADR-0112codeasserted on the refusal);http-nodes.test.ts(+2 — tenant threaded verbatim; over-denial control: org-less run still enqueues, key absent, warns);auto-enqueuer.test.ts(+3 — per-record, org-less control, bulk path).e2ecb71: service-messaging 7 files / 48 tests pass (incl. the tenant-audit and parked-record integration suites), service-automation 4 files / 34 pass, plugin-webhooks 3 files / 40 pass.typecheckgreen for service-messaging + plugin-webhooks (service-automation declares none);--listFilesconfirms the new test file is inside the messaging typecheck.@objectstack/service-messagingviadist/index.d.ts(tsc --listFiles); mutating the producer key toorganizationIdXreddens typecheck with TS2561 namingorganizationIdas the fix — proving the rebuilt.d.tscarries the member and the literal is enforced against it. Restore verified byte-identical (worktree blob = HEAD blob7f52692).e2ecb71: all 14 path-derived families green exceptcheck:dual-build-cjs-loads, which is NOT MEASURED locally (PREREQUISITE NOT MET— it reads every package's dist and 41+ untouched packages are unbuilt here; CI's Build Core owns the full run). Declared narrowing: the gate's own population is every published package's built entry (quoted from its error output); this diff touches 3 packages and no build/exports config of any other, so untouched packages' verdicts cannot move; the 3 touched packages were rebuilt and their CJS entriesrequire()clean (3/3 probed).Generated by Claude Code
Generated by Claude Code