Feat: OLAP Table for CEL Eval Failures (#2012)

* feat: add table, wire up partitioning

* feat: wire failures into the OLAP db from rabbit

* feat: bubble failures up to controller

* fix: naming

* fix: hack around enum type

* fix: typo

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: typos

* fix: migration name

* feat: log debug failure

* feat: pub message from debug endpoint to log failure

* fix: error handling

* fix: use ingestor

* fix: olap suffix

* fix: pass source through

* fix: dont log ingest failure

* fix: rm debug as enum opt

* chore: gen

* Feat: Webhooks (#1978)

* feat: migration + go gen

* feat: non unique source name

* feat: api types

* fix: rm cruft

* feat: initial api for webhooks

* feat: handle encryption of incoming keys

* fix: nil pointer errors

* fix: import

* feat: add endpoint for incoming webhooks

* fix: naming

* feat: start wiring up basic auth

* feat: wire up cel event parsing

* feat: implement authentication

* fix: hack for plain text content

* feat: add source to enum

* feat: add source name enum

* feat: db source name enum fix

* fix: use source name enums

* feat: nest sources

* feat: first pass at stripe

* fix: clean up source name passing

* fix: use unique name for webhook

* feat: populator test

* fix: null values

* fix: ordering

* fix: rm unnecessary index

* fix: validation

* feat: validation on create

* fix: lint

* fix: naming

* feat: wire triggering webhook name through to events table

* feat: cleanup + python gen + e2e test for basic auth

* feat: query to insert webhook validation errors

* refactor: auth handler

* fix: naming

* refactor: validation errors, part II

* feat: wire up writes through olap

* fix: linting, fallthrough case

* fix: validation

* feat: tests for failure cases for basic auth

* feat: expand tests

* fix: correctly return 404 out of task getter

* chore: generated stuff

* fix: rm cruft

* fix: longer sleep

* debug: print name + events to logs

* feat: limit to N

* feat: add limit env var

* debug: ci test

* fix: apply namespaces to keys

* fix: namespacing, part ii

* fix: sdk config

* fix: handle prefixing

* feat: handle partitioning logic

* chore: gen

* feat: add webhook limit

* feat: wire up limits

* fix: gen

* fix: reverse order of generic fallthrough

* fix: comment for potential unexpected behavior

* fix: add check constraints, improve error handling

* chore: gen

* chore: gen

* fix: improve naming

* feat: scaffold webhooks page

* feat: sidebar

* feat: first pass at page

* feat: improve feedback on UI

* feat: initial work on create modal

* feat: change default to basic

* fix: openapi spec discriminated union

* fix: go side

* feat: start wiring up placeholders for stripe and github

* feat: pre-populated fields for Stripe + Github

* feat: add name section

* feat: copy improvements, show URL

* feat: UI cleanup

* fix: check if tenant populator errors

* feat: add comments

* chore: gen again

* fix: default name

* fix: styling

* fix: improve stripe header processing

* feat: docs, part 1

* fix: lint

* fix: migration order

* feat: implement rate limit per-webhook

* feat: comment

* feat: clean up docs

* chore: gen

* fix: migration versions

* fix: olap naming

* fix: partitions

* chore: gen

* feat: store webhook cel eval failures properly

* fix: pk order

* fix: auth tweaks, move fetches out of populator

* fix: pgtype.Text instead of string pointer

* chore: gen

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
matt
2025-07-30 13:27:38 -04:00
committed by GitHub
parent 1b2a2bf566
commit d6f8be2c0f
111 changed files with 11294 additions and 374 deletions

View File

@@ -36,6 +36,7 @@ func NewAuthN(config *server.ServerConfig) *AuthN {
func (a *AuthN) Middleware(r *middleware.RouteInfo) echo.HandlerFunc {
return func(c echo.Context) error {
err := a.authenticate(c, r)
if err != nil {
return err
}
@@ -58,6 +59,7 @@ func (a *AuthN) authenticate(c echo.Context, r *middleware.RouteInfo) error {
if r.Security.CookieAuth() {
cookieErr = a.handleCookieAuth(c)
c.Set("auth_strategy", "cookie")
if cookieErr == nil {
@@ -73,6 +75,7 @@ func (a *AuthN) authenticate(c echo.Context, r *middleware.RouteInfo) error {
if r.Security.BearerAuth() {
bearerErr = a.handleBearerAuth(c)
c.Set("auth_strategy", "bearer")
if bearerErr == nil {

View File

@@ -32,7 +32,7 @@ func (t *EventService) EventCreate(ctx echo.Context, request gen.EventCreateRequ
}
}
newEvent, err := t.config.Ingestor.IngestEvent(ctx.Request().Context(), tenant, request.Body.Key, dataBytes, additionalMetadata, request.Body.Priority, request.Body.Scope)
newEvent, err := t.config.Ingestor.IngestEvent(ctx.Request().Context(), tenant, request.Body.Key, dataBytes, additionalMetadata, request.Body.Priority, request.Body.Scope, nil)
if err != nil {
if err == metered.ErrResourceExhausted {

View File

@@ -64,7 +64,7 @@ func (i *IngestorsService) SnsUpdate(ctx echo.Context, req gen.SnsUpdateRequestO
return nil, err
}
default:
_, err := i.config.Ingestor.IngestEvent(ctx.Request().Context(), tenant, req.Event, body, nil, nil, nil)
_, err := i.config.Ingestor.IngestEvent(ctx.Request().Context(), tenant, req.Event, body, nil, nil, nil, nil)
if err != nil {
return nil, err

View File

@@ -13,7 +13,13 @@ func (t *TasksService) V1TaskGet(ctx echo.Context, request gen.V1TaskGetRequestO
taskInterface := ctx.Get("task")
if taskInterface == nil {
return nil, echo.NewHTTPError(404, "Task not found")
return gen.V1TaskGet404JSONResponse{
Errors: []gen.APIError{
{
Description: "task not found",
},
},
}, nil
}
task, ok := taskInterface.(*sqlcv1.V1TasksOlap)

View File

@@ -0,0 +1,184 @@
package webhooksv1
import (
"encoding/json"
"fmt"
"github.com/hatchet-dev/hatchet/api/v1/server/oas/gen"
"github.com/hatchet-dev/hatchet/api/v1/server/oas/transformers/v1"
"github.com/hatchet-dev/hatchet/pkg/repository/postgres/dbsqlc"
"github.com/hatchet-dev/hatchet/pkg/repository/postgres/sqlchelpers"
v1 "github.com/hatchet-dev/hatchet/pkg/repository/v1"
"github.com/hatchet-dev/hatchet/pkg/repository/v1/sqlcv1"
"github.com/labstack/echo/v4"
)
func (w *V1WebhooksService) V1WebhookCreate(ctx echo.Context, request gen.V1WebhookCreateRequestObject) (gen.V1WebhookCreateResponseObject, error) {
tenant := ctx.Get("tenant").(*dbsqlc.Tenant)
canCreate, _, err := w.config.EntitlementRepository.TenantLimit().CanCreate(ctx.Request().Context(), dbsqlc.LimitResourceINCOMINGWEBHOOK, tenant.ID.String(), 1)
if err != nil {
return nil, fmt.Errorf("failed to check if webhook can be created: %w", err)
}
if !canCreate {
return gen.V1WebhookCreate400JSONResponse{
Errors: []gen.APIError{
{
Description: "incoming webhook limit reached",
},
},
}, nil
}
params, err := w.constructCreateOpts(tenant.ID.String(), *request.Body)
if err != nil {
return gen.V1WebhookCreate400JSONResponse{
Errors: []gen.APIError{
{
Description: fmt.Sprintf("failed to construct webhook create params: %v", err),
},
},
}, nil
}
webhook, err := w.config.V1.Webhooks().CreateWebhook(
ctx.Request().Context(),
tenant.ID.String(),
params,
)
if err != nil {
return nil, fmt.Errorf("failed to create webhook")
}
transformed := transformers.ToV1Webhook(webhook)
return gen.V1WebhookCreate200JSONResponse(transformed), nil
}
func extractAuthType(request gen.V1CreateWebhookRequest) (sqlcv1.V1IncomingWebhookAuthType, error) {
j, err := request.MarshalJSON()
if err != nil {
return "", fmt.Errorf("failed to get marshal request: %w", err)
}
parsedBody := make(map[string]interface{})
if err := json.Unmarshal(j, &parsedBody); err != nil {
return "", fmt.Errorf("failed to unmarshal request body: %w", err)
}
unparsedDiscriminator, ok := parsedBody["authType"]
if !ok {
return "", fmt.Errorf("authType field is missing in the request body")
}
discriminator := unparsedDiscriminator.(string)
authType := sqlcv1.V1IncomingWebhookAuthType(discriminator)
if authType == "" {
return "", fmt.Errorf("invalid auth type: %s", unparsedDiscriminator)
}
return authType, nil
}
func (w *V1WebhooksService) constructCreateOpts(tenantId string, request gen.V1CreateWebhookRequest) (v1.CreateWebhookOpts, error) {
params := v1.CreateWebhookOpts{
Tenantid: sqlchelpers.UUIDFromStr(tenantId),
}
discriminator, err := extractAuthType(request)
if err != nil {
return params, fmt.Errorf("failed to get discriminator: %w", err)
}
authConfig := v1.AuthConfig{}
switch discriminator {
case sqlcv1.V1IncomingWebhookAuthTypeBASIC:
basicAuth, err := request.AsV1CreateWebhookRequestBasicAuth()
if err != nil {
return params, fmt.Errorf("failed to parse basic auth: %w", err)
}
authConfig.Type = sqlcv1.V1IncomingWebhookAuthTypeBASIC
passwordEncrypted, err := w.config.Encryption.Encrypt([]byte(basicAuth.Auth.Password), "v1_webhook_basic_auth_password")
if err != nil {
return params, fmt.Errorf("failed to encrypt basic auth password: %s", err.Error())
}
authConfig.BasicAuth = &v1.BasicAuthCredentials{
Username: basicAuth.Auth.Username,
EncryptedPassword: passwordEncrypted,
}
params.Sourcename = sqlcv1.V1IncomingWebhookSourceName(basicAuth.SourceName)
params.Name = basicAuth.Name
params.Eventkeyexpression = basicAuth.EventKeyExpression
params.AuthConfig = authConfig
case sqlcv1.V1IncomingWebhookAuthTypeAPIKEY:
apiKeyAuth, err := request.AsV1CreateWebhookRequestAPIKey()
if err != nil {
return params, fmt.Errorf("failed to parse api key auth: %w", err)
}
authConfig.Type = sqlcv1.V1IncomingWebhookAuthTypeAPIKEY
authConfig := v1.AuthConfig{
Type: sqlcv1.V1IncomingWebhookAuthTypeAPIKEY,
}
apiKeyEncrypted, err := w.config.Encryption.Encrypt([]byte(apiKeyAuth.Auth.ApiKey), "v1_webhook_api_key")
if err != nil {
return params, fmt.Errorf("failed to encrypt api key: %s", err.Error())
}
authConfig.APIKeyAuth = &v1.APIKeyAuthCredentials{
HeaderName: apiKeyAuth.Auth.HeaderName,
EncryptedKey: apiKeyEncrypted,
}
params.Sourcename = sqlcv1.V1IncomingWebhookSourceName(apiKeyAuth.SourceName)
params.Name = apiKeyAuth.Name
params.Eventkeyexpression = apiKeyAuth.EventKeyExpression
params.AuthConfig = authConfig
case sqlcv1.V1IncomingWebhookAuthTypeHMAC:
hmacAuth, err := request.AsV1CreateWebhookRequestHMAC()
if err != nil {
return params, fmt.Errorf("failed to parse hmac auth: %w", err)
}
authConfig := v1.AuthConfig{
Type: sqlcv1.V1IncomingWebhookAuthTypeHMAC,
}
signingSecretEncrypted, err := w.config.Encryption.Encrypt([]byte(hmacAuth.Auth.SigningSecret), "v1_webhook_hmac_signing_secret")
if err != nil {
return params, fmt.Errorf("failed to encrypt api key: %s", err.Error())
}
authConfig.HMACAuth = &v1.HMACAuthCredentials{
Algorithm: sqlcv1.V1IncomingWebhookHmacAlgorithm(hmacAuth.Auth.Algorithm),
Encoding: sqlcv1.V1IncomingWebhookHmacEncoding(hmacAuth.Auth.Encoding),
SignatureHeaderName: hmacAuth.Auth.SignatureHeaderName,
EncryptedWebhookSigningSecret: signingSecretEncrypted,
}
params.Sourcename = sqlcv1.V1IncomingWebhookSourceName(hmacAuth.SourceName)
params.Name = hmacAuth.Name
params.Eventkeyexpression = hmacAuth.EventKeyExpression
params.AuthConfig = authConfig
default:
return params, fmt.Errorf("unsupported auth type: %s", discriminator)
}
return params, nil
}

View File

@@ -0,0 +1,29 @@
package webhooksv1
import (
"github.com/hatchet-dev/hatchet/api/v1/server/oas/apierrors"
"github.com/hatchet-dev/hatchet/api/v1/server/oas/gen"
"github.com/hatchet-dev/hatchet/api/v1/server/oas/transformers/v1"
"github.com/hatchet-dev/hatchet/pkg/repository/v1/sqlcv1"
"github.com/labstack/echo/v4"
)
func (w *V1WebhooksService) V1WebhookDelete(ctx echo.Context, request gen.V1WebhookDeleteRequestObject) (gen.V1WebhookDeleteResponseObject, error) {
webhook := ctx.Get("v1-webhook").(*sqlcv1.V1IncomingWebhook)
webhook, err := w.config.V1.Webhooks().DeleteWebhook(
ctx.Request().Context(),
webhook.TenantID.String(),
webhook.Name,
)
if err != nil {
return gen.V1WebhookDelete400JSONResponse(apierrors.NewAPIErrors("failed to delete webhook")), nil
}
transformed := transformers.ToV1Webhook(webhook)
return gen.V1WebhookDelete200JSONResponse(
transformed,
), nil
}

View File

@@ -0,0 +1,18 @@
package webhooksv1
import (
"github.com/hatchet-dev/hatchet/api/v1/server/oas/gen"
"github.com/hatchet-dev/hatchet/api/v1/server/oas/transformers/v1"
"github.com/hatchet-dev/hatchet/pkg/repository/v1/sqlcv1"
"github.com/labstack/echo/v4"
)
func (w *V1WebhooksService) V1WebhookGet(ctx echo.Context, request gen.V1WebhookGetRequestObject) (gen.V1WebhookGetResponseObject, error) {
webhook := ctx.Get("v1-webhook").(*sqlcv1.V1IncomingWebhook)
transformed := transformers.ToV1Webhook(webhook)
return gen.V1WebhookGet200JSONResponse(
transformed,
), nil
}

View File

@@ -0,0 +1,49 @@
package webhooksv1
import (
"github.com/hatchet-dev/hatchet/api/v1/server/oas/apierrors"
"github.com/hatchet-dev/hatchet/api/v1/server/oas/gen"
"github.com/hatchet-dev/hatchet/api/v1/server/oas/transformers/v1"
"github.com/hatchet-dev/hatchet/pkg/repository/postgres/dbsqlc"
v1 "github.com/hatchet-dev/hatchet/pkg/repository/v1"
"github.com/hatchet-dev/hatchet/pkg/repository/v1/sqlcv1"
"github.com/labstack/echo/v4"
)
func (w *V1WebhooksService) V1WebhookList(ctx echo.Context, request gen.V1WebhookListRequestObject) (gen.V1WebhookListResponseObject, error) {
tenant := ctx.Get("tenant").(*dbsqlc.Tenant)
var sourceNames []sqlcv1.V1IncomingWebhookSourceName
var webhookNames []string
if request.Params.SourceNames != nil {
for _, sourceName := range *request.Params.SourceNames {
sourceNames = append(sourceNames, sqlcv1.V1IncomingWebhookSourceName(sourceName))
}
}
if request.Params.WebhookNames != nil {
webhookNames = *request.Params.WebhookNames
}
webhooks, err := w.config.V1.Webhooks().ListWebhooks(
ctx.Request().Context(),
tenant.ID.String(),
v1.ListWebhooksOpts{
WebhookNames: webhookNames,
WebhookSourceNames: sourceNames,
Limit: request.Params.Limit,
Offset: request.Params.Offset,
},
)
if err != nil {
return gen.V1WebhookList400JSONResponse(apierrors.NewAPIErrors("failed to list webhooks")), nil
}
transformed := transformers.ToV1WebhookList(webhooks)
return gen.V1WebhookList200JSONResponse(
transformed,
), nil
}

View File

@@ -0,0 +1,459 @@
package webhooksv1
import (
"crypto/hmac"
"crypto/md5"
"crypto/sha1"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/hatchet-dev/hatchet/api/v1/server/oas/gen"
"github.com/hatchet-dev/hatchet/internal/cel"
"github.com/hatchet-dev/hatchet/pkg/repository/v1/sqlcv1"
"github.com/labstack/echo/v4"
)
func (w *V1WebhooksService) V1WebhookReceive(ctx echo.Context, request gen.V1WebhookReceiveRequestObject) (gen.V1WebhookReceiveResponseObject, error) {
tenantId := request.Tenant.String()
webhookName := request.V1Webhook
tenant, err := w.config.APIRepository.Tenant().GetTenantByID(ctx.Request().Context(), tenantId)
if err != nil || tenant == nil {
return gen.V1WebhookReceive400JSONResponse{
Errors: []gen.APIError{
{
Description: "tenant not found",
},
},
}, nil
}
webhook, err := w.config.V1.Webhooks().GetWebhook(ctx.Request().Context(), tenantId, webhookName)
if err != nil || webhook == nil {
return gen.V1WebhookReceive400JSONResponse{
Errors: []gen.APIError{
{
Description: fmt.Sprintf("webhook %s not found for tenant %s", webhookName, tenantId),
},
},
}, nil
}
if webhook.TenantID.String() != tenantId {
return gen.V1WebhookReceive403JSONResponse{
Errors: []gen.APIError{
{
Description: fmt.Sprintf("webhook %s does not belong to tenant %s", webhookName, tenantId),
},
},
}, nil
}
rawBody, err := io.ReadAll(ctx.Request().Body)
if err != nil {
return nil, fmt.Errorf("failed to read request body: %w", err)
}
ok, validationError := w.validateWebhook(rawBody, *webhook, *ctx.Request())
if !ok {
err := w.config.Ingestor.IngestWebhookValidationFailure(
ctx.Request().Context(),
tenant,
webhook.Name,
validationError.ErrorText,
)
if err != nil {
return nil, fmt.Errorf("failed to ingest webhook validation failure: %w", err)
}
return validationError.ToResponse()
}
payloadMap := make(map[string]interface{})
if rawBody != nil {
err := json.Unmarshal(rawBody, &payloadMap)
if err != nil {
return gen.V1WebhookReceive400JSONResponse{
Errors: []gen.APIError{
{
Description: fmt.Sprintf("failed to unmarshal request body: %v", err),
},
},
}, nil
}
// This could cause unexpected behavior if the payload contains a key named "tenant" or "v1-webhook"
delete(payloadMap, "tenant")
delete(payloadMap, "v1-webhook")
}
eventKey, err := w.celParser.EvaluateIncomingWebhookExpression(webhook.EventKeyExpression, cel.NewInput(
cel.WithInput(payloadMap),
),
)
if err != nil {
if eventKey == "" {
err = fmt.Errorf("event key evaluted to an empty string")
}
ingestionErr := w.config.Ingestor.IngestCELEvaluationFailure(
ctx.Request().Context(),
tenant.ID.String(),
err.Error(),
sqlcv1.V1CelEvaluationFailureSourceWEBHOOK,
)
if ingestionErr != nil {
return nil, fmt.Errorf("failed to ingest webhook validation failure: %w", ingestionErr)
}
return gen.V1WebhookReceive400JSONResponse{
Errors: []gen.APIError{
{
Description: fmt.Sprintf("failed to evaluate event key expression: %v", err),
},
},
}, nil
}
payload, err := json.Marshal(payloadMap)
if err != nil {
return gen.V1WebhookReceive400JSONResponse{
Errors: []gen.APIError{
{
Description: fmt.Sprintf("failed to marshal request body: %v", err),
},
},
}, nil
}
_, err = w.config.Ingestor.IngestEvent(
ctx.Request().Context(),
tenant,
eventKey,
payload,
nil,
nil,
nil,
&webhook.Name,
)
if err != nil {
return nil, fmt.Errorf("failed to ingest event")
}
msg := "ok"
return gen.V1WebhookReceive200JSONResponse(
gen.V1WebhookReceive200JSONResponse{
Message: &msg,
},
), nil
}
func computeHMACSignature(payload []byte, secret []byte, algorithm sqlcv1.V1IncomingWebhookHmacAlgorithm, encoding sqlcv1.V1IncomingWebhookHmacEncoding) (string, error) {
var hashFunc func() hash.Hash
switch algorithm {
case sqlcv1.V1IncomingWebhookHmacAlgorithmSHA1:
hashFunc = sha1.New
case sqlcv1.V1IncomingWebhookHmacAlgorithmSHA256:
hashFunc = sha256.New
case sqlcv1.V1IncomingWebhookHmacAlgorithmSHA512:
hashFunc = sha512.New
case sqlcv1.V1IncomingWebhookHmacAlgorithmMD5:
hashFunc = md5.New
default:
return "", fmt.Errorf("unsupported HMAC algorithm: %s", algorithm)
}
h := hmac.New(hashFunc, secret)
h.Write(payload)
signature := h.Sum(nil)
switch encoding {
case sqlcv1.V1IncomingWebhookHmacEncodingHEX:
return hex.EncodeToString(signature), nil
case sqlcv1.V1IncomingWebhookHmacEncodingBASE64:
return base64.StdEncoding.EncodeToString(signature), nil
case sqlcv1.V1IncomingWebhookHmacEncodingBASE64URL:
return base64.URLEncoding.EncodeToString(signature), nil
default:
return "", fmt.Errorf("unsupported HMAC encoding: %s", encoding)
}
}
type HttpResponseCode int
const (
Http400 HttpResponseCode = iota
Http403
Http500
)
type ValidationError struct {
Code HttpResponseCode
ErrorText string
}
func (vr ValidationError) ToResponse() (gen.V1WebhookReceiveResponseObject, error) {
switch vr.Code {
case Http400:
return gen.V1WebhookReceive400JSONResponse{
Errors: []gen.APIError{
{
Description: vr.ErrorText,
},
},
}, nil
case Http403:
return gen.V1WebhookReceive403JSONResponse{
Errors: []gen.APIError{
{
Description: vr.ErrorText,
},
},
}, nil
case Http500:
return nil, errors.New(vr.ErrorText)
default:
return nil, fmt.Errorf("no validation error set")
}
}
func (w *V1WebhooksService) validateWebhook(webhookPayload []byte, webhook sqlcv1.V1IncomingWebhook, request http.Request) (
bool,
*ValidationError,
) {
switch webhook.SourceName {
case sqlcv1.V1IncomingWebhookSourceNameSTRIPE:
signatureHeader := request.Header.Get(webhook.AuthHmacSignatureHeaderName.String)
if signatureHeader == "" {
return false, &ValidationError{
Code: Http400,
ErrorText: fmt.Sprintf("missing or invalid signature header: %s", webhook.AuthHmacSignatureHeaderName.String),
}
}
splitHeader := strings.Split(signatureHeader, ",")
headersMap := make(map[string]string)
for _, header := range splitHeader {
parts := strings.Split(header, "=")
if len(parts) != 2 {
return false, &ValidationError{
Code: Http400,
ErrorText: fmt.Sprintf("invalid signature header format: %s", webhook.AuthHmacSignatureHeaderName.String),
}
}
headersMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
}
timestampHeader, hasTimestampHeader := headersMap["t"]
v1SignatureHeader, hasV1SignatureHeader := headersMap["v1"]
if timestampHeader == "" || v1SignatureHeader == "" || !hasTimestampHeader || !hasV1SignatureHeader {
return false, &ValidationError{
Code: Http400,
ErrorText: fmt.Sprintf("missing or invalid signature header: %s", webhook.AuthHmacSignatureHeaderName.String),
}
}
timestamp := strings.TrimPrefix(timestampHeader, "t=")
signature := strings.TrimPrefix(v1SignatureHeader, "v1=")
if timestamp == "" || signature == "" {
return false, &ValidationError{
Code: Http400,
ErrorText: fmt.Sprintf("missing or invalid signature header: %s", webhook.AuthHmacSignatureHeaderName.String),
}
}
parsedTimestamp, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false, &ValidationError{
Code: Http400,
ErrorText: fmt.Sprintf("invalid timestamp in signature header: %s", err),
}
}
if time.Unix(parsedTimestamp, 0).UTC().Before(time.Now().Add(-10 * time.Minute)) {
return false, &ValidationError{
Code: Http400,
ErrorText: "timestamp in signature header is out of range",
}
}
decryptedSigningSecret, err := w.config.Encryption.Decrypt(webhook.AuthHmacWebhookSigningSecret, "v1_webhook_hmac_signing_secret")
if err != nil {
return false, &ValidationError{
Code: Http500,
ErrorText: fmt.Sprintf("failed to decrypt HMAC signing secret: %s", err),
}
}
algorithm := webhook.AuthHmacAlgorithm.V1IncomingWebhookHmacAlgorithm
encoding := webhook.AuthHmacEncoding.V1IncomingWebhookHmacEncoding
signedPayload := fmt.Sprintf("%s.%s", timestamp, webhookPayload)
expectedSignature, err := computeHMACSignature([]byte(signedPayload), decryptedSigningSecret, algorithm, encoding)
if err != nil {
return false, &ValidationError{
Code: Http403,
ErrorText: fmt.Sprintf("failed to compute HMAC signature: %s", err),
}
}
if !signaturesMatch(signature, expectedSignature) {
return false, &ValidationError{
Code: Http403,
ErrorText: "invalid HMAC signature",
}
}
case sqlcv1.V1IncomingWebhookSourceNameGITHUB:
fallthrough
case sqlcv1.V1IncomingWebhookSourceNameGENERIC:
switch webhook.AuthMethod {
case sqlcv1.V1IncomingWebhookAuthTypeBASIC:
username, password, ok := request.BasicAuth()
if !ok {
return false, &ValidationError{
Code: Http403,
ErrorText: "missing or invalid authorization header",
}
}
decryptedPassword, err := w.config.Encryption.Decrypt(webhook.AuthBasicPassword, "v1_webhook_basic_auth_password")
if err != nil {
return false, &ValidationError{
Code: Http500,
ErrorText: fmt.Sprintf("failed to decrypt basic auth password: %s", err),
}
}
if username != webhook.AuthBasicUsername.String || password != string(decryptedPassword) {
return false, &ValidationError{
Code: Http403,
ErrorText: "invalid basic auth credentials",
}
}
case sqlcv1.V1IncomingWebhookAuthTypeAPIKEY:
apiKey := request.Header.Get(webhook.AuthApiKeyHeaderName.String)
if apiKey == "" {
return false, &ValidationError{
Code: Http403,
ErrorText: fmt.Sprintf("missing or invalid api key header: %s", webhook.AuthApiKeyHeaderName.String),
}
}
decryptedApiKey, err := w.config.Encryption.Decrypt(webhook.AuthApiKeyKey, "v1_webhook_api_key")
if err != nil {
return false, &ValidationError{
Code: Http500,
ErrorText: fmt.Sprintf("failed to decrypt api key: %s", err),
}
}
if apiKey != string(decryptedApiKey) {
return false, &ValidationError{
Code: Http403,
ErrorText: fmt.Sprintf("invalid api key: %s", webhook.AuthApiKeyHeaderName.String),
}
}
case sqlcv1.V1IncomingWebhookAuthTypeHMAC:
signature := request.Header.Get(webhook.AuthHmacSignatureHeaderName.String)
if signature == "" {
return false, &ValidationError{
Code: Http403,
ErrorText: fmt.Sprintf("missing or invalid signature header: %s", webhook.AuthHmacSignatureHeaderName.String),
}
}
decryptedSigningSecret, err := w.config.Encryption.Decrypt(webhook.AuthHmacWebhookSigningSecret, "v1_webhook_hmac_signing_secret")
if err != nil {
return false, &ValidationError{
Code: Http500,
ErrorText: fmt.Sprintf("failed to decrypt HMAC signing secret: %s", err),
}
}
algorithm := webhook.AuthHmacAlgorithm.V1IncomingWebhookHmacAlgorithm
encoding := webhook.AuthHmacEncoding.V1IncomingWebhookHmacEncoding
expectedSignature, err := computeHMACSignature(webhookPayload, decryptedSigningSecret, algorithm, encoding)
if err != nil {
return false, &ValidationError{
Code: Http500,
ErrorText: fmt.Sprintf("failed to compute HMAC signature: %s", err),
}
}
if !signaturesMatch(signature, expectedSignature) {
return false, &ValidationError{
Code: Http403,
ErrorText: "invalid HMAC signature",
}
}
default:
return false, &ValidationError{
Code: Http400,
ErrorText: fmt.Sprintf("unsupported auth type: %s", webhook.AuthMethod),
}
}
default:
return false, &ValidationError{
Code: Http400,
ErrorText: fmt.Sprintf("unsupported source name: %+v", webhook.SourceName),
}
}
return true, nil
}
func signaturesMatch(providedSignature, expectedSignature string) bool {
providedSignature = strings.TrimSpace(providedSignature)
expectedSignature = strings.TrimSpace(expectedSignature)
return hmac.Equal(
[]byte(removePrefixesFromSignature(providedSignature)),
[]byte(removePrefixesFromSignature(expectedSignature)),
)
}
func removePrefixesFromSignature(signature string) string {
signature = strings.TrimPrefix(signature, "sha1=")
signature = strings.TrimPrefix(signature, "sha256=")
signature = strings.TrimPrefix(signature, "sha512=")
signature = strings.TrimPrefix(signature, "md5=")
return signature
}

View File

@@ -0,0 +1,18 @@
package webhooksv1
import (
"github.com/hatchet-dev/hatchet/internal/cel"
"github.com/hatchet-dev/hatchet/pkg/config/server"
)
type V1WebhooksService struct {
config *server.ServerConfig
celParser *cel.CELParser
}
func NewV1WebhooksService(config *server.ServerConfig) *V1WebhooksService {
return &V1WebhooksService{
config: config,
celParser: cel.NewCELParser(),
}
}

View File

@@ -46,6 +46,15 @@ func topLevelResourceGetter(config *server.ServerConfig, parentId, id string) (i
}, "", nil
}
func invalidParentGetter(config *server.ServerConfig, parentId, id string) (interface{}, string, error) {
newUuid := uuid.NewString()
return &oneToManyResource{
ID: id,
ParentID: newUuid,
}, newUuid, nil
}
func TestPopulatorMiddleware(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
@@ -78,3 +87,35 @@ func TestPopulatorMiddleware(t *testing.T) {
assert.NotNil(t, c.Get("resource1"))
assert.NotNil(t, c.Get("resource2"))
}
func TestPopulatorMiddlewareParentDisagreement(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
// Mock RouteInfo
routeInfo := &middleware.RouteInfo{
Resources: []string{"resource1", "resource2"},
}
resource1Id := uuid.New().String()
resource2Id := uuid.New().String()
// Setting params for the context - NOTE: we need to set both params at once
c.SetParamNames("resource1", "resource2")
c.SetParamValues(resource1Id, resource2Id)
// Creating Populator with mock getter function
populator := NewPopulator(&server.ServerConfig{})
populator.RegisterGetter("resource1", topLevelResourceGetter)
populator.RegisterGetter("resource2", invalidParentGetter)
// Using the Populator middleware
middlewareFunc := populator.Middleware(routeInfo)
err := middlewareFunc(c)
// Assertions
assert.ErrorContains(t, err, "could not be populated")
}

View File

@@ -0,0 +1,43 @@
package middleware
import (
"fmt"
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/rs/zerolog"
"golang.org/x/time/rate"
)
func WebhookRateLimitMiddleware(rateLimit rate.Limit, burst int, l *zerolog.Logger) echo.MiddlewareFunc {
config := middleware.RateLimiterConfig{
Skipper: func(c echo.Context) bool {
method := c.Request().Method
if method != http.MethodPost {
return true
}
tenantId := c.Param("tenant")
webhookName := c.Param("v1-webhook")
if tenantId == "" || webhookName == "" {
return true
}
return c.Request().URL.Path != fmt.Sprintf("/api/v1/stable/tenants/%s/webhooks/%s", tenantId, webhookName)
},
Store: middleware.NewRateLimiterMemoryStoreWithConfig(
middleware.RateLimiterMemoryStoreConfig{
Rate: rateLimit,
Burst: burst,
ExpiresIn: 10 * time.Minute,
},
),
}
return middleware.RateLimiterWithConfig(config)
}

File diff suppressed because it is too large Load Diff

View File

@@ -92,10 +92,11 @@ func ToV1EventList(events []*v1.ListEventsRow, limit, offset, total int64) gen.V
Failed: row.FailedCount,
Running: row.RunningCount,
},
Payload: &payload,
SeenAt: &row.EventSeenAt.Time,
Scope: &row.EventScope,
TriggeredRuns: &triggeredRuns,
Payload: &payload,
SeenAt: &row.EventSeenAt.Time,
Scope: &row.EventScope,
TriggeredRuns: &triggeredRuns,
TriggeringWebhookName: row.TriggeringWebhookName,
}
}

View File

@@ -0,0 +1,37 @@
package transformers
import (
"github.com/google/uuid"
"github.com/hatchet-dev/hatchet/api/v1/server/oas/gen"
"github.com/hatchet-dev/hatchet/pkg/repository/v1/sqlcv1"
)
func ToV1Webhook(webhook *sqlcv1.V1IncomingWebhook) gen.V1Webhook {
// Intentionally empty uuid
var id uuid.UUID
return gen.V1Webhook{
AuthType: gen.V1WebhookAuthType(webhook.AuthMethod),
Metadata: gen.APIResourceMeta{
CreatedAt: webhook.InsertedAt.Time,
UpdatedAt: webhook.UpdatedAt.Time,
Id: id.String(),
},
TenantId: webhook.TenantID.String(),
EventKeyExpression: webhook.EventKeyExpression,
Name: webhook.Name,
SourceName: gen.V1WebhookSourceName(webhook.SourceName),
}
}
func ToV1WebhookList(webhooks []*sqlcv1.V1IncomingWebhook) gen.V1WebhookList {
rows := make([]gen.V1Webhook, len(webhooks))
for i, webhook := range webhooks {
rows[i] = ToV1Webhook(webhook)
}
return gen.V1WebhookList{
Rows: &rows,
}
}

View File

@@ -30,6 +30,7 @@ import (
eventsv1 "github.com/hatchet-dev/hatchet/api/v1/server/handlers/v1/events"
filtersv1 "github.com/hatchet-dev/hatchet/api/v1/server/handlers/v1/filters"
"github.com/hatchet-dev/hatchet/api/v1/server/handlers/v1/tasks"
webhooksv1 "github.com/hatchet-dev/hatchet/api/v1/server/handlers/v1/webhooks"
workflowrunsv1 "github.com/hatchet-dev/hatchet/api/v1/server/handlers/v1/workflow-runs"
webhookworker "github.com/hatchet-dev/hatchet/api/v1/server/handlers/webhook-worker"
"github.com/hatchet-dev/hatchet/api/v1/server/handlers/workers"
@@ -41,6 +42,7 @@ import (
"github.com/hatchet-dev/hatchet/api/v1/server/oas/gen"
"github.com/hatchet-dev/hatchet/pkg/config/server"
"github.com/hatchet-dev/hatchet/pkg/repository/postgres/sqlchelpers"
"golang.org/x/time/rate"
)
type apiService struct {
@@ -64,6 +66,7 @@ type apiService struct {
*workflowrunsv1.V1WorkflowRunsService
*eventsv1.V1EventsService
*filtersv1.V1FiltersService
*webhooksv1.V1WebhooksService
*celv1.V1CELService
}
@@ -89,6 +92,7 @@ func newAPIService(config *server.ServerConfig) *apiService {
V1WorkflowRunsService: workflowrunsv1.NewV1WorkflowRunsService(config),
V1EventsService: eventsv1.NewV1EventsService(config),
V1FiltersService: filtersv1.NewV1FiltersService(config),
V1WebhooksService: webhooksv1.NewV1WebhooksService(config),
V1CELService: celv1.NewV1CELService(config),
}
}
@@ -417,6 +421,20 @@ func (t *APIServer) registerSpec(g *echo.Group, spec *openapi3.T, middlewares []
return filter, sqlchelpers.UUIDToStr(filter.TenantID), nil
})
populatorMW.RegisterGetter("v1-webhook", func(config *server.ServerConfig, parentId, id string) (result interface{}, uniqueParentId string, err error) {
webhook, err := t.config.V1.Webhooks().GetWebhook(
context.Background(),
parentId,
id,
)
if err != nil {
return nil, "", err
}
return webhook, sqlchelpers.UUIDToStr(webhook.TenantID), nil
})
authnMW := authn.NewAuthN(t.config)
authzMW := authz.NewAuthZ(t.config)
@@ -488,6 +506,11 @@ func (t *APIServer) registerSpec(g *echo.Group, spec *openapi3.T, middlewares []
loggerMiddleware,
middleware.Recover(),
allHatchetMiddleware,
hatchetmiddleware.WebhookRateLimitMiddleware(
rate.Limit(t.config.Runtime.WebhookRateLimit),
t.config.Runtime.WebhookRateLimitBurst,
t.config.Logger,
),
)
return populatorMW, nil