Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs/internal/billing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions docs/internal/integrations.md
Original file line number Diff line number Diff line change
@@ -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".
7 changes: 4 additions & 3 deletions docs/public/api/webhooks.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
9 changes: 7 additions & 2 deletions docs/public/user/webhooks.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
23 changes: 20 additions & 3 deletions internal/web/handlers/orgs/orgs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.",
Expand Down
139 changes: 139 additions & 0 deletions internal/web/handlers/orgs/settings_actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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{
Expand All @@ -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 }}`)},
Expand All @@ -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)
Expand Down
Loading
Loading