diff --git a/modules/github-events/README.md b/modules/github-events/README.md index 36409c51..1f40007e 100644 --- a/modules/github-events/README.md +++ b/modules/github-events/README.md @@ -159,6 +159,7 @@ No requirements. | [additional\_webhook\_secrets](#input\_additional\_webhook\_secrets) | Additional secrets to be used by the service.

- key: Local identifier for the secret. This will be prefixed with WEBHOOK\_SECRET\_ in the service's environment vars.
- secret: The name of the secret in Cloud Secret Manager. Format: {secretName} if the secret is in the same project. projects/{project}/secrets/{secretName} if the secret is in a different project.
- version: The version of the secret to use. Can be a number or 'latest'.

See https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/cloud_run_v2_service#nested_env for related documentation. |
map(object({
secret = string
version = string
}))
| `{}` | no | | [deletion\_protection](#input\_deletion\_protection) | Whether to enable delete protection for the service. | `bool` | `true` | no | | [enable\_profiler](#input\_enable\_profiler) | Enable cloud profiler. | `bool` | `false` | no | +| [github\_organizations](#input\_github\_organizations) | csv string of GitHub organizations to allow. | `string` | `""` | no | | [ingress](#input\_ingress) | An object holding the name of the ingress service, which can be used to authorize callers to publish cloud events. |
object({
name = string
})
| n/a | yes | | [max\_delivery\_attempts](#input\_max\_delivery\_attempts) | The maximum number of delivery attempts for any event. | `number` | `5` | no | | [name](#input\_name) | n/a | `string` | n/a | yes | diff --git a/modules/github-events/cmd/trampoline/main.go b/modules/github-events/cmd/trampoline/main.go index faa4d6a6..d96a12c6 100644 --- a/modules/github-events/cmd/trampoline/main.go +++ b/modules/github-events/cmd/trampoline/main.go @@ -38,6 +38,7 @@ var env = envconfig.MustProcess(context.Background(), &struct { RequestedOnly []string `env:"REQUESTED_ONLY_WEBHOOK_ID"` // If set, only events from the specified webhook IDs will be processed. WebhookID []string `env:"WEBHOOK_ID"` + OrgFilter []string `env:"GITHUB_ORGANIZATIONS_FILTER"` }{}) func main() { @@ -58,7 +59,12 @@ func main() { srv := &http.Server{ Addr: fmt.Sprintf(":%d", env.Port), ReadHeaderTimeout: 10 * time.Second, - Handler: httpmetrics.Handler("trampoline", trampoline.NewServer(ceclient, secrets, env.WebhookID, env.RequestedOnly)), + Handler: httpmetrics.Handler("trampoline", trampoline.NewServer(ceclient, trampoline.ServerOptions{ + Secrets: secrets, + WebhookID: env.WebhookID, + RequestedOnlyWebhook: env.RequestedOnly, + OrgFilter: env.OrgFilter, + })), } clog.FatalContextf(ctx, "ListenAndServe: %v", srv.ListenAndServe()) } diff --git a/modules/github-events/internal/trampoline/server.go b/modules/github-events/internal/trampoline/server.go index c371f53d..9e9817d6 100644 --- a/modules/github-events/internal/trampoline/server.go +++ b/modules/github-events/internal/trampoline/server.go @@ -8,6 +8,7 @@ import ( "io" "mime" "net/http" + "strings" "time" "github.com/chainguard-dev/clog" @@ -24,14 +25,23 @@ type Server struct { // If webhookID is empty, the trampoline will listen to all events. webhookID []string requestedOnlyWebhook []string + orgFilter []string } -func NewServer(client cloudevents.Client, secrets [][]byte, webhookID []string, requestedOnlyWebhook []string) *Server { +type ServerOptions struct { + Secrets [][]byte + WebhookID []string + RequestedOnlyWebhook []string + OrgFilter []string +} + +func NewServer(client cloudevents.Client, opts ServerOptions) *Server { return &Server{ client: client, - secrets: secrets, - requestedOnlyWebhook: requestedOnlyWebhook, - webhookID: webhookID, + secrets: opts.Secrets, + requestedOnlyWebhook: opts.RequestedOnlyWebhook, + webhookID: opts.WebhookID, + orgFilter: opts.OrgFilter, clock: clockwork.NewRealClock(), } } @@ -102,6 +112,22 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { log = log.With("action", msg.Action, "repo", msg.Repository.FullName) } + // Filter webhook at org level. + if len(s.orgFilter) > 0 { + found := false + for _, org := range s.orgFilter { + if strings.HasPrefix(msg.Repository.FullName, org+"/") { + found = true + break + } + } + if !found { + log.Warnf("ignoring event from repository %q due to non-matching org", msg.Repository.FullName) + w.WriteHeader(http.StatusAccepted) + return + } + } + log.Debugf("forwarding event: %s", t) event := cloudevents.NewEvent() diff --git a/modules/github-events/internal/trampoline/server_test.go b/modules/github-events/internal/trampoline/server_test.go index 21412347..d63f7b9f 100644 --- a/modules/github-events/internal/trampoline/server_test.go +++ b/modules/github-events/internal/trampoline/server_test.go @@ -36,10 +36,13 @@ func TestTrampoline(t *testing.T) { secret := []byte("hunter2") clock := clockwork.NewFakeClock() - impl := NewServer(client, [][]byte{ - []byte("badsecret"), // This secret should be ignored - secret, - }, nil, nil) + opts := ServerOptions{ + Secrets: [][]byte{ + []byte("badsecret"), // This secret should be ignored + secret, + }, + } + impl := NewServer(client, opts) impl.clock = clock srv := httptest.NewServer(impl) @@ -127,7 +130,7 @@ func sendevent(t *testing.T, client *http.Client, url string, eventType string, } func TestForbidden(t *testing.T) { - srv := httptest.NewServer(NewServer(&fakeClient{}, nil, nil, nil)) + srv := httptest.NewServer(NewServer(&fakeClient{}, ServerOptions{})) defer srv.Close() // Doesn't really matter what we send, we just want to ensure we get a forbidden response @@ -142,7 +145,11 @@ func TestForbidden(t *testing.T) { func TestWebhookIDFilter(t *testing.T) { secret := []byte("hunter2") - srv := httptest.NewServer(NewServer(&fakeClient{}, [][]byte{secret}, []string{"doesnotmatch"}, nil)) + opts := ServerOptions{ + Secrets: [][]byte{secret}, + WebhookID: []string{"doesnotmatch"}, + } + srv := httptest.NewServer(NewServer(&fakeClient{}, opts)) defer srv.Close() // Send an event with the requested action @@ -157,12 +164,19 @@ func TestWebhookIDFilter(t *testing.T) { func TestRequestedOnlyWebhook(t *testing.T) { secret := []byte("hunter2") - srv := httptest.NewServer(NewServer(&fakeClient{}, [][]byte{secret}, nil, []string{"1234"})) + opts := ServerOptions{ + Secrets: [][]byte{secret}, + RequestedOnlyWebhook: []string{"1234"}, + } + srv := httptest.NewServer(NewServer(&fakeClient{}, opts)) defer srv.Close() // Send an event with the requested action resp, err := sendevent(t, srv.Client(), srv.URL, "check_run", map[string]interface{}{ "action": "requested", + "repository": map[string]interface{}{ + "full_name": "org/repo", + }, }, secret) if err != nil { t.Fatalf("error sending event: %v", err) @@ -180,3 +194,37 @@ func TestRequestedOnlyWebhook(t *testing.T) { t.Fatalf("unexpected status: %v", resp.Status) } } + +func TestOrgFilter(t *testing.T) { + secret := []byte("hunter2") + opts := ServerOptions{ + Secrets: [][]byte{secret}, + OrgFilter: []string{"org"}, + } + srv := httptest.NewServer(NewServer(&fakeClient{}, opts)) + defer srv.Close() + + // Send an event with the requested action + resp, err := sendevent(t, srv.Client(), srv.URL, "pull_request", map[string]interface{}{ + "action": "opened", + }, secret) + if err != nil { + t.Fatalf("error sending event: %v", err) + } + if resp.StatusCode != http.StatusAccepted { + t.Fatalf("unexpected status: %v", resp.Status) + } + + resp, err = sendevent(t, srv.Client(), srv.URL, "pull_request", map[string]interface{}{ + "action": "opened", + "repository": map[string]interface{}{ + "full_name": "org/repo", + }, + }, secret) + if err != nil { + t.Fatalf("error sending event: %v", err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %v", resp.Status) + } +} diff --git a/modules/github-events/main.tf b/modules/github-events/main.tf index 76fd8b9b..d4e85899 100644 --- a/modules/github-events/main.tf +++ b/modules/github-events/main.tf @@ -45,15 +45,21 @@ module "this" { } ports = [{ container_port = 8080 }] env = concat( - [{ - name = "WEBHOOK_SECRET" - value_source = { - secret_key_ref = { - secret = module.webhook-secret.secret_id - version = "latest" + [ + { + name = "WEBHOOK_SECRET" + value_source = { + secret_key_ref = { + secret = module.webhook-secret.secret_id + version = "latest" + } } - } - }], + }, + { + name = "GITHUB_ORGANIZATIONS_FILTER" + value = var.github_organizations + }, + ], [for name, secret in var.additional_webhook_secrets : { name = "WEBHOOK_SECRET_${upper(name)}" value_source = { diff --git a/modules/github-events/variables.tf b/modules/github-events/variables.tf index a10bfea2..cd3f0b49 100644 --- a/modules/github-events/variables.tf +++ b/modules/github-events/variables.tf @@ -57,7 +57,7 @@ Additional secrets to be used by the service. See https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/cloud_run_v2_service#nested_env for related documentation. EOD - default = {} + default = {} } variable "service-ingress" { @@ -93,3 +93,9 @@ variable "squad" { error_message = "squad needs to specified or disable check by setting require_squad = false" } } + +variable "github_organizations" { + description = "csv string of GitHub organizations to allow." + type = string + default = "" +}