From ba9a32bd766549ff80c68ed91a4021e96487e6fe Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 05:52:15 +0000 Subject: [PATCH 1/8] fix(plugin-webhooks): gate redelivery on webhook-administration access WIP: the redeliver door asks the security service, before the body is read, whether the signed-in caller may administer webhooks. Claude-Session: https://claude.ai/code/session_017xRBcSwDXZrZg7nGjP6P3U Co-authored-by: Claude --- .../src/redeliver-entitlement.ts | 155 ++++++++++++++++++ .../src/webhook-outbox-plugin.ts | 29 +++- 2 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 packages/plugins/plugin-webhooks/src/redeliver-entitlement.ts 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..3e57ffd226f --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/redeliver-entitlement.ts @@ -0,0 +1,155 @@ +// 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. + * + * ## 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 admitted + * a caller who may not read a single delivery or webhook row, and let that + * caller reach answers carrying row-specific detail. + * - **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. + * + * ## Fail direction — CLOSED, and each state says which it is + * + * - an `allowed: false` verdict (or a context the composition could not + * resolve for the caller) 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. + * + * 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 RequestAuthService } 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; +} + +/** 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; + /** 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, as the door read it. */ + 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.', +}; + +/** + * Decide whether the signed-in caller may redeliver: `undefined` admits, a + * {@link RedeliverRefusal} refuses. Never throws. + */ +export async function judgeRedeliverEntitlement( + input: RedeliverEntitlementInput, +): Promise { + const security = input.security; + if (!security || typeof security.explain !== 'function') return NO_DECIDER; + try { + const context = await resolveRequestExecutionContext({ + headers: input.headers, + ql: input.ql, + authService: input.authService, + // A session-only door, as the `/auth/me` endpoints are: it opens no + // API-key door, so no posture-conditional key refusal applies. + tenancy: undefined, + anonymous: 'fail-closed', + // The door asks the ADR-0069 gate itself, on the same session read. + authGate: 'withhold', + 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 NOT_ENTITLED; + const decision = await security.explain( + { object: REDELIVER_ADMINISTERED_OBJECT, operation: REDELIVER_REQUIRED_OPERATION }, + context, + ); + return decision?.allowed === true ? undefined : NOT_ENTITLED; + } catch (err) { + input.logError('[webhook-outbox] redeliver entitlement could not be decided; the caller was refused 503', err); + return 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..0af6f1c88ba 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts @@ -3,15 +3,17 @@ 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 { judgeRedeliverEntitlement } from './redeliver-entitlement.js'; import type { EnqueueHttpInput } from '@objectstack/service-messaging'; import { inProcessSessionReadInput } from '@objectstack/types'; import { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js'; @@ -569,7 +571,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; @@ -626,7 +628,10 @@ export class WebhookOutboxPlugin implements Plugin { /** * 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` @@ -678,6 +683,24 @@ 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. + const activeOrgForGate = session?.session?.activeOrganizationId; + const refusal = await judgeRedeliverEntitlement({ + headers: c.req.raw.headers, + ql: this.tryGetService(ctx, ['objectql', 'data']), + authService: this.tryGetService(ctx, ['auth']), + security: this.tryGetService>(ctx, ['security']), + userId, + activeOrganizationId: typeof activeOrgForGate === 'string' && activeOrgForGate.length > 0 + ? activeOrgForGate + : undefined, + logError: (message, err) => ctx.logger.error?.(message, err as Error), + }); + if (refusal) { + return c.json({ success: false, error: { code: refusal.code, message: refusal.message } }, refusal.status); + } let body: any; try { body = await c.req.json(); From 641ad963506469a83a1142947ecfe056eea4d2d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:13:12 +0000 Subject: [PATCH 2/8] test(plugin-webhooks): pin the redeliver door's entitlement gate at the HTTP layer Both faces: an administrator replays; a signed-in caller not entitled is refused 403 with one answer whatever the request names; no security service or a throwing decision refuses 503. Existing door suites register a security double that admits their callers. Claude-Session: https://claude.ai/code/session_017xRBcSwDXZrZg7nGjP6P3U Co-authored-by: Claude --- .../src/in-process-session-read.pin.test.ts | 3 + .../src/redeliver-entitlement.testkit.ts | 45 +++ .../webhook-redeliver-auth-gate.pin.test.ts | 3 + .../src/webhook-redeliver-entitlement.test.ts | 275 ++++++++++++++++++ .../src/webhook-redeliver-member.test.ts | 3 + .../webhook-redeliver-tenant-scope.test.ts | 11 +- 6 files changed, 336 insertions(+), 4 deletions(-) create mode 100644 packages/plugins/plugin-webhooks/src/redeliver-entitlement.testkit.ts create mode 100644 packages/plugins/plugin-webhooks/src/webhook-redeliver-entitlement.test.ts 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..6df2860d82e --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/redeliver-entitlement.testkit.ts @@ -0,0 +1,45 @@ +// 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; +} + +export interface WebhookAdministrationSecurityDouble { + readonly asked: AskedEntitlement[]; + explain(request: { object: string; operation: string }, context?: { userId?: 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 }); + const allowed = request.object === 'sys_webhook' + && request.operation === 'update' + && isAdministrator(context?.userId); + return { allowed, layers: [] }; + }, + }; +} 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..84e78676738 --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/webhook-redeliver-entitlement.test.ts @@ -0,0 +1,275 @@ +// 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. + * + * What this pins: + * 1. an entitled caller is admitted: the delivery is replayed, and the gate + * asked about the caller the session named; + * 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; + * 5. order: an anonymous caller is still `401` and the gate is never asked. + */ + +import { randomUUID } from 'node:crypto'; +import { afterEach, describe, expect, it } from 'vitest'; +import { Hono } from 'hono'; +import { LiteKernel } 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 { 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'; + +type SecurityShape = 'double' | 'absent' | 'throws'; + +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: string[] = []; + + constructor(private readonly securityShape: SecurityShape) { + 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 }], + ]); + ctx.registerService('manifest', { register: () => {}, list: () => [] }); + ctx.registerService('objectql', { + registry: { listItems: () => [], getObject: () => undefined }, + async find() { return []; }, + 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); + return realRedeliver(id, options); + }) as typeof messaging.redeliverHttp; + ctx.registerService('messaging', messaging); + if (this.securityShape === 'double') ctx.registerService('security', this.security); + if (this.securityShape === 'throws') { + ctx.registerService('security', { + async explain() { throw new Error('permission store unreachable'); }, + }); + } + 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(securityShape: SecurityShape = 'double') { + const fixture = new FixturePlugin(securityShape); + const errors: string[] = []; + class ObservedPlugin extends WebhookOutboxPlugin { + override async init(ctx: PluginContext): Promise { + const original = ctx.logger.error?.bind(ctx.logger); + ctx.logger.error = ((message: unknown, ...rest: unknown[]) => { + errors.push(String(message)); + return original?.(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 }; +} + +const post = (body: unknown, caller: string | null): RequestInit => ({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(caller ? { Authorization: `Bearer ${caller}@${ORG_A}` } : {}), + }, + 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 } = 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 }]); + }); + + 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(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); + } + }); + } + } +}); 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 = { From 5d8aa9de033c7ffc189d3ca4df09a7ea7f15c928 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 06:15:15 +0000 Subject: [PATCH 3/8] docs(plugin-webhooks): the redeliver door's webhook-administration gate, in docs, checklist and changeset Claude-Session: https://claude.ai/code/session_017xRBcSwDXZrZg7nGjP6P3U Co-authored-by: Claude --- .changeset/harden-webhook-redeliver-door.md | 17 +++++++++++++ content/docs/automation/webhooks.mdx | 8 ++++++- .../areas/integration-system.json | 24 ++++++++++++++----- .../dogfood/test/authz-conformance.matrix.ts | 4 ++-- ...ono-webhooks-redeliver.conformance.test.ts | 7 ++++++ .../src/objects/http-delivery.object.ts | 4 ++-- 6 files changed, 53 insertions(+), 11 deletions(-) create mode 100644 .changeset/harden-webhook-redeliver-door.md diff --git a/.changeset/harden-webhook-redeliver-door.md b/.changeset/harden-webhook-redeliver-door.md new file mode 100644 index 00000000000..96549e0d27c --- /dev/null +++ b/.changeset/harden-webhook-redeliver-door.md @@ -0,0 +1,17 @@ +--- +'@objectstack/plugin-webhooks': patch +--- + +fix(plugin-webhooks): gate webhook redelivery on webhook-administration access + +Clause-②: no +Metadata-protocol: none + +`POST /api/v1/webhooks/redeliver` (both faces: the self-hosted mount and the `webhooks` service's `handleRedeliver`) now requires the signed-in caller to be entitled to administer webhooks, 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 resolved execution context, the same answer the data API's write gate gives that caller. The shipped administrator permission sets carry it; a member baseline does not. +- **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, answers `503 SERVICE_UNAVAILABLE` and replays nothing. +- **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. diff --git a/content/docs/automation/webhooks.mdx b/content/docs/automation/webhooks.mdx index 4f51e4b7446..3377d6f9f80 100644 --- a/content/docs/automation/webhooks.mdx +++ b/content/docs/automation/webhooks.mdx @@ -624,7 +624,13 @@ 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). 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..db9bfa48350 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": 12, "priority": "P1", "surface": "mixed", "fixtures": { @@ -643,7 +643,7 @@ "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 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", "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,12 @@ "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" } ], "enumSource": { @@ -2295,7 +2301,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 +2319,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 +2350,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 +2376,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/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/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', From ce14ead30f966814cc692d0c7749ede6edc5389d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 07:14:37 +0000 Subject: [PATCH 4/8] chore(scripts): record the redeliver entitlement suite's engine double in the pinned ledger Claude-Session: https://claude.ai/code/session_017xRBcSwDXZrZg7nGjP6P3U Co-authored-by: Claude --- scripts/engine-double-contract.pinned.json | 5 +++++ 1 file changed, 5 insertions(+) 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", From 957b96882b24c7fdfad77662bf2dfac32515e9de Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 07:39:50 +0000 Subject: [PATCH 5/8] docs(plugin-webhooks): word the entitlement module's history note as the alignment it is Claude-Session: https://claude.ai/code/session_017xRBcSwDXZrZg7nGjP6P3U Co-authored-by: Claude --- .../plugins/plugin-webhooks/src/redeliver-entitlement.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-webhooks/src/redeliver-entitlement.ts b/packages/plugins/plugin-webhooks/src/redeliver-entitlement.ts index 3e57ffd226f..ad7d926b236 100644 --- a/packages/plugins/plugin-webhooks/src/redeliver-entitlement.ts +++ b/packages/plugins/plugin-webhooks/src/redeliver-entitlement.ts @@ -18,9 +18,9 @@ * 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 admitted - * a caller who may not read a single delivery or webhook row, and let that - * caller reach answers carrying row-specific detail. + * 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. From f1c7919300cda68b070935ebdb0f3350d4f14917 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:03:39 +0000 Subject: [PATCH 6/8] fix(plugin-webhooks): vet the redeliver gate's organization claim with the tenancy posture, and scope the replay to it The entitlement gate now hands the request-context composition the door's own way to reach the tenancy service, so the session's organization claim is vetted against the posture in force exactly as it is on the data API (the per-request membership check of the 2026-09-05 ruling, option B). The admission carries the vetted tenantId and the replay is scoped to it, never to the raw session claim; a claim the composition did not keep is refused with the same caller-level answer, since the replay would have no organization to be scoped to. The plugin also says once at kernel:ready when the door is served with no security service to decide, naming the remedy, instead of leaving the 503 to be discovered per request. Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude --- .../src/redeliver-entitlement.ts | 143 ++++++++++++++++-- .../src/webhook-outbox-plugin.ts | 95 ++++++++---- 2 files changed, 197 insertions(+), 41 deletions(-) diff --git a/packages/plugins/plugin-webhooks/src/redeliver-entitlement.ts b/packages/plugins/plugin-webhooks/src/redeliver-entitlement.ts index ad7d926b236..ea5e9bd6612 100644 --- a/packages/plugins/plugin-webhooks/src/redeliver-entitlement.ts +++ b/packages/plugins/plugin-webhooks/src/redeliver-entitlement.ts @@ -4,7 +4,8 @@ * 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. + * 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 * @@ -36,21 +37,56 @@ * identity the rest of the door acts as come from one read and cannot * disagree. * - * ## Fail direction — CLOSED, and each state says which it is + * ## 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) is `403 PERMISSION_DENIED`; + * 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 RequestAuthService } from '@objectstack/core'; +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. */ @@ -66,6 +102,14 @@ export interface RedeliverRefusal { 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). */ @@ -74,11 +118,18 @@ export interface RedeliverEntitlementInput { 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, as the door read it. */ + /** 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; @@ -105,26 +156,82 @@ const UNDECIDED: RedeliverRefusal = { + '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: `undefined` admits, a - * {@link RedeliverRefusal} refuses. Never throws. + * Decide whether the signed-in caller may redeliver, and in which + * organization. Never throws. */ export async function judgeRedeliverEntitlement( input: RedeliverEntitlementInput, -): Promise { +): Promise { const security = input.security; - if (!security || typeof security.explain !== 'function') return NO_DECIDER; + if (!security || !canDecideRedeliverEntitlement(security)) return refuse(NO_DECIDER); try { const context = await resolveRequestExecutionContext({ headers: input.headers, ql: input.ql, authService: input.authService, - // A session-only door, as the `/auth/me` endpoints are: it opens no - // API-key door, so no posture-conditional key refusal applies. - tenancy: undefined, + // 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: { @@ -142,14 +249,20 @@ export async function judgeRedeliverEntitlement( oauth: undefined, onAdmission: undefined, }); - if (!context || context.userId !== input.userId) return NOT_ENTITLED; + 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 ? undefined : NOT_ENTITLED; + 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 UNDECIDED; + 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 0af6f1c88ba..c13b3ae5567 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts @@ -13,7 +13,11 @@ import type { ISecurityService, IWebhookService, } from '@objectstack/spec/contracts'; -import { judgeRedeliverEntitlement } from './redeliver-entitlement.js'; +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'; @@ -298,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); }); } @@ -521,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) { @@ -531,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 }, + ); } /** @@ -609,20 +644,23 @@ 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; } /** @@ -647,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: @@ -686,19 +728,29 @@ export class WebhookOutboxPlugin implements Plugin { // [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. - const activeOrgForGate = session?.session?.activeOrganizationId; - const refusal = await judgeRedeliverEntitlement({ + // + // [#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 activeOrgForGate === 'string' && activeOrgForGate.length > 0 - ? activeOrgForGate + activeOrganizationId: typeof activeOrgClaim === 'string' && activeOrgClaim.length > 0 + ? activeOrgClaim : undefined, logError: (message, err) => ctx.logger.error?.(message, err as Error), }); - if (refusal) { + if (!verdict.admitted) { + const { refusal } = verdict; return c.json({ success: false, error: { code: refusal.code, message: refusal.message } }, refusal.status); } let body: any; @@ -715,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 } }); From 1b07782bdbf61d9c1b309a2dfa6a178211fc1cdf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:08:34 +0000 Subject: [PATCH 7/8] test(plugin-webhooks): pin the redeliver gate's organization vetting; retire stale posture text; declare the narrowing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entitlement suite gains the walled-posture cases (an organization claim no current membership backs is refused with the caller-level answer and nothing is replayed, on isolated and group; a backed claim is admitted and the replay is scoped to that organization), the once-at-boot line for a kernel with no security service, and the door's tenancy resolver on a real ObjectKernel registry (never registered reads as no posture; registered and failing is the 503). The security double records the organization the caller's context was resolved in. Three stale "any authenticated user" posture statements are retired: the two messaging outbox docblocks and the platform checklist clause's verify field (r13, with its step driving the redeliver as the seeded admin). The changeset declares the narrowing (Clause-② no (narrowing), BREAKING banner, one ADR-0087 disposition); the level stays patch. ADR-0124 D1 gains an anchor for redeliver-entitlement.ts, and the docs page states the organization vetting. Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude --- .changeset/harden-webhook-redeliver-door.md | 15 +- content/docs/automation/webhooks.mdx | 6 +- .../areas/integration-system.json | 14 +- .../src/redeliver-entitlement.testkit.ts | 11 +- .../src/webhook-redeliver-entitlement.test.ts | 218 ++++++++++++++++-- .../service-messaging/src/http-outbox.ts | 13 +- .../service-messaging/src/sql-http-outbox.ts | 8 +- ...bhooks__src__redeliver-entitlement.ts.json | 7 + 8 files changed, 250 insertions(+), 42 deletions(-) create mode 100644 scripts/adr-anchors/packages__plugins__plugin-webhooks__src__redeliver-entitlement.ts.json diff --git a/.changeset/harden-webhook-redeliver-door.md b/.changeset/harden-webhook-redeliver-door.md index 96549e0d27c..639ee09f9d8 100644 --- a/.changeset/harden-webhook-redeliver-door.md +++ b/.changeset/harden-webhook-redeliver-door.md @@ -2,16 +2,19 @@ '@objectstack/plugin-webhooks': patch --- -fix(plugin-webhooks): gate webhook redelivery on webhook-administration access +fix(plugin-webhooks)!: gate webhook redelivery on webhook-administration access -Clause-②: no +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 -`POST /api/v1/webhooks/redeliver` (both faces: the self-hosted mount and the `webhooks` service's `handleRedeliver`) now requires the signed-in caller to be entitled to administer webhooks, 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 resolved execution context, the same answer the data API's write gate gives that caller. The shipped administrator permission sets carry it; a member baseline does not. +**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, answers `503 SERVICE_UNAVAILABLE` and replays nothing. +- **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. +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 3377d6f9f80..b04fc6c5bbe 100644 --- a/content/docs/automation/webhooks.mdx +++ b/content/docs/automation/webhooks.mdx @@ -630,7 +630,11 @@ 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). Failures answer in the declared +(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 db9bfa48350..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": 12, + "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). 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 is answered 403 PERMISSION_DENIED before any row-specific detail, and no security service (or an undecidable check) is 503 SERVICE_UNAVAILABLE", + "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" }, { @@ -752,6 +752,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": { diff --git a/packages/plugins/plugin-webhooks/src/redeliver-entitlement.testkit.ts b/packages/plugins/plugin-webhooks/src/redeliver-entitlement.testkit.ts index 6df2860d82e..7fa98940c35 100644 --- a/packages/plugins/plugin-webhooks/src/redeliver-entitlement.testkit.ts +++ b/packages/plugins/plugin-webhooks/src/redeliver-entitlement.testkit.ts @@ -17,11 +17,13 @@ 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 }): Promise; + explain(request: { object: string; operation: string }, context?: { userId?: unknown; tenantId?: unknown }): Promise; } /** @@ -35,7 +37,12 @@ export function webhookAdministrationSecurity( return { asked, async explain(request, context) { - asked.push({ object: request.object, operation: request.operation, userId: context?.userId }); + 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); diff --git a/packages/plugins/plugin-webhooks/src/webhook-redeliver-entitlement.test.ts b/packages/plugins/plugin-webhooks/src/webhook-redeliver-entitlement.test.ts index 84e78676738..57fca0d5329 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-redeliver-entitlement.test.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-redeliver-entitlement.test.ts @@ -11,10 +11,13 @@ * 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; + * 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 @@ -25,19 +28,27 @@ * (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; - * 5. order: an anonymous caller is still `401` and the gate is never asked. + * `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 } from '@objectstack/core'; +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'; @@ -51,9 +62,27 @@ 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; @@ -62,9 +91,9 @@ class FixturePlugin implements Plugin { readonly messaging = new MessagingService({ logger: { info() {}, warn() {}, error() {}, debug() {} } as any }); readonly app = new Hono(); readonly security: WebhookAdministrationSecurityDouble = webhookAdministrationSecurity((id) => id === ADMIN); - readonly redeliverCalls: string[] = []; + readonly redeliverCalls: Array<{ id: string; tenantId: unknown }> = []; - constructor(private readonly securityShape: SecurityShape) { + constructor(private readonly options: FixtureOptions) { this.messaging.setHttpOutbox(this.outbox); } @@ -73,10 +102,14 @@ class FixturePlugin implements Plugin { [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() { return []; }, + async find(object: string, query?: any) { + if (object !== 'sys_member') return []; + return memberships.filter((row) => matches(row, query?.where)); + }, async findOne(object: string, opts?: any) { assertEngineFindOnePredicate(object, opts); return subscriptions.get(opts?.where?.id) ?? null; @@ -89,16 +122,18 @@ class FixturePlugin implements Plugin { const calls = this.redeliverCalls; const realRedeliver = messaging.redeliverHttp.bind(messaging); messaging.redeliverHttp = (async (id: string, options: any) => { - calls.push(id); + calls.push({ id, tenantId: options?.tenantId }); return realRedeliver(id, options); }) as typeof messaging.redeliverHttp; ctx.registerService('messaging', messaging); - if (this.securityShape === 'double') ctx.registerService('security', this.security); - if (this.securityShape === 'throws') { + 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 }) { @@ -120,15 +155,21 @@ afterEach(async () => { } }); -async function boot(securityShape: SecurityShape = 'double') { - const fixture = new FixturePlugin(securityShape); +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 original = ctx.logger.error?.bind(ctx.logger); + const originalError = ctx.logger.error?.bind(ctx.logger); ctx.logger.error = ((message: unknown, ...rest: unknown[]) => { errors.push(String(message)); - return original?.(message as any, ...(rest as [])); + 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); } @@ -161,14 +202,14 @@ async function boot(securityShape: SecurityShape = 'double') { return id; }; const statusOf = async (id: string) => (await fixture.outbox.list()).find((row) => row.id === id)?.status; - return { fixture, faces, attempted, statusOf, errors }; + return { fixture, faces, attempted, statusOf, errors, warnings }; } -const post = (body: unknown, caller: string | null): RequestInit => ({ +const post = (body: unknown, caller: string | null, organization: string = ORG_A): RequestInit => ({ method: 'POST', headers: { 'Content-Type': 'application/json', - ...(caller ? { Authorization: `Bearer ${caller}@${ORG_A}` } : {}), + ...(caller ? { Authorization: `Bearer ${caller}@${organization}` } : {}), }, body: typeof body === 'string' ? body : JSON.stringify(body), }); @@ -181,7 +222,7 @@ async function read(res: Response) { 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 } = await boot(); + 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))); @@ -189,7 +230,12 @@ describe('the redeliver door admits a caller entitled to administer webhooks', ( 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 }]); + 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 () => { @@ -256,7 +302,7 @@ 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(shape); + 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))); @@ -272,4 +318,136 @@ describe('the redeliver door fails closed when entitlement cannot be decided', ( }); } } + + 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/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/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." +} From b4a2146cb6c24b77a6a5244274195b2067e295b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:15:46 +0000 Subject: [PATCH 8/8] test(plugin-webhooks): the entitlement suite's sys_member double holds the caller's bound Claude-Session: https://claude.ai/code/session_017xRBcVy4VXmkQjn2MGeaWk Co-authored-by: Claude --- .../plugin-webhooks/src/webhook-redeliver-entitlement.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-webhooks/src/webhook-redeliver-entitlement.test.ts b/packages/plugins/plugin-webhooks/src/webhook-redeliver-entitlement.test.ts index 57fca0d5329..9808d6dce85 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-redeliver-entitlement.test.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-redeliver-entitlement.test.ts @@ -108,7 +108,8 @@ class FixturePlugin implements Plugin { registry: { listItems: () => [], getObject: () => undefined }, async find(object: string, query?: any) { if (object !== 'sys_member') return []; - return memberships.filter((row) => matches(row, query?.where)); + 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);