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:
@@ -112,6 +112,15 @@ from .updater import (
|
||||
RollbackRequest,
|
||||
RollbackResponse,
|
||||
)
|
||||
from .webhooks import (
|
||||
SimpleEvent,
|
||||
WebhookCreateRequest,
|
||||
WebhookCreateResponse,
|
||||
WebhookResponse,
|
||||
WebhookSubscription,
|
||||
WebhookTestResponse,
|
||||
WebhookUpdateRequest,
|
||||
)
|
||||
from .workers import (
|
||||
StartAllResponse,
|
||||
StartFailure,
|
||||
@@ -218,6 +227,14 @@ __all__ = [
|
||||
"PushUpdateResult",
|
||||
"RollbackRequest",
|
||||
"RollbackResponse",
|
||||
# webhooks
|
||||
"SimpleEvent",
|
||||
"WebhookCreateRequest",
|
||||
"WebhookCreateResponse",
|
||||
"WebhookResponse",
|
||||
"WebhookSubscription",
|
||||
"WebhookTestResponse",
|
||||
"WebhookUpdateRequest",
|
||||
# workers
|
||||
"StartAllResponse",
|
||||
"StartFailure",
|
||||
|
||||
126
decnet/web/db/models/webhooks.py
Normal file
126
decnet/web/db/models/webhooks.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Webhook subscription table + CRUD DTOs.
|
||||
|
||||
Webhooks push DECNET bus events out to external SIEM / SOAR stacks
|
||||
(Wazuh, Shuffle, TheHive, n8n, ...). Each subscription carries a set
|
||||
of NATS-style topic patterns; the `decnet webhook` worker subscribes
|
||||
to the union of patterns across all enabled subscriptions and POSTs
|
||||
matching events to each matching URL with HMAC-SHA256 signing.
|
||||
|
||||
Simple mode (UI) exposes a friendly enum (`AttackerDetail`,
|
||||
`DeckyStatus`, `SystemStatus`) that expands to patterns at save time.
|
||||
Advanced mode lets an admin set raw patterns directly. Storage is
|
||||
always the expanded list — the enum is sugar at the router layer.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, List, Literal, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import BaseModel, Field as PydanticField, HttpUrl
|
||||
from sqlmodel import Field, SQLModel
|
||||
|
||||
|
||||
SimpleEvent = Literal["AttackerDetail", "DeckyStatus", "SystemStatus"]
|
||||
|
||||
|
||||
class WebhookSubscription(SQLModel, table=True):
|
||||
__tablename__ = "webhook_subscriptions"
|
||||
|
||||
uuid: str = Field(default_factory=lambda: str(uuid4()), primary_key=True)
|
||||
name: str = Field(index=True, unique=True)
|
||||
url: str
|
||||
secret: str # HMAC-SHA256 key; plaintext pre-v1 (see DEBT-037 §7)
|
||||
# JSON-encoded list[str] of NATS-style bus topic patterns.
|
||||
# Storing as TEXT keeps the schema portable across SQLite and MySQL
|
||||
# without pulling in dialect-specific JSON columns.
|
||||
topic_patterns: str = Field(default="[]")
|
||||
enabled: bool = Field(default=True, index=True)
|
||||
consecutive_failures: int = Field(default=0)
|
||||
last_success_at: Optional[datetime] = None
|
||||
last_failure_at: Optional[datetime] = None
|
||||
last_error: Optional[str] = None
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
def patterns(self) -> list[str]:
|
||||
"""Decode `topic_patterns` to a list. Returns [] on bad/empty JSON."""
|
||||
try:
|
||||
raw = json.loads(self.topic_patterns or "[]")
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
return [p for p in raw if isinstance(p, str)]
|
||||
|
||||
|
||||
# --- API Request / Response Models (Pydantic) ---
|
||||
|
||||
|
||||
class WebhookCreateRequest(BaseModel):
|
||||
name: str = PydanticField(..., min_length=1, max_length=64)
|
||||
url: HttpUrl
|
||||
# If secret is omitted, the router generates a secure random one and
|
||||
# returns it exactly once on the create response. After that, callers
|
||||
# can only rotate via PATCH.
|
||||
secret: Optional[str] = PydanticField(None, min_length=16, max_length=256)
|
||||
# At least one of simple_events / topic_patterns must be non-empty
|
||||
# (validated in the router, not Pydantic, so the 400 carries a clear
|
||||
# detail message).
|
||||
simple_events: List[SimpleEvent] = PydanticField(default_factory=list)
|
||||
topic_patterns: List[str] = PydanticField(default_factory=list)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class WebhookUpdateRequest(BaseModel):
|
||||
# Partial update — every field optional; the router diffs against the
|
||||
# current row and only writes what changed.
|
||||
name: Optional[str] = PydanticField(None, min_length=1, max_length=64)
|
||||
url: Optional[HttpUrl] = None
|
||||
secret: Optional[str] = PydanticField(None, min_length=16, max_length=256)
|
||||
simple_events: Optional[List[SimpleEvent]] = None
|
||||
topic_patterns: Optional[List[str]] = None
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class WebhookResponse(BaseModel):
|
||||
"""Public shape — deliberately omits `secret`."""
|
||||
|
||||
uuid: str
|
||||
name: str
|
||||
url: str
|
||||
topic_patterns: List[str]
|
||||
enabled: bool
|
||||
consecutive_failures: int
|
||||
last_success_at: Optional[datetime] = None
|
||||
last_failure_at: Optional[datetime] = None
|
||||
last_error: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class WebhookCreateResponse(WebhookResponse):
|
||||
"""Create-path response — carries the secret exactly once, for copy-out."""
|
||||
|
||||
secret: str
|
||||
|
||||
|
||||
class WebhookTestResponse(BaseModel):
|
||||
delivered: bool
|
||||
status_code: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
def _row_to_response_dict(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Normalize a DB row into the WebhookResponse dict shape.
|
||||
|
||||
Used by the CRUD router to decode `topic_patterns` JSON and drop the
|
||||
`secret` column before returning to the client.
|
||||
"""
|
||||
out = dict(row)
|
||||
raw = out.pop("topic_patterns", "[]")
|
||||
try:
|
||||
out["topic_patterns"] = json.loads(raw or "[]")
|
||||
except (ValueError, TypeError):
|
||||
out["topic_patterns"] = []
|
||||
out.pop("secret", None)
|
||||
return out
|
||||
@@ -431,3 +431,40 @@ class BaseRepository(ABC):
|
||||
|
||||
async def list_live_topology_ids(self) -> list[str]:
|
||||
return []
|
||||
|
||||
# --------------------------------------------------------- webhooks
|
||||
# Webhook subscriptions — external SIEM / SOAR egress configuration.
|
||||
# Default NotImplementedError keeps non-default backends honest; the
|
||||
# SQLModel-backed SQLite and MySQL repos override everything below.
|
||||
|
||||
async def create_webhook_subscription(self, data: dict[str, Any]) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_webhook_subscription(self, uuid: str) -> Optional[dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def get_webhook_subscription_by_name(
|
||||
self, name: str
|
||||
) -> Optional[dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def list_webhook_subscriptions(
|
||||
self, enabled_only: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
|
||||
async def update_webhook_subscription(
|
||||
self, uuid: str, patch: dict[str, Any]
|
||||
) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
async def delete_webhook_subscription(self, uuid: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
async def record_webhook_success(self, uuid: str, ts: Any) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def record_webhook_failure(
|
||||
self, uuid: str, ts: Any, error: str
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -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