diff --git a/.configs/gqlgen.yaml b/.configs/gqlgen.yaml index c88200ccf..a96bbbd95 100644 --- a/.configs/gqlgen.yaml +++ b/.configs/gqlgen.yaml @@ -81,6 +81,7 @@ autobind: - "github.com/nais/api/internal/workload/config" - "github.com/nais/api/internal/workload/instancegroup" - "github.com/nais/api/internal/workload/secret" + - "github.com/nais/api/internal/activitylog/webhook" # Don't generate Get functions for fields included in the GraphQL interfaces omit_getters: true diff --git a/.configs/sqlc.yaml b/.configs/sqlc.yaml index 253278864..f8751b82d 100644 --- a/.configs/sqlc.yaml +++ b/.configs/sqlc.yaml @@ -232,3 +232,12 @@ sql: <<: *default_go package: "restteamsapisql" out: "../internal/rest/restteamsapi/restteamsapisql" + + - <<: *default_domain + name: "Webhook SQL" + queries: "../internal/activitylog/webhook/queries" + gen: + go: + <<: *default_go + package: "webhooksql" + out: "../internal/activitylog/webhook/webhooksql" diff --git a/internal/activitylog/filter.go b/internal/activitylog/filter.go index cd7a4413f..032200002 100644 --- a/internal/activitylog/filter.go +++ b/internal/activitylog/filter.go @@ -2,8 +2,10 @@ package activitylog import ( "slices" + "strings" "github.com/jackc/pgx/v5/pgtype" + "github.com/sirupsen/logrus" ) type filter struct { @@ -16,26 +18,199 @@ var knownFilters = map[ActivityLogActivityType]filter{} // reverseFilters maps "resource_type:action" strings to their ActivityLogActivityType values. var reverseFilters = map[string][]ActivityLogActivityType{} -func RegisterFilter(activityType ActivityLogActivityType, action ActivityLogEntryAction, resourceType ActivityLogEntryResourceType) { +// WebhookEventTypeInfo describes a single webhook-subscribable event type. +type WebhookEventTypeInfo struct { + // Type is the identifier used in webhook subscription event_types (e.g. "TEAM_MEMBER_ADDED"). + Type ActivityLogActivityType `json:"type"` + // CloudEventType is the CloudEvents-spec type string (e.g. "io.nais.team.member.added"). + CloudEventType string `json:"cloudEventType"` + // Description is a human-readable summary of the event. + Description string `json:"description"` + // Group is a logical grouping label for the event type (e.g. "Team", "Service Account"). + Group string `json:"group"` + // TeamScoped indicates if this event type can be subscribed to by team-scoped webhooks. + TeamScoped bool `json:"teamScoped"` + + ignoreWebhook bool // internal flag to indicate that this event type should not be exposed in the webhook catalogue +} + +type ActivityTypeOption func(*WebhookEventTypeInfo) + +// WithDescription sets a custom description for the event type. +func WithDescription(desc string) ActivityTypeOption { + return func(info *WebhookEventTypeInfo) { + info.Description = desc + } +} + +// WithGroup sets a custom group label for the event type. +func WithGroup(group string) ActivityTypeOption { + return func(info *WebhookEventTypeInfo) { + info.Group = group + } +} + +// GlobalOnly marks the event type as global-only (not team-scoped). +func GlobalOnly() ActivityTypeOption { + return func(info *WebhookEventTypeInfo) { + info.TeamScoped = false + } +} + +// IgnoreWebhook excludes the event type from the webhook event type catalogue entirely. +func IgnoreWebhook() ActivityTypeOption { + return func(info *WebhookEventTypeInfo) { + info.ignoreWebhook = true + } +} + +// eventTypeInfos stores metadata for all registered activity types. +var eventTypeInfos = map[ActivityLogActivityType]WebhookEventTypeInfo{} + +// groupPrefixes maps known multi-word prefixes to their display group names. +var groupPrefixes = map[string]string{ + "SERVICE_ACCOUNT": "Service Account", + "GENERIC_KUBERNETES_RESOURCE": "Kubernetes", + "OPENSEARCH": "OpenSearch", + "JOB_RUN": "Job", +} + +// autoGroupAndDescription derives a display group and description from an activity type name. +// Example: "TEAM_MEMBER_ADDED" → group "Team", description "Team member added". +func autoGroupAndDescription(at ActivityLogActivityType) (description, group string) { + s := string(at) + + // Check multi-word prefix overrides first (longest match wins) + longestPrefix := "" + longestGroup := "" + for prefix, grp := range groupPrefixes { + if (s == prefix || strings.HasPrefix(s, prefix+"_")) && len(prefix) > len(longestPrefix) { + longestPrefix = prefix + longestGroup = grp + } + } + if longestGroup != "" { + group = longestGroup + } else { + // Single-word group: first token, title-cased + before, _, ok := strings.Cut(s, "_") + if !ok { + group = titleCase(s) + } else { + group = titleCase(before) + } + } + + words := strings.Split(strings.ToLower(s), "_") + description = strings.Join(words, " ") + // Title-case first word only for sentence-style description + if len(description) > 0 { + description = strings.ToUpper(description[:1]) + description[1:] + } + return +} + +func titleCase(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + strings.ToLower(s[1:]) +} + +// CloudEventType converts an ActivityLogActivityType to a CloudEvents-spec type string. +// Example: "TEAM_MEMBER_ADDED" → "io.nais.team.member.added". +func CloudEventType(at ActivityLogActivityType) string { + lower := strings.ToLower(string(at)) + dotted := strings.ReplaceAll(lower, "_", ".") + return "io.nais." + dotted +} + +// KnownEventTypes returns metadata for all registered activity log event types, suitable +// for exposing as a webhook event type catalogue. +func KnownEventTypes() []WebhookEventTypeInfo { + result := make([]WebhookEventTypeInfo, 0, len(knownFilters)) + for at := range knownFilters { + info, ok := eventTypeInfos[at] + if !ok { + logrus.WithField("activity_type", at).Warn("activity type registered without webhook event type info; using auto-generated description and group") + desc, grp := autoGroupAndDescription(at) + info = WebhookEventTypeInfo{ + Type: at, + CloudEventType: CloudEventType(at), + Description: desc, + Group: grp, + TeamScoped: true, + } + } + + if info.ignoreWebhook { + continue + } + result = append(result, info) + } + slices.SortFunc(result, func(a, b WebhookEventTypeInfo) int { + if a.Group != b.Group { + return strings.Compare(a.Group, b.Group) + } + return strings.Compare(string(a.Type), string(b.Type)) + }) + return result +} + +// IsTeamScoped returns true if the event type is subscribable by team webhooks. +func IsTeamScoped(at ActivityLogActivityType) bool { + info, ok := eventTypeInfos[at] + if !ok { + return true + } + return info.TeamScoped +} + +// IsValidActivityType returns true if the event type is '*' or a registered activity type. +func IsValidActivityType(at string) bool { + if at == "*" { + return true + } + _, ok := knownFilters[ActivityLogActivityType(at)] + return ok +} + +// RegisterActivityType registers an activity log activity type, configuring its action, +// resourceType mapping, and optional webhook options. +func RegisterActivityType(activityType ActivityLogActivityType, action ActivityLogEntryAction, resourceType ActivityLogEntryResourceType, opts ...ActivityTypeOption) { if f, ok := knownFilters[activityType]; ok { if f.action == action { - // If the activity type is already registered with the same action, append the resource type f.resourceType = append(f.resourceType, resourceType) - // Make sure the resource type slice is unique slices.Sort(f.resourceType) f.resourceType = slices.Compact(f.resourceType) knownFilters[activityType] = f rebuildReverseFilters() - return + } else { + panic("activity type already registered: " + string(activityType) + " with action " + string(f.action)) + } + } else { + knownFilters[activityType] = filter{ + action: action, + resourceType: []ActivityLogEntryResourceType{resourceType}, } - panic("filter already registered: " + string(activityType) + " with action " + string(f.action)) + rebuildReverseFilters() + } + + desc, grp := autoGroupAndDescription(activityType) + info := &WebhookEventTypeInfo{ + Type: activityType, + CloudEventType: CloudEventType(activityType), + Description: desc, + Group: grp, + TeamScoped: true, } - knownFilters[activityType] = filter{ - action: action, - resourceType: []ActivityLogEntryResourceType{resourceType}, + + for _, opt := range opts { + opt(info) } - rebuildReverseFilters() + + eventTypeInfos[activityType] = *info } func rebuildReverseFilters() { diff --git a/internal/activitylog/filter_test.go b/internal/activitylog/filter_test.go new file mode 100644 index 000000000..cae6c8dad --- /dev/null +++ b/internal/activitylog/filter_test.go @@ -0,0 +1,178 @@ +package activitylog + +import ( + "slices" + "testing" +) + +func TestRegisterActivityType_PanicsOnConflictingAction(t *testing.T) { + const activityType ActivityLogActivityType = "FILTER_TEST_CONFLICT" + + RegisterActivityType(activityType, ActivityLogEntryActionCreated, "FILTER_TEST_RESOURCE_A") + + defer func() { + if r := recover(); r == nil { + t.Fatal("expected RegisterActivityType to panic when re-registering with a different action") + } + }() + + RegisterActivityType(activityType, ActivityLogEntryActionUpdated, "FILTER_TEST_RESOURCE_A") +} + +func TestRegisterActivityType_MergesResourceTypes(t *testing.T) { + const activityType ActivityLogActivityType = "FILTER_TEST_MERGE" + + RegisterActivityType(activityType, ActivityLogEntryActionCreated, "FILTER_TEST_RESOURCE_B") + RegisterActivityType(activityType, ActivityLogEntryActionCreated, "FILTER_TEST_RESOURCE_C") + // Registering the same resource type again must not create a duplicate entry. + RegisterActivityType(activityType, ActivityLogEntryActionCreated, "FILTER_TEST_RESOURCE_B") + + typesB := LookupActivityTypes("FILTER_TEST_RESOURCE_B", string(ActivityLogEntryActionCreated)) + typesC := LookupActivityTypes("FILTER_TEST_RESOURCE_C", string(ActivityLogEntryActionCreated)) + + if !slices.Contains(typesB, activityType) { + t.Fatalf("expected %q to be resolvable from resource B, got %v", activityType, typesB) + } + if !slices.Contains(typesC, activityType) { + t.Fatalf("expected %q to be resolvable from resource C, got %v", activityType, typesC) + } + + f := knownFilters[activityType] + count := 0 + for _, rt := range f.resourceType { + if rt == "FILTER_TEST_RESOURCE_B" { + count++ + } + } + if count != 1 { + t.Fatalf("expected FILTER_TEST_RESOURCE_B to appear exactly once, got %d", count) + } +} + +func TestKnownEventTypes_ExcludesIgnoreWebhook(t *testing.T) { + const visible ActivityLogActivityType = "FILTER_TEST_VISIBLE" + const hidden ActivityLogActivityType = "FILTER_TEST_HIDDEN" + + RegisterActivityType(visible, ActivityLogEntryActionCreated, "FILTER_TEST_RESOURCE_D") + RegisterActivityType(hidden, ActivityLogEntryActionCreated, "FILTER_TEST_RESOURCE_D", IgnoreWebhook()) + + all := KnownEventTypes() + + foundVisible := false + for _, info := range all { + if info.Type == hidden { + t.Fatalf("expected %q to be excluded from KnownEventTypes, but it was present", hidden) + } + if info.Type == visible { + foundVisible = true + } + } + if !foundVisible { + t.Fatalf("expected %q to be present in KnownEventTypes", visible) + } +} + +func TestIsTeamScoped(t *testing.T) { + const teamScoped ActivityLogActivityType = "FILTER_TEST_TEAM_SCOPED" + const globalOnly ActivityLogActivityType = "FILTER_TEST_GLOBAL_ONLY" + + RegisterActivityType(teamScoped, ActivityLogEntryActionCreated, "FILTER_TEST_RESOURCE_E") + RegisterActivityType(globalOnly, ActivityLogEntryActionCreated, "FILTER_TEST_RESOURCE_E", GlobalOnly()) + + if !IsTeamScoped(teamScoped) { + t.Errorf("expected %q to be team-scoped by default", teamScoped) + } + if IsTeamScoped(globalOnly) { + t.Errorf("expected %q to be global-only", globalOnly) + } + if !IsTeamScoped("FILTER_TEST_UNKNOWN") { + t.Error("expected an unregistered activity type to default to team-scoped") + } +} + +func TestIsValidActivityType(t *testing.T) { + const activityType ActivityLogActivityType = "FILTER_TEST_VALID" + RegisterActivityType(activityType, ActivityLogEntryActionCreated, "FILTER_TEST_RESOURCE_F") + + if !IsValidActivityType("*") { + t.Error(`expected "*" to be valid`) + } + if !IsValidActivityType(string(activityType)) { + t.Errorf("expected %q to be valid", activityType) + } + if IsValidActivityType("FILTER_TEST_NOT_REGISTERED") { + t.Error("expected an unregistered activity type to be invalid") + } +} + +func TestCloudEventType(t *testing.T) { + tests := []struct { + in ActivityLogActivityType + want string + }{ + {"TEAM_MEMBER_ADDED", "io.nais.team.member.added"}, + {"POSTGRES_DELETED", "io.nais.postgres.deleted"}, + } + + for _, tt := range tests { + if got := CloudEventType(tt.in); got != tt.want { + t.Errorf("CloudEventType(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestAutoGroupAndDescription(t *testing.T) { + tests := []struct { + in ActivityLogActivityType + wantGroup string + }{ + {"TEAM_MEMBER_ADDED", "Team"}, + {"SERVICE_ACCOUNT_CREATED", "Service Account"}, + {"GENERIC_KUBERNETES_RESOURCE_CREATED", "Kubernetes"}, + {"OPENSEARCH_CREATED", "OpenSearch"}, + {"JOB_RUN_DELETED", "Job"}, + } + + for _, tt := range tests { + _, group := autoGroupAndDescription(tt.in) + if group != tt.wantGroup { + t.Errorf("autoGroupAndDescription(%q) group = %q, want %q", tt.in, group, tt.wantGroup) + } + } +} + +func TestRegisterActivityType_CustomOptions(t *testing.T) { + const activityType ActivityLogActivityType = "FILTER_TEST_CUSTOM" + + RegisterActivityType(activityType, ActivityLogEntryActionCreated, "FILTER_TEST_RESOURCE_G", + WithDescription("custom description"), + WithGroup("Custom Group"), + ) + + var found *WebhookEventTypeInfo + for _, info := range KnownEventTypes() { + if info.Type == activityType { + info := info + found = &info + break + } + } + if found == nil { + t.Fatalf("expected %q to be present in KnownEventTypes", activityType) + } + if found.Description != "custom description" { + t.Errorf("expected custom description, got %q", found.Description) + } + if found.Group != "Custom Group" { + t.Errorf("expected custom group, got %q", found.Group) + } + if !found.TeamScoped { + t.Error("expected TeamScoped to default to true") + } +} + +func TestLookupActivityTypes_UnknownReturnsEmpty(t *testing.T) { + if got := LookupActivityTypes("FILTER_TEST_UNKNOWN_RESOURCE", "UNKNOWN_ACTION"); len(got) != 0 { + t.Errorf("expected no matches, got %v", got) + } +} diff --git a/internal/activitylog/webhook/README.md b/internal/activitylog/webhook/README.md new file mode 100644 index 000000000..7e6168cbf --- /dev/null +++ b/internal/activitylog/webhook/README.md @@ -0,0 +1,178 @@ +# Webhook System + +Sends HTTP callbacks to user-registered endpoints when activity log events occur. +Supports team-scoped and global subscriptions, HMAC-signed CloudEvents 1.0 payloads, +durable delivery via a PostgreSQL outbox, and automatic retry with exponential backoff. + +## Flow + +Processing happens in two stages: outbox events are fanned out into per-subscription +delivery rows, and each delivery row is then retried independently of the others. + +```mermaid +sequenceDiagram + participant App as Application code + participant AL as activity_log_entries + participant WE as webhook_events (outbox) + participant ED as webhook_event_deliveries (per-subscriber queue) + participant D as Dispatcher + participant Sub as Subscriber endpoint + + App->>AL: INSERT (any domain action) + AL->>WE: Trigger copies row + pg_notify('api_notify') + D-->>WE: LISTEN / 30s poll fallback + D->>WE: SELECT … FOR UPDATE SKIP LOCKED (claim batch) + D->>ED: Match against enabled subscriptions, INSERT one row per match + D->>WE: mark fanned-out event 'completed' (same transaction as above) + D->>ED: SELECT … FOR UPDATE SKIP LOCKED (claim batch) + D->>Sub: HTTP POST CloudEvent (HMAC-signed) + alt 2xx + D->>ED: status = 'completed' + D->>webhook_subscriptions: reset consecutive_failures + else failure / timeout + D->>ED: requeue this row with run_at = NOW() + backoff + D->>webhook_subscriptions: increment consecutive_failures + note over D: auto-disable after 10 consecutive failures + end +``` + +## Key components + +| File | Responsibility | +| ---------------- | ---------------------------------------------------------------------- | +| `dispatcher.go` | Outbox consumer: LISTEN/NOTIFY + poll, fan-out, claim, deliver, retry | +| `cleaner.go` | Daily leader-only pruning of processed outbox/queue and old deliveries | +| `cloudevents.go` | Build CloudEvents 1.0 envelope; derive `type` from activity type | +| `signer.go` | HMAC-SHA256 payload signing (`X-Webhook-Signature` header) | +| `model.go` | Domain types; `MatchesEvent` subscription/event matching logic | +| `queries.go` | CRUD operations with authorisation | +| `dataloader.go` | Context-scoped DB + dispatcher access | + +## Database tables + +- **`webhook_subscriptions`** — registered endpoints (URL, secret, event_types, team scope) +- **`webhook_events`** — lightweight outbox; each row is just a reference (`activity_log_entries_id`) to the source event, inserted by a PostgreSQL trigger on `activity_log_entries`. No data duplication. A row's job is done once it has been "fanned out" (see below); it doesn't track delivery outcomes itself. +- **`webhook_event_deliveries`** — one row per `(webhook_event, subscription)` match, created by the dispatcher's fan-out step. This is the actual unit of retry: `status`/`retry_count`/`run_at` here are scoped to a single subscriber's delivery of a single event, so retries never touch other subscribers. +- **`webhook_deliveries`** — audit log of every actual HTTP delivery attempt, optionally linked back to the `webhook_event_deliveries` row that produced it. + +### Why two stages? + +Subscription matching (event-type wildcards `*`, team scoping) is application logic that +lives in Go (`activitylog.RegisterActivityType`/`MatchesEvent`), so it can't be done by the +trigger. The trigger only records that an event happened; the dispatcher fans it out into +one delivery row per matching subscription, and retry/backoff bookkeeping happens at that +level. + +## Multi-instance safety + +The dispatcher is started on every API replica (`go webhookDispatcher.Run(ctx)`, unconditional — no leader election). This is safe: + +- Postgres `NOTIFY` on `api_notify` is broadcast to every connection currently `LISTEN`ing on it, so all replicas wake up when new work arrives (plus a 30s poll fallback per replica in case a notification is missed). +- Both the fan-out claim (`ClaimOutboxEventsForFanout`) and the delivery claim (`ClaimPendingDeliveries`) use `SELECT ... FOR UPDATE SKIP LOCKED`. Concurrent replicas racing on these queries can never select the same row — whichever transaction locks a row first "wins" it, and everyone else's `SKIP LOCKED` simply skips it and claims different rows instead. More replicas just means more parallel draining capacity, never duplicate work. +- Fan-out (claiming an event, matching subscriptions, inserting delivery rows, marking the event completed) happens in a single DB transaction. `CreateEventDelivery` is idempotent (`ON CONFLICT (webhook_event_id, subscription_id) DO NOTHING`), so an interrupted or repeated fan-out attempt is safe. +- Delivery marks a `webhook_event_deliveries` row `completed` at claim time, before the HTTP call is made. A replica killed mid-delivery could lose that one delivery without a retry — a known trade-off; a `processing` status with a lease and reaper would close this gap if it becomes a problem in practice. + +## Retention & cleanup + +`RunCleaner` runs once a day on every replica, but only the current leader (via `leaderelection.IsLeader`) actually performs deletes, so pruning happens exactly once cluster-wide per interval: + +- `webhook_events` and `webhook_event_deliveries` (internal processing state) are pruned after 7 days. +- `webhook_deliveries` (the user-facing delivery audit log) is pruned after 30 days. + +## Event types + +Event types are driven by `activitylog.RegisterActivityType` calls throughout the codebase. +Every registered activity type is automatically available as a subscribable event type. Option functions allow customising descriptions, grouping, or scope. + +```go +// Any domain package's init(): +activitylog.RegisterActivityType( + "TEAM_MEMBER_ADDED", + activitylog.ActivityLogEntryActionAdded, + resourceType, + activitylog.WithDescription("A user was added to the team"), // Custom description + activitylog.WithGroup("Team"), // Custom UI grouping +) + +// Global/admin-only event types can be marked so team-scoped webhooks cannot subscribe to them: +activitylog.RegisterActivityType( + "RECONCILER_ENABLED", + action, + resourceType, + activitylog.GlobalOnly(), +) +``` + +The `webhookEventTypes` GraphQL query exposes the full catalogue with descriptions, groups, and `teamScoped` status. + +Subscription `event_types` accepts activity type names (e.g. `TEAM_MEMBER_ADDED`) or `*` for all events. If a team-scoped webhook tries to subscribe to a `GlobalOnly` event type, creation/update will fail validation. + +## CloudEvents type mapping + +The PostgreSQL trigger stores events as `RESOURCE_TYPE:ACTION` (e.g. `TEAM:ADDED`). +The dispatcher resolves this to an `ActivityLogActivityType` via `LookupActivityTypes`, then converts +it to a CloudEvents-spec type string: + +``` +TEAM_MEMBER_ADDED → io.nais.team.member.added +POSTGRES_DELETED → io.nais.postgres.deleted +``` + +### Idempotency / deduplication + +The CloudEvents `id` field is set to the `webhook_event_deliveries` row's own id, which is +stable across retries of that specific `(event, subscription)` delivery — it does **not** +change if a delivery is retried after a failure. Subscribers that need exactly-once +processing semantics should treat delivery as **at-least-once** and deduplicate on `id`. + +## Retry schedule + +| Attempt | Delay | +| ------- | -------- | +| 1 | 1 min | +| 2 | 5 min | +| 3 | 15 min | +| 4 | 1 hour | +| 5 | 4 hours | +| 6 | 8 hours | +| 7 | 12 hours | + +After 7 failed attempts the delivery is marked `failed`. After 10 consecutive failures across +any deliveries, the subscription is automatically disabled (`enabled = false`, `disabled_at` set). +Retries and the failure counter are both scoped per subscriber — one broken subscriber +retrying (and eventually being auto-disabled) has no effect on other subscribers of the +same events. + +## Authorisation + +| Action | Allowed | +| ------------------- | ------------------------------------------ | +| Team-scoped webhook | Team owner | +| Global webhook | Admin (Go-level check, not a DB role) | +| Update / delete | Owner of the subscription's team, or admin | + +## Monitoring & Metrics + +The webhook domain exports telemetry using native OpenTelemetry metrics under the meter name `webhook`: + +### PromQL Alerts Examples + +1. **Increasing delivery queue size** (Potential worker blockage or overload): + + ```promql + max(nais_api_webhook_queue_size{status="pending"}) > 100 + ``` + + _Trigger conditions_: `nais_api_webhook_queue_size` reads shared Postgres state (a plain `COUNT(*) GROUP BY status`), so **every** replica reports the identical number — it is not gated behind leader election. Use `max()` or `avg()` when aggregating across replicas, **not `sum()`**, since summing would multiply the true value by the replica count. + +2. **High webhook delivery failure rate**: + + ```promql + sum(rate(webhook_deliveries_total{success="false"}[5m])) / sum(rate(webhook_deliveries_total[5m])) * 100 > 10 + ``` + +3. **Auto-disabled subscriptions rate**: + + ```promql + sum(rate(webhook_subscriptions_auto_disabled_total[1h])) > 0 + ``` diff --git a/internal/activitylog/webhook/cleaner.go b/internal/activitylog/webhook/cleaner.go new file mode 100644 index 000000000..d24f1aaa7 --- /dev/null +++ b/internal/activitylog/webhook/cleaner.go @@ -0,0 +1,68 @@ +package webhook + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/nais/api/internal/activitylog/webhook/webhooksql" + "github.com/nais/api/internal/leaderelection" + "github.com/sirupsen/logrus" +) + +const ( + cleanupInterval = 24 * time.Hour + + // queueRetention controls how long completed/failed rows are kept in the internal + // processing tables (webhook_events, webhook_event_deliveries) after they've reached + // a terminal state. + queueRetention = 7 * 24 * time.Hour + + // deliveryRetention controls how long delivery attempts are kept in the user-facing + // audit log (webhook_deliveries). + deliveryRetention = 30 * 24 * time.Hour +) + +// RunCleaner periodically prunes old webhook processing and delivery data. Blocks until +// ctx is cancelled. Only the current leader replica performs deletes; other replicas are +// a no-op on each tick. +func RunCleaner(ctx context.Context, dbtx webhooksql.DBTX, log logrus.FieldLogger) { + q := webhooksql.New(dbtx) + + for { + if err := clean(ctx, q); err != nil { + log.WithError(err).Error("cleaning webhook data") + } + + select { + case <-ctx.Done(): + return + case <-time.After(cleanupInterval): + } + } +} + +func clean(ctx context.Context, q *webhooksql.Queries) error { + if !leaderelection.IsLeader() { + return nil + } + + now := time.Now() + queueBefore := pgtype.Timestamptz{Time: now.Add(-queueRetention), Valid: true} + deliveryBefore := pgtype.Timestamptz{Time: now.Add(-deliveryRetention), Valid: true} + + if err := q.PruneOldOutboxEvents(ctx, queueBefore); err != nil { + return fmt.Errorf("pruning outbox events: %w", err) + } + + if err := q.PruneOldEventDeliveries(ctx, queueBefore); err != nil { + return fmt.Errorf("pruning event deliveries: %w", err) + } + + if err := q.PruneDeliveries(ctx, deliveryBefore); err != nil { + return fmt.Errorf("pruning delivery audit log: %w", err) + } + + return nil +} diff --git a/internal/activitylog/webhook/cloudevents.go b/internal/activitylog/webhook/cloudevents.go new file mode 100644 index 000000000..0e44bc9f7 --- /dev/null +++ b/internal/activitylog/webhook/cloudevents.go @@ -0,0 +1,88 @@ +package webhook + +import ( + "encoding/json" + "strings" + "time" + + "github.com/nais/api/internal/activitylog" +) + +// CloudEvent represents a CloudEvents 1.0 envelope. +type CloudEvent struct { + SpecVersion string `json:"specversion"` + ID string `json:"id"` + Source string `json:"source"` + Type string `json:"type"` + Subject string `json:"subject,omitempty"` + Time string `json:"time"` + DataContentType string `json:"datacontenttype"` + Data json.RawMessage `json:"data"` +} + +// CloudEventData is the data payload within a CloudEvent. +type CloudEventData struct { + Actor string `json:"actor"` + ResourceType string `json:"resourceType"` + ResourceName string `json:"resourceName"` + TeamSlug *string `json:"teamSlug,omitempty"` + Environment *string `json:"environment,omitempty"` + Data json.RawMessage `json:"data,omitempty"` +} + +// cloudEventTypeFromEvent returns the CloudEvents-spec type for an event. +// If the event has resolved ActivityTypes, the first one is used; otherwise the raw +// event type is lowercased and dot-separated (e.g. "ping" → "io.nais.ping"). +func cloudEventTypeFromEvent(event WebhookEvent) string { + if len(event.ActivityTypes) > 0 { + return activitylog.CloudEventType(activitylog.ActivityLogActivityType(event.ActivityTypes[0])) + } + // Synthetic events (e.g. "ping") — just prefix with io.nais. + lower := strings.ToLower(event.RawEventType) + dotted := strings.ReplaceAll(lower, "_", ".") + return "io.nais." + dotted +} + +// BuildCloudEvent creates a CloudEvents 1.0 envelope from a webhook event. +// +// id must be stable across redeliveries of the same logical (event, subscriber) delivery +// attempt. +func BuildCloudEvent(source, id string, event WebhookEvent) ([]byte, error) { + var teamSlug *string + if event.TeamSlug != nil { + s := event.TeamSlug.String() + teamSlug = &s + } + + eventData := CloudEventData{ + Actor: event.Actor, + ResourceType: event.ResourceType, + ResourceName: event.ResourceName, + TeamSlug: teamSlug, + Environment: event.Environment, + Data: event.Data, + } + + dataBytes, err := json.Marshal(eventData) + if err != nil { + return nil, err + } + + subject := event.ResourceName + if event.TeamSlug != nil { + subject = event.TeamSlug.String() + "/" + event.ResourceName + } + + ce := CloudEvent{ + SpecVersion: "1.0", + ID: id, + Source: source, + Type: cloudEventTypeFromEvent(event), + Subject: subject, + Time: time.Now().UTC().Format(time.RFC3339), + DataContentType: "application/json", + Data: dataBytes, + } + + return json.Marshal(ce) +} diff --git a/internal/activitylog/webhook/cloudevents_test.go b/internal/activitylog/webhook/cloudevents_test.go new file mode 100644 index 000000000..82ad9e940 --- /dev/null +++ b/internal/activitylog/webhook/cloudevents_test.go @@ -0,0 +1,163 @@ +package webhook + +import ( + "encoding/json" + "testing" + "time" + + "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/slug" +) + +func init() { + activitylog.RegisterActivityType("WEBHOOK_TEST_CLOUDEVENTS_EVENT", activitylog.ActivityLogEntryActionCreated, "WEBHOOK_TEST_CLOUDEVENTS_RESOURCE") +} + +func TestBuildCloudEvent_Envelope(t *testing.T) { + team := slug.Slug("my-team") + event := WebhookEvent{ + ActivityTypes: []string{"WEBHOOK_TEST_CLOUDEVENTS_EVENT"}, + TeamSlug: &team, + Actor: "user@example.com", + ResourceType: "TEAM", + ResourceName: "my-resource", + } + + before := time.Now().UTC() + raw, err := BuildCloudEvent("https://api.example.com", "delivery-id-123", event) + if err != nil { + t.Fatalf("BuildCloudEvent() error = %v", err) + } + after := time.Now().UTC() + + var ce CloudEvent + if err := json.Unmarshal(raw, &ce); err != nil { + t.Fatalf("failed to unmarshal cloud event: %v", err) + } + + if ce.SpecVersion != "1.0" { + t.Errorf("SpecVersion = %q, want %q", ce.SpecVersion, "1.0") + } + if ce.ID != "delivery-id-123" { + t.Errorf("ID = %q, want %q", ce.ID, "delivery-id-123") + } + if ce.Source != "https://api.example.com" { + t.Errorf("Source = %q, want %q", ce.Source, "https://api.example.com") + } + if ce.DataContentType != "application/json" { + t.Errorf("DataContentType = %q, want %q", ce.DataContentType, "application/json") + } + if ce.Subject != "my-team/my-resource" { + t.Errorf("Subject = %q, want %q", ce.Subject, "my-team/my-resource") + } + wantType := activitylog.CloudEventType("WEBHOOK_TEST_CLOUDEVENTS_EVENT") + if ce.Type != wantType { + t.Errorf("Type = %q, want %q", ce.Type, wantType) + } + + parsedTime, err := time.Parse(time.RFC3339, ce.Time) + if err != nil { + t.Fatalf("failed to parse Time as RFC3339: %v", err) + } + if parsedTime.Before(before.Add(-time.Second)) || parsedTime.After(after.Add(time.Second)) { + t.Errorf("Time %v not within expected range [%v, %v]", parsedTime, before, after) + } + + var data CloudEventData + if err := json.Unmarshal(ce.Data, &data); err != nil { + t.Fatalf("failed to unmarshal cloud event data: %v", err) + } + if data.Actor != event.Actor { + t.Errorf("Data.Actor = %q, want %q", data.Actor, event.Actor) + } + if data.ResourceType != event.ResourceType { + t.Errorf("Data.ResourceType = %q, want %q", data.ResourceType, event.ResourceType) + } + if data.ResourceName != event.ResourceName { + t.Errorf("Data.ResourceName = %q, want %q", data.ResourceName, event.ResourceName) + } + if data.TeamSlug == nil || *data.TeamSlug != team.String() { + t.Errorf("Data.TeamSlug = %v, want %q", data.TeamSlug, team.String()) + } + if data.Environment != nil { + t.Errorf("Data.Environment = %v, want nil", data.Environment) + } +} + +func TestBuildCloudEvent_SubjectWithoutTeam(t *testing.T) { + event := WebhookEvent{ + ActivityTypes: []string{"WEBHOOK_TEST_CLOUDEVENTS_EVENT"}, + ResourceName: "my-resource", + } + + raw, err := BuildCloudEvent("https://api.example.com", "id", event) + if err != nil { + t.Fatalf("BuildCloudEvent() error = %v", err) + } + + var ce CloudEvent + if err := json.Unmarshal(raw, &ce); err != nil { + t.Fatalf("failed to unmarshal cloud event: %v", err) + } + + if ce.Subject != "my-resource" { + t.Errorf("Subject = %q, want %q", ce.Subject, "my-resource") + } + + var data CloudEventData + if err := json.Unmarshal(ce.Data, &data); err != nil { + t.Fatalf("failed to unmarshal cloud event data: %v", err) + } + if data.TeamSlug != nil { + t.Errorf("Data.TeamSlug = %v, want nil", data.TeamSlug) + } +} + +func TestCloudEventTypeFromEvent_SyntheticEventFallsBackToRawType(t *testing.T) { + event := WebhookEvent{ + RawEventType: "ping", + } + + if got := cloudEventTypeFromEvent(event); got != "io.nais.ping" { + t.Errorf("cloudEventTypeFromEvent() = %q, want %q", got, "io.nais.ping") + } +} + +func TestCloudEventTypeFromEvent_UsesFirstActivityType(t *testing.T) { + event := WebhookEvent{ + ActivityTypes: []string{"WEBHOOK_TEST_CLOUDEVENTS_EVENT", "SOME_OTHER_TYPE"}, + } + + want := activitylog.CloudEventType("WEBHOOK_TEST_CLOUDEVENTS_EVENT") + if got := cloudEventTypeFromEvent(event); got != want { + t.Errorf("cloudEventTypeFromEvent() = %q, want %q", got, want) + } +} + +func TestBuildCloudEvent_DataOmittedWhenNil(t *testing.T) { + event := WebhookEvent{ + ActivityTypes: []string{"WEBHOOK_TEST_CLOUDEVENTS_EVENT"}, + ResourceName: "my-resource", + } + + raw, err := BuildCloudEvent("https://api.example.com", "id", event) + if err != nil { + t.Fatalf("BuildCloudEvent() error = %v", err) + } + + var ce CloudEvent + if err := json.Unmarshal(raw, &ce); err != nil { + t.Fatalf("failed to unmarshal cloud event: %v", err) + } + + var raw2 map[string]json.RawMessage + if err := json.Unmarshal(ce.Data, &raw2); err != nil { + t.Fatalf("failed to unmarshal cloud event data as map: %v", err) + } + if _, ok := raw2["data"]; ok { + t.Error("expected 'data' field to be omitted when event.Data is nil") + } + if _, ok := raw2["environment"]; ok { + t.Error("expected 'environment' field to be omitted when event.Environment is nil") + } +} diff --git a/internal/activitylog/webhook/dataloader.go b/internal/activitylog/webhook/dataloader.go new file mode 100644 index 000000000..03294f7d5 --- /dev/null +++ b/internal/activitylog/webhook/dataloader.go @@ -0,0 +1,73 @@ +package webhook + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/nais/api/internal/activitylog/webhook/webhooksql" + "github.com/nais/api/internal/database" + "github.com/nais/api/internal/graph/loader" + "github.com/vikstrous/dataloadgen" +) + +type ctxKey int + +const loadersKey ctxKey = iota + +func NewLoaderContext(ctx context.Context, dbConn *pgxpool.Pool, dispatcher *Dispatcher) context.Context { + return context.WithValue(ctx, loadersKey, newLoaders(dbConn, dispatcher)) +} + +func fromContext(ctx context.Context) *loaders { + return ctx.Value(loadersKey).(*loaders) +} + +type loaders struct { + internalQuerier *webhooksql.Queries + subscriptionLoader *dataloadgen.Loader[uuid.UUID, *WebhookSubscription] + deliveryLoader *dataloadgen.Loader[uuid.UUID, *WebhookDelivery] + dispatcher *Dispatcher +} + +func newLoaders(dbConn *pgxpool.Pool, dispatcher *Dispatcher) *loaders { + db := webhooksql.New(dbConn) + + subLoader := &subscriptionDataloader{db: db} + delLoader := &deliveryDataloader{db: db} + + return &loaders{ + internalQuerier: db, + subscriptionLoader: dataloadgen.NewLoader(subLoader.get, loader.DefaultDataLoaderOptions...), + deliveryLoader: dataloadgen.NewLoader(delLoader.get, loader.DefaultDataLoaderOptions...), + dispatcher: dispatcher, + } +} + +type subscriptionDataloader struct { + db webhooksql.Querier +} + +func (l *subscriptionDataloader) get(ctx context.Context, ids []uuid.UUID) ([]*WebhookSubscription, []error) { + makeKey := func(obj *WebhookSubscription) uuid.UUID { return obj.UUID } + return loader.LoadModels(ctx, ids, l.db.ListSubscriptionsByIDs, toGraphSubscription, makeKey) +} + +type deliveryDataloader struct { + db webhooksql.Querier +} + +func (l *deliveryDataloader) get(ctx context.Context, ids []uuid.UUID) ([]*WebhookDelivery, []error) { + makeKey := func(obj *WebhookDelivery) uuid.UUID { return obj.UUID } + return loader.LoadModels(ctx, ids, l.db.ListDeliveriesByIDs, toGraphDelivery, makeKey) +} + +func db(ctx context.Context) *webhooksql.Queries { + l := fromContext(ctx) + + if tx := database.TransactionFromContext(ctx); tx != nil { + return l.internalQuerier.WithTx(tx) + } + + return l.internalQuerier +} diff --git a/internal/activitylog/webhook/dispatcher.go b/internal/activitylog/webhook/dispatcher.go new file mode 100644 index 000000000..0222b8758 --- /dev/null +++ b/internal/activitylog/webhook/dispatcher.go @@ -0,0 +1,416 @@ +package webhook + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog/webhook/webhooksql" + "github.com/nais/api/internal/database/notify" + "github.com/nais/api/internal/slug" + "github.com/sirupsen/logrus" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +const ( + defaultTimeout = 10 * time.Second + eventBatchSize = 50 + pollInterval = 30 * time.Second + maxRetryCount = 7 // ~24h total with exponential backoff + disableThreshold = 10 // auto-disable after 10 consecutive failures + userAgentHeader = "Nais-API-Webhook/1.0" + signatureHeader = "X-Webhook-Signature" + contentTypeHeader = "application/cloudevents+json" +) + +// retryBackoffs defines how long to wait before retrying at each retry_count. +// Total span: ~24 hours. +var retryBackoffs = []time.Duration{ + 1 * time.Minute, + 5 * time.Minute, + 15 * time.Minute, + 1 * time.Hour, + 4 * time.Hour, + 8 * time.Hour, + 12 * time.Hour, +} + +// Dispatcher processes webhook events from the outbox table and delivers them to subscribers. +type Dispatcher struct { + pool *pgxpool.Pool + notifier *notify.Notifier + log logrus.FieldLogger + source string + httpClient *http.Client + metrics *webhookMetrics +} + +// NewDispatcher creates a new webhook dispatcher that drains events from the outbox table. +func NewDispatcher(pool *pgxpool.Pool, notifier *notify.Notifier, source string, log logrus.FieldLogger) (*Dispatcher, error) { + q := webhooksql.New(pool) + m, err := newWebhookMetrics(q) + if err != nil { + return nil, fmt.Errorf("setting up webhook metrics: %w", err) + } + + return &Dispatcher{ + pool: pool, + notifier: notifier, + log: log.WithField("subsystem", "webhook_dispatcher"), + source: source, + httpClient: &http.Client{ + Timeout: defaultTimeout, + }, + metrics: m, + }, nil +} + +// Run starts the dispatcher. It listens for PG NOTIFY on "webhook_events" and +// periodically polls for unprocessed events. Blocks until ctx is cancelled. +func (d *Dispatcher) Run(ctx context.Context) { + ch := d.notifier.Listen("webhook_events") + + // Process any events that were queued before we started + d.drainOutbox(ctx) + + for { + select { + case <-ctx.Done(): + return + case <-ch: + d.drainOutbox(ctx) + case <-time.After(pollInterval): + // Safety net: poll periodically in case a notification was missed + // or to pick up events/deliveries whose run_at has arrived. + d.drainOutbox(ctx) + } + } +} + +// drainOutbox fans pending outbox events out into per-subscription delivery rows, then +// drains and delivers any pending delivery rows. +func (d *Dispatcher) drainOutbox(ctx context.Context) { + d.fanOutPendingEvents(ctx) + d.drainPendingDeliveries(ctx) +} + +// fanOutPendingEvents claims batches of outbox events that haven't been matched against +// subscriptions yet, and creates one webhook_event_deliveries row per currently enabled +// subscription that matches. Subscription matching only happens here; every later retry +// operates on a single (event, subscription) delivery row. +func (d *Dispatcher) fanOutPendingEvents(ctx context.Context) { + for { + more, err := d.fanOutBatch(ctx) + if err != nil { + d.log.WithError(err).Error("fanning out webhook outbox events") + return + } + if !more { + return + } + } +} + +func (d *Dispatcher) fanOutBatch(ctx context.Context) (bool, error) { + tx, err := d.pool.Begin(ctx) + if err != nil { + return false, fmt.Errorf("beginning fan-out transaction: %w", err) + } + defer tx.Rollback(ctx) // no-op once committed + + q := webhooksql.New(d.pool).WithTx(tx) + + claimed, err := q.ClaimOutboxEventsForFanout(ctx, eventBatchSize) + if err != nil { + return false, fmt.Errorf("claiming outbox events: %w", err) + } + if len(claimed) == 0 { + return false, nil + } + + subs, err := q.ListEnabledSubscriptions(ctx) + if err != nil { + return false, fmt.Errorf("listing enabled webhook subscriptions: %w", err) + } + + ids := make([]uuid.UUID, 0, len(claimed)) + for _, row := range claimed { + ids = append(ids, row.WebhookEvent.ID) + + event := toWebhookEvent(&row.ActivityLogEntry) + for _, sub := range subs { + if !toGraphSubscription(sub).MatchesEvent(event) { + continue + } + + if err := q.CreateEventDelivery(ctx, webhooksql.CreateEventDeliveryParams{ + WebhookEventID: row.WebhookEvent.ID, + SubscriptionID: sub.ID, + }); err != nil { + return false, fmt.Errorf("creating event delivery: %w", err) + } + } + } + + // Fan-out and marking the event completed happen in the same transaction, so a failed + // or interrupted attempt simply leaves the event pending for a later retry. + if err := q.MarkOutboxEventsCompleted(ctx, ids); err != nil { + return false, fmt.Errorf("marking outbox events fanned out: %w", err) + } + + if err := tx.Commit(ctx); err != nil { + return false, fmt.Errorf("committing fan-out transaction: %w", err) + } + + return true, nil +} + +// drainPendingDeliveries claims and processes batches of per-subscription delivery rows. +func (d *Dispatcher) drainPendingDeliveries(ctx context.Context) { + q := webhooksql.New(d.pool) + + for { + deliveries, err := q.ClaimPendingDeliveries(ctx, eventBatchSize) + if err != nil { + d.log.WithError(err).Error("claiming pending webhook deliveries") + return + } + + if len(deliveries) == 0 { + return + } + + for _, row := range deliveries { + d.processDelivery(ctx, q, &row.WebhookEventDelivery, &row.WebhookSubscription, &row.ActivityLogEntry) + } + } +} + +// toWebhookEvent builds the internal WebhookEvent representation (used both for matching +// subscriptions and for building the CloudEvent payload) from a stored activity log entry. +func toWebhookEvent(a *webhooksql.ActivityLogEntry) WebhookEvent { + rawEventType := a.ResourceType + ":" + a.Action + + // Resolve "RESOURCE_TYPE:ACTION" → ActivityLogActivityType names + // (e.g. ResourceType="TEAM", Action="ADDED" → ["TEAM_MEMBER_ADDED"]). + resolved := activitylog.LookupActivityTypes(a.ResourceType, a.Action) + activityTypes := make([]string, len(resolved)) + for i, at := range resolved { + activityTypes[i] = string(at) + } + // Fall back to the raw type if no mapping is registered, so the event is still deliverable. + if len(activityTypes) == 0 { + activityTypes = []string{rawEventType} + } + + var teamSlug *slug.Slug + if a.TeamSlug != nil { + s := slug.Slug(*a.TeamSlug) + teamSlug = &s + } + + return WebhookEvent{ + ActivityTypes: activityTypes, + RawEventType: rawEventType, + TeamSlug: teamSlug, + Actor: a.Actor, + ResourceType: a.ResourceType, + ResourceName: a.ResourceName, + Environment: a.Environment, + Data: a.Data, + } +} + +func (d *Dispatcher) processDelivery(ctx context.Context, q *webhooksql.Queries, del *webhooksql.WebhookEventDelivery, sub *webhooksql.WebhookSubscription, a *webhooksql.ActivityLogEntry) { + event := toWebhookEvent(a) + + // Use the first resolved activity type as the delivery event type label. + eventType := event.RawEventType + if len(event.ActivityTypes) > 0 { + eventType = event.ActivityTypes[0] + } + + // The CloudEvent id is derived from the delivery row's own id, so it stays stable across retries. + payload, err := BuildCloudEvent(d.source, del.ID.String(), event) + if err != nil { + d.log.WithError(err).Error("building CloudEvent payload") + return + } + + success := d.deliver(ctx, q, sub, eventType, payload, &del.ID) + + if success { + d.metrics.processedCounter.Add(ctx, 1, metric.WithAttributes( + attribute.String("status", "completed"), + )) + return + } + + // Requeue this delivery with exponential backoff, or mark it permanently failed once + // the retry budget is exhausted. + nextRetry := int(del.RetryCount) + 1 + if nextRetry <= maxRetryCount { + backoff := retryBackoffs[min(nextRetry-1, len(retryBackoffs)-1)] + runAt := time.Now().Add(backoff) + if err := q.RequeueDelivery(ctx, webhooksql.RequeueDeliveryParams{ + ID: del.ID, + RetryCount: int32(nextRetry), + RunAt: pgtype.Timestamptz{Time: runAt, Valid: true}, + }); err != nil { + d.log.WithError(err).Error("requeueing webhook delivery") + } + d.metrics.processedCounter.Add(ctx, 1, metric.WithAttributes( + attribute.String("status", "requeued"), + )) + } else { + if err := q.MarkDeliveryFailed(ctx, del.ID); err != nil { + d.log.WithError(err).Error("marking webhook delivery as failed") + } + d.metrics.processedCounter.Add(ctx, 1, metric.WithAttributes( + attribute.String("status", "failed"), + )) + } +} + +// deliver sends a single HTTP delivery attempt to sub and records it in the audit log. +// deliveryRowID links the audit row back to the originating webhook_event_deliveries row, +// and is nil for ad hoc deliveries (e.g. Ping) that aren't backed by a queue row. +func (d *Dispatcher) deliver(ctx context.Context, q *webhooksql.Queries, sub *webhooksql.WebhookSubscription, eventType string, payload []byte, deliveryRowID *uuid.UUID) bool { + signature := SignPayload(sub.Secret, payload) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, sub.Url, bytes.NewReader(payload)) + if err != nil { + d.log.WithError(err).WithField("subscription_id", sub.ID).Error("creating HTTP request") + return false + } + + req.Header.Set("Content-Type", contentTypeHeader) + req.Header.Set("User-Agent", userAgentHeader) + req.Header.Set(signatureHeader, signature) + + start := time.Now() + resp, err := d.httpClient.Do(req) + durationSeconds := time.Since(start).Seconds() + durationMs := int32(durationSeconds * 1000) + + var ( + responseStatus *int32 + responseBody *string + success bool + ) + + statusStr := "network_error" + if err != nil { + errMsg := err.Error() + responseBody = &errMsg + } else { + defer resp.Body.Close() + status := int32(resp.StatusCode) + responseStatus = &status + statusStr = strconv.Itoa(int(resp.StatusCode)) + success = resp.StatusCode >= 200 && resp.StatusCode < 300 + + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1024*10)) // 10KB max + if readErr == nil { + bodyStr := string(body) + responseBody = &bodyStr + } + } + + successStr := "false" + if success { + successStr = "true" + } + + d.metrics.deliveriesCounter.Add(ctx, 1, metric.WithAttributes( + attribute.String("event_type", eventType), + attribute.String("status_code", statusStr), + attribute.String("success", successStr), + )) + + d.metrics.durationHistogram.Record(ctx, durationSeconds, metric.WithAttributes( + attribute.String("event_type", eventType), + )) + + // Record delivery attempt + if _, recordErr := q.CreateDelivery(ctx, webhooksql.CreateDeliveryParams{ + SubscriptionID: sub.ID, + WebhookEventDeliveryID: deliveryRowID, + EventType: eventType, + RequestBody: payload, + ResponseStatus: responseStatus, + ResponseBody: responseBody, + DurationMs: durationMs, + Success: success, + }); recordErr != nil { + d.log.WithError(recordErr).Error("recording webhook delivery") + } + + // Track consecutive failures for auto-disable + if success { + if sub.ConsecutiveFailures > 0 { + if err := q.ResetConsecutiveFailures(ctx, sub.ID); err != nil { + d.log.WithError(err).Error("resetting consecutive failures") + } + } + } else { + updated, err := q.IncrementConsecutiveFailures(ctx, sub.ID) + if err != nil { + d.log.WithError(err).Error("incrementing consecutive failures") + } else if updated.ConsecutiveFailures >= disableThreshold { + d.log.WithField("subscription_id", sub.ID).Warn("auto-disabling webhook subscription after repeated failures") + if err := q.DisableSubscription(ctx, sub.ID); err != nil { + d.log.WithError(err).Error("disabling webhook subscription") + } + d.metrics.autoDisabledCounter.Add(ctx, 1, metric.WithAttributes( + attribute.String("subscription_id", sub.ID.String()), + )) + } + } + + return success +} + +// SerializeEventData is a helper to serialize event data to JSON for the webhook payload. +func SerializeEventData(data any) ([]byte, error) { + if data == nil { + return nil, nil + } + return json.Marshal(data) +} + +// Ping sends a test ping payload to the given subscription and records the delivery. +// Used to verify connectivity when a new webhook is registered. +func (d *Dispatcher) Ping(ctx context.Context, sub *WebhookSubscription) error { + pingEvent := WebhookEvent{ + RawEventType: "ping", + TeamSlug: sub.TeamSlug, + Actor: "system", + ResourceType: "webhook", + ResourceName: sub.UUID.String(), + } + + payload, err := BuildCloudEvent(d.source, uuid.New().String(), pingEvent) + if err != nil { + return fmt.Errorf("building ping CloudEvent: %w", err) + } + + q := webhooksql.New(d.pool) + dbSub := &webhooksql.WebhookSubscription{ + ID: sub.UUID, + Url: sub.URL, + Secret: sub.Secret, + } + d.deliver(ctx, q, dbSub, "ping", payload, nil) + return nil +} diff --git a/internal/activitylog/webhook/dispatcher_integration_test.go b/internal/activitylog/webhook/dispatcher_integration_test.go new file mode 100644 index 000000000..8a535f8af --- /dev/null +++ b/internal/activitylog/webhook/dispatcher_integration_test.go @@ -0,0 +1,886 @@ +//go:build integration_test + +package webhook + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog/activitylogsql" + "github.com/nais/api/internal/activitylog/webhook/webhooksql" + "github.com/nais/api/internal/database" + "github.com/nais/api/internal/database/notify" + "github.com/nais/api/internal/slug" + "github.com/sirupsen/logrus" + logrustest "github.com/sirupsen/logrus/hooks/test" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" +) + +const ( + testActivityType = activitylog.ActivityLogActivityType("WEBHOOK_INTEGRATION_TEST_EVENT") + testResourceType = "WEBHOOK_INTEGRATION_TEST_RESOURCE" +) + +func init() { + activitylog.RegisterActivityType(testActivityType, activitylog.ActivityLogEntryActionCreated, testResourceType) +} + +func TestDispatcherIntegration(t *testing.T) { + ctx := context.Background() + log, _ := logrustest.NewNullLogger() + + container, dsn, err := startPostgresql(ctx, t, log) + if err != nil { + t.Fatalf("failed to start postgres container: %v", err) + } + + t.Run("fan out and deliver success", func(t *testing.T) { + pool := getConnection(ctx, t, container, dsn, log) + createTeam(ctx, t, pool, "team-a") + team := slug.Slug("team-a") + + srv := newRecordingServer(http.StatusOK) + defer srv.Close() + + q := webhooksql.New(pool) + sub, err := q.CreateSubscription(ctx, webhooksql.CreateSubscriptionParams{ + Url: srv.URL, + Secret: "test-secret", + EventTypes: []string{"*"}, + CreatedBy: "tester", + }) + if err != nil { + t.Fatalf("failed to create subscription: %v", err) + } + + insertActivityLogEntry(ctx, t, pool, &team, "my-resource") + + d := newTestDispatcher(t, pool, log) + d.fanOutPendingEvents(ctx) + d.drainPendingDeliveries(ctx) + + if got := srv.count(); got != 1 { + t.Fatalf("expected exactly 1 request, got %d", got) + } + + req := srv.requestAt(0) + if req.signature == "" { + t.Error("expected X-Webhook-Signature header to be set") + } + + var ce CloudEvent + if err := json.Unmarshal(req.body, &ce); err != nil { + t.Fatalf("failed to unmarshal delivered CloudEvent: %v", err) + } + wantSubject := "team-a/my-resource" + if ce.Subject != wantSubject { + t.Errorf("Subject = %q, want %q", ce.Subject, wantSubject) + } + wantType := activitylog.CloudEventType(testActivityType) + if ce.Type != wantType { + t.Errorf("Type = %q, want %q", ce.Type, wantType) + } + + assertOnlyOutboxEventStatus(ctx, t, pool, "completed") + + delivery := fetchOnlyDeliveryForSubscription(ctx, t, pool, sub.ID) + if delivery.Status != webhooksql.WebhookDeliveryStatusCompleted { + t.Errorf("delivery status = %q, want %q", delivery.Status, webhooksql.WebhookDeliveryStatusCompleted) + } + if ce.ID != delivery.ID.String() { + t.Errorf("CloudEvent id = %q, want delivery id %q", ce.ID, delivery.ID.String()) + } + + auditRows := listAuditDeliveries(ctx, t, pool, sub.ID) + if len(auditRows) != 1 { + t.Fatalf("expected exactly 1 audit delivery row, got %d", len(auditRows)) + } + if !auditRows[0].Success { + t.Error("expected audit delivery row to record success=true") + } + + updatedSub, err := q.GetSubscription(ctx, sub.ID) + if err != nil { + t.Fatalf("failed to fetch subscription: %v", err) + } + if updatedSub.ConsecutiveFailures != 0 { + t.Errorf("ConsecutiveFailures = %d, want 0", updatedSub.ConsecutiveFailures) + } + }) + + t.Run("fan out is idempotent", func(t *testing.T) { + pool := getConnection(ctx, t, container, dsn, log) + + srv := newRecordingServer(http.StatusOK) + defer srv.Close() + + q := webhooksql.New(pool) + sub, err := q.CreateSubscription(ctx, webhooksql.CreateSubscriptionParams{ + Url: srv.URL, + Secret: "test-secret", + EventTypes: []string{"*"}, + CreatedBy: "tester", + }) + if err != nil { + t.Fatalf("failed to create subscription: %v", err) + } + + insertActivityLogEntry(ctx, t, pool, nil, "my-resource") + eventID := fetchOnlyOutboxEventID(ctx, t, pool) + + params := webhooksql.CreateEventDeliveryParams{ + WebhookEventID: eventID, + SubscriptionID: sub.ID, + } + if err := q.CreateEventDelivery(ctx, params); err != nil { + t.Fatalf("first CreateEventDelivery failed: %v", err) + } + if err := q.CreateEventDelivery(ctx, params); err != nil { + t.Fatalf("second CreateEventDelivery failed: %v", err) + } + + var count int + if err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM webhook_event_deliveries WHERE webhook_event_id = $1 AND subscription_id = $2`, eventID, sub.ID).Scan(&count); err != nil { + t.Fatalf("failed to count delivery rows: %v", err) + } + if count != 1 { + t.Errorf("expected exactly 1 delivery row after two CreateEventDelivery calls, got %d", count) + } + }) + + t.Run("delivery retry and backoff", func(t *testing.T) { + pool := getConnection(ctx, t, container, dsn, log) + + srv := newRecordingServer(http.StatusInternalServerError) + defer srv.Close() + + q := webhooksql.New(pool) + sub, err := q.CreateSubscription(ctx, webhooksql.CreateSubscriptionParams{ + Url: srv.URL, + Secret: "test-secret", + EventTypes: []string{"*"}, + CreatedBy: "tester", + }) + if err != nil { + t.Fatalf("failed to create subscription: %v", err) + } + + insertActivityLogEntry(ctx, t, pool, nil, "my-resource") + + d := newTestDispatcher(t, pool, log) + d.fanOutPendingEvents(ctx) + d.drainPendingDeliveries(ctx) + + delivery := fetchOnlyDeliveryForSubscription(ctx, t, pool, sub.ID) + if delivery.Status != webhooksql.WebhookDeliveryStatusPending { + t.Errorf("delivery status = %q, want %q", delivery.Status, webhooksql.WebhookDeliveryStatusPending) + } + if delivery.RetryCount != 1 { + t.Errorf("RetryCount = %d, want 1", delivery.RetryCount) + } + + wantRunAt := time.Now().Add(retryBackoffs[0]) + if diff := delivery.RunAt.Time.Sub(wantRunAt); diff < -10*time.Second || diff > 10*time.Second { + t.Errorf("RunAt = %v, want approximately %v (diff %v)", delivery.RunAt.Time, wantRunAt, diff) + } + + updatedSub, err := q.GetSubscription(ctx, sub.ID) + if err != nil { + t.Fatalf("failed to fetch subscription: %v", err) + } + if updatedSub.ConsecutiveFailures != 1 { + t.Errorf("ConsecutiveFailures = %d, want 1", updatedSub.ConsecutiveFailures) + } + + auditRows := listAuditDeliveries(ctx, t, pool, sub.ID) + if len(auditRows) != 1 { + t.Fatalf("expected exactly 1 audit delivery row, got %d", len(auditRows)) + } + if auditRows[0].Success { + t.Error("expected audit delivery row to record success=false") + } + if auditRows[0].ResponseStatus == nil || *auditRows[0].ResponseStatus != http.StatusInternalServerError { + t.Errorf("ResponseStatus = %v, want %d", auditRows[0].ResponseStatus, http.StatusInternalServerError) + } + }) + + t.Run("delivery reaches failed status after exhausting retry budget", func(t *testing.T) { + pool := getConnection(ctx, t, container, dsn, log) + + srv := newRecordingServer(http.StatusInternalServerError) + defer srv.Close() + + q := webhooksql.New(pool) + sub, err := q.CreateSubscription(ctx, webhooksql.CreateSubscriptionParams{ + Url: srv.URL, + Secret: "test-secret", + EventTypes: []string{"*"}, + CreatedBy: "tester", + }) + if err != nil { + t.Fatalf("failed to create subscription: %v", err) + } + + insertActivityLogEntry(ctx, t, pool, nil, "my-resource") + + d := newTestDispatcher(t, pool, log) + d.fanOutPendingEvents(ctx) + + rows, err := q.ClaimPendingDeliveries(ctx, 1) + if err != nil { + t.Fatalf("failed to claim delivery: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected exactly 1 claimable delivery, got %d", len(rows)) + } + del, subRow, entry := rows[0].WebhookEventDelivery, rows[0].WebhookSubscription, rows[0].ActivityLogEntry + + // Drive the same delivery through every retry attempt directly, bypassing the + // run_at gate that ClaimPendingDeliveries would otherwise enforce, so the test + // doesn't need to wait on real backoff durations. + for i := 0; i <= maxRetryCount; i++ { + d.processDelivery(ctx, q, &del, &subRow, &entry) + del = fetchDeliveryByID(ctx, t, pool, del.ID) + } + + if del.Status != webhooksql.WebhookDeliveryStatusFailed { + t.Errorf("delivery status = %q, want %q after %d attempts", del.Status, webhooksql.WebhookDeliveryStatusFailed, maxRetryCount+1) + } + + auditRows := listAuditDeliveries(ctx, t, pool, sub.ID) + if len(auditRows) != maxRetryCount+1 { + t.Errorf("expected %d audit delivery rows, got %d", maxRetryCount+1, len(auditRows)) + } + }) + + t.Run("subscription auto-disables after repeated failures", func(t *testing.T) { + pool := getConnection(ctx, t, container, dsn, log) + + srv := newRecordingServer(http.StatusInternalServerError) + defer srv.Close() + + q := webhooksql.New(pool) + sub, err := q.CreateSubscription(ctx, webhooksql.CreateSubscriptionParams{ + Url: srv.URL, + Secret: "test-secret", + EventTypes: []string{"*"}, + CreatedBy: "tester", + }) + if err != nil { + t.Fatalf("failed to create subscription: %v", err) + } + + // consecutive_failures increments once per failed delivery attempt across any + // event, so disableThreshold separate events is the cleanest way to reach it + // deterministically without looping retries on a single delivery. + for i := 0; i < disableThreshold; i++ { + insertActivityLogEntry(ctx, t, pool, nil, fmt.Sprintf("resource-%d", i)) + } + + d := newTestDispatcher(t, pool, log) + d.fanOutPendingEvents(ctx) + d.drainPendingDeliveries(ctx) + + updatedSub, err := q.GetSubscription(ctx, sub.ID) + if err != nil { + t.Fatalf("failed to fetch subscription: %v", err) + } + if updatedSub.Enabled { + t.Error("expected subscription to be auto-disabled") + } + if !updatedSub.DisabledAt.Valid { + t.Error("expected DisabledAt to be set") + } + }) + + t.Run("per-subscriber isolation", func(t *testing.T) { + pool := getConnection(ctx, t, container, dsn, log) + + healthySrv := newRecordingServer(http.StatusOK) + defer healthySrv.Close() + failingSrv := newRecordingServer(http.StatusInternalServerError) + defer failingSrv.Close() + + q := webhooksql.New(pool) + healthySub, err := q.CreateSubscription(ctx, webhooksql.CreateSubscriptionParams{ + Url: healthySrv.URL, + Secret: "secret", + EventTypes: []string{"*"}, + CreatedBy: "tester", + }) + if err != nil { + t.Fatalf("failed to create healthy subscription: %v", err) + } + failingSub, err := q.CreateSubscription(ctx, webhooksql.CreateSubscriptionParams{ + Url: failingSrv.URL, + Secret: "secret", + EventTypes: []string{"*"}, + CreatedBy: "tester", + }) + if err != nil { + t.Fatalf("failed to create failing subscription: %v", err) + } + + insertActivityLogEntry(ctx, t, pool, nil, "shared-resource") + + d := newTestDispatcher(t, pool, log) + d.fanOutPendingEvents(ctx) + d.drainPendingDeliveries(ctx) + + healthyDelivery := fetchOnlyDeliveryForSubscription(ctx, t, pool, healthySub.ID) + if healthyDelivery.Status != webhooksql.WebhookDeliveryStatusCompleted { + t.Errorf("healthy subscriber delivery status = %q, want %q", healthyDelivery.Status, webhooksql.WebhookDeliveryStatusCompleted) + } + + failingDelivery := fetchOnlyDeliveryForSubscription(ctx, t, pool, failingSub.ID) + if failingDelivery.Status != webhooksql.WebhookDeliveryStatusPending { + t.Errorf("failing subscriber delivery status = %q, want %q", failingDelivery.Status, webhooksql.WebhookDeliveryStatusPending) + } + if failingDelivery.RetryCount != 1 { + t.Errorf("failing subscriber RetryCount = %d, want 1", failingDelivery.RetryCount) + } + }) + + t.Run("stable CloudEvent id across retries", func(t *testing.T) { + pool := getConnection(ctx, t, container, dsn, log) + + var attempts atomic.Int32 + var mu sync.Mutex + var bodies [][]byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + mu.Lock() + bodies = append(bodies, body) + mu.Unlock() + + if attempts.Add(1) == 1 { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + q := webhooksql.New(pool) + if _, err := q.CreateSubscription(ctx, webhooksql.CreateSubscriptionParams{ + Url: srv.URL, + Secret: "secret", + EventTypes: []string{"*"}, + CreatedBy: "tester", + }); err != nil { + t.Fatalf("failed to create subscription: %v", err) + } + + insertActivityLogEntry(ctx, t, pool, nil, "my-resource") + + d := newTestDispatcher(t, pool, log) + d.fanOutPendingEvents(ctx) + + rows, err := q.ClaimPendingDeliveries(ctx, 1) + if err != nil { + t.Fatalf("failed to claim delivery: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected exactly 1 claimable delivery, got %d", len(rows)) + } + del, subRow, entry := rows[0].WebhookEventDelivery, rows[0].WebhookSubscription, rows[0].ActivityLogEntry + + // First attempt fails and is requeued. + d.processDelivery(ctx, q, &del, &subRow, &entry) + del = fetchDeliveryByID(ctx, t, pool, del.ID) + if del.Status != webhooksql.WebhookDeliveryStatusPending { + t.Fatalf("expected delivery to be requeued after first failed attempt, status = %q", del.Status) + } + + // Simulate the backoff having elapsed, then let the delivery be claimed and + // processed again through the normal drain path (rather than calling + // processDelivery directly), since ClaimPendingDeliveries is what optimistically + // marks a delivery completed at claim time. + if _, err := pool.Exec(ctx, `UPDATE webhook_event_deliveries SET run_at = NOW() WHERE id = $1`, del.ID); err != nil { + t.Fatalf("failed to fast-forward run_at: %v", err) + } + d.drainPendingDeliveries(ctx) + del = fetchDeliveryByID(ctx, t, pool, del.ID) + if del.Status != webhooksql.WebhookDeliveryStatusCompleted { + t.Fatalf("expected delivery to be completed after second attempt, status = %q", del.Status) + } + + mu.Lock() + gotBodies := append([][]byte(nil), bodies...) + mu.Unlock() + if len(gotBodies) != 2 { + t.Fatalf("expected exactly 2 requests, got %d", len(gotBodies)) + } + + var firstCE, secondCE CloudEvent + if err := json.Unmarshal(gotBodies[0], &firstCE); err != nil { + t.Fatalf("failed to unmarshal first request body: %v", err) + } + if err := json.Unmarshal(gotBodies[1], &secondCE); err != nil { + t.Fatalf("failed to unmarshal second request body: %v", err) + } + + if firstCE.ID != secondCE.ID { + t.Errorf("CloudEvent id changed across retries: %q != %q", firstCE.ID, secondCE.ID) + } + if firstCE.ID != del.ID.String() { + t.Errorf("CloudEvent id = %q, want delivery id %q", firstCE.ID, del.ID.String()) + } + }) + + t.Run("ping", func(t *testing.T) { + pool := getConnection(ctx, t, container, dsn, log) + + srv := newRecordingServer(http.StatusOK) + defer srv.Close() + + q := webhooksql.New(pool) + row, err := q.CreateSubscription(ctx, webhooksql.CreateSubscriptionParams{ + Url: srv.URL, + Secret: "secret", + EventTypes: []string{"*"}, + CreatedBy: "tester", + }) + if err != nil { + t.Fatalf("failed to create subscription: %v", err) + } + + d := newTestDispatcher(t, pool, log) + gsub := toGraphSubscription(row) + if err := d.Ping(ctx, gsub); err != nil { + t.Fatalf("Ping() error = %v", err) + } + + if got := srv.count(); got != 1 { + t.Fatalf("expected exactly 1 request, got %d", got) + } + + var ce CloudEvent + if err := json.Unmarshal(srv.requestAt(0).body, &ce); err != nil { + t.Fatalf("failed to unmarshal ping CloudEvent: %v", err) + } + if ce.Type != "io.nais.ping" { + t.Errorf("Type = %q, want %q", ce.Type, "io.nais.ping") + } + + var deliveryID *uuid.UUID + var eventType string + var success bool + err = pool.QueryRow(ctx, `SELECT webhook_event_delivery_id, event_type, success FROM webhook_deliveries WHERE subscription_id = $1`, row.ID). + Scan(&deliveryID, &eventType, &success) + if err != nil { + t.Fatalf("failed to fetch ping audit row: %v", err) + } + if deliveryID != nil { + t.Errorf("webhook_event_delivery_id = %v, want nil", deliveryID) + } + if eventType != "ping" { + t.Errorf("event_type = %q, want %q", eventType, "ping") + } + if !success { + t.Error("expected ping delivery to be recorded as successful") + } + }) + + t.Run("prune queries", func(t *testing.T) { + pool := getConnection(ctx, t, container, dsn, log) + q := webhooksql.New(pool) + + sub, err := q.CreateSubscription(ctx, webhooksql.CreateSubscriptionParams{ + Url: "https://example.invalid", + Secret: "secret", + EventTypes: []string{"*"}, + CreatedBy: "tester", + }) + if err != nil { + t.Fatalf("failed to create subscription: %v", err) + } + sub2, err := q.CreateSubscription(ctx, webhooksql.CreateSubscriptionParams{ + Url: "https://example.invalid/2", + Secret: "secret", + EventTypes: []string{"*"}, + CreatedBy: "tester", + }) + if err != nil { + t.Fatalf("failed to create second subscription: %v", err) + } + + // Only used to obtain a valid activity_log_entries row to satisfy the FK on + // webhook_events; the auto-created outbox row is discarded so the rest of this + // test can fully control the rows it exercises. + insertActivityLogEntry(ctx, t, pool, nil, "resource-a") + activityLogEntryID := fetchOnlyActivityLogEntryID(ctx, t, pool) + if _, err := pool.Exec(ctx, `DELETE FROM webhook_events`); err != nil { + t.Fatalf("failed to clear auto-created outbox event: %v", err) + } + + prunableEventID := createOutboxEventDirect(ctx, t, pool, activityLogEntryID, webhooksql.WebhookOutboxStatusCompleted, 10*24*time.Hour) + eventWithPendingChildID := createOutboxEventDirect(ctx, t, pool, activityLogEntryID, webhooksql.WebhookOutboxStatusCompleted, 10*24*time.Hour) + freshEventID := createOutboxEventDirect(ctx, t, pool, activityLogEntryID, webhooksql.WebhookOutboxStatusCompleted, 0) + + pendingChildDeliveryID := createEventDeliveryDirect(ctx, t, pool, eventWithPendingChildID, sub.ID, webhooksql.WebhookDeliveryStatusPending, 10*24*time.Hour) + + before := pgtypeTimestamptz(-7 * 24 * time.Hour) + if err := q.PruneOldOutboxEvents(ctx, before); err != nil { + t.Fatalf("PruneOldOutboxEvents() error = %v", err) + } + + remaining := fetchOutboxEventIDs(ctx, t, pool) + assertSameUUIDSet(t, remaining, []uuid.UUID{eventWithPendingChildID, freshEventID}) + if containsUUID(remaining, prunableEventID) { + t.Error("expected the old, childless, completed outbox event to be pruned") + } + + // Event deliveries: an old completed row should be pruned; the old pending row + // from above should survive (status filter), and a fresh completed row should + // also survive (age filter). + oldCompletedDeliveryID := createEventDeliveryDirect(ctx, t, pool, freshEventID, sub.ID, webhooksql.WebhookDeliveryStatusCompleted, 10*24*time.Hour) + freshCompletedDeliveryID := createEventDeliveryDirect(ctx, t, pool, freshEventID, sub2.ID, webhooksql.WebhookDeliveryStatusCompleted, 0) + + if err := q.PruneOldEventDeliveries(ctx, before); err != nil { + t.Fatalf("PruneOldEventDeliveries() error = %v", err) + } + + if deliveryExists(ctx, t, pool, oldCompletedDeliveryID) { + t.Error("expected old completed delivery to be pruned") + } + if !deliveryExists(ctx, t, pool, pendingChildDeliveryID) { + t.Error("expected old pending delivery to survive pruning (status filter)") + } + if !deliveryExists(ctx, t, pool, freshCompletedDeliveryID) { + t.Error("expected fresh completed delivery to survive pruning (age filter)") + } + + // Delivery audit log. + oldAuditID := createAuditDeliveryDirect(ctx, t, pool, sub.ID, 40*24*time.Hour) + freshAuditID := createAuditDeliveryDirect(ctx, t, pool, sub.ID, 0) + + deliveryBefore := pgtypeTimestamptz(-30 * 24 * time.Hour) + if err := q.PruneDeliveries(ctx, deliveryBefore); err != nil { + t.Fatalf("PruneDeliveries() error = %v", err) + } + + if auditDeliveryExists(ctx, t, pool, oldAuditID) { + t.Error("expected old audit delivery row to be pruned") + } + if !auditDeliveryExists(ctx, t, pool, freshAuditID) { + t.Error("expected fresh audit delivery row to survive pruning") + } + }) +} + +func newTestDispatcher(t *testing.T, pool *pgxpool.Pool, log logrus.FieldLogger) *Dispatcher { + t.Helper() + d, err := NewDispatcher(pool, notify.New(pool, log), "https://test.example", log) + if err != nil { + t.Fatalf("failed to create dispatcher: %v", err) + } + return d +} + +func createTeam(ctx context.Context, t *testing.T, pool *pgxpool.Pool, teamSlug string) { + t.Helper() + _, err := pool.Exec(ctx, `INSERT INTO teams (slug, purpose, slack_channel) VALUES ($1, 'test team', '#test')`, teamSlug) + if err != nil { + t.Fatalf("failed to create team %q: %v", teamSlug, err) + } +} + +func insertActivityLogEntry(ctx context.Context, t *testing.T, pool *pgxpool.Pool, teamSlug *slug.Slug, resourceName string) { + t.Helper() + err := activitylogsql.New(pool).Create(ctx, activitylogsql.CreateParams{ + Actor: "actor@example.com", + Action: string(activitylog.ActivityLogEntryActionCreated), + ResourceType: testResourceType, + ResourceName: resourceName, + TeamSlug: teamSlug, + }) + if err != nil { + t.Fatalf("failed to insert activity log entry: %v", err) + } +} + +type recordedRequest struct { + signature string + body []byte +} + +// recordingServer is an httptest.Server that always responds with a fixed status code, and +// remembers every request it received so tests can assert on delivered payloads/headers. +type recordingServer struct { + *httptest.Server + mu sync.Mutex + requests []recordedRequest +} + +func newRecordingServer(status int) *recordingServer { + rs := &recordingServer{} + rs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + rs.mu.Lock() + rs.requests = append(rs.requests, recordedRequest{signature: r.Header.Get(signatureHeader), body: body}) + rs.mu.Unlock() + w.WriteHeader(status) + })) + return rs +} + +func (rs *recordingServer) count() int { + rs.mu.Lock() + defer rs.mu.Unlock() + return len(rs.requests) +} + +func (rs *recordingServer) requestAt(i int) recordedRequest { + rs.mu.Lock() + defer rs.mu.Unlock() + return rs.requests[i] +} + +func assertOnlyOutboxEventStatus(ctx context.Context, t *testing.T, pool *pgxpool.Pool, want string) { + t.Helper() + var status string + if err := pool.QueryRow(ctx, `SELECT status FROM webhook_events`).Scan(&status); err != nil { + t.Fatalf("failed to fetch outbox event status: %v", err) + } + if status != want { + t.Errorf("outbox event status = %q, want %q", status, want) + } +} + +func fetchOnlyOutboxEventID(ctx context.Context, t *testing.T, pool *pgxpool.Pool) uuid.UUID { + t.Helper() + var id uuid.UUID + if err := pool.QueryRow(ctx, `SELECT id FROM webhook_events`).Scan(&id); err != nil { + t.Fatalf("failed to fetch outbox event id: %v", err) + } + return id +} + +func fetchOnlyActivityLogEntryID(ctx context.Context, t *testing.T, pool *pgxpool.Pool) uuid.UUID { + t.Helper() + var id uuid.UUID + if err := pool.QueryRow(ctx, `SELECT id FROM activity_log_entries`).Scan(&id); err != nil { + t.Fatalf("failed to fetch activity log entry id: %v", err) + } + return id +} + +func fetchOutboxEventIDs(ctx context.Context, t *testing.T, pool *pgxpool.Pool) []uuid.UUID { + t.Helper() + rows, err := pool.Query(ctx, `SELECT id FROM webhook_events ORDER BY created_at ASC`) + if err != nil { + t.Fatalf("failed to fetch outbox event ids: %v", err) + } + defer rows.Close() + + var ids []uuid.UUID + for rows.Next() { + var id uuid.UUID + if err := rows.Scan(&id); err != nil { + t.Fatalf("failed to scan outbox event id: %v", err) + } + ids = append(ids, id) + } + return ids +} + +func createOutboxEventDirect(ctx context.Context, t *testing.T, pool *pgxpool.Pool, activityLogEntryID uuid.UUID, status webhooksql.WebhookOutboxStatus, age time.Duration) uuid.UUID { + t.Helper() + var id uuid.UUID + err := pool.QueryRow(ctx, ` + INSERT INTO webhook_events (activity_log_entries_id, status, created_at) + VALUES ($1, $2, NOW() - $3::INTERVAL) + RETURNING id + `, activityLogEntryID, string(status), age.String()).Scan(&id) + if err != nil { + t.Fatalf("failed to insert outbox event directly: %v", err) + } + return id +} + +func createEventDeliveryDirect(ctx context.Context, t *testing.T, pool *pgxpool.Pool, eventID, subscriptionID uuid.UUID, status webhooksql.WebhookDeliveryStatus, age time.Duration) uuid.UUID { + t.Helper() + var id uuid.UUID + err := pool.QueryRow(ctx, ` + INSERT INTO webhook_event_deliveries (webhook_event_id, subscription_id, status, created_at) + VALUES ($1, $2, $3, NOW() - $4::INTERVAL) + RETURNING id + `, eventID, subscriptionID, string(status), age.String()).Scan(&id) + if err != nil { + t.Fatalf("failed to insert event delivery directly: %v", err) + } + return id +} + +func deliveryExists(ctx context.Context, t *testing.T, pool *pgxpool.Pool, id uuid.UUID) bool { + t.Helper() + var exists bool + if err := pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM webhook_event_deliveries WHERE id = $1)`, id).Scan(&exists); err != nil { + t.Fatalf("failed to check delivery existence: %v", err) + } + return exists +} + +func createAuditDeliveryDirect(ctx context.Context, t *testing.T, pool *pgxpool.Pool, subscriptionID uuid.UUID, age time.Duration) uuid.UUID { + t.Helper() + var id uuid.UUID + err := pool.QueryRow(ctx, ` + INSERT INTO webhook_deliveries (subscription_id, event_type, request_body, duration_ms, success, created_at) + VALUES ($1, 'test', '{}'::jsonb, 1, true, NOW() - $2::INTERVAL) + RETURNING id + `, subscriptionID, age.String()).Scan(&id) + if err != nil { + t.Fatalf("failed to insert audit delivery directly: %v", err) + } + return id +} + +func auditDeliveryExists(ctx context.Context, t *testing.T, pool *pgxpool.Pool, id uuid.UUID) bool { + t.Helper() + var exists bool + if err := pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM webhook_deliveries WHERE id = $1)`, id).Scan(&exists); err != nil { + t.Fatalf("failed to check audit delivery existence: %v", err) + } + return exists +} + +func fetchDeliveryByID(ctx context.Context, t *testing.T, pool *pgxpool.Pool, id uuid.UUID) webhooksql.WebhookEventDelivery { + t.Helper() + var d webhooksql.WebhookEventDelivery + err := pool.QueryRow(ctx, ` + SELECT id, webhook_event_id, subscription_id, status, retry_count, run_at, created_at + FROM webhook_event_deliveries WHERE id = $1 + `, id).Scan(&d.ID, &d.WebhookEventID, &d.SubscriptionID, &d.Status, &d.RetryCount, &d.RunAt, &d.CreatedAt) + if err != nil { + t.Fatalf("failed to fetch delivery row: %v", err) + } + return d +} + +func fetchOnlyDeliveryForSubscription(ctx context.Context, t *testing.T, pool *pgxpool.Pool, subscriptionID uuid.UUID) webhooksql.WebhookEventDelivery { + t.Helper() + var d webhooksql.WebhookEventDelivery + err := pool.QueryRow(ctx, ` + SELECT id, webhook_event_id, subscription_id, status, retry_count, run_at, created_at + FROM webhook_event_deliveries WHERE subscription_id = $1 + `, subscriptionID).Scan(&d.ID, &d.WebhookEventID, &d.SubscriptionID, &d.Status, &d.RetryCount, &d.RunAt, &d.CreatedAt) + if err != nil { + t.Fatalf("failed to fetch delivery row for subscription %s: %v", subscriptionID, err) + } + return d +} + +func listAuditDeliveries(ctx context.Context, t *testing.T, pool *pgxpool.Pool, subscriptionID uuid.UUID) []webhooksql.WebhookDelivery { + t.Helper() + rows, err := pool.Query(ctx, ` + SELECT id, subscription_id, webhook_event_delivery_id, event_type, request_body, response_status, response_body, duration_ms, success, created_at + FROM webhook_deliveries WHERE subscription_id = $1 ORDER BY created_at ASC + `, subscriptionID) + if err != nil { + t.Fatalf("failed to list audit deliveries: %v", err) + } + defer rows.Close() + + var result []webhooksql.WebhookDelivery + for rows.Next() { + var d webhooksql.WebhookDelivery + if err := rows.Scan(&d.ID, &d.SubscriptionID, &d.WebhookEventDeliveryID, &d.EventType, &d.RequestBody, &d.ResponseStatus, &d.ResponseBody, &d.DurationMs, &d.Success, &d.CreatedAt); err != nil { + t.Fatalf("failed to scan audit delivery: %v", err) + } + result = append(result, d) + } + return result +} + +func containsUUID(haystack []uuid.UUID, needle uuid.UUID) bool { + for _, id := range haystack { + if id == needle { + return true + } + } + return false +} + +// assertSameUUIDSet compares got and want as sets (order-independent), using go-cmp for a +// readable diff on failure. +func assertSameUUIDSet(t *testing.T, got, want []uuid.UUID) { + t.Helper() + + toSet := func(ids []uuid.UUID) map[uuid.UUID]bool { + set := make(map[uuid.UUID]bool, len(ids)) + for _, id := range ids { + set[id] = true + } + return set + } + + if diff := cmp.Diff(toSet(want), toSet(got)); diff != "" { + t.Errorf("unexpected set of ids (-want +got):\n%s", diff) + } +} + +func pgtypeTimestamptz(offset time.Duration) pgtype.Timestamptz { + return pgtype.Timestamptz{Time: time.Now().Add(offset), Valid: true} +} + +func startPostgresql(ctx context.Context, t *testing.T, log logrus.FieldLogger) (container *postgres.PostgresContainer, dsn string, err error) { + container, err = postgres.Run( + ctx, + "docker.io/postgres:16-alpine", + postgres.WithDatabase("test"), + postgres.WithUsername("test"), + postgres.WithPassword("test"), + postgres.WithSQLDriver("pgx"), + postgres.BasicWaitStrategies(), + ) + defer testcontainers.CleanupContainer(t, container) + + if err != nil { + return nil, "", fmt.Errorf("failed to start container: %w", err) + } + + dsn, err = container.ConnectionString(ctx, "sslmode=disable") + if err != nil { + return nil, "", fmt.Errorf("failed to get connection string: %w", err) + } + + pool, err := database.NewPool(ctx, dsn, log, true) + if err != nil { + return nil, "", fmt.Errorf("failed to create pool: %w", err) + } + pool.Close() + + if err := container.Snapshot(ctx); err != nil { + return nil, "", fmt.Errorf("failed to snapshot: %w", err) + } + + return container, dsn, nil +} + +func getConnection(ctx context.Context, t *testing.T, container *postgres.PostgresContainer, dsn string, log logrus.FieldLogger) *pgxpool.Pool { + pool, _ := database.NewPool(ctx, dsn, log, false) + + t.Cleanup(func() { + pool.Close() + if err := container.Restore(ctx); err != nil { + t.Fatalf("failed to restore database: %v", err) + } + }) + + return pool +} diff --git a/internal/activitylog/webhook/metrics.go b/internal/activitylog/webhook/metrics.go new file mode 100644 index 000000000..55fc9dbe0 --- /dev/null +++ b/internal/activitylog/webhook/metrics.go @@ -0,0 +1,100 @@ +package webhook + +import ( + "context" + "fmt" + + "github.com/nais/api/internal/activitylog/webhook/webhooksql" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" +) + +type webhookMetrics struct { + deliveriesCounter metric.Int64Counter + durationHistogram metric.Float64Histogram + processedCounter metric.Int64Counter + autoDisabledCounter metric.Int64Counter + queueSizeGauge metric.Int64ObservableGauge +} + +func newWebhookMetrics(q *webhooksql.Queries) (*webhookMetrics, error) { + meter := otel.GetMeterProvider().Meter("webhook") + + deliveriesCounter, err := meter.Int64Counter( + "nais_api_webhook_deliveries_total", + metric.WithDescription("Total number of webhook deliveries attempted."), + ) + if err != nil { + return nil, fmt.Errorf("create deliveries counter: %w", err) + } + + durationHistogram, err := meter.Float64Histogram( + "nais_api_webhook_delivery_duration_seconds", + metric.WithDescription("Webhook delivery latency in seconds."), + metric.WithExplicitBucketBoundaries(0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10), + ) + if err != nil { + return nil, fmt.Errorf("create duration histogram: %w", err) + } + + processedCounter, err := meter.Int64Counter( + "nais_api_webhook_events_processed_total", + metric.WithDescription("Total number of outbox webhook deliveries processed by the dispatcher."), + ) + if err != nil { + return nil, fmt.Errorf("create processed counter: %w", err) + } + + autoDisabledCounter, err := meter.Int64Counter( + "nais_api_webhook_subscriptions_auto_disabled_total", + metric.WithDescription("Total number of webhook subscriptions automatically disabled due to consecutive failures."), + ) + if err != nil { + return nil, fmt.Errorf("create auto-disabled counter: %w", err) + } + + m := &webhookMetrics{ + deliveriesCounter: deliveriesCounter, + durationHistogram: durationHistogram, + processedCounter: processedCounter, + autoDisabledCounter: autoDisabledCounter, + } + + queueSizeGauge, err := meter.Int64ObservableGauge( + "nais_api_webhook_queue_size", + metric.WithDescription("Current size of the webhook delivery queue grouped by status. Reported identically by every replica; aggregate with max()/avg(), not sum()."), + metric.WithInt64Callback(func(ctx context.Context, observer metric.Int64Observer) error { + rows, err := q.GetQueueSizeByStatus(ctx) + if err != nil { + return err + } + + // Ensure every known status is reported, even if its count is currently 0. + statuses := map[webhooksql.WebhookDeliveryStatus]int64{ + webhooksql.WebhookDeliveryStatusPending: 0, + webhooksql.WebhookDeliveryStatusCompleted: 0, + webhooksql.WebhookDeliveryStatusFailed: 0, + } + + for _, row := range rows { + statuses[row.Status] = row.Count + } + + for status, count := range statuses { + observer.Observe(count, metric.WithAttributes( + attribute.String("status", string(status)), + )) + } + + return nil + }), + ) + if err != nil { + return nil, fmt.Errorf("create queue size gauge: %w", err) + } + + m.queueSizeGauge = queueSizeGauge + + return m, nil +} diff --git a/internal/activitylog/webhook/model.go b/internal/activitylog/webhook/model.go new file mode 100644 index 000000000..9c4ddb96c --- /dev/null +++ b/internal/activitylog/webhook/model.go @@ -0,0 +1,131 @@ +package webhook + +import ( + "slices" + "time" + + "github.com/google/uuid" + "github.com/nais/api/internal/graph/ident" + "github.com/nais/api/internal/graph/pagination" + "github.com/nais/api/internal/slug" +) + +type WebhookSubscription struct { + UUID uuid.UUID `json:"id"` + TeamSlug *slug.Slug `json:"teamSlug,omitempty"` + URL string `json:"url"` + Secret string `json:"-"` + EventTypes []string `json:"eventTypes"` + Enabled bool `json:"enabled"` + ConsecutiveFailures int `json:"consecutiveFailures"` + DisabledAt *time.Time `json:"disabledAt,omitempty"` + CreatedBy string `json:"createdBy"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (WebhookSubscription) IsNode() {} + +func (w WebhookSubscription) ID() ident.Ident { + return newSubscriptionIdent(w.UUID) +} + +type ( + WebhookSubscriptionConnection = pagination.Connection[*WebhookSubscription] + WebhookSubscriptionEdge = pagination.Edge[*WebhookSubscription] +) + +type WebhookDelivery struct { + UUID uuid.UUID `json:"id"` + SubscriptionID uuid.UUID `json:"subscriptionID"` + EventType string `json:"eventType"` + RequestBody string `json:"requestBody"` + ResponseStatus *int `json:"responseStatus,omitempty"` + ResponseBody *string `json:"responseBody,omitempty"` + DurationMs int `json:"durationMs"` + Success bool `json:"success"` + CreatedAt time.Time `json:"createdAt"` +} + +func (WebhookDelivery) IsNode() {} + +func (w WebhookDelivery) ID() ident.Ident { + return newDeliveryIdent(w.UUID) +} + +type ( + WebhookDeliveryConnection = pagination.Connection[*WebhookDelivery] + WebhookDeliveryEdge = pagination.Edge[*WebhookDelivery] +) + +type CreateWebhookInput struct { + TeamSlug *slug.Slug `json:"teamSlug,omitempty"` + URL string `json:"url"` + Secret string `json:"secret"` + EventTypes []string `json:"eventTypes"` +} + +type CreateWebhookPayload struct { + Webhook *WebhookSubscription `json:"webhook"` +} + +type UpdateWebhookInput struct { + ID ident.Ident `json:"id"` + URL *string `json:"url,omitempty"` + Secret *string `json:"secret,omitempty"` + EventTypes []string `json:"eventTypes,omitempty"` + Enabled *bool `json:"enabled,omitempty"` +} + +type UpdateWebhookPayload struct { + Webhook *WebhookSubscription `json:"webhook"` +} + +type DeleteWebhookInput struct { + ID ident.Ident `json:"id"` +} + +type DeleteWebhookPayload struct { + WebhookID ident.Ident `json:"webhookID"` +} + +// WebhookEvent is an internal event passed to the dispatcher when an activity log entry is created. +type WebhookEvent struct { + // ActivityTypes holds the resolved ActivityLogActivityType values for this event + // (e.g. ["TEAM_MEMBER_ADDED"]). Populated by the dispatcher via LookupActivityTypes. + // For synthetic events such as "ping", this may be left nil. + ActivityTypes []string + // RawEventType is the raw "RESOURCE_TYPE:ACTION" string stored in the outbox. + RawEventType string + TeamSlug *slug.Slug + Actor string + ResourceType string + ResourceName string + Environment *string + Data []byte +} + +func (w *WebhookSubscription) MatchesEvent(event WebhookEvent) bool { + if !w.Enabled { + return false + } + + // Global webhooks (no team) match all events. + // Team-scoped webhooks only match events for that team. + if w.TeamSlug != nil { + if event.TeamSlug == nil || *w.TeamSlug != *event.TeamSlug { + return false + } + } + + for _, subType := range w.EventTypes { + if subType == "*" { + return true + } + if slices.Contains(event.ActivityTypes, subType) { + return true + } + } + + return false +} diff --git a/internal/activitylog/webhook/model_test.go b/internal/activitylog/webhook/model_test.go new file mode 100644 index 000000000..acf07c26c --- /dev/null +++ b/internal/activitylog/webhook/model_test.go @@ -0,0 +1,97 @@ +package webhook + +import ( + "testing" + + "github.com/nais/api/internal/slug" +) + +func TestWebhookSubscription_MatchesEvent(t *testing.T) { + teamA := slug.Slug("team-a") + teamB := slug.Slug("team-b") + + tests := []struct { + name string + sub WebhookSubscription + event WebhookEvent + want bool + }{ + { + name: "disabled subscription never matches", + sub: WebhookSubscription{ + Enabled: false, + EventTypes: []string{"*"}, + }, + event: WebhookEvent{TeamSlug: &teamA, ActivityTypes: []string{"TEAM_MEMBER_ADDED"}}, + want: false, + }, + { + name: "global wildcard subscription matches any event", + sub: WebhookSubscription{ + Enabled: true, + TeamSlug: nil, + EventTypes: []string{"*"}, + }, + event: WebhookEvent{TeamSlug: &teamA, ActivityTypes: []string{"TEAM_MEMBER_ADDED"}}, + want: true, + }, + { + name: "team-scoped subscription matches same team and type", + sub: WebhookSubscription{ + Enabled: true, + TeamSlug: &teamA, + EventTypes: []string{"TEAM_MEMBER_ADDED"}, + }, + event: WebhookEvent{TeamSlug: &teamA, ActivityTypes: []string{"TEAM_MEMBER_ADDED"}}, + want: true, + }, + { + name: "team-scoped subscription does not match a different team", + sub: WebhookSubscription{ + Enabled: true, + TeamSlug: &teamA, + EventTypes: []string{"*"}, + }, + event: WebhookEvent{TeamSlug: &teamB, ActivityTypes: []string{"TEAM_MEMBER_ADDED"}}, + want: false, + }, + { + name: "team-scoped subscription does not match event with no team", + sub: WebhookSubscription{ + Enabled: true, + TeamSlug: &teamA, + EventTypes: []string{"*"}, + }, + event: WebhookEvent{TeamSlug: nil, ActivityTypes: []string{"TEAM_MEMBER_ADDED"}}, + want: false, + }, + { + name: "subscription event types include one of the event's activity types", + sub: WebhookSubscription{ + Enabled: true, + TeamSlug: &teamA, + EventTypes: []string{"TEAM_MEMBER_REMOVED", "TEAM_MEMBER_ADDED"}, + }, + event: WebhookEvent{TeamSlug: &teamA, ActivityTypes: []string{"TEAM_MEMBER_ADDED"}}, + want: true, + }, + { + name: "subscription event types do not include any of the event's activity types", + sub: WebhookSubscription{ + Enabled: true, + TeamSlug: &teamA, + EventTypes: []string{"TEAM_MEMBER_REMOVED"}, + }, + event: WebhookEvent{TeamSlug: &teamA, ActivityTypes: []string{"TEAM_MEMBER_ADDED"}}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.sub.MatchesEvent(tt.event); got != tt.want { + t.Errorf("MatchesEvent() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/activitylog/webhook/node.go b/internal/activitylog/webhook/node.go new file mode 100644 index 000000000..8d55d8dba --- /dev/null +++ b/internal/activitylog/webhook/node.go @@ -0,0 +1,46 @@ +package webhook + +import ( + "fmt" + + "github.com/google/uuid" + "github.com/nais/api/internal/graph/ident" +) + +type identType int + +const ( + identWebhookSubscription identType = iota + identWebhookDelivery +) + +func init() { + ident.RegisterIdentType(identWebhookSubscription, "WHS", GetSubscriptionByIdent) + ident.RegisterIdentType(identWebhookDelivery, "WHD", GetDeliveryByIdent) +} + +func newSubscriptionIdent(id uuid.UUID) ident.Ident { + return ident.NewIdent(identWebhookSubscription, id.String()) +} + +func newDeliveryIdent(id uuid.UUID) ident.Ident { + return ident.NewIdent(identWebhookDelivery, id.String()) +} + +func parseSubscriptionIdent(id ident.Ident) (uuid.UUID, error) { + parts := id.Parts() + if len(parts) != 1 { + return uuid.Nil, fmt.Errorf("invalid webhook subscription ident") + } + + return uuid.Parse(parts[0]) +} + +func parseDeliveryIdent(id ident.Ident) (uuid.UUID, error) { + parts := id.Parts() + if len(parts) != 1 { + return uuid.Nil, fmt.Errorf("invalid webhook delivery ident") + } + + return uuid.Parse(parts[0]) +} diff --git a/internal/activitylog/webhook/node_test.go b/internal/activitylog/webhook/node_test.go new file mode 100644 index 000000000..ff14ee48e --- /dev/null +++ b/internal/activitylog/webhook/node_test.go @@ -0,0 +1,64 @@ +package webhook + +import ( + "testing" + + "github.com/google/uuid" + "github.com/nais/api/internal/graph/ident" +) + +func TestSubscriptionIdent_RoundTrip(t *testing.T) { + id := uuid.New() + + got, err := parseSubscriptionIdent(newSubscriptionIdent(id)) + if err != nil { + t.Fatalf("parseSubscriptionIdent() error = %v", err) + } + if got != id { + t.Errorf("parseSubscriptionIdent() = %v, want %v", got, id) + } +} + +func TestDeliveryIdent_RoundTrip(t *testing.T) { + id := uuid.New() + + got, err := parseDeliveryIdent(newDeliveryIdent(id)) + if err != nil { + t.Fatalf("parseDeliveryIdent() error = %v", err) + } + if got != id { + t.Errorf("parseDeliveryIdent() = %v, want %v", got, id) + } +} + +func TestParseSubscriptionIdent_MalformedIdentReturnsError(t *testing.T) { + malformed := ident.Ident{ID: "a|b", Type: "WHS"} + + if _, err := parseSubscriptionIdent(malformed); err == nil { + t.Error("expected an error for a malformed ident, got nil") + } +} + +func TestParseSubscriptionIdent_InvalidUUIDReturnsError(t *testing.T) { + invalid := ident.Ident{ID: "not-a-uuid", Type: "WHS"} + + if _, err := parseSubscriptionIdent(invalid); err == nil { + t.Error("expected an error for an invalid UUID, got nil") + } +} + +func TestParseDeliveryIdent_MalformedIdentReturnsError(t *testing.T) { + malformed := ident.Ident{ID: "a|b", Type: "WHD"} + + if _, err := parseDeliveryIdent(malformed); err == nil { + t.Error("expected an error for a malformed ident, got nil") + } +} + +func TestParseDeliveryIdent_InvalidUUIDReturnsError(t *testing.T) { + invalid := ident.Ident{ID: "not-a-uuid", Type: "WHD"} + + if _, err := parseDeliveryIdent(invalid); err == nil { + t.Error("expected an error for an invalid UUID, got nil") + } +} diff --git a/internal/activitylog/webhook/queries.go b/internal/activitylog/webhook/queries.go new file mode 100644 index 000000000..f82fab0de --- /dev/null +++ b/internal/activitylog/webhook/queries.go @@ -0,0 +1,264 @@ +package webhook + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog/webhook/webhooksql" + "github.com/nais/api/internal/auth/authz" + "github.com/nais/api/internal/graph/ident" + "github.com/nais/api/internal/graph/pagination" + "github.com/nais/api/internal/slug" +) + +func GetSubscription(ctx context.Context, id uuid.UUID) (*WebhookSubscription, error) { + return fromContext(ctx).subscriptionLoader.Load(ctx, id) +} + +func GetSubscriptionByIdent(ctx context.Context, id ident.Ident) (*WebhookSubscription, error) { + uid, err := parseSubscriptionIdent(id) + if err != nil { + return nil, err + } + return GetSubscription(ctx, uid) +} + +func GetDelivery(ctx context.Context, id uuid.UUID) (*WebhookDelivery, error) { + return fromContext(ctx).deliveryLoader.Load(ctx, id) +} + +func GetDeliveryByIdent(ctx context.Context, id ident.Ident) (*WebhookDelivery, error) { + uid, err := parseDeliveryIdent(id) + if err != nil { + return nil, err + } + return GetDelivery(ctx, uid) +} + +func Create(ctx context.Context, input CreateWebhookInput) (*CreateWebhookPayload, error) { + actor := authz.ActorFromContext(ctx) + + if err := authz.CanCreateWebhook(ctx, input.TeamSlug); err != nil { + return nil, err + } + + if err := validateEventTypes(input.TeamSlug, input.EventTypes); err != nil { + return nil, err + } + + row, err := db(ctx).CreateSubscription(ctx, webhooksql.CreateSubscriptionParams{ + TeamSlug: input.TeamSlug, + Url: input.URL, + Secret: input.Secret, + EventTypes: input.EventTypes, + CreatedBy: actor.User.Identity(), + }) + if err != nil { + return nil, fmt.Errorf("creating webhook subscription: %w", err) + } + + sub := toGraphSubscription(row) + + if d := fromContext(ctx).dispatcher; d != nil { + // Ping errors are non-fatal and already recorded as a delivery entry + _ = d.Ping(ctx, sub) + } + + return &CreateWebhookPayload{ + Webhook: sub, + }, nil +} + +func Update(ctx context.Context, input UpdateWebhookInput) (*UpdateWebhookPayload, error) { + uid, err := parseSubscriptionIdent(input.ID) + if err != nil { + return nil, err + } + + existing, err := GetSubscription(ctx, uid) + if err != nil { + return nil, err + } + + if err := authz.CanUpdateWebhook(ctx, existing.TeamSlug); err != nil { + return nil, err + } + + if input.EventTypes != nil { + if err := validateEventTypes(existing.TeamSlug, input.EventTypes); err != nil { + return nil, err + } + } + + row, err := db(ctx).UpdateSubscription(ctx, webhooksql.UpdateSubscriptionParams{ + ID: uid, + Url: input.URL, + Secret: input.Secret, + EventTypes: input.EventTypes, + Enabled: input.Enabled, + }) + if err != nil { + return nil, fmt.Errorf("updating webhook subscription: %w", err) + } + + return &UpdateWebhookPayload{ + Webhook: toGraphSubscription(row), + }, nil +} + +func Delete(ctx context.Context, input DeleteWebhookInput) (*DeleteWebhookPayload, error) { + uid, err := parseSubscriptionIdent(input.ID) + if err != nil { + return nil, err + } + + existing, err := GetSubscription(ctx, uid) + if err != nil { + return nil, err + } + + if err := authz.CanDeleteWebhook(ctx, existing.TeamSlug); err != nil { + return nil, err + } + + if err := db(ctx).DeleteSubscription(ctx, uid); err != nil { + return nil, fmt.Errorf("deleting webhook subscription: %w", err) + } + + return &DeleteWebhookPayload{ + WebhookID: input.ID, + }, nil +} + +func ListForTeam(ctx context.Context, teamSlug slug.Slug, page *pagination.Pagination) (*WebhookSubscriptionConnection, error) { + q := db(ctx) + + rows, err := q.ListSubscriptionsForTeam(ctx, webhooksql.ListSubscriptionsForTeamParams{ + TeamSlug: &teamSlug, + Offset: page.Offset(), + Limit: page.Limit(), + }) + if err != nil { + return nil, err + } + + var total int64 + if len(rows) > 0 { + total = rows[0].TotalCount + } + + return pagination.NewConvertConnection(rows, page, total, func(row *webhooksql.ListSubscriptionsForTeamRow) *WebhookSubscription { + return toGraphSubscription(&row.WebhookSubscription) + }), nil +} + +func ListGlobal(ctx context.Context, page *pagination.Pagination) (*WebhookSubscriptionConnection, error) { + q := db(ctx) + + rows, err := q.ListGlobalSubscriptions(ctx, webhooksql.ListGlobalSubscriptionsParams{ + Offset: page.Offset(), + Limit: page.Limit(), + }) + if err != nil { + return nil, err + } + + var total int64 + if len(rows) > 0 { + total = rows[0].TotalCount + } + + return pagination.NewConvertConnection(rows, page, total, func(row *webhooksql.ListGlobalSubscriptionsRow) *WebhookSubscription { + return toGraphSubscription(&row.WebhookSubscription) + }), nil +} + +func ListDeliveries(ctx context.Context, subscriptionID uuid.UUID, page *pagination.Pagination) (*WebhookDeliveryConnection, error) { + q := db(ctx) + + rows, err := q.ListDeliveriesForSubscription(ctx, webhooksql.ListDeliveriesForSubscriptionParams{ + SubscriptionID: subscriptionID, + Offset: page.Offset(), + Limit: page.Limit(), + }) + if err != nil { + return nil, err + } + + var total int64 + if len(rows) > 0 { + total = rows[0].TotalCount + } + + return pagination.NewConvertConnection(rows, page, total, func(row *webhooksql.ListDeliveriesForSubscriptionRow) *WebhookDelivery { + return toGraphDelivery(&row.WebhookDelivery) + }), nil +} + +func toGraphSubscription(row *webhooksql.WebhookSubscription) *WebhookSubscription { + var disabledAt *time.Time + if row.DisabledAt.Valid { + disabledAt = &row.DisabledAt.Time + } + + return &WebhookSubscription{ + UUID: row.ID, + TeamSlug: row.TeamSlug, + URL: row.Url, + Secret: row.Secret, + EventTypes: row.EventTypes, + Enabled: row.Enabled, + ConsecutiveFailures: int(row.ConsecutiveFailures), + DisabledAt: disabledAt, + CreatedBy: row.CreatedBy, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + } +} + +func toGraphDelivery(row *webhooksql.WebhookDelivery) *WebhookDelivery { + body := string(row.RequestBody) + + var respStatus *int + if row.ResponseStatus != nil { + s := int(*row.ResponseStatus) + respStatus = &s + } + + return &WebhookDelivery{ + UUID: row.ID, + SubscriptionID: row.SubscriptionID, + EventType: row.EventType, + RequestBody: body, + ResponseStatus: respStatus, + ResponseBody: row.ResponseBody, + DurationMs: int(row.DurationMs), + Success: row.Success, + CreatedAt: row.CreatedAt.Time, + } +} + +// MaskedSecret returns the secret with all but the last 4 characters masked. +func MaskedSecret(secret string) string { + if len(secret) <= 4 { + return "****" + } + return "****" + secret[len(secret)-4:] +} + +func validateEventTypes(teamSlug *slug.Slug, eventTypes []string) error { + for _, et := range eventTypes { + if !activitylog.IsValidActivityType(et) { + return fmt.Errorf("invalid event type: %q", et) + } + if teamSlug != nil && et != "*" { + if !activitylog.IsTeamScoped(activitylog.ActivityLogActivityType(et)) { + return fmt.Errorf("event type %q is global-only and cannot be subscribed to by a team-scoped webhook", et) + } + } + } + return nil +} diff --git a/internal/activitylog/webhook/queries/webhook.sql b/internal/activitylog/webhook/queries/webhook.sql new file mode 100644 index 000000000..0fca5e54f --- /dev/null +++ b/internal/activitylog/webhook/queries/webhook.sql @@ -0,0 +1,321 @@ +-- name: CreateSubscription :one +INSERT INTO + webhook_subscriptions (team_slug, url, secret, event_types, created_by) +VALUES + ( + @team_slug, + @url, + @secret, + @event_types, + @created_by + ) +RETURNING + * +; + +-- name: UpdateSubscription :one +UPDATE webhook_subscriptions +SET + url = COALESCE(sqlc.narg(url), url), + secret = COALESCE(sqlc.narg(secret), secret), + event_types = COALESCE(sqlc.narg(event_types), event_types), + enabled = COALESCE(sqlc.narg(enabled), enabled) +WHERE + id = @id +RETURNING + * +; + +-- name: DeleteSubscription :exec +DELETE FROM webhook_subscriptions +WHERE + id = @id +; + +-- name: GetSubscription :one +SELECT + * +FROM + webhook_subscriptions +WHERE + id = @id +; + +-- name: ListSubscriptionsByIDs :many +SELECT + * +FROM + webhook_subscriptions +WHERE + id = ANY (@ids::UUID[]) +ORDER BY + created_at DESC +; + +-- name: ListSubscriptionsForTeam :many +SELECT + sqlc.embed(webhook_subscriptions), + COUNT(*) OVER () AS total_count +FROM + webhook_subscriptions +WHERE + team_slug = @team_slug +ORDER BY + created_at DESC +LIMIT + sqlc.arg('limit') +OFFSET + sqlc.arg('offset') +; + +-- name: ListGlobalSubscriptions :many +SELECT + sqlc.embed(webhook_subscriptions), + COUNT(*) OVER () AS total_count +FROM + webhook_subscriptions +WHERE + team_slug IS NULL +ORDER BY + created_at DESC +LIMIT + sqlc.arg('limit') +OFFSET + sqlc.arg('offset') +; + +-- name: ListEnabledSubscriptions :many +SELECT + * +FROM + webhook_subscriptions +WHERE + enabled = TRUE +ORDER BY + created_at DESC +; + +-- name: IncrementConsecutiveFailures :one +UPDATE webhook_subscriptions +SET + consecutive_failures = consecutive_failures + 1 +WHERE + id = @id +RETURNING + * +; + +-- name: ResetConsecutiveFailures :exec +UPDATE webhook_subscriptions +SET + consecutive_failures = 0 +WHERE + id = @id +; + +-- name: DisableSubscription :exec +UPDATE webhook_subscriptions +SET + enabled = FALSE, + disabled_at = NOW() +WHERE + id = @id +; + +-- name: CreateDelivery :one +INSERT INTO + webhook_deliveries ( + subscription_id, + webhook_event_delivery_id, + event_type, + request_body, + response_status, + response_body, + duration_ms, + success + ) +VALUES + ( + @subscription_id, + sqlc.narg(webhook_event_delivery_id), + @event_type, + @request_body, + @response_status, + @response_body, + @duration_ms, + @success + ) +RETURNING + * +; + +-- name: GetDelivery :one +SELECT + * +FROM + webhook_deliveries +WHERE + id = @id +; + +-- name: ListDeliveriesByIDs :many +SELECT + * +FROM + webhook_deliveries +WHERE + id = ANY (@ids::UUID[]) +ORDER BY + created_at DESC +; + +-- name: ListDeliveriesForSubscription :many +SELECT + sqlc.embed(webhook_deliveries), + COUNT(*) OVER () AS total_count +FROM + webhook_deliveries +WHERE + subscription_id = @subscription_id +ORDER BY + created_at DESC +LIMIT + sqlc.arg('limit') +OFFSET + sqlc.arg('offset') +; + +-- name: PruneDeliveries :exec +DELETE FROM webhook_deliveries +WHERE + created_at < @before +; + +-- name: ClaimOutboxEventsForFanout :many +-- Claims outbox events pending fan-out. FOR UPDATE SKIP LOCKED lets multiple dispatcher +-- instances claim batches concurrently without claiming the same row. Rows are marked +-- completed by the caller after fan-out succeeds, not by this query. +SELECT + sqlc.embed(webhook_events), + sqlc.embed(activity_log_entries) +FROM + webhook_events + JOIN activity_log_entries ON webhook_events.activity_log_entries_id = activity_log_entries.id +WHERE + webhook_events.status = 'pending' +ORDER BY + webhook_events.created_at ASC +LIMIT + sqlc.arg('batch_size') +FOR UPDATE OF + webhook_events SKIP LOCKED +; + +-- name: MarkOutboxEventsCompleted :exec +UPDATE webhook_events +SET + status = 'completed' +WHERE + id = ANY (@ids::UUID[]) +; + +-- name: CreateEventDelivery :exec +INSERT INTO + webhook_event_deliveries (webhook_event_id, subscription_id) +VALUES + (@webhook_event_id, @subscription_id) +ON CONFLICT (webhook_event_id, subscription_id) DO NOTHING +; + +-- name: ClaimPendingDeliveries :many +-- Claims per-(event, subscription) delivery rows for processing, using the same +-- FOR UPDATE SKIP LOCKED pattern as ClaimOutboxEventsForFanout. +WITH + claimed_deliveries AS ( + UPDATE webhook_event_deliveries + SET + status = 'completed' + WHERE + id IN ( + SELECT + id + FROM + webhook_event_deliveries + WHERE + status = 'pending' + AND run_at <= NOW() + ORDER BY + run_at ASC + LIMIT + sqlc.arg('batch_size') + FOR UPDATE + SKIP LOCKED + ) + RETURNING + * + ) +SELECT + sqlc.embed(webhook_event_deliveries), + sqlc.embed(webhook_subscriptions), + sqlc.embed(activity_log_entries) +FROM + claimed_deliveries webhook_event_deliveries + JOIN webhook_subscriptions ON webhook_event_deliveries.subscription_id = webhook_subscriptions.id + JOIN webhook_events ON webhook_event_deliveries.webhook_event_id = webhook_events.id + JOIN activity_log_entries ON webhook_events.activity_log_entries_id = activity_log_entries.id +; + +-- name: RequeueDelivery :exec +UPDATE webhook_event_deliveries +SET + status = 'pending', + retry_count = @retry_count, + run_at = @run_at +WHERE + id = @id +; + +-- name: MarkDeliveryFailed :exec +UPDATE webhook_event_deliveries +SET + status = 'failed' +WHERE + id = @id +; + +-- name: PruneOldOutboxEvents :exec +-- Prunes outbox events whose deliveries have all reached a terminal state (a delete +-- cascades to webhook_event_deliveries, so pending rows must be excluded). +DELETE FROM webhook_events +WHERE + webhook_events.created_at < @before + AND webhook_events.status = 'completed' + AND NOT EXISTS ( + SELECT + 1 + FROM + webhook_event_deliveries + WHERE + webhook_event_deliveries.webhook_event_id = webhook_events.id + AND webhook_event_deliveries.status = 'pending' + ) +; + +-- name: PruneOldEventDeliveries :exec +DELETE FROM webhook_event_deliveries +WHERE + created_at < @before + AND status IN ('completed', 'failed') +; + +-- name: GetQueueSizeByStatus :many +SELECT + status, + COUNT(*) AS count +FROM + webhook_event_deliveries +GROUP BY + status +ORDER BY + status +; diff --git a/internal/activitylog/webhook/queries_test.go b/internal/activitylog/webhook/queries_test.go new file mode 100644 index 000000000..8f9260c9d --- /dev/null +++ b/internal/activitylog/webhook/queries_test.go @@ -0,0 +1,84 @@ +package webhook + +import ( + "strings" + "testing" + + "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/slug" +) + +func init() { + activitylog.RegisterActivityType("WEBHOOK_TEST_QUERIES_EVENT", activitylog.ActivityLogEntryActionCreated, "WEBHOOK_TEST_QUERIES_RESOURCE") + activitylog.RegisterActivityType("WEBHOOK_TEST_QUERIES_GLOBAL_EVENT", activitylog.ActivityLogEntryActionCreated, "WEBHOOK_TEST_QUERIES_RESOURCE", activitylog.GlobalOnly()) +} + +func TestMaskedSecret(t *testing.T) { + tests := []struct { + secret string + want string + }{ + {"", "****"}, + {"a", "****"}, + {"abcd", "****"}, + {"abcde", "****bcde"}, + {"supersecretvalue", "****alue"}, + } + + for _, tt := range tests { + if got := MaskedSecret(tt.secret); got != tt.want { + t.Errorf("MaskedSecret(%q) = %q, want %q", tt.secret, got, tt.want) + } + } +} + +func TestMaskedSecret_NeverLeaksMoreThanLastFourChars(t *testing.T) { + secret := "supersecretvalue" + masked := MaskedSecret(secret) + + if strings.Contains(masked, secret[:len(secret)-4]) { + t.Errorf("masked secret %q leaks part of the original secret %q", masked, secret) + } +} + +func TestValidateEventTypes_Wildcard(t *testing.T) { + team := slug.Slug("my-team") + + if err := validateEventTypes(&team, []string{"*"}); err != nil { + t.Errorf("expected wildcard to always be valid for team-scoped webhooks, got error: %v", err) + } + if err := validateEventTypes(nil, []string{"*"}); err != nil { + t.Errorf("expected wildcard to always be valid for global webhooks, got error: %v", err) + } +} + +func TestValidateEventTypes_UnknownActivityType(t *testing.T) { + if err := validateEventTypes(nil, []string{"WEBHOOK_TEST_QUERIES_UNKNOWN"}); err == nil { + t.Error("expected an error for an unknown activity type") + } +} + +func TestValidateEventTypes_TeamScopedWebhookCannotSubscribeToGlobalOnlyType(t *testing.T) { + team := slug.Slug("my-team") + + if err := validateEventTypes(&team, []string{"WEBHOOK_TEST_QUERIES_GLOBAL_EVENT"}); err == nil { + t.Error("expected an error when a team-scoped webhook subscribes to a global-only event type") + } +} + +func TestValidateEventTypes_TeamScopedWebhookCanSubscribeToTeamScopedType(t *testing.T) { + team := slug.Slug("my-team") + + if err := validateEventTypes(&team, []string{"WEBHOOK_TEST_QUERIES_EVENT"}); err != nil { + t.Errorf("expected no error for a team-scoped event type, got: %v", err) + } +} + +func TestValidateEventTypes_GlobalWebhookCanSubscribeToAnyValidType(t *testing.T) { + if err := validateEventTypes(nil, []string{"WEBHOOK_TEST_QUERIES_EVENT"}); err != nil { + t.Errorf("expected no error for a team-scoped event type on a global webhook, got: %v", err) + } + if err := validateEventTypes(nil, []string{"WEBHOOK_TEST_QUERIES_GLOBAL_EVENT"}); err != nil { + t.Errorf("expected no error for a global-only event type on a global webhook, got: %v", err) + } +} diff --git a/internal/activitylog/webhook/signer.go b/internal/activitylog/webhook/signer.go new file mode 100644 index 000000000..8164dca06 --- /dev/null +++ b/internal/activitylog/webhook/signer.go @@ -0,0 +1,14 @@ +package webhook + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" +) + +// SignPayload computes an HMAC-SHA256 signature of the payload using the given secret. +func SignPayload(secret string, payload []byte) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} diff --git a/internal/activitylog/webhook/signer_test.go b/internal/activitylog/webhook/signer_test.go new file mode 100644 index 000000000..7b8030783 --- /dev/null +++ b/internal/activitylog/webhook/signer_test.go @@ -0,0 +1,52 @@ +package webhook + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "strings" + "testing" +) + +func TestSignPayload_Deterministic(t *testing.T) { + payload := []byte(`{"hello":"world"}`) + + sig1 := SignPayload("my-secret", payload) + sig2 := SignPayload("my-secret", payload) + + if sig1 != sig2 { + t.Errorf("expected signature to be deterministic, got %q and %q", sig1, sig2) + } +} + +func TestSignPayload_DifferentSecretsProduceDifferentSignatures(t *testing.T) { + payload := []byte(`{"hello":"world"}`) + + sig1 := SignPayload("secret-a", payload) + sig2 := SignPayload("secret-b", payload) + + if sig1 == sig2 { + t.Error("expected different secrets to produce different signatures") + } +} + +func TestSignPayload_HasSHA256Prefix(t *testing.T) { + sig := SignPayload("my-secret", []byte("payload")) + + if !strings.HasPrefix(sig, "sha256=") { + t.Errorf("expected signature to have sha256= prefix, got %q", sig) + } +} + +func TestSignPayload_MatchesManualComputation(t *testing.T) { + secret := "my-secret" + payload := []byte(`{"hello":"world"}`) + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + want := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + + if got := SignPayload(secret, payload); got != want { + t.Errorf("SignPayload() = %q, want %q", got, want) + } +} diff --git a/internal/activitylog/webhook/webhooksql/db.go b/internal/activitylog/webhook/webhooksql/db.go new file mode 100644 index 000000000..f57c68ec3 --- /dev/null +++ b/internal/activitylog/webhook/webhooksql/db.go @@ -0,0 +1,30 @@ +// Code generated by sqlc. DO NOT EDIT. + +package webhooksql + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/activitylog/webhook/webhooksql/models.go b/internal/activitylog/webhook/webhooksql/models.go new file mode 100644 index 000000000..238cb5644 --- /dev/null +++ b/internal/activitylog/webhook/webhooksql/models.go @@ -0,0 +1,187 @@ +// Code generated by sqlc. DO NOT EDIT. + +package webhooksql + +import ( + "database/sql/driver" + "fmt" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/nais/api/internal/slug" +) + +type WebhookDeliveryStatus string + +const ( + WebhookDeliveryStatusPending WebhookDeliveryStatus = "pending" + WebhookDeliveryStatusCompleted WebhookDeliveryStatus = "completed" + WebhookDeliveryStatusFailed WebhookDeliveryStatus = "failed" +) + +func (e *WebhookDeliveryStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = WebhookDeliveryStatus(s) + case string: + *e = WebhookDeliveryStatus(s) + default: + return fmt.Errorf("unsupported scan type for WebhookDeliveryStatus: %T", src) + } + return nil +} + +type NullWebhookDeliveryStatus struct { + WebhookDeliveryStatus WebhookDeliveryStatus + Valid bool // Valid is true if WebhookDeliveryStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullWebhookDeliveryStatus) Scan(value interface{}) error { + if value == nil { + ns.WebhookDeliveryStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.WebhookDeliveryStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullWebhookDeliveryStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.WebhookDeliveryStatus), nil +} + +func (e WebhookDeliveryStatus) Valid() bool { + switch e { + case WebhookDeliveryStatusPending, + WebhookDeliveryStatusCompleted, + WebhookDeliveryStatusFailed: + return true + } + return false +} + +func AllWebhookDeliveryStatusValues() []WebhookDeliveryStatus { + return []WebhookDeliveryStatus{ + WebhookDeliveryStatusPending, + WebhookDeliveryStatusCompleted, + WebhookDeliveryStatusFailed, + } +} + +type WebhookOutboxStatus string + +const ( + WebhookOutboxStatusPending WebhookOutboxStatus = "pending" + WebhookOutboxStatusCompleted WebhookOutboxStatus = "completed" +) + +func (e *WebhookOutboxStatus) Scan(src interface{}) error { + switch s := src.(type) { + case []byte: + *e = WebhookOutboxStatus(s) + case string: + *e = WebhookOutboxStatus(s) + default: + return fmt.Errorf("unsupported scan type for WebhookOutboxStatus: %T", src) + } + return nil +} + +type NullWebhookOutboxStatus struct { + WebhookOutboxStatus WebhookOutboxStatus + Valid bool // Valid is true if WebhookOutboxStatus is not NULL +} + +// Scan implements the Scanner interface. +func (ns *NullWebhookOutboxStatus) Scan(value interface{}) error { + if value == nil { + ns.WebhookOutboxStatus, ns.Valid = "", false + return nil + } + ns.Valid = true + return ns.WebhookOutboxStatus.Scan(value) +} + +// Value implements the driver Valuer interface. +func (ns NullWebhookOutboxStatus) Value() (driver.Value, error) { + if !ns.Valid { + return nil, nil + } + return string(ns.WebhookOutboxStatus), nil +} + +func (e WebhookOutboxStatus) Valid() bool { + switch e { + case WebhookOutboxStatusPending, + WebhookOutboxStatusCompleted: + return true + } + return false +} + +func AllWebhookOutboxStatusValues() []WebhookOutboxStatus { + return []WebhookOutboxStatus{ + WebhookOutboxStatusPending, + WebhookOutboxStatusCompleted, + } +} + +type ActivityLogEntry struct { + ID uuid.UUID + CreatedAt pgtype.Timestamptz + Actor string + Action string + ResourceType string + ResourceName string + TeamSlug *slug.Slug + Data []byte + Environment *string +} + +type WebhookDelivery struct { + ID uuid.UUID + SubscriptionID uuid.UUID + WebhookEventDeliveryID *uuid.UUID + EventType string + RequestBody []byte + ResponseStatus *int32 + ResponseBody *string + DurationMs int32 + Success bool + CreatedAt pgtype.Timestamptz +} + +type WebhookEvent struct { + ID uuid.UUID + ActivityLogEntriesID uuid.UUID + Status WebhookOutboxStatus + CreatedAt pgtype.Timestamptz +} + +type WebhookEventDelivery struct { + ID uuid.UUID + WebhookEventID uuid.UUID + SubscriptionID uuid.UUID + Status WebhookDeliveryStatus + RetryCount int32 + RunAt pgtype.Timestamptz + CreatedAt pgtype.Timestamptz +} + +type WebhookSubscription struct { + ID uuid.UUID + TeamSlug *slug.Slug + Url string + Secret string + EventTypes []string + Enabled bool + ConsecutiveFailures int32 + DisabledAt pgtype.Timestamptz + CreatedBy string + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} diff --git a/internal/activitylog/webhook/webhooksql/querier.go b/internal/activitylog/webhook/webhooksql/querier.go new file mode 100644 index 000000000..b2fa4311d --- /dev/null +++ b/internal/activitylog/webhook/webhooksql/querier.go @@ -0,0 +1,47 @@ +// Code generated by sqlc. DO NOT EDIT. + +package webhooksql + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +type Querier interface { + // Claims outbox events pending fan-out. FOR UPDATE SKIP LOCKED lets multiple dispatcher + // instances claim batches concurrently without claiming the same row. Rows are marked + // completed by the caller after fan-out succeeds, not by this query. + ClaimOutboxEventsForFanout(ctx context.Context, batchSize int32) ([]*ClaimOutboxEventsForFanoutRow, error) + // Claims per-(event, subscription) delivery rows for processing, using the same + // FOR UPDATE SKIP LOCKED pattern as ClaimOutboxEventsForFanout. + ClaimPendingDeliveries(ctx context.Context, batchSize int32) ([]*ClaimPendingDeliveriesRow, error) + CreateDelivery(ctx context.Context, arg CreateDeliveryParams) (*WebhookDelivery, error) + CreateEventDelivery(ctx context.Context, arg CreateEventDeliveryParams) error + CreateSubscription(ctx context.Context, arg CreateSubscriptionParams) (*WebhookSubscription, error) + DeleteSubscription(ctx context.Context, id uuid.UUID) error + DisableSubscription(ctx context.Context, id uuid.UUID) error + GetDelivery(ctx context.Context, id uuid.UUID) (*WebhookDelivery, error) + GetQueueSizeByStatus(ctx context.Context) ([]*GetQueueSizeByStatusRow, error) + GetSubscription(ctx context.Context, id uuid.UUID) (*WebhookSubscription, error) + IncrementConsecutiveFailures(ctx context.Context, id uuid.UUID) (*WebhookSubscription, error) + ListDeliveriesByIDs(ctx context.Context, ids []uuid.UUID) ([]*WebhookDelivery, error) + ListDeliveriesForSubscription(ctx context.Context, arg ListDeliveriesForSubscriptionParams) ([]*ListDeliveriesForSubscriptionRow, error) + ListEnabledSubscriptions(ctx context.Context) ([]*WebhookSubscription, error) + ListGlobalSubscriptions(ctx context.Context, arg ListGlobalSubscriptionsParams) ([]*ListGlobalSubscriptionsRow, error) + ListSubscriptionsByIDs(ctx context.Context, ids []uuid.UUID) ([]*WebhookSubscription, error) + ListSubscriptionsForTeam(ctx context.Context, arg ListSubscriptionsForTeamParams) ([]*ListSubscriptionsForTeamRow, error) + MarkDeliveryFailed(ctx context.Context, id uuid.UUID) error + MarkOutboxEventsCompleted(ctx context.Context, ids []uuid.UUID) error + PruneDeliveries(ctx context.Context, before pgtype.Timestamptz) error + PruneOldEventDeliveries(ctx context.Context, before pgtype.Timestamptz) error + // Prunes outbox events whose deliveries have all reached a terminal state (a delete + // cascades to webhook_event_deliveries, so pending rows must be excluded). + PruneOldOutboxEvents(ctx context.Context, before pgtype.Timestamptz) error + RequeueDelivery(ctx context.Context, arg RequeueDeliveryParams) error + ResetConsecutiveFailures(ctx context.Context, id uuid.UUID) error + UpdateSubscription(ctx context.Context, arg UpdateSubscriptionParams) (*WebhookSubscription, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/activitylog/webhook/webhooksql/webhook.sql.go b/internal/activitylog/webhook/webhooksql/webhook.sql.go new file mode 100644 index 000000000..f82ec55d1 --- /dev/null +++ b/internal/activitylog/webhook/webhooksql/webhook.sql.go @@ -0,0 +1,898 @@ +// Code generated by sqlc. DO NOT EDIT. +// source: webhook.sql + +package webhooksql + +import ( + "context" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/nais/api/internal/slug" +) + +const claimOutboxEventsForFanout = `-- name: ClaimOutboxEventsForFanout :many +SELECT + webhook_events.id, webhook_events.activity_log_entries_id, webhook_events.status, webhook_events.created_at, + activity_log_entries.id, activity_log_entries.created_at, activity_log_entries.actor, activity_log_entries.action, activity_log_entries.resource_type, activity_log_entries.resource_name, activity_log_entries.team_slug, activity_log_entries.data, activity_log_entries.environment +FROM + webhook_events + JOIN activity_log_entries ON webhook_events.activity_log_entries_id = activity_log_entries.id +WHERE + webhook_events.status = 'pending' +ORDER BY + webhook_events.created_at ASC +LIMIT + $1 +FOR UPDATE OF + webhook_events SKIP LOCKED +` + +type ClaimOutboxEventsForFanoutRow struct { + WebhookEvent WebhookEvent + ActivityLogEntry ActivityLogEntry +} + +// Claims outbox events pending fan-out. FOR UPDATE SKIP LOCKED lets multiple dispatcher +// instances claim batches concurrently without claiming the same row. Rows are marked +// completed by the caller after fan-out succeeds, not by this query. +func (q *Queries) ClaimOutboxEventsForFanout(ctx context.Context, batchSize int32) ([]*ClaimOutboxEventsForFanoutRow, error) { + rows, err := q.db.Query(ctx, claimOutboxEventsForFanout, batchSize) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ClaimOutboxEventsForFanoutRow{} + for rows.Next() { + var i ClaimOutboxEventsForFanoutRow + if err := rows.Scan( + &i.WebhookEvent.ID, + &i.WebhookEvent.ActivityLogEntriesID, + &i.WebhookEvent.Status, + &i.WebhookEvent.CreatedAt, + &i.ActivityLogEntry.ID, + &i.ActivityLogEntry.CreatedAt, + &i.ActivityLogEntry.Actor, + &i.ActivityLogEntry.Action, + &i.ActivityLogEntry.ResourceType, + &i.ActivityLogEntry.ResourceName, + &i.ActivityLogEntry.TeamSlug, + &i.ActivityLogEntry.Data, + &i.ActivityLogEntry.Environment, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const claimPendingDeliveries = `-- name: ClaimPendingDeliveries :many +WITH + claimed_deliveries AS ( + UPDATE webhook_event_deliveries + SET + status = 'completed' + WHERE + id IN ( + SELECT + id + FROM + webhook_event_deliveries + WHERE + status = 'pending' + AND run_at <= NOW() + ORDER BY + run_at ASC + LIMIT + $1 + FOR UPDATE + SKIP LOCKED + ) + RETURNING + id, webhook_event_id, subscription_id, status, retry_count, run_at, created_at + ) +SELECT + webhook_event_deliveries.id, webhook_event_deliveries.webhook_event_id, webhook_event_deliveries.subscription_id, webhook_event_deliveries.status, webhook_event_deliveries.retry_count, webhook_event_deliveries.run_at, webhook_event_deliveries.created_at, + webhook_subscriptions.id, webhook_subscriptions.team_slug, webhook_subscriptions.url, webhook_subscriptions.secret, webhook_subscriptions.event_types, webhook_subscriptions.enabled, webhook_subscriptions.consecutive_failures, webhook_subscriptions.disabled_at, webhook_subscriptions.created_by, webhook_subscriptions.created_at, webhook_subscriptions.updated_at, + activity_log_entries.id, activity_log_entries.created_at, activity_log_entries.actor, activity_log_entries.action, activity_log_entries.resource_type, activity_log_entries.resource_name, activity_log_entries.team_slug, activity_log_entries.data, activity_log_entries.environment +FROM + claimed_deliveries webhook_event_deliveries + JOIN webhook_subscriptions ON webhook_event_deliveries.subscription_id = webhook_subscriptions.id + JOIN webhook_events ON webhook_event_deliveries.webhook_event_id = webhook_events.id + JOIN activity_log_entries ON webhook_events.activity_log_entries_id = activity_log_entries.id +` + +type ClaimPendingDeliveriesRow struct { + WebhookEventDelivery WebhookEventDelivery + WebhookSubscription WebhookSubscription + ActivityLogEntry ActivityLogEntry +} + +// Claims per-(event, subscription) delivery rows for processing, using the same +// FOR UPDATE SKIP LOCKED pattern as ClaimOutboxEventsForFanout. +func (q *Queries) ClaimPendingDeliveries(ctx context.Context, batchSize int32) ([]*ClaimPendingDeliveriesRow, error) { + rows, err := q.db.Query(ctx, claimPendingDeliveries, batchSize) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ClaimPendingDeliveriesRow{} + for rows.Next() { + var i ClaimPendingDeliveriesRow + if err := rows.Scan( + &i.WebhookEventDelivery.ID, + &i.WebhookEventDelivery.WebhookEventID, + &i.WebhookEventDelivery.SubscriptionID, + &i.WebhookEventDelivery.Status, + &i.WebhookEventDelivery.RetryCount, + &i.WebhookEventDelivery.RunAt, + &i.WebhookEventDelivery.CreatedAt, + &i.WebhookSubscription.ID, + &i.WebhookSubscription.TeamSlug, + &i.WebhookSubscription.Url, + &i.WebhookSubscription.Secret, + &i.WebhookSubscription.EventTypes, + &i.WebhookSubscription.Enabled, + &i.WebhookSubscription.ConsecutiveFailures, + &i.WebhookSubscription.DisabledAt, + &i.WebhookSubscription.CreatedBy, + &i.WebhookSubscription.CreatedAt, + &i.WebhookSubscription.UpdatedAt, + &i.ActivityLogEntry.ID, + &i.ActivityLogEntry.CreatedAt, + &i.ActivityLogEntry.Actor, + &i.ActivityLogEntry.Action, + &i.ActivityLogEntry.ResourceType, + &i.ActivityLogEntry.ResourceName, + &i.ActivityLogEntry.TeamSlug, + &i.ActivityLogEntry.Data, + &i.ActivityLogEntry.Environment, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const createDelivery = `-- name: CreateDelivery :one +INSERT INTO + webhook_deliveries ( + subscription_id, + webhook_event_delivery_id, + event_type, + request_body, + response_status, + response_body, + duration_ms, + success + ) +VALUES + ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + $8 + ) +RETURNING + id, subscription_id, webhook_event_delivery_id, event_type, request_body, response_status, response_body, duration_ms, success, created_at +` + +type CreateDeliveryParams struct { + SubscriptionID uuid.UUID + WebhookEventDeliveryID *uuid.UUID + EventType string + RequestBody []byte + ResponseStatus *int32 + ResponseBody *string + DurationMs int32 + Success bool +} + +func (q *Queries) CreateDelivery(ctx context.Context, arg CreateDeliveryParams) (*WebhookDelivery, error) { + row := q.db.QueryRow(ctx, createDelivery, + arg.SubscriptionID, + arg.WebhookEventDeliveryID, + arg.EventType, + arg.RequestBody, + arg.ResponseStatus, + arg.ResponseBody, + arg.DurationMs, + arg.Success, + ) + var i WebhookDelivery + err := row.Scan( + &i.ID, + &i.SubscriptionID, + &i.WebhookEventDeliveryID, + &i.EventType, + &i.RequestBody, + &i.ResponseStatus, + &i.ResponseBody, + &i.DurationMs, + &i.Success, + &i.CreatedAt, + ) + return &i, err +} + +const createEventDelivery = `-- name: CreateEventDelivery :exec +INSERT INTO + webhook_event_deliveries (webhook_event_id, subscription_id) +VALUES + ($1, $2) +ON CONFLICT (webhook_event_id, subscription_id) DO NOTHING +` + +type CreateEventDeliveryParams struct { + WebhookEventID uuid.UUID + SubscriptionID uuid.UUID +} + +func (q *Queries) CreateEventDelivery(ctx context.Context, arg CreateEventDeliveryParams) error { + _, err := q.db.Exec(ctx, createEventDelivery, arg.WebhookEventID, arg.SubscriptionID) + return err +} + +const createSubscription = `-- name: CreateSubscription :one +INSERT INTO + webhook_subscriptions (team_slug, url, secret, event_types, created_by) +VALUES + ( + $1, + $2, + $3, + $4, + $5 + ) +RETURNING + id, team_slug, url, secret, event_types, enabled, consecutive_failures, disabled_at, created_by, created_at, updated_at +` + +type CreateSubscriptionParams struct { + TeamSlug *slug.Slug + Url string + Secret string + EventTypes []string + CreatedBy string +} + +func (q *Queries) CreateSubscription(ctx context.Context, arg CreateSubscriptionParams) (*WebhookSubscription, error) { + row := q.db.QueryRow(ctx, createSubscription, + arg.TeamSlug, + arg.Url, + arg.Secret, + arg.EventTypes, + arg.CreatedBy, + ) + var i WebhookSubscription + err := row.Scan( + &i.ID, + &i.TeamSlug, + &i.Url, + &i.Secret, + &i.EventTypes, + &i.Enabled, + &i.ConsecutiveFailures, + &i.DisabledAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const deleteSubscription = `-- name: DeleteSubscription :exec +DELETE FROM webhook_subscriptions +WHERE + id = $1 +` + +func (q *Queries) DeleteSubscription(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, deleteSubscription, id) + return err +} + +const disableSubscription = `-- name: DisableSubscription :exec +UPDATE webhook_subscriptions +SET + enabled = FALSE, + disabled_at = NOW() +WHERE + id = $1 +` + +func (q *Queries) DisableSubscription(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, disableSubscription, id) + return err +} + +const getDelivery = `-- name: GetDelivery :one +SELECT + id, subscription_id, webhook_event_delivery_id, event_type, request_body, response_status, response_body, duration_ms, success, created_at +FROM + webhook_deliveries +WHERE + id = $1 +` + +func (q *Queries) GetDelivery(ctx context.Context, id uuid.UUID) (*WebhookDelivery, error) { + row := q.db.QueryRow(ctx, getDelivery, id) + var i WebhookDelivery + err := row.Scan( + &i.ID, + &i.SubscriptionID, + &i.WebhookEventDeliveryID, + &i.EventType, + &i.RequestBody, + &i.ResponseStatus, + &i.ResponseBody, + &i.DurationMs, + &i.Success, + &i.CreatedAt, + ) + return &i, err +} + +const getQueueSizeByStatus = `-- name: GetQueueSizeByStatus :many +SELECT + status, + COUNT(*) AS count +FROM + webhook_event_deliveries +GROUP BY + status +ORDER BY + status +` + +type GetQueueSizeByStatusRow struct { + Status WebhookDeliveryStatus + Count int64 +} + +func (q *Queries) GetQueueSizeByStatus(ctx context.Context) ([]*GetQueueSizeByStatusRow, error) { + rows, err := q.db.Query(ctx, getQueueSizeByStatus) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*GetQueueSizeByStatusRow{} + for rows.Next() { + var i GetQueueSizeByStatusRow + if err := rows.Scan(&i.Status, &i.Count); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getSubscription = `-- name: GetSubscription :one +SELECT + id, team_slug, url, secret, event_types, enabled, consecutive_failures, disabled_at, created_by, created_at, updated_at +FROM + webhook_subscriptions +WHERE + id = $1 +` + +func (q *Queries) GetSubscription(ctx context.Context, id uuid.UUID) (*WebhookSubscription, error) { + row := q.db.QueryRow(ctx, getSubscription, id) + var i WebhookSubscription + err := row.Scan( + &i.ID, + &i.TeamSlug, + &i.Url, + &i.Secret, + &i.EventTypes, + &i.Enabled, + &i.ConsecutiveFailures, + &i.DisabledAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const incrementConsecutiveFailures = `-- name: IncrementConsecutiveFailures :one +UPDATE webhook_subscriptions +SET + consecutive_failures = consecutive_failures + 1 +WHERE + id = $1 +RETURNING + id, team_slug, url, secret, event_types, enabled, consecutive_failures, disabled_at, created_by, created_at, updated_at +` + +func (q *Queries) IncrementConsecutiveFailures(ctx context.Context, id uuid.UUID) (*WebhookSubscription, error) { + row := q.db.QueryRow(ctx, incrementConsecutiveFailures, id) + var i WebhookSubscription + err := row.Scan( + &i.ID, + &i.TeamSlug, + &i.Url, + &i.Secret, + &i.EventTypes, + &i.Enabled, + &i.ConsecutiveFailures, + &i.DisabledAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const listDeliveriesByIDs = `-- name: ListDeliveriesByIDs :many +SELECT + id, subscription_id, webhook_event_delivery_id, event_type, request_body, response_status, response_body, duration_ms, success, created_at +FROM + webhook_deliveries +WHERE + id = ANY ($1::UUID[]) +ORDER BY + created_at DESC +` + +func (q *Queries) ListDeliveriesByIDs(ctx context.Context, ids []uuid.UUID) ([]*WebhookDelivery, error) { + rows, err := q.db.Query(ctx, listDeliveriesByIDs, ids) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*WebhookDelivery{} + for rows.Next() { + var i WebhookDelivery + if err := rows.Scan( + &i.ID, + &i.SubscriptionID, + &i.WebhookEventDeliveryID, + &i.EventType, + &i.RequestBody, + &i.ResponseStatus, + &i.ResponseBody, + &i.DurationMs, + &i.Success, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDeliveriesForSubscription = `-- name: ListDeliveriesForSubscription :many +SELECT + webhook_deliveries.id, webhook_deliveries.subscription_id, webhook_deliveries.webhook_event_delivery_id, webhook_deliveries.event_type, webhook_deliveries.request_body, webhook_deliveries.response_status, webhook_deliveries.response_body, webhook_deliveries.duration_ms, webhook_deliveries.success, webhook_deliveries.created_at, + COUNT(*) OVER () AS total_count +FROM + webhook_deliveries +WHERE + subscription_id = $1 +ORDER BY + created_at DESC +LIMIT + $3 +OFFSET + $2 +` + +type ListDeliveriesForSubscriptionParams struct { + SubscriptionID uuid.UUID + Offset int32 + Limit int32 +} + +type ListDeliveriesForSubscriptionRow struct { + WebhookDelivery WebhookDelivery + TotalCount int64 +} + +func (q *Queries) ListDeliveriesForSubscription(ctx context.Context, arg ListDeliveriesForSubscriptionParams) ([]*ListDeliveriesForSubscriptionRow, error) { + rows, err := q.db.Query(ctx, listDeliveriesForSubscription, arg.SubscriptionID, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ListDeliveriesForSubscriptionRow{} + for rows.Next() { + var i ListDeliveriesForSubscriptionRow + if err := rows.Scan( + &i.WebhookDelivery.ID, + &i.WebhookDelivery.SubscriptionID, + &i.WebhookDelivery.WebhookEventDeliveryID, + &i.WebhookDelivery.EventType, + &i.WebhookDelivery.RequestBody, + &i.WebhookDelivery.ResponseStatus, + &i.WebhookDelivery.ResponseBody, + &i.WebhookDelivery.DurationMs, + &i.WebhookDelivery.Success, + &i.WebhookDelivery.CreatedAt, + &i.TotalCount, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listEnabledSubscriptions = `-- name: ListEnabledSubscriptions :many +SELECT + id, team_slug, url, secret, event_types, enabled, consecutive_failures, disabled_at, created_by, created_at, updated_at +FROM + webhook_subscriptions +WHERE + enabled = TRUE +ORDER BY + created_at DESC +` + +func (q *Queries) ListEnabledSubscriptions(ctx context.Context) ([]*WebhookSubscription, error) { + rows, err := q.db.Query(ctx, listEnabledSubscriptions) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*WebhookSubscription{} + for rows.Next() { + var i WebhookSubscription + if err := rows.Scan( + &i.ID, + &i.TeamSlug, + &i.Url, + &i.Secret, + &i.EventTypes, + &i.Enabled, + &i.ConsecutiveFailures, + &i.DisabledAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listGlobalSubscriptions = `-- name: ListGlobalSubscriptions :many +SELECT + webhook_subscriptions.id, webhook_subscriptions.team_slug, webhook_subscriptions.url, webhook_subscriptions.secret, webhook_subscriptions.event_types, webhook_subscriptions.enabled, webhook_subscriptions.consecutive_failures, webhook_subscriptions.disabled_at, webhook_subscriptions.created_by, webhook_subscriptions.created_at, webhook_subscriptions.updated_at, + COUNT(*) OVER () AS total_count +FROM + webhook_subscriptions +WHERE + team_slug IS NULL +ORDER BY + created_at DESC +LIMIT + $2 +OFFSET + $1 +` + +type ListGlobalSubscriptionsParams struct { + Offset int32 + Limit int32 +} + +type ListGlobalSubscriptionsRow struct { + WebhookSubscription WebhookSubscription + TotalCount int64 +} + +func (q *Queries) ListGlobalSubscriptions(ctx context.Context, arg ListGlobalSubscriptionsParams) ([]*ListGlobalSubscriptionsRow, error) { + rows, err := q.db.Query(ctx, listGlobalSubscriptions, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ListGlobalSubscriptionsRow{} + for rows.Next() { + var i ListGlobalSubscriptionsRow + if err := rows.Scan( + &i.WebhookSubscription.ID, + &i.WebhookSubscription.TeamSlug, + &i.WebhookSubscription.Url, + &i.WebhookSubscription.Secret, + &i.WebhookSubscription.EventTypes, + &i.WebhookSubscription.Enabled, + &i.WebhookSubscription.ConsecutiveFailures, + &i.WebhookSubscription.DisabledAt, + &i.WebhookSubscription.CreatedBy, + &i.WebhookSubscription.CreatedAt, + &i.WebhookSubscription.UpdatedAt, + &i.TotalCount, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSubscriptionsByIDs = `-- name: ListSubscriptionsByIDs :many +SELECT + id, team_slug, url, secret, event_types, enabled, consecutive_failures, disabled_at, created_by, created_at, updated_at +FROM + webhook_subscriptions +WHERE + id = ANY ($1::UUID[]) +ORDER BY + created_at DESC +` + +func (q *Queries) ListSubscriptionsByIDs(ctx context.Context, ids []uuid.UUID) ([]*WebhookSubscription, error) { + rows, err := q.db.Query(ctx, listSubscriptionsByIDs, ids) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*WebhookSubscription{} + for rows.Next() { + var i WebhookSubscription + if err := rows.Scan( + &i.ID, + &i.TeamSlug, + &i.Url, + &i.Secret, + &i.EventTypes, + &i.Enabled, + &i.ConsecutiveFailures, + &i.DisabledAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSubscriptionsForTeam = `-- name: ListSubscriptionsForTeam :many +SELECT + webhook_subscriptions.id, webhook_subscriptions.team_slug, webhook_subscriptions.url, webhook_subscriptions.secret, webhook_subscriptions.event_types, webhook_subscriptions.enabled, webhook_subscriptions.consecutive_failures, webhook_subscriptions.disabled_at, webhook_subscriptions.created_by, webhook_subscriptions.created_at, webhook_subscriptions.updated_at, + COUNT(*) OVER () AS total_count +FROM + webhook_subscriptions +WHERE + team_slug = $1 +ORDER BY + created_at DESC +LIMIT + $3 +OFFSET + $2 +` + +type ListSubscriptionsForTeamParams struct { + TeamSlug *slug.Slug + Offset int32 + Limit int32 +} + +type ListSubscriptionsForTeamRow struct { + WebhookSubscription WebhookSubscription + TotalCount int64 +} + +func (q *Queries) ListSubscriptionsForTeam(ctx context.Context, arg ListSubscriptionsForTeamParams) ([]*ListSubscriptionsForTeamRow, error) { + rows, err := q.db.Query(ctx, listSubscriptionsForTeam, arg.TeamSlug, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*ListSubscriptionsForTeamRow{} + for rows.Next() { + var i ListSubscriptionsForTeamRow + if err := rows.Scan( + &i.WebhookSubscription.ID, + &i.WebhookSubscription.TeamSlug, + &i.WebhookSubscription.Url, + &i.WebhookSubscription.Secret, + &i.WebhookSubscription.EventTypes, + &i.WebhookSubscription.Enabled, + &i.WebhookSubscription.ConsecutiveFailures, + &i.WebhookSubscription.DisabledAt, + &i.WebhookSubscription.CreatedBy, + &i.WebhookSubscription.CreatedAt, + &i.WebhookSubscription.UpdatedAt, + &i.TotalCount, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markDeliveryFailed = `-- name: MarkDeliveryFailed :exec +UPDATE webhook_event_deliveries +SET + status = 'failed' +WHERE + id = $1 +` + +func (q *Queries) MarkDeliveryFailed(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, markDeliveryFailed, id) + return err +} + +const markOutboxEventsCompleted = `-- name: MarkOutboxEventsCompleted :exec +UPDATE webhook_events +SET + status = 'completed' +WHERE + id = ANY ($1::UUID[]) +` + +func (q *Queries) MarkOutboxEventsCompleted(ctx context.Context, ids []uuid.UUID) error { + _, err := q.db.Exec(ctx, markOutboxEventsCompleted, ids) + return err +} + +const pruneDeliveries = `-- name: PruneDeliveries :exec +DELETE FROM webhook_deliveries +WHERE + created_at < $1 +` + +func (q *Queries) PruneDeliveries(ctx context.Context, before pgtype.Timestamptz) error { + _, err := q.db.Exec(ctx, pruneDeliveries, before) + return err +} + +const pruneOldEventDeliveries = `-- name: PruneOldEventDeliveries :exec +DELETE FROM webhook_event_deliveries +WHERE + created_at < $1 + AND status IN ('completed', 'failed') +` + +func (q *Queries) PruneOldEventDeliveries(ctx context.Context, before pgtype.Timestamptz) error { + _, err := q.db.Exec(ctx, pruneOldEventDeliveries, before) + return err +} + +const pruneOldOutboxEvents = `-- name: PruneOldOutboxEvents :exec +DELETE FROM webhook_events +WHERE + webhook_events.created_at < $1 + AND webhook_events.status = 'completed' + AND NOT EXISTS ( + SELECT + 1 + FROM + webhook_event_deliveries + WHERE + webhook_event_deliveries.webhook_event_id = webhook_events.id + AND webhook_event_deliveries.status = 'pending' + ) +` + +// Prunes outbox events whose deliveries have all reached a terminal state (a delete +// cascades to webhook_event_deliveries, so pending rows must be excluded). +func (q *Queries) PruneOldOutboxEvents(ctx context.Context, before pgtype.Timestamptz) error { + _, err := q.db.Exec(ctx, pruneOldOutboxEvents, before) + return err +} + +const requeueDelivery = `-- name: RequeueDelivery :exec +UPDATE webhook_event_deliveries +SET + status = 'pending', + retry_count = $1, + run_at = $2 +WHERE + id = $3 +` + +type RequeueDeliveryParams struct { + RetryCount int32 + RunAt pgtype.Timestamptz + ID uuid.UUID +} + +func (q *Queries) RequeueDelivery(ctx context.Context, arg RequeueDeliveryParams) error { + _, err := q.db.Exec(ctx, requeueDelivery, arg.RetryCount, arg.RunAt, arg.ID) + return err +} + +const resetConsecutiveFailures = `-- name: ResetConsecutiveFailures :exec +UPDATE webhook_subscriptions +SET + consecutive_failures = 0 +WHERE + id = $1 +` + +func (q *Queries) ResetConsecutiveFailures(ctx context.Context, id uuid.UUID) error { + _, err := q.db.Exec(ctx, resetConsecutiveFailures, id) + return err +} + +const updateSubscription = `-- name: UpdateSubscription :one +UPDATE webhook_subscriptions +SET + url = COALESCE($1, url), + secret = COALESCE($2, secret), + event_types = COALESCE($3, event_types), + enabled = COALESCE($4, enabled) +WHERE + id = $5 +RETURNING + id, team_slug, url, secret, event_types, enabled, consecutive_failures, disabled_at, created_by, created_at, updated_at +` + +type UpdateSubscriptionParams struct { + Url *string + Secret *string + EventTypes []string + Enabled *bool + ID uuid.UUID +} + +func (q *Queries) UpdateSubscription(ctx context.Context, arg UpdateSubscriptionParams) (*WebhookSubscription, error) { + row := q.db.QueryRow(ctx, updateSubscription, + arg.Url, + arg.Secret, + arg.EventTypes, + arg.Enabled, + arg.ID, + ) + var i WebhookSubscription + err := row.Scan( + &i.ID, + &i.TeamSlug, + &i.Url, + &i.Secret, + &i.EventTypes, + &i.Enabled, + &i.ConsecutiveFailures, + &i.DisabledAt, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} diff --git a/internal/auth/authz/queries.go b/internal/auth/authz/queries.go index 7527e99bf..874f729eb 100644 --- a/internal/auth/authz/queries.go +++ b/internal/auth/authz/queries.go @@ -328,6 +328,18 @@ func CanCreateTunnel(ctx context.Context, teamSlug slug.Slug) error { return requireTeamAuthorization(ctx, teamSlug, "tunnels:create") } +func CanCreateWebhook(ctx context.Context, teamSlug *slug.Slug) error { + return requireAuthorization(ctx, "webhooks:create", teamSlug) +} + +func CanUpdateWebhook(ctx context.Context, teamSlug *slug.Slug) error { + return requireAuthorization(ctx, "webhooks:update", teamSlug) +} + +func CanDeleteWebhook(ctx context.Context, teamSlug *slug.Slug) error { + return requireAuthorization(ctx, "webhooks:delete", teamSlug) +} + func RequireGlobalAdmin(ctx context.Context) error { if ActorFromContext(ctx).User.IsAdmin() { return nil diff --git a/internal/cmd/api/api.go b/internal/cmd/api/api.go index 420ba0e77..cc1c11c71 100644 --- a/internal/cmd/api/api.go +++ b/internal/cmd/api/api.go @@ -14,6 +14,7 @@ import ( aiven_service "github.com/aiven/go-client-codegen" "github.com/joho/godotenv" "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog/webhook" "github.com/nais/api/internal/apply" "github.com/nais/api/internal/auth/authn" "github.com/nais/api/internal/auth/middleware" @@ -255,6 +256,13 @@ func run(ctx context.Context, cfg *Config, log logrus.FieldLogger) error { notifier := notify.New(pool, log.WithField("subsystem", "notifier")) go notifier.Run(ctx) + // Webhook dispatcher — drains the webhook_events outbox table on PG NOTIFY + webhookDispatcher, err := webhook.NewDispatcher(pool, notifier, "https://"+cfg.TenantDomain+"/api", log) + if err != nil { + return fmt.Errorf("creating webhook dispatcher: %w", err) + } + go webhookDispatcher.Run(ctx) + if !cfg.Fakes.WithFakeKubernetes { k8sClients, err := kubernetes.NewClientSets(clusterConfig) if err != nil { @@ -330,6 +338,7 @@ func run(ctx context.Context, cfg *Config, log logrus.FieldLogger) error { lokiClient, cfg.AuditLog.ProjectID, cfg.AuditLog.Location, + webhookDispatcher, log.WithField("subsystem", "http"), ) if err != nil { @@ -414,6 +423,11 @@ func run(ctx context.Context, cfg *Config, log logrus.FieldLogger) error { return nil }) + wg.Go(func() error { + webhook.RunCleaner(ctx, pool, log.WithField("subsystem", "webhook_cleaner")) + return nil + }) + wg.Go(func() error { activitylog.RunRefresher(ctx, pool, log.WithField("subsystem", "activitylog_refresher")) return nil diff --git a/internal/cmd/api/http.go b/internal/cmd/api/http.go index 9d4c19372..3fb77b44b 100644 --- a/internal/cmd/api/http.go +++ b/internal/cmd/api/http.go @@ -12,6 +12,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5/pgxpool" "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog/webhook" "github.com/nais/api/internal/alerts" "github.com/nais/api/internal/auth/authn" "github.com/nais/api/internal/auth/authz" @@ -199,6 +200,7 @@ func ConfigureGraph( lokiClient loki.Client, auditLogProjectID string, auditLogLocation string, + webhookDispatcher *webhook.Dispatcher, log logrus.FieldLogger, ) (func(http.Handler) http.Handler, error) { logStep := func(name string, fn func() error) error { @@ -379,6 +381,7 @@ func ConfigureGraph( ctx = tunnel.WithLoaders(ctx, tunnel.NewLoaders(watchers.TunnelWatcher)) ctx = logging.NewPackageContext(ctx, tenantName, defaultLogDestinations) ctx = environment.NewLoaderContext(ctx, pool) + ctx = webhook.NewLoaderContext(ctx, pool, webhookDispatcher) ctx = feature.NewLoaderContext( ctx, watchers.UnleashWatcher.Enabled(), diff --git a/internal/database/migrations/0072_webhooks.sql b/internal/database/migrations/0072_webhooks.sql new file mode 100644 index 000000000..e92011460 --- /dev/null +++ b/internal/database/migrations/0072_webhooks.sql @@ -0,0 +1,140 @@ +-- +goose Up +CREATE TABLE webhook_subscriptions ( + id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(), + team_slug slug REFERENCES teams (slug) ON DELETE CASCADE, + url TEXT NOT NULL, + secret TEXT NOT NULL, + event_types TEXT[] NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + consecutive_failures INT NOT NULL DEFAULT 0, + disabled_at TIMESTAMPTZ, + created_by TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +) +; + +CREATE TRIGGER webhook_subscriptions_updated_at +BEFORE UPDATE ON webhook_subscriptions FOR EACH ROW +EXECUTE FUNCTION set_updated_at () +; + +CREATE INDEX idx_webhook_subscriptions_team ON webhook_subscriptions (team_slug) +; + +CREATE INDEX idx_webhook_subscriptions_global ON webhook_subscriptions (id) +WHERE + team_slug IS NULL +; + +CREATE INDEX idx_webhook_subscriptions_enabled ON webhook_subscriptions (enabled) +WHERE + enabled = TRUE +; + +-- Outbox table for durable webhook event processing. Rows are inserted by a trigger on +-- activity_log_entries. Subscription matching (event type wildcards, team scoping) happens +-- in the dispatcher, which fans each row out into per-subscription rows in +-- webhook_event_deliveries below. +CREATE TYPE webhook_outbox_status AS ENUM('pending', 'completed') +; + +CREATE TABLE webhook_events ( + id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(), + activity_log_entries_id UUID NOT NULL REFERENCES activity_log_entries (id) ON DELETE CASCADE, + status webhook_outbox_status NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +) +; + +CREATE INDEX idx_webhook_events_pending ON webhook_events (created_at ASC) +WHERE + status = 'pending' +; + +-- Per-(event, subscription) delivery queue; this is the unit of retry and backoff. +CREATE TYPE webhook_delivery_status AS ENUM('pending', 'completed', 'failed') +; + +CREATE TABLE webhook_event_deliveries ( + id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(), + webhook_event_id UUID NOT NULL REFERENCES webhook_events (id) ON DELETE CASCADE, + subscription_id UUID NOT NULL REFERENCES webhook_subscriptions (id) ON DELETE CASCADE, + status webhook_delivery_status NOT NULL DEFAULT 'pending', + retry_count INT NOT NULL DEFAULT 0, + run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (webhook_event_id, subscription_id) +) +; + +CREATE INDEX idx_webhook_event_deliveries_pending ON webhook_event_deliveries (run_at ASC) +WHERE + status = 'pending' +; + +CREATE INDEX idx_webhook_event_deliveries_event ON webhook_event_deliveries (webhook_event_id) +; + +-- Audit log of every actual HTTP delivery attempt. +CREATE TABLE webhook_deliveries ( + id UUID PRIMARY KEY DEFAULT GEN_RANDOM_UUID(), + subscription_id UUID NOT NULL REFERENCES webhook_subscriptions (id) ON DELETE CASCADE, + webhook_event_delivery_id UUID REFERENCES webhook_event_deliveries (id) ON DELETE SET NULL, + event_type TEXT NOT NULL, + request_body JSONB NOT NULL, + response_status INT, + response_body TEXT, + duration_ms INT NOT NULL, + success BOOLEAN NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +) +; + +CREATE INDEX idx_webhook_deliveries_subscription ON webhook_deliveries (subscription_id, created_at DESC) +; + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION webhook_events_notify () RETURNS trigger AS $$ +BEGIN + INSERT INTO webhook_events (activity_log_entries_id) + VALUES ( + NEW.id + ); + + PERFORM pg_notify('api_notify', jsonb_build_object('table', 'webhook_events', 'op', 'INSERT', 'data', '{}'::jsonb)::text); + RETURN NULL; +END; +$$ LANGUAGE plpgsql +; + +-- +goose StatementEnd +CREATE TRIGGER activity_log_webhook_notify +AFTER INSERT ON activity_log_entries FOR EACH ROW +EXECUTE FUNCTION webhook_events_notify () +; + +INSERT INTO + authorizations (name, description) +VALUES + ( + 'webhooks:create', + 'Permission to create webhook subscriptions.' + ), + ( + 'webhooks:update', + 'Permission to update webhook subscriptions.' + ), + ( + 'webhooks:delete', + 'Permission to delete webhook subscriptions.' + ) +; + +INSERT INTO + role_authorizations (role_name, authorization_name) +VALUES + ('Team owner', 'webhooks:create'), + ('Team owner', 'webhooks:update'), + ('Team owner', 'webhooks:delete') +; diff --git a/internal/deployment/deploymentactivity/activitylog.go b/internal/deployment/deploymentactivity/activitylog.go index 25474d278..5dc94cd80 100644 --- a/internal/deployment/deploymentactivity/activitylog.go +++ b/internal/deployment/deploymentactivity/activitylog.go @@ -23,7 +23,7 @@ func init() { } }) - activitylog.RegisterFilter("TEAM_DEPLOY_KEY_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeDeployKey) + activitylog.RegisterActivityType("TEAM_DEPLOY_KEY_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeDeployKey) } type TeamDeployKeyUpdatedActivityLogEntry struct { diff --git a/internal/github/repository/activitylog.go b/internal/github/repository/activitylog.go index d3ce74e62..a3a9ffeec 100644 --- a/internal/github/repository/activitylog.go +++ b/internal/github/repository/activitylog.go @@ -27,8 +27,18 @@ func init() { } }) - activitylog.RegisterFilter("REPOSITORY_ADDED", activitylog.ActivityLogEntryActionAdded, activityLogEntryResourceTypeRepository) - activitylog.RegisterFilter("REPOSITORY_REMOVED", activitylog.ActivityLogEntryActionRemoved, activityLogEntryResourceTypeRepository) + activitylog.RegisterActivityType( + "REPOSITORY_ADDED", + activitylog.ActivityLogEntryActionAdded, + activityLogEntryResourceTypeRepository, + activitylog.WithDescription("Triggered when a repository is added to a team."), + ) + activitylog.RegisterActivityType( + "REPOSITORY_REMOVED", + activitylog.ActivityLogEntryActionRemoved, + activityLogEntryResourceTypeRepository, + activitylog.WithDescription("Triggered when a repository is removed from a team."), + ) } type RepositoryAddedActivityLogEntry struct { diff --git a/internal/graph/gengql/complexity.go b/internal/graph/gengql/complexity.go index 9031a405c..58d679d2e 100644 --- a/internal/graph/gengql/complexity.go +++ b/internal/graph/gengql/complexity.go @@ -133,6 +133,9 @@ func NewComplexityRoot() ComplexityRoot { c.Query.Deployments = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *deployment.DeploymentOrder, filter *deployment.DeploymentFilter) int { return cursorComplexity(first, last) * childComplexity } + c.Query.GlobalWebhooks = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int { + return cursorComplexity(first, last) * childComplexity + } c.Query.Reconcilers = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int { return cursorComplexity(first, last) * childComplexity } @@ -250,6 +253,9 @@ func NewComplexityRoot() ComplexityRoot { c.Team.VulnerabilitySummaries = func(childComplexity int, filter *vulnerability.TeamVulnerabilitySummaryFilter, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *vulnerability.VulnerabilitySummaryOrder) int { return cursorComplexity(first, last) * childComplexity } + c.Team.Webhooks = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int { + return cursorComplexity(first, last) * childComplexity + } c.Team.Workloads = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *workload.WorkloadOrder, filter *workload.TeamWorkloadsFilter) int { return cursorComplexity(first, last) * childComplexity } @@ -274,6 +280,9 @@ func NewComplexityRoot() ComplexityRoot { c.ValkeyMaintenance.Updates = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int { return cursorComplexity(first, last) * childComplexity } + c.WebhookSubscription.Deliveries = func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int { + return cursorComplexity(first, last) * childComplexity + } return c } diff --git a/internal/graph/gengql/prelude.generated.go b/internal/graph/gengql/prelude.generated.go index b1c4cbc55..80688d81a 100644 --- a/internal/graph/gengql/prelude.generated.go +++ b/internal/graph/gengql/prelude.generated.go @@ -11,6 +11,7 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql/introspection" + "github.com/nais/api/internal/activitylog" "github.com/nais/api/internal/graph/ident" "github.com/nais/api/internal/persistence/opensearch" "github.com/vektah/gqlparser/v2/ast" @@ -1640,6 +1641,16 @@ func (ec *executionContext) marshalNInt2ᚕintᚄ(ctx context.Context, sel ast.S return ret } +func (ec *executionContext) unmarshalNString2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐActivityLogActivityType(ctx context.Context, v any) (activitylog.ActivityLogActivityType, error) { + var res activitylog.ActivityLogActivityType + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNString2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐActivityLogActivityType(ctx context.Context, sel ast.SelectionSet, v activitylog.ActivityLogActivityType) graphql.Marshaler { + return v +} + func (ec *executionContext) unmarshalNString2string(ctx context.Context, v any) (string, error) { res, err := graphql.UnmarshalString(v) return res, graphql.ErrorOnPath(ctx, err) diff --git a/internal/graph/gengql/root_.generated.go b/internal/graph/gengql/root_.generated.go index a8e1eff62..366d4b6f9 100644 --- a/internal/graph/gengql/root_.generated.go +++ b/internal/graph/gengql/root_.generated.go @@ -10,6 +10,7 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog/webhook" "github.com/nais/api/internal/alerts" "github.com/nais/api/internal/auth/authz" "github.com/nais/api/internal/cost" @@ -149,6 +150,7 @@ type ResolverRoot interface { ValkeyIssue() ValkeyIssueResolver ValkeyMaintenance() ValkeyMaintenanceResolver VulnerableImageIssue() VulnerableImageIssueResolver + WebhookSubscription() WebhookSubscriptionResolver WorkloadCost() WorkloadCostResolver WorkloadCostSample() WorkloadCostSampleResolver WorkloadProblemIssue() WorkloadProblemIssueResolver @@ -750,6 +752,10 @@ type ComplexityRoot struct { Valkey func(childComplexity int) int } + CreateWebhookPayload struct { + Webhook func(childComplexity int) int + } + CredentialsActivityLogEntry struct { Actor func(childComplexity int) int CreatedAt func(childComplexity int) int @@ -824,6 +830,10 @@ type ComplexityRoot struct { ValkeyDeleted func(childComplexity int) int } + DeleteWebhookPayload struct { + WebhookID func(childComplexity int) int + } + Deployment struct { CommitSha func(childComplexity int) int CreatedAt func(childComplexity int) int @@ -1536,6 +1546,7 @@ type ComplexityRoot struct { CreateUnleashForTeam func(childComplexity int, input unleash.CreateUnleashForTeamInput) int CreateValkey func(childComplexity int, input valkey.CreateValkeyInput) int CreateValkeyCredentials func(childComplexity int, input valkey.CreateValkeyCredentialsInput) int + CreateWebhook func(childComplexity int, input webhook.CreateWebhookInput) int DeleteApplication func(childComplexity int, input application.DeleteApplicationInput) int DeleteConfig func(childComplexity int, input config.DeleteConfigInput) int DeleteJob func(childComplexity int, input job.DeleteJobInput) int @@ -1548,6 +1559,7 @@ type ComplexityRoot struct { DeleteTunnel func(childComplexity int, input tunnel.DeleteTunnelInput) int DeleteUnleashInstance func(childComplexity int, input unleash.DeleteUnleashInstanceInput) int DeleteValkey func(childComplexity int, input valkey.DeleteValkeyInput) int + DeleteWebhook func(childComplexity int, input webhook.DeleteWebhookInput) int DisableReconciler func(childComplexity int, input reconciler.DisableReconcilerInput) int EnableReconciler func(childComplexity int, input reconciler.EnableReconcilerInput) int GrantPostgresAccess func(childComplexity int, input postgres.GrantPostgresAccessInput) int @@ -1578,6 +1590,7 @@ type ComplexityRoot struct { UpdateTeamEnvironment func(childComplexity int, input team.UpdateTeamEnvironmentInput) int UpdateUnleashInstance func(childComplexity int, input unleash.UpdateUnleashInstanceInput) int UpdateValkey func(childComplexity int, input valkey.UpdateValkeyInput) int + UpdateWebhook func(childComplexity int, input webhook.UpdateWebhookInput) int ViewSecretValues func(childComplexity int, input secret.ViewSecretValuesInput) int } @@ -1889,6 +1902,7 @@ type ComplexityRoot struct { Environment func(childComplexity int, name string) int Environments func(childComplexity int, orderBy *environment.EnvironmentOrder) int Features func(childComplexity int) int + GlobalWebhooks func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int ImageVulnerabilityHistory func(childComplexity int, from scalar.Date) int Me func(childComplexity int) int Node func(childComplexity int, id ident.Ident) int @@ -1906,6 +1920,7 @@ type ComplexityRoot struct { Users func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *user.UserOrder) int VulnerabilityFixHistory func(childComplexity int, from scalar.Date) int VulnerabilitySummary func(childComplexity int) int + WebhookEventTypes func(childComplexity int) int } Reconciler struct { @@ -2746,6 +2761,7 @@ type ComplexityRoot struct { VulnerabilityFixHistory func(childComplexity int, from scalar.Date) int VulnerabilitySummaries func(childComplexity int, filter *vulnerability.TeamVulnerabilitySummaryFilter, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *vulnerability.VulnerabilitySummaryOrder) int VulnerabilitySummary func(childComplexity int, filter *vulnerability.TeamVulnerabilitySummaryFilter) int + Webhooks func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int WorkloadUtilization func(childComplexity int, resourceType utilization.UtilizationResourceType) int Workloads func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *workload.WorkloadOrder, filter *workload.TeamWorkloadsFilter) int } @@ -3329,6 +3345,10 @@ type ComplexityRoot struct { Valkey func(childComplexity int) int } + UpdateWebhookPayload struct { + Webhook func(childComplexity int) int + } + User struct { Email func(childComplexity int) int ExternalID func(childComplexity int) int @@ -3591,6 +3611,62 @@ type ComplexityRoot struct { Workload func(childComplexity int) int } + WebhookDelivery struct { + CreatedAt func(childComplexity int) int + DurationMs func(childComplexity int) int + EventType func(childComplexity int) int + ID func(childComplexity int) int + RequestBody func(childComplexity int) int + ResponseBody func(childComplexity int) int + ResponseStatus func(childComplexity int) int + Success func(childComplexity int) int + } + + WebhookDeliveryConnection struct { + Edges func(childComplexity int) int + Nodes func(childComplexity int) int + PageInfo func(childComplexity int) int + } + + WebhookDeliveryEdge struct { + Cursor func(childComplexity int) int + Node func(childComplexity int) int + } + + WebhookEventTypeInfo struct { + CloudEventType func(childComplexity int) int + Description func(childComplexity int) int + Group func(childComplexity int) int + TeamScoped func(childComplexity int) int + Type func(childComplexity int) int + } + + WebhookSubscription struct { + ConsecutiveFailures func(childComplexity int) int + CreatedAt func(childComplexity int) int + CreatedBy func(childComplexity int) int + Deliveries func(childComplexity int, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) int + DisabledAt func(childComplexity int) int + Enabled func(childComplexity int) int + EventTypes func(childComplexity int) int + ID func(childComplexity int) int + MaskedSecret func(childComplexity int) int + TeamSlug func(childComplexity int) int + URL func(childComplexity int) int + UpdatedAt func(childComplexity int) int + } + + WebhookSubscriptionConnection struct { + Edges func(childComplexity int) int + Nodes func(childComplexity int) int + PageInfo func(childComplexity int) int + } + + WebhookSubscriptionEdge struct { + Cursor func(childComplexity int) int + Node func(childComplexity int) int + } + WorkloadConnection struct { Edges func(childComplexity int) int Nodes func(childComplexity int) int @@ -6092,6 +6168,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.CreateValkeyPayload.Valkey(childComplexity), true + case "CreateWebhookPayload.webhook": + if e.ComplexityRoot.CreateWebhookPayload.Webhook == nil { + break + } + + return e.ComplexityRoot.CreateWebhookPayload.Webhook(childComplexity), true + case "CredentialsActivityLogEntry.actor": if e.ComplexityRoot.CredentialsActivityLogEntry.Actor == nil { break @@ -6295,6 +6378,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.DeleteValkeyPayload.ValkeyDeleted(childComplexity), true + case "DeleteWebhookPayload.webhookID": + if e.ComplexityRoot.DeleteWebhookPayload.WebhookID == nil { + break + } + + return e.ComplexityRoot.DeleteWebhookPayload.WebhookID(childComplexity), true + case "Deployment.commitSha": if e.ComplexityRoot.Deployment.CommitSha == nil { break @@ -9403,6 +9493,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.Mutation.CreateValkeyCredentials(childComplexity, args["input"].(valkey.CreateValkeyCredentialsInput)), true + case "Mutation.createWebhook": + if e.ComplexityRoot.Mutation.CreateWebhook == nil { + break + } + + args, err := ec.field_Mutation_createWebhook_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.CreateWebhook(childComplexity, args["input"].(webhook.CreateWebhookInput)), true + case "Mutation.deleteApplication": if e.ComplexityRoot.Mutation.DeleteApplication == nil { break @@ -9547,6 +9649,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.Mutation.DeleteValkey(childComplexity, args["input"].(valkey.DeleteValkeyInput)), true + case "Mutation.deleteWebhook": + if e.ComplexityRoot.Mutation.DeleteWebhook == nil { + break + } + + args, err := ec.field_Mutation_deleteWebhook_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.DeleteWebhook(childComplexity, args["input"].(webhook.DeleteWebhookInput)), true + case "Mutation.disableReconciler": if e.ComplexityRoot.Mutation.DisableReconciler == nil { break @@ -9907,6 +10021,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.Mutation.UpdateValkey(childComplexity, args["input"].(valkey.UpdateValkeyInput)), true + case "Mutation.updateWebhook": + if e.ComplexityRoot.Mutation.UpdateWebhook == nil { + break + } + + args, err := ec.field_Mutation_updateWebhook_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.UpdateWebhook(childComplexity, args["input"].(webhook.UpdateWebhookInput)), true + case "Mutation.viewSecretValues": if e.ComplexityRoot.Mutation.ViewSecretValues == nil { break @@ -11288,6 +11414,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.Query.Features(childComplexity), true + case "Query.globalWebhooks": + if e.ComplexityRoot.Query.GlobalWebhooks == nil { + break + } + + args, err := ec.field_Query_globalWebhooks_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Query.GlobalWebhooks(childComplexity, args["first"].(*int), args["after"].(*pagination.Cursor), args["last"].(*int), args["before"].(*pagination.Cursor)), true + case "Query.imageVulnerabilityHistory": if e.ComplexityRoot.Query.ImageVulnerabilityHistory == nil { break @@ -11477,6 +11615,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.Query.VulnerabilitySummary(childComplexity), true + case "Query.webhookEventTypes": + if e.ComplexityRoot.Query.WebhookEventTypes == nil { + break + } + + return e.ComplexityRoot.Query.WebhookEventTypes(childComplexity), true + case "Reconciler.activityLog": if e.ComplexityRoot.Reconciler.ActivityLog == nil { break @@ -15247,6 +15392,18 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.Team.VulnerabilitySummary(childComplexity, args["filter"].(*vulnerability.TeamVulnerabilitySummaryFilter)), true + case "Team.webhooks": + if e.ComplexityRoot.Team.Webhooks == nil { + break + } + + args, err := ec.field_Team_webhooks_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Team.Webhooks(childComplexity, args["first"].(*int), args["after"].(*pagination.Cursor), args["last"].(*int), args["before"].(*pagination.Cursor)), true + case "Team.workloadUtilization": if e.ComplexityRoot.Team.WorkloadUtilization == nil { break @@ -17624,6 +17781,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.UpdateValkeyPayload.Valkey(childComplexity), true + case "UpdateWebhookPayload.webhook": + if e.ComplexityRoot.UpdateWebhookPayload.Webhook == nil { + break + } + + return e.ComplexityRoot.UpdateWebhookPayload.Webhook(childComplexity), true + case "User.email": if e.ComplexityRoot.User.Email == nil { break @@ -18748,6 +18912,256 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.VulnerableImageIssue.Workload(childComplexity), true + case "WebhookDelivery.createdAt": + if e.ComplexityRoot.WebhookDelivery.CreatedAt == nil { + break + } + + return e.ComplexityRoot.WebhookDelivery.CreatedAt(childComplexity), true + + case "WebhookDelivery.durationMs": + if e.ComplexityRoot.WebhookDelivery.DurationMs == nil { + break + } + + return e.ComplexityRoot.WebhookDelivery.DurationMs(childComplexity), true + + case "WebhookDelivery.eventType": + if e.ComplexityRoot.WebhookDelivery.EventType == nil { + break + } + + return e.ComplexityRoot.WebhookDelivery.EventType(childComplexity), true + + case "WebhookDelivery.id": + if e.ComplexityRoot.WebhookDelivery.ID == nil { + break + } + + return e.ComplexityRoot.WebhookDelivery.ID(childComplexity), true + + case "WebhookDelivery.requestBody": + if e.ComplexityRoot.WebhookDelivery.RequestBody == nil { + break + } + + return e.ComplexityRoot.WebhookDelivery.RequestBody(childComplexity), true + + case "WebhookDelivery.responseBody": + if e.ComplexityRoot.WebhookDelivery.ResponseBody == nil { + break + } + + return e.ComplexityRoot.WebhookDelivery.ResponseBody(childComplexity), true + + case "WebhookDelivery.responseStatus": + if e.ComplexityRoot.WebhookDelivery.ResponseStatus == nil { + break + } + + return e.ComplexityRoot.WebhookDelivery.ResponseStatus(childComplexity), true + + case "WebhookDelivery.success": + if e.ComplexityRoot.WebhookDelivery.Success == nil { + break + } + + return e.ComplexityRoot.WebhookDelivery.Success(childComplexity), true + + case "WebhookDeliveryConnection.edges": + if e.ComplexityRoot.WebhookDeliveryConnection.Edges == nil { + break + } + + return e.ComplexityRoot.WebhookDeliveryConnection.Edges(childComplexity), true + + case "WebhookDeliveryConnection.nodes": + if e.ComplexityRoot.WebhookDeliveryConnection.Nodes == nil { + break + } + + return e.ComplexityRoot.WebhookDeliveryConnection.Nodes(childComplexity), true + + case "WebhookDeliveryConnection.pageInfo": + if e.ComplexityRoot.WebhookDeliveryConnection.PageInfo == nil { + break + } + + return e.ComplexityRoot.WebhookDeliveryConnection.PageInfo(childComplexity), true + + case "WebhookDeliveryEdge.cursor": + if e.ComplexityRoot.WebhookDeliveryEdge.Cursor == nil { + break + } + + return e.ComplexityRoot.WebhookDeliveryEdge.Cursor(childComplexity), true + + case "WebhookDeliveryEdge.node": + if e.ComplexityRoot.WebhookDeliveryEdge.Node == nil { + break + } + + return e.ComplexityRoot.WebhookDeliveryEdge.Node(childComplexity), true + + case "WebhookEventTypeInfo.cloudEventType": + if e.ComplexityRoot.WebhookEventTypeInfo.CloudEventType == nil { + break + } + + return e.ComplexityRoot.WebhookEventTypeInfo.CloudEventType(childComplexity), true + + case "WebhookEventTypeInfo.description": + if e.ComplexityRoot.WebhookEventTypeInfo.Description == nil { + break + } + + return e.ComplexityRoot.WebhookEventTypeInfo.Description(childComplexity), true + + case "WebhookEventTypeInfo.group": + if e.ComplexityRoot.WebhookEventTypeInfo.Group == nil { + break + } + + return e.ComplexityRoot.WebhookEventTypeInfo.Group(childComplexity), true + + case "WebhookEventTypeInfo.teamScoped": + if e.ComplexityRoot.WebhookEventTypeInfo.TeamScoped == nil { + break + } + + return e.ComplexityRoot.WebhookEventTypeInfo.TeamScoped(childComplexity), true + + case "WebhookEventTypeInfo.type": + if e.ComplexityRoot.WebhookEventTypeInfo.Type == nil { + break + } + + return e.ComplexityRoot.WebhookEventTypeInfo.Type(childComplexity), true + + case "WebhookSubscription.consecutiveFailures": + if e.ComplexityRoot.WebhookSubscription.ConsecutiveFailures == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.ConsecutiveFailures(childComplexity), true + + case "WebhookSubscription.createdAt": + if e.ComplexityRoot.WebhookSubscription.CreatedAt == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.CreatedAt(childComplexity), true + + case "WebhookSubscription.createdBy": + if e.ComplexityRoot.WebhookSubscription.CreatedBy == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.CreatedBy(childComplexity), true + + case "WebhookSubscription.deliveries": + if e.ComplexityRoot.WebhookSubscription.Deliveries == nil { + break + } + + args, err := ec.field_WebhookSubscription_deliveries_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.WebhookSubscription.Deliveries(childComplexity, args["first"].(*int), args["after"].(*pagination.Cursor), args["last"].(*int), args["before"].(*pagination.Cursor)), true + + case "WebhookSubscription.disabledAt": + if e.ComplexityRoot.WebhookSubscription.DisabledAt == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.DisabledAt(childComplexity), true + + case "WebhookSubscription.enabled": + if e.ComplexityRoot.WebhookSubscription.Enabled == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.Enabled(childComplexity), true + + case "WebhookSubscription.eventTypes": + if e.ComplexityRoot.WebhookSubscription.EventTypes == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.EventTypes(childComplexity), true + + case "WebhookSubscription.id": + if e.ComplexityRoot.WebhookSubscription.ID == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.ID(childComplexity), true + + case "WebhookSubscription.maskedSecret": + if e.ComplexityRoot.WebhookSubscription.MaskedSecret == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.MaskedSecret(childComplexity), true + + case "WebhookSubscription.teamSlug": + if e.ComplexityRoot.WebhookSubscription.TeamSlug == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.TeamSlug(childComplexity), true + + case "WebhookSubscription.url": + if e.ComplexityRoot.WebhookSubscription.URL == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.URL(childComplexity), true + + case "WebhookSubscription.updatedAt": + if e.ComplexityRoot.WebhookSubscription.UpdatedAt == nil { + break + } + + return e.ComplexityRoot.WebhookSubscription.UpdatedAt(childComplexity), true + + case "WebhookSubscriptionConnection.edges": + if e.ComplexityRoot.WebhookSubscriptionConnection.Edges == nil { + break + } + + return e.ComplexityRoot.WebhookSubscriptionConnection.Edges(childComplexity), true + + case "WebhookSubscriptionConnection.nodes": + if e.ComplexityRoot.WebhookSubscriptionConnection.Nodes == nil { + break + } + + return e.ComplexityRoot.WebhookSubscriptionConnection.Nodes(childComplexity), true + + case "WebhookSubscriptionConnection.pageInfo": + if e.ComplexityRoot.WebhookSubscriptionConnection.PageInfo == nil { + break + } + + return e.ComplexityRoot.WebhookSubscriptionConnection.PageInfo(childComplexity), true + + case "WebhookSubscriptionEdge.cursor": + if e.ComplexityRoot.WebhookSubscriptionEdge.Cursor == nil { + break + } + + return e.ComplexityRoot.WebhookSubscriptionEdge.Cursor(childComplexity), true + + case "WebhookSubscriptionEdge.node": + if e.ComplexityRoot.WebhookSubscriptionEdge.Node == nil { + break + } + + return e.ComplexityRoot.WebhookSubscriptionEdge.Node(childComplexity), true + case "WorkloadConnection.edges": if e.ComplexityRoot.WorkloadConnection.Edges == nil { break @@ -19232,6 +19646,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputCreateUnleashForTeamInput, ec.unmarshalInputCreateValkeyCredentialsInput, ec.unmarshalInputCreateValkeyInput, + ec.unmarshalInputCreateWebhookInput, ec.unmarshalInputDeleteApplicationInput, ec.unmarshalInputDeleteConfigInput, ec.unmarshalInputDeleteJobInput, @@ -19244,6 +19659,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputDeleteTunnelInput, ec.unmarshalInputDeleteUnleashInstanceInput, ec.unmarshalInputDeleteValkeyInput, + ec.unmarshalInputDeleteWebhookInput, ec.unmarshalInputDeploymentFilter, ec.unmarshalInputDeploymentOrder, ec.unmarshalInputDisableReconcilerInput, @@ -19320,6 +19736,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputUpdateTeamInput, ec.unmarshalInputUpdateUnleashInstanceInput, ec.unmarshalInputUpdateValkeyInput, + ec.unmarshalInputUpdateWebhookInput, ec.unmarshalInputUpdateWorkloadEnvironmentVariableInput, ec.unmarshalInputUserOrder, ec.unmarshalInputUserTeamOrder, @@ -32224,6 +32641,272 @@ enum SBOMStatus { "SBOM generation failed." FAILED } +`, BuiltIn: false}, + {Name: "../schema/webhooks.graphqls", Input: `""" +A webhook subscription that receives HTTP callbacks when activity log events occur. +Webhooks can be scoped to a specific team or registered globally. +""" +type WebhookSubscription implements Node { + "Globally unique ID of the webhook subscription." + id: ID! + + "The team this webhook is scoped to. Null for global webhooks." + teamSlug: Slug + + "The URL that will receive webhook HTTP POST requests." + url: String! + + "The event types this webhook is subscribed to. Use '*' to subscribe to all events." + eventTypes: [String!]! + + "Whether the webhook is currently enabled." + enabled: Boolean! + + "Number of consecutive delivery failures. Resets to 0 on a successful delivery." + consecutiveFailures: Int! + + "When the webhook was automatically disabled due to repeated failures. Null if not auto-disabled." + disabledAt: Time + + "The identity of the user who created this webhook." + createdBy: String! + + "When the webhook was created." + createdAt: Time! + + "When the webhook was last updated." + updatedAt: Time! + + "The masked signing secret. Only the last 4 characters are visible." + maskedSecret: String! + + "Recent delivery attempts for this webhook." + deliveries( + "Get the first n items in the connection." + first: Int + + "Get items after this cursor." + after: Cursor + + "Get the last n items in the connection." + last: Int + + "Get items before this cursor." + before: Cursor + ): WebhookDeliveryConnection! +} + +"A paginated list of webhook subscriptions." +type WebhookSubscriptionConnection { + "Pagination information." + pageInfo: PageInfo! + + "The webhook subscriptions in this page." + nodes: [WebhookSubscription!]! + + "The webhook subscription edges in this page." + edges: [WebhookSubscriptionEdge!]! +} + +"An edge in a webhook subscription connection." +type WebhookSubscriptionEdge { + "The cursor for this edge." + cursor: Cursor! + + "The webhook subscription at this edge." + node: WebhookSubscription! +} + +""" +A record of a webhook delivery attempt, including the request sent and the response received. +""" +type WebhookDelivery implements Node { + "Globally unique ID of the delivery." + id: ID! + + "The event type that triggered this delivery." + eventType: String! + + "The CloudEvents JSON payload that was sent." + requestBody: String! + + "The HTTP status code returned by the webhook endpoint. Null if the request failed before receiving a response." + responseStatus: Int + + "The response body returned by the webhook endpoint. Null if the request failed." + responseBody: String + + "How long the delivery took in milliseconds." + durationMs: Int! + + "Whether the delivery was successful (HTTP 2xx response)." + success: Boolean! + + "When the delivery was attempted." + createdAt: Time! +} + +"A paginated list of webhook deliveries." +type WebhookDeliveryConnection { + "Pagination information." + pageInfo: PageInfo! + + "The webhook deliveries in this page." + nodes: [WebhookDelivery!]! + + "The webhook delivery edges in this page." + edges: [WebhookDeliveryEdge!]! +} + +"An edge in a webhook delivery connection." +type WebhookDeliveryEdge { + "The cursor for this edge." + cursor: Cursor! + + "The webhook delivery at this edge." + node: WebhookDelivery! +} + +extend type Team { + "Webhook subscriptions registered for this team." + webhooks( + "Get the first n items in the connection." + first: Int + + "Get items after this cursor." + after: Cursor + + "Get the last n items in the connection." + last: Int + + "Get items before this cursor." + before: Cursor + ): WebhookSubscriptionConnection! +} + +extend type Query { + "List all globally registered webhook subscriptions. Only accessible by admins." + globalWebhooks( + "Get the first n items in the connection." + first: Int + + "Get items after this cursor." + after: Cursor + + "Get the last n items in the connection." + last: Int + + "Get items before this cursor." + before: Cursor + ): WebhookSubscriptionConnection! + + """ + List all supported webhook event types with human-readable descriptions and grouping. + Use the 'type' field value when registering event_types on a webhook subscription. + """ + webhookEventTypes: [WebhookEventTypeInfo!]! +} + +""" +Metadata about a webhook-subscribable event type. +""" +type WebhookEventTypeInfo { + "The identifier to use in webhook subscription eventTypes (e.g. 'TEAM_MEMBER_ADDED')." + type: String! + + "The CloudEvents 1.0 type string that will appear in delivered payloads (e.g. 'io.nais.team.member.added')." + cloudEventType: String! + + "A human-readable description of the event (e.g. 'Team member added')." + description: String! + + "Logical group for UI display (e.g. 'Team', 'Service Account')." + group: String! + + "Indicates if this event type is subscribable by team-scoped webhooks." + teamScoped: Boolean! +} + +extend type Mutation { + """ + Create a new webhook subscription. + + If a team slug is provided, the webhook will only receive events for that team. + If no team slug is provided, the webhook is global and receives all events (admin only). + """ + createWebhook(input: CreateWebhookInput!): CreateWebhookPayload! + + """ + Update an existing webhook subscription. + + Can be used to change the URL, secret, event types, or enabled status. + """ + updateWebhook(input: UpdateWebhookInput!): UpdateWebhookPayload! + + """ + Delete a webhook subscription. + + All associated delivery records will also be deleted. + """ + deleteWebhook(input: DeleteWebhookInput!): DeleteWebhookPayload! +} + +"Input for creating a new webhook subscription." +input CreateWebhookInput { + "The team slug to scope this webhook to. Omit for a global webhook." + teamSlug: Slug + + "The URL that will receive webhook HTTP POST requests." + url: String! + + "The secret used for HMAC-SHA256 signing of webhook payloads." + secret: String! + + "The event types to subscribe to. Use '*' to subscribe to all events." + eventTypes: [String!]! +} + +"Payload returned after creating a webhook." +type CreateWebhookPayload { + "The created webhook subscription." + webhook: WebhookSubscription! +} + +"Input for updating an existing webhook subscription." +input UpdateWebhookInput { + "The ID of the webhook subscription to update." + id: ID! + + "The new URL for the webhook. Null to keep the current value." + url: String + + "The new secret for signing. Null to keep the current value." + secret: String + + "The new event types to subscribe to. Null to keep the current value." + eventTypes: [String!] + + "Whether the webhook should be enabled. Null to keep the current value." + enabled: Boolean +} + +"Payload returned after updating a webhook." +type UpdateWebhookPayload { + "The updated webhook subscription." + webhook: WebhookSubscription! +} + +"Input for deleting a webhook subscription." +input DeleteWebhookInput { + "The ID of the webhook subscription to delete." + id: ID! +} + +"Payload returned after deleting a webhook." +type DeleteWebhookPayload { + "The ID of the deleted webhook subscription." + webhookID: ID! +} `, BuiltIn: false}, {Name: "../schema/workloads.graphqls", Input: `extend type Team { """ @@ -33667,6 +34350,14 @@ func (ec *executionContext) childFields_CreateValkeyPayload(ctx context.Context, return nil, fmt.Errorf("no field named %q was found under type CreateValkeyPayload", field.Name) } +func (ec *executionContext) childFields_CreateWebhookPayload(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "webhook": + return ec.fieldContext_CreateWebhookPayload_webhook(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type CreateWebhookPayload", field.Name) +} + func (ec *executionContext) childFields_CredentialsActivityLogEntryData(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "permission": @@ -33791,6 +34482,14 @@ func (ec *executionContext) childFields_DeleteValkeyPayload(ctx context.Context, return nil, fmt.Errorf("no field named %q was found under type DeleteValkeyPayload", field.Name) } +func (ec *executionContext) childFields_DeleteWebhookPayload(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "webhookID": + return ec.fieldContext_DeleteWebhookPayload_webhookID(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type DeleteWebhookPayload", field.Name) +} + func (ec *executionContext) childFields_Deployment(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "id": @@ -36287,6 +36986,8 @@ func (ec *executionContext) childFields_Team(ctx context.Context, field graphql. return ec.fieldContext_Team_vulnerabilitySummary(ctx, field) case "vulnerabilitySummaries": return ec.fieldContext_Team_vulnerabilitySummaries(ctx, field) + case "webhooks": + return ec.fieldContext_Team_webhooks(ctx, field) case "workloads": return ec.fieldContext_Team_workloads(ctx, field) } @@ -37097,6 +37798,14 @@ func (ec *executionContext) childFields_UpdateValkeyPayload(ctx context.Context, return nil, fmt.Errorf("no field named %q was found under type UpdateValkeyPayload", field.Name) } +func (ec *executionContext) childFields_UpdateWebhookPayload(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "webhook": + return ec.fieldContext_UpdateWebhookPayload_webhook(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type UpdateWebhookPayload", field.Name) +} + func (ec *executionContext) childFields_User(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "id": @@ -37433,6 +38142,118 @@ func (ec *executionContext) childFields_VulnerabilityFixSample(ctx context.Conte return nil, fmt.Errorf("no field named %q was found under type VulnerabilityFixSample", field.Name) } +func (ec *executionContext) childFields_WebhookDelivery(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_WebhookDelivery_id(ctx, field) + case "eventType": + return ec.fieldContext_WebhookDelivery_eventType(ctx, field) + case "requestBody": + return ec.fieldContext_WebhookDelivery_requestBody(ctx, field) + case "responseStatus": + return ec.fieldContext_WebhookDelivery_responseStatus(ctx, field) + case "responseBody": + return ec.fieldContext_WebhookDelivery_responseBody(ctx, field) + case "durationMs": + return ec.fieldContext_WebhookDelivery_durationMs(ctx, field) + case "success": + return ec.fieldContext_WebhookDelivery_success(ctx, field) + case "createdAt": + return ec.fieldContext_WebhookDelivery_createdAt(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type WebhookDelivery", field.Name) +} + +func (ec *executionContext) childFields_WebhookDeliveryConnection(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "pageInfo": + return ec.fieldContext_WebhookDeliveryConnection_pageInfo(ctx, field) + case "nodes": + return ec.fieldContext_WebhookDeliveryConnection_nodes(ctx, field) + case "edges": + return ec.fieldContext_WebhookDeliveryConnection_edges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type WebhookDeliveryConnection", field.Name) +} + +func (ec *executionContext) childFields_WebhookDeliveryEdge(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "cursor": + return ec.fieldContext_WebhookDeliveryEdge_cursor(ctx, field) + case "node": + return ec.fieldContext_WebhookDeliveryEdge_node(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type WebhookDeliveryEdge", field.Name) +} + +func (ec *executionContext) childFields_WebhookEventTypeInfo(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "type": + return ec.fieldContext_WebhookEventTypeInfo_type(ctx, field) + case "cloudEventType": + return ec.fieldContext_WebhookEventTypeInfo_cloudEventType(ctx, field) + case "description": + return ec.fieldContext_WebhookEventTypeInfo_description(ctx, field) + case "group": + return ec.fieldContext_WebhookEventTypeInfo_group(ctx, field) + case "teamScoped": + return ec.fieldContext_WebhookEventTypeInfo_teamScoped(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type WebhookEventTypeInfo", field.Name) +} + +func (ec *executionContext) childFields_WebhookSubscription(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_WebhookSubscription_id(ctx, field) + case "teamSlug": + return ec.fieldContext_WebhookSubscription_teamSlug(ctx, field) + case "url": + return ec.fieldContext_WebhookSubscription_url(ctx, field) + case "eventTypes": + return ec.fieldContext_WebhookSubscription_eventTypes(ctx, field) + case "enabled": + return ec.fieldContext_WebhookSubscription_enabled(ctx, field) + case "consecutiveFailures": + return ec.fieldContext_WebhookSubscription_consecutiveFailures(ctx, field) + case "disabledAt": + return ec.fieldContext_WebhookSubscription_disabledAt(ctx, field) + case "createdBy": + return ec.fieldContext_WebhookSubscription_createdBy(ctx, field) + case "createdAt": + return ec.fieldContext_WebhookSubscription_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_WebhookSubscription_updatedAt(ctx, field) + case "maskedSecret": + return ec.fieldContext_WebhookSubscription_maskedSecret(ctx, field) + case "deliveries": + return ec.fieldContext_WebhookSubscription_deliveries(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type WebhookSubscription", field.Name) +} + +func (ec *executionContext) childFields_WebhookSubscriptionConnection(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "pageInfo": + return ec.fieldContext_WebhookSubscriptionConnection_pageInfo(ctx, field) + case "nodes": + return ec.fieldContext_WebhookSubscriptionConnection_nodes(ctx, field) + case "edges": + return ec.fieldContext_WebhookSubscriptionConnection_edges(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type WebhookSubscriptionConnection", field.Name) +} + +func (ec *executionContext) childFields_WebhookSubscriptionEdge(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "cursor": + return ec.fieldContext_WebhookSubscriptionEdge_cursor(ctx, field) + case "node": + return ec.fieldContext_WebhookSubscriptionEdge_node(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type WebhookSubscriptionEdge", field.Name) +} + func (ec *executionContext) childFields_WorkloadConnection(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { case "pageInfo": diff --git a/internal/graph/gengql/schema.generated.go b/internal/graph/gengql/schema.generated.go index 021c55c7a..253cd9490 100644 --- a/internal/graph/gengql/schema.generated.go +++ b/internal/graph/gengql/schema.generated.go @@ -13,6 +13,7 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql/introspection" "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog/webhook" "github.com/nais/api/internal/alerts" "github.com/nais/api/internal/auth/authz" "github.com/nais/api/internal/cost" @@ -130,6 +131,9 @@ type MutationResolver interface { DeleteValkey(ctx context.Context, input valkey.DeleteValkeyInput) (*valkey.DeleteValkeyPayload, error) CreateValkeyCredentials(ctx context.Context, input valkey.CreateValkeyCredentialsInput) (*valkey.CreateValkeyCredentialsPayload, error) UpdateImageVulnerability(ctx context.Context, input vulnerability.UpdateImageVulnerabilityInput) (*vulnerability.UpdateImageVulnerabilityPayload, error) + CreateWebhook(ctx context.Context, input webhook.CreateWebhookInput) (*webhook.CreateWebhookPayload, error) + UpdateWebhook(ctx context.Context, input webhook.UpdateWebhookInput) (*webhook.UpdateWebhookPayload, error) + DeleteWebhook(ctx context.Context, input webhook.DeleteWebhookInput) (*webhook.DeleteWebhookPayload, error) } type QueryResolver interface { Node(ctx context.Context, id ident.Ident) (model.Node, error) @@ -158,6 +162,8 @@ type QueryResolver interface { VulnerabilityFixHistory(ctx context.Context, from scalar.Date) (*vulnerability.VulnerabilityFixHistory, error) CVE(ctx context.Context, identifier string) (*vulnerability.CVE, error) Cves(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *vulnerability.CVEOrder) (*pagination.Connection[*vulnerability.CVE], error) + GlobalWebhooks(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) (*pagination.Connection[*webhook.WebhookSubscription], error) + WebhookEventTypes(ctx context.Context) ([]*activitylog.WebhookEventTypeInfo, error) } type SubscriptionResolver interface { Log(ctx context.Context, filter loki.LogSubscriptionFilter) (<-chan *loki.LogLine, error) @@ -476,6 +482,20 @@ func (ec *executionContext) field_Mutation_createValkey_args(ctx context.Context return args, nil } +func (ec *executionContext) field_Mutation_createWebhook_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", + func(ctx context.Context, v any) (webhook.CreateWebhookInput, error) { + return ec.unmarshalNCreateWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐCreateWebhookInput(ctx, v) + }) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_deleteApplication_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -644,6 +664,20 @@ func (ec *executionContext) field_Mutation_deleteValkey_args(ctx context.Context return args, nil } +func (ec *executionContext) field_Mutation_deleteWebhook_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", + func(ctx context.Context, v any) (webhook.DeleteWebhookInput, error) { + return ec.unmarshalNDeleteWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐDeleteWebhookInput(ctx, v) + }) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_disableReconciler_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1064,6 +1098,20 @@ func (ec *executionContext) field_Mutation_updateValkey_args(ctx context.Context return args, nil } +func (ec *executionContext) field_Mutation_updateWebhook_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", + func(ctx context.Context, v any) (webhook.UpdateWebhookInput, error) { + return ec.unmarshalNUpdateWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐUpdateWebhookInput(ctx, v) + }) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_viewSecretValues_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1302,6 +1350,44 @@ func (ec *executionContext) field_Query_environments_args(ctx context.Context, r return args, nil } +func (ec *executionContext) field_Query_globalWebhooks_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "first", + func(ctx context.Context, v any) (*int, error) { + return ec.unmarshalOInt2ᚖint(ctx, v) + }) + if err != nil { + return nil, err + } + args["first"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "after", + func(ctx context.Context, v any) (*pagination.Cursor, error) { + return ec.unmarshalOCursor2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐCursor(ctx, v) + }) + if err != nil { + return nil, err + } + args["after"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "last", + func(ctx context.Context, v any) (*int, error) { + return ec.unmarshalOInt2ᚖint(ctx, v) + }) + if err != nil { + return nil, err + } + args["last"] = arg2 + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "before", + func(ctx context.Context, v any) (*pagination.Cursor, error) { + return ec.unmarshalOCursor2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐCursor(ctx, v) + }) + if err != nil { + return nil, err + } + args["before"] = arg3 + return args, nil +} + func (ec *executionContext) field_Query_imageVulnerabilityHistory_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -4602,6 +4688,138 @@ func (ec *executionContext) fieldContext_Mutation_updateImageVulnerability(ctx c return fc, nil } +func (ec *executionContext) _Mutation_createWebhook(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Mutation_createWebhook(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().CreateWebhook(ctx, fc.Args["input"].(webhook.CreateWebhookInput)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *webhook.CreateWebhookPayload) graphql.Marshaler { + return ec.marshalNCreateWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐCreateWebhookPayload(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Mutation_createWebhook(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_CreateWebhookPayload(ctx, field) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_createWebhook_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_updateWebhook(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Mutation_updateWebhook(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().UpdateWebhook(ctx, fc.Args["input"].(webhook.UpdateWebhookInput)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *webhook.UpdateWebhookPayload) graphql.Marshaler { + return ec.marshalNUpdateWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐUpdateWebhookPayload(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Mutation_updateWebhook(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_UpdateWebhookPayload(ctx, field) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_updateWebhook_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_deleteWebhook(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Mutation_deleteWebhook(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().DeleteWebhook(ctx, fc.Args["input"].(webhook.DeleteWebhookInput)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *webhook.DeleteWebhookPayload) graphql.Marshaler { + return ec.marshalNDeleteWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐDeleteWebhookPayload(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Mutation_deleteWebhook(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_DeleteWebhookPayload(ctx, field) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_deleteWebhook_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _PageInfo_hasNextPage(ctx context.Context, field graphql.CollectedField, obj *pagination.PageInfo) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -5838,6 +6056,82 @@ func (ec *executionContext) fieldContext_Query_cves(ctx context.Context, field g return fc, nil } +func (ec *executionContext) _Query_globalWebhooks(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Query_globalWebhooks(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Query().GlobalWebhooks(ctx, fc.Args["first"].(*int), fc.Args["after"].(*pagination.Cursor), fc.Args["last"].(*int), fc.Args["before"].(*pagination.Cursor)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *pagination.Connection[*webhook.WebhookSubscription]) graphql.Marshaler { + return ec.marshalNWebhookSubscriptionConnection2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐConnection(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Query_globalWebhooks(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookSubscriptionConnection(ctx, field) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Query_globalWebhooks_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Query_webhookEventTypes(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Query_webhookEventTypes(ctx, field) + }, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().WebhookEventTypes(ctx) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []*activitylog.WebhookEventTypeInfo) graphql.Marshaler { + return ec.marshalNWebhookEventTypeInfo2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐWebhookEventTypeInfoᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Query_webhookEventTypes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Query", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookEventTypeInfo(ctx, field) + }, + } + return fc, nil +} + func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -6761,6 +7055,20 @@ func (ec *executionContext) _Node(ctx context.Context, sel ast.SelectionSet, obj return graphql.Null } return ec._WorkloadVulnerabilitySummary(ctx, sel, obj) + case webhook.WebhookSubscription: + return ec._WebhookSubscription(ctx, sel, &obj) + case *webhook.WebhookSubscription: + if obj == nil { + return graphql.Null + } + return ec._WebhookSubscription(ctx, sel, obj) + case webhook.WebhookDelivery: + return ec._WebhookDelivery(ctx, sel, &obj) + case *webhook.WebhookDelivery: + if obj == nil { + return graphql.Null + } + return ec._WebhookDelivery(ctx, sel, obj) case usersync.UserSyncLogEntry: if obj == nil { return graphql.Null @@ -7457,6 +7765,27 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "createWebhook": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_createWebhook(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "updateWebhook": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_updateWebhook(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "deleteWebhook": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_deleteWebhook(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -8130,6 +8459,50 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "globalWebhooks": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_globalWebhooks(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "webhookEventTypes": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_webhookEventTypes(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + rrm := func(ctx context.Context) graphql.Marshaler { + return ec.OperationContext.RootResolverMiddleware(ctx, + func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "__type": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { diff --git a/internal/graph/gengql/teams.generated.go b/internal/graph/gengql/teams.generated.go index 0480e03c0..0d8a3d61e 100644 --- a/internal/graph/gengql/teams.generated.go +++ b/internal/graph/gengql/teams.generated.go @@ -12,6 +12,7 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog/webhook" "github.com/nais/api/internal/alerts" "github.com/nais/api/internal/cost" "github.com/nais/api/internal/deployment" @@ -86,6 +87,7 @@ type TeamResolver interface { VulnerabilityFixHistory(ctx context.Context, obj *team.Team, from scalar.Date) (*vulnerability.VulnerabilityFixHistory, error) VulnerabilitySummary(ctx context.Context, obj *team.Team, filter *vulnerability.TeamVulnerabilitySummaryFilter) (*vulnerability.TeamVulnerabilitySummary, error) VulnerabilitySummaries(ctx context.Context, obj *team.Team, filter *vulnerability.TeamVulnerabilitySummaryFilter, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *vulnerability.VulnerabilitySummaryOrder) (*pagination.Connection[*vulnerability.WorkloadVulnerabilitySummary], error) + Webhooks(ctx context.Context, obj *team.Team, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) (*pagination.Connection[*webhook.WebhookSubscription], error) Workloads(ctx context.Context, obj *team.Team, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor, orderBy *workload.WorkloadOrder, filter *workload.TeamWorkloadsFilter) (*pagination.Connection[workload.Workload], error) } type TeamDeleteKeyResolver interface { @@ -1434,6 +1436,44 @@ func (ec *executionContext) field_Team_vulnerabilitySummary_args(ctx context.Con return args, nil } +func (ec *executionContext) field_Team_webhooks_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "first", + func(ctx context.Context, v any) (*int, error) { + return ec.unmarshalOInt2ᚖint(ctx, v) + }) + if err != nil { + return nil, err + } + args["first"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "after", + func(ctx context.Context, v any) (*pagination.Cursor, error) { + return ec.unmarshalOCursor2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐCursor(ctx, v) + }) + if err != nil { + return nil, err + } + args["after"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "last", + func(ctx context.Context, v any) (*int, error) { + return ec.unmarshalOInt2ᚖint(ctx, v) + }) + if err != nil { + return nil, err + } + args["last"] = arg2 + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "before", + func(ctx context.Context, v any) (*pagination.Cursor, error) { + return ec.unmarshalOCursor2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐCursor(ctx, v) + }) + if err != nil { + return nil, err + } + args["before"] = arg3 + return args, nil +} + func (ec *executionContext) field_Team_workloadUtilization_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -3277,6 +3317,50 @@ func (ec *executionContext) fieldContext_Team_vulnerabilitySummaries(ctx context return fc, nil } +func (ec *executionContext) _Team_webhooks(ctx context.Context, field graphql.CollectedField, obj *team.Team) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_Team_webhooks(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Team().Webhooks(ctx, obj, fc.Args["first"].(*int), fc.Args["after"].(*pagination.Cursor), fc.Args["last"].(*int), fc.Args["before"].(*pagination.Cursor)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *pagination.Connection[*webhook.WebhookSubscription]) graphql.Marshaler { + return ec.marshalNWebhookSubscriptionConnection2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐConnection(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_Team_webhooks(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Team", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookSubscriptionConnection(ctx, field) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Team_webhooks_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Team_workloads(ctx context.Context, field graphql.CollectedField, obj *team.Team) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -9368,6 +9452,42 @@ func (ec *executionContext) _Team(ctx context.Context, sel ast.SelectionSet, obj continue } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "webhooks": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Team_webhooks(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) case "workloads": field := field diff --git a/internal/graph/gengql/webhooks.generated.go b/internal/graph/gengql/webhooks.generated.go new file mode 100644 index 000000000..9cf41e88e --- /dev/null +++ b/internal/graph/gengql/webhooks.generated.go @@ -0,0 +1,2002 @@ +// Code generated by github.com/99designs/gqlgen, DO NOT EDIT. + +package gengql + +import ( + "context" + "errors" + "math" + "strconv" + "sync/atomic" + "time" + + "github.com/99designs/gqlgen/graphql" + "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog/webhook" + "github.com/nais/api/internal/graph/ident" + "github.com/nais/api/internal/graph/pagination" + "github.com/nais/api/internal/slug" + "github.com/vektah/gqlparser/v2/ast" +) + +// region ************************** generated!.gotpl ************************** + +type WebhookSubscriptionResolver interface { + MaskedSecret(ctx context.Context, obj *webhook.WebhookSubscription) (string, error) + Deliveries(ctx context.Context, obj *webhook.WebhookSubscription, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) (*pagination.Connection[*webhook.WebhookDelivery], error) +} + +// endregion ************************** generated!.gotpl ************************** + +// region ***************************** args.gotpl ***************************** + +func (ec *executionContext) field_WebhookSubscription_deliveries_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "first", + func(ctx context.Context, v any) (*int, error) { + return ec.unmarshalOInt2ᚖint(ctx, v) + }) + if err != nil { + return nil, err + } + args["first"] = arg0 + arg1, err := graphql.ProcessArgField(ctx, rawArgs, "after", + func(ctx context.Context, v any) (*pagination.Cursor, error) { + return ec.unmarshalOCursor2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐCursor(ctx, v) + }) + if err != nil { + return nil, err + } + args["after"] = arg1 + arg2, err := graphql.ProcessArgField(ctx, rawArgs, "last", + func(ctx context.Context, v any) (*int, error) { + return ec.unmarshalOInt2ᚖint(ctx, v) + }) + if err != nil { + return nil, err + } + args["last"] = arg2 + arg3, err := graphql.ProcessArgField(ctx, rawArgs, "before", + func(ctx context.Context, v any) (*pagination.Cursor, error) { + return ec.unmarshalOCursor2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐCursor(ctx, v) + }) + if err != nil { + return nil, err + } + args["before"] = arg3 + return args, nil +} + +// endregion ***************************** args.gotpl ***************************** + +// region ************************** directives.gotpl ************************** + +// endregion ************************** directives.gotpl ************************** + +// region **************************** field.gotpl ***************************** + +func (ec *executionContext) _CreateWebhookPayload_webhook(ctx context.Context, field graphql.CollectedField, obj *webhook.CreateWebhookPayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_CreateWebhookPayload_webhook(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Webhook, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *webhook.WebhookSubscription) graphql.Marshaler { + return ec.marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookSubscription(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_CreateWebhookPayload_webhook(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CreateWebhookPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookSubscription(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _DeleteWebhookPayload_webhookID(ctx context.Context, field graphql.CollectedField, obj *webhook.DeleteWebhookPayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_DeleteWebhookPayload_webhookID(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.WebhookID, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v ident.Ident) graphql.Marshaler { + return ec.marshalNID2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋidentᚐIdent(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_DeleteWebhookPayload_webhookID(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("DeleteWebhookPayload", field, false, false, errors.New("field of type ID does not have child fields")) +} + +func (ec *executionContext) _UpdateWebhookPayload_webhook(ctx context.Context, field graphql.CollectedField, obj *webhook.UpdateWebhookPayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_UpdateWebhookPayload_webhook(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Webhook, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *webhook.WebhookSubscription) graphql.Marshaler { + return ec.marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookSubscription(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_UpdateWebhookPayload_webhook(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "UpdateWebhookPayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookSubscription(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _WebhookDelivery_id(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookDelivery) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDelivery_id(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.ID(), nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v ident.Ident) graphql.Marshaler { + return ec.marshalNID2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋidentᚐIdent(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDelivery_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookDelivery", field, true, false, errors.New("field of type ID does not have child fields")) +} + +func (ec *executionContext) _WebhookDelivery_eventType(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookDelivery) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDelivery_eventType(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.EventType, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDelivery_eventType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookDelivery", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookDelivery_requestBody(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookDelivery) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDelivery_requestBody(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.RequestBody, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDelivery_requestBody(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookDelivery", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookDelivery_responseStatus(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookDelivery) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDelivery_responseStatus(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.ResponseStatus, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *int) graphql.Marshaler { + return ec.marshalOInt2ᚖint(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_WebhookDelivery_responseStatus(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookDelivery", field, false, false, errors.New("field of type Int does not have child fields")) +} + +func (ec *executionContext) _WebhookDelivery_responseBody(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookDelivery) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDelivery_responseBody(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.ResponseBody, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *string) graphql.Marshaler { + return ec.marshalOString2ᚖstring(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_WebhookDelivery_responseBody(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookDelivery", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookDelivery_durationMs(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookDelivery) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDelivery_durationMs(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.DurationMs, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v int) graphql.Marshaler { + return ec.marshalNInt2int(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDelivery_durationMs(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookDelivery", field, false, false, errors.New("field of type Int does not have child fields")) +} + +func (ec *executionContext) _WebhookDelivery_success(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookDelivery) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDelivery_success(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Success, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v bool) graphql.Marshaler { + return ec.marshalNBoolean2bool(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDelivery_success(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookDelivery", field, false, false, errors.New("field of type Boolean does not have child fields")) +} + +func (ec *executionContext) _WebhookDelivery_createdAt(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookDelivery) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDelivery_createdAt(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.CreatedAt, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v time.Time) graphql.Marshaler { + return ec.marshalNTime2timeᚐTime(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDelivery_createdAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookDelivery", field, false, false, errors.New("field of type Time does not have child fields")) +} + +func (ec *executionContext) _WebhookDeliveryConnection_pageInfo(ctx context.Context, field graphql.CollectedField, obj *pagination.Connection[*webhook.WebhookDelivery]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDeliveryConnection_pageInfo(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.PageInfo, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v pagination.PageInfo) graphql.Marshaler { + return ec.marshalNPageInfo2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐPageInfo(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDeliveryConnection_pageInfo(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WebhookDeliveryConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_PageInfo(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _WebhookDeliveryConnection_nodes(ctx context.Context, field graphql.CollectedField, obj *pagination.Connection[*webhook.WebhookDelivery]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDeliveryConnection_nodes(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Nodes(), nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []*webhook.WebhookDelivery) graphql.Marshaler { + return ec.marshalNWebhookDelivery2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookDeliveryᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDeliveryConnection_nodes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WebhookDeliveryConnection", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookDelivery(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _WebhookDeliveryConnection_edges(ctx context.Context, field graphql.CollectedField, obj *pagination.Connection[*webhook.WebhookDelivery]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDeliveryConnection_edges(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Edges, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []pagination.Edge[*webhook.WebhookDelivery]) graphql.Marshaler { + return ec.marshalNWebhookDeliveryEdge2ᚕgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐEdgeᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDeliveryConnection_edges(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WebhookDeliveryConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookDeliveryEdge(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _WebhookDeliveryEdge_cursor(ctx context.Context, field graphql.CollectedField, obj *pagination.Edge[*webhook.WebhookDelivery]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDeliveryEdge_cursor(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Cursor, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v pagination.Cursor) graphql.Marshaler { + return ec.marshalNCursor2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐCursor(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDeliveryEdge_cursor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookDeliveryEdge", field, false, false, errors.New("field of type Cursor does not have child fields")) +} + +func (ec *executionContext) _WebhookDeliveryEdge_node(ctx context.Context, field graphql.CollectedField, obj *pagination.Edge[*webhook.WebhookDelivery]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookDeliveryEdge_node(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Node, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *webhook.WebhookDelivery) graphql.Marshaler { + return ec.marshalNWebhookDelivery2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookDelivery(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookDeliveryEdge_node(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WebhookDeliveryEdge", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookDelivery(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _WebhookEventTypeInfo_type(ctx context.Context, field graphql.CollectedField, obj *activitylog.WebhookEventTypeInfo) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookEventTypeInfo_type(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Type, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v activitylog.ActivityLogActivityType) graphql.Marshaler { + return ec.marshalNString2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐActivityLogActivityType(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookEventTypeInfo_type(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookEventTypeInfo", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookEventTypeInfo_cloudEventType(ctx context.Context, field graphql.CollectedField, obj *activitylog.WebhookEventTypeInfo) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookEventTypeInfo_cloudEventType(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.CloudEventType, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookEventTypeInfo_cloudEventType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookEventTypeInfo", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookEventTypeInfo_description(ctx context.Context, field graphql.CollectedField, obj *activitylog.WebhookEventTypeInfo) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookEventTypeInfo_description(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Description, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookEventTypeInfo_description(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookEventTypeInfo", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookEventTypeInfo_group(ctx context.Context, field graphql.CollectedField, obj *activitylog.WebhookEventTypeInfo) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookEventTypeInfo_group(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Group, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookEventTypeInfo_group(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookEventTypeInfo", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookEventTypeInfo_teamScoped(ctx context.Context, field graphql.CollectedField, obj *activitylog.WebhookEventTypeInfo) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookEventTypeInfo_teamScoped(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.TeamScoped, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v bool) graphql.Marshaler { + return ec.marshalNBoolean2bool(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookEventTypeInfo_teamScoped(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookEventTypeInfo", field, false, false, errors.New("field of type Boolean does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_id(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_id(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.ID(), nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v ident.Ident) graphql.Marshaler { + return ec.marshalNID2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋidentᚐIdent(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, true, false, errors.New("field of type ID does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_teamSlug(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_teamSlug(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.TeamSlug, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *slug.Slug) graphql.Marshaler { + return ec.marshalOSlug2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋslugᚐSlug(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_teamSlug(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, false, false, errors.New("field of type Slug does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_url(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_url(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.URL, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_url(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_eventTypes(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_eventTypes(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.EventTypes, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []string) graphql.Marshaler { + return ec.marshalNString2ᚕstringᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_eventTypes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_enabled(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_enabled(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Enabled, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v bool) graphql.Marshaler { + return ec.marshalNBoolean2bool(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_enabled(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, false, false, errors.New("field of type Boolean does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_consecutiveFailures(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_consecutiveFailures(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.ConsecutiveFailures, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v int) graphql.Marshaler { + return ec.marshalNInt2int(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_consecutiveFailures(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, false, false, errors.New("field of type Int does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_disabledAt(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_disabledAt(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.DisabledAt, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *time.Time) graphql.Marshaler { + return ec.marshalOTime2ᚖtimeᚐTime(ctx, selections, v) + }, + true, + false, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_disabledAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, false, false, errors.New("field of type Time does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_createdBy(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_createdBy(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.CreatedBy, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_createdBy(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, false, false, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_createdAt(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_createdAt(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.CreatedAt, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v time.Time) graphql.Marshaler { + return ec.marshalNTime2timeᚐTime(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_createdAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, false, false, errors.New("field of type Time does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_updatedAt(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_updatedAt(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.UpdatedAt, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v time.Time) graphql.Marshaler { + return ec.marshalNTime2timeᚐTime(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_updatedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, false, false, errors.New("field of type Time does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_maskedSecret(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_maskedSecret(ctx, field) + }, + func(ctx context.Context) (any, error) { + return ec.Resolvers.WebhookSubscription().MaskedSecret(ctx, obj) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v string) graphql.Marshaler { + return ec.marshalNString2string(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_maskedSecret(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscription", field, true, true, errors.New("field of type String does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscription_deliveries(ctx context.Context, field graphql.CollectedField, obj *webhook.WebhookSubscription) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscription_deliveries(ctx, field) + }, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.WebhookSubscription().Deliveries(ctx, obj, fc.Args["first"].(*int), fc.Args["after"].(*pagination.Cursor), fc.Args["last"].(*int), fc.Args["before"].(*pagination.Cursor)) + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *pagination.Connection[*webhook.WebhookDelivery]) graphql.Marshaler { + return ec.marshalNWebhookDeliveryConnection2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐConnection(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscription_deliveries(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WebhookSubscription", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookDeliveryConnection(ctx, field) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_WebhookSubscription_deliveries_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _WebhookSubscriptionConnection_pageInfo(ctx context.Context, field graphql.CollectedField, obj *pagination.Connection[*webhook.WebhookSubscription]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscriptionConnection_pageInfo(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.PageInfo, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v pagination.PageInfo) graphql.Marshaler { + return ec.marshalNPageInfo2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐPageInfo(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscriptionConnection_pageInfo(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WebhookSubscriptionConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_PageInfo(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _WebhookSubscriptionConnection_nodes(ctx context.Context, field graphql.CollectedField, obj *pagination.Connection[*webhook.WebhookSubscription]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscriptionConnection_nodes(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Nodes(), nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []*webhook.WebhookSubscription) graphql.Marshaler { + return ec.marshalNWebhookSubscription2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookSubscriptionᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscriptionConnection_nodes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WebhookSubscriptionConnection", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookSubscription(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _WebhookSubscriptionConnection_edges(ctx context.Context, field graphql.CollectedField, obj *pagination.Connection[*webhook.WebhookSubscription]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscriptionConnection_edges(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Edges, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v []pagination.Edge[*webhook.WebhookSubscription]) graphql.Marshaler { + return ec.marshalNWebhookSubscriptionEdge2ᚕgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐEdgeᚄ(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscriptionConnection_edges(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WebhookSubscriptionConnection", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookSubscriptionEdge(ctx, field) + }, + } + return fc, nil +} + +func (ec *executionContext) _WebhookSubscriptionEdge_cursor(ctx context.Context, field graphql.CollectedField, obj *pagination.Edge[*webhook.WebhookSubscription]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscriptionEdge_cursor(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Cursor, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v pagination.Cursor) graphql.Marshaler { + return ec.marshalNCursor2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐCursor(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscriptionEdge_cursor(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + return graphql.NewScalarFieldContext("WebhookSubscriptionEdge", field, false, false, errors.New("field of type Cursor does not have child fields")) +} + +func (ec *executionContext) _WebhookSubscriptionEdge_node(ctx context.Context, field graphql.CollectedField, obj *pagination.Edge[*webhook.WebhookSubscription]) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.fieldContext_WebhookSubscriptionEdge_node(ctx, field) + }, + func(ctx context.Context) (any, error) { + return obj.Node, nil + }, + nil, + func(ctx context.Context, selections ast.SelectionSet, v *webhook.WebhookSubscription) graphql.Marshaler { + return ec.marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookSubscription(ctx, selections, v) + }, + true, + true, + ) +} +func (ec *executionContext) fieldContext_WebhookSubscriptionEdge_node(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "WebhookSubscriptionEdge", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return ec.childFields_WebhookSubscription(ctx, field) + }, + } + return fc, nil +} + +// endregion **************************** field.gotpl ***************************** + +// region **************************** input.gotpl ***************************** + +func (ec *executionContext) unmarshalInputCreateWebhookInput(ctx context.Context, obj any) (webhook.CreateWebhookInput, error) { + var it webhook.CreateWebhookInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"teamSlug", "url", "secret", "eventTypes"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "teamSlug": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("teamSlug")) + data, err := ec.unmarshalOSlug2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋslugᚐSlug(ctx, v) + if err != nil { + return it, err + } + it.TeamSlug = data + case "url": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("url")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.URL = data + case "secret": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("secret")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Secret = data + case "eventTypes": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("eventTypes")) + data, err := ec.unmarshalNString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.EventTypes = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputDeleteWebhookInput(ctx context.Context, obj any) (webhook.DeleteWebhookInput, error) { + var it webhook.DeleteWebhookInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"id"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "id": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) + data, err := ec.unmarshalNID2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋidentᚐIdent(ctx, v) + if err != nil { + return it, err + } + it.ID = data + } + } + return it, nil +} + +func (ec *executionContext) unmarshalInputUpdateWebhookInput(ctx context.Context, obj any) (webhook.UpdateWebhookInput, error) { + var it webhook.UpdateWebhookInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"id", "url", "secret", "eventTypes", "enabled"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "id": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id")) + data, err := ec.unmarshalNID2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋidentᚐIdent(ctx, v) + if err != nil { + return it, err + } + it.ID = data + case "url": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("url")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.URL = data + case "secret": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("secret")) + data, err := ec.unmarshalOString2ᚖstring(ctx, v) + if err != nil { + return it, err + } + it.Secret = data + case "eventTypes": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("eventTypes")) + data, err := ec.unmarshalOString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.EventTypes = data + case "enabled": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("enabled")) + data, err := ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } + it.Enabled = data + } + } + return it, nil +} + +// endregion **************************** input.gotpl ***************************** + +// region ************************** interface.gotpl *************************** + +// endregion ************************** interface.gotpl *************************** + +// region **************************** object.gotpl **************************** + +var createWebhookPayloadImplementors = []string{"CreateWebhookPayload"} + +func (ec *executionContext) _CreateWebhookPayload(ctx context.Context, sel ast.SelectionSet, obj *webhook.CreateWebhookPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, createWebhookPayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("CreateWebhookPayload") + case "webhook": + out.Values[i] = ec._CreateWebhookPayload_webhook(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var deleteWebhookPayloadImplementors = []string{"DeleteWebhookPayload"} + +func (ec *executionContext) _DeleteWebhookPayload(ctx context.Context, sel ast.SelectionSet, obj *webhook.DeleteWebhookPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, deleteWebhookPayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("DeleteWebhookPayload") + case "webhookID": + out.Values[i] = ec._DeleteWebhookPayload_webhookID(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var updateWebhookPayloadImplementors = []string{"UpdateWebhookPayload"} + +func (ec *executionContext) _UpdateWebhookPayload(ctx context.Context, sel ast.SelectionSet, obj *webhook.UpdateWebhookPayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, updateWebhookPayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("UpdateWebhookPayload") + case "webhook": + out.Values[i] = ec._UpdateWebhookPayload_webhook(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var webhookDeliveryImplementors = []string{"WebhookDelivery", "Node"} + +func (ec *executionContext) _WebhookDelivery(ctx context.Context, sel ast.SelectionSet, obj *webhook.WebhookDelivery) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, webhookDeliveryImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("WebhookDelivery") + case "id": + out.Values[i] = ec._WebhookDelivery_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "eventType": + out.Values[i] = ec._WebhookDelivery_eventType(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "requestBody": + out.Values[i] = ec._WebhookDelivery_requestBody(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "responseStatus": + out.Values[i] = ec._WebhookDelivery_responseStatus(ctx, field, obj) + case "responseBody": + out.Values[i] = ec._WebhookDelivery_responseBody(ctx, field, obj) + case "durationMs": + out.Values[i] = ec._WebhookDelivery_durationMs(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "success": + out.Values[i] = ec._WebhookDelivery_success(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "createdAt": + out.Values[i] = ec._WebhookDelivery_createdAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var webhookDeliveryConnectionImplementors = []string{"WebhookDeliveryConnection"} + +func (ec *executionContext) _WebhookDeliveryConnection(ctx context.Context, sel ast.SelectionSet, obj *pagination.Connection[*webhook.WebhookDelivery]) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, webhookDeliveryConnectionImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("WebhookDeliveryConnection") + case "pageInfo": + out.Values[i] = ec._WebhookDeliveryConnection_pageInfo(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "nodes": + out.Values[i] = ec._WebhookDeliveryConnection_nodes(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "edges": + out.Values[i] = ec._WebhookDeliveryConnection_edges(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var webhookDeliveryEdgeImplementors = []string{"WebhookDeliveryEdge"} + +func (ec *executionContext) _WebhookDeliveryEdge(ctx context.Context, sel ast.SelectionSet, obj *pagination.Edge[*webhook.WebhookDelivery]) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, webhookDeliveryEdgeImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("WebhookDeliveryEdge") + case "cursor": + out.Values[i] = ec._WebhookDeliveryEdge_cursor(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "node": + out.Values[i] = ec._WebhookDeliveryEdge_node(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var webhookEventTypeInfoImplementors = []string{"WebhookEventTypeInfo"} + +func (ec *executionContext) _WebhookEventTypeInfo(ctx context.Context, sel ast.SelectionSet, obj *activitylog.WebhookEventTypeInfo) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, webhookEventTypeInfoImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("WebhookEventTypeInfo") + case "type": + out.Values[i] = ec._WebhookEventTypeInfo_type(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "cloudEventType": + out.Values[i] = ec._WebhookEventTypeInfo_cloudEventType(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "description": + out.Values[i] = ec._WebhookEventTypeInfo_description(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "group": + out.Values[i] = ec._WebhookEventTypeInfo_group(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "teamScoped": + out.Values[i] = ec._WebhookEventTypeInfo_teamScoped(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var webhookSubscriptionImplementors = []string{"WebhookSubscription", "Node"} + +func (ec *executionContext) _WebhookSubscription(ctx context.Context, sel ast.SelectionSet, obj *webhook.WebhookSubscription) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, webhookSubscriptionImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("WebhookSubscription") + case "id": + out.Values[i] = ec._WebhookSubscription_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "teamSlug": + out.Values[i] = ec._WebhookSubscription_teamSlug(ctx, field, obj) + case "url": + out.Values[i] = ec._WebhookSubscription_url(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "eventTypes": + out.Values[i] = ec._WebhookSubscription_eventTypes(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "enabled": + out.Values[i] = ec._WebhookSubscription_enabled(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "consecutiveFailures": + out.Values[i] = ec._WebhookSubscription_consecutiveFailures(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "disabledAt": + out.Values[i] = ec._WebhookSubscription_disabledAt(ctx, field, obj) + case "createdBy": + out.Values[i] = ec._WebhookSubscription_createdBy(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "createdAt": + out.Values[i] = ec._WebhookSubscription_createdAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "updatedAt": + out.Values[i] = ec._WebhookSubscription_updatedAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "maskedSecret": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._WebhookSubscription_maskedSecret(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "deliveries": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._WebhookSubscription_deliveries(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var webhookSubscriptionConnectionImplementors = []string{"WebhookSubscriptionConnection"} + +func (ec *executionContext) _WebhookSubscriptionConnection(ctx context.Context, sel ast.SelectionSet, obj *pagination.Connection[*webhook.WebhookSubscription]) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, webhookSubscriptionConnectionImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("WebhookSubscriptionConnection") + case "pageInfo": + out.Values[i] = ec._WebhookSubscriptionConnection_pageInfo(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "nodes": + out.Values[i] = ec._WebhookSubscriptionConnection_nodes(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "edges": + out.Values[i] = ec._WebhookSubscriptionConnection_edges(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +var webhookSubscriptionEdgeImplementors = []string{"WebhookSubscriptionEdge"} + +func (ec *executionContext) _WebhookSubscriptionEdge(ctx context.Context, sel ast.SelectionSet, obj *pagination.Edge[*webhook.WebhookSubscription]) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, webhookSubscriptionEdgeImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("WebhookSubscriptionEdge") + case "cursor": + out.Values[i] = ec._WebhookSubscriptionEdge_cursor(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "node": + out.Values[i] = ec._WebhookSubscriptionEdge_node(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.Deferred, int32(min(len(deferred), math.MaxInt32))) + + for label, dfs := range deferred { + ec.ProcessDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + +// endregion **************************** object.gotpl **************************** + +// region ***************************** type.gotpl ***************************** + +func (ec *executionContext) unmarshalNCreateWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐCreateWebhookInput(ctx context.Context, v any) (webhook.CreateWebhookInput, error) { + res, err := ec.unmarshalInputCreateWebhookInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNCreateWebhookPayload2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐCreateWebhookPayload(ctx context.Context, sel ast.SelectionSet, v webhook.CreateWebhookPayload) graphql.Marshaler { + return ec._CreateWebhookPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNCreateWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐCreateWebhookPayload(ctx context.Context, sel ast.SelectionSet, v *webhook.CreateWebhookPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._CreateWebhookPayload(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNDeleteWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐDeleteWebhookInput(ctx context.Context, v any) (webhook.DeleteWebhookInput, error) { + res, err := ec.unmarshalInputDeleteWebhookInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNDeleteWebhookPayload2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐDeleteWebhookPayload(ctx context.Context, sel ast.SelectionSet, v webhook.DeleteWebhookPayload) graphql.Marshaler { + return ec._DeleteWebhookPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNDeleteWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐDeleteWebhookPayload(ctx context.Context, sel ast.SelectionSet, v *webhook.DeleteWebhookPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._DeleteWebhookPayload(ctx, sel, v) +} + +func (ec *executionContext) unmarshalNUpdateWebhookInput2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐUpdateWebhookInput(ctx context.Context, v any) (webhook.UpdateWebhookInput, error) { + res, err := ec.unmarshalInputUpdateWebhookInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNUpdateWebhookPayload2githubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐUpdateWebhookPayload(ctx context.Context, sel ast.SelectionSet, v webhook.UpdateWebhookPayload) graphql.Marshaler { + return ec._UpdateWebhookPayload(ctx, sel, &v) +} + +func (ec *executionContext) marshalNUpdateWebhookPayload2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐUpdateWebhookPayload(ctx context.Context, sel ast.SelectionSet, v *webhook.UpdateWebhookPayload) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._UpdateWebhookPayload(ctx, sel, v) +} + +func (ec *executionContext) marshalNWebhookDelivery2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookDeliveryᚄ(ctx context.Context, sel ast.SelectionSet, v []*webhook.WebhookDelivery) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNWebhookDelivery2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookDelivery(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNWebhookDelivery2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookDelivery(ctx context.Context, sel ast.SelectionSet, v *webhook.WebhookDelivery) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._WebhookDelivery(ctx, sel, v) +} + +func (ec *executionContext) marshalNWebhookDeliveryConnection2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐConnection(ctx context.Context, sel ast.SelectionSet, v pagination.Connection[*webhook.WebhookDelivery]) graphql.Marshaler { + return ec._WebhookDeliveryConnection(ctx, sel, &v) +} + +func (ec *executionContext) marshalNWebhookDeliveryConnection2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐConnection(ctx context.Context, sel ast.SelectionSet, v *pagination.Connection[*webhook.WebhookDelivery]) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._WebhookDeliveryConnection(ctx, sel, v) +} + +func (ec *executionContext) marshalNWebhookDeliveryEdge2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐEdge(ctx context.Context, sel ast.SelectionSet, v pagination.Edge[*webhook.WebhookDelivery]) graphql.Marshaler { + return ec._WebhookDeliveryEdge(ctx, sel, &v) +} + +func (ec *executionContext) marshalNWebhookDeliveryEdge2ᚕgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐEdgeᚄ(ctx context.Context, sel ast.SelectionSet, v []pagination.Edge[*webhook.WebhookDelivery]) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNWebhookDeliveryEdge2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐEdge(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNWebhookEventTypeInfo2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐWebhookEventTypeInfoᚄ(ctx context.Context, sel ast.SelectionSet, v []*activitylog.WebhookEventTypeInfo) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNWebhookEventTypeInfo2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐWebhookEventTypeInfo(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNWebhookEventTypeInfo2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚐWebhookEventTypeInfo(ctx context.Context, sel ast.SelectionSet, v *activitylog.WebhookEventTypeInfo) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._WebhookEventTypeInfo(ctx, sel, v) +} + +func (ec *executionContext) marshalNWebhookSubscription2ᚕᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookSubscriptionᚄ(ctx context.Context, sel ast.SelectionSet, v []*webhook.WebhookSubscription) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookSubscription(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNWebhookSubscription2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋactivitylogᚋwebhookᚐWebhookSubscription(ctx context.Context, sel ast.SelectionSet, v *webhook.WebhookSubscription) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._WebhookSubscription(ctx, sel, v) +} + +func (ec *executionContext) marshalNWebhookSubscriptionConnection2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐConnection(ctx context.Context, sel ast.SelectionSet, v pagination.Connection[*webhook.WebhookSubscription]) graphql.Marshaler { + return ec._WebhookSubscriptionConnection(ctx, sel, &v) +} + +func (ec *executionContext) marshalNWebhookSubscriptionConnection2ᚖgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐConnection(ctx context.Context, sel ast.SelectionSet, v *pagination.Connection[*webhook.WebhookSubscription]) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._WebhookSubscriptionConnection(ctx, sel, v) +} + +func (ec *executionContext) marshalNWebhookSubscriptionEdge2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐEdge(ctx context.Context, sel ast.SelectionSet, v pagination.Edge[*webhook.WebhookSubscription]) graphql.Marshaler { + return ec._WebhookSubscriptionEdge(ctx, sel, &v) +} + +func (ec *executionContext) marshalNWebhookSubscriptionEdge2ᚕgithubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐEdgeᚄ(ctx context.Context, sel ast.SelectionSet, v []pagination.Edge[*webhook.WebhookSubscription]) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNWebhookSubscriptionEdge2githubᚗcomᚋnaisᚋapiᚋinternalᚋgraphᚋpaginationᚐEdge(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +// endregion ***************************** type.gotpl ***************************** diff --git a/internal/graph/schema/webhooks.graphqls b/internal/graph/schema/webhooks.graphqls new file mode 100644 index 000000000..37d37c850 --- /dev/null +++ b/internal/graph/schema/webhooks.graphqls @@ -0,0 +1,265 @@ +""" +A webhook subscription that receives HTTP callbacks when activity log events occur. +Webhooks can be scoped to a specific team or registered globally. +""" +type WebhookSubscription implements Node { + "Globally unique ID of the webhook subscription." + id: ID! + + "The team this webhook is scoped to. Null for global webhooks." + teamSlug: Slug + + "The URL that will receive webhook HTTP POST requests." + url: String! + + "The event types this webhook is subscribed to. Use '*' to subscribe to all events." + eventTypes: [String!]! + + "Whether the webhook is currently enabled." + enabled: Boolean! + + "Number of consecutive delivery failures. Resets to 0 on a successful delivery." + consecutiveFailures: Int! + + "When the webhook was automatically disabled due to repeated failures. Null if not auto-disabled." + disabledAt: Time + + "The identity of the user who created this webhook." + createdBy: String! + + "When the webhook was created." + createdAt: Time! + + "When the webhook was last updated." + updatedAt: Time! + + "The masked signing secret. Only the last 4 characters are visible." + maskedSecret: String! + + "Recent delivery attempts for this webhook." + deliveries( + "Get the first n items in the connection." + first: Int + + "Get items after this cursor." + after: Cursor + + "Get the last n items in the connection." + last: Int + + "Get items before this cursor." + before: Cursor + ): WebhookDeliveryConnection! +} + +"A paginated list of webhook subscriptions." +type WebhookSubscriptionConnection { + "Pagination information." + pageInfo: PageInfo! + + "The webhook subscriptions in this page." + nodes: [WebhookSubscription!]! + + "The webhook subscription edges in this page." + edges: [WebhookSubscriptionEdge!]! +} + +"An edge in a webhook subscription connection." +type WebhookSubscriptionEdge { + "The cursor for this edge." + cursor: Cursor! + + "The webhook subscription at this edge." + node: WebhookSubscription! +} + +""" +A record of a webhook delivery attempt, including the request sent and the response received. +""" +type WebhookDelivery implements Node { + "Globally unique ID of the delivery." + id: ID! + + "The event type that triggered this delivery." + eventType: String! + + "The CloudEvents JSON payload that was sent." + requestBody: String! + + "The HTTP status code returned by the webhook endpoint. Null if the request failed before receiving a response." + responseStatus: Int + + "The response body returned by the webhook endpoint. Null if the request failed." + responseBody: String + + "How long the delivery took in milliseconds." + durationMs: Int! + + "Whether the delivery was successful (HTTP 2xx response)." + success: Boolean! + + "When the delivery was attempted." + createdAt: Time! +} + +"A paginated list of webhook deliveries." +type WebhookDeliveryConnection { + "Pagination information." + pageInfo: PageInfo! + + "The webhook deliveries in this page." + nodes: [WebhookDelivery!]! + + "The webhook delivery edges in this page." + edges: [WebhookDeliveryEdge!]! +} + +"An edge in a webhook delivery connection." +type WebhookDeliveryEdge { + "The cursor for this edge." + cursor: Cursor! + + "The webhook delivery at this edge." + node: WebhookDelivery! +} + +extend type Team { + "Webhook subscriptions registered for this team." + webhooks( + "Get the first n items in the connection." + first: Int + + "Get items after this cursor." + after: Cursor + + "Get the last n items in the connection." + last: Int + + "Get items before this cursor." + before: Cursor + ): WebhookSubscriptionConnection! +} + +extend type Query { + "List all globally registered webhook subscriptions. Only accessible by admins." + globalWebhooks( + "Get the first n items in the connection." + first: Int + + "Get items after this cursor." + after: Cursor + + "Get the last n items in the connection." + last: Int + + "Get items before this cursor." + before: Cursor + ): WebhookSubscriptionConnection! + + """ + List all supported webhook event types with human-readable descriptions and grouping. + Use the 'type' field value when registering event_types on a webhook subscription. + """ + webhookEventTypes: [WebhookEventTypeInfo!]! +} + +""" +Metadata about a webhook-subscribable event type. +""" +type WebhookEventTypeInfo { + "The identifier to use in webhook subscription eventTypes (e.g. 'TEAM_MEMBER_ADDED')." + type: String! + + "The CloudEvents 1.0 type string that will appear in delivered payloads (e.g. 'io.nais.team.member.added')." + cloudEventType: String! + + "A human-readable description of the event (e.g. 'Team member added')." + description: String! + + "Logical group for UI display (e.g. 'Team', 'Service Account')." + group: String! + + "Indicates if this event type is subscribable by team-scoped webhooks." + teamScoped: Boolean! +} + +extend type Mutation { + """ + Create a new webhook subscription. + + If a team slug is provided, the webhook will only receive events for that team. + If no team slug is provided, the webhook is global and receives all events (admin only). + """ + createWebhook(input: CreateWebhookInput!): CreateWebhookPayload! + + """ + Update an existing webhook subscription. + + Can be used to change the URL, secret, event types, or enabled status. + """ + updateWebhook(input: UpdateWebhookInput!): UpdateWebhookPayload! + + """ + Delete a webhook subscription. + + All associated delivery records will also be deleted. + """ + deleteWebhook(input: DeleteWebhookInput!): DeleteWebhookPayload! +} + +"Input for creating a new webhook subscription." +input CreateWebhookInput { + "The team slug to scope this webhook to. Omit for a global webhook." + teamSlug: Slug + + "The URL that will receive webhook HTTP POST requests." + url: String! + + "The secret used for HMAC-SHA256 signing of webhook payloads." + secret: String! + + "The event types to subscribe to. Use '*' to subscribe to all events." + eventTypes: [String!]! +} + +"Payload returned after creating a webhook." +type CreateWebhookPayload { + "The created webhook subscription." + webhook: WebhookSubscription! +} + +"Input for updating an existing webhook subscription." +input UpdateWebhookInput { + "The ID of the webhook subscription to update." + id: ID! + + "The new URL for the webhook. Null to keep the current value." + url: String + + "The new secret for signing. Null to keep the current value." + secret: String + + "The new event types to subscribe to. Null to keep the current value." + eventTypes: [String!] + + "Whether the webhook should be enabled. Null to keep the current value." + enabled: Boolean +} + +"Payload returned after updating a webhook." +type UpdateWebhookPayload { + "The updated webhook subscription." + webhook: WebhookSubscription! +} + +"Input for deleting a webhook subscription." +input DeleteWebhookInput { + "The ID of the webhook subscription to delete." + id: ID! +} + +"Payload returned after deleting a webhook." +type DeleteWebhookPayload { + "The ID of the deleted webhook subscription." + webhookID: ID! +} diff --git a/internal/graph/webhooks.resolvers.go b/internal/graph/webhooks.resolvers.go new file mode 100644 index 000000000..485d74eb2 --- /dev/null +++ b/internal/graph/webhooks.resolvers.go @@ -0,0 +1,74 @@ +package graph + +import ( + "context" + + "github.com/nais/api/internal/activitylog" + "github.com/nais/api/internal/activitylog/webhook" + "github.com/nais/api/internal/auth/authz" + "github.com/nais/api/internal/graph/gengql" + "github.com/nais/api/internal/graph/pagination" + "github.com/nais/api/internal/team" +) + +func (r *mutationResolver) CreateWebhook(ctx context.Context, input webhook.CreateWebhookInput) (*webhook.CreateWebhookPayload, error) { + return webhook.Create(ctx, input) +} + +func (r *mutationResolver) UpdateWebhook(ctx context.Context, input webhook.UpdateWebhookInput) (*webhook.UpdateWebhookPayload, error) { + return webhook.Update(ctx, input) +} + +func (r *mutationResolver) DeleteWebhook(ctx context.Context, input webhook.DeleteWebhookInput) (*webhook.DeleteWebhookPayload, error) { + return webhook.Delete(ctx, input) +} + +func (r *queryResolver) GlobalWebhooks(ctx context.Context, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) (*pagination.Connection[*webhook.WebhookSubscription], error) { + if err := authz.RequireGlobalAdmin(ctx); err != nil { + return nil, err + } + + page, err := pagination.ParsePage(first, after, last, before) + if err != nil { + return nil, err + } + + return webhook.ListGlobal(ctx, page) +} + +func (r *queryResolver) WebhookEventTypes(ctx context.Context) ([]*activitylog.WebhookEventTypeInfo, error) { + all := activitylog.KnownEventTypes() + result := make([]*activitylog.WebhookEventTypeInfo, len(all)) + for i := range all { + result[i] = &all[i] + } + return result, nil +} + +func (r *teamResolver) Webhooks(ctx context.Context, obj *team.Team, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) (*pagination.Connection[*webhook.WebhookSubscription], error) { + page, err := pagination.ParsePage(first, after, last, before) + if err != nil { + return nil, err + } + + return webhook.ListForTeam(ctx, obj.Slug, page) +} + +func (r *webhookSubscriptionResolver) MaskedSecret(ctx context.Context, obj *webhook.WebhookSubscription) (string, error) { + return webhook.MaskedSecret(obj.Secret), nil +} + +func (r *webhookSubscriptionResolver) Deliveries(ctx context.Context, obj *webhook.WebhookSubscription, first *int, after *pagination.Cursor, last *int, before *pagination.Cursor) (*pagination.Connection[*webhook.WebhookDelivery], error) { + page, err := pagination.ParsePage(first, after, last, before) + if err != nil { + return nil, err + } + + return webhook.ListDeliveries(ctx, obj.UUID, page) +} + +func (r *Resolver) WebhookSubscription() gengql.WebhookSubscriptionResolver { + return &webhookSubscriptionResolver{r} +} + +type webhookSubscriptionResolver struct{ *Resolver } diff --git a/internal/integration/manager.go b/internal/integration/manager.go index 061d3b4e5..31ba6cd80 100644 --- a/internal/integration/manager.go +++ b/internal/integration/manager.go @@ -291,6 +291,7 @@ func newGQLRunner( lokiClient, "test-audit-project", // auditLogProjectID for testing "test-location", // auditLogLocation for testing + nil, // webhookDispatcher log, ) if err != nil { diff --git a/internal/kubernetes/event/pubsublog/activitylog.go b/internal/kubernetes/event/pubsublog/activitylog.go index f46ab08dd..01ec0d0bc 100644 --- a/internal/kubernetes/event/pubsublog/activitylog.go +++ b/internal/kubernetes/event/pubsublog/activitylog.go @@ -13,7 +13,7 @@ const ( ) func init() { - activitylog.RegisterFilter(activityLogActivityTypeClusterAudit, activityLogEntryActionClusterAudit, ActivityLogEntryResourceTypeClusterAudit) + activitylog.RegisterActivityType(activityLogActivityTypeClusterAudit, activityLogEntryActionClusterAudit, ActivityLogEntryResourceTypeClusterAudit, activitylog.GlobalOnly()) activitylog.RegisterTransformer(ActivityLogEntryResourceTypeClusterAudit, func(entry activitylog.GenericActivityLogEntry) (activitylog.ActivityLogEntry, error) { data, err := activitylog.UnmarshalData[ClusterAuditActivityLogEntryData](entry) diff --git a/internal/persistence/opensearch/activitylog.go b/internal/persistence/opensearch/activitylog.go index 0eb52c712..3ffe3924a 100644 --- a/internal/persistence/opensearch/activitylog.go +++ b/internal/persistence/opensearch/activitylog.go @@ -43,11 +43,36 @@ func init() { } }) - activitylog.RegisterFilter("OPENSEARCH_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeOpenSearch) - activitylog.RegisterFilter("OPENSEARCH_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeOpenSearch) - activitylog.RegisterFilter("OPENSEARCH_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeOpenSearch) - activitylog.RegisterFilter("OPENSEARCH_MAINTENANCE_STARTED", servicemaintenanceal.ActivityLogEntryActionMaintenanceStarted, ActivityLogEntryResourceTypeOpenSearch) - activitylog.RegisterFilter(aivencredentials.ActivityLogActivityTypeCredentialsCreated, aivencredentials.ActivityLogEntryActionCredentialsCreated, ActivityLogEntryResourceTypeOpenSearch) + activitylog.RegisterActivityType( + "OPENSEARCH_CREATED", + activitylog.ActivityLogEntryActionCreated, + ActivityLogEntryResourceTypeOpenSearch, + activitylog.WithDescription("Triggered when an OpenSearch instance is created."), + ) + activitylog.RegisterActivityType( + "OPENSEARCH_UPDATED", + activitylog.ActivityLogEntryActionUpdated, + ActivityLogEntryResourceTypeOpenSearch, + activitylog.WithDescription("Triggered when an OpenSearch instance is updated."), + ) + activitylog.RegisterActivityType( + "OPENSEARCH_DELETED", + activitylog.ActivityLogEntryActionDeleted, + ActivityLogEntryResourceTypeOpenSearch, + activitylog.WithDescription("Triggered when an OpenSearch instance is deleted."), + ) + activitylog.RegisterActivityType( + "OPENSEARCH_MAINTENANCE_STARTED", + servicemaintenanceal.ActivityLogEntryActionMaintenanceStarted, + ActivityLogEntryResourceTypeOpenSearch, + activitylog.WithDescription("Triggered when service maintenance is started for an OpenSearch instance."), + ) + activitylog.RegisterActivityType( + aivencredentials.ActivityLogActivityTypeCredentialsCreated, + aivencredentials.ActivityLogEntryActionCredentialsCreated, + ActivityLogEntryResourceTypeOpenSearch, + activitylog.WithDescription("Triggered when credentials are created for an OpenSearch instance or a Valkey."), + ) } type OpenSearchCreatedActivityLogEntry struct { diff --git a/internal/persistence/postgres/activitylog.go b/internal/persistence/postgres/activitylog.go index 44371b3b6..651329e7e 100644 --- a/internal/persistence/postgres/activitylog.go +++ b/internal/persistence/postgres/activitylog.go @@ -40,8 +40,16 @@ func init() { } }) - activitylog.RegisterFilter("POSTGRES_GRANT_ACCESS", activityLogEntryActionGrantAccess, activityLogEntryResourceTypePostgres) - activitylog.RegisterFilter("POSTGRES_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypePostgres) + activitylog.RegisterActivityType("POSTGRES_GRANT_ACCESS", + activityLogEntryActionGrantAccess, + activityLogEntryResourceTypePostgres, + activitylog.WithDescription("Triggered when user access to a Postgres instance is granted."), + ) + activitylog.RegisterActivityType("POSTGRES_DELETED", + activitylog.ActivityLogEntryActionDeleted, + activityLogEntryResourceTypePostgres, + activitylog.WithDescription("Triggered when a Postgres instance is deleted."), + ) } type PostgresDeletedActivityLogEntry struct { diff --git a/internal/persistence/valkey/activitylog.go b/internal/persistence/valkey/activitylog.go index 2a944b10c..eb42dff25 100644 --- a/internal/persistence/valkey/activitylog.go +++ b/internal/persistence/valkey/activitylog.go @@ -44,11 +44,36 @@ func init() { } }) - activitylog.RegisterFilter("VALKEY_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeValkey) - activitylog.RegisterFilter("VALKEY_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeValkey) - activitylog.RegisterFilter("VALKEY_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeValkey) - activitylog.RegisterFilter("VALKEY_MAINTENANCE_STARTED", servicemaintenanceal.ActivityLogEntryActionMaintenanceStarted, ActivityLogEntryResourceTypeValkey) - activitylog.RegisterFilter(aivencredentials.ActivityLogActivityTypeCredentialsCreated, aivencredentials.ActivityLogEntryActionCredentialsCreated, ActivityLogEntryResourceTypeValkey) + activitylog.RegisterActivityType( + "VALKEY_CREATED", + activitylog.ActivityLogEntryActionCreated, + ActivityLogEntryResourceTypeValkey, + activitylog.WithDescription("Triggered when a Valkey is created."), + ) + activitylog.RegisterActivityType( + "VALKEY_UPDATED", + activitylog.ActivityLogEntryActionUpdated, + ActivityLogEntryResourceTypeValkey, + activitylog.WithDescription("Triggered when a Valkey is updated."), + ) + activitylog.RegisterActivityType( + "VALKEY_DELETED", + activitylog.ActivityLogEntryActionDeleted, + ActivityLogEntryResourceTypeValkey, + activitylog.WithDescription("Triggered when a Valkey is deleted."), + ) + activitylog.RegisterActivityType( + "VALKEY_MAINTENANCE_STARTED", + servicemaintenanceal.ActivityLogEntryActionMaintenanceStarted, + ActivityLogEntryResourceTypeValkey, + activitylog.WithDescription("Triggered when service maintenance is started for a Valkey."), + ) + activitylog.RegisterActivityType( + aivencredentials.ActivityLogActivityTypeCredentialsCreated, + aivencredentials.ActivityLogEntryActionCredentialsCreated, + ActivityLogEntryResourceTypeValkey, + activitylog.WithDescription("Triggered when credentials are created for an OpenSearch instance or a Valkey."), + ) } type ValkeyCreatedActivityLogEntry struct { diff --git a/internal/reconciler/activitylog.go b/internal/reconciler/activitylog.go index fd7c8f062..48d5a1518 100644 --- a/internal/reconciler/activitylog.go +++ b/internal/reconciler/activitylog.go @@ -44,9 +44,9 @@ func init() { } }) - activitylog.RegisterFilter("RECONCILER_ENABLED", activityLogEntryActionEnableReconciler, ActivityLogEntryResourceTypeReconciler) - activitylog.RegisterFilter("RECONCILER_DISABLED", activityLogEntryActionDisableReconciler, ActivityLogEntryResourceTypeReconciler) - activitylog.RegisterFilter("RECONCILER_CONFIGURED", activityLogEntryActionConfigureReconciler, ActivityLogEntryResourceTypeReconciler) + activitylog.RegisterActivityType("RECONCILER_ENABLED", activityLogEntryActionEnableReconciler, ActivityLogEntryResourceTypeReconciler, activitylog.GlobalOnly()) + activitylog.RegisterActivityType("RECONCILER_DISABLED", activityLogEntryActionDisableReconciler, ActivityLogEntryResourceTypeReconciler, activitylog.GlobalOnly()) + activitylog.RegisterActivityType("RECONCILER_CONFIGURED", activityLogEntryActionConfigureReconciler, ActivityLogEntryResourceTypeReconciler, activitylog.GlobalOnly()) } type ReconcilerEnabledActivityLogEntry struct { diff --git a/internal/serviceaccount/activitylog.go b/internal/serviceaccount/activitylog.go index 594749c65..ef73eb8a1 100644 --- a/internal/serviceaccount/activitylog.go +++ b/internal/serviceaccount/activitylog.go @@ -137,16 +137,66 @@ func init() { } }) - activitylog.RegisterFilter("SERVICE_ACCOUNT_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeServiceAccount) - activitylog.RegisterFilter("SERVICE_ACCOUNT_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeServiceAccount) - activitylog.RegisterFilter("SERVICE_ACCOUNT_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeServiceAccount) - activitylog.RegisterFilter("SERVICE_ACCOUNT_TOKEN_CREATED", activityLogEntryActionCreateServiceAccountToken, ActivityLogEntryResourceTypeServiceAccount) - activitylog.RegisterFilter("SERVICE_ACCOUNT_TOKEN_UPDATED", activityLogEntryActionUpdateServiceAccountToken, ActivityLogEntryResourceTypeServiceAccount) - activitylog.RegisterFilter("SERVICE_ACCOUNT_TOKEN_DELETED", activityLogEntryActionDeleteServiceAccountToken, ActivityLogEntryResourceTypeServiceAccount) - activitylog.RegisterFilter("SERVICE_ACCOUNT_ROLE_ASSIGNED", activityLogEntryActionAssignServiceAccountRole, ActivityLogEntryResourceTypeServiceAccount) - activitylog.RegisterFilter("SERVICE_ACCOUNT_ROLE_REVOKED", activityLogEntryActionRevokeServiceAccountRole, ActivityLogEntryResourceTypeServiceAccount) - activitylog.RegisterFilter("SERVICE_ACCOUNT_WORKLOAD_BINDING_ADDED", activityLogEntryActionAddServiceAccountWorkloadBinding, ActivityLogEntryResourceTypeServiceAccount) - activitylog.RegisterFilter("SERVICE_ACCOUNT_WORKLOAD_BINDING_REMOVED", activityLogEntryActionRemoveServiceAccountWorkloadBinding, ActivityLogEntryResourceTypeServiceAccount) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_CREATED", + activitylog.ActivityLogEntryActionCreated, + ActivityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a service account is created."), + ) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_UPDATED", + activitylog.ActivityLogEntryActionUpdated, + ActivityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a service account is updated."), + ) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_DELETED", + activitylog.ActivityLogEntryActionDeleted, + ActivityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a service account is deleted."), + ) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_TOKEN_CREATED", + activityLogEntryActionCreateServiceAccountToken, + ActivityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a service account token is created."), + ) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_TOKEN_UPDATED", + activityLogEntryActionUpdateServiceAccountToken, + ActivityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a service account token is updated."), + ) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_TOKEN_DELETED", + activityLogEntryActionDeleteServiceAccountToken, + ActivityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a service account token is deleted."), + ) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_ROLE_ASSIGNED", + activityLogEntryActionAssignServiceAccountRole, + ActivityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a role is assigned to a service account."), + ) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_ROLE_REVOKED", + activityLogEntryActionRevokeServiceAccountRole, + ActivityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a role is revoked from a service account."), + ) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_WORKLOAD_BINDING_ADDED", + activityLogEntryActionAddServiceAccountWorkloadBinding, + ActivityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a workload binding is added to a service account."), + ) + activitylog.RegisterActivityType( + "SERVICE_ACCOUNT_WORKLOAD_BINDING_REMOVED", + activityLogEntryActionRemoveServiceAccountWorkloadBinding, + ActivityLogEntryResourceTypeServiceAccount, + activitylog.WithDescription("Triggered when a workload binding is removed from a service account."), + ) } type RoleAssignedToServiceAccountActivityLogEntry struct { diff --git a/internal/team/activitylog.go b/internal/team/activitylog.go index a9b3548d9..ca284eac9 100644 --- a/internal/team/activitylog.go +++ b/internal/team/activitylog.go @@ -97,14 +97,56 @@ func init() { } }) - activitylog.RegisterFilter("TEAM_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeTeam) - activitylog.RegisterFilter("TEAM_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeTeam) - activitylog.RegisterFilter("TEAM_CREATE_DELETE_KEY", activityLogEntryActionCreateDeleteKey, activityLogEntryResourceTypeTeam) - activitylog.RegisterFilter("TEAM_CONFIRM_DELETE_KEY", activityLogEntryActionConfirmDeleteKey, activityLogEntryResourceTypeTeam) - activitylog.RegisterFilter("TEAM_MEMBER_ADDED", activitylog.ActivityLogEntryActionAdded, activityLogEntryResourceTypeTeam) - activitylog.RegisterFilter("TEAM_MEMBER_REMOVED", activitylog.ActivityLogEntryActionRemoved, activityLogEntryResourceTypeTeam) - activitylog.RegisterFilter("TEAM_MEMBER_SET_ROLE", activityLogEntryActionSetMemberRole, activityLogEntryResourceTypeTeam) - activitylog.RegisterFilter("TEAM_ENVIRONMENT_UPDATED", activityLogEntryActionUpdateEnvironment, activityLogEntryResourceTypeTeam) + activitylog.RegisterActivityType( + "TEAM_CREATED", + activitylog.ActivityLogEntryActionCreated, + activityLogEntryResourceTypeTeam, + activitylog.GlobalOnly(), + activitylog.WithDescription("Triggered when a team is created."), + ) + activitylog.RegisterActivityType( + "TEAM_UPDATED", + activitylog.ActivityLogEntryActionUpdated, + activityLogEntryResourceTypeTeam, + activitylog.WithDescription("Triggered when a team is updated."), + ) + activitylog.RegisterActivityType( + "TEAM_CREATE_DELETE_KEY", + activityLogEntryActionCreateDeleteKey, + activityLogEntryResourceTypeTeam, + activitylog.WithDescription("Triggered when a delete key is created for a team."), + ) + activitylog.RegisterActivityType( + "TEAM_CONFIRM_DELETE_KEY", + activityLogEntryActionConfirmDeleteKey, + activityLogEntryResourceTypeTeam, + activitylog.GlobalOnly(), + activitylog.WithDescription("Triggered when a delete key is confirmed for a team and the team is deleted."), + ) + activitylog.RegisterActivityType( + "TEAM_MEMBER_ADDED", + activitylog.ActivityLogEntryActionAdded, + activityLogEntryResourceTypeTeam, + activitylog.WithDescription("Triggered when a member is added to a team."), + ) + activitylog.RegisterActivityType( + "TEAM_MEMBER_REMOVED", + activitylog.ActivityLogEntryActionRemoved, + activityLogEntryResourceTypeTeam, + activitylog.WithDescription("Triggered when a member is removed from a team."), + ) + activitylog.RegisterActivityType( + "TEAM_MEMBER_SET_ROLE", + activityLogEntryActionSetMemberRole, + activityLogEntryResourceTypeTeam, + activitylog.WithDescription("Triggered when a member's role is modified in a team."), + ) + activitylog.RegisterActivityType( + "TEAM_ENVIRONMENT_UPDATED", + activityLogEntryActionUpdateEnvironment, + activityLogEntryResourceTypeTeam, + activitylog.WithDescription("Triggered when a team's environment is updated."), + ) } type TeamCreatedActivityLogEntry struct { diff --git a/internal/tunnel/activitylog.go b/internal/tunnel/activitylog.go index e3550c4c4..17a4a4b4d 100644 --- a/internal/tunnel/activitylog.go +++ b/internal/tunnel/activitylog.go @@ -44,8 +44,8 @@ func init() { } }) - activitylog.RegisterFilter("TUNNEL_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeTunnel) - activitylog.RegisterFilter("TUNNEL_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeTunnel) + activitylog.RegisterActivityType("TUNNEL_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeTunnel, activitylog.IgnoreWebhook()) + activitylog.RegisterActivityType("TUNNEL_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeTunnel, activitylog.IgnoreWebhook()) } type tunnelCreatedData struct { diff --git a/internal/unleash/activitylog.go b/internal/unleash/activitylog.go index 0a07557f6..ab53c2bd0 100644 --- a/internal/unleash/activitylog.go +++ b/internal/unleash/activitylog.go @@ -43,9 +43,9 @@ func init() { } }) - activitylog.RegisterFilter("UNLEASH_INSTANCE_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeUnleash) - activitylog.RegisterFilter("UNLEASH_INSTANCE_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeUnleash) - activitylog.RegisterFilter("UNLEASH_INSTANCE_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypeUnleash) + activitylog.RegisterActivityType("UNLEASH_INSTANCE_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeUnleash, activitylog.IgnoreWebhook()) + activitylog.RegisterActivityType("UNLEASH_INSTANCE_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeUnleash, activitylog.IgnoreWebhook()) + activitylog.RegisterActivityType("UNLEASH_INSTANCE_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypeUnleash, activitylog.IgnoreWebhook()) } type UnleashInstanceCreatedActivityLogEntry struct { diff --git a/internal/vulnerability/activitylog.go b/internal/vulnerability/activitylog.go index 16fe963dc..e7eb558e9 100644 --- a/internal/vulnerability/activitylog.go +++ b/internal/vulnerability/activitylog.go @@ -27,7 +27,12 @@ func init() { } }) - activitylog.RegisterFilter("VULNERABILITY_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeVulnerability) + activitylog.RegisterActivityType( + "VULNERABILITY_UPDATED", + activitylog.ActivityLogEntryActionUpdated, + activityLogEntryResourceTypeVulnerability, + activitylog.WithDescription("Triggered when a vulnerability finding is updated by a user."), + ) } type VulnerabilityUpdatedActivityLogEntry struct { diff --git a/internal/workload/application/activitylog.go b/internal/workload/application/activitylog.go index 5c69abb16..d2221e907 100644 --- a/internal/workload/application/activitylog.go +++ b/internal/workload/application/activitylog.go @@ -79,12 +79,42 @@ func init() { } }) - activitylog.RegisterFilter("APPLICATION_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeApplication) - activitylog.RegisterFilter("APPLICATION_RESTARTED", activityLogEntryActionRestartApplication, ActivityLogEntryResourceTypeApplication) - activitylog.RegisterFilter("APPLICATION_SCALED", activityLogEntryActionAutoScaleApplication, ActivityLogEntryResourceTypeApplication) - activitylog.RegisterFilter("DEPLOYMENT", deploymentactivity.ActivityLogEntryActionDeployment, ActivityLogEntryResourceTypeApplication) - activitylog.RegisterFilter("GENERIC_KUBERNETES_RESOURCE_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeApplication) - activitylog.RegisterFilter("APPLICATION_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeApplication) + activitylog.RegisterActivityType( + "APPLICATION_DELETED", + activitylog.ActivityLogEntryActionDeleted, + ActivityLogEntryResourceTypeApplication, + activitylog.WithDescription("Triggered when an application is deleted."), + ) + activitylog.RegisterActivityType( + "APPLICATION_RESTARTED", + activityLogEntryActionRestartApplication, + ActivityLogEntryResourceTypeApplication, + activitylog.WithDescription("Triggered when an application is restarted."), + ) + activitylog.RegisterActivityType( + "APPLICATION_SCALED", + activityLogEntryActionAutoScaleApplication, + ActivityLogEntryResourceTypeApplication, + activitylog.WithDescription("Triggered when an application is scaled."), + ) + activitylog.RegisterActivityType( + "DEPLOYMENT", + deploymentactivity.ActivityLogEntryActionDeployment, + ActivityLogEntryResourceTypeApplication, + activitylog.WithDescription("Triggered when a resource is deployed using the nais/deploy action."), + ) + activitylog.RegisterActivityType( + "GENERIC_KUBERNETES_RESOURCE_CREATED", + activitylog.ActivityLogEntryActionCreated, + ActivityLogEntryResourceTypeApplication, + activitylog.WithDescription("Triggered when a generic Kubernetes resource is created."), + ) + activitylog.RegisterActivityType( + "APPLICATION_UPDATED", + activitylog.ActivityLogEntryActionUpdated, + ActivityLogEntryResourceTypeApplication, + activitylog.WithDescription("Triggered when an application is updated."), + ) } type ApplicationRestartedActivityLogEntry struct { diff --git a/internal/workload/config/activitylog.go b/internal/workload/config/activitylog.go index 2b5d1ef73..a6b6058a6 100644 --- a/internal/workload/config/activitylog.go +++ b/internal/workload/config/activitylog.go @@ -36,9 +36,24 @@ func init() { } }) - activitylog.RegisterFilter("CONFIG_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeConfig) - activitylog.RegisterFilter("CONFIG_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeConfig) - activitylog.RegisterFilter("CONFIG_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypeConfig) + activitylog.RegisterActivityType( + "CONFIG_CREATED", + activitylog.ActivityLogEntryActionCreated, + activityLogEntryResourceTypeConfig, + activitylog.WithDescription("Triggered when a config is created."), + ) + activitylog.RegisterActivityType( + "CONFIG_UPDATED", + activitylog.ActivityLogEntryActionUpdated, + activityLogEntryResourceTypeConfig, + activitylog.WithDescription("Triggered when a config is updated."), + ) + activitylog.RegisterActivityType( + "CONFIG_DELETED", + activitylog.ActivityLogEntryActionDeleted, + activityLogEntryResourceTypeConfig, + activitylog.WithDescription("Triggered when a config is deleted."), + ) } type ConfigCreatedActivityLogEntry struct { diff --git a/internal/workload/job/activitylog.go b/internal/workload/job/activitylog.go index eb7acb5ac..8606bf9e9 100644 --- a/internal/workload/job/activitylog.go +++ b/internal/workload/job/activitylog.go @@ -76,12 +76,42 @@ func init() { } }) - activitylog.RegisterFilter("JOB_DELETED", activitylog.ActivityLogEntryActionDeleted, ActivityLogEntryResourceTypeJob) - activitylog.RegisterFilter("JOB_RUN_DELETED", activityLogEntryActionDeleteJobRun, ActivityLogEntryResourceTypeJob) - activitylog.RegisterFilter("JOB_TRIGGERED", activityLogEntryActionTriggerJob, ActivityLogEntryResourceTypeJob) - activitylog.RegisterFilter("DEPLOYMENT", deploymentactivity.ActivityLogEntryActionDeployment, ActivityLogEntryResourceTypeJob) - activitylog.RegisterFilter("GENERIC_KUBERNETES_RESOURCE_CREATED", activitylog.ActivityLogEntryActionCreated, ActivityLogEntryResourceTypeJob) - activitylog.RegisterFilter("JOB_UPDATED", activitylog.ActivityLogEntryActionUpdated, ActivityLogEntryResourceTypeJob) + activitylog.RegisterActivityType( + "JOB_DELETED", + activitylog.ActivityLogEntryActionDeleted, + ActivityLogEntryResourceTypeJob, + activitylog.WithDescription("Triggered when a job is deleted."), + ) + activitylog.RegisterActivityType( + "JOB_RUN_DELETED", + activityLogEntryActionDeleteJobRun, + ActivityLogEntryResourceTypeJob, + activitylog.WithDescription("Triggered when a job run is deleted."), + ) + activitylog.RegisterActivityType( + "JOB_TRIGGERED", + activityLogEntryActionTriggerJob, + ActivityLogEntryResourceTypeJob, + activitylog.WithDescription("Triggered when a job is manually triggered."), + ) + activitylog.RegisterActivityType( + "DEPLOYMENT", + deploymentactivity.ActivityLogEntryActionDeployment, + ActivityLogEntryResourceTypeJob, + activitylog.WithDescription("Triggered when a resource is deployed using the nais/deploy action."), + ) + activitylog.RegisterActivityType( + "GENERIC_KUBERNETES_RESOURCE_CREATED", + activitylog.ActivityLogEntryActionCreated, + ActivityLogEntryResourceTypeJob, + activitylog.WithDescription("Triggered when a generic Kubernetes resource is created."), + ) + activitylog.RegisterActivityType( + "JOB_UPDATED", + activitylog.ActivityLogEntryActionUpdated, + ActivityLogEntryResourceTypeJob, + activitylog.WithDescription("Triggered when a job is updated."), + ) } type JobTriggeredActivityLogEntry struct { diff --git a/internal/workload/secret/activitylog.go b/internal/workload/secret/activitylog.go index 634fe0147..ab57cf5d6 100644 --- a/internal/workload/secret/activitylog.go +++ b/internal/workload/secret/activitylog.go @@ -90,13 +90,48 @@ func init() { } }) - activitylog.RegisterFilter("SECRET_CREATED", activitylog.ActivityLogEntryActionCreated, activityLogEntryResourceTypeSecret) - activitylog.RegisterFilter("SECRET_UPDATED", activitylog.ActivityLogEntryActionUpdated, activityLogEntryResourceTypeSecret) - activitylog.RegisterFilter("SECRET_DELETED", activitylog.ActivityLogEntryActionDeleted, activityLogEntryResourceTypeSecret) - activitylog.RegisterFilter("SECRET_VALUE_ADDED", activityLogEntryActionAddSecretValue, activityLogEntryResourceTypeSecret) - activitylog.RegisterFilter("SECRET_VALUE_UPDATED", activityLogEntryActionUpdateSecretValue, activityLogEntryResourceTypeSecret) - activitylog.RegisterFilter("SECRET_VALUE_REMOVED", activityLogEntryActionRemoveSecretValue, activityLogEntryResourceTypeSecret) - activitylog.RegisterFilter("SECRET_VALUES_VIEWED", activityLogEntryActionViewSecretValues, activityLogEntryResourceTypeSecret) + activitylog.RegisterActivityType( + "SECRET_CREATED", + activitylog.ActivityLogEntryActionCreated, + activityLogEntryResourceTypeSecret, + activitylog.WithDescription("Triggered when a secret is created."), + ) + activitylog.RegisterActivityType( + "SECRET_UPDATED", + activitylog.ActivityLogEntryActionUpdated, + activityLogEntryResourceTypeSecret, + activitylog.WithDescription("Triggered when a secret is updated."), + ) + activitylog.RegisterActivityType( + "SECRET_DELETED", + activitylog.ActivityLogEntryActionDeleted, + activityLogEntryResourceTypeSecret, + activitylog.WithDescription("Triggered when a secret is deleted."), + ) + activitylog.RegisterActivityType( + "SECRET_VALUE_ADDED", + activityLogEntryActionAddSecretValue, + activityLogEntryResourceTypeSecret, + activitylog.WithDescription("Triggered when a secret value is added."), + ) + activitylog.RegisterActivityType( + "SECRET_VALUE_UPDATED", + activityLogEntryActionUpdateSecretValue, + activityLogEntryResourceTypeSecret, + activitylog.WithDescription("Triggered when a secret value is updated."), + ) + activitylog.RegisterActivityType( + "SECRET_VALUE_REMOVED", + activityLogEntryActionRemoveSecretValue, + activityLogEntryResourceTypeSecret, + activitylog.WithDescription("Triggered when a secret value is removed."), + ) + activitylog.RegisterActivityType( + "SECRET_VALUES_VIEWED", + activityLogEntryActionViewSecretValues, + activityLogEntryResourceTypeSecret, + activitylog.WithDescription("Triggered when secret values are viewed."), + ) } type SecretCreatedActivityLogEntry struct {