feat(webhooks): subscription CRUD + HMAC-signed delivery client
Introduces the webhook egress foundation — a new WebhookSubscription table, admin-gated CRUD under /api/v1/webhooks, and the shared delivery client that both the test-ping route and the upcoming worker will use. No worker yet; this commit is API + model + client only. Simple-mode enum (AttackerDetail / DeckyStatus / SystemStatus) expands to bus-topic patterns at the router layer; storage is always the raw pattern list. Advanced mode lets admins supply raw NATS-style patterns directly. Filter-at-subscribe: the worker (next commit) will subscribe to the union of patterns across enabled subscriptions. Delivery client handles HMAC-SHA256 signing (X-DECNET-Signature), retry on 429/5xx/network errors with jittered backoff, no-retry on 4xx. Secrets never leave the server on GET/LIST — only the create response carries the secret for copy-out. CRUD routes publish WEBHOOK_SUBSCRIPTIONS_CHANGED on the bus after every mutation so the (future) worker can hot-reload. Opens DEBT-037 for the deferred items (circuit breaker, dead-letter, batch delivery, payload templates, secret-at-rest).
This commit is contained in:
@@ -44,6 +44,7 @@ from decnet.web.db.models import (
|
||||
TopologyEdge,
|
||||
TopologyStatusEvent,
|
||||
TopologyMutation,
|
||||
WebhookSubscription,
|
||||
)
|
||||
|
||||
|
||||
@@ -1744,3 +1745,110 @@ class SQLModelRepository(BaseRepository):
|
||||
)
|
||||
)
|
||||
return [r for r in result.scalars().all()]
|
||||
|
||||
# --------------------------------------------------------- webhooks
|
||||
|
||||
async def create_webhook_subscription(self, data: dict[str, Any]) -> None:
|
||||
async with self._session() as session:
|
||||
session.add(WebhookSubscription(**data))
|
||||
await session.commit()
|
||||
|
||||
async def get_webhook_subscription(
|
||||
self, uuid: str
|
||||
) -> Optional[dict[str, Any]]:
|
||||
async with self._session() as session:
|
||||
result = await session.execute(
|
||||
select(WebhookSubscription).where(WebhookSubscription.uuid == uuid)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
return row.model_dump() if row else None
|
||||
|
||||
async def get_webhook_subscription_by_name(
|
||||
self, name: str
|
||||
) -> Optional[dict[str, Any]]:
|
||||
async with self._session() as session:
|
||||
result = await session.execute(
|
||||
select(WebhookSubscription).where(WebhookSubscription.name == name)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
return row.model_dump() if row else None
|
||||
|
||||
async def list_webhook_subscriptions(
|
||||
self, enabled_only: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
async with self._session() as session:
|
||||
stmt = select(WebhookSubscription)
|
||||
if enabled_only:
|
||||
stmt = stmt.where(WebhookSubscription.enabled.is_(True))
|
||||
stmt = stmt.order_by(WebhookSubscription.created_at)
|
||||
result = await session.execute(stmt)
|
||||
return [r.model_dump() for r in result.scalars().all()]
|
||||
|
||||
async def update_webhook_subscription(
|
||||
self, uuid: str, patch: dict[str, Any]
|
||||
) -> bool:
|
||||
if not patch:
|
||||
return True
|
||||
patch = {**patch, "updated_at": datetime.now(timezone.utc)}
|
||||
async with self._session() as session:
|
||||
result = await session.execute(
|
||||
update(WebhookSubscription)
|
||||
.where(WebhookSubscription.uuid == uuid)
|
||||
.values(**patch)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount > 0
|
||||
|
||||
async def delete_webhook_subscription(self, uuid: str) -> bool:
|
||||
async with self._session() as session:
|
||||
result = await session.execute(
|
||||
select(WebhookSubscription).where(WebhookSubscription.uuid == uuid)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if not row:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def record_webhook_success(
|
||||
self, uuid: str, ts: datetime
|
||||
) -> None:
|
||||
async with self._session() as session:
|
||||
await session.execute(
|
||||
update(WebhookSubscription)
|
||||
.where(WebhookSubscription.uuid == uuid)
|
||||
.values(
|
||||
consecutive_failures=0,
|
||||
last_success_at=ts,
|
||||
last_error=None,
|
||||
updated_at=ts,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def record_webhook_failure(
|
||||
self, uuid: str, ts: datetime, error: str
|
||||
) -> None:
|
||||
async with self._session() as session:
|
||||
# Read current failure count, bump, write. Small race window on
|
||||
# concurrent deliveries to the same subscription is acceptable —
|
||||
# the counter informs the circuit-breaker heuristic (DEBT-037),
|
||||
# not a correctness invariant.
|
||||
result = await session.execute(
|
||||
select(WebhookSubscription.consecutive_failures).where(
|
||||
WebhookSubscription.uuid == uuid
|
||||
)
|
||||
)
|
||||
current = result.scalar_one_or_none() or 0
|
||||
await session.execute(
|
||||
update(WebhookSubscription)
|
||||
.where(WebhookSubscription.uuid == uuid)
|
||||
.values(
|
||||
consecutive_failures=current + 1,
|
||||
last_failure_at=ts,
|
||||
last_error=error[:512] if error else None,
|
||||
updated_at=ts,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
Reference in New Issue
Block a user