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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user