Skip to content

Commit a7eb491

Browse files
authored
feat(webhooks): managed webhook subscriptions (phase 2) (#258)
Phase 2 of #256: DB-backed, operator-managed webhook subscriptions delivered through the same bus as the phase-1 config webhook. Multiple endpoints, runtime management via REST (no restart), per-endpoint event filter, signing secret, and delivery-status observability. - store: webhook_subscriptions table (migration 021) + CRUD + RecordDelivery. The HMAC secret is envelope-encrypted under the master key (per-row DEK wrapped under the master, secret sealed under the DEK — the cas table's scheme), so it never leaves the server in cleartext and is never returned by the API. - webhook.Dispatcher refactored from a single static target to fan-out: it delivers each event to the optional static config target plus all matching managed subscriptions from a SubscriptionSource, with per-target retry and status recording. StoreSource resolves active subscriptions and decrypts their secrets at delivery time. Phase-1 config behavior is preserved. - api: owner-scoped REST CRUD at /api/v1/webhook-subscriptions (admin sees all, operators see their own). The secret is write-only (omit to keep, "" to clear, value to set); responses expose only has_secret. config.ValidateWebhookURL is exported and reused for URL/SSRF validation. - serve.go: the dispatcher now always runs (managed subs are created at runtime) with a store-backed source; cert.expiring still folds into the bus. - api/openapi.yaml: the CRUD endpoints + schemas; contract tests validate the responses. docs/webhooks.md documents managed subscriptions. The route-scoping registry and the cross-operator leak test are extended to cover the new collection and single-resource routes. Web UI for subscriptions remains a follow-up (still tracked in #256). Refs #256
1 parent 3bfd1b3 commit a7eb491

20 files changed

Lines changed: 1375 additions & 85 deletions

api/openapi.yaml

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,86 @@ paths:
198198
items:
199199
$ref: "#/components/schemas/AuditEntry"
200200

201+
/api/v1/webhook-subscriptions:
202+
get:
203+
operationId: listWebhookSubscriptions
204+
summary: List webhook subscriptions visible to the operator.
205+
responses:
206+
"200":
207+
description: Subscriptions.
208+
content:
209+
application/json:
210+
schema:
211+
type: array
212+
items:
213+
$ref: "#/components/schemas/WebhookSubscription"
214+
post:
215+
operationId: createWebhookSubscription
216+
summary: Create a webhook subscription.
217+
requestBody:
218+
required: true
219+
content:
220+
application/json:
221+
schema:
222+
$ref: "#/components/schemas/WebhookSubscriptionRequest"
223+
responses:
224+
"201":
225+
description: Created subscription.
226+
content:
227+
application/json:
228+
schema:
229+
$ref: "#/components/schemas/WebhookSubscription"
230+
"400":
231+
$ref: "#/components/responses/Error"
232+
233+
/api/v1/webhook-subscriptions/{id}:
234+
get:
235+
operationId: getWebhookSubscription
236+
summary: Get a webhook subscription by id.
237+
parameters:
238+
- { in: path, name: id, required: true, schema: { type: string } }
239+
responses:
240+
"200":
241+
description: Subscription.
242+
content:
243+
application/json:
244+
schema:
245+
$ref: "#/components/schemas/WebhookSubscription"
246+
"404":
247+
$ref: "#/components/responses/Error"
248+
patch:
249+
operationId: updateWebhookSubscription
250+
summary: Update a webhook subscription.
251+
parameters:
252+
- { in: path, name: id, required: true, schema: { type: string } }
253+
requestBody:
254+
required: true
255+
content:
256+
application/json:
257+
schema:
258+
$ref: "#/components/schemas/WebhookSubscriptionRequest"
259+
responses:
260+
"200":
261+
description: Updated subscription.
262+
content:
263+
application/json:
264+
schema:
265+
$ref: "#/components/schemas/WebhookSubscription"
266+
"400":
267+
$ref: "#/components/responses/Error"
268+
"404":
269+
$ref: "#/components/responses/Error"
270+
delete:
271+
operationId: deleteWebhookSubscription
272+
summary: Delete a webhook subscription.
273+
parameters:
274+
- { in: path, name: id, required: true, schema: { type: string } }
275+
responses:
276+
"204":
277+
description: Deleted.
278+
"404":
279+
$ref: "#/components/responses/Error"
280+
201281
# Outbound webhooks the server POSTs to an operator-configured endpoint (#256).
202282
# Each delivery carries X-Nebula-Event (type), X-Nebula-Delivery (id), and
203283
# X-Nebula-Signature (sha256=<hmac> over the raw body). The handler/scanner emit
@@ -604,3 +684,59 @@ components:
604684
format: date-time
605685
data:
606686
$ref: "#/components/schemas/CertExpiringData"
687+
688+
# --- Managed webhook subscriptions (#256 phase 2) ---
689+
690+
WebhookSubscriptionRequest:
691+
type: object
692+
required: [url]
693+
properties:
694+
url:
695+
type: string
696+
events:
697+
type: ["array", "null"]
698+
items:
699+
type: string
700+
active:
701+
type: boolean
702+
allow_private:
703+
type: boolean
704+
secret:
705+
type: string
706+
description: Write-only HMAC secret. Omit to keep, "" to clear, value to set.
707+
708+
WebhookSubscription:
709+
type: object
710+
required: [id, owner_operator_id, url, active, allow_private, has_secret, consecutive_failures, created_at, updated_at]
711+
properties:
712+
id:
713+
type: string
714+
owner_operator_id:
715+
type: string
716+
url:
717+
type: string
718+
events:
719+
type: ["array", "null"]
720+
items:
721+
type: string
722+
active:
723+
type: boolean
724+
allow_private:
725+
type: boolean
726+
has_secret:
727+
type: boolean
728+
last_delivery_at:
729+
type: string
730+
format: date-time
731+
last_status:
732+
type: string
733+
last_error:
734+
type: string
735+
consecutive_failures:
736+
type: integer
737+
created_at:
738+
type: string
739+
format: date-time
740+
updated_at:
741+
type: string
742+
format: date-time

docs/webhooks.md

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,52 @@ webhooks:
2424
`url` is validated at startup (must be http/https; loopback/private/link-local
2525
targets are rejected unless `allow_private: true`).
2626

27-
## Events (phase 1)
27+
The config webhook is a single static target. For multiple endpoints, runtime
28+
management, and per-endpoint delivery status, use **managed subscriptions**
29+
below — both are delivered through the same bus.
30+
31+
## Managed subscriptions
32+
33+
Subscriptions are operator-owned rows managed through the REST API; they need no
34+
config and take effect immediately (no restart). Each is one delivery target
35+
with its own URL, event filter, signing secret, and delivery status.
36+
37+
```
38+
GET /api/v1/webhook-subscriptions # list (admin: all; operator: own)
39+
POST /api/v1/webhook-subscriptions # create
40+
GET /api/v1/webhook-subscriptions/{id} # get
41+
PATCH /api/v1/webhook-subscriptions/{id} # update
42+
DELETE /api/v1/webhook-subscriptions/{id} # delete
43+
```
44+
45+
Create body (all but `url` optional):
46+
47+
```json
48+
{
49+
"url": "https://hooks.example.com/team-a",
50+
"events": ["host.enrolled", "cert.expiring"],
51+
"active": true,
52+
"allow_private": false,
53+
"secret": "<hmac secret>"
54+
}
55+
```
56+
57+
- `events` empty/omitted means all events.
58+
- `secret` is **write-only**: it is stored envelope-encrypted under
59+
`NEBULA_MGMT_MASTER_KEY` (the same scheme as CA keys) and never returned.
60+
Responses expose only `has_secret`. On update, omit `secret` to keep it, send
61+
`""` to clear it, or a new value to replace it. A non-empty secret requires
62+
the master key to be configured.
63+
- `url` is SSRF-validated like the config webhook; `allow_private: true` opts a
64+
private/loopback target in.
65+
66+
Each subscription tracks `last_delivery_at`, `last_status` (`ok`/`failed`),
67+
`last_error`, and `consecutive_failures` for observability.
68+
69+
## Events
70+
71+
| Event | Fires when | `data` fields |
72+
|---|---|---|
2873

2974
| Event | Fires when | `data` fields |
3075
|---|---|---|
@@ -76,9 +121,8 @@ Reject deliveries that do not match.
76121
as a notification stream, not a system of record — the audit log remains the
77122
authoritative history.
78123

79-
## Not in phase 1
124+
## Not yet
80125

81-
- Managed subscriptions (multiple endpoints, CRUD, per-subscription status) —
82-
phase 1 is a single config-driven subscription.
126+
- A Web UI for managing subscriptions (the REST API exists today).
83127
- `ca.expiring` and other CA-lifecycle events.
84128
- A persistent dead-letter queue and delivery dashboards.

internal/api/metrics.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ const (
7272
auditOperatorAPIKeyCreate = "operator.api_key.create"
7373
auditOperatorAPIKeyRevoke = "operator.api_key.revoke"
7474
auditSettingsEnforce2FA = "settings.enforce_2fa"
75+
auditWebhookSubCreate = "webhook_subscription.create"
76+
auditWebhookSubUpdate = "webhook_subscription.update"
77+
auditWebhookSubDelete = "webhook_subscription.delete"
7578
)
7679

7780
// Audit reason codes used in the `details` field of host.auth.failed audit
@@ -197,6 +200,7 @@ func newMetrics(s store.Store) *metrics {
197200
auditOperatorCreate, auditOperatorDisable, auditOperatorEnable,
198201
auditOperatorAPIKeyCreate, auditOperatorAPIKeyRevoke,
199202
auditSettingsEnforce2FA,
203+
auditWebhookSubCreate, auditWebhookSubUpdate, auditWebhookSubDelete,
200204
} {
201205
m.auditEntries.WithLabelValues(action).Add(0)
202206
}

internal/api/scoping_property_test.go

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,10 @@ var protectedGETScoping = map[string]listScoping{
6363
"/api/v1/agent/updates": publicRoute, // agent poll, signed-poll auth — not operator bearer
6464

6565
// Collection reads — must scope non-admins to owned CAs.
66-
"/api/v1/networks": scopedToOwner,
67-
"/api/v1/hosts": scopedToOwner,
68-
"/api/v1/cas": scopedToOwner,
66+
"/api/v1/networks": scopedToOwner,
67+
"/api/v1/hosts": scopedToOwner,
68+
"/api/v1/cas": scopedToOwner,
69+
"/api/v1/webhook-subscriptions": scopedToOwner,
6970

7071
// Admin-only reads — non-admin gets 403, no tenant data in the body.
7172
"/api/v1/operators": adminOnly,
@@ -75,10 +76,11 @@ var protectedGETScoping = map[string]listScoping{
7576
"/api/v1/settings": adminOnly,
7677

7778
// Single-resource reads — guarded per-row by canAccess*.
78-
"/api/v1/networks/{id}": singleResource,
79-
"/api/v1/networks/{id}/firewall": singleResource,
80-
"/api/v1/hosts/{id}": singleResource,
81-
"/api/v1/cas/{id}": singleResource,
79+
"/api/v1/networks/{id}": singleResource,
80+
"/api/v1/networks/{id}/firewall": singleResource,
81+
"/api/v1/hosts/{id}": singleResource,
82+
"/api/v1/cas/{id}": singleResource,
83+
"/api/v1/webhook-subscriptions/{id}": singleResource,
8284
}
8385

8486
// TestProtectedGETRoutesAreClassified fails the moment setupRoutes registers
@@ -116,12 +118,16 @@ func TestProtectedGETRoutesAreClassified(t *testing.T) {
116118
func TestListEndpointsScopeToOwner(t *testing.T) {
117119
srv, st := newTestServer(t)
118120

119-
keyA, _, caA := createOperatorWithCA(t, srv)
120-
keyB, _, caB := createOperatorWithCA(t, srv)
121+
keyA, opA, caA := createOperatorWithCA(t, srv)
122+
keyB, opB, caB := createOperatorWithCA(t, srv)
121123
// Distinct CIDRs/IPs so the seed is robust to any nebula_ip uniqueness
122124
// scheme; ownership is what the test cares about, not addressing.
123125
seedNetworkAndHost(t, st, caA.ID, "a", "10.10.0.0/24", "10.10.0.10")
124126
seedNetworkAndHost(t, st, caB.ID, "b", "10.20.0.0/24", "10.20.0.10")
127+
// Webhook subscriptions carry no ca_id, so embed the owner's CA id in the
128+
// URL to satisfy the generic owner-leak assertions below.
129+
seedWebhookSub(t, st, opA.ID, "https://example.com/"+caA.ID)
130+
seedWebhookSub(t, st, opB.ID, "https://example.com/"+caB.ID)
125131

126132
for route, scoping := range protectedGETScoping {
127133
switch scoping {
@@ -178,6 +184,20 @@ func concretePath(route string) string {
178184
return strings.ReplaceAll(route, "{id}", "test-admin")
179185
}
180186

187+
// seedWebhookSub inserts a webhook subscription owned by ownerID directly via
188+
// the store, bypassing the create handler's owner derivation so cross-operator
189+
// fixtures can be set up.
190+
func seedWebhookSub(t *testing.T, st *store.SQLiteStore, ownerID, url string) {
191+
t.Helper()
192+
require.NoError(t, st.CreateWebhookSubscription(context.Background(), &models.WebhookSubscription{
193+
ID: uuid.New().String(),
194+
OwnerOperatorID: ownerID,
195+
URL: url,
196+
Active: true,
197+
CreatedAt: time.Now(),
198+
}))
199+
}
200+
181201
// seedNetworkAndHost inserts a network and a host bound to caID directly via
182202
// the store, bypassing the create handlers (which would re-derive ownership
183203
// from the caller). This lets the scoping tests set up cross-operator

internal/api/server.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,12 @@ func (s *Server) setupRoutes() {
239239
r.Post("/api/v1/cas/{id}/rotate", s.handleRotateCA)
240240
r.Get("/api/v1/settings", s.handleGetSettings)
241241
r.Patch("/api/v1/settings", s.handlePatchSettings)
242+
243+
r.Get("/api/v1/webhook-subscriptions", s.handleListWebhookSubscriptions)
244+
r.Post("/api/v1/webhook-subscriptions", s.handleCreateWebhookSubscription)
245+
r.Get("/api/v1/webhook-subscriptions/{id}", s.handleGetWebhookSubscription)
246+
r.Patch("/api/v1/webhook-subscriptions/{id}", s.handleUpdateWebhookSubscription)
247+
r.Delete("/api/v1/webhook-subscriptions/{id}", s.handleDeleteWebhookSubscription)
242248
})
243249

244250
s.router = r
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
)
10+
11+
// TestContract_WebhookSubscriptions validates the CRUD responses against the
12+
// OpenAPI spec, so the managed-subscription API cannot drift from its contract.
13+
func TestContract_WebhookSubscriptions(t *testing.T) {
14+
v := loadContract(t)
15+
srv, _ := newTestServer(t)
16+
17+
// Create.
18+
body := `{"url":"https://hooks.example.com/c","events":["host.enrolled","cert.rotated"],"secret":"s3cret"}`
19+
req := httptest.NewRequest(http.MethodPost, "/api/v1/webhook-subscriptions", bytes.NewBufferString(body))
20+
authRequest(req)
21+
rec := serve(srv, req)
22+
if rec.Code != http.StatusCreated {
23+
t.Fatalf("create: %d / %s", rec.Code, rec.Body.String())
24+
}
25+
assertContract(t, v, req, rec)
26+
var sub struct {
27+
ID string `json:"id"`
28+
}
29+
_ = json.Unmarshal(rec.Body.Bytes(), &sub)
30+
31+
// List.
32+
lreq := httptest.NewRequest(http.MethodGet, "/api/v1/webhook-subscriptions", nil)
33+
authRequest(lreq)
34+
assertContract(t, v, lreq, serve(srv, lreq))
35+
36+
// Get.
37+
greq := httptest.NewRequest(http.MethodGet, "/api/v1/webhook-subscriptions/"+sub.ID, nil)
38+
authRequest(greq)
39+
assertContract(t, v, greq, serve(srv, greq))
40+
41+
// Update.
42+
ureq := httptest.NewRequest(http.MethodPatch, "/api/v1/webhook-subscriptions/"+sub.ID,
43+
bytes.NewBufferString(`{"url":"https://hooks.example.com/d","active":false}`))
44+
authRequest(ureq)
45+
urec := serve(srv, ureq)
46+
if urec.Code != http.StatusOK {
47+
t.Fatalf("update: %d / %s", urec.Code, urec.Body.String())
48+
}
49+
assertContract(t, v, ureq, urec)
50+
}

0 commit comments

Comments
 (0)