-
Notifications
You must be signed in to change notification settings - Fork 44
feat(reconcile): add a Webhook kind #1772
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
0e2ea0d
e6bd3b6
8c41776
3f1e6ca
784bfdc
55346b8
8b276b3
dfa424f
0ecbd4e
1ca1f19
cc852c2
45fd480
b7615ed
d2832dc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,8 @@ import ( | |
| "context" | ||
| "fmt" | ||
| "log/slog" | ||
| "net/url" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/raystack/frontier/pkg/server/consts" | ||
|
|
@@ -48,6 +50,13 @@ func (s Service) CreateEndpoint(ctx context.Context, endpoint Endpoint) (Endpoin | |
| if endpoint.State == "" { | ||
| endpoint.State = Enabled | ||
| } | ||
| endpoint.URL = strings.TrimSpace(endpoint.URL) | ||
| if err := validateEndpoint(endpoint); err != nil { | ||
| return Endpoint{}, err | ||
| } | ||
| if err := s.ensureURLIsFree(ctx, endpoint.URL, endpoint.ID); err != nil { | ||
| return Endpoint{}, err | ||
| } | ||
|
|
||
| // generate a random secret in hex | ||
| secretHex, err := crypt.NewEncryptionKeyInHex() | ||
|
|
@@ -65,6 +74,13 @@ func (s Service) UpdateEndpoint(ctx context.Context, endpoint Endpoint) (Endpoin | |
| if endpoint.ID == "" { | ||
| return Endpoint{}, ErrInvalidUUID | ||
| } | ||
| endpoint.URL = strings.TrimSpace(endpoint.URL) | ||
| if err := validateEndpoint(endpoint); err != nil { | ||
| return Endpoint{}, err | ||
| } | ||
| if err := s.ensureURLIsFree(ctx, endpoint.URL, endpoint.ID); err != nil { | ||
| return Endpoint{}, err | ||
| } | ||
| updated, err := s.eRepo.UpdateByID(ctx, endpoint) | ||
| if err != nil { | ||
| return Endpoint{}, err | ||
|
|
@@ -73,6 +89,45 @@ func (s Service) UpdateEndpoint(ctx context.Context, endpoint Endpoint) (Endpoin | |
| return updated, nil | ||
| } | ||
|
|
||
| // validateEndpoint checks the operator-supplied fields the reconcile flow relies | ||
| // on. The URL is that flow's identity for an endpoint and the state is managed | ||
| // as enabled/disabled, so the server only stores values that reconcile can | ||
| // represent and round-trip: a valid absolute URL, and a known state. | ||
| func validateEndpoint(endpoint Endpoint) error { | ||
| u, err := url.Parse(endpoint.URL) | ||
| if err != nil || !u.IsAbs() { | ||
| return fmt.Errorf("%w: url must be a valid absolute URL", ErrInvalidDetail) | ||
| } | ||
| // The server dispatches events to this URL, so restrict it to http(s). This | ||
| // keeps other schemes (file, gopher, ...) out of the delivery path. | ||
| if u.Scheme != "http" && u.Scheme != "https" { | ||
| return fmt.Errorf("%w: url scheme must be http or https", ErrInvalidDetail) | ||
| } | ||
|
Comment on lines
+96
to
+105
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Map relevant files and symbols first
git ls-files core/webhook
printf '\n--- outline: core/webhook/service.go ---\n'
ast-grep outline core/webhook/service.go --view expanded || true
printf '\n--- outline: any tests in core/webhook ---\n'
fd -a -t f . core/webhook | sed 's#^\./##' | sortRepository: raystack/frontier Length of output: 1864 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the webhook service implementation around validation and uniqueness.
sed -n '1,220p' core/webhook/service.go | cat -n
printf '\n--- search for URL uniqueness and trimming ---\n'
rg -n "ensureURLIsFree|validateEndpoint|TrimSpace|Parse\\(" core/webhook/service.go core/webhook -g '!**/*_test.go'Repository: raystack/frontier Length of output: 10217 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect tests that may cover webhook URL validation/uniqueness.
fd -a -t f '.*_test\\.go' core/webhook | sort
printf '\n--- relevant test snippets ---\n'
rg -n "http:foo|http:///|IsAbs|Host|ensureURLIsFree|duplicate|TrimSpace" core/webhook -g '*_test.go'Repository: raystack/frontier Length of output: 187 Require a host for HTTP(S) webhook URLs.
|
||
| switch endpoint.State { | ||
| case "", Enabled, Disabled: | ||
| default: | ||
| return fmt.Errorf("%w: state must be %q or %q", ErrInvalidDetail, Enabled, Disabled) | ||
| } | ||
| return nil | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| // ensureURLIsFree rejects a URL that another endpoint already uses. The reconcile | ||
| // flow uses the URL as the endpoint's identity, so two endpoints sharing a URL | ||
| // would be ambiguous. excludeID is the endpoint being updated, so it does not | ||
| // conflict with itself. | ||
| func (s Service) ensureURLIsFree(ctx context.Context, rawURL, excludeID string) error { | ||
| existing, err := s.eRepo.List(ctx, EndpointFilter{}) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| for _, e := range existing { | ||
| if e.ID != excludeID && e.URL == rawURL { | ||
| return fmt.Errorf("%w: url %q is already used by another webhook", ErrConflict, rawURL) | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func (s Service) DeleteEndpoint(ctx context.Context, id string) error { | ||
| return s.eRepo.Delete(ctx, id) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| package webhook_test | ||
|
|
||
| import ( | ||
| "context" | ||
| "testing" | ||
|
|
||
| "github.com/raystack/frontier/core/webhook" | ||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
|
|
||
| // fakeEndpointRepo is an in-memory webhook.EndpointRepository for service tests. | ||
| type fakeEndpointRepo struct { | ||
| items []webhook.Endpoint | ||
| } | ||
|
|
||
| func (f *fakeEndpointRepo) Create(_ context.Context, e webhook.Endpoint) (webhook.Endpoint, error) { | ||
| f.items = append(f.items, e) | ||
| return e, nil | ||
| } | ||
|
|
||
| func (f *fakeEndpointRepo) UpdateByID(_ context.Context, e webhook.Endpoint) (webhook.Endpoint, error) { | ||
| for i := range f.items { | ||
| if f.items[i].ID == e.ID { | ||
| f.items[i] = e | ||
| return e, nil | ||
| } | ||
| } | ||
| return webhook.Endpoint{}, webhook.ErrNotFound | ||
| } | ||
|
|
||
| func (f *fakeEndpointRepo) Delete(_ context.Context, _ string) error { return nil } | ||
|
|
||
| func (f *fakeEndpointRepo) List(_ context.Context, _ webhook.EndpointFilter) ([]webhook.Endpoint, error) { | ||
| return f.items, nil | ||
| } | ||
|
|
||
| func TestServiceCreateEndpointValidation(t *testing.T) { | ||
| t.Run("rejects a non-absolute url", func(t *testing.T) { | ||
| s := webhook.NewService(&fakeEndpointRepo{}) | ||
| _, err := s.CreateEndpoint(context.Background(), webhook.Endpoint{URL: "not-a-url"}) | ||
| assert.ErrorIs(t, err, webhook.ErrInvalidDetail) | ||
| }) | ||
|
|
||
| t.Run("rejects an empty url", func(t *testing.T) { | ||
| s := webhook.NewService(&fakeEndpointRepo{}) | ||
| _, err := s.CreateEndpoint(context.Background(), webhook.Endpoint{URL: " "}) | ||
| assert.ErrorIs(t, err, webhook.ErrInvalidDetail) | ||
| }) | ||
|
|
||
| t.Run("rejects a non-http(s) scheme", func(t *testing.T) { | ||
| s := webhook.NewService(&fakeEndpointRepo{}) | ||
| _, err := s.CreateEndpoint(context.Background(), webhook.Endpoint{URL: "ftp://a.example/hook"}) | ||
| assert.ErrorIs(t, err, webhook.ErrInvalidDetail) | ||
| }) | ||
|
|
||
| t.Run("rejects an unknown state", func(t *testing.T) { | ||
| s := webhook.NewService(&fakeEndpointRepo{}) | ||
| _, err := s.CreateEndpoint(context.Background(), webhook.Endpoint{URL: "https://a.example/hook", State: "paused"}) | ||
| assert.ErrorIs(t, err, webhook.ErrInvalidDetail) | ||
| }) | ||
|
|
||
| t.Run("rejects a url another endpoint already uses", func(t *testing.T) { | ||
| repo := &fakeEndpointRepo{items: []webhook.Endpoint{{ID: "e1", URL: "https://a.example/hook"}}} | ||
| _, err := webhook.NewService(repo).CreateEndpoint(context.Background(), webhook.Endpoint{URL: "https://a.example/hook"}) | ||
| assert.ErrorIs(t, err, webhook.ErrConflict) | ||
| }) | ||
|
|
||
| t.Run("creates a valid endpoint, defaulting state and generating a secret", func(t *testing.T) { | ||
| got, err := webhook.NewService(&fakeEndpointRepo{}).CreateEndpoint( | ||
| context.Background(), webhook.Endpoint{URL: "https://a.example/hook"}) | ||
| assert.NoError(t, err) | ||
| assert.Equal(t, webhook.Enabled, got.State) // defaulted | ||
| assert.Len(t, got.Secrets, 1) // server-generated signing secret | ||
| }) | ||
| } | ||
|
|
||
| func TestServiceUpdateEndpointURLUniqueness(t *testing.T) { | ||
| t.Run("rejects a url used by a different endpoint", func(t *testing.T) { | ||
| repo := &fakeEndpointRepo{items: []webhook.Endpoint{ | ||
| {ID: "e1", URL: "https://a.example/hook", State: webhook.Enabled}, | ||
| {ID: "e2", URL: "https://b.example/hook", State: webhook.Enabled}, | ||
| }} | ||
| _, err := webhook.NewService(repo).UpdateEndpoint(context.Background(), | ||
| webhook.Endpoint{ID: "e2", URL: "https://a.example/hook", State: webhook.Enabled}) | ||
| assert.ErrorIs(t, err, webhook.ErrConflict) | ||
| }) | ||
|
|
||
| t.Run("lets an endpoint keep its own url", func(t *testing.T) { | ||
| repo := &fakeEndpointRepo{items: []webhook.Endpoint{ | ||
| {ID: "e1", URL: "https://a.example/hook", State: webhook.Enabled}, | ||
| }} | ||
| _, err := webhook.NewService(repo).UpdateEndpoint(context.Background(), | ||
| webhook.Endpoint{ID: "e1", URL: "https://a.example/hook", State: webhook.Disabled}) | ||
| assert.NoError(t, err) | ||
| }) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift
TOCTOU race on URL uniqueness, plus a full table scan on every write.
ensureURLIsFreelists all endpoints and checks in Go, thenCreate/UpdateByIDruns as a separate, unsynchronized step. Two concurrentCreateEndpointcalls for the same URL can both pass the check and both persist — exactly the "two endpoints sharing a url" scenario thatinternal/reconcile/webhook.go'sdiffWebhookshas to special-case as an unrecoverable error ("the url identity is ambiguous... delete the extra one by hand"). This check-then-act pattern is unreliable without a DB-level unique constraint or a lock/transaction spanning the check and the write.Separately,
s.eRepo.List(ctx, EndpointFilter{})fetches every endpoint on every create/update, which won't scale as the number of webhooks grows.Recommend enforcing uniqueness with a unique index at the storage layer (with the app-level check kept as a fast-path/better error message), and, if a targeted lookup by URL is available on the repository, using that instead of a full list scan.
Also applies to: 77-83, 108-123