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:
54
decnet/webhook/enums.py
Normal file
54
decnet/webhook/enums.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Simple-mode event enum → bus-topic pattern expansion.
|
||||
|
||||
The UI's Simple mode hides the NATS-style wildcard syntax behind three
|
||||
friendly choices. Storage is always the expanded pattern list — the
|
||||
enum exists only at the API boundary.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
# Patterns map to the bus topic hierarchy shipped by DEBT-031's worker
|
||||
# rollout (see `decnet/bus/topics.py`):
|
||||
# - attacker.{observed,fingerprinted,scored,session.started,session.ended}
|
||||
# - decky.{id}.{state,traffic}
|
||||
# - system.{log,<worker>.health,<worker>.control,bus.health}
|
||||
SIMPLE_EVENT_PATTERNS: dict[str, list[str]] = {
|
||||
"AttackerDetail": ["attacker.>"],
|
||||
"DeckyStatus": ["decky.*.state", "decky.*.traffic"],
|
||||
"SystemStatus": ["system.>"],
|
||||
}
|
||||
|
||||
|
||||
def expand_simple_events(names: list[str]) -> list[str]:
|
||||
"""Flatten a list of simple-event names into their bus patterns.
|
||||
|
||||
Unknown names are silently dropped — the router layer validates
|
||||
against the `SimpleEvent` Literal before calling us, so a bad value
|
||||
here means a programming error elsewhere, not user input.
|
||||
"""
|
||||
out: list[str] = []
|
||||
for n in names:
|
||||
out.extend(SIMPLE_EVENT_PATTERNS.get(n, []))
|
||||
return out
|
||||
|
||||
|
||||
def merge_patterns(
|
||||
simple: list[str] | None, advanced: list[str] | None
|
||||
) -> list[str]:
|
||||
"""Combine simple-event expansions with advanced raw patterns, deduped.
|
||||
|
||||
Order-preserving (simple expansions first, then advanced patterns in
|
||||
the order the user supplied them) so operators see deterministic
|
||||
patterns in API responses.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for p in expand_simple_events(simple or []):
|
||||
if p not in seen:
|
||||
seen.add(p)
|
||||
out.append(p)
|
||||
for p in advanced or []:
|
||||
if isinstance(p, str) and p and p not in seen:
|
||||
seen.add(p)
|
||||
out.append(p)
|
||||
return out
|
||||
Reference in New Issue
Block a user