Skip to content
Draft
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
38 changes: 37 additions & 1 deletion docs/content/docs/reconcile.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,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.
- `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 @@ -227,7 +263,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
20 changes: 20 additions & 0 deletions internal/reconcile/role.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,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
203 changes: 203 additions & 0 deletions internal/reconcile/webhook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
package reconcile

import (
"fmt"
"net/url"
"sort"
"strings"
)

// KindWebhook is the desired-state document kind for webhook endpoints.
const KindWebhook = "Webhook"

// Webhook states. The server enables a new endpoint by default, so an entry
// that does not set a state leaves the server's value in place.
const (
webhookStateEnabled = "enabled"
webhookStateDisabled = "disabled"
)

// WebhookSpec is one desired webhook endpoint. The URL is the identity and
// never changes. Subscribed events are the full desired set for the endpoint,
// not a per-field overlay: an empty list (or none) means the endpoint receives
// every event, which is the server's own default, and export always writes the
// field so an all-events endpoint reads as an explicit `subscribed_events: []`.
// Description and state are managed the ordinary way: present is set, omitted
// keeps the server value. Signing secrets are server-owned: the server
// generates one on create and never returns it on read, so they are not part of
// the spec.
type WebhookSpec struct {
URL string `yaml:"url"`
Description string `yaml:"description,omitempty"`
SubscribedEvents []string `yaml:"subscribed_events"`
State string `yaml:"state,omitempty"`
Delete bool `yaml:"delete,omitempty"`
}

// currentWebhook is one endpoint as returned by ListWebhooks. Headers and
// metadata are carried through an update untouched, so a reconcile that only
// changes a managed field does not wipe values an operator set elsewhere.
// Secrets are never read: the server redacts them on list.
type currentWebhook struct {
ID string
URL string
Description string
SubscribedEvents []string
State string
Headers map[string]string
Metadata map[string]any
}

// webhookOp is a single planned change. spec carries the final values to send;
// id is set for updates and deletes; headers and metadata are the endpoint's
// current values, carried through an update untouched.
type webhookOp struct {
action opAction
spec WebhookSpec
id string
detail string
headers map[string]string
metadata map[string]any
}

func (o webhookOp) String() string {
switch o.action {
case opRemove:
return fmt.Sprintf("delete webhook %s", o.spec.URL)
case opUpdate:
return fmt.Sprintf("update webhook %s (%s)", o.spec.URL, o.detail)
default:
events := "all events"
if len(o.spec.SubscribedEvents) > 0 {
events = strings.Join(o.spec.SubscribedEvents, ", ")
}
return fmt.Sprintf("add webhook %s [%s]", o.spec.URL, events)
}
}

// validateWebhookSpec rejects entries the flow cannot manage. A live entry needs
// only a valid URL: an empty event list is allowed and means the endpoint
// receives every event, which is the server's own default.
func validateWebhookSpec(s WebhookSpec) error {
if strings.TrimSpace(s.URL) == "" {
return fmt.Errorf("url is required")
}
if u, err := url.Parse(s.URL); err != nil || !u.IsAbs() {
return fmt.Errorf("url %q must be a valid absolute URL", s.URL)
}
if s.Delete {
return nil
}
if s.State != "" && s.State != webhookStateEnabled && s.State != webhookStateDisabled {
return fmt.Errorf("state must be %q or %q", webhookStateEnabled, webhookStateDisabled)
}
return nil
}

// diffWebhooks returns the ops that make the current webhook endpoints match the
// desired spec. The URL is the identity: every endpoint on the server must
// appear in the file — kept, or marked delete — so nothing is removed by
// omission, and an unaccounted endpoint fails the plan. Because the server does
// not enforce URL uniqueness, two endpoints sharing a URL make the identity
// ambiguous and also fail the plan, naming the ids to clean up.
func diffWebhooks(desired []WebhookSpec, current []currentWebhook) ([]webhookOp, error) {
idsByURL := map[string][]string{}
for _, c := range current {
idsByURL[c.URL] = append(idsByURL[c.URL], c.ID)
}
var ambiguous []string
for u, ids := range idsByURL {
if len(ids) > 1 {
sort.Strings(ids)
ambiguous = append(ambiguous, fmt.Sprintf("%s (ids: %s)", u, strings.Join(ids, ", ")))
}
}
if len(ambiguous) > 0 {
sort.Strings(ambiguous)
return nil, fmt.Errorf("the server has webhooks that share a url, so the url identity is ambiguous: %s; delete the extra one by hand, then reconcile", strings.Join(ambiguous, "; "))
}

byURL := make(map[string]currentWebhook, len(current))
for _, c := range current {
byURL[c.URL] = c
}

seen := map[string]struct{}{}
var adds, updates, removes []webhookOp
for _, s := range desired {
if err := validateWebhookSpec(s); err != nil {
return nil, fmt.Errorf("invalid webhook spec %q: %w", s.URL, err)
}
if _, dup := seen[s.URL]; dup {
return nil, fmt.Errorf("webhook %q is listed more than once", s.URL)
}
seen[s.URL] = struct{}{}

cur, exists := byURL[s.URL]
if s.Delete {
if exists {
removes = append(removes, webhookOp{action: opRemove, spec: s, id: cur.ID})
}
continue
}
desiredEvents := uniqueSorted(s.SubscribedEvents)
if !exists {
adds = append(adds, webhookOp{action: opAdd, spec: WebhookSpec{
URL: s.URL,
Description: s.Description,
SubscribedEvents: desiredEvents,
State: s.State,
}})
continue
}

merged := WebhookSpec{
URL: s.URL,
Description: cur.Description,
SubscribedEvents: uniqueSorted(cur.SubscribedEvents),
State: cur.State,
}
var changes []string
if s.Description != "" && s.Description != cur.Description {
merged.Description = s.Description
changes = append(changes, "description")
}
// Events are the full desired set, always compared. An empty or omitted
// list means every event, so leaving it out sets the endpoint to all
// events rather than keeping the server's set. The set is deduplicated so
// a hand-written list that repeats a value does not send duplicates or
// plan a spurious update.
if !stringSetsEqual(desiredEvents, uniqueSorted(cur.SubscribedEvents)) {
merged.SubscribedEvents = desiredEvents
changes = append(changes, "subscribed_events")
}
if s.State != "" && s.State != cur.State {
merged.State = s.State
changes = append(changes, "state")
}
if len(changes) > 0 {
updates = append(updates, webhookOp{
action: opUpdate,
spec: merged,
id: cur.ID,
detail: strings.Join(changes, ", "),
headers: cur.Headers,
metadata: cur.Metadata,
})
}
}

var unaccounted []string
for u := range byURL {
if _, ok := seen[u]; !ok {
unaccounted = append(unaccounted, u)
}
}
if len(unaccounted) > 0 {
sort.Strings(unaccounted)
return nil, fmt.Errorf("webhooks exist on the server but are not in the file: %s; keep them or mark them 'delete: true'", strings.Join(unaccounted, ", "))
}

ops := append(adds, updates...)
return append(ops, removes...), nil
}
Loading
Loading