mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
feat: outbound webhooks — signed event delivery + delivery log
Every audited action now emits a "resource.action" event (bare actions get resource-qualified; already-qualified actions like service.clone pass through). Webhooks subscribe per-user with exact names, resource.* wildcards, or *. - schema: outbound_webhooks + webhook_deliveries (sqlc + goose) - dispatcher: 4 workers, 512-queue, 3 attempts with backoff, 10s timeout; deliveries record status/http-code/body(4KiB)/duration - signing: X-Containr-Signature sha256=<hmac> over raw body; X-Containr-Event + X-Containr-Delivery headers; custom headers per hook - api: CRUD + /deliveries + /test (ping) under authed group - fix: SetAuditDB never called — LogAudit was a silent no-op at every call site; now wired at boot - coverage: service create/delete audited (previously uninstrumented) - cli: containr webhooks list|create|update|delete|deliveries|test - mcp: 6 tools, delete requires confirm - frontend: /settings/webhooks page — list/create/expand-deliveries/ test/disable/delete, secret-shown-once banner; nav + account menu - openapi + regenerated types; CLI.md + docs snapshot; roadmap ticks
This commit is contained in:
1 parent
5c2f2861bd
commit
f499678964
23 files changed
+2278
-7
No files matched your search
@@ -52,6 +52,8 @@ func LogAudit(userID, resource, resourceID, action string, details map[string]in
|
||||
|
||||
if err != nil {
|
||||
}
|
||||
|
||||
emitWebhookEvent(userID, resource, resourceID, action, details)
|
||||
}
|
||||
|
||||
func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, details map[string]interface{}) {
|
||||
@@ -79,6 +81,8 @@ func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, de
|
||||
|
||||
if err != nil {
|
||||
}
|
||||
|
||||
emitWebhookEvent(userIDStr, resource, resourceID, action, details)
|
||||
}
|
||||
|
||||
var auditDB *database.DB
|
||||
|
||||
@@ -23,6 +23,11 @@ import (
|
||||
)
|
||||
|
||||
func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) {
|
||||
// Audit + outbound webhook delivery need a package-level handle for
|
||||
// call sites that don't carry a gin context.
|
||||
SetAuditDB(db)
|
||||
StartWebhookDispatcher()
|
||||
|
||||
// Expose Better Auth through backend so frontend can use a single backend origin.
|
||||
setupAuthProxyRoutes(router, cfg, db)
|
||||
|
||||
@@ -328,6 +333,14 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
||||
// WebSocket endpoint
|
||||
authed.GET("/ws", handleWebSocket)
|
||||
|
||||
// Outbound webhooks — signed event delivery to user HTTP endpoints
|
||||
authed.GET("/webhooks", handleListOutboundWebhooks)
|
||||
authed.POST("/webhooks", handleCreateOutboundWebhook)
|
||||
authed.PATCH("/webhooks/:id", handleUpdateOutboundWebhook)
|
||||
authed.DELETE("/webhooks/:id", handleDeleteOutboundWebhook)
|
||||
authed.GET("/webhooks/:id/deliveries", handleListOutboundWebhookDeliveries)
|
||||
authed.POST("/webhooks/:id/test", handleTestOutboundWebhook)
|
||||
|
||||
// Owned-resource mutations — handlers scope to the caller and
|
||||
// admit admins. New projects land unapproved until an admin
|
||||
// publishes them.
|
||||
|
||||
@@ -454,6 +454,10 @@ func handleCreateService(c *gin.Context) {
|
||||
}
|
||||
service.Domains = loadServiceDomains(db.(*database.DB), service.ID)
|
||||
|
||||
LogAuditWithRequest(c, "service", service.ID.String(), "create", map[string]interface{}{
|
||||
"name": service.Name, "type": service.Type, "project_id": service.ProjectID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"service": service})
|
||||
}
|
||||
|
||||
@@ -876,5 +880,7 @@ func handleDeleteService(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
LogAuditWithRequest(c, "service", serviceID.String(), "delete", map[string]interface{}{})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Service deleted successfully"})
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"containr/internal/database"
|
||||
"containr/internal/database/sqlcdb"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
)
|
||||
|
||||
// Outbound webhooks: every audited action (LogAudit) emits an event named
|
||||
// "resource.action" (e.g. "service.deploy"). Webhooks subscribe with an
|
||||
// event list supporting "service.*" and "*" wildcards. Deliveries are
|
||||
// HMAC-signed and retried up to 3 times with backoff; each attempt lands
|
||||
// in webhook_deliveries.
|
||||
|
||||
var (
|
||||
webhookQueue = make(chan webhookJob, 512)
|
||||
webhookWorkers sync.Once
|
||||
)
|
||||
|
||||
type webhookJob struct {
|
||||
Webhook sqlcdb.OutboundWebhook
|
||||
Event string
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
type webhookEventPayload struct {
|
||||
Event string `json:"event"`
|
||||
Resource string `json:"resource"`
|
||||
ResourceID string `json:"resource_id"`
|
||||
Action string `json:"action"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Details map[string]interface{} `json:"details,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// emitWebhookEvent fans an audited action out to all enabled webhooks whose
|
||||
// event list matches. Called from LogAudit — fire-and-forget.
|
||||
func emitWebhookEvent(userID, resource, resourceID, action string, details map[string]interface{}) {
|
||||
db := GetAuditDB()
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
// Actions that already carry a resource prefix ("service.clone") pass
|
||||
// through unchanged; bare actions ("create") get resource-qualified.
|
||||
event := action
|
||||
if !strings.Contains(action, ".") {
|
||||
event = resource + "." + action
|
||||
}
|
||||
|
||||
queries := sqlcdb.New(db.DB)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
hooks, err := queries.ListEnabledOutboundWebhooks(ctx)
|
||||
if err != nil || len(hooks) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(webhookEventPayload{
|
||||
Event: event, Resource: resource, ResourceID: resourceID,
|
||||
Action: action, UserID: userID, Details: details, Timestamp: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
uid, uidErr := uuid.Parse(strings.TrimSpace(userID))
|
||||
for _, hook := range hooks {
|
||||
if !webhookEventMatches(hook.Events, event) {
|
||||
continue
|
||||
}
|
||||
// Webhooks only receive events for their owner — unless subscribed to "*"
|
||||
// is deliberate admin reach; owner scoping keeps per-user isolation.
|
||||
if uidErr != nil || hook.UserID != uid {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case webhookQueue <- webhookJob{Webhook: hook, Event: event, Payload: payload}:
|
||||
default:
|
||||
// Queue full: drop rather than block the request path.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webhookEventMatches(events json.RawMessage, event string) bool {
|
||||
var subs []string
|
||||
if err := json.Unmarshal(events, &subs); err != nil || len(subs) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, sub := range subs {
|
||||
sub = strings.TrimSpace(sub)
|
||||
if sub == "*" || sub == event {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(sub, ".*") && strings.HasPrefix(event, strings.TrimSuffix(sub, "*")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StartWebhookDispatcher launches delivery workers; call once at boot.
|
||||
func StartWebhookDispatcher() {
|
||||
webhookWorkers.Do(func() {
|
||||
for i := 0; i < 4; i++ {
|
||||
go webhookDeliveryWorker()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func webhookDeliveryWorker() {
|
||||
for job := range webhookQueue {
|
||||
deliverWebhook(job)
|
||||
}
|
||||
}
|
||||
|
||||
var webhookHTTPClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
func deliverWebhook(job webhookJob) {
|
||||
db := GetAuditDB()
|
||||
if db == nil {
|
||||
return
|
||||
}
|
||||
queries := sqlcdb.New(db.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
deliveryID := uuid.New()
|
||||
_ = queries.CreateWebhookDelivery(ctx, sqlcdb.CreateWebhookDeliveryParams{
|
||||
ID: deliveryID,
|
||||
WebhookID: job.Webhook.ID,
|
||||
Event: job.Event,
|
||||
Payload: pqtype.NullRawMessage{RawMessage: job.Payload, Valid: true},
|
||||
Status: "pending",
|
||||
})
|
||||
|
||||
var lastStatus string = "failed"
|
||||
var respStatus sql.NullInt32
|
||||
var respBody sql.NullString
|
||||
var durationMs sql.NullInt32
|
||||
var deliveredAt sql.NullTime
|
||||
|
||||
for attempt := int32(1); attempt <= 3; attempt++ {
|
||||
start := time.Now()
|
||||
status, code, body := postWebhook(job, deliveryID, attempt)
|
||||
durationMs = sql.NullInt32{Int32: int32(time.Since(start).Milliseconds()), Valid: true}
|
||||
respStatus = code
|
||||
respBody = body
|
||||
if status == "success" {
|
||||
lastStatus = "success"
|
||||
deliveredAt = sql.NullTime{Time: time.Now().UTC(), Valid: true}
|
||||
_ = queries.UpdateWebhookDeliveryResult(ctx, sqlcdb.UpdateWebhookDeliveryResultParams{
|
||||
ID: deliveryID,
|
||||
Status: lastStatus,
|
||||
ResponseStatus: respStatus,
|
||||
ResponseBody: respBody,
|
||||
Attempts: attempt,
|
||||
DurationMs: durationMs,
|
||||
DeliveredAt: deliveredAt,
|
||||
})
|
||||
return
|
||||
}
|
||||
if attempt < 3 {
|
||||
time.Sleep(time.Duration(attempt*attempt) * 2 * time.Second)
|
||||
}
|
||||
}
|
||||
_ = queries.UpdateWebhookDeliveryResult(ctx, sqlcdb.UpdateWebhookDeliveryResultParams{
|
||||
ID: deliveryID,
|
||||
Status: lastStatus,
|
||||
ResponseStatus: respStatus,
|
||||
ResponseBody: respBody,
|
||||
Attempts: 3,
|
||||
DurationMs: durationMs,
|
||||
DeliveredAt: deliveredAt,
|
||||
})
|
||||
}
|
||||
|
||||
func postWebhook(job webhookJob, deliveryID uuid.UUID, attempt int32) (string, sql.NullInt32, sql.NullString) {
|
||||
req, err := http.NewRequest(http.MethodPost, job.Webhook.Url, bytes.NewReader(job.Payload))
|
||||
if err != nil {
|
||||
return "failed", sql.NullInt32{}, sql.NullString{String: err.Error(), Valid: true}
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Containr-Webhook/1.0")
|
||||
req.Header.Set("X-Containr-Event", job.Event)
|
||||
req.Header.Set("X-Containr-Delivery", deliveryID.String())
|
||||
if job.Webhook.Secret != "" {
|
||||
mac := hmac.New(sha256.New, []byte(job.Webhook.Secret))
|
||||
mac.Write(job.Payload)
|
||||
req.Header.Set("X-Containr-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
if job.Webhook.Headers.Valid {
|
||||
var custom map[string]string
|
||||
if err := json.Unmarshal(job.Webhook.Headers.RawMessage, &custom); err == nil {
|
||||
for k, v := range custom {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := webhookHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "failed", sql.NullInt32{}, sql.NullString{String: err.Error(), Valid: true}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
code := sql.NullInt32{Int32: int32(resp.StatusCode), Valid: true}
|
||||
bodyStr := sql.NullString{String: string(body), Valid: len(body) > 0}
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return "success", code, bodyStr
|
||||
}
|
||||
return "failed", code, bodyStr
|
||||
}
|
||||
|
||||
// ---------- HTTP API ----------
|
||||
|
||||
type outboundWebhookResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Secret string `json:"secret,omitempty"`
|
||||
Events []string `json:"events"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func outboundWebhookJSON(row sqlcdb.OutboundWebhook, revealSecret bool) outboundWebhookResponse {
|
||||
var events []string
|
||||
_ = json.Unmarshal(row.Events, &events)
|
||||
if events == nil {
|
||||
events = []string{}
|
||||
}
|
||||
var headers map[string]string
|
||||
if row.Headers.Valid {
|
||||
_ = json.Unmarshal(row.Headers.RawMessage, &headers)
|
||||
}
|
||||
resp := outboundWebhookResponse{
|
||||
ID: row.ID.String(),
|
||||
Name: row.Name,
|
||||
URL: row.Url,
|
||||
Events: events,
|
||||
Headers: headers,
|
||||
Enabled: row.Enabled,
|
||||
}
|
||||
if revealSecret {
|
||||
resp.Secret = row.Secret
|
||||
}
|
||||
if row.CreatedAt.Valid {
|
||||
resp.CreatedAt = row.CreatedAt.Time
|
||||
}
|
||||
if row.UpdatedAt.Valid {
|
||||
resp.UpdatedAt = row.UpdatedAt.Time
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func validWebhookURL(raw string) error {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return errors.New("url must be a valid http(s) URL")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type webhookMutationRequest struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Secret *string `json:"secret"`
|
||||
Events []string `json:"events"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func handleListOutboundWebhooks(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
rows, err := sqlcdb.New(db.DB).ListOutboundWebhooksByUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list webhooks"})
|
||||
return
|
||||
}
|
||||
out := make([]outboundWebhookResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, outboundWebhookJSON(row, false))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"webhooks": out})
|
||||
}
|
||||
|
||||
func handleCreateOutboundWebhook(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req webhookMutationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "name is required")
|
||||
return
|
||||
}
|
||||
if err := validWebhookURL(req.URL); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
|
||||
return
|
||||
}
|
||||
if len(req.Events) == 0 {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "at least one event subscription is required (e.g. \"service.*\" or \"*\")")
|
||||
return
|
||||
}
|
||||
|
||||
secret := ""
|
||||
if req.Secret != nil {
|
||||
secret = strings.TrimSpace(*req.Secret)
|
||||
}
|
||||
if secret == "" {
|
||||
buf := make([]byte, 24)
|
||||
_, _ = rand.Read(buf)
|
||||
secret = hex.EncodeToString(buf)
|
||||
}
|
||||
eventsJSON, _ := json.Marshal(req.Events)
|
||||
var headersJSON pqtype.NullRawMessage
|
||||
if len(req.Headers) > 0 {
|
||||
if raw, err := json.Marshal(req.Headers); err == nil {
|
||||
headersJSON = pqtype.NullRawMessage{RawMessage: raw, Valid: true}
|
||||
}
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
id := uuid.New()
|
||||
err := sqlcdb.New(db.DB).CreateOutboundWebhook(c.Request.Context(), sqlcdb.CreateOutboundWebhookParams{
|
||||
ID: id,
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Url: strings.TrimSpace(req.URL),
|
||||
Secret: secret,
|
||||
Events: eventsJSON,
|
||||
Headers: headersJSON,
|
||||
Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create webhook"})
|
||||
return
|
||||
}
|
||||
LogAuditWithRequest(c, "webhook", id.String(), "create", map[string]interface{}{"name": req.Name})
|
||||
c.JSON(http.StatusCreated, outboundWebhookJSON(sqlcdb.OutboundWebhook{
|
||||
ID: id, Name: req.Name, Url: strings.TrimSpace(req.URL), Secret: secret,
|
||||
Events: eventsJSON, Headers: headersJSON, Enabled: enabled,
|
||||
CreatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true},
|
||||
}, true))
|
||||
}
|
||||
|
||||
func handleUpdateOutboundWebhook(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
webhookID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
queries := sqlcdb.New(db.DB)
|
||||
row, err := queries.GetOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.GetOutboundWebhookByIDAndUserParams{
|
||||
ID: webhookID, UserID: userID,
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "Webhook not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load webhook"})
|
||||
return
|
||||
}
|
||||
|
||||
var req webhookMutationRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
name := row.Name
|
||||
if req.Name != "" {
|
||||
name = strings.TrimSpace(req.Name)
|
||||
}
|
||||
targetURL := row.Url
|
||||
if req.URL != "" {
|
||||
if err := validWebhookURL(req.URL); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", err.Error())
|
||||
return
|
||||
}
|
||||
targetURL = strings.TrimSpace(req.URL)
|
||||
}
|
||||
secret := row.Secret
|
||||
if req.Secret != nil {
|
||||
secret = strings.TrimSpace(*req.Secret)
|
||||
}
|
||||
eventsJSON := row.Events
|
||||
if req.Events != nil {
|
||||
if len(req.Events) == 0 {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "at least one event subscription is required")
|
||||
return
|
||||
}
|
||||
eventsJSON, _ = json.Marshal(req.Events)
|
||||
}
|
||||
headersJSON := row.Headers
|
||||
if req.Headers != nil {
|
||||
if raw, err := json.Marshal(req.Headers); err == nil {
|
||||
headersJSON = pqtype.NullRawMessage{RawMessage: raw, Valid: true}
|
||||
}
|
||||
}
|
||||
enabled := row.Enabled
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
|
||||
err = queries.UpdateOutboundWebhook(c.Request.Context(), sqlcdb.UpdateOutboundWebhookParams{
|
||||
ID: webhookID, UserID: userID,
|
||||
Name: name, Url: targetURL, Secret: secret,
|
||||
Events: eventsJSON, Headers: headersJSON, Enabled: enabled,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update webhook"})
|
||||
return
|
||||
}
|
||||
LogAuditWithRequest(c, "webhook", webhookID.String(), "update", map[string]interface{}{"name": name})
|
||||
row.Name, row.Url, row.Secret, row.Events, row.Headers, row.Enabled = name, targetURL, secret, eventsJSON, headersJSON, enabled
|
||||
c.JSON(http.StatusOK, outboundWebhookJSON(row, true))
|
||||
}
|
||||
|
||||
func handleDeleteOutboundWebhook(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
webhookID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
err = sqlcdb.New(db.DB).DeleteOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.DeleteOutboundWebhookByIDAndUserParams{
|
||||
ID: webhookID, UserID: userID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete webhook"})
|
||||
return
|
||||
}
|
||||
LogAuditWithRequest(c, "webhook", webhookID.String(), "delete", map[string]interface{}{})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Webhook deleted"})
|
||||
}
|
||||
|
||||
func handleListOutboundWebhookDeliveries(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
webhookID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
queries := sqlcdb.New(db.DB)
|
||||
if _, err := queries.GetOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.GetOutboundWebhookByIDAndUserParams{
|
||||
ID: webhookID, UserID: userID,
|
||||
}); errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "Webhook not found")
|
||||
return
|
||||
}
|
||||
rows, err := queries.ListWebhookDeliveriesByWebhook(c.Request.Context(), webhookID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list deliveries"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"deliveries": rows})
|
||||
}
|
||||
|
||||
func handleTestOutboundWebhook(c *gin.Context) {
|
||||
userID, ok := requireAuthenticatedUserUUID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
webhookID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "Invalid webhook id")
|
||||
return
|
||||
}
|
||||
db := c.MustGet("db").(*database.DB)
|
||||
row, err := sqlcdb.New(db.DB).GetOutboundWebhookByIDAndUser(c.Request.Context(), sqlcdb.GetOutboundWebhookByIDAndUserParams{
|
||||
ID: webhookID, UserID: userID,
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(c, http.StatusNotFound, "NOT_FOUND", "Webhook not found")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load webhook"})
|
||||
return
|
||||
}
|
||||
if !row.Enabled {
|
||||
respondError(c, http.StatusBadRequest, "VALIDATION", "Webhook is disabled")
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(webhookEventPayload{
|
||||
Event: "ping", Resource: "webhook", ResourceID: webhookID.String(),
|
||||
Action: "ping", UserID: userID.String(), Timestamp: time.Now().UTC(),
|
||||
})
|
||||
select {
|
||||
case webhookQueue <- webhookJob{Webhook: row, Event: "ping", Payload: payload}:
|
||||
c.JSON(http.StatusAccepted, gin.H{"message": "Test delivery queued"})
|
||||
default:
|
||||
respondError(c, http.StatusServiceUnavailable, "UNAVAILABLE", "Delivery queue is full")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWebhookEventMatches(t *testing.T) {
|
||||
subs := func(list ...string) json.RawMessage {
|
||||
raw, _ := json.Marshal(list)
|
||||
return raw
|
||||
}
|
||||
cases := []struct {
|
||||
events json.RawMessage
|
||||
event string
|
||||
want bool
|
||||
}{
|
||||
{subs("service.*"), "service.deploy", true},
|
||||
{subs("service.*"), "database.create", false},
|
||||
{subs("*"), "service.deploy", true},
|
||||
{subs("deployment.fail", "service.*"), "deployment.fail", true},
|
||||
{subs("deployment.fail"), "deployment.success", false},
|
||||
{subs(" ping "), "ping", true},
|
||||
{subs(), "service.deploy", false},
|
||||
{json.RawMessage(`"not-a-list"`), "service.deploy", false},
|
||||
{json.RawMessage(`invalid`), "service.deploy", false},
|
||||
}
|
||||
for i, tc := range cases {
|
||||
if got := webhookEventMatches(tc.events, tc.event); got != tc.want {
|
||||
t.Errorf("case %d: matches(%s, %q) = %v, want %v", i, tc.events, tc.event, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var webhookEvents []string
|
||||
var webhookHeaders []string
|
||||
var webhookSecret string
|
||||
var webhookName string
|
||||
|
||||
// WebhooksCmd manages outbound webhooks — signed HTTP event delivery.
|
||||
var WebhooksCmd = &cobra.Command{
|
||||
Use: "webhooks",
|
||||
Aliases: []string{"hooks"},
|
||||
Short: "Manage outbound webhooks (signed event delivery)",
|
||||
}
|
||||
|
||||
var webhookListCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List webhooks",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/webhooks", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "webhooks")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, w := range items {
|
||||
events := ""
|
||||
if ev, ok := w["events"].([]interface{}); ok {
|
||||
names := make([]string, 0, len(ev))
|
||||
for _, e := range ev {
|
||||
if s, ok := e.(string); ok {
|
||||
names = append(names, s)
|
||||
}
|
||||
}
|
||||
events = strings.Join(names, ",")
|
||||
}
|
||||
rows = append(rows, []string{
|
||||
str(w, "id"), str(w, "name"), str(w, "url"), events, str(w, "enabled"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "NAME", "URL", "EVENTS", "ENABLED"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func webhookBody(req map[string]interface{}) (map[string]interface{}, error) {
|
||||
if len(webhookEvents) > 0 {
|
||||
req["events"] = webhookEvents
|
||||
}
|
||||
if webhookSecret != "" {
|
||||
req["secret"] = webhookSecret
|
||||
}
|
||||
if webhookName != "" {
|
||||
req["name"] = webhookName
|
||||
}
|
||||
if len(webhookHeaders) > 0 {
|
||||
hdrs, err := parseKVFlags(webhookHeaders)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req["headers"] = hdrs
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
var webhookCreateCmd = &cobra.Command{
|
||||
Use: "create <name> <url>",
|
||||
Short: "Create a webhook (events repeat with --event; use \"*\" for all)",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if len(webhookEvents) == 0 {
|
||||
return fmt.Errorf("at least one --event is required (e.g. --event 'service.*' or --event '*')")
|
||||
}
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := webhookBody(map[string]interface{}{"name": args[0], "url": args[1]})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", "/webhooks", body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var webhookUpdateCmd = &cobra.Command{
|
||||
Use: "update <id>",
|
||||
Short: "Update a webhook (--name --url --event --secret --header --enabled/--disabled)",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := webhookBody(map[string]interface{}{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if url, _ := cmd.Flags().GetString("url"); url != "" {
|
||||
body["url"] = url
|
||||
}
|
||||
if cmd.Flags().Changed("enabled") {
|
||||
body["enabled"] = true
|
||||
}
|
||||
if cmd.Flags().Changed("disabled") {
|
||||
body["enabled"] = false
|
||||
}
|
||||
data, err := c.Do("PATCH", "/webhooks/"+args[0], body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var webhookDeleteCmd = &cobra.Command{
|
||||
Use: "delete <id>",
|
||||
Short: "Delete a webhook",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if !Confirm("Delete webhook " + args[0] + "?") {
|
||||
return nil
|
||||
}
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("DELETE", "/webhooks/"+args[0], nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var webhookDeliveriesCmd = &cobra.Command{
|
||||
Use: "deliveries <id>",
|
||||
Short: "Show recent delivery attempts for a webhook",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("GET", "/webhooks/"+args[0]+"/deliveries", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := unwrapList(data, "deliveries")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, d := range items {
|
||||
rows = append(rows, []string{
|
||||
str(d, "id"), str(d, "event"), str(d, "status"),
|
||||
str(d, "response_status"), str(d, "attempts"), relTime(d, "created_at"),
|
||||
})
|
||||
}
|
||||
printRows(data, []string{"ID", "EVENT", "STATUS", "HTTP", "TRIES", "AGE"}, rows)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
var webhookTestCmd = &cobra.Command{
|
||||
Use: "test <id>",
|
||||
Short: "Queue a test ping delivery",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := c.Do("POST", "/webhooks/"+args[0]+"/test", map[string]interface{}{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
PrintRaw(data)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
webhookCreateCmd.Flags().StringArrayVar(&webhookEvents, "event", nil, "Event subscription (e.g. 'service.*', 'deployment.fail', '*'; repeatable)")
|
||||
webhookCreateCmd.Flags().StringVar(&webhookSecret, "secret", "", "Signing secret (auto-generated when omitted)")
|
||||
webhookCreateCmd.Flags().StringArrayVar(&webhookHeaders, "header", nil, "Extra request header KEY=VALUE (repeatable)")
|
||||
webhookUpdateCmd.Flags().StringVar(&webhookName, "name", "", "New name")
|
||||
webhookUpdateCmd.Flags().String("url", "", "New URL")
|
||||
webhookUpdateCmd.Flags().StringArrayVar(&webhookEvents, "event", nil, "Replace event subscriptions (repeatable)")
|
||||
webhookUpdateCmd.Flags().StringVar(&webhookSecret, "secret", "", "New signing secret")
|
||||
webhookUpdateCmd.Flags().StringArrayVar(&webhookHeaders, "header", nil, "Replace request headers KEY=VALUE (repeatable)")
|
||||
webhookUpdateCmd.Flags().Bool("enabled", false, "Enable the webhook")
|
||||
webhookUpdateCmd.Flags().Bool("disabled", false, "Disable the webhook")
|
||||
|
||||
WebhooksCmd.AddCommand(webhookListCmd, webhookCreateCmd, webhookUpdateCmd, webhookDeleteCmd, webhookDeliveriesCmd, webhookTestCmd)
|
||||
}
|
||||
@@ -87,6 +87,7 @@ func init() {
|
||||
rootCmd.AddCommand(commands.GatewayCmd)
|
||||
rootCmd.AddCommand(commands.AdminCmd)
|
||||
rootCmd.AddCommand(commands.NotificationsCmd)
|
||||
rootCmd.AddCommand(commands.WebhooksCmd)
|
||||
rootCmd.AddCommand(commands.UpCmd)
|
||||
}
|
||||
|
||||
|
||||
@@ -175,6 +175,19 @@ type Notification struct {
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
}
|
||||
|
||||
type OutboundWebhook struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Url string `json:"url"`
|
||||
Secret string `json:"secret"`
|
||||
Events json.RawMessage `json:"events"`
|
||||
Headers pqtype.NullRawMessage `json:"headers"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -286,3 +299,17 @@ type UserToken struct {
|
||||
RevokedAt sql.NullTime `json:"revoked_at"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
}
|
||||
|
||||
type WebhookDelivery struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
WebhookID uuid.UUID `json:"webhook_id"`
|
||||
Event string `json:"event"`
|
||||
Payload pqtype.NullRawMessage `json:"payload"`
|
||||
Status string `json:"status"`
|
||||
ResponseStatus sql.NullInt32 `json:"response_status"`
|
||||
ResponseBody sql.NullString `json:"response_body"`
|
||||
Attempts int32 `json:"attempts"`
|
||||
DurationMs sql.NullInt32 `json:"duration_ms"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
DeliveredAt sql.NullTime `json:"delivered_at"`
|
||||
}
|
||||
@@ -27,15 +27,18 @@ type Querier interface {
|
||||
CreateDatabaseService(ctx context.Context, arg CreateDatabaseServiceParams) error
|
||||
CreateExternalDatabaseService(ctx context.Context, arg CreateExternalDatabaseServiceParams) error
|
||||
CreateNotification(ctx context.Context, arg CreateNotificationParams) error
|
||||
CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error
|
||||
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
||||
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
|
||||
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
|
||||
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
|
||||
CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) error
|
||||
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
|
||||
DeleteAgent(ctx context.Context, id string) error
|
||||
DeleteBackupTargetByIDAndUser(ctx context.Context, arg DeleteBackupTargetByIDAndUserParams) error
|
||||
DeleteDatabaseServiceByID(ctx context.Context, id string) error
|
||||
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
|
||||
DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error
|
||||
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
|
||||
DeleteUserTemplate(ctx context.Context, arg DeleteUserTemplateParams) (int64, error)
|
||||
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
|
||||
@@ -50,6 +53,8 @@ type Querier interface {
|
||||
GetDatabaseServiceByID(ctx context.Context, id string) (DatabaseService, error)
|
||||
GetDatabaseServiceByIDAndUser(ctx context.Context, arg GetDatabaseServiceByIDAndUserParams) (DatabaseService, error)
|
||||
GetDatabaseServiceOwnerID(ctx context.Context, id string) (string, error)
|
||||
GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error)
|
||||
GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error)
|
||||
GetProjectByIDForUser(ctx context.Context, arg GetProjectByIDForUserParams) (Project, error)
|
||||
GetProjectOwnerByID(ctx context.Context, projectID uuid.UUID) (uuid.UUID, error)
|
||||
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||
@@ -69,7 +74,9 @@ type Querier interface {
|
||||
ListDatabaseServicesByProject(ctx context.Context, projectID uuid.NullUUID) ([]DatabaseService, error)
|
||||
ListDatabaseServicesByUser(ctx context.Context, userID string) ([]DatabaseService, error)
|
||||
ListDueDatabaseBackups(ctx context.Context) ([]ListDueDatabaseBackupsRow, error)
|
||||
ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error)
|
||||
ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error)
|
||||
ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error)
|
||||
ListPendingCommands(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
|
||||
ListPendingProjects(ctx context.Context) ([]Project, error)
|
||||
// Visibility model: a project is readable when it is approved for public
|
||||
@@ -79,6 +86,7 @@ type Querier interface {
|
||||
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
|
||||
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
|
||||
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
|
||||
ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error)
|
||||
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
|
||||
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
||||
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
|
||||
@@ -102,8 +110,10 @@ type Querier interface {
|
||||
UpdateDatabaseServiceNameAndPlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameAndPlanByIDAndUserParams) error
|
||||
UpdateDatabaseServiceNameByIDAndUser(ctx context.Context, arg UpdateDatabaseServiceNameByIDAndUserParams) error
|
||||
UpdateDatabaseServicePlanByIDAndUser(ctx context.Context, arg UpdateDatabaseServicePlanByIDAndUserParams) error
|
||||
UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error
|
||||
UpdateProjectByID(ctx context.Context, arg UpdateProjectByIDParams) (int64, error)
|
||||
UpdateUserTemplate(ctx context.Context, arg UpdateUserTemplateParams) (int64, error)
|
||||
UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error
|
||||
UpsertEnvironmentVariable(ctx context.Context, arg UpsertEnvironmentVariableParams) error
|
||||
UpsertServiceTemplate(ctx context.Context, arg UpsertServiceTemplateParams) error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: webhooks.sql
|
||||
|
||||
package sqlcdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sqlc-dev/pqtype"
|
||||
)
|
||||
|
||||
const createOutboundWebhook = `-- name: CreateOutboundWebhook :exec
|
||||
INSERT INTO outbound_webhooks (id, user_id, name, url, secret, events, headers, enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
`
|
||||
|
||||
type CreateOutboundWebhookParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Url string `json:"url"`
|
||||
Secret string `json:"secret"`
|
||||
Events json.RawMessage `json:"events"`
|
||||
Headers pqtype.NullRawMessage `json:"headers"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateOutboundWebhook(ctx context.Context, arg CreateOutboundWebhookParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createOutboundWebhook,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
arg.Name,
|
||||
arg.Url,
|
||||
arg.Secret,
|
||||
arg.Events,
|
||||
arg.Headers,
|
||||
arg.Enabled,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const createWebhookDelivery = `-- name: CreateWebhookDelivery :exec
|
||||
INSERT INTO webhook_deliveries (id, webhook_id, event, payload, status, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`
|
||||
|
||||
type CreateWebhookDeliveryParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
WebhookID uuid.UUID `json:"webhook_id"`
|
||||
Event string `json:"event"`
|
||||
Payload pqtype.NullRawMessage `json:"payload"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt sql.NullTime `json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateWebhookDelivery(ctx context.Context, arg CreateWebhookDeliveryParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createWebhookDelivery,
|
||||
arg.ID,
|
||||
arg.WebhookID,
|
||||
arg.Event,
|
||||
arg.Payload,
|
||||
arg.Status,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteOutboundWebhookByIDAndUser = `-- name: DeleteOutboundWebhookByIDAndUser :exec
|
||||
DELETE FROM outbound_webhooks
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type DeleteOutboundWebhookByIDAndUserParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteOutboundWebhookByIDAndUser(ctx context.Context, arg DeleteOutboundWebhookByIDAndUserParams) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteOutboundWebhookByIDAndUser, arg.ID, arg.UserID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getOutboundWebhookByID = `-- name: GetOutboundWebhookByID :one
|
||||
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
|
||||
FROM outbound_webhooks
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetOutboundWebhookByID(ctx context.Context, id uuid.UUID) (OutboundWebhook, error) {
|
||||
row := q.db.QueryRowContext(ctx, getOutboundWebhookByID, id)
|
||||
var i OutboundWebhook
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Url,
|
||||
&i.Secret,
|
||||
&i.Events,
|
||||
&i.Headers,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getOutboundWebhookByIDAndUser = `-- name: GetOutboundWebhookByIDAndUser :one
|
||||
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
|
||||
FROM outbound_webhooks
|
||||
WHERE id = $1 AND user_id = $2
|
||||
`
|
||||
|
||||
type GetOutboundWebhookByIDAndUserParams struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetOutboundWebhookByIDAndUser(ctx context.Context, arg GetOutboundWebhookByIDAndUserParams) (OutboundWebhook, error) {
|
||||
row := q.db.QueryRowContext(ctx, getOutboundWebhookByIDAndUser, arg.ID, arg.UserID)
|
||||
var i OutboundWebhook
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Url,
|
||||
&i.Secret,
|
||||
&i.Events,
|
||||
&i.Headers,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const listEnabledOutboundWebhooks = `-- name: ListEnabledOutboundWebhooks :many
|
||||
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
|
||||
FROM outbound_webhooks
|
||||
WHERE enabled
|
||||
`
|
||||
|
||||
func (q *Queries) ListEnabledOutboundWebhooks(ctx context.Context) ([]OutboundWebhook, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listEnabledOutboundWebhooks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []OutboundWebhook{}
|
||||
for rows.Next() {
|
||||
var i OutboundWebhook
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Url,
|
||||
&i.Secret,
|
||||
&i.Events,
|
||||
&i.Headers,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listOutboundWebhooksByUser = `-- name: ListOutboundWebhooksByUser :many
|
||||
SELECT outbound_webhooks.id, outbound_webhooks.user_id, outbound_webhooks.name, outbound_webhooks.url, outbound_webhooks.secret, outbound_webhooks.events, outbound_webhooks.headers, outbound_webhooks.enabled, outbound_webhooks.created_at, outbound_webhooks.updated_at
|
||||
FROM outbound_webhooks
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
func (q *Queries) ListOutboundWebhooksByUser(ctx context.Context, userID uuid.UUID) ([]OutboundWebhook, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listOutboundWebhooksByUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []OutboundWebhook{}
|
||||
for rows.Next() {
|
||||
var i OutboundWebhook
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.UserID,
|
||||
&i.Name,
|
||||
&i.Url,
|
||||
&i.Secret,
|
||||
&i.Events,
|
||||
&i.Headers,
|
||||
&i.Enabled,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listWebhookDeliveriesByWebhook = `-- name: ListWebhookDeliveriesByWebhook :many
|
||||
SELECT webhook_deliveries.id, webhook_deliveries.webhook_id, webhook_deliveries.event, webhook_deliveries.payload, webhook_deliveries.status, webhook_deliveries.response_status, webhook_deliveries.response_body, webhook_deliveries.attempts, webhook_deliveries.duration_ms, webhook_deliveries.created_at, webhook_deliveries.delivered_at
|
||||
FROM webhook_deliveries
|
||||
WHERE webhook_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`
|
||||
|
||||
func (q *Queries) ListWebhookDeliveriesByWebhook(ctx context.Context, webhookID uuid.UUID) ([]WebhookDelivery, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listWebhookDeliveriesByWebhook, webhookID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []WebhookDelivery{}
|
||||
for rows.Next() {
|
||||
var i WebhookDelivery
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WebhookID,
|
||||
&i.Event,
|
||||
&i.Payload,
|
||||
&i.Status,
|
||||
&i.ResponseStatus,
|
||||
&i.ResponseBody,
|
||||
&i.Attempts,
|
||||
&i.DurationMs,
|
||||
&i.CreatedAt,
|
||||
&i.DeliveredAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateOutboundWebhook = `-- name: UpdateOutboundWebhook :exec
|
||||
UPDATE outbound_webhooks
|
||||
SET name = $1, url = $2, secret = $3, events = $4, headers = $5, enabled = $6, updated_at = $7
|
||||
WHERE id = $8 AND user_id = $9
|
||||
`
|
||||
|
||||
type UpdateOutboundWebhookParams struct {
|
||||
Name string `json:"name"`
|
||||
Url string `json:"url"`
|
||||
Secret string `json:"secret"`
|
||||
Events json.RawMessage `json:"events"`
|
||||
Headers pqtype.NullRawMessage `json:"headers"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateOutboundWebhook(ctx context.Context, arg UpdateOutboundWebhookParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateOutboundWebhook,
|
||||
arg.Name,
|
||||
arg.Url,
|
||||
arg.Secret,
|
||||
arg.Events,
|
||||
arg.Headers,
|
||||
arg.Enabled,
|
||||
arg.UpdatedAt,
|
||||
arg.ID,
|
||||
arg.UserID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateWebhookDeliveryResult = `-- name: UpdateWebhookDeliveryResult :exec
|
||||
UPDATE webhook_deliveries
|
||||
SET status = $1, response_status = $2, response_body = $3, attempts = $4, duration_ms = $5, delivered_at = $6
|
||||
WHERE id = $7
|
||||
`
|
||||
|
||||
type UpdateWebhookDeliveryResultParams struct {
|
||||
Status string `json:"status"`
|
||||
ResponseStatus sql.NullInt32 `json:"response_status"`
|
||||
ResponseBody sql.NullString `json:"response_body"`
|
||||
Attempts int32 `json:"attempts"`
|
||||
DurationMs sql.NullInt32 `json:"duration_ms"`
|
||||
DeliveredAt sql.NullTime `json:"delivered_at"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateWebhookDeliveryResult(ctx context.Context, arg UpdateWebhookDeliveryResultParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateWebhookDeliveryResult,
|
||||
arg.Status,
|
||||
arg.ResponseStatus,
|
||||
arg.ResponseBody,
|
||||
arg.Attempts,
|
||||
arg.DurationMs,
|
||||
arg.DeliveredAt,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -140,6 +140,13 @@ func init() {
|
||||
register(spec{"containr_notifications_list", "List notifications", "GET", "/notifications", nil, nil, false})
|
||||
register(spec{"containr_notifications_read", "Mark a notification read, or all notifications with id=all", "POST", "/notifications/{id}/read", []string{"id"}, nil, false})
|
||||
|
||||
register(spec{"containr_webhooks_list", "List outbound webhooks", "GET", "/webhooks", nil, nil, false})
|
||||
register(spec{"containr_webhooks_create", "Create an outbound webhook. Args: name, url, events (list, e.g. [\"service.*\",\"*\"]), secret (auto-generated if omitted), headers, enabled.", "POST", "/webhooks", nil, []string{"name", "url", "events", "secret", "headers", "enabled"}, false})
|
||||
register(spec{"containr_webhooks_update", "Update an outbound webhook. Args: id, plus name/url/secret/events/headers/enabled to change.", "PATCH", "/webhooks/{id}", []string{"id"}, []string{"name", "url", "secret", "events", "headers", "enabled"}, false})
|
||||
register(spec{"containr_webhooks_delete", "Delete an outbound webhook. Args: id. Requires confirm=true.", "DELETE", "/webhooks/{id}", []string{"id"}, nil, true})
|
||||
register(spec{"containr_webhooks_deliveries", "List recent delivery attempts for a webhook. Args: id.", "GET", "/webhooks/{id}/deliveries", []string{"id"}, nil, false})
|
||||
register(spec{"containr_webhooks_test", "Queue a test ping delivery for a webhook. Args: id.", "POST", "/webhooks/{id}/test", []string{"id"}, nil, false})
|
||||
|
||||
// Nodes / agent tokens (admin)
|
||||
register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false})
|
||||
register(spec{"containr_nodes_get", "Get a node agent (admin)", "GET", "/agents/{id}", []string{"id"}, nil, false})
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
-- +goose Up
|
||||
-- Outbound webhooks: user-owned HTTP endpoints that receive signed
|
||||
-- platform events (resource.action), with a delivery log.
|
||||
CREATE TABLE IF NOT EXISTS outbound_webhooks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name varchar(255) NOT NULL,
|
||||
url varchar(500) NOT NULL,
|
||||
secret text NOT NULL DEFAULT '',
|
||||
events jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
headers jsonb,
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
webhook_id uuid NOT NULL REFERENCES outbound_webhooks(id) ON DELETE CASCADE,
|
||||
event varchar(255) NOT NULL,
|
||||
payload jsonb,
|
||||
status varchar(50) NOT NULL DEFAULT 'pending',
|
||||
response_status integer,
|
||||
response_body text,
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
duration_ms integer,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
delivered_at timestamptz
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_outbound_webhooks_user ON outbound_webhooks(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_webhook ON webhook_deliveries(webhook_id, created_at DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS webhook_deliveries;
|
||||
DROP TABLE IF EXISTS outbound_webhooks;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- name: ListOutboundWebhooksByUser :many
|
||||
SELECT outbound_webhooks.*
|
||||
FROM outbound_webhooks
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: GetOutboundWebhookByIDAndUser :one
|
||||
SELECT outbound_webhooks.*
|
||||
FROM outbound_webhooks
|
||||
WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- name: ListEnabledOutboundWebhooks :many
|
||||
SELECT outbound_webhooks.*
|
||||
FROM outbound_webhooks
|
||||
WHERE enabled;
|
||||
|
||||
-- name: CreateOutboundWebhook :exec
|
||||
INSERT INTO outbound_webhooks (id, user_id, name, url, secret, events, headers, enabled, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);
|
||||
|
||||
-- name: UpdateOutboundWebhook :exec
|
||||
UPDATE outbound_webhooks
|
||||
SET name = $1, url = $2, secret = $3, events = $4, headers = $5, enabled = $6, updated_at = $7
|
||||
WHERE id = $8 AND user_id = $9;
|
||||
|
||||
-- name: DeleteOutboundWebhookByIDAndUser :exec
|
||||
DELETE FROM outbound_webhooks
|
||||
WHERE id = $1 AND user_id = $2;
|
||||
|
||||
-- name: CreateWebhookDelivery :exec
|
||||
INSERT INTO webhook_deliveries (id, webhook_id, event, payload, status, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6);
|
||||
|
||||
-- name: UpdateWebhookDeliveryResult :exec
|
||||
UPDATE webhook_deliveries
|
||||
SET status = $1, response_status = $2, response_body = $3, attempts = $4, duration_ms = $5, delivered_at = $6
|
||||
WHERE id = $7;
|
||||
|
||||
-- name: ListWebhookDeliveriesByWebhook :many
|
||||
SELECT webhook_deliveries.*
|
||||
FROM webhook_deliveries
|
||||
WHERE webhook_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100;
|
||||
|
||||
-- name: GetOutboundWebhookByID :one
|
||||
SELECT outbound_webhooks.*
|
||||
FROM outbound_webhooks
|
||||
WHERE id = $1;
|
||||
@@ -275,3 +275,30 @@ CREATE TABLE user_tokens (
|
||||
revoked_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE outbound_webhooks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
secret TEXT NOT NULL DEFAULT '',
|
||||
events JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
headers JSONB,
|
||||
enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE webhook_deliveries (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
webhook_id UUID NOT NULL REFERENCES outbound_webhooks(id) ON DELETE CASCADE,
|
||||
event VARCHAR(255) NOT NULL,
|
||||
payload JSONB,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
response_status INTEGER,
|
||||
response_body TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
duration_ms INTEGER,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
delivered_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
Reference in new issue
Block a user