From afc99c5707a7d3378a845b66681777ad6c63c887 Mon Sep 17 00:00:00 2001
From: Billy Lynch <billy@chainguard.dev>
Date: Mon, 13 Jan 2025 12:35:25 -0500
Subject: [PATCH] github-events: Add GITHUB_ORGANIZATIONS_FILTER

Adds filter for only listening to events from specific orgs. In order to
install an App on multiple orgs (e.g. chainguard-dev, wolfi-os), we need to make it public.
This restricts the trampoline to only listen and forward to specific
orgs. If omitted, all events are forwarded.
---
 modules/github-events/README.md               |  1 +
 modules/github-events/cmd/trampoline/main.go  |  8 ++-
 .../internal/trampoline/server.go             | 34 ++++++++--
 .../internal/trampoline/server_test.go        | 62 ++++++++++++++++---
 modules/github-events/main.tf                 | 22 ++++---
 modules/github-events/variables.tf            |  8 ++-
 6 files changed, 114 insertions(+), 21 deletions(-)

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.
 | <a name="input_additional_webhook_secrets"></a> [additional\_webhook\_secrets](#input\_additional\_webhook\_secrets) | Additional secrets to be used by the service.<br/><br/>- key: Local identifier for the secret. This will be prefixed with WEBHOOK\_SECRET\_ in the service's environment vars.<br/>- 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.<br/>- version: The version of the secret to use. Can be a number or 'latest'.<br/><br/>See https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/cloud_run_v2_service#nested_env for related documentation. | <pre>map(object({<br/>    secret  = string<br/>    version = string<br/>  }))</pre> | `{}` | no |
 | <a name="input_deletion_protection"></a> [deletion\_protection](#input\_deletion\_protection) | Whether to enable delete protection for the service. | `bool` | `true` | no |
 | <a name="input_enable_profiler"></a> [enable\_profiler](#input\_enable\_profiler) | Enable cloud profiler. | `bool` | `false` | no |
+| <a name="input_github_organizations"></a> [github\_organizations](#input\_github\_organizations) | csv string of GitHub organizations to allow. | `string` | `""` | no |
 | <a name="input_ingress"></a> [ingress](#input\_ingress) | An object holding the name of the ingress service, which can be used to authorize callers to publish cloud events. | <pre>object({<br/>    name = string<br/>  })</pre> | n/a | yes |
 | <a name="input_max_delivery_attempts"></a> [max\_delivery\_attempts](#input\_max\_delivery\_attempts) | The maximum number of delivery attempts for any event. | `number` | `5` | no |
 | <a name="input_name"></a> [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     = ""
+}