Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions cmd/reconcile.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ func ReconcileCommand(cliConfig *Config) *cli.Command {
Make platform resources match a desired-state YAML file, through the admin API.

Kinds: PlatformUser (platform admins and members), Permission (custom
permissions), Role (platform-level roles), and Preference (platform
settings). Deleting a permission or a custom role needs an explicit
'delete: true' on its entry; nothing is deleted by omission, and a predefined
role cannot be deleted. A preference left out of the file resets to its
default. Log in as a superuser (for example the bootstrap service account)
with --header.
permissions), Role (platform-level roles), Preference (platform
settings), and Webhook (webhook endpoints). Deleting a permission, a
custom role, or a webhook needs an explicit 'delete: true' on its entry;
nothing is deleted by omission, and a predefined role cannot be deleted. A
preference left out of the file resets to its default. Log in as a superuser
(for example the bootstrap service account) with --header.

Use "frontier export <kind>" to print the current state in this file format.
`),
Expand Down Expand Up @@ -88,6 +88,7 @@ func buildReconcileRegistry(host, header string) (map[string]reconcile.Reconcile
reconcile.KindPermission: reconcile.NewPermissionReconciler(api, header),
reconcile.KindRole: reconcile.NewRoleReconciler(api, header),
reconcile.KindPreference: reconcile.NewPreferenceReconciler(api, header),
reconcile.KindWebhook: reconcile.NewWebhookReconciler(adminClient, header),
}, nil
}

Expand Down
55 changes: 55 additions & 0 deletions core/webhook/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"fmt"
"log/slog"
"net/url"
"strings"
"time"

"github.com/raystack/frontier/pkg/server/consts"
Expand Down Expand Up @@ -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
}
Comment on lines +53 to +59

Copy link
Copy Markdown
Contributor

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.

ensureURLIsFree lists all endpoints and checks in Go, then Create/UpdateByID runs as a separate, unsynchronized step. Two concurrent CreateEndpoint calls for the same URL can both pass the check and both persist — exactly the "two endpoints sharing a url" scenario that internal/reconcile/webhook.go's diffWebhooks has 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


// generate a random secret in hex
secretHex, err := crypt.NewEncryptionKeyInHex()
Expand All @@ -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
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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#^\./##' | sort

Repository: 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.

url.Parse + IsAbs() still accepts values like http:foo or http:///path, so endpoints without a destination host can be saved and only fail during delivery. Reject empty u.Host here.

switch endpoint.State {
case "", Enabled, Disabled:
default:
return fmt.Errorf("%w: state must be %q or %q", ErrInvalidDetail, Enabled, Disabled)
}
return nil
}
Comment thread
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)
}
Expand Down
96 changes: 96 additions & 0 deletions core/webhook/service_test.go
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)
})
}
38 changes: 37 additions & 1 deletion docs/content/docs/reconcile.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,42 @@ spec:
- Export writes only the preferences whose value differs from the default, so settings at
their default stay out of the file.

## The Webhook kind

`Webhook` manages webhook endpoints: a URL, the events it subscribes to, and whether it is
enabled. The URL is the identity and never changes.

```yaml
apiVersion: v1
kind: Webhook
spec:
- url: https://hooks.example.org/frontier
description: Ops notifications
subscribed_events:
- app.user.created
- app.group.created
state: enabled
- url: https://old.example.org/frontier
delete: true
```

- The URL must be a valid absolute URL, and it is the identity. If two endpoints on the
server share a URL, the identity is ambiguous: the plan fails and names the ids so you can
remove the extra one by hand.
Comment on lines +216 to +218

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document the HTTP(S)-only URL constraint.

The service validates absolute http:// and https:// URLs, but this wording could imply that other absolute schemes are valid. Please state the supported schemes explicitly.

Proposed wording
-- The URL must be a valid absolute URL, and it is the identity.
+- The URL must be a valid absolute HTTP(S) URL, and it is the identity.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- The URL must be a valid absolute URL, and it is the identity. If two endpoints on the
server share a URL, the identity is ambiguous: the plan fails and names the ids so you can
remove the extra one by hand.
- The URL must be a valid absolute HTTP(S) URL, and it is the identity. If two endpoints on the
server share a URL, the identity is ambiguous: the plan fails and names the ids so you can
remove the extra one by hand.

- `subscribed_events` is the full set of events the endpoint receives, compared as a set. An
empty list, or leaving the field out, means every event, which is the server default. It is
the complete desired set, not a keep-if-omitted field: dropping it sets the endpoint to all
events rather than keeping the current set, and export always writes it, showing `[]` for an
all-events endpoint. `description` and `state` (`enabled` or `disabled`) are ordinary managed
fields: leave one out to keep its server value.
- Every endpoint on the server must appear in the file, kept or marked `delete: true`. One
that is missing fails the plan; nothing is deleted just because it is missing.
- The signing secret is server-owned. The server generates it when the endpoint is created
and never returns it on read, so it is not part of the file, never shows up in a plan, and
can never appear in an export.
- Export leaves out `state` when it is the default `enabled`, and headers and metadata set
through other tools are carried through an update untouched.

## Running it

Log in as a superuser. The bootstrap service user exists for exactly this; its client id
Expand Down Expand Up @@ -236,7 +272,7 @@ The kind argument is case-insensitive and accepts a plural, so `platformuser` an

## More kinds

This page covers `PlatformUser`, `Permission`, `Role`, and `Preference`. The design and
This page covers `PlatformUser`, `Permission`, `Role`, `Preference`, and `Webhook`. The design and
the rules every kind follows live in
[RFC 0001](https://github.com/raystack/frontier/blob/main/docs/rfcs/0001-declarative-reconcile.md),
which also lists the kinds proposed next. The flag reference for both commands is in the
Expand Down
11 changes: 6 additions & 5 deletions docs/content/docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ List of supported environment variables
Export the current state of a kind as a desired-state YAML file, printed to
stdout. The output is the format `frontier reconcile` reads: reconciling it
changes nothing. Supported kinds: `PlatformUser`, `Permission`, `Role`,
`Preference`. See the
`Preference`, `Webhook`. See the
[Reconcile guide](../reconcile.md) for the file format and the flow.

```
Expand Down Expand Up @@ -249,10 +249,11 @@ View a project
Make platform resources match a desired-state YAML file, through the admin
API. Supported kinds: `PlatformUser` (anyone listed is added, anyone not
listed is removed), `Permission` (custom permissions), `Role`
(platform-level roles), and `Preference` (platform settings, where a setting
left out of the file resets to its default). Deleting a permission or a custom
role needs an explicit `delete: true` on its entry; nothing is deleted by
omission, and a predefined role cannot be deleted. Use
(platform-level roles), `Preference` (platform settings, where a setting
left out of the file resets to its default), and `Webhook` (webhook endpoints).
Deleting a permission, a custom role, or a webhook needs an explicit
`delete: true` on its entry; nothing is deleted by omission, and a predefined
role cannot be deleted. Use
`frontier export` to print the current state in this file format, and see the
[Reconcile guide](../reconcile.md) for the full flow.

Expand Down
25 changes: 21 additions & 4 deletions internal/api/v1beta1connect/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package v1beta1connect

import (
"context"
"errors"
"fmt"

"connectrpc.com/connect"
Expand All @@ -11,6 +12,22 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
)

// webhookErrCode maps a webhook service error to the connect status code the
// caller should see, so a bad request reads as invalid-argument rather than an
// internal error.
func webhookErrCode(err error) connect.Code {
switch {
case errors.Is(err, webhook.ErrInvalidDetail), errors.Is(err, webhook.ErrInvalidUUID):
return connect.CodeInvalidArgument
case errors.Is(err, webhook.ErrConflict):
return connect.CodeAlreadyExists
case errors.Is(err, webhook.ErrNotFound):
return connect.CodeNotFound
default:
return connect.CodeInternal
}
}

func (h *ConnectHandler) CreateWebhook(ctx context.Context, req *connect.Request[frontierv1beta1.CreateWebhookRequest]) (*connect.Response[frontierv1beta1.CreateWebhookResponse], error) {
var metaDataMap metadata.Metadata
if req.Msg.GetBody().GetMetadata() != nil {
Expand All @@ -25,7 +42,7 @@ func (h *ConnectHandler) CreateWebhook(ctx context.Context, req *connect.Request
Metadata: metaDataMap,
})
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("CreateWebhook: url=%s: %w", req.Msg.GetBody().GetUrl(), err))
return nil, connect.NewError(webhookErrCode(err), fmt.Errorf("CreateWebhook: url=%s: %w", req.Msg.GetBody().GetUrl(), err))
}
endpointPb, err := toProtoWebhookEndpoint(endpoint)
if err != nil {
Expand Down Expand Up @@ -53,7 +70,7 @@ func (h *ConnectHandler) UpdateWebhook(ctx context.Context, req *connect.Request
Metadata: metaDataMap,
})
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("UpdateWebhook: webhook_id=%s url=%s: %w", webhookID, req.Msg.GetBody().GetUrl(), err))
return nil, connect.NewError(webhookErrCode(err), fmt.Errorf("UpdateWebhook: webhook_id=%s url=%s: %w", webhookID, req.Msg.GetBody().GetUrl(), err))
}
endpointPb, err := toProtoWebhookEndpoint(endpoint)
if err != nil {
Expand All @@ -68,7 +85,7 @@ func (h *ConnectHandler) ListWebhooks(ctx context.Context, req *connect.Request[
filter := webhook.EndpointFilter{}
endpoints, err := h.webhookService.ListEndpoints(ctx, filter)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("ListWebhooks: %w", err))
return nil, connect.NewError(webhookErrCode(err), fmt.Errorf("ListWebhooks: %w", err))
}
var webhooks []*frontierv1beta1.Webhook
for _, endpoint := range endpoints {
Expand All @@ -88,7 +105,7 @@ func (h *ConnectHandler) DeleteWebhook(ctx context.Context, req *connect.Request

err := h.webhookService.DeleteEndpoint(ctx, webhookID)
if err != nil {
return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("DeleteWebhook: webhook_id=%s: %w", webhookID, err))
return nil, connect.NewError(webhookErrCode(err), fmt.Errorf("DeleteWebhook: webhook_id=%s: %w", webhookID, err))
}
return connect.NewResponse(&frontierv1beta1.DeleteWebhookResponse{}), nil
}
Expand Down
20 changes: 20 additions & 0 deletions internal/reconcile/role.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,26 @@ func sortedCopy(in []string) []string {
return out
}

// uniqueSorted returns the input as a sorted, deduplicated slice. It is used for
// set-valued fields whose input can hold duplicates (a hand-written list may
// repeat a value), so the compare and the value sent to the server both use the
// same canonical set.
func uniqueSorted(in []string) []string {
if len(in) == 0 {
return nil
}
set := make(map[string]struct{}, len(in))
for _, v := range in {
set[v] = struct{}{}
}
out := make([]string, 0, len(set))
for v := range set {
out = append(out, v)
}
sort.Strings(out)
return out
}

func stringSetsEqual(a, b []string) bool {
if len(a) != len(b) {
return false
Expand Down
Loading
Loading