From 5949c5ea8432b3ce9d0160b538b398196f62f77d Mon Sep 17 00:00:00 2001 From: espadonne Date: Wed, 27 May 2026 20:00:55 -0400 Subject: [PATCH 1/2] Add organization webhook settings --- internal/web/handlers/orgs/orgs.go | 23 +- .../handlers/orgs/settings_actions_test.go | 139 ++++++ .../web/handlers/orgs/settings_webhooks.go | 408 ++++++++++++++++++ internal/web/templates/_org_settings_nav.html | 2 +- .../orgs/settings_hook_delivery.html | 56 +++ .../templates/orgs/settings_hook_edit.html | 114 +++++ .../web/templates/orgs/settings_hook_new.html | 64 +++ .../web/templates/orgs/settings_hooks.html | 67 +++ 8 files changed, 869 insertions(+), 4 deletions(-) create mode 100644 internal/web/handlers/orgs/settings_webhooks.go create mode 100644 internal/web/templates/orgs/settings_hook_delivery.html create mode 100644 internal/web/templates/orgs/settings_hook_edit.html create mode 100644 internal/web/templates/orgs/settings_hook_new.html create mode 100644 internal/web/templates/orgs/settings_hooks.html diff --git a/internal/web/handlers/orgs/orgs.go b/internal/web/handlers/orgs/orgs.go index a7fb065c..c3124019 100644 --- a/internal/web/handlers/orgs/orgs.go +++ b/internal/web/handlers/orgs/orgs.go @@ -15,6 +15,8 @@ // GET /organizations/{org}/settings/import GitHub org import // POST /organizations/{org}/settings/import start GitHub org import // GET /organizations/{org}/imports/{importID} GitHub org import progress +// GET /organizations/{org}/settings/hooks organization webhooks +// POST /organizations/{org}/settings/hooks create organization webhook // GET /organizations/{org}/settings/{secrets,variables}/actions // POST /organizations/{org}/settings/{secrets,variables}/actions // GET /invitations/{token} accept/decline view @@ -59,6 +61,7 @@ import ( usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc" "github.com/tenseleyFlow/shithub/internal/web/middleware" "github.com/tenseleyFlow/shithub/internal/web/render" + "github.com/tenseleyFlow/shithub/internal/webhook" ) // Deps wires the handler set. @@ -85,6 +88,10 @@ type Deps struct { // operator has enabled only one tier. StripeTeamPriceID string StripeProPriceID string + // WebhookSSRF is the SSRF policy applied at organization webhook + // create/update. Defaults to webhook.DefaultSSRFConfig() when zero; + // tests inject a loopback-permissive config. + WebhookSSRF webhook.SSRFConfig } // BillingPriceIDs returns the configured (team, pro) Stripe price @@ -136,6 +143,16 @@ func (h *Handlers) MountCreate(r chi.Router) { r.Post("/organizations/{org}/settings/scheduled-reminders/{reminderID}/pause", h.settingsScheduledReminderPause) r.Post("/organizations/{org}/settings/scheduled-reminders/{reminderID}/resume", h.settingsScheduledReminderResume) r.Post("/organizations/{org}/settings/scheduled-reminders/{reminderID}/delete", h.settingsScheduledReminderDelete) + r.Get("/organizations/{org}/settings/hooks", h.settingsHooks) + r.Get("/organizations/{org}/settings/hooks/new", h.settingsHookNew) + r.Post("/organizations/{org}/settings/hooks", h.settingsHookCreate) + r.Get("/organizations/{org}/settings/hooks/{id}", h.settingsHookEdit) + r.Post("/organizations/{org}/settings/hooks/{id}", h.settingsHookUpdate) + r.Post("/organizations/{org}/settings/hooks/{id}/delete", h.settingsHookDelete) + r.Post("/organizations/{org}/settings/hooks/{id}/toggle", h.settingsHookToggle) + r.Post("/organizations/{org}/settings/hooks/{id}/ping", h.settingsHookPing) + r.Get("/organizations/{org}/settings/hooks/{id}/deliveries/{deliveryID}", h.settingsHookDelivery) + r.Post("/organizations/{org}/settings/hooks/{id}/deliveries/{deliveryID}/redeliver", h.settingsHookRedeliver) r.Get("/organizations/{org}/settings/security", h.settingsSecurity) r.Post("/organizations/{org}/settings/security", h.settingsSecuritySubmit) r.Get("/organizations/{org}/settings/security/secret-patterns", h.settingsSecretPatterns) @@ -792,9 +809,9 @@ func orgPlanFeatureSections() []orgPlanFeatureSection { Name: "Marketplace and integrations", Rows: []orgPlanFeatureRow{ { - Name: "GitHub Apps", Description: "Install app-style integrations against organization repositories.", - Free: "Planned", Team: "Planned", Enterprise: "Contact sales", Owner: "SP29", - OwnerPath: ".docs/sprints/PAYMENTS/SP29-platform-security-compliance-integrations.md", State: "Planned", + Name: "App-style integrations", Description: "Connect organization repositories to external systems with org webhooks and check runs.", + Free: "Organization webhooks", Team: "Organization webhooks and status checks", Enterprise: "Contact sales", Owner: "SP29", + OwnerPath: ".docs/sprints/PAYMENTS/SP29-platform-security-compliance-integrations.md", State: "Baseline shipped", }, { Name: "Status checks", Description: "Require named check runs before protected branches merge.", diff --git a/internal/web/handlers/orgs/settings_actions_test.go b/internal/web/handlers/orgs/settings_actions_test.go index d136e939..cc4acd1c 100644 --- a/internal/web/handlers/orgs/settings_actions_test.go +++ b/internal/web/handlers/orgs/settings_actions_test.go @@ -25,6 +25,7 @@ import ( orgsh "github.com/tenseleyFlow/shithub/internal/web/handlers/orgs" "github.com/tenseleyFlow/shithub/internal/web/middleware" "github.com/tenseleyFlow/shithub/internal/web/render" + "github.com/tenseleyFlow/shithub/internal/webhook" ) func TestOrgActionsSettingsSecretAndVariableCRUD(t *testing.T) { @@ -379,6 +380,139 @@ func TestOrgAuditLogShowsOrgAndOwnedRepoEvents(t *testing.T) { } } +func TestOrgWebhooksCreateListAndFanout(t *testing.T) { + t.Parallel() + ctx := context.Background() + pool := dbtest.NewTestDB(t) + ownerID := insertOrgAvatarUser(t, pool, "owner") + orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme") + repoID := insertOrgAuditOrgRepo(t, pool, orgID, "project") + h := newOrgActionsHandler(t, pool) + mux := chi.NewRouter() + mux.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + viewer := middleware.CurrentUser{ID: ownerID, Username: "owner"} + next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), viewer))) + }) + }) + h.MountCreate(mux) + + resp := httptest.NewRecorder() + req := newOrgFormRequest(http.MethodPost, "/organizations/acme/settings/hooks", url.Values{ + "url": {"http://127.0.0.1:8080/hook"}, + "content_type": {"json"}, + "events": {"push, pull_request"}, + "secret": {"shared-secret"}, + "active": {"on"}, + "ssl_verification": {"on"}, + }) + mux.ServeHTTP(resp, req) + if resp.Code != http.StatusSeeOther { + t.Fatalf("POST org webhook status=%d body=%s", resp.Code, resp.Body.String()) + } + var hookID int64 + var ownerKind string + var ownerRef int64 + var events []string + if err := pool.QueryRow(ctx, + `SELECT id, owner_kind::text, owner_id, events FROM webhooks WHERE owner_kind = 'org' AND owner_id = $1`, + orgID, + ).Scan(&hookID, &ownerKind, &ownerRef, &events); err != nil { + t.Fatalf("query created webhook: %v", err) + } + if ownerKind != "org" || ownerRef != orgID { + t.Fatalf("webhook owner=%s/%d, want org/%d", ownerKind, ownerRef, orgID) + } + if strings.Join(events, ",") != "push,pull_request" { + t.Fatalf("events=%v, want push,pull_request", events) + } + + resp = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/organizations/acme/settings/hooks", nil) + mux.ServeHTTP(resp, req) + if resp.Code != http.StatusOK { + t.Fatalf("GET org webhooks status=%d body=%s", resp.Code, resp.Body.String()) + } + if got := resp.Body.String(); !strings.Contains(got, "HOOK=http://127.0.0.1:8080/hook|org|"+strconv.FormatInt(orgID, 10)+";") { + t.Fatalf("created org webhook missing from list: %s", got) + } + + if _, err := pool.Exec(ctx, + `INSERT INTO domain_events (actor_user_id, kind, repo_id, source_kind, source_id, public, payload) + VALUES ($1, 'push', $2, 'push', 99, true, '{"ref":"refs/heads/trunk"}'::jsonb)`, + ownerID, repoID, + ); err != nil { + t.Fatalf("insert domain event: %v", err) + } + processed, err := webhook.FanoutOnce(ctx, webhook.FanoutDeps{Pool: pool}) + if err != nil { + t.Fatalf("FanoutOnce: %v", err) + } + if processed != 1 { + t.Fatalf("FanoutOnce processed=%d, want 1", processed) + } + var deliveryCount int + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM webhook_deliveries WHERE webhook_id = $1 AND event_kind = 'push'`, + hookID, + ).Scan(&deliveryCount); err != nil { + t.Fatalf("query push deliveries: %v", err) + } + if deliveryCount != 1 { + t.Fatalf("push deliveries=%d, want 1", deliveryCount) + } + + var auditCount int + if err := pool.QueryRow(ctx, + `SELECT count(*) FROM auth_audit_log WHERE action = 'webhook_created' AND target_type = 'org' AND target_id = $1`, + orgID, + ).Scan(&auditCount); err != nil { + t.Fatalf("query webhook audit: %v", err) + } + if auditCount != 1 { + t.Fatalf("webhook_created audit rows=%d, want 1", auditCount) + } +} + +func TestOrgWebhooksRejectRepoOwnedHookID(t *testing.T) { + t.Parallel() + ctx := context.Background() + pool := dbtest.NewTestDB(t) + ownerID := insertOrgAvatarUser(t, pool, "owner") + orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme") + repoID := insertOrgAuditOrgRepo(t, pool, orgID, "project") + var repoHookID int64 + if err := pool.QueryRow(ctx, + `INSERT INTO webhooks ( + owner_kind, owner_id, url, content_type, events, + secret_ciphertext, secret_nonce, active, ssl_verification, + auto_disable_threshold, created_by_user_id + ) VALUES ( + 'repo', $1, 'https://hooks.example.com/repo', 'json', ARRAY[]::text[], + '\x01'::bytea, '\x02'::bytea, true, true, 50, $2 + ) RETURNING id`, + repoID, ownerID, + ).Scan(&repoHookID); err != nil { + t.Fatalf("seed repo webhook: %v", err) + } + h := newOrgActionsHandler(t, pool) + mux := chi.NewRouter() + mux.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + viewer := middleware.CurrentUser{ID: ownerID, Username: "owner"} + next.ServeHTTP(w, r.WithContext(middleware.WithCurrentUserForTest(r.Context(), viewer))) + }) + }) + h.MountCreate(mux) + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/organizations/acme/settings/hooks/"+strconv.FormatInt(repoHookID, 10), nil) + mux.ServeHTTP(resp, req) + if resp.Code != http.StatusNotFound { + t.Fatalf("GET repo-owned hook through org settings status=%d body=%s", resp.Code, resp.Body.String()) + } +} + func newOrgActionsHandler(t *testing.T, pool *pgxpool.Pool) *orgsh.Handlers { t.Helper() tmplFS := fstest.MapFS{ @@ -388,6 +522,10 @@ func newOrgActionsHandler(t *testing.T, pool *pgxpool.Pool) *orgsh.Handlers { "orgs/settings_secret_patterns.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ with .WritesDisabledMessage }}LOCK={{ . }}{{ end }}{{ range .Patterns }}PATTERN={{ .Name }}:{{ .Enabled }};{{ end }}{{ end }}`)}, "orgs/settings_security.html": {Data: []byte(`{{ define "page" }}{{ with .Notice }}NOTICE={{ . }}{{ end }}SETTING={{ .Settings.RequireTwoFactor }}{{ end }}`)}, "orgs/settings_audit.html": {Data: []byte(`{{ define "page" }}{{ range .Rows }}ROW={{ .Action }}|{{ .TargetType }}|{{ if .TargetID.Valid }}{{ .TargetID.Int64 }}{{ end }};{{ end }}FILTERS={{ .Filters }};{{ end }}`)}, + "orgs/settings_hooks.html": {Data: []byte(`{{ define "page" }}{{ with .SetupError }}SETUP={{ . }}{{ end }}{{ range .Webhooks }}HOOK={{ .Url }}|{{ .OwnerKind }}|{{ .OwnerID }};{{ end }}{{ end }}`)}, + "orgs/settings_hook_new.html": {Data: []byte(`{{ define "page" }}{{ with .Error }}ERROR={{ . }}{{ end }}{{ end }}`)}, + "orgs/settings_hook_edit.html": {Data: []byte(`{{ define "page" }}WEBHOOK={{ .Webhook.Url }};{{ range .Deliveries }}DELIVERY={{ .EventKind }}|{{ .Status }};{{ end }}{{ end }}`)}, + "orgs/settings_hook_delivery.html": {Data: []byte(`{{ define "page" }}DELIVERY={{ .Delivery.EventKind }};{{ .PayloadPretty }}{{ end }}`)}, "errors/403.html": {Data: []byte(`{{ define "page" }}403{{ end }}`)}, "errors/404.html": {Data: []byte(`{{ define "page" }}404{{ end }}`)}, "errors/500.html": {Data: []byte(`{{ define "page" }}500{{ end }}`)}, @@ -410,6 +548,7 @@ func newOrgActionsHandler(t *testing.T, pool *pgxpool.Pool) *orgsh.Handlers { Pool: pool, ObjectStore: storage.NewMemoryStore(), SecretBox: box, + WebhookSSRF: webhook.SSRFConfig{AllowPrivateNetworks: true}, }) if err != nil { t.Fatalf("orgsh.New: %v", err) diff --git a/internal/web/handlers/orgs/settings_webhooks.go b/internal/web/handlers/orgs/settings_webhooks.go new file mode 100644 index 00000000..cb4003ad --- /dev/null +++ b/internal/web/handlers/orgs/settings_webhooks.go @@ -0,0 +1,408 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package orgs + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/tenseleyFlow/shithub/internal/auth/audit" + orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc" + "github.com/tenseleyFlow/shithub/internal/web/middleware" + "github.com/tenseleyFlow/shithub/internal/webhook" + webhookdb "github.com/tenseleyFlow/shithub/internal/webhook/sqlc" +) + +func (h *Handlers) settingsHooks(w http.ResponseWriter, r *http.Request) { + org, ok := h.loadOrgSettingsOwner(w, r) + if !ok { + return + } + if h.d.SecretBox == nil { + h.renderOrgWebhooksList(w, r, org, nil, "Webhook delivery requires the at-rest secret key.", "") + return + } + hooks, err := webhookdb.New().ListWebhooksForOwner(r.Context(), h.d.Pool, webhookdb.ListWebhooksForOwnerParams{ + OwnerKind: webhookdb.WebhookOwnerKindOrg, + OwnerID: org.ID, + }) + if err != nil { + h.d.Logger.WarnContext(r.Context(), "org webhooks: list", "org_id", org.ID, "error", err) + hooks = nil + } + h.renderOrgWebhooksList(w, r, org, hooks, "", orgWebhookNoticeMessage(r.URL.Query().Get("notice"))) +} + +func (h *Handlers) settingsHookNew(w http.ResponseWriter, r *http.Request) { + org, ok := h.loadOrgSettingsOwner(w, r) + if !ok { + return + } + if h.d.SecretBox == nil { + h.renderOrgWebhooksList(w, r, org, nil, "Webhook delivery requires the at-rest secret key.", "") + return + } + h.renderOrgWebhookForm(w, r, org, nil, "") +} + +func (h *Handlers) settingsHookCreate(w http.ResponseWriter, r *http.Request) { + org, ok := h.loadOrgSettingsOwner(w, r) + if !ok { + return + } + if h.d.SecretBox == nil { + http.Error(w, "webhook key not configured", http.StatusServiceUnavailable) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "form parse", http.StatusBadRequest) + return + } + viewer := middleware.CurrentUserFromContext(r.Context()) + params := webhook.CreateParams{ + OwnerKind: "org", + OwnerID: org.ID, + URL: strings.TrimSpace(r.PostFormValue("url")), + ContentType: orgWebhookContentType(r.PostFormValue("content_type")), + Events: orgWebhookEvents(r.PostFormValue("events")), + Secret: strings.TrimSpace(r.PostFormValue("secret")), + Active: r.PostFormValue("active") == "on", + SSL: r.PostFormValue("ssl_verification") == "on" || r.PostFormValue("ssl_verification") == "", + ActorUserID: viewer.ID, + } + created, err := webhook.Create(r.Context(), webhook.ManageDeps{ + Pool: h.d.Pool, SecretBox: h.d.SecretBox, SSRF: h.webhookSSRFConfig(), + }, params) + if err != nil { + h.renderOrgWebhookForm(w, r, org, &orgWebhookFormState{ + URL: params.URL, ContentType: params.ContentType, Events: params.Events, + Active: params.Active, SSL: params.SSL, + }, friendlyOrgWebhookError(err)) + return + } + h.recordOrgWebhookAudit(r, viewer, audit.ActionWebhookCreated, org.ID, map[string]any{ + "webhook_id": created.ID, + "url": params.URL, + }) + http.Redirect(w, r, orgHooksPath(org.Slug)+"?notice=saved", http.StatusSeeOther) +} + +func (h *Handlers) settingsHookEdit(w http.ResponseWriter, r *http.Request) { + org, ok := h.loadOrgSettingsOwner(w, r) + if !ok { + return + } + hook, ok := h.loadOwnedOrgWebhook(w, r, org.ID) + if !ok { + return + } + deliveries, _ := webhookdb.New().ListDeliveriesForWebhook(r.Context(), h.d.Pool, webhookdb.ListDeliveriesForWebhookParams{ + WebhookID: hook.ID, + Limit: 50, + }) + h.d.Render.RenderPage(w, r, "orgs/settings_hook_edit", map[string]any{ + "Title": org.Slug + " · webhook", + "CSRFToken": middleware.CSRFTokenForRequest(r), + "Org": org, + "Webhook": hook, + "EventsCSV": strings.Join(hook.Events, ", "), + "Deliveries": deliveries, + "OrgSettingsActive": "integrations", + }) +} + +func (h *Handlers) settingsHookUpdate(w http.ResponseWriter, r *http.Request) { + org, ok := h.loadOrgSettingsOwner(w, r) + if !ok { + return + } + hook, ok := h.loadOwnedOrgWebhook(w, r, org.ID) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "form parse", http.StatusBadRequest) + return + } + params := webhook.UpdateParams{ + URL: strings.TrimSpace(r.PostFormValue("url")), + ContentType: orgWebhookContentType(r.PostFormValue("content_type")), + Events: orgWebhookEvents(r.PostFormValue("events")), + Active: r.PostFormValue("active") == "on", + SSL: r.PostFormValue("ssl_verification") == "on" || r.PostFormValue("ssl_verification") == "", + NewSecret: strings.TrimSpace(r.PostFormValue("new_secret")), + } + if err := webhook.Update(r.Context(), webhook.ManageDeps{ + Pool: h.d.Pool, SecretBox: h.d.SecretBox, SSRF: h.webhookSSRFConfig(), + }, hook.ID, params); err != nil { + http.Error(w, friendlyOrgWebhookError(err), http.StatusBadRequest) + return + } + viewer := middleware.CurrentUserFromContext(r.Context()) + h.recordOrgWebhookAudit(r, viewer, audit.ActionWebhookUpdated, org.ID, map[string]any{"webhook_id": hook.ID}) + http.Redirect(w, r, orgHookPath(org.Slug, hook.ID)+"?notice=saved", http.StatusSeeOther) +} + +func (h *Handlers) settingsHookDelete(w http.ResponseWriter, r *http.Request) { + org, ok := h.loadOrgSettingsOwner(w, r) + if !ok { + return + } + hook, ok := h.loadOwnedOrgWebhook(w, r, org.ID) + if !ok { + return + } + if err := webhook.Delete(r.Context(), webhook.ManageDeps{Pool: h.d.Pool, SecretBox: h.d.SecretBox}, hook.ID); err != nil { + http.Error(w, "delete failed", http.StatusInternalServerError) + return + } + viewer := middleware.CurrentUserFromContext(r.Context()) + h.recordOrgWebhookAudit(r, viewer, audit.ActionWebhookDeleted, org.ID, map[string]any{ + "webhook_id": hook.ID, + "url": hook.Url, + }) + http.Redirect(w, r, orgHooksPath(org.Slug)+"?notice=saved", http.StatusSeeOther) +} + +func (h *Handlers) settingsHookToggle(w http.ResponseWriter, r *http.Request) { + org, ok := h.loadOrgSettingsOwner(w, r) + if !ok { + return + } + hook, ok := h.loadOwnedOrgWebhook(w, r, org.ID) + if !ok { + return + } + newActive := !hook.Active + if err := webhook.SetActive(r.Context(), webhook.ManageDeps{Pool: h.d.Pool, SecretBox: h.d.SecretBox}, hook.ID, newActive); err != nil { + http.Error(w, "toggle failed", http.StatusInternalServerError) + return + } + viewer := middleware.CurrentUserFromContext(r.Context()) + action := audit.ActionWebhookActiveSet + if !newActive { + action = audit.ActionWebhookActiveUnset + } + h.recordOrgWebhookAudit(r, viewer, action, org.ID, map[string]any{"webhook_id": hook.ID}) + http.Redirect(w, r, orgHookPath(org.Slug, hook.ID)+"?notice=saved", http.StatusSeeOther) +} + +func (h *Handlers) settingsHookPing(w http.ResponseWriter, r *http.Request) { + org, ok := h.loadOrgSettingsOwner(w, r) + if !ok { + return + } + hook, ok := h.loadOwnedOrgWebhook(w, r, org.ID) + if !ok { + return + } + if err := webhook.EnqueuePing(r.Context(), webhook.FanoutDeps{ + Pool: h.d.Pool, Logger: h.d.Logger, + }, hook.ID); err != nil { + h.d.Logger.WarnContext(r.Context(), "org webhook ping", "webhook_id", hook.ID, "error", err) + } + viewer := middleware.CurrentUserFromContext(r.Context()) + h.recordOrgWebhookAudit(r, viewer, audit.ActionWebhookPinged, org.ID, map[string]any{"webhook_id": hook.ID}) + http.Redirect(w, r, orgHookPath(org.Slug, hook.ID)+"?notice=saved", http.StatusSeeOther) +} + +func (h *Handlers) settingsHookDelivery(w http.ResponseWriter, r *http.Request) { + org, ok := h.loadOrgSettingsOwner(w, r) + if !ok { + return + } + hook, ok := h.loadOwnedOrgWebhook(w, r, org.ID) + if !ok { + return + } + deliveryID, err := strconv.ParseInt(chi.URLParam(r, "deliveryID"), 10, 64) + if err != nil || deliveryID <= 0 { + http.Error(w, "bad delivery id", http.StatusBadRequest) + return + } + delivery, err := webhookdb.New().GetDeliveryByID(r.Context(), h.d.Pool, deliveryID) + if err != nil || delivery.WebhookID != hook.ID { + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") + return + } + h.d.Render.RenderPage(w, r, "orgs/settings_hook_delivery", map[string]any{ + "Title": "Delivery #" + strconv.FormatInt(delivery.ID, 10), + "CSRFToken": middleware.CSRFTokenForRequest(r), + "Org": org, + "Webhook": hook, + "Delivery": delivery, + "PayloadPretty": prettyOrgWebhookJSON(delivery.Payload), + "ResponseBody": string(delivery.ResponseBody), + "OrgSettingsActive": "integrations", + }) +} + +func (h *Handlers) settingsHookRedeliver(w http.ResponseWriter, r *http.Request) { + org, ok := h.loadOrgSettingsOwner(w, r) + if !ok { + return + } + hook, ok := h.loadOwnedOrgWebhook(w, r, org.ID) + if !ok { + return + } + originalID, err := strconv.ParseInt(chi.URLParam(r, "deliveryID"), 10, 64) + if err != nil || originalID <= 0 { + http.Error(w, "bad delivery id", http.StatusBadRequest) + return + } + orig, err := webhookdb.New().GetDeliveryByID(r.Context(), h.d.Pool, originalID) + if err != nil || orig.WebhookID != hook.ID { + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") + return + } + newID, err := webhook.Redeliver(r.Context(), webhook.FanoutDeps{Pool: h.d.Pool, Logger: h.d.Logger}, originalID) + if err != nil { + http.Error(w, "redeliver failed", http.StatusInternalServerError) + return + } + viewer := middleware.CurrentUserFromContext(r.Context()) + h.recordOrgWebhookAudit(r, viewer, audit.ActionWebhookRedelivered, org.ID, map[string]any{ + "webhook_id": hook.ID, + "original_delivery_id": originalID, + "new_delivery_id": newID, + }) + http.Redirect(w, r, orgHookDeliveryPath(org.Slug, hook.ID, newID), http.StatusSeeOther) +} + +func (h *Handlers) renderOrgWebhooksList(w http.ResponseWriter, r *http.Request, org orgsdb.Org, hooks []webhookdb.Webhook, setupErr, notice string) { + h.d.Render.RenderPage(w, r, "orgs/settings_hooks", map[string]any{ + "Title": org.Slug + " · Webhooks", + "CSRFToken": middleware.CSRFTokenForRequest(r), + "Org": org, + "Webhooks": hooks, + "SetupError": setupErr, + "Notice": notice, + "OrgSettingsActive": "integrations", + }) +} + +type orgWebhookFormState struct { + URL string + ContentType string + Events []string + Active bool + SSL bool +} + +func (h *Handlers) renderOrgWebhookForm(w http.ResponseWriter, r *http.Request, org orgsdb.Org, state *orgWebhookFormState, errMsg string) { + if state == nil { + state = &orgWebhookFormState{Active: true, SSL: true, ContentType: "json"} + } + h.d.Render.RenderPage(w, r, "orgs/settings_hook_new", map[string]any{ + "Title": org.Slug + " · New webhook", + "CSRFToken": middleware.CSRFTokenForRequest(r), + "Org": org, + "Form": state, + "EventsCSV": strings.Join(state.Events, ", "), + "Error": errMsg, + "OrgSettingsActive": "integrations", + }) +} + +func (h *Handlers) loadOwnedOrgWebhook(w http.ResponseWriter, r *http.Request, orgID int64) (webhookdb.Webhook, bool) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil || id <= 0 { + http.Error(w, "bad id", http.StatusBadRequest) + return webhookdb.Webhook{}, false + } + hook, err := webhookdb.New().GetWebhookByID(r.Context(), h.d.Pool, id) + if err != nil { + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") + return webhookdb.Webhook{}, false + } + if hook.OwnerKind != webhookdb.WebhookOwnerKindOrg || hook.OwnerID != orgID { + h.d.Render.HTTPError(w, r, http.StatusNotFound, "") + return webhookdb.Webhook{}, false + } + return hook, true +} + +func (h *Handlers) webhookSSRFConfig() webhook.SSRFConfig { + return h.d.WebhookSSRF +} + +func (h *Handlers) recordOrgWebhookAudit(r *http.Request, viewer middleware.CurrentUser, action audit.Action, orgID int64, meta map[string]any) { + auditActor, auditMeta := viewer.AuditActor(meta) + _ = h.d.Audit.Record(r.Context(), h.d.Pool, auditActor, action, audit.TargetOrg, orgID, auditMeta) +} + +func orgWebhookContentType(s string) string { + switch strings.TrimSpace(s) { + case "form": + return "form" + default: + return "json" + } +} + +func orgWebhookEvents(s string) []string { + return splitOrgWebhookList(s) +} + +func splitOrgWebhookList(s string) []string { + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +func friendlyOrgWebhookError(err error) string { + switch { + case errors.Is(err, webhook.ErrBadURL): + return "URL must be http or https with a host." + case errors.Is(err, webhook.ErrBadContentType): + return "Content type must be json or form." + case errors.Is(err, webhook.ErrBadEvent): + return "Event names must be 1–64 lowercase characters." + } + return err.Error() +} + +func orgWebhookNoticeMessage(code string) string { + switch code { + case "saved": + return "Webhook settings saved." + default: + return "" + } +} + +func prettyOrgWebhookJSON(raw []byte) string { + var v any + if err := json.Unmarshal(raw, &v); err != nil { + return string(raw) + } + out, err := json.MarshalIndent(v, "", " ") + if err != nil { + return string(raw) + } + return string(out) +} + +func orgHooksPath(slug string) string { + return "/organizations/" + slug + "/settings/hooks" +} + +func orgHookPath(slug string, hookID int64) string { + return orgHooksPath(slug) + "/" + strconv.FormatInt(hookID, 10) +} + +func orgHookDeliveryPath(slug string, hookID, deliveryID int64) string { + return orgHookPath(slug, hookID) + "/deliveries/" + strconv.FormatInt(deliveryID, 10) +} diff --git a/internal/web/templates/_org_settings_nav.html b/internal/web/templates/_org_settings_nav.html index 8ec37661..18a2f65f 100644 --- a/internal/web/templates/_org_settings_nav.html +++ b/internal/web/templates/_org_settings_nav.html @@ -23,7 +23,7 @@

Access

{{ else }} {{ octicon "credit-card" }} Billing and licensing {{ end }} - {{ octicon "gear" }} Integrations + {{ octicon "gear" }} Integrations {{ octicon "history" }} Audit log diff --git a/internal/web/templates/orgs/settings_hook_delivery.html b/internal/web/templates/orgs/settings_hook_delivery.html new file mode 100644 index 00000000..75d46f25 --- /dev/null +++ b/internal/web/templates/orgs/settings_hook_delivery.html @@ -0,0 +1,56 @@ +{{ define "page" -}} +
+
+ +
+ +
+ {{ template "org-settings-nav" . }} + +
+
+

Delivery #{{ .Delivery.ID }}

+ +
+ +
+

Summary

+
    +
  • Event: {{ .Delivery.EventKind }}
  • +
  • Status: {{ .Delivery.Status }}
  • +
  • Attempt: {{ .Delivery.Attempt }} of {{ .Delivery.MaxAttempts }}
  • +
  • Started: {{ relativeTime .Delivery.StartedAt.Time }}
  • + {{ if .Delivery.CompletedAt.Valid }}
  • Completed: {{ relativeTime .Delivery.CompletedAt.Time }}
  • {{ end }} + {{ if .Delivery.NextRetryAt.Valid }}
  • Next retry: {{ relativeTime .Delivery.NextRetryAt.Time }}
  • {{ end }} + {{ if .Delivery.ResponseStatus.Valid }}
  • HTTP response: {{ .Delivery.ResponseStatus.Int32 }}{{ if .Delivery.ResponseTruncated }} (body truncated){{ end }}
  • {{ end }} + {{ if .Delivery.RedeliverOf.Valid }}
  • Redelivery of #{{ .Delivery.RedeliverOf.Int64 }}
  • {{ end }} + {{ if .Delivery.ErrorSummary.Valid }}
  • Error: {{ .Delivery.ErrorSummary.String }}
  • {{ end }} +
+
+ + +
+
+ +
+

Payload

+
{{ .PayloadPretty }}
+
+ +
+

Response

+ {{ if .ResponseBody }} +
{{ .ResponseBody }}
+ {{ else }} +

No response body recorded.

+ {{ end }} +
+
+
+
+{{- end }} diff --git a/internal/web/templates/orgs/settings_hook_edit.html b/internal/web/templates/orgs/settings_hook_edit.html new file mode 100644 index 00000000..0feafe92 --- /dev/null +++ b/internal/web/templates/orgs/settings_hook_edit.html @@ -0,0 +1,114 @@ +{{ define "page" -}} +
+
+ +
+ +
+ {{ template "org-settings-nav" . }} + +
+
+

Edit webhook

+ +
+ +
+

Configuration

+
+ + + + + +
+ Options + + +
+ +
+
+ +
+

Test and lifecycle

+
+ + +
+
+ + +
+
+ + +
+ {{ if .Webhook.DisabledAt.Valid }} +

Auto-disabled {{ relativeTime .Webhook.DisabledAt.Time }}: {{ .Webhook.DisabledReason.String }}

+ {{ end }} +
+ +
+

Recent deliveries

+ {{ if .Deliveries }} + + + + + + {{ range .Deliveries }} + + + + + + + + {{ end }} + +
EventStatusStarted
+ {{ if eq (printf "%s" .Status) "succeeded" }}✓ + {{ else if eq (printf "%s" .Status) "failed_permanent" }}✗ + {{ else if eq (printf "%s" .Status) "failed_retry" }}… + {{ else }}·{{ end }} + {{ .EventKind }} + {{ if .ResponseStatus.Valid }}HTTP {{ .ResponseStatus.Int32 }}{{ else }}{{ .Status }}{{ end }} + {{ if .RedeliverOf.Valid }}(redelivery){{ end }} + {{ relativeTime .StartedAt.Time }} + View +
+ {{ else }} +

No deliveries yet.

+ {{ end }} +
+
+
+
+{{- end }} diff --git a/internal/web/templates/orgs/settings_hook_new.html b/internal/web/templates/orgs/settings_hook_new.html new file mode 100644 index 00000000..83724f74 --- /dev/null +++ b/internal/web/templates/orgs/settings_hook_new.html @@ -0,0 +1,64 @@ +{{ define "page" -}} +
+
+ +
+ +
+ {{ template "org-settings-nav" . }} + +
+
+

Add webhook

+
Create an organization-level subscriber for repository events.
+
+ {{ with .Error }}{{ end }} + +
+
+ + + + + +
+ Options + + +
+ + Cancel +
+
+
+
+
+{{- end }} diff --git a/internal/web/templates/orgs/settings_hooks.html b/internal/web/templates/orgs/settings_hooks.html new file mode 100644 index 00000000..becb0345 --- /dev/null +++ b/internal/web/templates/orgs/settings_hooks.html @@ -0,0 +1,67 @@ +{{ define "page" -}} +
+
+ +
+ +
+ {{ template "org-settings-nav" . }} + +
+ {{ with .Notice }}

{{ . }}

{{ end }} + {{ with .SetupError }}{{ end }} + +
+

Webhooks

+
Send repository events from this organization to external services.
+
+ +
+

+ Organization webhooks receive events for repositories owned by this organization. + Each delivery includes X-Shithub-Signature-256 for receiver-side verification. +

+ {{ if not .SetupError }} +

+ Add webhook +

+ {{ end }} + + {{ if .Webhooks }} + + + + + + {{ range .Webhooks }} + + + + + + + {{ end }} + +
URLEventsStateLast activity
{{ .Url }}{{ if .Events }}{{ range $i, $e := .Events }}{{ if $i }}, {{ end }}{{ $e }}{{ end }}{{ else }}all{{ end }} + {{ if .DisabledAt.Valid }}auto-disabled + {{ else if .Active }}active + {{ else }}inactive{{ end }} + {{ if gt .ConsecutiveFailures 0 }} · {{ .ConsecutiveFailures }} consecutive failures{{ end }} + + {{ if .LastSuccessAt.Valid }}✓ {{ relativeTime .LastSuccessAt.Time }} + {{ else if .LastFailureAt.Valid }}✗ {{ relativeTime .LastFailureAt.Time }} + {{ else }}no deliveries yet{{ end }} +
+ {{ else }} +

No webhooks configured.

+ {{ end }} +
+
+
+
+{{- end }} From 9c8154b3f7ea3ed1ad3fc8a5af11a1dd8c5aca70 Mon Sep 17 00:00:00 2001 From: espadonne Date: Wed, 27 May 2026 20:02:06 -0400 Subject: [PATCH 2/2] Document app-style organization integrations --- docs/internal/billing.md | 10 +++--- docs/internal/integrations.md | 64 +++++++++++++++++++++++++++++++++++ docs/public/api/webhooks.md | 7 ++-- docs/public/user/webhooks.md | 9 +++-- 4 files changed, 81 insertions(+), 9 deletions(-) create mode 100644 docs/internal/integrations.md diff --git a/docs/internal/billing.md b/docs/internal/billing.md index 96ac9c74..5c27acda 100644 --- a/docs/internal/billing.md +++ b/docs/internal/billing.md @@ -119,7 +119,7 @@ Rules for paid-org copy: | Audit log API | Deferred | Deferred | Later Enterprise feature | | SBOMs | Public repositories and personal repos | SPDX JSON generation/storage for private org repos | Contact sales | | Artifact attestations | Public repositories and personal repos | Store/download in-toto statements for private org repos | Contact sales | -| GitHub Apps / app-style integrations | Planned | Planned | Contact sales | +| App-style integrations | Organization webhooks and external check runs | Organization webhooks, external check runs, and required checks for private org repositories | Contact sales | | Status checks | Included through branch protection | Included through branch protection | Contact sales | | Pre-receive hooks | Deferred | Deferred | Enterprise Server planning item | | Pages | Deferred until static Pages hosting is active | Deferred until static Pages hosting is active | Deferred | @@ -228,9 +228,11 @@ Present but still moving toward full enforcement: rows from `/organizations/{org}/settings/audit-log` and export the filtered scope as capped CSV, repository SBOMs generate/download SPDX JSON from stored dependency snapshots, repository artifact attestations - store/download in-toto Statement JSON documents, GitHub Apps remain - planned, and audit-log API/pre-receive hooks remain Enterprise/deferred - placement. + store/download in-toto Statement JSON documents, and organization + webhooks plus external check runs provide the first app-style + integration surface. Full GitHub Apps installation/auth remains + planned, and audit-log API/pre-receive hooks remain + Enterprise/deferred placement. - Codespaces are not implemented. S41 Actions runner workspaces are ephemeral CI execution directories and must not be represented as hosted development environments. PAYMENTS SP28 marks this as a diff --git a/docs/internal/integrations.md b/docs/internal/integrations.md new file mode 100644 index 00000000..9b4532dd --- /dev/null +++ b/docs/internal/integrations.md @@ -0,0 +1,64 @@ +# Integrations + +shithub's first app-style integration surface is deliberately narrow: +signed webhooks plus external check runs. This gives organizations the +core automation loop without claiming full GitHub Apps parity. + +## Organization Webhooks + +Organization owners can manage webhooks at: + +- `GET /organizations/{org}/settings/hooks` +- `GET /organizations/{org}/settings/hooks/new` +- `POST /organizations/{org}/settings/hooks` +- `GET /organizations/{org}/settings/hooks/{id}` + +The handlers store rows in `webhooks` with `owner_kind = 'org'` and +`owner_id = org.id`. Repo domain events for repositories owned by that +organization fan out to both repo-level webhooks and matching org-level +webhooks. User-owned repositories do not fan out to organization +webhooks. + +Webhook secrets are encrypted at rest with `internal/auth/secretbox`. +Create and update paths run the same SSRF validation as repo webhooks: +scheme/port checks, DNS resolution, and private/loopback address +rejection unless the operator explicitly configures an allow-list. + +Every lifecycle action records an org-targeted audit row: + +- `webhook_created` +- `webhook_updated` +- `webhook_deleted` +- `webhook_active_set` +- `webhook_active_unset` +- `webhook_pinged` +- `webhook_redelivered` + +## External Check Runs + +The Checks API remains the current integration point for CI systems. +External systems post check runs with `app_slug = 'external'`, and +branch protection evaluates named check-run contexts through the policy +and branch-protection gates. This is not a GitHub App installation +model; it is the baseline external status/check mechanism. + +## Billing Placement + +Organization webhooks and external check runs are baseline +functionality. Team plan value comes from using these integrations +against private organization repositories alongside protected branches, +required checks, Actions settings, security scanning, SBOMs, and +artifact attestations. + +## Deferred GitHub Apps Parity + +Still deferred: + +- app registration with manifests and callback URLs; +- installation records per organization/repository; +- app-scoped authentication, JWT signing, and installation tokens; +- per-app permission grants and webhook event subscriptions; +- marketplace/listing/review flows. + +Until those ship, product copy should say "app-style integrations" or +"organization webhooks and checks" rather than "GitHub Apps". diff --git a/docs/public/api/webhooks.md b/docs/public/api/webhooks.md index 90f53474..52c70a3e 100644 --- a/docs/public/api/webhooks.md +++ b/docs/public/api/webhooks.md @@ -1,8 +1,9 @@ # Webhooks -The webhook **delivery format** (payloads, signing) and the -webhook **management API** (CRUD over webhooks on a repo) are -both shipped. The management endpoints are PAT-authenticated and +The webhook **delivery format** (payloads, signing), the repository +webhook **management API**, and the organization-owner web UI for +organization webhooks are shipped. The REST management endpoints are +currently repo-scoped, PAT-authenticated, and share the canonical [API conventions](overview.md) (JSON error envelopes, `X-RateLimit-*`, `X-OAuth-Scopes`, `Link` pagination). diff --git a/docs/public/user/webhooks.md b/docs/public/user/webhooks.md index 57cec6fa..ca4b7691 100644 --- a/docs/public/user/webhooks.md +++ b/docs/public/user/webhooks.md @@ -1,8 +1,13 @@ # Webhooks Webhooks send HTTP POSTs to your URL when something happens in a -repo (push, PR opened, issue commented, etc.). Configured at -Repository → Settings → Webhooks → "Add webhook". +repo (push, PR opened, issue commented, etc.). + +- Repository webhooks are configured at Repository → Settings → + Webhooks → "Add webhook". +- Organization webhooks are configured at Organization settings → + Integrations → Webhooks. They receive events for repositories owned + by that organization. ## Configuration