Skip to content

Commit

Permalink
github-events: Add GITHUB_ORGANIZATIONS_FILTER (#690)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
wlynch authored Jan 13, 2025
1 parent 33b4f50 commit 576a3dd
Show file tree
Hide file tree
Showing 6 changed files with 114 additions and 21 deletions.
1 change: 1 addition & 0 deletions modules/github-events/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
8 changes: 7 additions & 1 deletion modules/github-events/cmd/trampoline/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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())
}
34 changes: 30 additions & 4 deletions modules/github-events/internal/trampoline/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"mime"
"net/http"
"strings"
"time"

"github.com/chainguard-dev/clog"
Expand All @@ -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(),
}
}
Expand Down Expand Up @@ -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()
Expand Down
62 changes: 55 additions & 7 deletions modules/github-events/internal/trampoline/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)
}
}
22 changes: 14 additions & 8 deletions modules/github-events/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
8 changes: 7 additions & 1 deletion modules/github-events/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -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 = ""
}

0 comments on commit 576a3dd

Please sign in to comment.