Skip to content
Merged
20 changes: 20 additions & 0 deletions .changeset/harden-webhook-redeliver-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/plugin-webhooks': patch
---

fix(plugin-webhooks)!: gate webhook redelivery on webhook-administration access

Clause-②: no (narrowing) — `POST /api/v1/webhooks/redeliver` admits only a signed-in caller entitled to administer webhooks, in an organization a current membership backs on a walled posture, where it admitted every signed-in caller. Nothing is widened.
Metadata-protocol: none

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, and no exported declaration changes, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. What narrows is the set of callers one HTTP door admits; the remedy is a permission grant an administrator makes, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this change (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**BREAKING** accept-set narrowing at the webhook redeliver door (`@objectstack/plugin-webhooks`), on both faces: the self-hosted mount and the `webhooks` service's `handleRedeliver`. The caller's entitlement is decided server-side by the security service before the request body is read or any delivery row is looked up.

- **The entitlement.** The security service's object-level `update` admission on `sys_webhook` (`ISecurityService.explain`), for the caller's execution context as the platform's request-context composition resolves it, the same answer the data API's write gate gives that caller. The shipped administrator permission sets carry it; a member baseline does not.
- **The organization.** That context is resolved with the deployment's tenancy posture, as the data API resolves it: on a walled posture (`isolated`, `group`) a session's active-organization claim that no current membership backs is not kept, and the door answers that caller with the same `403`. The replay is scoped to the organization the caller was admitted in.
- **An unentitled caller** is answered `403 PERMISSION_DENIED`, the same answer whatever the request names, so the refusal carries no row-specific detail and nothing is replayed.
- **Fail closed.** A deployment with no security service registered, or a check that cannot be decided (including a tenancy service that is registered and cannot answer), answers `503 SERVICE_UNAVAILABLE` and replays nothing. A kernel that serves the door with no security service says so once at boot, naming the remedy.
- **Unchanged for an entitled caller:** the auth gate, `401` for no session, the `400` body refusals, the active-organization scoping (`404` outside it), the redelivery veto's `409`s and the `200` replay. The door still admits no API key.

Migration: a deployment that relied on non-administrators pressing redeliver grants them `allowEdit` on `sys_webhook` through a permission set; a caller on a walled posture switches to an organization it belongs to.
12 changes: 11 additions & 1 deletion content/docs/automation/webhooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,17 @@ The plugin mounts exactly **one** custom endpoint:
| POST | `/api/v1/webhooks/redeliver` | `{ deliveryId }` | Re-queue a delivery that was **attempted** and failed. A row that was never attempted cannot be redelivered. |

It delegates to `messaging.redeliverHttp(deliveryId)` and is guarded by the
better-auth session (any authenticated user). Failures answer in the declared
better-auth session **and by webhook-administration access**: the caller must be
allowed to edit `sys_webhook` (the security service's object-level `update`
admission, decided server-side before the request body is read). A signed-in
caller without it is answered `403` `PERMISSION_DENIED` before any detail about
the delivery; a deployment with no security service to decide answers `503`
`SERVICE_UNAVAILABLE`. Grant webhook administration through a permission set
(the shipped administrator sets carry it). The caller's active organization is
vetted the way the data API vets it: on a walled tenancy posture (`isolated`,
`group`) an organization claim that no current membership backs is answered with
the same `403`, and a replay reaches only deliveries of the organization the
caller was admitted in (`404` for any other). Failures answer in the declared
envelope — `{ success: false, error: { code, message } }` — with `401`
`UNAUTHENTICATED`, `400` `INVALID_REQUEST` or `MISSING_REQUIRED_FIELD`, `404`
`RESOURCE_NOT_FOUND`, `409` `DELIVERY_NOT_ELIGIBLE`, or `409`
Expand Down
34 changes: 26 additions & 8 deletions docs/qa/platform-checklist/areas/integration-system.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -46,6 +47,8 @@ async function redeliver(headers: Record<string, string>) {
return { id, status: 'pending' };
},
},
// The entitlement gate: every signed-in caller here administers webhooks.
security: webhookAdministrationSecurity(),
auth: {
api: {
getSession: async (input: any) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A `security` service double for the redeliver door's entitlement gate
* (`redeliver-entitlement.ts`): it answers `explain` the way the real service
* answers the one question the gate asks — may this caller `update`
* `sys_webhook`? — for the users it is told are webhook administrators, and
* records every question it was asked.
*
* A suite whose subject is NOT entitlement (tenant scope, the veto, the auth
* gate, the session read) registers {@link webhookAdministrationSecurity} with
* no argument, so every signed-in caller in it is an administrator and the
* suite drives the door's admitted path exactly as it did before the gate.
*/

export interface AskedEntitlement {
readonly object: string;
readonly operation: string;
readonly userId: unknown;
/** The organization the caller's context was resolved in when the gate asked. */
readonly tenantId: unknown;
}

export interface WebhookAdministrationSecurityDouble {
readonly asked: AskedEntitlement[];
explain(request: { object: string; operation: string }, context?: { userId?: unknown; tenantId?: unknown }): Promise<any>;
}

/**
* @param isAdministrator which signed-in users may administer webhooks;
* omitted, every one may.
*/
export function webhookAdministrationSecurity(
isAdministrator: (userId: unknown) => boolean = () => true,
): WebhookAdministrationSecurityDouble {
const asked: AskedEntitlement[] = [];
return {
asked,
async explain(request, context) {
asked.push({
object: request.object,
operation: request.operation,
userId: context?.userId,
tenantId: context?.tenantId,
});
const allowed = request.object === 'sys_webhook'
&& request.operation === 'update'
&& isAdministrator(context?.userId);
return { allowed, layers: [] };
},
};
}
Loading
Loading