Files
qbit-webhooks/src/main/java/com/kingsrook/qbits/webhooks/Webhooks.java
T
james.maesandClaude Opus 5.5 6b56de589d feat!: qbit-webhooks 1.0 (signed, SSRF-safe, replica-safe outbound webhooks)
Rebuilds the qbit on QQQ 4.1.0-RC.4 per the assessment (QRun-IO/qqq#1105,
decisions D1-D8):

- webhooks_endpoint, webhooks_endpoint_secret, webhooks_subscription,
  webhooks_delivery and webhooks_delivery_attempt (snake_case, webhooks_
  prefix), permissions webhooks.view / manage / admin
- Standard Webhooks signing with per-endpoint secrets (AES-GCM or env
  references) and overlapping rotation
- SSRF-safe client: private, loopback, link-local and metadata ranges
  blocked, DNS pinned, no redirects, timeouts, size cap, admin-only
  internal allowlist (shared/ is an internal copy of QRun-IO/qqq#1109,
  replaced at RC.5)
- event sources: table insert/update/delete (record change listener),
  process completed/failed, ESB topics (own events skipped, CloudEvent
  id dedup), custom events and a channel API for other qbits
- delivery: unique attempt-row claims with leases (no double sends across
  replicas), retries with backoff, Retry-After, dead letters and Replay,
  serial mode, per-endpoint rate limits, health with probation and
  auto-disable, alerts through the Alerts SPI when present, retention
- subscriptions: record filters, record security, read permission check,
  detail levels, include and exclude fields, payload customizer hook
- app home dashboard, actions with the standard review/result screens,
  ESB / Quick Search / API integration hooks
- JaCoCo gate, Checkstyle in verify, Maven wrapper, default 4.1 profile,
  Postgres DDL in docs/ddl

The 0.x sources stay in the tree, excluded from the build, until their
removal is approved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:49:03 -05:00

184 lines
8.3 KiB
Java

/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.webhooks;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.webhooks.events.DeliveryOutbox;
import com.kingsrook.qbits.webhooks.events.WebhookEvent;
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.ActiveStatus;
import com.kingsrook.qbits.webhooks.metadata.WebhooksEnums.EventSource;
import com.kingsrook.qbits.webhooks.metadata.WebhooksTables;
import com.kingsrook.qbits.webhooks.registry.WebhookEventType;
import com.kingsrook.qbits.webhooks.registry.WebhooksRegistry;
import com.kingsrook.qbits.webhooks.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
/*******************************************************************************
** The qbit's public API for code: fire custom (domain) events, and deliver
** to a named endpoint as a channel (the Alerts qbit's webhook channel uses
** this, through reflection, when this qbit is installed).
**
** <pre>
** // a domain event: every subscription to "post.published" gets it
** Webhooks.fire("post.published", Map.of("slug", slug, "title", title));
**
** // in the same transaction as the write that caused it
** Webhooks.fireForRecord("post.published", "cms_blog_post", record, transaction);
**
** // a channel delivery to one endpoint, queued once per idempotency key
** Integer deliveryId = Webhooks.deliver("ops-pager", "alerts.alert.raised", body, "alert-123-raised");
** </pre>
*******************************************************************************/
public final class Webhooks
{
/*******************************************************************************
** Utility class.
*******************************************************************************/
private Webhooks()
{
}
/*******************************************************************************
** Register a custom event type (call while building the instance).
*******************************************************************************/
public static void registerEventType(WebhookEventType eventType)
{
WebhooksRegistry.of(QContext.getQInstance()).register(eventType);
}
/*******************************************************************************
** Fire a custom event. Returns the number of deliveries queued.
*******************************************************************************/
public static int fire(String eventTypeName, Map<String, ?> data) throws QException
{
return (fire(eventTypeName, data, null));
}
/*******************************************************************************
** Fire a custom event inside a transaction (on the qbit's backend).
*******************************************************************************/
public static int fire(String eventTypeName, Map<String, ?> data, QBackendTransaction transaction) throws QException
{
requireType(eventTypeName);
WebhooksQBitConfig config = WebhooksRuntime.config();
WebhookEvent event = new WebhookEvent()
.withEventTypeName(eventTypeName)
.withSource(EventSource.CUSTOM)
.withSourceUri("qqq://" + config.getSourceName() + "/event/" + eventTypeName)
.withOccurredAt(WebhooksRuntime.clock().instant())
.withData(data);
return (DeliveryOutbox.enqueue(List.of(event), transaction).size());
}
/*******************************************************************************
** Fire a custom event about a record (its subscriptions' filters, record
** security and payload shaping apply to the record).
*******************************************************************************/
public static int fireForRecord(String eventTypeName, String tableName, QRecord record, QBackendTransaction transaction) throws QException
{
requireType(eventTypeName);
WebhooksQBitConfig config = WebhooksRuntime.config();
QTableMetaData table = QContext.getQInstance().getTable(tableName);
Object key = table == null ? null : record.getValue(table.getPrimaryKeyField());
WebhookEvent event = new WebhookEvent()
.withEventTypeName(eventTypeName)
.withSource(EventSource.CUSTOM)
.withSourceTable(tableName)
.withSourceRecordId(key == null ? null : ValueUtils.getValueAsString(key))
.withSourceUri("qqq://" + config.getSourceName() + "/table/" + tableName)
.withOccurredAt(WebhooksRuntime.clock().instant())
.withRecord(record);
return (DeliveryOutbox.enqueue(List.of(event), transaction).size());
}
/*******************************************************************************
** Deliver a payload to one endpoint (by name), with no subscription: the
** channel API for other qbits. Signing, retries, dead letters, health and
** the delivery log all apply. Calling again with the same idempotency key
** returns the existing delivery instead of queuing another.
**
** @return the webhooks_delivery id
*******************************************************************************/
public static Integer deliver(String endpointName, String eventTypeName, Map<String, ?> data, String idempotencyKey) throws QException
{
if(!WebhooksRuntime.isInstalled())
{
throw (new QException("The webhooks qbit is not installed"));
}
List<QRecord> endpoints = SystemWrites.query(WebhooksTables.ENDPOINT, new QQueryFilter(new QFilterCriteria("name", QCriteriaOperator.EQUALS, endpointName)));
if(endpoints.isEmpty())
{
throw (new QException("No webhook endpoint is named " + endpointName));
}
QRecord endpoint = endpoints.get(0);
if(ActiveStatus.DISABLED.name().equals(endpoint.getValueString("activeStatus")))
{
throw (new QException("The webhook endpoint " + endpointName + " is disabled"));
}
WebhooksQBitConfig config = WebhooksRuntime.config();
WebhookEvent event = new WebhookEvent()
.withEventTypeName(eventTypeName)
.withSource(EventSource.CHANNEL)
.withSourceUri("qqq://" + config.getSourceName() + "/channel/" + eventTypeName)
.withOccurredAt(WebhooksRuntime.clock().instant())
.withData(data);
return (DeliveryOutbox.enqueueDirect(event, endpoint.getValueInteger("id"), idempotencyKey).getValueInteger("id"));
}
/*******************************************************************************
** Fail on an unregistered type (a typo would otherwise fire into nothing).
*******************************************************************************/
private static void requireType(String eventTypeName) throws QException
{
if(!WebhooksRuntime.isInstalled())
{
throw (new QException("The webhooks qbit is not installed"));
}
if(WebhooksRegistry.of(QContext.getQInstance()).get(eventTypeName) == null)
{
throw (new QException("Unknown webhook event type: " + eventTypeName + " (register it with Webhooks.registerEventType)"));
}
}
}