mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-26 00:23:30 +00:00
* fix(security): harden upstream critical surfaces Refs #30 * fix(security): close pre-landing review gaps Refs #30 * fix(security): close official release blockers
580 lines
20 KiB
Go
580 lines
20 KiB
Go
package http
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base32"
|
|
"encoding/hex"
|
|
"log/slog"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
|
"github.com/nextlevelbuilder/goclaw/internal/edition"
|
|
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
|
"github.com/nextlevelbuilder/goclaw/internal/permissions"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
// Compile-time assertion: WebhooksAdminHandler must implement routeRegistrar
|
|
// (the interface defined in internal/gateway/server.go).
|
|
var _ interface{ RegisterRoutes(mux *http.ServeMux) } = (*WebhooksAdminHandler)(nil)
|
|
|
|
// webhookKinds is the set of valid webhook kinds.
|
|
var webhookKinds = map[string]bool{
|
|
"llm": true,
|
|
"message": true,
|
|
}
|
|
|
|
// WebhooksAdminHandler implements CRUD for webhook registry entries.
|
|
// All endpoints are tenant-admin-gated (requireTenantAdmin).
|
|
// encKey is the AES-256-GCM encryption key (GOCLAW_ENCRYPTION_KEY); if empty, encrypted_secret
|
|
// is stored as "" and HMAC auth requires rotation before it can be used.
|
|
type WebhooksAdminHandler struct {
|
|
webhooks store.WebhookStore
|
|
tenants store.TenantStore
|
|
msgBus *bus.MessageBus
|
|
encKey string // AES-256-GCM key for encrypting raw webhook secrets at rest
|
|
}
|
|
|
|
// NewWebhooksAdminHandler creates a handler for webhook admin endpoints.
|
|
func NewWebhooksAdminHandler(webhooks store.WebhookStore, tenants store.TenantStore, msgBus *bus.MessageBus) *WebhooksAdminHandler {
|
|
return &WebhooksAdminHandler{
|
|
webhooks: webhooks,
|
|
tenants: tenants,
|
|
msgBus: msgBus,
|
|
}
|
|
}
|
|
|
|
// SetEncKey sets the AES-256-GCM encryption key used to encrypt raw webhook secrets at rest.
|
|
// Must be called before the first Create/Rotate request; safe to call at startup only.
|
|
func (h *WebhooksAdminHandler) SetEncKey(encKey string) {
|
|
h.encKey = encKey
|
|
}
|
|
|
|
// RegisterRoutes registers all webhook admin routes on mux.
|
|
// Admin CRUD routes mount for both editions.
|
|
// Runtime routes (/v1/webhooks/message, /v1/webhooks/llm) are mounted by phases 05/06
|
|
// conditionally: message-kind only if edition.Current().AllowsChannels().
|
|
func (h *WebhooksAdminHandler) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("POST /v1/webhooks", h.requireAdmin(h.handleCreate))
|
|
mux.HandleFunc("GET /v1/webhooks", h.requireAdmin(h.handleList))
|
|
mux.HandleFunc("GET /v1/webhooks/{id}", h.requireAdmin(h.handleGet))
|
|
mux.HandleFunc("PATCH /v1/webhooks/{id}", h.requireAdmin(h.handleUpdate))
|
|
mux.HandleFunc("POST /v1/webhooks/{id}/rotate", h.requireAdmin(h.handleRotate))
|
|
mux.HandleFunc("DELETE /v1/webhooks/{id}", h.requireAdmin(h.handleRevoke))
|
|
}
|
|
|
|
func (h *WebhooksAdminHandler) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if role := permissions.Role(store.RoleFromContext(r.Context())); role != "" {
|
|
if !permissions.HasMinRole(role, permissions.RoleAdmin) {
|
|
writeJSON(w, http.StatusForbidden, map[string]string{
|
|
"error": i18n.T(store.LocaleFromContext(r.Context()), i18n.MsgPermissionDenied, r.URL.Path+" requires "+string(permissions.RoleAdmin)+" role"),
|
|
})
|
|
return
|
|
}
|
|
next(w, r)
|
|
return
|
|
}
|
|
requireAuth(permissions.RoleAdmin, next)(w, r)
|
|
}
|
|
}
|
|
|
|
// --- Create ---
|
|
|
|
// createWebhookReq is the request body for POST /v1/webhooks.
|
|
type createWebhookReq struct {
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"` // "llm" | "message"
|
|
AgentID *uuid.UUID `json:"agent_id,omitempty"`
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
ChannelID *uuid.UUID `json:"channel_id,omitempty"`
|
|
RateLimitPerMin int `json:"rate_limit_per_min,omitempty"`
|
|
IPAllowlist []string `json:"ip_allowlist,omitempty"`
|
|
RequireHMAC bool `json:"require_hmac,omitempty"`
|
|
LocalhostOnly bool `json:"localhost_only,omitempty"`
|
|
}
|
|
|
|
// webhookCreateResp is the response for create and rotate — includes raw secret once.
|
|
// hmac_signing_key = raw secret itself — callers sign HMAC requests using raw secret bytes.
|
|
// The raw secret is encrypted at rest; secret_hash is kept only for bearer-token lookup.
|
|
type webhookCreateResp struct {
|
|
ID uuid.UUID `json:"id"`
|
|
TenantID uuid.UUID `json:"tenant_id"`
|
|
AgentID *uuid.UUID `json:"agent_id,omitempty"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
SecretPrefix string `json:"secret_prefix"`
|
|
Secret string `json:"secret"` // raw secret — shown ONCE; use this as HMAC key
|
|
HMACSigningKey string `json:"hmac_signing_key"` // same as Secret — raw bytes for X-GoClaw-Signature
|
|
Scopes []string `json:"scopes"`
|
|
ChannelID *uuid.UUID `json:"channel_id,omitempty"`
|
|
RateLimitPerMin int `json:"rate_limit_per_min"`
|
|
IPAllowlist []string `json:"ip_allowlist"`
|
|
RequireHMAC bool `json:"require_hmac"`
|
|
LocalhostOnly bool `json:"localhost_only"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func (h *WebhooksAdminHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
|
|
// Auth first — don't leak config state (encKey presence) to unauthenticated callers.
|
|
if !requireTenantAdmin(w, r, h.tenants) {
|
|
slog.Warn("security.webhook.admin_denied", "action", "create", "path", r.URL.Path,
|
|
"user_id", store.UserIDFromContext(r.Context()))
|
|
return
|
|
}
|
|
|
|
// Defense-in-depth: primary guard is skip-mount in gateway_http_wiring.go.
|
|
// This secondary guard protects if the handler is ever wired without an encKey
|
|
// (e.g. test harness or future refactor that bypasses the wiring guard).
|
|
if h.encKey == "" {
|
|
slog.Error("security.webhook.admin_no_enc_key", "action", "create")
|
|
writeError(w, http.StatusServiceUnavailable, protocol.ErrInternal, i18n.T(locale, i18n.MsgWebhookEncryptionUnavailable))
|
|
return
|
|
}
|
|
|
|
var req createWebhookReq
|
|
if !bindJSON(w, r, locale, &req) {
|
|
return
|
|
}
|
|
|
|
// Validate required fields.
|
|
if req.Name == "" {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name"))
|
|
return
|
|
}
|
|
if len(req.Name) > 100 {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "name must be 100 characters or less"))
|
|
return
|
|
}
|
|
if !webhookKinds[req.Kind] {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "kind must be 'llm' or 'message'"))
|
|
return
|
|
}
|
|
|
|
// Edition gate: message kind requires channels edition.
|
|
if req.Kind == "message" && !edition.Current().AllowsChannels() {
|
|
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgInvalidRequest, "message webhooks require Standard edition"))
|
|
return
|
|
}
|
|
|
|
// Lite edition: force localhost_only=true for all webhook kinds.
|
|
if !edition.Current().AllowsChannels() {
|
|
req.LocalhostOnly = true
|
|
}
|
|
|
|
raw, secretHash, secretPrefix, err := generateWebhookSecret()
|
|
if err != nil {
|
|
slog.Error("webhook.admin.secret_generate_failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "secret generation"))
|
|
return
|
|
}
|
|
|
|
// Encrypt raw secret at rest. If encKey is empty, encryptedSecret is "" (requires rotation).
|
|
encryptedSecret, encErr := crypto.Encrypt(raw, h.encKey)
|
|
if encErr != nil {
|
|
slog.Error("webhook.admin.secret_encrypt_failed", "error", encErr)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "secret encryption"))
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
now := time.Now()
|
|
|
|
wh := &store.WebhookData{
|
|
ID: store.GenNewID(),
|
|
TenantID: tenantID,
|
|
AgentID: req.AgentID,
|
|
Name: req.Name,
|
|
Kind: req.Kind,
|
|
SecretPrefix: secretPrefix,
|
|
SecretHash: secretHash,
|
|
EncryptedSecret: encryptedSecret,
|
|
Scopes: req.Scopes,
|
|
ChannelID: req.ChannelID,
|
|
RateLimitPerMin: req.RateLimitPerMin,
|
|
IPAllowlist: req.IPAllowlist,
|
|
RequireHMAC: req.RequireHMAC,
|
|
LocalhostOnly: req.LocalhostOnly,
|
|
Revoked: false,
|
|
CreatedBy: extractUserID(r),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
if wh.Scopes == nil {
|
|
wh.Scopes = []string{}
|
|
}
|
|
if wh.IPAllowlist == nil {
|
|
wh.IPAllowlist = []string{}
|
|
}
|
|
|
|
if err := h.webhooks.Create(ctx, wh); err != nil {
|
|
slog.Error("webhook.admin.create_failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToCreate, "webhook", "internal error"))
|
|
return
|
|
}
|
|
|
|
slog.Info("webhook.created", "id", wh.ID, "tenant_id", tenantID, "actor", wh.CreatedBy, "kind", wh.Kind)
|
|
h.emitCacheInvalidate(wh.ID.String())
|
|
|
|
writeJSON(w, http.StatusCreated, webhookCreateResp{
|
|
ID: wh.ID,
|
|
TenantID: wh.TenantID,
|
|
AgentID: wh.AgentID,
|
|
Name: wh.Name,
|
|
Kind: wh.Kind,
|
|
SecretPrefix: wh.SecretPrefix,
|
|
Secret: raw,
|
|
HMACSigningKey: raw, // raw secret bytes are the HMAC key (encrypted at rest; decrypted at sign time)
|
|
Scopes: wh.Scopes,
|
|
ChannelID: wh.ChannelID,
|
|
RateLimitPerMin: wh.RateLimitPerMin,
|
|
IPAllowlist: wh.IPAllowlist,
|
|
RequireHMAC: wh.RequireHMAC,
|
|
LocalhostOnly: wh.LocalhostOnly,
|
|
CreatedAt: wh.CreatedAt,
|
|
})
|
|
}
|
|
|
|
// --- List ---
|
|
|
|
func (h *WebhooksAdminHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
|
|
if !requireTenantAdmin(w, r, h.tenants) {
|
|
slog.Warn("security.webhook.admin_denied", "action", "list", "path", r.URL.Path,
|
|
"user_id", store.UserIDFromContext(r.Context()))
|
|
return
|
|
}
|
|
|
|
// Optional ?agent_id= filter.
|
|
var f store.WebhookListFilter
|
|
if agentIDStr := r.URL.Query().Get("agent_id"); agentIDStr != "" {
|
|
aid, err := uuid.Parse(agentIDStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "agent_id"))
|
|
return
|
|
}
|
|
f.AgentID = &aid
|
|
}
|
|
|
|
rows, err := h.webhooks.List(r.Context(), f)
|
|
if err != nil {
|
|
slog.Error("webhook.admin.list_failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "webhooks"))
|
|
return
|
|
}
|
|
if rows == nil {
|
|
rows = []store.WebhookData{}
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
// --- Get ---
|
|
|
|
func (h *WebhooksAdminHandler) handleGet(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
|
|
if !requireTenantAdmin(w, r, h.tenants) {
|
|
slog.Warn("security.webhook.admin_denied", "action", "get", "path", r.URL.Path,
|
|
"user_id", store.UserIDFromContext(r.Context()))
|
|
return
|
|
}
|
|
|
|
id, ok := parseWebhookID(w, r, locale)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
wh, err := h.webhooks.GetByID(r.Context(), id)
|
|
if err != nil || wh == nil {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
|
|
// Cross-tenant isolation: GetByID is tenant-scoped via context, but verify explicitly.
|
|
tenantID := store.TenantIDFromContext(r.Context())
|
|
if !store.IsOwnerRole(r.Context()) && wh.TenantID != tenantID {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, wh)
|
|
}
|
|
|
|
// --- Update ---
|
|
|
|
// updateWebhookReq is the request body for PATCH /v1/webhooks/{id}.
|
|
// All fields are optional; omitted fields are not changed.
|
|
type updateWebhookReq struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
ChannelID *uuid.UUID `json:"channel_id,omitempty"`
|
|
RateLimitPerMin *int `json:"rate_limit_per_min,omitempty"`
|
|
IPAllowlist []string `json:"ip_allowlist,omitempty"`
|
|
RequireHMAC *bool `json:"require_hmac,omitempty"`
|
|
LocalhostOnly *bool `json:"localhost_only,omitempty"`
|
|
}
|
|
|
|
func (h *WebhooksAdminHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
|
|
if !requireTenantAdmin(w, r, h.tenants) {
|
|
slog.Warn("security.webhook.admin_denied", "action", "update", "path", r.URL.Path,
|
|
"user_id", store.UserIDFromContext(r.Context()))
|
|
return
|
|
}
|
|
|
|
id, ok := parseWebhookID(w, r, locale)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
// Verify ownership before mutating.
|
|
wh, err := h.webhooks.GetByID(ctx, id)
|
|
if err != nil || wh == nil {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
if !store.IsOwnerRole(ctx) && wh.TenantID != tenantID {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
|
|
var req updateWebhookReq
|
|
if !bindJSON(w, r, locale, &req) {
|
|
return
|
|
}
|
|
|
|
updates := make(map[string]any)
|
|
if req.Name != nil {
|
|
if *req.Name == "" {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "name"))
|
|
return
|
|
}
|
|
if len(*req.Name) > 100 {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "name must be 100 characters or less"))
|
|
return
|
|
}
|
|
updates["name"] = *req.Name
|
|
}
|
|
if req.Scopes != nil {
|
|
updates["scopes"] = req.Scopes
|
|
}
|
|
if req.ChannelID != nil {
|
|
updates["channel_id"] = *req.ChannelID
|
|
}
|
|
if req.RateLimitPerMin != nil {
|
|
updates["rate_limit_per_min"] = *req.RateLimitPerMin
|
|
}
|
|
if req.IPAllowlist != nil {
|
|
updates["ip_allowlist"] = req.IPAllowlist
|
|
}
|
|
if req.RequireHMAC != nil {
|
|
updates["require_hmac"] = *req.RequireHMAC
|
|
}
|
|
if req.LocalhostOnly != nil {
|
|
// Lite edition: cannot unset localhost_only.
|
|
if !*req.LocalhostOnly && !edition.Current().AllowsChannels() {
|
|
writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgInvalidRequest, "localhost_only cannot be disabled on Lite edition"))
|
|
return
|
|
}
|
|
updates["localhost_only"] = *req.LocalhostOnly
|
|
}
|
|
|
|
if len(updates) == 0 {
|
|
// Nothing to update — return current state.
|
|
writeJSON(w, http.StatusOK, wh)
|
|
return
|
|
}
|
|
|
|
if err := h.webhooks.Update(ctx, id, updates); err != nil {
|
|
slog.Error("webhook.admin.update_failed", "error", err, "id", id)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToUpdate, "webhook", "internal error"))
|
|
return
|
|
}
|
|
|
|
slog.Info("webhook.updated", "id", id, "tenant_id", tenantID, "actor", extractUserID(r))
|
|
|
|
// Re-fetch to return updated state.
|
|
updated, err := h.webhooks.GetByID(ctx, id)
|
|
if err != nil || updated == nil {
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "fetch updated webhook"))
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, updated)
|
|
}
|
|
|
|
// --- Rotate Secret ---
|
|
|
|
func (h *WebhooksAdminHandler) handleRotate(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
|
|
// Auth first — don't leak config state (encKey presence) to unauthenticated callers.
|
|
if !requireTenantAdmin(w, r, h.tenants) {
|
|
slog.Warn("security.webhook.admin_denied", "action", "rotate", "path", r.URL.Path,
|
|
"user_id", store.UserIDFromContext(r.Context()))
|
|
return
|
|
}
|
|
|
|
// Defense-in-depth: same guard as handleCreate — encryption key must be present
|
|
// before we generate and persist a new secret.
|
|
if h.encKey == "" {
|
|
slog.Error("security.webhook.admin_no_enc_key", "action", "rotate")
|
|
writeError(w, http.StatusServiceUnavailable, protocol.ErrInternal, i18n.T(locale, i18n.MsgWebhookEncryptionUnavailable))
|
|
return
|
|
}
|
|
|
|
id, ok := parseWebhookID(w, r, locale)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
// Verify ownership before mutating.
|
|
wh, err := h.webhooks.GetByID(ctx, id)
|
|
if err != nil || wh == nil {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
if !store.IsOwnerRole(ctx) && wh.TenantID != tenantID {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
|
|
raw, newHash, newPrefix, err := generateWebhookSecret()
|
|
if err != nil {
|
|
slog.Error("webhook.admin.secret_generate_failed", "error", err)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "secret generation"))
|
|
return
|
|
}
|
|
|
|
newEncryptedSecret, encErr := crypto.Encrypt(raw, h.encKey)
|
|
if encErr != nil {
|
|
slog.Error("webhook.admin.secret_encrypt_failed", "error", encErr)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "secret encryption"))
|
|
return
|
|
}
|
|
|
|
if err := h.webhooks.RotateSecret(ctx, id, newHash, newPrefix, newEncryptedSecret); err != nil {
|
|
slog.Error("webhook.admin.rotate_failed", "error", err, "id", id)
|
|
writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "rotate secret"))
|
|
return
|
|
}
|
|
|
|
slog.Info("webhook.rotated", "id", id, "tenant_id", tenantID, "actor", extractUserID(r))
|
|
|
|
// Invalidate the cache so the middleware picks up the new hash immediately.
|
|
h.emitCacheInvalidate(id.String())
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"id": id,
|
|
"secret": raw, // new raw secret — shown ONCE; use as HMAC key
|
|
"hmac_signing_key": raw, // same as secret; raw bytes are HMAC key (encrypted at rest)
|
|
"secret_prefix": newPrefix,
|
|
})
|
|
}
|
|
|
|
// --- Revoke ---
|
|
|
|
func (h *WebhooksAdminHandler) handleRevoke(w http.ResponseWriter, r *http.Request) {
|
|
locale := extractLocale(r)
|
|
|
|
if !requireTenantAdmin(w, r, h.tenants) {
|
|
slog.Warn("security.webhook.admin_denied", "action", "revoke", "path", r.URL.Path,
|
|
"user_id", store.UserIDFromContext(r.Context()))
|
|
return
|
|
}
|
|
|
|
id, ok := parseWebhookID(w, r, locale)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
ctx := r.Context()
|
|
|
|
// Verify ownership before revoking.
|
|
wh, err := h.webhooks.GetByID(ctx, id)
|
|
if err != nil || wh == nil {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
tenantID := store.TenantIDFromContext(ctx)
|
|
if !store.IsOwnerRole(ctx) && wh.TenantID != tenantID {
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
|
|
if err := h.webhooks.Revoke(ctx, id); err != nil {
|
|
slog.Error("webhook.admin.revoke_failed", "error", err, "id", id)
|
|
writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String()))
|
|
return
|
|
}
|
|
|
|
slog.Info("webhook.revoked", "id", id, "tenant_id", tenantID, "actor", extractUserID(r))
|
|
|
|
// Invalidate the cache so the middleware rejects the old secret immediately.
|
|
h.emitCacheInvalidate(id.String())
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
// generateWebhookSecret creates a new webhook secret in format "wh_<base32(24 bytes)>".
|
|
// Returns (rawSecret, secretHash, secretPrefix, error).
|
|
// secretPrefix = first 8 chars of rawSecret (includes "wh_" + start of base32).
|
|
// secretHash = hex(SHA-256(rawSecret)) — stored in DB, used as HMAC signing key.
|
|
func generateWebhookSecret() (raw, secretHash, secretPrefix string, err error) {
|
|
b := make([]byte, 24)
|
|
if _, err = rand.Read(b); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
// base32 (no padding) produces 40 chars for 24 bytes.
|
|
encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
|
|
raw = "wh_" + encoded // total 43 chars
|
|
|
|
h := sha256.Sum256([]byte(raw))
|
|
secretHash = hex.EncodeToString(h[:])
|
|
|
|
// First 8 chars of the full raw secret (includes "wh_" + first 5 base32 chars).
|
|
secretPrefix = raw[:8]
|
|
return raw, secretHash, secretPrefix, nil
|
|
}
|
|
|
|
// parseWebhookID parses the {id} path value, writing a 400 on error.
|
|
func parseWebhookID(w http.ResponseWriter, r *http.Request, locale string) (uuid.UUID, bool) {
|
|
idStr := r.PathValue("id")
|
|
id, err := uuid.Parse(idStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "webhook"))
|
|
return uuid.Nil, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
// emitCacheInvalidate broadcasts a cache invalidation event for webhook secrets.
|
|
// This signals the WebhookAuthMiddleware (phase 03) to drop cached entries.
|
|
func (h *WebhooksAdminHandler) emitCacheInvalidate(webhookID string) {
|
|
if h.msgBus == nil {
|
|
return
|
|
}
|
|
h.msgBus.Broadcast(bus.Event{
|
|
Name: protocol.EventCacheInvalidate,
|
|
Payload: bus.CacheInvalidatePayload{Kind: "webhooks", Key: webhookID},
|
|
})
|
|
}
|