diff --git a/.changeset/harden-webhook-redeliver-door.md b/.changeset/harden-webhook-redeliver-door.md new file mode 100644 index 00000000000..639ee09f9d8 --- /dev/null +++ b/.changeset/harden-webhook-redeliver-door.md @@ -0,0 +1,20 @@ +--- +'@objectstack/plugin-webhooks': patch +--- + +fix(plugin-webhooks)!: gate webhook redelivery on webhook-administration access + +Clause-②: no (narrowing) — `POST /api/v1/webhooks/redeliver` admits only a signed-in caller entitled to administer webhooks, in an organization a current membership backs on a walled posture, where it admitted every signed-in caller. Nothing is widened. +Metadata-protocol: none + + + +**BREAKING** accept-set narrowing at the webhook redeliver door (`@objectstack/plugin-webhooks`), on both faces: the self-hosted mount and the `webhooks` service's `handleRedeliver`. The caller's entitlement is decided server-side by the security service before the request body is read or any delivery row is looked up. + +- **The entitlement.** The security service's object-level `update` admission on `sys_webhook` (`ISecurityService.explain`), for the caller's execution context as the platform's request-context composition resolves it, the same answer the data API's write gate gives that caller. The shipped administrator permission sets carry it; a member baseline does not. +- **The organization.** That context is resolved with the deployment's tenancy posture, as the data API resolves it: on a walled posture (`isolated`, `group`) a session's active-organization claim that no current membership backs is not kept, and the door answers that caller with the same `403`. The replay is scoped to the organization the caller was admitted in. +- **An unentitled caller** is answered `403 PERMISSION_DENIED`, the same answer whatever the request names, so the refusal carries no row-specific detail and nothing is replayed. +- **Fail closed.** A deployment with no security service registered, or a check that cannot be decided (including a tenancy service that is registered and cannot answer), answers `503 SERVICE_UNAVAILABLE` and replays nothing. A kernel that serves the door with no security service says so once at boot, naming the remedy. +- **Unchanged for an entitled caller:** the auth gate, `401` for no session, the `400` body refusals, the active-organization scoping (`404` outside it), the redelivery veto's `409`s and the `200` replay. The door still admits no API key. + +Migration: a deployment that relied on non-administrators pressing redeliver grants them `allowEdit` on `sys_webhook` through a permission set; a caller on a walled posture switches to an organization it belongs to. diff --git a/content/docs/automation/webhooks.mdx b/content/docs/automation/webhooks.mdx index 4f51e4b7446..b04fc6c5bbe 100644 --- a/content/docs/automation/webhooks.mdx +++ b/content/docs/automation/webhooks.mdx @@ -624,7 +624,17 @@ The plugin mounts exactly **one** custom endpoint: | POST | `/api/v1/webhooks/redeliver` | `{ deliveryId }` | Re-queue a delivery that was **attempted** and failed. A row that was never attempted cannot be redelivered. | It delegates to `messaging.redeliverHttp(deliveryId)` and is guarded by the -better-auth session (any authenticated user). Failures answer in the declared +better-auth session **and by webhook-administration access**: the caller must be +allowed to edit `sys_webhook` (the security service's object-level `update` +admission, decided server-side before the request body is read). A signed-in +caller without it is answered `403` `PERMISSION_DENIED` before any detail about +the delivery; a deployment with no security service to decide answers `503` +`SERVICE_UNAVAILABLE`. Grant webhook administration through a permission set +(the shipped administrator sets carry it). The caller's active organization is +vetted the way the data API vets it: on a walled tenancy posture (`isolated`, +`group`) an organization claim that no current membership backs is answered with +the same `403`, and a replay reaches only deliveries of the organization the +caller was admitted in (`404` for any other). Failures answer in the declared envelope — `{ success: false, error: { code, message } }` — with `401` `UNAUTHENTICATED`, `400` `INVALID_REQUEST` or `MISSING_REQUIRED_FIELD`, `404` `RESOURCE_NOT_FOUND`, `409` `DELIVERY_NOT_ELIGIBLE`, or `409` diff --git a/docs/qa/platform-checklist/areas/integration-system.json b/docs/qa/platform-checklist/areas/integration-system.json index d5e2c07caac..e6d685df967 100644 --- a/docs/qa/platform-checklist/areas/integration-system.json +++ b/docs/qa/platform-checklist/areas/integration-system.json @@ -582,7 +582,7 @@ "title": "Outbound webhooks are read from the registry (spec object→object_name, no sys_webhook row) and switched through the activation ledger, fire per trigger variant through the sys_http_delivery outbox with HMAC + timeout honored, redeliver terminal rows tenant-scoped via POST /api/v1/webhooks/redeliver, and reject retired trigger kinds", "since": "v15", "status": "active", - "revision": 11, + "revision": 13, "priority": "P1", "surface": "mixed", "fixtures": { @@ -614,7 +614,7 @@ "run a predicate multi-update and multi-delete (multi:true) matching several rows — through a FLOW node, not REST: author update_record / delete_record nodes with multi:true and fire them via the api trigger (the REST batch routes strip options.multi, #3897 — see the knownGap); capture the bulk deliveries and their { object, matched } shape", "read sys_http_delivery over /api/v1/data (a `$filter` is a JSON filter object): one row per delivery with status/attempts/response_code", "kill the receiver and mutate again; re-read the delivery row through its retry/failure states", - "restore the receiver answering a permanent 4xx (not 408/429), so the failing row reaches a terminal status (failed/dead) at its next attempt — packages/services/service-messaging/src/http-sender.ts maps a 4xx to a permanent failure and retries 408, 429, 5xx and transport errors on the outbox schedule, so a receiver that only comes back healthy leaves the row non-terminal for the whole schedule — then POST /api/v1/webhooks/redeliver with { deliveryId: } as an authenticated user: capture the 200 { id, status } and the receiver's re-hit — compare the replayed X-Objectstack-Signature byte-for-byte against the original delivery's; re-read the row; then probe the refusals: anonymous POST (401), non-JSON body (400 INVALID_REQUEST), body without deliveryId (400 MISSING_REQUIRED_FIELD), a garbage/unknown deliveryId (404), a row still pending/in_flight (409 DELIVERY_NOT_ELIGIBLE)", + "restore the receiver answering a permanent 4xx (not 408/429), so the failing row reaches a terminal status (failed/dead) at its next attempt — packages/services/service-messaging/src/http-sender.ts maps a 4xx to a permanent failure and retries 408, 429, 5xx and transport errors on the outbox schedule, so a receiver that only comes back healthy leaves the row non-terminal for the whole schedule — then POST /api/v1/webhooks/redeliver with { deliveryId: } as the seeded admin (the door is gated on webhook-administration access): capture the 200 { id, status } and the receiver's re-hit — compare the replayed X-Objectstack-Signature byte-for-byte against the original delivery's; re-read the row; then probe the refusals: anonymous POST (401), non-JSON body (400 INVALID_REQUEST), body without deliveryId (400 MISSING_REQUIRED_FIELD), a garbage/unknown deliveryId (404), a row still pending/in_flight (409 DELIVERY_NOT_ELIGIBLE)", "redeploy/reboot and confirm the activation switch survived: the sys_metadata_activation row is still there with `active` true, and the switch is still in force. No public door reads a declared webhook's switch: GET /api/v1/meta/webhook/showcase_task_changed serves the declaration, which answers isActive:false by design (the activation ledger is separate, ADR-0126 §4), so the in-force half is read off a fresh sys_http_delivery row for a task change made after the reboot (the switch is deployment state; the declaration is code)" ], "acceptance": [ @@ -643,9 +643,9 @@ "evidence": "headers + delivery rows" }, { - "clause": "POST /api/v1/webhooks/redeliver replays a TERMINAL delivery (status success/failed/dead with attempts > 0): the row resets and re-dispatches carrying the STORED HMAC signature byte-for-byte (signed at enqueue, replayed verbatim — still verifiable against the secret), the row updates, and the response is { id, status }. The refusal matrix never 500s for a caller-fixable state: anonymous → 401 UNAUTHENTICATED; non-JSON → 400 INVALID_REQUEST; missing deliveryId → 400 MISSING_REQUIRED_FIELD; unknown OR cross-organization deliveryId → 404 RESOURCE_NOT_FOUND (the caller's activeOrganizationId is threaded into the lookup, #10740 — another org's row is simply not found, not forbidden-with-a-hint); non-terminal row → 409 DELIVERY_NOT_ELIGIBLE; a PARKED row (attempts === 0, recordUndeliverable's record of a delivery never prepared) → 409 DELIVERY_NEVER_SENT, because redelivering it would be an UNSIGNED first delivery (#7799/#8069 — the redeliver guard also refuses when the signing configuration is gone, fail-closed on a guard that throws). ⚠ PIN THE POSTURE: there is deliberately NO capability gate above the auth floor — 'every authenticated user counts' (the plugin's own words); a non-admin session redelivering successfully is the designed behavior, not a missing-gate finding, and a run must neither file it nor 'fix' it", + "clause": "POST /api/v1/webhooks/redeliver replays a TERMINAL delivery (status success/failed/dead with attempts > 0): the row resets and re-dispatches carrying the STORED HMAC signature byte-for-byte (signed at enqueue, replayed verbatim — still verifiable against the secret), the row updates, and the response is { id, status }. The refusal matrix never 500s for a caller-fixable state: anonymous → 401 UNAUTHENTICATED; non-JSON → 400 INVALID_REQUEST; missing deliveryId → 400 MISSING_REQUIRED_FIELD; unknown OR cross-organization deliveryId → 404 RESOURCE_NOT_FOUND (the caller's activeOrganizationId is threaded into the lookup, #10740 — another org's row is simply not found, not forbidden-with-a-hint); non-terminal row → 409 DELIVERY_NOT_ELIGIBLE; a PARKED row (attempts === 0, recordUndeliverable's record of a delivery never prepared) → 409 DELIVERY_NEVER_SENT, because redelivering it would be an UNSIGNED first delivery (#7799/#8069 — the redeliver guard also refuses when the signing configuration is gone, fail-closed on a guard that throws). The door is gated on WEBHOOK-ADMINISTRATION access above the auth floor (redeliver-entitlement.ts: the security service's object-level `update` admission on sys_webhook, asked before the body is read): drive this clause as the seeded admin; a signed-in caller without that access, or whose active organization no current membership backs on a walled posture (the claim is vetted as the data API vets it), is answered 403 PERMISSION_DENIED before any row-specific detail, and no security service (or an undecidable check) is 503 SERVICE_UNAVAILABLE", "oracle": "api", - "verify": "the redeliver 200 + the receiver's replayed signature equal to the original (pinned byte-for-byte in http-signature-at-rest.integration.test.ts); each refusal against webhook-outbox-plugin.ts (401/400/400/404 arms) and http-outbox.ts (assertHttpRedeliverable / assertRedeliverAllowed — the 409 pair and the guard); the no-capability posture and the #10740 tenant threading in the route's own docblock", + "verify": "the redeliver 200 + the receiver's replayed signature equal to the original (pinned byte-for-byte in http-signature-at-rest.integration.test.ts); each refusal against webhook-outbox-plugin.ts (401/400/400/404 arms) and http-outbox.ts (assertHttpRedeliverable / assertRedeliverAllowed — the 409 pair and the guard); the webhook-administration gate (403 PERMISSION_DENIED, 503 SERVICE_UNAVAILABLE) and the replay's organization (the session's activeOrganizationId as the request-context composition vetted it against the tenancy posture, threaded per #10740) in redeliver-entitlement.ts's docblock and the route's own, both sides pinned in webhook-redeliver-entitlement.test.ts", "evidence": "the redeliver response + replayed receiver hit + the refusal responses, keyed by arm" }, { @@ -675,7 +675,7 @@ "packages/spec/liveness/webhook.json (9 props live on the registry read; label/description live on the Studio metadata list and quick-find, which read the declared item over GET /api/v1/meta/webhook)", "packages/plugins/plugin-webhooks/src/declared-webhooks.ts + auto-enqueuer.ts (the registry read and its remaps; trigger→event mapping incl. the #4639 bulk pair; #3196 unknown-trigger warn; the activation-ledger switch; an 18.0 database's seeded rows read beside the declaration)", "packages/services/service-messaging/src/http-outbox.ts#HttpRedeliverError (delivery statuses, attempts; HttpRedeliverError + assertHttpRedeliverable/assertRedeliverAllowed — terminal-only, the attempts===0 parked-row refusal, the fail-closed guard) + packages/services/service-messaging/src/http-signature-at-rest.integration.test.ts (redelivery replays the stored signature byte-for-byte)", - "packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts#session (POST /api/v1/webhooks/redeliver — session auth with NO capability gate by design ('every authenticated user counts'), #10740 activeOrganizationId threading, the 401/400/404/409/500 arms) + (the #8069 redeliver-guard install; its ABSENCE is an error-level log, not a silent pass)", + "packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts#session (POST /api/v1/webhooks/redeliver — session auth, then the webhook-administration entitlement gate (redeliver-entitlement.ts — update on sys_webhook, asked before the body is read), #10740 activeOrganizationId threading, the 401/400/404/409/500 arms) + (the #8069 redeliver-guard install; its ABSENCE is an error-level log, not a silent pass)", "packages/rest/src/rest-server.ts#deleteMany (#3897 — the batch routes parse against the spec contract and Zod STRIPS unknown keys, so options.multi/options.where cannot ride in; deleteMany deletes per id. This is why the bulk clause must be driven from a flow node, not REST)", "examples/app-showcase/src/automation/webhooks/index.ts (the shipped inactive fixture and its activation story)", "#3358 §9 (webhook undelete/api trigger removal gate)" @@ -746,6 +746,18 @@ "date": "2026-10-27", "change": "Checklist-accuracy findings of the 18.2.0 wave 2 (QA run #26456, release-18.2.0-w2-manual-browser, at 756220c4; wave anchor #25807, slice A). steps[4] and acceptance[3] named a delivery column `lastStatusCode` that no code defines: the sys_http_delivery column is response_code (packages/services/service-messaging/src/objects/http-delivery.object.ts). steps[1] names the activation row's `active` column beside the door's `enabled`. steps[4] says a `$filter` is JSON. steps[6] names the 4xx receiver a terminal row needs (http-sender.ts: 4xx except 408/429 is permanent, the rest retries). steps[7] and acceptance[6].verify stop citing a public read of 'the declaration still reads switched on': the meta read answers isActive:false by design and the run read the switch off a post-reboot delivery. acceptance[5].verify names the requires pair a scratch webhook stack needs. knownGaps[1] records that #23166, the blocked.ref the run cited, is a closed run record and no open card carries its maintainer question, and adds the wave-2 re-measure. Read at base e4ba846f. No clause's assertion changed", "ref": "#25807" + }, + { + "revision": 12, + "date": "2026-10-29", + "change": "the redeliver clause's posture sentence follows the door's hardening: redelivery is gated on webhook-administration access (update on sys_webhook, decided by the security service before the body is read), so the clause is driven as the seeded admin, and an unentitled caller's 403 / an undecidable check's 503 are named; the source line names the gate. The admin-driven assertions are unchanged", + "ref": "claude/harden-webhook-redeliver-door" + }, + { + "revision": 13, + "date": "2026-10-29", + "change": "contract-review follow-up on the same hardening: acceptance[4].verify no longer cites a no-capability posture in the route's docblock (the docblock states the gate), and names where the gate and the replay's organization are stated and pinned; steps[6] drives the redeliver as the seeded admin, not as any authenticated user; acceptance[4] names the walled-posture refusal of an organization claim no current membership backs, vetted as the data API vets it. The admin-driven assertions are unchanged", + "ref": "#27474" } ], "enumSource": { @@ -2295,7 +2307,7 @@ "title": "Messaging and integration answers on v18: a notify from a system-context run stores the organization of the record it is about (never NULL), mark-read writes a receipt only for notifications delivered to the caller, redelivering for an inactive or deleted webhook subscription is 409 DELIVERY_NOT_ELIGIBLE, and a declarative MCP stdio connector with a relative command boots healthy from another directory", "since": "v18", "status": "active", - "revision": 3, + "revision": 4, "priority": "P2", "surface": "api", "personas": [ @@ -2313,7 +2325,7 @@ "steps": [ "fire the notify flow from a system-context run; GET /api/v1/data/sys_notification and read organization_id", "as the member POST /api/v1/notifications/read with the ADMIN's notification id; read readCount", - "POST /api/v1/webhooks/redeliver for that terminal delivery of the now-inactive webhook. ⛔ Not for a delivery still 'pending': it answers DELIVERY_NOT_ELIGIBLE too, for its non-terminal status rather than the webhook's switch (packages/services/service-messaging/src/http-outbox.ts#assertHttpRedeliverable), so it would read as a pass for the wrong reason", + "as the seeded admin (the door is gated on webhook-administration access), POST /api/v1/webhooks/redeliver for that terminal delivery of the now-inactive webhook. ⛔ Not for a delivery still 'pending': it answers DELIVERY_NOT_ELIGIBLE too, for its non-terminal status rather than the webhook's switch (packages/services/service-messaging/src/http-outbox.ts#assertHttpRedeliverable), so it would read as a pass for the wrong reason", "from the repo root run `os verify --app examples/app-showcase` (or boot from another cwd) and read the MCP connector health — no live clause scores this leg, only acceptance[0]'s pin (mcp-stdio-cwd.test.ts), so record the reading as corroboration, never as a clause verdict (QA run #24558)" ], "acceptance": [ @@ -2344,7 +2356,7 @@ ], "automated": { "kind": "unit", - "ref": "packages/services/service-messaging/src/notification-organization-follows-event.integration.test.ts + packages/services/service-messaging/src/messaging-service.test.ts + packages/plugins/plugin-webhooks/src/webhook-redeliver-member.test.ts + packages/connectors/connector-mcp/src/mcp-stdio-cwd.test.ts" + "ref": "packages/services/service-messaging/src/notification-organization-follows-event.integration.test.ts + packages/services/service-messaging/src/messaging-service.test.ts + packages/plugins/plugin-webhooks/src/webhook-redeliver-member.test.ts + packages/plugins/plugin-webhooks/src/webhook-redeliver-entitlement.test.ts + packages/connectors/connector-mcp/src/mcp-stdio-cwd.test.ts" }, "source": [ "packages/services/service-messaging/src/messaging-service.ts", @@ -2370,6 +2382,12 @@ "date": "2026-10-26", "change": "acceptance[1], [2] and [3] verify name the layer each pin drives — checklist-accuracy findings of QA run #25815 (18.2.0 sweep, release-18.2.0-tier1, 6679644a; wave anchor #25807; card #25844). a1: notification-organization-follows-event.integration.test.ts proves organization_id at the engine and asserts nothing about the '[notify] no organization in scope' line (whose source is gone on main 9753d163). a2: the foreign mark-read is pinned only at unit level (messaging-service.test.ts). a3: the redeliver pin drives Request→Response on a LiteKernel, not a served stack. No clause changed", "ref": "#25844" + }, + { + "revision": 4, + "date": "2026-10-29", + "change": "steps[2] names its persona: the redeliver door is now gated on webhook-administration access, so the inactive-webhook redeliver is driven as the seeded admin (an unentitled caller is refused 403 before any row-specific detail). automated.ref adds webhook-redeliver-entitlement.test.ts, which pins both sides of that gate. The admin-driven clause is unchanged", + "ref": "claude/harden-webhook-redeliver-door" } ] } diff --git a/packages/plugins/plugin-webhooks/src/in-process-session-read.pin.test.ts b/packages/plugins/plugin-webhooks/src/in-process-session-read.pin.test.ts index a95c206c0dd..28f04f05ba9 100644 --- a/packages/plugins/plugin-webhooks/src/in-process-session-read.pin.test.ts +++ b/packages/plugins/plugin-webhooks/src/in-process-session-read.pin.test.ts @@ -21,6 +21,7 @@ import { describe, it, expect } from 'vitest'; import { WebhookOutboxPlugin } from './webhook-outbox-plugin.js'; +import { webhookAdministrationSecurity } from './redeliver-entitlement.testkit.js'; const SESSION_COOKIE = 'better-auth.session_token=tok_22258.c2lnbmF0dXJl'; const BEARER = 'Bearer tok_22258.c2lnbmF0dXJl'; @@ -46,6 +47,8 @@ async function redeliver(headers: Record) { return { id, status: 'pending' }; }, }, + // The entitlement gate: every signed-in caller here administers webhooks. + security: webhookAdministrationSecurity(), auth: { api: { getSession: async (input: any) => { diff --git a/packages/plugins/plugin-webhooks/src/redeliver-entitlement.testkit.ts b/packages/plugins/plugin-webhooks/src/redeliver-entitlement.testkit.ts new file mode 100644 index 00000000000..7fa98940c35 --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/redeliver-entitlement.testkit.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A `security` service double for the redeliver door's entitlement gate + * (`redeliver-entitlement.ts`): it answers `explain` the way the real service + * answers the one question the gate asks — may this caller `update` + * `sys_webhook`? — for the users it is told are webhook administrators, and + * records every question it was asked. + * + * A suite whose subject is NOT entitlement (tenant scope, the veto, the auth + * gate, the session read) registers {@link webhookAdministrationSecurity} with + * no argument, so every signed-in caller in it is an administrator and the + * suite drives the door's admitted path exactly as it did before the gate. + */ + +export interface AskedEntitlement { + readonly object: string; + readonly operation: string; + readonly userId: unknown; + /** The organization the caller's context was resolved in when the gate asked. */ + readonly tenantId: unknown; +} + +export interface WebhookAdministrationSecurityDouble { + readonly asked: AskedEntitlement[]; + explain(request: { object: string; operation: string }, context?: { userId?: unknown; tenantId?: unknown }): Promise; +} + +/** + * @param isAdministrator which signed-in users may administer webhooks; + * omitted, every one may. + */ +export function webhookAdministrationSecurity( + isAdministrator: (userId: unknown) => boolean = () => true, +): WebhookAdministrationSecurityDouble { + const asked: AskedEntitlement[] = []; + return { + asked, + async explain(request, context) { + asked.push({ + object: request.object, + operation: request.operation, + userId: context?.userId, + tenantId: context?.tenantId, + }); + const allowed = request.object === 'sys_webhook' + && request.operation === 'update' + && isAdministrator(context?.userId); + return { allowed, layers: [] }; + }, + }; +} diff --git a/packages/plugins/plugin-webhooks/src/redeliver-entitlement.ts b/packages/plugins/plugin-webhooks/src/redeliver-entitlement.ts new file mode 100644 index 00000000000..ea5e9bd6612 --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/redeliver-entitlement.ts @@ -0,0 +1,268 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Who may press the webhook redeliver door (`POST /api/v1/webhooks/redeliver`, + * both faces): a caller entitled to ADMINISTER WEBHOOKS, decided server-side + * (ADR-0124 D1) by the security service, before the door reads the request + * body or looks up any delivery row — and the organization the replay is + * scoped to is the one that caller was admitted in. + * + * ## The entitlement, and why this one + * + * A redelivery sends a finished delivery's signed payload to the + * subscriber's endpoint again. That is a webhook-administration act — the + * same power as editing or re-activating the subscription — so it is gated on + * the access that governs webhook administration: an object-level `update` + * admission on the webhook configuration object (`sys_webhook`), exactly as + * the security service answers it for that caller on the data API. + * + * Two readings were weighed and refused: + * + * - **Signed in is enough.** The door's original posture, written as a + * deferral ("finer RBAC is future work"), never as a decision. It left the + * door unaligned with webhook administration, and answered row-level + * refusals to callers who administer nothing. + * - **Read access to `sys_http_delivery`.** The object the button sits on, + * but READ is the wrong axis for an act that writes the row and sends a + * request: a read-only principal would gain a write-shaped action. + * + * ## The question is asked through the contract, never re-derived + * + * `ISecurityService.explain` is the contract's non-optional member that + * answers "would enforcement allow this operation for this caller?", computed + * by the same resolution the enforcement path uses. The caller's + * `ExecutionContext` is resolved by `resolveRequestExecutionContext`, the one + * composition every platform door uses, from the session the door ALREADY + * read (`session: { established }`) — so the identity the gate judges and the + * identity the rest of the door acts as come from one read and cannot + * disagree. + * + * ## The organization: vetted by the composition, and the replay's scope + * + * The composition is handed the door's own way to reach the `tenancy` + * service ({@link redeliverTenancyResolver}), as the data API's doors hand it + * theirs, so the session's organization claim is vetted exactly as it is + * there (maintainer ruling 2026-09-05, option B: on a walled posture a claim + * no current membership backs is dropped, per request, on every door). The + * caller's grants and the context's `tenantId` are therefore resolved in the + * organization the data API would resolve them in. + * + * The admission carries that vetted `tenantId`, and the door threads IT into + * the replay — never the raw session claim — so the organization the replay + * writes into is the one the caller was admitted in. + * + * A claim the composition did NOT keep is refused here, with the same answer + * as every other caller-level refusal. The replay is scoped by one + * organization (the tenant the driver writes under); a dropped claim leaves + * none, and a replay with none is an unscoped write, wider than any write the + * data API's gate gives that caller (with no active organization the tenant + * layer denies every row under `isolated`, and admits only the caller's own + * organizations under `group`, a set this door's single-tenant scope cannot + * express). The PRINCIPAL is not refused: the session stays signed in, with + * no active organization, free to switch to one it belongs to — option B's + * consequence is unchanged; only this door's act, which needs an + * organization, is declined. + * + * ## Fail direction — CLOSED, and each state says which it is (ADR-0124 D3) + * + * - an `allowed: false` verdict (or a context the composition could not + * resolve for the caller, or a claim it did not keep) is + * `403 PERMISSION_DENIED`; + * - no security service (none registered, or one without `explain`) refuses + * with `503 SERVICE_UNAVAILABLE`: there is nothing to decide entitlement, + * and a loud refusal is preferred over a silent admit; + * - a resolution or decision that THROWS reached no verdict, so it does not + * answer as one: `503 SERVICE_UNAVAILABLE`, the cause in the server log. + * A `tenancy` service that is registered and cannot answer is one of + * these (`classifyAdmissionTenancyPosture` raises it as the outage). + * + * Every refusal here is about the CALLER, never about a row: the gate runs + * before the body is read, so no answer it gives depends on the delivery id. + */ + +import { + resolveRequestExecutionContext, + type PluginContext, + type RequestAuthService, + type TenancyPostureSource, + type TenancyServiceResolver, +} from '@objectstack/core'; +import type { ISecurityService } from '@objectstack/spec/contracts'; + +/** The webhook configuration object whose administration governs redelivery. */ +export const REDELIVER_ADMINISTERED_OBJECT = 'sys_webhook'; + +/** The operation on {@link REDELIVER_ADMINISTERED_OBJECT} the caller must be admitted to. */ +export const REDELIVER_REQUIRED_OPERATION = 'update'; + +/** A refusal the door answers in its own envelope. */ +export interface RedeliverRefusal { + readonly status: 403 | 503; + readonly code: 'PERMISSION_DENIED' | 'SERVICE_UNAVAILABLE'; + readonly message: string; +} + +/** + * The gate's answer: an admission carrying the organization the caller was + * admitted in (the replay's scope), or a refusal the door answers as it is. + */ +export type RedeliverVerdict = + | { readonly admitted: true; readonly tenantId: string | undefined } + | { readonly admitted: false; readonly refusal: RedeliverRefusal }; + +/** What the gate reads, every key required so a caller cannot drop one by omission. */ +export interface RedeliverEntitlementInput { + /** The request's headers (the composition's input; no credential is re-read from them). */ + readonly headers: Headers; + /** The data engine, or `undefined` when the kernel has none. */ + readonly ql: unknown; + /** The kernel's `auth` service, or `undefined`. */ + readonly authService: RequestAuthService | undefined; + /** + * How this door reaches the `tenancy` service ({@link redeliverTenancyResolver}), + * handed to the composition so the session's organization claim is vetted + * against the posture in force. `undefined` only for a host that cannot + * reach a `tenancy` service at all. + */ + readonly tenancy: TenancyServiceResolver | undefined; + /** The kernel's `security` service, or `undefined` when none is registered. */ + readonly security: Pick | undefined; + /** The signed-in caller, from the session the door already read. */ + readonly userId: string; + /** That session's active organization claim, as the door read it (unvetted). */ + readonly activeOrganizationId: string | undefined; + /** Where a fault that reached no verdict is reported. */ + readonly logError: (message: string, err: unknown) => void; +} + +const NOT_ENTITLED: RedeliverRefusal = { + status: 403, + code: 'PERMISSION_DENIED', + message: 'Redelivering a webhook delivery requires permission to administer webhooks ' + + '(edit access to sys_webhook). Ask an administrator to grant it, or to redeliver for you.', +}; + +const NO_DECIDER: RedeliverRefusal = { + status: 503, + code: 'SERVICE_UNAVAILABLE', + message: 'Webhook redelivery is unavailable: no security service is registered to decide who may ' + + 'administer webhooks. Compose the security plugin to enable redelivery.', +}; + +const UNDECIDED: RedeliverRefusal = { + status: 503, + code: 'SERVICE_UNAVAILABLE', + message: 'Webhook redelivery is temporarily unavailable: permission to administer webhooks could not be ' + + 'checked. Retry shortly.', +}; + +const refuse = (refusal: RedeliverRefusal): RedeliverVerdict => ({ admitted: false, refusal }); + +/** + * Whether a security service can answer the gate's question. The door asks it + * per request; the plugin also asks it once at boot, to say that a door with + * nothing to decide will answer `503` to every caller (absence must be loud). + */ +export function canDecideRedeliverEntitlement(security: unknown): boolean { + return typeof (security as Pick | undefined)?.explain === 'function'; +} + +/** + * How the redeliver door reaches the `tenancy` service, read PER REQUEST and + * classified by the composition (`classifyAdmissionTenancyPosture`): never + * registered is a kernel with no organization wall (quiet); registered and + * unable to answer is the outage (the gate's `503`). + * + * Two legs, because only the ASYNC accessor carries that discriminator — the + * branded "never registered" rejection is raised by `PluginLoader.getService`, + * which the sync accessor never reaches, and on `ObjectKernel` the sync + * accessor throws for a factory-backed service that is merely not built yet. + * + * - A kernel with `getServiceAsync` (`ObjectKernel`, every shipped + * composition): the thunk is that accessor, so the classification sees the + * registry's own answer. ⛔ Never the plugin's swallowing `tryGetService` + * here: it would read a failed service as an absent one, the + * permissive-on-failure collapse the classification exists to prevent. + * - A kernel without it (a `KernelBase`-shaped host such as `LiteKernel`): + * such a host instantiates no service factories at all + * (`registerServiceFactory` throws there), so "nothing is registered under + * that name" is the only fault its sync accessor can report, and absorbing + * it inside the thunk is the SAME classification, not a second collapse. + */ +export function redeliverTenancyResolver( + ctx: Pick, +): TenancyServiceResolver | undefined { + const kernel = typeof ctx.getKernel === 'function' + ? (ctx.getKernel() as unknown as { getServiceAsync?: (name: string) => Promise } | undefined) + : undefined; + if (kernel && typeof kernel.getServiceAsync === 'function') { + const getServiceAsync = kernel.getServiceAsync.bind(kernel); + return () => getServiceAsync('tenancy'); + } + if (typeof ctx.getService !== 'function') return undefined; + return () => { + try { + return ctx.getService('tenancy') ?? undefined; + } catch { + // The one fault a factory-less host's sync accessor reports: not registered. + return undefined; + } + }; +} + +/** + * Decide whether the signed-in caller may redeliver, and in which + * organization. Never throws. + */ +export async function judgeRedeliverEntitlement( + input: RedeliverEntitlementInput, +): Promise { + const security = input.security; + if (!security || !canDecideRedeliverEntitlement(security)) return refuse(NO_DECIDER); + try { + const context = await resolveRequestExecutionContext({ + headers: input.headers, + ql: input.ql, + authService: input.authService, + // The door's own resolver, as the data API's doors pass theirs: + // the session arm vets the organization claim against the posture. + tenancy: input.tenancy, + anonymous: 'fail-closed', + // The door asks the ADR-0069 gate itself, on the same session read. + authGate: 'withhold', + // The door opens no API-key door: a key-only request reads no + // session and is answered 401 before this gate. + credentials: 'session-only', + sessionApi: 'optional', + session: { + established: { + user: { id: input.userId }, + session: { + userId: input.userId, + ...(input.activeOrganizationId ? { activeOrganizationId: input.activeOrganizationId } : {}), + }, + }, + }, + localization: 'withhold', + requestLocale: undefined, + accessToken: 'withhold', + oauth: undefined, + onAdmission: undefined, + }); + if (!context || context.userId !== input.userId) return refuse(NOT_ENTITLED); + const tenantId = typeof context.tenantId === 'string' && context.tenantId.length > 0 + ? context.tenantId + : undefined; + // A claim the composition did not keep: the replay would have no + // organization to be scoped to (see the module docblock). + if (tenantId !== input.activeOrganizationId) return refuse(NOT_ENTITLED); + const decision = await security.explain( + { object: REDELIVER_ADMINISTERED_OBJECT, operation: REDELIVER_REQUIRED_OPERATION }, + context, + ); + return decision?.allowed === true ? { admitted: true, tenantId } : refuse(NOT_ENTITLED); + } catch (err) { + input.logError('[webhook-outbox] redeliver entitlement could not be decided; the caller was refused 503', err); + return refuse(UNDECIDED); + } +} diff --git a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts index 1ca4e826143..c13b3ae5567 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts @@ -3,15 +3,21 @@ import type { Plugin, PluginContext } from '@objectstack/core'; // ADR-0069 — the authentication-policy gate's ONE decision, asked by the // redeliver door on the session it already reads (see `serveRedeliver`). -import { evaluateAuthGate } from '@objectstack/core'; +import { evaluateAuthGate, type RequestAuthService } from '@objectstack/core'; import type { AuthSessionApi, IDataEngine, II18nService, IMetadataService, IRealtimeService, + ISecurityService, IWebhookService, } from '@objectstack/spec/contracts'; +import { + canDecideRedeliverEntitlement, + judgeRedeliverEntitlement, + redeliverTenancyResolver, +} from './redeliver-entitlement.js'; import type { EnqueueHttpInput } from '@objectstack/service-messaging'; import { inProcessSessionReadInput } from '@objectstack/types'; import { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js'; @@ -296,9 +302,10 @@ export class WebhookOutboxPlugin implements Plugin { // step above, and never on the auto-enqueue prerequisites. It // runs before the enqueuer writes its first row (#8069), and // before the mount below is mounted. - this.bootRedeliverDoor(ctx, autoEnqueueOpt); + const servedAsService = this.bootRedeliverDoor(ctx, autoEnqueueOpt); await this.bootAutoEnqueue(ctx, autoEnqueueOpt); - this.registerAdminRoutes(ctx); + const mounted = this.registerAdminRoutes(ctx); + if (servedAsService || mounted) this.reportRedeliverEntitlementDecider(ctx); }); } @@ -519,8 +526,11 @@ export class WebhookOutboxPlugin implements Plugin { * * `subscriptionsObject` is read from the auto-enqueue options, as before, * so a host that renamed its subscriptions object keeps the veto reading it. + * + * Returns whether the `webhooks` service was registered (the door's + * service face is served). */ - private bootRedeliverDoor(ctx: PluginContext, opt: boolean | AutoEnqueuerOptions): void { + private bootRedeliverDoor(ctx: PluginContext, opt: boolean | AutoEnqueuerOptions): boolean { const engine = this.tryGetService(ctx, ['objectql', 'data']); const messaging = this.getMessaging(ctx); if (!engine || !messaging) { @@ -529,16 +539,43 @@ export class WebhookOutboxPlugin implements Plugin { + '— the data engine or the messaging service is not available', { hasEngine: !!engine, hasMessaging: !!messaging }, ); - return; + return false; } const subscriptionsObject = typeof opt === 'object' ? opt.subscriptionsObject : undefined; - if (!this.installRedeliverGuard(ctx, messaging, engine, subscriptionsObject)) return; + if (!this.installRedeliverGuard(ctx, messaging, engine, subscriptionsObject)) return false; const service: IWebhookService = { handleRedeliver: (request: Request) => this.serveRedeliver(ctx, messaging, requestDoorContext(request)), }; ctx.registerService(WEBHOOKS_SLOT, service); + return true; + } + + /** + * Absence must be loud: a redeliver door that is served (on either face) + * on a kernel with no security service to decide webhook administration + * answers `503 SERVICE_UNAVAILABLE` to EVERY caller + * ({@link judgeRedeliverEntitlement}). Said once, at `kernel:ready` — after + * every plugin's `init()` and `start()`, so the registry this reads is + * complete — naming the remedy, rather than left to be discovered one + * request at a time in the server log. + * + * `warn`, not `error`, by AGENTS.md's one question: the door is visibly + * unavailable (every press answers 503 with the remedy), and nothing that + * claims to persist goes missing. The door still decides per request, so a + * security service that is present answers as usual whatever this said. + */ + private reportRedeliverEntitlementDecider(ctx: PluginContext): void { + const security = this.tryGetService(ctx, ['security']); + if (canDecideRedeliverEntitlement(security)) return; + ctx.logger.warn?.( + '[webhook-outbox] no security service is registered to decide who may administer webhooks, ' + + 'so the redeliver door (POST /api/v1/webhooks/redeliver and the webhooks service\'s ' + + 'handleRedeliver) answers 503 SERVICE_UNAVAILABLE to every caller. Fix: compose the ' + + 'security plugin (@objectstack/plugin-security).', + { hasSecurityService: security !== undefined }, + ); } /** @@ -569,7 +606,7 @@ export class WebhookOutboxPlugin implements Plugin { + 'pressing redeliver may send a delivery that can no longer be authenticated ' + '(an unsigned send, which webhook delivery otherwise never makes). The POST ' + '/api/v1/webhooks/redeliver endpoint is reachable by any ' - + 'authenticated user; the webhooks service is not registered. Fix: upgrade ' + + 'caller entitled to administer webhooks; the webhooks service is not registered. Fix: upgrade ' + '@objectstack/service-messaging to a build that implements registerRedeliverGuard.', ); return false; @@ -607,26 +644,32 @@ export class WebhookOutboxPlugin implements Plugin { * because `getService` throws on an empty slot. Where both names are * registered (`plugin-hono-server` registers one server under both), the * first answers, so the path is mounted once. + * + * Returns whether the path was mounted. */ - private registerAdminRoutes(ctx: PluginContext): void { + private registerAdminRoutes(ctx: PluginContext): boolean { const http = this.tryGetService(ctx, ['http.server', 'http-server']); if (!http || typeof http.getRawApp !== 'function') { ctx.logger.debug?.('[webhook-outbox] HTTP server not available; redeliver endpoint not mounted'); - return; + return false; } const rawApp = http.getRawApp(); const messaging = this.getMessaging(ctx); - if (!rawApp || !messaging) return; + if (!rawApp || !messaging) return false; rawApp.post('/api/v1/webhooks/redeliver', async (c: any) => this.serveRedeliver(ctx, messaging, c)); ctx.logger.info?.('[webhook-outbox] redeliver endpoint mounted at POST /api/v1/webhooks/redeliver'); + return true; } /** * The redeliver door: `POST /api/v1/webhooks/redeliver`. Delegates to * `messaging.redeliverHttp(deliveryId, …)`. Auth is the better-auth session - * cookie — every authenticated user counts. + * cookie, and a signed-in caller must also be entitled to ADMINISTER + * WEBHOOKS ({@link judgeRedeliverEntitlement}, `redeliver-entitlement.ts`): + * asked right after authentication and before the body is read, so an + * unentitled caller is answered `403` before any row-specific detail. * * [#22756] ONE body, two faces. The self-hosted mount * ({@link registerAdminRoutes}) hands it the Hono context; the `webhooks` @@ -642,6 +685,10 @@ export class WebhookOutboxPlugin implements Plugin { * 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. + * The tenant threaded is the organization the entitlement gate ADMITTED the + * caller in (the composition's vetted `tenantId`), never the raw session + * claim: on a walled posture a claim no current membership backs is + * dropped there, and the gate refuses rather than replay with no scope. * * ⚠️ A session with no active organization threads `undefined`, and the * driver's tenant-audit line then fires for that write. That is deliberate: @@ -678,6 +725,34 @@ export class WebhookOutboxPlugin implements Plugin { 401, ); } + // [ADR-0124 D1] Entitlement, server-side, before the body is read and + // before any row is looked up: every refusal from here is about the + // caller, never about a delivery. + // + // [#10740] `session.session.activeOrganizationId` is the canonical + // spelling of the caller's active organization CLAIM (better-auth's + // organization plugin; see `plugin-auth/auth-schema-config.ts`). Read + // from the one place it lives — a `??` chain over alternative + // spellings would make a MISSING organization indistinguishable from + // a differently-shaped one, and the missing case is the one that must + // stay visible. The gate vets it; the replay below uses the verdict's. + const activeOrgClaim = session?.session?.activeOrganizationId; + const verdict = await judgeRedeliverEntitlement({ + headers: c.req.raw.headers, + ql: this.tryGetService(ctx, ['objectql', 'data']), + authService: this.tryGetService(ctx, ['auth']), + tenancy: redeliverTenancyResolver(ctx), + security: this.tryGetService>(ctx, ['security']), + userId, + activeOrganizationId: typeof activeOrgClaim === 'string' && activeOrgClaim.length > 0 + ? activeOrgClaim + : undefined, + logError: (message, err) => ctx.logger.error?.(message, err as Error), + }); + if (!verdict.admitted) { + const { refusal } = verdict; + return c.json({ success: false, error: { code: refusal.code, message: refusal.message } }, refusal.status); + } let body: any; try { body = await c.req.json(); @@ -692,18 +767,9 @@ export class WebhookOutboxPlugin implements Plugin { ); } try { - // [#10740] `session.session.activeOrganizationId` is the - // canonical spelling of the caller's active organization - // (better-auth's organization plugin; see - // `plugin-auth/auth-schema-config.ts`). Read from the one - // place it lives — a `??` chain over alternative spellings - // would make a MISSING organization indistinguishable from a - // differently-shaped one, and the missing case is the one that - // must stay visible. - const activeOrg = session?.session?.activeOrganizationId; - const tenantId = typeof activeOrg === 'string' && activeOrg.length > 0 - ? activeOrg - : undefined; + // [#10740] The organization the gate admitted the caller in — the + // composition's vetted `tenantId` — is the replay's scope. + const tenantId = verdict.tenantId; const row = await messaging.redeliverHttp(deliveryId, { tenantId }); ctx.logger.info?.('[webhook-outbox] redelivered', { deliveryId, requestedBy: userId, tenantId }); return c.json({ success: true, data: { id: row.id, status: row.status } }); diff --git a/packages/plugins/plugin-webhooks/src/webhook-redeliver-auth-gate.pin.test.ts b/packages/plugins/plugin-webhooks/src/webhook-redeliver-auth-gate.pin.test.ts index 80cd0a93cca..615065298cf 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-redeliver-auth-gate.pin.test.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-redeliver-auth-gate.pin.test.ts @@ -42,6 +42,7 @@ import type { IWebhookService } from '@objectstack/spec/contracts'; import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; import { MemoryHttpOutbox, MessagingService } from '@objectstack/service-messaging'; import { WebhookOutboxPlugin } from './webhook-outbox-plugin.js'; +import { webhookAdministrationSecurity } from './redeliver-entitlement.testkit.js'; const PATH = '/api/v1/webhooks/redeliver'; const ORIGIN = 'http://webhooks.test'; @@ -92,6 +93,8 @@ class FixturePlugin implements Plugin { async resolveSecretField() { return null; }, }); ctx.registerService('messaging', this.messaging); + // The entitlement gate: every signed-in caller here administers webhooks. + ctx.registerService('security', webhookAdministrationSecurity()); ctx.registerService('auth', { api: { async getSession(input: { headers: Headers }) { diff --git a/packages/plugins/plugin-webhooks/src/webhook-redeliver-entitlement.test.ts b/packages/plugins/plugin-webhooks/src/webhook-redeliver-entitlement.test.ts new file mode 100644 index 00000000000..9808d6dce85 --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/webhook-redeliver-entitlement.test.ts @@ -0,0 +1,454 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The webhook redeliver door (`POST /api/v1/webhooks/redeliver`) is gated on + * webhook-administration access (`redeliver-entitlement.ts`), at the HTTP + * layer, on BOTH faces: the self-hosted raw-app mount and the `webhooks` + * slot's `handleRedeliver`. + * + * Driven on a real `LiteKernel` with the plugin's real boot, over the real + * `MessagingService` and `MemoryHttpOutbox`, so a replay is a real row reset + * and a refusal is observable as a row left alone. The `security` service is + * a double that answers the gate's one question (may this caller `update` + * `sys_webhook`?) for a named administrator, and records what it was asked. + * The caller's context is resolved by the real composition + * (`resolveRequestExecutionContext`), so the organization vetting is the real + * session arm, fed the fixture's `tenancy` service and `sys_member` rows. + * + * What this pins: + * 1. an entitled caller is admitted: the delivery is replayed, and the gate + * asked about the caller the session named, in its organization; + * 2. a signed-in caller who is not entitled is refused `403 + * PERMISSION_DENIED` with ONE answer whatever the request names — an + * existing delivery, another organization's, one whose subscription is + * inactive or gone, an unknown id, a malformed body — so no answer depends + * on a row, none carries a delivery id, subscription id or webhook name, + * and no row is touched; + * 3. control: the entitled caller still gets the row-level refusals + * (the inactive subscription's `409`), so the gate hides nothing from an + * administrator; + * 4. fail closed: no security service, or one whose decision throws, refuses + * `503 SERVICE_UNAVAILABLE` and replays nothing; the first is also said + * once at boot; + * 5. order: an anonymous caller is still `401` and the gate is never asked; + * 6. the organization claim is vetted against the tenancy posture, as on the + * data API: on a walled posture a claim no current membership backs is + * refused with the same answer as (2), nothing replayed; a backed claim is + * admitted and the replay is scoped to that organization; + * 7. the door's `tenancy` resolver, on a real `ObjectKernel`: never + * registered reads as no posture, registered-and-failing is the `503`. + */ + +import { randomUUID } from 'node:crypto'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Hono } from 'hono'; +import { LiteKernel, ObjectKernel } from '@objectstack/core'; +import type { Plugin, PluginContext } from '@objectstack/core'; +import type { IWebhookService } from '@objectstack/spec/contracts'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; +import { MemoryHttpOutbox, MessagingService } from '@objectstack/service-messaging'; +import { WebhookOutboxPlugin } from './webhook-outbox-plugin.js'; +import { judgeRedeliverEntitlement, redeliverTenancyResolver } from './redeliver-entitlement.js'; +import { webhookAdministrationSecurity, type WebhookAdministrationSecurityDouble } from './redeliver-entitlement.testkit.js'; + +const PATH = '/api/v1/webhooks/redeliver'; +const ORIGIN = 'http://webhooks.test'; +const ADMIN = 'u_admin'; +const MEMBER = 'u_member'; +const ORG_A = 'org_a'; +const ORG_B = 'org_b'; +const LIVE = 'wh_live'; +const LIVE_NAME = 'orders_hook'; +const INACTIVE = 'wh_off'; +const INACTIVE_NAME = 'paused_hook'; +const GONE = 'wh_gone'; +const NO_DECIDER_BOOT_LINE = 'no security service is registered to decide who may administer webhooks'; + +type SecurityShape = 'double' | 'absent' | 'throws'; + +interface Membership { + readonly user_id: string; + readonly organization_id: string; +} + +interface FixtureOptions { + readonly security?: SecurityShape; + /** The `tenancy` service's posture; omitted, no `tenancy` service is registered. */ + readonly posture?: 'single' | 'isolated' | 'group'; + /** The `sys_member` rows the permission store holds. */ + readonly memberships?: readonly Membership[]; +} + +/** Equality on every key of a `where`, the only shape the grant resolver issues here. */ +const matches = (row: Membership, where: Record | undefined) => + Object.entries(where ?? {}).every(([key, value]) => (row as unknown as Record)[key] === value); + +class FixturePlugin implements Plugin { + name = 'com.objectstack.service.messaging'; + type = 'standard' as const; + version = '1.0.0'; + readonly outbox = new MemoryHttpOutbox(); + readonly messaging = new MessagingService({ logger: { info() {}, warn() {}, error() {}, debug() {} } as any }); + readonly app = new Hono(); + readonly security: WebhookAdministrationSecurityDouble = webhookAdministrationSecurity((id) => id === ADMIN); + readonly redeliverCalls: Array<{ id: string; tenantId: unknown }> = []; + + constructor(private readonly options: FixtureOptions) { + this.messaging.setHttpOutbox(this.outbox); + } + + init(ctx: PluginContext): void { + const subscriptions = new Map>([ + [LIVE, { id: LIVE, name: LIVE_NAME, active: true }], + [INACTIVE, { id: INACTIVE, name: INACTIVE_NAME, active: false }], + ]); + const memberships = this.options.memberships ?? []; + ctx.registerService('manifest', { register: () => {}, list: () => [] }); + ctx.registerService('objectql', { + registry: { listItems: () => [], getObject: () => undefined }, + async find(object: string, query?: any) { + if (object !== 'sys_member') return []; + const rows = memberships.filter((row) => matches(row, query?.where)); + return typeof query?.limit === 'number' ? rows.slice(0, query.limit) : rows; + }, + async findOne(object: string, opts?: any) { + assertEngineFindOnePredicate(object, opts); + return subscriptions.get(opts?.where?.id) ?? null; + }, + registerHook() {}, + unregisterHooksByPackage() { return 0; }, + async resolveSecretField() { return null; }, + }); + const messaging = this.messaging; + const calls = this.redeliverCalls; + const realRedeliver = messaging.redeliverHttp.bind(messaging); + messaging.redeliverHttp = (async (id: string, options: any) => { + calls.push({ id, tenantId: options?.tenantId }); + return realRedeliver(id, options); + }) as typeof messaging.redeliverHttp; + ctx.registerService('messaging', messaging); + const shape = this.options.security ?? 'double'; + if (shape === 'double') ctx.registerService('security', this.security); + if (shape === 'throws') { + ctx.registerService('security', { + async explain() { throw new Error('permission store unreachable'); }, + }); + } + if (this.options.posture) ctx.registerService('tenancy', { posture: this.options.posture }); + ctx.registerService('auth', { + api: { + async getSession(input: { headers: Headers }) { + const match = /^Bearer (\w+)@(\w+)$/.exec(input.headers.get('authorization') ?? ''); + if (!match) return null; + return { user: { id: match[1] }, session: { userId: match[1], activeOrganizationId: match[2] } }; + }, + }, + }); + const app = this.app; + ctx.registerService('http.server', { getRawApp: () => app }); + } +} + +const openKernels: LiteKernel[] = []; +afterEach(async () => { + while (openKernels.length) { + try { await openKernels.pop()?.shutdown(); } catch { /* already stopped */ } + } +}); + +async function boot(options: FixtureOptions = {}) { + const fixture = new FixturePlugin(options); + const errors: string[] = []; + const warnings: string[] = []; + class ObservedPlugin extends WebhookOutboxPlugin { + override async init(ctx: PluginContext): Promise { + const originalError = ctx.logger.error?.bind(ctx.logger); + ctx.logger.error = ((message: unknown, ...rest: unknown[]) => { + errors.push(String(message)); + return originalError?.(message as any, ...(rest as [])); + }) as any; + const originalWarn = ctx.logger.warn?.bind(ctx.logger); + ctx.logger.warn = ((message: unknown, ...rest: unknown[]) => { + warnings.push(String(message)); + return originalWarn?.(message as any, ...(rest as [])); + }) as any; + await super.init(ctx); + } + } + const kernel = new LiteKernel({ logger: { level: 'silent' } as any }); + openKernels.push(kernel); + kernel.use(fixture); + kernel.use(new ObservedPlugin()); + await kernel.bootstrap(); + + const slot = kernel.getService('webhooks'); + const faces = { + member: (init: RequestInit) => slot.handleRedeliver!(new Request(`${ORIGIN}${PATH}`, init)), + mount: (init: RequestInit) => Promise.resolve(fixture.app.request(`${ORIGIN}${PATH}`, init)), + }; + + /** A delivery attempted once and dead-lettered: a row an administrator may replay. */ + const attempted = async (organizationId: string, refId = LIVE): Promise => { + const id = await fixture.outbox.enqueue({ + source: 'webhook', refId, dedupKey: randomUUID(), url: 'https://receiver.example/hook', + payload: { event: 'record.created' }, organizationId, + }); + const claimed = (await fixture.outbox.claim({ nodeId: 'node_1', limit: 100, claimTtlMs: 60_000 })) + .find((row) => row.id === id)!; + await fixture.outbox.ack( + id, + { success: false, dead: true, httpStatus: 500, error: 'HTTP 500', durationMs: 5 }, + { claimedBy: claimed.claimedBy!, claimedAt: claimed.claimedAt! }, + ); + return id; + }; + const statusOf = async (id: string) => (await fixture.outbox.list()).find((row) => row.id === id)?.status; + return { fixture, faces, attempted, statusOf, errors, warnings }; +} + +const post = (body: unknown, caller: string | null, organization: string = ORG_A): RequestInit => ({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(caller ? { Authorization: `Bearer ${caller}@${organization}` } : {}), + }, + body: typeof body === 'string' ? body : JSON.stringify(body), +}); + +async function read(res: Response) { + const text = await res.text(); + return { status: res.status, text, json: JSON.parse(text) }; +} + +describe('the redeliver door admits a caller entitled to administer webhooks', () => { + for (const face of ['member', 'mount'] as const) { + it(`${face}: an administrator replays a finished delivery, and the gate asked about that caller`, async () => { + const { fixture, faces, attempted, statusOf, warnings } = await boot(); + const id = await attempted(ORG_A); + + const answer = await read(await faces[face](post({ deliveryId: id }, ADMIN))); + + expect(answer.status).toBe(200); + expect(answer.json).toEqual({ success: true, data: { id, status: 'pending' } }); + expect(await statusOf(id)).toBe('pending'); + expect(fixture.security.asked).toEqual([ + { object: 'sys_webhook', operation: 'update', userId: ADMIN, tenantId: ORG_A }, + ]); + expect(fixture.redeliverCalls).toEqual([{ id, tenantId: ORG_A }]); + // A kernel with a security service says nothing about it at boot. + expect(warnings.filter((m) => m.includes(NO_DECIDER_BOOT_LINE))).toEqual([]); + }); + + it(`${face}: control — an administrator still gets the row-level refusal for an inactive subscription`, async () => { + const { faces, attempted, statusOf } = await boot(); + const id = await attempted(ORG_A, INACTIVE); + + const answer = await read(await faces[face](post({ deliveryId: id }, ADMIN))); + + expect(answer.status).toBe(409); + expect(answer.json).toMatchObject({ success: false, error: { code: 'DELIVERY_NOT_ELIGIBLE' } }); + expect(answer.text).toContain(INACTIVE_NAME); + expect(await statusOf(id)).toBe('dead'); + }); + } +}); + +describe('the redeliver door refuses a signed-in caller not entitled to administer webhooks', () => { + for (const face of ['member', 'mount'] as const) { + it(`${face}: 403 PERMISSION_DENIED, one answer whatever the request names, no row detail, nothing replayed`, async () => { + const { fixture, faces, attempted, statusOf } = await boot(); + const finished = await attempted(ORG_A); + const otherOrg = await attempted(ORG_B); + const inactive = await attempted(ORG_A, INACTIVE); + const gone = await attempted(ORG_A, GONE); + + const requests: Array<[string, RequestInit]> = [ + ['a finished delivery of the caller\'s organization', post({ deliveryId: finished }, MEMBER)], + ['another organization\'s delivery', post({ deliveryId: otherOrg }, MEMBER)], + ['a delivery whose subscription is inactive', post({ deliveryId: inactive }, MEMBER)], + ['a delivery whose subscription is gone', post({ deliveryId: gone }, MEMBER)], + ['an unknown delivery', post({ deliveryId: randomUUID() }, MEMBER)], + ['no deliveryId', post({}, MEMBER)], + ['a body that is not JSON', post('{deliveryId:', MEMBER)], + ]; + const answers = []; + for (const [label, init] of requests) { + const answer = await read(await faces[face](init)); + expect(answer.status, label).toBe(403); + expect(answer.json, label).toMatchObject({ success: false, error: { code: 'PERMISSION_DENIED' } }); + for (const detail of [finished, otherOrg, inactive, gone, LIVE, INACTIVE, GONE, LIVE_NAME, INACTIVE_NAME]) { + expect(answer.text, `${label} discloses ${detail}`).not.toContain(detail); + } + answers.push(answer.text); + } + // One answer for every request: nothing in it depends on a row. + expect(new Set(answers).size).toBe(1); + // Refused before any row was looked up, and no row was touched. + expect(fixture.redeliverCalls).toEqual([]); + for (const id of [finished, otherOrg, inactive, gone]) expect(await statusOf(id)).toBe('dead'); + }); + } + + it('an anonymous caller is still 401 UNAUTHENTICATED, and the gate is never asked', async () => { + const { fixture, faces, attempted } = await boot(); + const id = await attempted(ORG_A); + const answer = await read(await faces.member(post({ deliveryId: id }, null))); + expect(answer.status).toBe(401); + expect(answer.json).toMatchObject({ success: false, error: { code: 'UNAUTHENTICATED' } }); + expect(fixture.security.asked).toEqual([]); + }); +}); + +describe('the redeliver door fails closed when entitlement cannot be decided', () => { + for (const [shape, label] of [['absent', 'no security service is registered'], ['throws', 'the decision throws']] as const) { + for (const face of ['member', 'mount'] as const) { + it(`${face}: ${label} → 503 SERVICE_UNAVAILABLE, nothing replayed`, async () => { + const { fixture, faces, attempted, statusOf, errors } = await boot({ security: shape }); + const id = await attempted(ORG_A); + + const answer = await read(await faces[face](post({ deliveryId: id }, ADMIN))); + + expect(answer.status).toBe(503); + expect(answer.json).toMatchObject({ success: false, error: { code: 'SERVICE_UNAVAILABLE' } }); + expect(answer.text).not.toContain(id); + expect(fixture.redeliverCalls).toEqual([]); + expect(await statusOf(id)).toBe('dead'); + if (shape === 'throws') { + expect(errors.some((m) => m.includes('redeliver entitlement could not be decided'))).toBe(true); + } + }); + } + } + + it('a kernel with no security service says so ONCE at boot, naming the remedy, though both faces are served', async () => { + const { warnings } = await boot({ security: 'absent' }); + const lines = warnings.filter((m) => m.includes(NO_DECIDER_BOOT_LINE)); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('503 SERVICE_UNAVAILABLE'); + expect(lines[0]).toContain('@objectstack/plugin-security'); + }); +}); + +describe('the redeliver door vets the organization claim against the tenancy posture, as the data API does', () => { + for (const face of ['member', 'mount'] as const) { + it(`${face}: isolated — a claim no current membership backs is refused with the caller-level answer, nothing replayed`, async () => { + // ADMIN would be admitted by the security double; only the vetting + // stands between. MEMBER's claim IS backed, so its refusal is the + // administration refusal the answer is compared against. + const { fixture, faces, attempted, statusOf } = await boot({ + posture: 'isolated', + memberships: [{ user_id: MEMBER, organization_id: ORG_A }], + }); + const id = await attempted(ORG_A); + + const refused = await read(await faces[face](post({ deliveryId: id }, ADMIN))); + const unentitled = await read(await faces[face](post({ deliveryId: id }, MEMBER))); + + expect(refused.status).toBe(403); + expect(refused.json).toMatchObject({ success: false, error: { code: 'PERMISSION_DENIED' } }); + // The same body as the refusal of a caller who administers nothing. + expect(refused.text).toBe(unentitled.text); + expect(refused.text).not.toContain(id); + expect(fixture.redeliverCalls).toEqual([]); + expect(await statusOf(id)).toBe('dead'); + }); + + it(`${face}: group — a claim on an organization the caller left is refused, for its rows and for the organization it still belongs to`, async () => { + const { fixture, faces, attempted, statusOf } = await boot({ + posture: 'group', + memberships: [ + { user_id: ADMIN, organization_id: ORG_B }, + { user_id: MEMBER, organization_id: ORG_A }, + ], + }); + const claimed = await attempted(ORG_A); + const stillMember = await attempted(ORG_B); + const unentitled = await read(await faces[face](post({ deliveryId: claimed }, MEMBER))); + + for (const id of [claimed, stillMember]) { + const answer = await read(await faces[face](post({ deliveryId: id }, ADMIN, ORG_A))); + expect(answer.status, id).toBe(403); + expect(answer.text, id).toBe(unentitled.text); + } + expect(fixture.redeliverCalls).toEqual([]); + for (const id of [claimed, stillMember]) expect(await statusOf(id)).toBe('dead'); + }); + + it(`${face}: control — a claim a current membership backs is admitted, and the replay is scoped to that organization`, async () => { + const { fixture, faces, attempted, statusOf } = await boot({ + posture: 'isolated', + memberships: [{ user_id: ADMIN, organization_id: ORG_A }], + }); + const own = await attempted(ORG_A); + const other = await attempted(ORG_B); + + const admitted = await read(await faces[face](post({ deliveryId: own }, ADMIN))); + expect(admitted.status).toBe(200); + expect(await statusOf(own)).toBe('pending'); + + const outside = await read(await faces[face](post({ deliveryId: other }, ADMIN))); + expect(outside.status).toBe(404); + expect(outside.json).toMatchObject({ success: false, error: { code: 'RESOURCE_NOT_FOUND' } }); + expect(await statusOf(other)).toBe('dead'); + + expect(fixture.redeliverCalls).toEqual([ + { id: own, tenantId: ORG_A }, + { id: other, tenantId: ORG_A }, + ]); + expect(fixture.security.asked.map((q) => q.tenantId)).toEqual([ORG_A, ORG_A]); + }); + } +}); + +describe('the door\'s tenancy resolver, on a real ObjectKernel registry', () => { + // The classification under test is the registry's own (branded "never + // registered" versus the unbranded rejection of a factory that threw), so + // the kernel is real and nothing at the seam is stubbed. + const kernelWith = (wiring: 'unregistered' | 'isolated' | 'factory-throws') => { + const kernel = new ObjectKernel({ skipSystemValidation: true, gracefulShutdown: false } as never); + if (wiring === 'isolated') kernel.registerService('tenancy', { posture: 'isolated' }); + if (wiring === 'factory-throws') { + kernel.registerServiceFactory('tenancy', () => { + throw new Error('tenancy backend unavailable'); + }); + } + return kernel; + }; + const judge = async (kernel: ObjectKernel) => { + const security = webhookAdministrationSecurity((id) => id === ADMIN); + const logged: string[] = []; + const ctx = { + getKernel: () => kernel, + getService: (name: string): T => kernel.getService(name), + } as unknown as PluginContext; + const verdict = await judgeRedeliverEntitlement({ + headers: new Headers(), + ql: { async find() { return []; } }, + authService: undefined, + tenancy: redeliverTenancyResolver(ctx), + security, + userId: ADMIN, + activeOrganizationId: ORG_A, + logError: (message) => logged.push(message), + }); + return { verdict, asked: security.asked, logged }; + }; + + it('never registered: no posture, so the claim stands and the caller is admitted in it', async () => { + const { verdict, asked } = await judge(kernelWith('unregistered')); + expect(verdict).toEqual({ admitted: true, tenantId: ORG_A }); + expect(asked.map((q) => q.tenantId)).toEqual([ORG_A]); + }); + + it('registered with a walled posture: the unbacked claim is dropped and refused 403', async () => { + const { verdict } = await judge(kernelWith('isolated')); + expect(verdict).toMatchObject({ admitted: false, refusal: { status: 403, code: 'PERMISSION_DENIED' } }); + }); + + it('registered and unable to answer: no verdict was reached, so 503, the cause logged, and the security service never asked', async () => { + const { verdict, asked, logged } = await judge(kernelWith('factory-throws')); + expect(verdict).toMatchObject({ admitted: false, refusal: { status: 503, code: 'SERVICE_UNAVAILABLE' } }); + expect(asked).toEqual([]); + expect(logged.some((m) => m.includes('redeliver entitlement could not be decided'))).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-webhooks/src/webhook-redeliver-member.test.ts b/packages/plugins/plugin-webhooks/src/webhook-redeliver-member.test.ts index 5248323afa5..71ea515f578 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-redeliver-member.test.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-redeliver-member.test.ts @@ -38,6 +38,7 @@ import type { IWebhookService } from '@objectstack/spec/contracts'; import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; import { MemoryHttpOutbox, MessagingService } from '@objectstack/service-messaging'; import { WebhookOutboxPlugin, type WebhookOutboxPluginOptions } from './webhook-outbox-plugin.js'; +import { webhookAdministrationSecurity } from './redeliver-entitlement.testkit.js'; const PATH = '/api/v1/webhooks/redeliver'; const ORIGIN = 'http://webhooks.test'; @@ -128,6 +129,8 @@ class FixturePlugin implements Plugin { } : messaging); const reads = this.sessionReads; + // The entitlement gate: every signed-in caller here administers webhooks. + ctx.registerService('security', webhookAdministrationSecurity()); ctx.registerService('auth', { api: { async getSession(input: { headers: Headers; query?: unknown }) { diff --git a/packages/plugins/plugin-webhooks/src/webhook-redeliver-tenant-scope.test.ts b/packages/plugins/plugin-webhooks/src/webhook-redeliver-tenant-scope.test.ts index 34cf5234276..4f893319fe1 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-redeliver-tenant-scope.test.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-redeliver-tenant-scope.test.ts @@ -4,10 +4,10 @@ * #10740 — `POST /api/v1/webhooks/redeliver` carries the CALLER'S tenant. * * This route is the reason `SqlHttpOutbox.redeliver` is classified - * request-contextual rather than as a dispatcher sweep: its auth gate is "any - * authenticated user", and `sys_http_delivery` is a tenant-scoped object. On a - * walled deployment (`OS_TENANCY_POSTURE=isolated`) an unscoped replay from - * here is an authenticated user writing another organization's delivery row — + * request-contextual rather than as a dispatcher sweep: a request reaches it + * (any caller entitled to administer webhooks), and `sys_http_delivery` is a + * tenant-scoped object. On a walled deployment (`OS_TENANCY_POSTURE=isolated`) + * an unscoped replay from here is a caller writing another organization's delivery row — * exactly what the driver's tenant audit exists to catch, and the one site * where silencing that audit would convert a detectable hole into an * undetectable one. @@ -28,6 +28,7 @@ import { describe, it, expect } from 'vitest'; import { WebhookOutboxPlugin } from './webhook-outbox-plugin.js'; +import { webhookAdministrationSecurity } from './redeliver-entitlement.testkit.js'; /** Captures the handler `registerAdminRoutes` mounts, and lets us call it. */ function mountRoute(opts: { @@ -49,6 +50,8 @@ function mountRoute(opts: { registerRedeliverGuard: () => {}, redeliverHttp: opts.redeliverHttp, }, + // The entitlement gate: every signed-in caller here administers webhooks. + security: webhookAdministrationSecurity(), auth: { api: { getSession: async () => opts.session } }, }; const ctx: any = { diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index 6502add51a9..b8faefcb3bc 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -393,8 +393,8 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ // owns. Its proofs are the plugin's member suite and the two-door wire suite, // neither of which lives in this directory; a dogfood proof driving the route // at its literal wire path is the honest upgrade, and would claim this row. - { id: 'webhook-redeliver-session', summary: 'Webhook redeliver door (`POST /webhooks/redeliver`, the operator button that replays a finished delivery) — any signed-in caller may press it, and only for a delivery row of their own active organization; an anonymous caller is refused by the member itself, exactly as the self-hosted mount refuses it', state: 'enforced', - enforcement: 'plugin-webhooks/webhook-outbox-plugin.ts serveRedeliver — the ONE body both faces run: the caller resolved from the request\'s own credentials through the auth service (no signed-in user: 401 UNAUTHENTICATED), the caller\'s active organization threaded into messaging.redeliverHttp as the tenant (a row outside it: 404 RESOURCE_NOT_FOUND), and the redelivery veto installed with messaging before the slot is registered (a row whose subscription or signing secret is gone: 409 DELIVERY_NOT_ELIGIBLE); reached through IWebhookService.handleRedeliver by runtime/domains/webhooks.ts, which forwards the transport\'s request UNREAD, resolves the slot per request and adds no credential of its own (an absent slot or member is a typed 501, never a route miss). The two dispatcher preamble gates apply to the exact path with NO exemption: core/security/auth-gate.ts ALLOW_ROUTES does not list it (a session owing an authentication step is refused 403 first) and runtime/http-dispatcher.ts enforceProjectMembership does not skip it (a signed-in non-member of the environment is refused 403 PROJECT_MEMBERSHIP_REQUIRED first). The self-hosted raw-app mount (plugin-webhooks registerAdminRoutes) runs the same body on the same outbox', + { id: 'webhook-redeliver-session', summary: 'Webhook redeliver door (`POST /webhooks/redeliver`, the operator button that replays a finished delivery) — a signed-in caller entitled to administer webhooks may press it, and only for a delivery row of their own active organization; a caller without that entitlement is refused 403 before any row-specific detail; an anonymous caller is refused by the member itself, exactly as the self-hosted mount refuses it', state: 'enforced', + enforcement: 'plugin-webhooks/webhook-outbox-plugin.ts serveRedeliver — the ONE body both faces run: the caller resolved from the request\'s own credentials through the auth service (no signed-in user: 401 UNAUTHENTICATED), then the webhook-administration entitlement asked of the security service before the body is read (redeliver-entitlement.ts: update on sys_webhook; refused: 403 PERMISSION_DENIED, undecidable: 503 SERVICE_UNAVAILABLE), the caller\'s active organization threaded into messaging.redeliverHttp as the tenant (a row outside it: 404 RESOURCE_NOT_FOUND), and the redelivery veto installed with messaging before the slot is registered (a row whose subscription or signing secret is gone: 409 DELIVERY_NOT_ELIGIBLE); reached through IWebhookService.handleRedeliver by runtime/domains/webhooks.ts, which forwards the transport\'s request UNREAD, resolves the slot per request and adds no credential of its own (an absent slot or member is a typed 501, never a route miss). The two dispatcher preamble gates apply to the exact path with NO exemption: core/security/auth-gate.ts ALLOW_ROUTES does not list it (a session owing an authentication step is refused 403 first) and runtime/http-dispatcher.ts enforceProjectMembership does not skip it (a signed-in non-member of the environment is refused 403 PROJECT_MEMBERSHIP_REQUIRED first). The self-hosted raw-app mount (plugin-webhooks registerAdminRoutes) runs the same body on the same outbox', covers: ['dispatcher-domain:route-ledger.ts:/webhooks/redeliver'], note: 'The DISPATCHER domain only: on a self-hosted kernel the same path is served from the raw Hono app and never reaches the dispatcher, which is why the route-ledger row is pinned unobservable on the showcase boot. Unit- and wire-proven, not dogfood-proven: plugin-webhooks webhook-redeliver-member.test.ts drives the member on a real kernel, messaging service and outbox (every refusal the contract declares, the tenant scope, the veto, byte parity with the mount); runtime domains/webhooks-redeliver.test.ts pins the domain on the real HttpDispatcher (the request forwarded unread, POST alone claimed, 501 for an absent slot or member, the slot read per request, a signed-in non-member and a gated session stopped before the member while a member of the environment and an anonymous caller reach it); http-conformance hono-webhooks-redeliver.conformance.test.ts drives the real plugin through createHonoApp and the self-hosted mount, where a replay lands in the outbox and every refusal, anonymous first, answers the same bytes at both doors.' }, diff --git a/packages/qa/http-conformance/src/hono-webhooks-redeliver.conformance.test.ts b/packages/qa/http-conformance/src/hono-webhooks-redeliver.conformance.test.ts index 3d4498227c3..d5d395c7483 100644 --- a/packages/qa/http-conformance/src/hono-webhooks-redeliver.conformance.test.ts +++ b/packages/qa/http-conformance/src/hono-webhooks-redeliver.conformance.test.ts @@ -107,6 +107,13 @@ class FixturePlugin implements Plugin { isHttpDeliveryReady: () => messaging.isHttpDeliveryReady(), redeliverHttp: (id: string, o: any) => messaging.redeliverHttp(id, o), }); + // The redeliver door's entitlement gate asks whether the caller may + // administer webhooks (update sys_webhook); every caller here may. + ctx.registerService('security', { + async explain(request: { object: string; operation: string }) { + return { allowed: request.object === 'sys_webhook' && request.operation === 'update', layers: [] }; + }, + }); ctx.registerService('auth', { api: { async getSession(input: { headers: Headers }) { diff --git a/packages/services/service-messaging/src/http-outbox.ts b/packages/services/service-messaging/src/http-outbox.ts index 56be2c3816a..7c03a1ea7d0 100644 --- a/packages/services/service-messaging/src/http-outbox.ts +++ b/packages/services/service-messaging/src/http-outbox.ts @@ -265,12 +265,13 @@ export type RedeliverGuard = ( * * ## Why the tenant is a REQUIRED property typed `string | undefined` * `redeliver` is the one door on `sys_http_delivery` that a request can reach: - * `POST /api/v1/webhooks/redeliver` is served to any authenticated user. The - * object is tenant-scoped (the kernel provisions `organization_id`), so on a - * walled deployment (`OS_TENANCY_POSTURE=isolated|group`) a `redeliver` that - * does not carry its caller's tenant is an unscoped write reachable by a user - * — precisely the finding the driver's `auditMissingTenant` gate exists to - * raise. + * `POST /api/v1/webhooks/redeliver`, served to a signed-in caller entitled to + * administer webhooks (`plugin-webhooks`' `redeliver-entitlement.ts`, which + * also vets the caller's organization). The object is tenant-scoped (the + * kernel provisions `organization_id`), so on a walled deployment + * (`OS_TENANCY_POSTURE=isolated|group`) a `redeliver` that does not carry its + * caller's tenant is an unscoped write reachable by a request — precisely the + * finding the driver's `auditMissingTenant` gate exists to raise. * * ⛔ The remedy for that finding is this field, and it is NEVER * `bypassTenantAudit`. Silencing the line would leave the hole and remove the diff --git a/packages/services/service-messaging/src/objects/http-delivery.object.ts b/packages/services/service-messaging/src/objects/http-delivery.object.ts index 403f8c2b7b2..2d130db6405 100644 --- a/packages/services/service-messaging/src/objects/http-delivery.object.ts +++ b/packages/services/service-messaging/src/objects/http-delivery.object.ts @@ -227,8 +227,8 @@ export const HttpDelivery = ObjectSchema.create({ // exactly that pair. It must, because a parked row carries no // `signature` (the secret that would have produced one is what went // missing) and `POST /api/v1/webhooks/redeliver` is reachable by any - // authenticated user — so without the refusal, recording the drop would - // hand that user a button that delivers the payload UNSIGNED, reopening + // webhook administrator — so without the refusal, recording the drop would + // hand that caller a button that delivers the payload UNSIGNED, reopening // #7799 through a door nobody would think to audit. attempts: Field.number({ label: 'Attempts', diff --git a/packages/services/service-messaging/src/sql-http-outbox.ts b/packages/services/service-messaging/src/sql-http-outbox.ts index 97628b775c6..5fc2718d35b 100644 --- a/packages/services/service-messaging/src/sql-http-outbox.ts +++ b/packages/services/service-messaging/src/sql-http-outbox.ts @@ -474,9 +474,11 @@ export class SqlHttpOutbox implements IHttpOutbox { * only site in this class that is tenant-SCOPED rather than declared * global. * - * `POST /api/v1/webhooks/redeliver` serves any authenticated user, so on a - * walled deployment the caller's organization is exactly the predicate - * this write is missing — and `options.tenantId` supplies it, to the reads + * `POST /api/v1/webhooks/redeliver` serves a signed-in caller entitled to + * administer webhooks, in the organization that caller was admitted in + * (`plugin-webhooks`' `redeliver-entitlement.ts`), so on a walled + * deployment the caller's organization is exactly the predicate this + * write is missing — and `options.tenantId` supplies it, to the reads * as well as the write. ⛔ `bypassTenantAudit` is NOT an option here: the * two produce the same silence in the log, and the audit line is the only * thing that would ever report this hole. See {@link RedeliverOptions}. diff --git a/scripts/adr-anchors/packages__plugins__plugin-webhooks__src__redeliver-entitlement.ts.json b/scripts/adr-anchors/packages__plugins__plugin-webhooks__src__redeliver-entitlement.ts.json new file mode 100644 index 00000000000..76fb84c5d67 --- /dev/null +++ b/scripts/adr-anchors/packages__plugins__plugin-webhooks__src__redeliver-entitlement.ts.json @@ -0,0 +1,7 @@ +{ + "file": "packages/plugins/plugin-webhooks/src/redeliver-entitlement.ts", + "adrs": [ + "ADR-0124" + ], + "invariant": "Who may press the webhook redeliver door (POST /api/v1/webhooks/redeliver, both faces) is decided SERVER-side (ADR-0124 D1), before the door reads the request body or looks up any delivery row: the security service's object-level `update` admission on sys_webhook, asked through ISecurityService.explain on the caller's ExecutionContext as the one request-context composition resolves it. That composition is handed the door's own `tenancy` resolver, so the session's organization claim is vetted against the posture in force exactly as on the data API (the per-request membership check of the 2026-09-05 ruling, option B), and the replay is scoped to the VETTED tenantId, never the raw claim; a claim the composition did not keep is refused with the caller-level 403, because a replay with no organization would be an unscoped write. A case the gate cannot decide is never admitted (ADR-0124 D3): no security service, a throwing decision, or a tenancy service that is registered and cannot answer is 503 SERVICE_UNAVAILABLE. Every refusal is about the caller and carries no row-specific detail. A client hiding the redeliver button is courtesy, never this gate." +} diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 9ca77140320..9824e626535 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -4111,6 +4111,11 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/plugins/plugin-webhooks/src/webhook-redeliver-entitlement.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/plugins/plugin-webhooks/src/webhook-redeliver-member.test.ts", "verb": "findOne",