From 405abc5d801272ce2fcaae9a1df2d8973daaf4dc Mon Sep 17 00:00:00 2001 From: Brian Ojeda <9335829+sgtoj@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:30:45 -0500 Subject: [PATCH 1/3] ci: lint, scan, and build in ci, release binaries, and fix dependabot CI runs golangci-lint, a go mod tidy check, the tests with the race detector, govulncheck, linux amd64 and arm64 builds of the server and the lambda function, and a container image build, on every pull request instead of only those targeting main. Every action is pinned to a commit of its latest release, the go version comes from go.mod, and checkouts don't persist credentials. Dependabot never ran: its config was at .github/.dependabot.yaml, a path GitHub doesn't read. It moves to .github/dependabot.yaml, groups its updates, and also watches the Dockerfile's base images. The release rules listed feat:minor and refactor:minor. A custom rule that matches a commit's type wins over the commit analyzer's "breaking change -> major" rule, so a feat! or refactor! commit was released as a minor version. Those rules are removed. The release now computes the version first (dry_run), builds the server and lambda binaries for amd64 and arm64 and the container image, and only then creates the tag, so a failed build leaves no tag behind. The semantic check no longer checks out code. It runs on pull_request_target, with a token that can write to the repository, and the action doesn't need the code. The Makefile gains help, lint, fmt, vuln, tidy, check, cover, image, and local-run targets, runs tests with the race detector, and stamps the version into the binaries. build-server is renamed build and writes dist/github-ops-app, and the lambda binary is built with the lambda.norpc tag for the provided.al2023 runtime. Co-Authored-By: Claude Opus 5.5 --- .devcontainer/devcontainer.json | 28 +++++ .dockerignore | 14 ++- .github/.dependabot.yaml | 11 -- .github/dependabot.yaml | 26 +++++ .github/workflows/ci.yaml | 87 ++++++++++------ .github/workflows/release.yaml | 52 +++++++++- .github/workflows/semantic-check.yaml | 4 +- .gitignore | 18 ++-- .golangci.yml | 51 ++++++++++ Makefile | 141 +++++++++++++++++++------- 10 files changed, 331 insertions(+), 101 deletions(-) create mode 100644 .devcontainer/devcontainer.json delete mode 100644 .github/.dependabot.yaml create mode 100644 .github/dependabot.yaml create mode 100644 .golangci.yml diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..79959d8 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,28 @@ +{ + "name": "github-ops-app", + "image": "mcr.microsoft.com/devcontainers/base:bookworm", + "features": { + "ghcr.io/devcontainers/features/go:1": { + "version": "1.27" + }, + "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {} + }, + "forwardPorts": [8080], + "postCreateCommand": "go mod download", + "customizations": { + "vscode": { + "extensions": [ + "editorconfig.editorconfig", + "github.vscode-github-actions", + "golang.go" + ], + "settings": { + "editor.formatOnSave": true, + "go.lintTool": "golangci-lint-v2", + "[markdown]": { + "editor.rulers": [80] + } + } + } + } +} diff --git a/.dockerignore b/.dockerignore index 509f8f4..7fda26e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,11 @@ -.local/ +.git +.github +.devcontainer +.worktrees +.local .env -dist/ -tmp/ -.git/ +dist +release +tmp +coverage.out +.DS_Store diff --git a/.github/.dependabot.yaml b/.github/.dependabot.yaml deleted file mode 100644 index f3f3cd3..0000000 --- a/.github/.dependabot.yaml +++ /dev/null @@ -1,11 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "daily" - - - package-ecosystem: "gomod" - directory: "/" - schedule: - interval: "weekly" diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..4bd58b6 --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,26 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + github-actions: + patterns: ["*"] + + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + groups: + go-modules: + patterns: ["*"] + + # base images in the Dockerfile + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + groups: + docker: + patterns: ["*"] diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d166b19..db0a1ee 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -5,63 +5,86 @@ on: branches: - main pull_request: - branches: - - main permissions: contents: read +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: lint: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v4 - + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: '1.24' - - - name: Run go vet - run: go vet ./... - - - name: Check formatting - run: | - unformatted=$(gofmt -l .) - if [ -n "$unformatted" ]; then - echo "The following files are not formatted:" - echo "$unformatted" - exit 1 - fi + go-version-file: go.mod + - name: Check go.mod is tidy + run: go mod tidy -diff + - name: Lint + run: make lint test: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v4 - + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: '1.24' - - - name: Run unit tests + go-version-file: go.mod + - name: Test run: make test - - name: Run integration tests - run: make test-verify-verbose + vuln: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - name: Check for known vulnerabilities + run: make vuln build: runs-on: ubuntu-latest + strategy: + matrix: + goarch: [amd64, arm64] steps: - name: Checkout Code - uses: actions/checkout@v4 - + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Go - uses: actions/setup-go@v5 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: - go-version: '1.24' - + go-version-file: go.mod + - name: Build Server + run: make build GOOS=linux GOARCH=${{ matrix.goarch }} - name: Build Lambda - run: GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -C cmd/lambda -o ../../dist/bootstrap + run: make build-lambda GOARCH=${{ matrix.goarch }} + + image: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup Buildx + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - name: Build Container Image + run: docker buildx build --platform linux/amd64,linux/arm64 --build-arg VERSION=ci . diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1f3031f..f28434e 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -8,22 +8,64 @@ on: permissions: contents: write +concurrency: + group: release + cancel-in-progress: false + jobs: release: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v3 - - name: Bump Version + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: false + # dry_run only computes the version; the release step creates the tag + # after the artifacts build, so a failed build leaves no tag behind + - name: Compute Version id: tag_version - uses: mathieudutour/github-tag-action@v6.1 + uses: mathieudutour/github-tag-action@af99e60ce8132224b8e6ebab5023449fe256ed46 # v7.0.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} + dry_run: true default_bump: minor - custom_release_rules: bug:patch:Fixes,chore:patch:Chores,docs:patch:Documentation,feat:minor:Features,refactor:minor:Refactors,test:patch:Tests,ci:patch:Development,dev:patch:Development + # a custom rule that matches a commit's type wins over the default + # "breaking change -> major" rule, so types that carry breaking + # changes (feat, fix, refactor) must not be listed here. feat and + # fix use the default rules, and refactor uses default_bump. + custom_release_rules: bug:patch:Fixes,chore:patch:Chores,docs:patch:Documentation,test:patch:Tests,ci:patch:Development,dev:patch:Development + - name: Build Artifacts + if: steps.tag_version.outputs.new_tag != '' + env: + VERSION: ${{ steps.tag_version.outputs.new_tag }} + run: | + mkdir -p release + for arch in amd64 arm64; do + make build GOOS=linux GOARCH="$arch" VERSION="$VERSION" + tar -czf "release/github-ops-app_linux_${arch}.tar.gz" -C dist github-ops-app + make build-lambda GOARCH="$arch" VERSION="$VERSION" + (cd dist && zip -X -q "../release/github-ops-app_lambda_${arch}.zip" bootstrap) + done + (cd release && sha256sum *.tar.gz *.zip > checksums.txt) + - name: Setup Buildx + if: steps.tag_version.outputs.new_tag != '' + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - name: Build Container Image + if: steps.tag_version.outputs.new_tag != '' + env: + VERSION: ${{ steps.tag_version.outputs.new_tag }} + run: docker buildx build --platform linux/amd64,linux/arm64 --build-arg VERSION="$VERSION" . - name: Create Release - uses: ncipollo/release-action@v1.12.0 + if: steps.tag_version.outputs.new_tag != '' + uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0 with: tag: ${{ steps.tag_version.outputs.new_tag }} + commit: ${{ github.sha }} name: ${{ steps.tag_version.outputs.new_tag }} body: ${{ steps.tag_version.outputs.changelog }} + artifacts: release/* diff --git a/.github/workflows/semantic-check.yaml b/.github/workflows/semantic-check.yaml index 2e5d44f..7de9607 100644 --- a/.github/workflows/semantic-check.yaml +++ b/.github/workflows/semantic-check.yaml @@ -15,9 +15,7 @@ jobs: name: Semantic Commit Message Check runs-on: ubuntu-latest steps: - - name: Checkout Code - uses: actions/checkout@v3 - - uses: amannn/action-semantic-pull-request@v5.2.0 + - uses: amannn/action-semantic-pull-request@2252a14e810aa9b85c2d560bd7070c802206ecf6 # v6.1.2 name: Check PR for Semantic Commit Message env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index ebcac20..5c20ec9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,16 @@ -!**/.gitkeep - -tmp/ +# build output dist/ -.DS_Store +release/ +coverage.out +/github-ops-app +/server -.local/ +# local settings and secrets .env +.local/ +tmp/ -cognito-hooks-go -main +# worktrees +.worktrees/ +.DS_Store diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..66de33c --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,51 @@ +version: "2" + +run: + timeout: 5m + +linters: + default: standard # errcheck, govet, ineffassign, staticcheck, unused + enable: + - bodyclose + - errorlint + - gocritic + - misspell + - nilerr + - noctx + - revive + - sloglint + - unconvert + - unparam + - usestdlibvars + settings: + errcheck: + exclude-functions: + - (net/http.ResponseWriter).Write + - io.WriteString + revive: + rules: + - name: exported + disabled: true # internal packages; doc comments where they help + - name: package-comments + disabled: true + sloglint: + kv-only: true + key-naming-case: snake + exclusions: + presets: + - std-error-handling + rules: + - path: _test\.go + linters: + - errcheck + - unparam + - noctx + +formatters: + enable: + - gofmt + - goimports + settings: + goimports: + local-prefixes: + - github.com/cruxstack/github-ops-app diff --git a/Makefile b/Makefile index cdbd6db..4224bb5 100644 --- a/Makefile +++ b/Makefile @@ -1,57 +1,120 @@ -# set mac-only linker flags only for go test (not global) -UNAME_S := $(shell uname -s) -TEST_ENV := -ifeq ($(UNAME_S),Darwin) - TEST_ENV = CGO_LDFLAGS=-w -endif +SHELL := /bin/bash +.DEFAULT_GOAL := help +# build settings; release builds pass GOOS=linux and each GOARCH +GOOS ?= $(shell go env GOOS) +GOARCH ?= $(shell go env GOARCH) +VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) +DIST := dist +BINARY := github-ops-app +LDFLAGS := -s -w -X main.version=$(VERSION) + +# tool versions, run with "go run" so they are built with this module's go +GOLANGCI_LINT := go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.15.0 +GOVULNCHECK := go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 + +# the race detector on macos needs this linker flag with some xcode versions +TEST_ENV := $(if $(filter Darwin,$(shell uname -s)),CGO_LDFLAGS=-w,) TEST_FLAGS := -race -count=1 -.PHONY: build-lambda -build-lambda: - GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o dist/bootstrap ./cmd/lambda +# local runs +COMPOSE ?= docker compose +BASE_URL ?= http://localhost:8080 +URL_PATH = $(patsubst %/,%,$(APP_BASE_PATH)) -.PHONY: build-server -build-server: - CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o dist/server ./cmd/server +.PHONY: help +help: ## show this help + @awk 'BEGIN {FS = ":.*## "} /^[a-zA-Z0-9_-]+:.*## / {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) -.PHONY: build-debug -build-debug: - GOOS=$(GOOS) GOARCH=$(GOARCH) go build -trimpath -ldflags "-s -w" -o dist/sample ./cmd/sample +##@ build -.PHONY: debug -debug: - go run ./cmd/sample +.PHONY: build +build: ## build the server binary (dist/github-ops-app) for GOOS and GOARCH + GOOS=$(GOOS) GOARCH=$(GOARCH) CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o $(DIST)/$(BINARY) ./cmd/server + +.PHONY: build-lambda +build-lambda: ## build the lambda bootstrap binary (dist/bootstrap) for linux and GOARCH + GOOS=linux GOARCH=$(GOARCH) CGO_ENABLED=0 go build -trimpath -tags lambda.norpc -ldflags "$(LDFLAGS)" -o $(DIST)/bootstrap ./cmd/lambda + +.PHONY: image +image: ## build the container image (github-ops-app:dev) + docker build --build-arg VERSION=$(VERSION) -t $(BINARY):dev . + +.PHONY: clean +clean: ## remove build output + rm -rf $(DIST) coverage.out -.PHONY: server -server: - go run ./cmd/server +##@ test .PHONY: test -test: +test: ## run all tests with the race detector $(TEST_ENV) go test $(TEST_FLAGS) ./... .PHONY: test-unit -test-unit: - $(TEST_ENV) go test $(TEST_FLAGS) ./internal/... +test-unit: ## run unit tests + $(TEST_ENV) go test $(TEST_FLAGS) ./internal/... ./cmd/... + +.PHONY: test-e2e +test-e2e: ## run the scenarios in fixtures/scenarios.json against mock apis + $(TEST_ENV) go test $(TEST_FLAGS) -v ./e2e + +.PHONY: cover +cover: ## run tests with coverage and print a summary + go test -count=1 -coverprofile=coverage.out -coverpkg=./internal/... ./... + go tool cover -func=coverage.out | tail -1 -.PHONY: test-verify -test-verify: - go run ./cmd/verify +##@ quality -.PHONY: test-verify-verbose -test-verify-verbose: - go run ./cmd/verify -verbose +.PHONY: lint +lint: ## run golangci-lint + $(GOLANGCI_LINT) run ./... -.PHONY: server-up -server-up: - docker compose up -d --build +.PHONY: fmt +fmt: ## format code + $(GOLANGCI_LINT) fmt ./... -.PHONY: server-logs -server-logs: - docker compose logs -f server +.PHONY: vuln +vuln: ## check for known vulnerabilities + $(GOVULNCHECK) ./... -.PHONY: server-stop -server-stop: - docker compose down --rmi local --remove-orphans +.PHONY: tidy +tidy: ## tidy go.mod and go.sum + go mod tidy +.PHONY: check +check: lint test vuln ## run lint, tests, and the vulnerability check, as CI does + go mod tidy -diff + +##@ local runs + +.PHONY: run +run: ## run the server locally with go run, reading .env + go run github.com/joho/godotenv/cmd/godotenv@v1.5.1 -f .env go run ./cmd/server + +.PHONY: up +up: ## start the server in docker compose and wait until it is healthy + $(COMPOSE) up --build --detach --wait server + +.PHONY: down +down: ## stop docker compose + $(COMPOSE) down --remove-orphans + +.PHONY: logs +logs: ## follow the server's output + $(COMPOSE) logs --follow server + +.PHONY: health +health: ## check a running server's readiness (BASE_URL, APP_BASE_PATH) + curl -fsS "$(BASE_URL)$(URL_PATH)/readyz" + +.PHONY: okta-sync +okta-sync: ## run an okta sync on a running server (BASE_URL, APP_ADMIN_TOKEN) + curl -fsS -X POST -H "Authorization: Bearer $(APP_ADMIN_TOKEN)" "$(BASE_URL)$(URL_PATH)/scheduled/okta-sync" + +.PHONY: slack-test +slack-test: ## send sample notifications from a running server (BASE_URL, APP_ADMIN_TOKEN) + curl -fsS -X POST -H "Authorization: Bearer $(APP_ADMIN_TOKEN)" "$(BASE_URL)$(URL_PATH)/scheduled/slack-test" + +.PHONY: sample +sample: ## send fixtures/samples.json through the app with .env; calls live apis + go run ./cmd/sample From c9d512d194fbe37bfcb863b5785a969141ccbfd5 Mon Sep 17 00:00:00 2001 From: Brian Ojeda <9335829+sgtoj@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:31:02 -0500 Subject: [PATCH 2/3] refactor!: adopt github-app-setup-go, update dependencies, and fix bugs Rework the service around github-app-setup-go, update every dependency, and fix the security and correctness problems a full review found. Dependencies: - go 1.24.4 to 1.27.3; go-github v79 to v93, okta-sdk-golang v6 to v8, slack-go v0.17.3 to v0.35.0, and every other module to its latest - adopt github-app-setup-go v0.7.1, and drop cockroachdb/errors for the standard library, which removes sentry-go and its dependencies; govulncheck reported 11 reachable vulnerabilities before and none after Structure: - internal/service runs both entry points on a github-app-setup-go runtime: the server loads at startup and reloads on SIGHUP or when the installer saves credentials, and lambda loads on first use and retries a failed load on the next invocation, instead of failing every invocation until the container is recycled - an optional manifest installer at /setup creates the GitHub App and saves its credentials to an env file or SSM; once an app is registered it refuses new codes - internal/config validates everything at startup and reports every problem at once; any APP_ value may be an SSM ARN, in any partition - the installation is looked up from the org when no id is set - /healthz and /readyz; JSON responses; the scenarios in fixtures/scenarios.json run as go tests in e2e/, replacing cmd/verify - the server waits for running syncs on shutdown; the image runs as a non-root user Security: - webhooks must be signed; the admin endpoints are disabled without APP_ADMIN_TOKEN, which is compared in constant time; EventBridge invocations run scheduled actions without it - the app manifest is private, and events from other orgs are ignored - slack text from GitHub and Okta is escaped, and okta usernames are validated as GitHub logins before they are used in API paths - auth headers are sent only to the GitHub API host, and the installation token cache no longer races Fixes: - the manifest used permission names GitHub doesn't know and lacked administration and statuses read, so branch protection and commit statuses were silently skipped; read errors now fail the check - PR compliance: read every page of reviews, statuses, and check runs; comments don't revoke approvals; neutral and skipped checks pass; maintainers are recognized; every bypass is reported, not only admins' - security alerts: read every page, not only the first 100 of each kind - okta sync: keep PASSWORD_EXPIRED, LOCKED_OUT, and RECOVERY users; honor create_team_if_missing; find teams whose slug differs from their name; don't re-invite pending invitees or remove child-team members, and don't report a 404 as a removal; report groups whose members can't be read; don't sync two groups into one team; retry rate-limited requests - syncs requested while one runs are coalesced and the follow-up always runs; webhook-triggered syncs run only for teams a rule manages, only for renames among edits, and after the response in server mode - orphaned users: reuse the synced member lists, and skip detection rather than report the members of a team that couldn't be found - slack: fit reports to Slack's block and text limits, and time out requests - lambda: pass query strings, cookies, and base64 bodies through from API Gateway; ping and unhandled events no longer return 400 BREAKING CHANGE: APP_DEBUG_ENABLED, APP_PORT, and APP_OKTA_KEY_ID are replaced by APP_LOG_LEVEL, APP_LISTEN_ADDR, and APP_OKTA_PRIVATE_KEY_ID, and setting an old name stops the service. The webhook secret is required, the admin API is disabled without a token, invalid or partial configuration stops the service, create_team_if_missing defaults to false, and the GitHub App needs administration and commit statuses read. See docs/upgrading-to-v1.md. Co-Authored-By: Claude Opus 5.5 --- .env.example | 77 +- Dockerfile | 38 +- assets/github/manifest.json | 19 +- assets/slack/manifest.json | 6 +- cmd/lambda/handler.go | 177 ++++ cmd/lambda/handler_test.go | 132 +++ cmd/lambda/main.go | 177 +--- cmd/sample/main.go | 119 +-- cmd/server/main.go | 122 ++- cmd/verify/.env.test | 29 - cmd/verify/README.md | 124 --- cmd/verify/logger.go | 56 -- cmd/verify/main.go | 88 -- cmd/verify/match.go | 49 - cmd/verify/mock.go | 142 --- cmd/verify/scenario.go | 364 ------- cmd/verify/tls.go | 86 -- compose.yaml | 24 +- docs/upgrading-to-v1.md | 136 +++ e2e/mock_test.go | 101 ++ e2e/scenarios_test.go | 326 ++++++ fixtures/scenarios.json | 892 ++++++++++++++++- go.mod | 74 +- go.sum | 198 ++-- internal/app/app.go | 223 +++-- internal/app/app_test.go | 695 ++++++++----- internal/app/factory.go | 70 -- internal/app/handlers.go | 527 ++++++---- internal/app/handlers_test.go | 780 --------------- internal/app/http.go | 171 ++++ internal/app/request.go | 195 ---- internal/app/{testdata.go => samples.go} | 20 +- internal/config/config.go | 935 +++++++++++------- internal/config/config_helpers_test.go | 330 ------- internal/config/config_test.go | 396 +++++--- internal/domain/domain_test.go | 117 +++ internal/domain/errors.go | 69 +- internal/domain/interfaces.go | 47 +- internal/domain/types.go | 186 +++- internal/fakes/fakes.go | 184 ++++ internal/github/client/client.go | 396 ++++---- internal/github/client/client_test.go | 674 +++++++++++++ internal/github/client/pr.go | 293 +++--- internal/github/client/security_alerts.go | 362 +++---- .../github/client/security_alerts_test.go | 70 -- internal/github/client/teams.go | 272 +++-- internal/github/webhooks/webhooks.go | 319 +++--- internal/github/webhooks/webhooks_test.go | 431 +++----- internal/notifiers/slack.go | 161 ++- internal/notifiers/slack_messages.go | 444 +++------ internal/notifiers/slack_messages_test.go | 353 ------- internal/notifiers/slack_test.go | 302 ++++-- internal/okta/client.go | 355 ++----- internal/okta/groups.go | 233 +++-- internal/okta/okta_test.go | 200 ++++ internal/service/service.go | 414 ++++++++ internal/service/service_test.go | 345 +++++++ internal/setup/setup.go | 86 ++ internal/setup/setup_test.go | 72 ++ internal/sync/sync.go | 237 ----- internal/sync/sync_test.go | 394 -------- internal/teamsync/teamsync.go | 214 ++++ internal/teamsync/teamsync_test.go | 202 ++++ 63 files changed, 8614 insertions(+), 6716 deletions(-) create mode 100644 cmd/lambda/handler.go create mode 100644 cmd/lambda/handler_test.go delete mode 100644 cmd/verify/.env.test delete mode 100644 cmd/verify/README.md delete mode 100644 cmd/verify/logger.go delete mode 100644 cmd/verify/main.go delete mode 100644 cmd/verify/match.go delete mode 100644 cmd/verify/mock.go delete mode 100644 cmd/verify/scenario.go delete mode 100644 cmd/verify/tls.go create mode 100644 docs/upgrading-to-v1.md create mode 100644 e2e/mock_test.go create mode 100644 e2e/scenarios_test.go delete mode 100644 internal/app/factory.go delete mode 100644 internal/app/handlers_test.go create mode 100644 internal/app/http.go delete mode 100644 internal/app/request.go rename internal/app/{testdata.go => samples.go} (85%) delete mode 100644 internal/config/config_helpers_test.go create mode 100644 internal/domain/domain_test.go create mode 100644 internal/fakes/fakes.go create mode 100644 internal/github/client/client_test.go delete mode 100644 internal/github/client/security_alerts_test.go delete mode 100644 internal/notifiers/slack_messages_test.go create mode 100644 internal/okta/okta_test.go create mode 100644 internal/service/service.go create mode 100644 internal/service/service_test.go create mode 100644 internal/setup/setup.go create mode 100644 internal/setup/setup_test.go delete mode 100644 internal/sync/sync.go delete mode 100644 internal/sync/sync_test.go create mode 100644 internal/teamsync/teamsync.go create mode 100644 internal/teamsync/teamsync_test.go diff --git a/.env.example b/.env.example index 0f7badb..18d6905 100644 --- a/.env.example +++ b/.env.example @@ -1,50 +1,69 @@ -# debug -APP_DEBUG_ENABLED=false +# every value may instead be an ssm parameter arn, such as +# arn:aws:ssm:us-east-1:123456789012:parameter/github-ops-app/slack-token -# admin token for /server/* and /scheduled/* endpoints (optional) -# when set, requests to these endpoints require "Authorization: Bearer " -# APP_ADMIN_TOKEN=your-secret-admin-token +# logging: debug, info, warn, or error (default: info) +# APP_LOG_LEVEL=info + +# server (cmd/server only) +# APP_LISTEN_ADDR=:8080 +# routes are served under this path, such as an api gateway stage (optional) +# APP_BASE_PATH=/prod +# external url of the service, including APP_BASE_PATH; used by the installer +# APP_PUBLIC_URL=https://github-ops.example.com + +# admin token for /server/* and /scheduled/*, at least 16 characters; the +# admin endpoints are disabled when it is unset +# APP_ADMIN_TOKEN=change-me-to-a-long-random-string # github app (required) +APP_GITHUB_ORG=acme APP_GITHUB_APP_ID=123456 -APP_GITHUB_APP_PRIVATE_KEY_PATH=./.local/private-key.pem -APP_GITHUB_INSTALLATION_ID=987654 -APP_GITHUB_ORG=cruxstack -APP_GITHUB_WEBHOOK_SECRET=your-webhook-secret-here +APP_GITHUB_APP_PRIVATE_KEY_PATH=./.local/github-private-key.pem +# or the key itself, with literal newlines or \n escapes +# APP_GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" +APP_GITHUB_WEBHOOK_SECRET=change-me +# looked up from the organization when unset +# APP_GITHUB_INSTALLATION_ID=987654 +# api url for github enterprise server +# APP_GITHUB_BASE_URL=https://github.example.com/api/v3 + +# github app installer (optional): serves /setup to create the app from a +# manifest and saves its credentials; disable it once the app is created +# GITHUB_APP_INSTALLER_ENABLED=true +# STORAGE_MODE=envfile # envfile or aws-ssm +# STORAGE_DIR=./.env # env file the envfile mode writes +# AWS_SSM_PARAMETER_PREFIX=/github-ops-app/ +# AWS_SSM_KMS_KEY_ID=alias/github-ops-app -# github pr compliance (optional) +# pr compliance (optional) APP_PR_COMPLIANCE_ENABLED=true -APP_PR_MONITORED_BRANCHES=main,master +# branch names or glob patterns +APP_PR_MONITORED_BRANCHES=main,master,release/* -# okta (optional) -APP_OKTA_DOMAIN=company.okta.com +# okta group sync (optional; setting any okta value requires all of them) +APP_OKTA_DOMAIN=acme.okta.com APP_OKTA_CLIENT_ID=0oaxxxxxxxxxxxxxxxxxxxxx APP_OKTA_PRIVATE_KEY_PATH=./.local/okta-private-key.pem +# APP_OKTA_PRIVATE_KEY_ID=kid-from-okta # APP_OKTA_SCOPES=okta.groups.read,okta.users.read - -# okta sync rules APP_OKTA_GITHUB_USER_FIELD=githubUsername -APP_OKTA_SYNC_RULES=[{"name":"sync-eng","enabled":true,"okta_group_pattern":"^github-eng-.*","github_team_prefix":"eng-","strip_prefix":"github-eng-","sync_members":true,"create_team_if_missing":true}] -# APP_OKTA_SYNC_SAFETY_THRESHOLD=0.5 # Prevent mass removal if more than 50% would be removed (default: 0.5) +APP_OKTA_SYNC_RULES=[{"name":"sync-eng","okta_group_pattern":"^github-eng-.*","strip_prefix":"github-eng-","github_team_prefix":"eng-","create_team_if_missing":true}] +# refuse to remove more than this share of a team's members (default: 0.5) +# APP_OKTA_SYNC_SAFETY_THRESHOLD=0.5 +# APP_OKTA_ORPHANED_USER_NOTIFICATIONS=true -# security alerts monitoring (optional) +# stale security alerts (optional) # APP_SECURITY_ALERTS_ENABLED=true -# APP_SECURITY_ALERTS_MIN_AGE_DAYS=30 # only report alerts older than N days (default: 30) -# APP_SECURITY_ALERTS_MIN_SEVERITY=high # minimum severity: critical, high, medium, low (default: high) +# APP_SECURITY_ALERTS_MIN_AGE_DAYS=30 +# APP_SECURITY_ALERTS_MIN_SEVERITY=high -# slack configuration (optional) +# slack (optional; the token and channel are required together) APP_SLACK_TOKEN=xoxb-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx APP_SLACK_CHANNEL=C01234ABCDE -# optional: per-notification-type channels (fall back to APP_SLACK_CHANNEL) +# per notification type; each falls back to APP_SLACK_CHANNEL # APP_SLACK_CHANNEL_PR_BYPASS=C01234ABCDE # APP_SLACK_CHANNEL_OKTA_SYNC=C01234ABCDE # APP_SLACK_CHANNEL_ORPHANED_USERS=C01234ABCDE # APP_SLACK_CHANNEL_SECURITY_ALERTS=C01234ABCDE -# optional: custom footer note for PR bypass notifications (supports Slack mrkdwn) +# footer for pr bypass notifications, in slack mrkdwn # APP_SLACK_FOOTER_NOTE_PR_BYPASS=_Please review the ._ - -# api gateway base path (optional, for lambda deployments with stage prefix) -# APP_BASE_PATH=v1 - -# server port (optional, for cmd/server only, default: 8080) -# APP_PORT=8080 diff --git a/Dockerfile b/Dockerfile index da8effe..36b43e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,39 @@ -FROM golang:1.24 AS builder +# syntax=docker/dockerfile:1 + +# The builder runs on the build machine's platform and cross-compiles for +# the target platform, so multi-platform builds need no emulation. +FROM --platform=$BUILDPLATFORM golang:1.27.3-alpine AS builder WORKDIR /src + COPY go.mod go.sum ./ RUN go mod download + COPY . . -RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /server ./cmd/server -FROM gcr.io/distroless/static-debian12 -COPY --from=builder /server /server -ENTRYPOINT ["/server"] +ARG TARGETOS +ARG TARGETARCH +ARG VERSION=dev +RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \ + go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/server ./cmd/server + +FROM alpine:3.24 + +RUN apk add --no-cache ca-certificates \ + && mkdir -p /data \ + && chown 65532:65532 /data + +WORKDIR /app +COPY --from=builder /out/server /app/server + +# A numeric unprivileged user, so runtimes can verify it isn't root (such as +# Kubernetes runAsNonRoot, which rejects a user given by name). +USER 65532:65532 + +# where the installer saves credentials with STORAGE_MODE=envfile +ENV STORAGE_DIR=/data/.env +VOLUME ["/data"] + +EXPOSE 8080 + +ENTRYPOINT ["/app/server"] diff --git a/assets/github/manifest.json b/assets/github/manifest.json index ac993e2..8531c56 100644 --- a/assets/github/manifest.json +++ b/assets/github/manifest.json @@ -2,21 +2,22 @@ "name": "github-ops-app", "url": "https://github.com/cruxstack/github-ops-app", "hook_attributes": { - "url": "https://your-api-gateway-url.execute-api.region.amazonaws.com/webhook", + "url": "https://your-api-gateway-url.execute-api.region.amazonaws.com/webhooks", "active": true }, "redirect_url": "https://github.com/cruxstack/github-ops-app", - "description": "Bot that automates GitHub operations with Okta integration and Slack notifications", - "public": true, + "description": "Syncs Okta groups to GitHub teams, reports PRs merged without meeting branch requirements, and reports stale security alerts", + "public": false, "default_permissions": { - "organization_administration": "read", + "administration": "read", + "checks": "read", "members": "write", - "contents": "read", + "metadata": "read", "pull_requests": "read", - "checks": "read", - "dependabot_alerts": "read", - "code_scanning_alerts": "read", - "secret_scanning_alerts": "read" + "secret_scanning_alerts": "read", + "security_events": "read", + "statuses": "read", + "vulnerability_alerts": "read" }, "default_events": [ "pull_request", diff --git a/assets/slack/manifest.json b/assets/slack/manifest.json index 8413e5b..15f5b65 100644 --- a/assets/slack/manifest.json +++ b/assets/slack/manifest.json @@ -13,10 +13,8 @@ "oauth_config": { "scopes": { "bot": [ - "channels:join", - "channels:read", - "chat:write.public", - "chat:write" + "chat:write", + "chat:write.public" ] } }, diff --git a/cmd/lambda/handler.go b/cmd/lambda/handler.go new file mode 100644 index 0000000..d9e7cc6 --- /dev/null +++ b/cmd/lambda/handler.go @@ -0,0 +1,177 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "unicode/utf8" + + awsevents "github.com/aws/aws-lambda-go/events" +) + +// scheduler runs scheduled actions. +type scheduler interface { + RunScheduled(ctx context.Context, action string) error +} + +// backend is what the Lambda handler needs from service.Service. +type backend interface { + scheduler + Handler() http.Handler +} + +type handler struct { + svc backend + logger *slog.Logger +} + +func newHandler(svc backend, logger *slog.Logger) *handler { + return &handler{svc: svc, logger: logger} +} + +// event holds the fields that tell the supported event types apart. +type event struct { + RequestContext struct { + HTTP struct { + Method string `json:"method"` + } `json:"http"` + } `json:"requestContext"` + DetailType string `json:"detail-type"` + Source string `json:"source"` +} + +// scheduledDetail is the detail of an EventBridge event that runs a +// scheduled action. +type scheduledDetail struct { + Action string `json:"action"` +} + +// invoke handles one Lambda invocation. +func (h *handler) invoke(ctx context.Context, raw json.RawMessage) (any, error) { + var e event + if err := json.Unmarshal(raw, &e); err != nil { + return nil, fmt.Errorf("failed to decode lambda event: %w", err) + } + + switch { + case e.RequestContext.HTTP.Method != "": + var req awsevents.APIGatewayV2HTTPRequest + if err := json.Unmarshal(raw, &req); err != nil { + return nil, fmt.Errorf("failed to decode api gateway request: %w", err) + } + return h.serveHTTP(ctx, req), nil + + case e.DetailType != "": + var evt awsevents.CloudWatchEvent + if err := json.Unmarshal(raw, &evt); err != nil { + return nil, fmt.Errorf("failed to decode eventbridge event: %w", err) + } + return nil, h.runScheduled(ctx, evt) + + default: + return nil, errors.New("unsupported lambda event: expected an api gateway http api (2.0) request or an eventbridge event") + } +} + +// runScheduled runs the action named in an EventBridge event's detail. +// EventBridge invocations are authorized by AWS, so no admin token is +// needed. +func (h *handler) runScheduled(ctx context.Context, evt awsevents.CloudWatchEvent) error { + var detail scheduledDetail + if err := json.Unmarshal(evt.Detail, &detail); err != nil || detail.Action == "" { + return fmt.Errorf("eventbridge event %s has no action in its detail", evt.ID) + } + if err := h.svc.RunScheduled(ctx, detail.Action); err != nil { + h.logger.ErrorContext(ctx, "scheduled action failed", + "action", detail.Action, + "event_id", evt.ID, + "error", err.Error()) + return fmt.Errorf("scheduled action '%s' failed: %w", detail.Action, err) + } + return nil +} + +// serveHTTP passes an API Gateway request through the HTTP handler. +func (h *handler) serveHTTP(ctx context.Context, e awsevents.APIGatewayV2HTTPRequest) awsevents.APIGatewayV2HTTPResponse { + req, err := toHTTPRequest(ctx, e) + if err != nil { + h.logger.WarnContext(ctx, "invalid api gateway request", "error", err.Error()) + return awsevents.APIGatewayV2HTTPResponse{ + StatusCode: http.StatusBadRequest, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: `{"error":"invalid request"}`, + } + } + rec := httptest.NewRecorder() + h.svc.Handler().ServeHTTP(rec, req) + return toAPIGatewayResponse(rec.Result(), rec.Body.Bytes()) +} + +// toHTTPRequest converts an API Gateway HTTP API request to an +// http.Request. +func toHTTPRequest(ctx context.Context, e awsevents.APIGatewayV2HTTPRequest) (*http.Request, error) { + body := []byte(e.Body) + if e.IsBase64Encoded { + decoded, err := base64.StdEncoding.DecodeString(e.Body) + if err != nil { + return nil, fmt.Errorf("failed to decode base64 body: %w", err) + } + body = decoded + } + + target := e.RawPath + if target == "" { + target = "/" + } + if e.RawQueryString != "" { + target += "?" + e.RawQueryString + } + + req, err := http.NewRequestWithContext(ctx, e.RequestContext.HTTP.Method, target, bytes.NewReader(body)) + if err != nil { + return nil, err + } + // api gateway joins the values of repeated headers with commas + for name, value := range e.Headers { + req.Header.Set(name, value) + } + if len(e.Cookies) > 0 { + req.Header.Set("Cookie", strings.Join(e.Cookies, "; ")) + } + req.Host = req.Header.Get("Host") + if req.Host == "" { + req.Host = e.RequestContext.DomainName + } + req.RemoteAddr = e.RequestContext.HTTP.SourceIP + req.RequestURI = target + return req, nil +} + +// toAPIGatewayResponse converts an HTTP response to an API Gateway response. +func toAPIGatewayResponse(res *http.Response, body []byte) awsevents.APIGatewayV2HTTPResponse { + out := awsevents.APIGatewayV2HTTPResponse{ + StatusCode: res.StatusCode, + Headers: make(map[string]string, len(res.Header)), + } + for name, values := range res.Header { + if http.CanonicalHeaderKey(name) == "Set-Cookie" { + out.Cookies = append(out.Cookies, values...) + continue + } + out.Headers[name] = strings.Join(values, ", ") + } + if utf8.Valid(body) { + out.Body = string(body) + } else { + out.Body = base64.StdEncoding.EncodeToString(body) + out.IsBase64Encoded = true + } + return out +} diff --git a/cmd/lambda/handler_test.go b/cmd/lambda/handler_test.go new file mode 100644 index 0000000..ed30d79 --- /dev/null +++ b/cmd/lambda/handler_test.go @@ -0,0 +1,132 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "strings" + "testing" +) + +type fakeService struct { + handler http.HandlerFunc + actions []string + actionErr error +} + +func (f *fakeService) Handler() http.Handler { return f.handler } + +func (f *fakeService) RunScheduled(_ context.Context, action string) error { + f.actions = append(f.actions, action) + return f.actionErr +} + +func invoke(t *testing.T, svc *fakeService, event string) (any, error) { + t.Helper() + h := newHandler(svc, slog.New(slog.DiscardHandler)) + return h.invoke(context.Background(), json.RawMessage(event)) +} + +func TestInvoke_APIGateway(t *testing.T) { + var got *http.Request + var gotBody string + svc := &fakeService{handler: func(w http.ResponseWriter, r *http.Request) { + got = r + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.Header().Add("X-Multi", "a") + w.Header().Add("X-Multi", "b") + http.SetCookie(w, &http.Cookie{Name: "c1", Value: "v1"}) + http.SetCookie(w, &http.Cookie{Name: "c2", Value: "v2"}) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte{0xff, 0xfe}) + }} + + event := `{ + "version": "2.0", + "rawPath": "/prod/a%20b", + "rawQueryString": "x=1&y=2", + "cookies": ["s=1", "t=2"], + "headers": {"host": "api.example.com", "x-github-event": "ping", "x-forwarded-proto": "https"}, + "body": "` + base64.StdEncoding.EncodeToString([]byte(`{"zen":"hi"}`)) + `", + "isBase64Encoded": true, + "requestContext": {"domainName": "fallback.example.com", "http": {"method": "POST", "sourceIp": "203.0.113.9"}} + }` + out, err := invoke(t, svc, event) + if err != nil { + t.Fatal(err) + } + + if got.Method != http.MethodPost || got.URL.Path != "/prod/a b" || got.URL.RawQuery != "x=1&y=2" { + t.Errorf("request = %s %s ? %s", got.Method, got.URL.Path, got.URL.RawQuery) + } + if gotBody != `{"zen":"hi"}` || got.Host != "api.example.com" || got.RemoteAddr != "203.0.113.9" { + t.Errorf("body %q host %q remote %q", gotBody, got.Host, got.RemoteAddr) + } + if got.Header.Get("X-GitHub-Event") != "ping" || got.Header.Get("Cookie") != "s=1; t=2" { + t.Errorf("headers = %v", got.Header) + } + + raw, _ := json.Marshal(out) + var resp struct { + StatusCode int `json:"statusCode"` + Headers map[string]string `json:"headers"` + Cookies []string `json:"cookies"` + Body string `json:"body"` + IsBase64Encoded bool `json:"isBase64Encoded"` + } + if err := json.Unmarshal(raw, &resp); err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusCreated || resp.Headers["X-Multi"] != "a, b" || len(resp.Cookies) != 2 { + t.Errorf("response = %+v", resp) + } + if !resp.IsBase64Encoded || resp.Body != base64.StdEncoding.EncodeToString([]byte{0xff, 0xfe}) { + t.Errorf("binary body not base64 encoded: %+v", resp) + } +} + +func TestInvoke_APIGatewayBadBody(t *testing.T) { + svc := &fakeService{handler: func(http.ResponseWriter, *http.Request) { t.Error("handler called") }} + out, err := invoke(t, svc, `{"rawPath":"/","body":"%%%","isBase64Encoded":true,"requestContext":{"http":{"method":"POST"}}}`) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(out) + if !strings.Contains(string(raw), `"statusCode":400`) { + t.Errorf("response = %s, want 400", raw) + } +} + +func TestInvoke_EventBridge(t *testing.T) { + svc := &fakeService{} + event := `{"id":"e1","source":"aws.events","detail-type":"Scheduled Event","detail":{"action":"okta-sync"}}` + if _, err := invoke(t, svc, event); err != nil { + t.Fatal(err) + } + if strings.Join(svc.actions, ",") != "okta-sync" { + t.Errorf("actions = %v", svc.actions) + } + + svc.actionErr = errors.New("boom") + if _, err := invoke(t, svc, event); err == nil { + t.Error("failed action returned no error") + } + + if _, err := invoke(t, svc, `{"source":"aws.events","detail-type":"Scheduled Event","detail":{}}`); err == nil { + t.Error("event without an action returned no error") + } +} + +func TestInvoke_Unsupported(t *testing.T) { + if _, err := invoke(t, &fakeService{}, `{"Records":[]}`); err == nil { + t.Error("unsupported event returned no error") + } + if _, err := invoke(t, &fakeService{}, `not json`); err == nil { + t.Error("invalid json returned no error") + } +} diff --git a/cmd/lambda/main.go b/cmd/lambda/main.go index 8d41b21..7363731 100644 --- a/cmd/lambda/main.go +++ b/cmd/lambda/main.go @@ -1,171 +1,42 @@ +// Command lambda runs github-ops-app as an AWS Lambda function. It handles +// API Gateway HTTP API (payload format 2.0) requests, which carry GitHub +// webhooks and admin requests, and EventBridge events, which run scheduled +// actions. package main import ( - "bytes" - "context" - "encoding/json" - "fmt" "log/slog" - "net/http" - "net/http/httptest" - "strings" - "sync" + "os" - awsevents "github.com/aws/aws-lambda-go/events" "github.com/aws/aws-lambda-go/lambda" - "github.com/cockroachdb/errors" - "github.com/cruxstack/github-ops-app/internal/app" - "github.com/cruxstack/github-ops-app/internal/config" -) -var ( - initOnce sync.Once - appInst *app.App - router http.Handler - logger *slog.Logger - initErr error + "github.com/cruxstack/github-ops-app/internal/config" + "github.com/cruxstack/github-ops-app/internal/service" ) -func initApp() { - initOnce.Do(func() { - logger = config.NewLogger() +// version is set at build time with -ldflags "-X main.version=v1.0.0". +var version = "dev" - cfg, err := config.NewConfig() - if err != nil { - initErr = errors.Wrap(err, "config init failed") - return - } - appInst, initErr = app.NewApp(context.Background(), cfg, logger) - if initErr != nil { - return - } - router = appInst.Handler() - }) -} - -// APIGatewayHandler converts API Gateway requests to stdlib *http.Request -// and routes them through the chi router. -func APIGatewayHandler(ctx context.Context, req awsevents.APIGatewayV2HTTPRequest) (awsevents.APIGatewayV2HTTPResponse, error) { - initApp() - if initErr != nil { - logger.Error("initialization failed", slog.String("error", initErr.Error())) - return awsevents.APIGatewayV2HTTPResponse{ - StatusCode: 500, - Body: "service initialization failed", - }, nil - } - - if appInst.Config.DebugEnabled { - j, _ := json.Marshal(req) - logger.Debug("received api gateway request", slog.String("request", string(j))) - } +func main() { + var level slog.LevelVar + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: &level})) + slog.SetDefault(logger) - httpReq, err := http.NewRequestWithContext( - ctx, - req.RequestContext.HTTP.Method, - req.RawPath, - strings.NewReader(req.Body), - ) + boot, err := config.LoadBootstrap(config.ProcessEnv()) if err != nil { - return awsevents.APIGatewayV2HTTPResponse{ - StatusCode: 500, - Body: "failed to construct http request", - }, nil - } - - for key, value := range req.Headers { - httpReq.Header.Set(key, value) + logger.Error("invalid configuration", "version", version, "error", err.Error()) + os.Exit(1) } + level.Set(boot.LogLevel) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, httpReq) - - return awsevents.APIGatewayV2HTTPResponse{ - StatusCode: rec.Code, - Headers: flattenHeaders(rec.Header()), - Body: rec.Body.String(), - }, nil -} - -// EventBridgeHandler converts EventBridge events to POST /scheduled/{action} -// requests and routes them through the chi router. -func EventBridgeHandler(ctx context.Context, evt awsevents.CloudWatchEvent) error { - initApp() - if initErr != nil { - return initErr - } - - if appInst.Config.DebugEnabled { - j, _ := json.Marshal(evt) - logger.Debug("received eventbridge event", slog.String("event", string(j))) - } - - var detail app.ScheduledEvent - if err := json.Unmarshal(evt.Detail, &detail); err != nil { - logger.Error("failed to parse event detail", slog.String("error", err.Error())) - return err - } - - path := fmt.Sprintf("%s/scheduled/%s", appInst.Config.BasePath, detail.Action) - - var body []byte - if detail.Data != nil { - body = detail.Data - } - - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, path, bytes.NewReader(body)) + // the application configuration loads on the first invocation, and is + // retried on later ones until it succeeds + svc, err := service.New(boot, service.Options{Mode: service.ModeLambda, Logger: logger}) if err != nil { - return errors.Wrap(err, "failed to construct http request") - } - - if appInst.Config.AdminToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+appInst.Config.AdminToken) - } - if len(body) > 0 { - httpReq.Header.Set("Content-Type", "application/json") - } - - rec := httptest.NewRecorder() - router.ServeHTTP(rec, httpReq) - - if rec.Code >= 400 { - return errors.Newf("scheduled event failed: %s", rec.Body.String()) - } - - return nil -} - -// UniversalHandler detects event type and routes to the appropriate handler. -func UniversalHandler(ctx context.Context, event json.RawMessage) (any, error) { - initApp() - if initErr != nil { - return nil, initErr + logger.Error("failed to create service", "version", version, "error", err.Error()) + os.Exit(1) } - var apiGatewayReq awsevents.APIGatewayV2HTTPRequest - if err := json.Unmarshal(event, &apiGatewayReq); err == nil && apiGatewayReq.RequestContext.HTTP.Method != "" { - return APIGatewayHandler(ctx, apiGatewayReq) - } - - var eventBridgeEvent awsevents.CloudWatchEvent - if err := json.Unmarshal(event, &eventBridgeEvent); err == nil && eventBridgeEvent.DetailType != "" { - return nil, EventBridgeHandler(ctx, eventBridgeEvent) - } - - return nil, errors.New("unknown lambda event type") -} - -// flattenHeaders converts multi-value http.Header to single-value map. -func flattenHeaders(h http.Header) map[string]string { - flat := make(map[string]string, len(h)) - for key, values := range h { - if len(values) > 0 { - flat[key] = values[0] - } - } - return flat -} - -func main() { - lambda.Start(UniversalHandler) + logger.Info("starting", "version", version) + lambda.Start(newHandler(svc, logger).invoke) } diff --git a/cmd/sample/main.go b/cmd/sample/main.go index 40888c4..b5452e4 100644 --- a/cmd/sample/main.go +++ b/cmd/sample/main.go @@ -1,115 +1,84 @@ -// Package main provides a sample test runner for live API testing. -// WARNING: DO NOT RUN - requires live credentials and makes real API calls -// to GitHub, Okta, and Slack. Use cmd/verify for offline testing instead. +// Command sample sends the samples in fixtures/samples.json through the +// application with the configuration in .env. +// +// WARNING: it uses live credentials and calls the real GitHub, Okta, and +// Slack APIs, which can change team membership. Use the e2e tests for +// offline testing instead. package main import ( "bytes" "context" "encoding/json" + "errors" "fmt" "log/slog" "net/http" "net/http/httptest" "os" - "path/filepath" "github.com/joho/godotenv" "github.com/cruxstack/github-ops-app/internal/app" "github.com/cruxstack/github-ops-app/internal/config" + "github.com/cruxstack/github-ops-app/internal/github/webhooks" ) -func main() { - logger := config.NewLogger() - ctx := context.Background() +type sample struct { + EventType string `json:"event_type"` + Description string `json:"description"` + Payload json.RawMessage `json:"payload,omitempty"` +} - envpath := filepath.Join(".env") - logger.Debug("loading environment", slog.String("path", envpath)) - if _, err := os.Stat(envpath); err == nil { - _ = godotenv.Load(envpath) +func main() { + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + if err := run(context.Background(), logger); err != nil { + logger.Error("sample run failed", "error", err.Error()) + os.Exit(1) } +} - cfg, err := config.NewConfig() +func run(ctx context.Context, logger *slog.Logger) error { + if err := godotenv.Load(".env"); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to read .env: %w", err) + } + cfg, err := config.Load(ctx, config.ProcessEnv()) if err != nil { - logger.Error("failed to load config", slog.String("error", err.Error())) - os.Exit(1) + return err } - - a, err := app.NewApp(ctx, cfg, logger) + a, err := app.New(cfg, logger) if err != nil { - logger.Error("failed to initialize app", slog.String("error", err.Error())) - os.Exit(1) + return err } - router := a.Handler() - - path := filepath.Join("fixtures", "samples.json") - raw, err := os.ReadFile(path) + raw, err := os.ReadFile("fixtures/samples.json") if err != nil { - logger.Error("failed to read samples file", slog.String("error", err.Error())) - os.Exit(1) + return err } - - var samples []map[string]any + var samples []sample if err := json.Unmarshal(raw, &samples); err != nil { - logger.Error("failed to parse samples json", slog.String("error", err.Error())) - os.Exit(1) + return fmt.Errorf("failed to parse samples: %w", err) } - for i, sample := range samples { - eventType, ok := sample["event_type"].(string) - if !ok { - logger.Error("missing or invalid event_type", slog.Int("sample", i)) - os.Exit(1) - } - - switch eventType { + for i, s := range samples { + logger.Info("running sample", "sample", i, "type", s.EventType, "description", s.Description) + switch s.EventType { case "okta_sync": - reqPath := fmt.Sprintf("%s/scheduled/okta-sync", cfg.BasePath) - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqPath, nil) - if err != nil { - logger.Error("failed to construct http request", - slog.Int("sample", i), - slog.String("error", err.Error())) - os.Exit(1) - } - if cfg.AdminToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+cfg.AdminToken) + if err := a.RunScheduled(ctx, app.ActionOktaSync); err != nil { + return fmt.Errorf("sample %d: %w", i, err) } - rec := httptest.NewRecorder() - router.ServeHTTP(rec, httpReq) - if rec.Code >= 400 { - logger.Error("failed to process okta_sync sample", - slog.Int("sample", i), - slog.String("response", rec.Body.String())) - os.Exit(1) - } - case "pr_webhook": - payload, _ := json.Marshal(sample["payload"]) - reqPath := fmt.Sprintf("%s/webhooks", cfg.BasePath) - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, reqPath, bytes.NewReader(payload)) - if err != nil { - logger.Error("failed to construct http request", - slog.Int("sample", i), - slog.String("error", err.Error())) - os.Exit(1) - } - httpReq.Header.Set("X-GitHub-Event", "pull_request") + req := httptest.NewRequestWithContext(ctx, http.MethodPost, cfg.Server.BasePath+"/webhooks", bytes.NewReader(s.Payload)) + req.Header.Set("X-GitHub-Event", webhooks.EventPullRequest) + req.Header.Set("X-Hub-Signature-256", webhooks.Sign(s.Payload, cfg.GitHub.WebhookSecret)) rec := httptest.NewRecorder() - router.ServeHTTP(rec, httpReq) - if rec.Code >= 400 { - logger.Error("failed to process pr_webhook sample", - slog.Int("sample", i), - slog.String("response", rec.Body.String())) - os.Exit(1) + a.Handler().ServeHTTP(rec, req) + if rec.Code >= http.StatusBadRequest { + return fmt.Errorf("sample %d: status %d: %s", i, rec.Code, rec.Body) } - default: - logger.Info("skipping unknown event type", slog.String("event_type", eventType)) + logger.Warn("skipping unknown sample type", "type", s.EventType) } - - logger.Info("processed sample successfully", slog.Int("sample", i)) } + return nil } diff --git a/cmd/server/main.go b/cmd/server/main.go index 23ab07c..58a05e1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,72 +1,112 @@ +// Command server runs github-ops-app as an HTTP server, for containers, +// Kubernetes, or a VM. package main import ( "context" + "errors" + "fmt" "log/slog" + "net" "net/http" "os" "os/signal" "syscall" "time" - "github.com/cruxstack/github-ops-app/internal/app" "github.com/cruxstack/github-ops-app/internal/config" + "github.com/cruxstack/github-ops-app/internal/service" +) + +// version is set at build time with -ldflags "-X main.version=v1.0.0". +var version = "dev" + +// HTTP server timeouts. Webhook payloads can be large and scheduled actions +// run for minutes, so writes may take as long as a job. +const ( + readHeaderTimeout = 10 * time.Second + readTimeout = time.Minute + writeTimeout = service.JobTimeout + 30*time.Second + idleTimeout = 2 * time.Minute + // shutdownTimeout lets running syncs finish rather than stop between + // adding and removing members. The platform's grace period, such as + // Kubernetes' terminationGracePeriodSeconds, caps it. + shutdownTimeout = service.JobTimeout + 30*time.Second ) func main() { - logger := config.NewLogger() - ctx := context.Background() + var level slog.LevelVar + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: &level})) + slog.SetDefault(logger) - cfg, err := config.NewConfig() - if err != nil { - logger.Error("config init failed", slog.String("error", err.Error())) + if err := run(logger, &level); err != nil { + logger.Error("server failed", "version", version, "error", err.Error()) os.Exit(1) } +} - appInst, err := app.NewApp(ctx, cfg, logger) +func run(logger *slog.Logger, level *slog.LevelVar) error { + boot, err := config.LoadBootstrap(config.ProcessEnv()) if err != nil { - logger.Error("app init failed", slog.String("error", err.Error())) - os.Exit(1) + return err } + level.Set(boot.LogLevel) - port := os.Getenv("APP_PORT") - if port == "" { - port = "8080" + svc, err := service.New(boot, service.Options{Mode: service.ModeServer, Logger: logger}) + if err != nil { + return err } + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + // listen before loading the configuration, so health checks and the + // installer respond while it loads + var lc net.ListenConfig + ln, err := lc.Listen(ctx, "tcp", boot.Server.ListenAddr) + if err != nil { + return fmt.Errorf("failed to listen on %s: %w", boot.Server.ListenAddr, err) + } srv := &http.Server{ - Addr: ":" + port, - Handler: appInst.Handler(), - ReadTimeout: 15 * time.Second, - WriteTimeout: 15 * time.Second, - IdleTimeout: 60 * time.Second, + Handler: svc.Handler(), + ReadHeaderTimeout: readHeaderTimeout, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelWarn), } + served := make(chan error, 1) + go func() { served <- srv.Serve(ln) }() + logger.InfoContext(ctx, "listening", + "version", version, + "addr", ln.Addr().String(), + "base_path", boot.Server.BasePath, + "installer", boot.Installer.Enabled) - done := make(chan bool, 1) - quit := make(chan os.Signal, 1) - - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - - go func() { - <-quit - logger.Info("server is shutting down") - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - srv.SetKeepAlivesEnabled(false) - if err := srv.Shutdown(ctx); err != nil { - logger.Error("server shutdown failed", slog.String("error", err.Error())) - } - close(done) - }() + if err := svc.Start(ctx); err != nil { + _ = srv.Close() + return err + } - logger.Info("server starting", slog.String("port", port)) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Error("server failed", slog.String("error", err.Error())) - os.Exit(1) + select { + case err := <-served: + return err + case <-ctx.Done(): } - <-done - logger.Info("server stopped") + logger.InfoContext(ctx, "shutting down") + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), shutdownTimeout) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + _ = srv.Close() + return fmt.Errorf("failed to shut down: %w", err) + } + if err := <-served; !errors.Is(err, http.ErrServerClosed) { + return err + } + if err := svc.Wait(shutdownCtx); err != nil { + return fmt.Errorf("background jobs did not finish: %w", err) + } + logger.InfoContext(ctx, "stopped") + return nil } diff --git a/cmd/verify/.env.test b/cmd/verify/.env.test deleted file mode 100644 index 9a375a7..0000000 --- a/cmd/verify/.env.test +++ /dev/null @@ -1,29 +0,0 @@ -# config used during offline verification testing -# -# - all api calls are mocked and all config is fake -# - private keys are dynamically generated during test setup -# - endpoints use dynamic ports assigned at runtime -# - base urls are set per-scenario by the test runner - -APP_DEBUG_ENABLED=false - -# github configuration -APP_GITHUB_APP_ID=123456 -APP_GITHUB_INSTALLATION_ID=987654 -APP_GITHUB_ORG=acme-ghorg -APP_GITHUB_WEBHOOK_SECRET=test_webhook_secret - -# github pr compliance configuration -APP_PR_COMPLIANCE_ENABLED=true -APP_PR_MONITORED_BRANCHES=main,master - -# okta configuration (oauth2 with private key) -APP_OKTA_DOMAIN=dev-12345.okta.com - -# okta sync rules -APP_OKTA_GITHUB_USER_FIELD=githubUsername -APP_OKTA_SYNC_RULES=[{"enabled":true,"okta_group_name":"Engineering","github_team_name":"engineering","sync_members":true}] - -# slack configuration -APP_SLACK_TOKEN=xoxb-test-token -APP_SLACK_CHANNEL=C01234TEST diff --git a/cmd/verify/README.md b/cmd/verify/README.md deleted file mode 100644 index 79e5271..0000000 --- a/cmd/verify/README.md +++ /dev/null @@ -1,124 +0,0 @@ -# Integration Tests - -Offline integration tests that validate the bot's HTTP interactions with GitHub, Okta, and Slack APIs using local mock servers. - -## How It Works - -Tests run the actual bot code against local HTTPS servers instead of -production APIs: - -1. **Mock servers** start on localhost (ports 9001-9003) with self-signed - TLS certificates -2. **Bot clients** (go-github, okta-sdk, slack-go) are configured via - environment variables to use mock base URLs -3. **Requests** are captured and matched against expected API calls -4. **Responses** return predefined JSON from scenario definitions -5. **Validation** ensures all expected calls were made with correct - parameters - -**Key advantage**: Tests run against production code paths with real SDK -clients—no mocking of `internal/` packages required. - -## Running Tests - -```bash -# all tests -make test-verify - -# verbose output (shows HTTP requests) -make test-verify-verbose - -# single scenario -go run cmd/verify/main.go -filter="okta_sync" - -# custom scenarios file -go run cmd/verify/main.go -scenarios=path/to/scenarios.json -``` - -### Setup - -Copy `.env.example` to `.env` (dummy credentials—never sent to real APIs): -```bash -cp cmd/verify/.env.example cmd/verify/.env -``` - -## Test Scenarios - -Scenarios are defined in `fixtures/scenarios.json` with: - -- **Input event**: Webhook or EventBridge payload -- **Expected API calls**: Method, path, query params, request body -- **Mock responses**: Status code and response body - -### Example Scenario - -```jsonc -{ - "name": "pr_compliance_check", - "event_type": "webhook", - "webhook_type": "pull_request", - "webhook_payload": {"action": "closed", "pull_request": {...}}, - "expected_calls": [ - { - "service": "github", - "method": "GET", - "path": "/repos/*/pulls/*" - } - ], - "mock_responses": [ - { - "service": "github", - "method": "GET", - "path": "/repos/owner/repo/pulls/123", - "status_code": 200, - "body": "{\"number\":123,\"merged\":true}" - } - ] -} -``` - -Path matching supports wildcards (`*`) for dynamic segments like org names, repo names, or IDs. - -## Adding Tests - -1. Add scenario to `fixtures/scenarios.json` -2. Define input event (webhook or scheduled) -3. List expected API calls with paths -4. Provide mock responses -5. Run with `make test-verify` - -## Architecture - -``` -Test Scenario → app.HandleRequest() → SDK Client → Mock Server (localhost:9001-9003) - ↓ - Record Request - ↓ - Return Mock Response - ↓ - Validate Expected Calls Made -``` - -Tests use the unified `app.HandleRequest()` interface, ensuring the same code -paths are exercised as production deployments. - -Base URLs configured via environment (all HTTPS with self-signed certs): -- `APP_GITHUB_BASE_URL` → `https://localhost:9001/` -- `APP_OKTA_BASE_URL` → `https://localhost:9002/` -- `APP_SLACK_API_URL` → `https://localhost:9003/` - -## Debugging - -Failed tests show: -- All captured HTTP requests (method, path, headers, body) -- Missing expected calls -- Unexpected calls made - -Use `-verbose` for real-time request logging during execution. - -## Limitations - -- Fixed ports (9001-9003) prevent parallel test execution -- Tests run serially -- All services use self-signed TLS certificates (auto-generated per test) -- GitHub base URL requires trailing slash diff --git a/cmd/verify/logger.go b/cmd/verify/logger.go deleted file mode 100644 index d7b9f68..0000000 --- a/cmd/verify/logger.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -import ( - "context" - "fmt" - "io" - "log/slog" - "strings" -) - -// testHandler implements slog.Handler for capturing application logs during -// tests. only displays logs in verbose mode. -type testHandler struct { - prefix string - verbose bool - w io.Writer -} - -// Enabled returns true for all log levels when verbose mode is enabled. -func (h *testHandler) Enabled(_ context.Context, _ slog.Level) bool { - return h.verbose -} - -// Handle formats and writes log records to output with test-appropriate -// formatting. -func (h *testHandler) Handle(_ context.Context, r slog.Record) error { - if !h.verbose { - return nil - } - - prefix := h.prefix + "› " - msg := r.Message - - var attrs []string - r.Attrs(func(a slog.Attr) bool { - attrs = append(attrs, fmt.Sprintf("%s=%v", a.Key, a.Value)) - return true - }) - - if len(attrs) > 0 { - msg = fmt.Sprintf("%s %s", msg, strings.Join(attrs, " ")) - } - - fmt.Fprintf(h.w, "%s%s\n", prefix, msg) - return nil -} - -// WithAttrs returns the handler unchanged. -func (h *testHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - return h -} - -// WithGroup returns the handler unchanged. -func (h *testHandler) WithGroup(name string) slog.Handler { - return h -} diff --git a/cmd/verify/main.go b/cmd/verify/main.go deleted file mode 100644 index d13b6c9..0000000 --- a/cmd/verify/main.go +++ /dev/null @@ -1,88 +0,0 @@ -// Package main provides offline integration testing using HTTP mock servers. -// tests GitHub webhooks and Okta sync without requiring live API credentials. -package main - -import ( - "context" - "encoding/json" - "flag" - "fmt" - "log/slog" - "os" - "path/filepath" - "strings" - - "github.com/joho/godotenv" -) - -func main() { - scenarioFile := flag.String("scenarios", "fixtures/scenarios.json", "path to test scenarios file") - verbose := flag.Bool("verbose", false, "enable verbose output") - scenarioFilter := flag.String("filter", "", "run only scenarios matching this name") - flag.Parse() - - logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ - Level: slog.LevelInfo, - })) - - envPath := filepath.Join("cmd", "verify", ".env") - envExamplePath := filepath.Join("cmd", "verify", ".env.test") - - if _, err := os.Stat(envPath); err == nil { - if err := godotenv.Load(envPath); err != nil { - logger.Warn("failed to load .env file", slog.String("error", err.Error())) - } - } else if _, err := os.Stat(envExamplePath); err == nil { - fmt.Printf("Using .env.test (no .env file found)\n") - if err := godotenv.Load(envExamplePath); err != nil { - logger.Warn("failed to load .env.test file", slog.String("error", err.Error())) - } - } - - ctx := context.Background() - - path := filepath.Join(*scenarioFile) - raw, err := os.ReadFile(path) - if err != nil { - logger.Error("failed to read scenarios file", slog.String("error", err.Error())) - os.Exit(1) - } - - var scenarios []TestScenario - if err := json.Unmarshal(raw, &scenarios); err != nil { - logger.Error("failed to parse scenarios", slog.String("error", err.Error())) - os.Exit(1) - } - - passed := 0 - failed := 0 - skipped := 0 - - for _, scenario := range scenarios { - if *scenarioFilter != "" && !strings.Contains(scenario.Name, *scenarioFilter) { - skipped++ - continue - } - - if err := runScenario(ctx, scenario, *verbose, logger); err != nil { - fmt.Printf("✗ FAILED: %v\n\n", err) - failed++ - } else { - passed++ - } - } - - fmt.Printf("\n") - separator := strings.Repeat("═", 60) - fmt.Printf("%s\n", separator) - if failed > 0 { - fmt.Printf(" Test Results: %d passed, %d failed, %d skipped\n", passed, failed, skipped) - } else { - fmt.Printf(" Test Results: ✓ All %d tests passed, %d skipped\n", passed, skipped) - } - fmt.Printf("%s\n", separator) - - if failed > 0 { - os.Exit(1) - } -} diff --git a/cmd/verify/match.go b/cmd/verify/match.go deleted file mode 100644 index febaf25..0000000 --- a/cmd/verify/match.go +++ /dev/null @@ -1,49 +0,0 @@ -package main - -import "strings" - -// matchPath checks if an actual HTTP path matches an expected pattern. -// Supports wildcards (*) in the expected pattern. -func matchPath(actual, expected string) bool { - if expected == "*" { - return true - } - - if actual == expected { - return true - } - - if strings.Contains(expected, "*") { - parts := strings.Split(expected, "*") - - if len(parts) == 2 { - return strings.HasPrefix(actual, parts[0]) && strings.HasSuffix(actual, parts[1]) - } - - pos := 0 - for i, part := range parts { - if part == "" { - continue - } - - idx := strings.Index(actual[pos:], part) - if idx == -1 { - return false - } - - if i == 0 && idx != 0 { - return false - } - - pos += idx + len(part) - } - - if parts[len(parts)-1] != "" { - return strings.HasSuffix(actual, parts[len(parts)-1]) - } - - return true - } - - return false -} diff --git a/cmd/verify/mock.go b/cmd/verify/mock.go deleted file mode 100644 index 43ee535..0000000 --- a/cmd/verify/mock.go +++ /dev/null @@ -1,142 +0,0 @@ -package main - -import ( - "fmt" - "io" - "net/http" - "strings" - "sync" - "time" -) - -// RequestRecord captures details of an HTTP request made during testing. -// used to verify expected API calls were made correctly. -type RequestRecord struct { - Timestamp time.Time `json:"timestamp"` - Method string `json:"method"` - Host string `json:"host"` - Path string `json:"path"` - Query string `json:"query,omitempty"` - Headers map[string][]string `json:"headers"` - Body string `json:"body,omitempty"` -} - -// MockResponse defines a canned HTTP response returned by the mock server -// for matching requests. -type MockResponse struct { - Service string `json:"service"` - Method string `json:"method"` - Path string `json:"path"` - StatusCode int `json:"status_code"` - Headers map[string]string `json:"headers,omitempty"` - Body string `json:"body"` -} - -// MockServer simulates an HTTP API service for integration testing. -// Records all requests and returns predefined responses. -type MockServer struct { - name string - mu sync.Mutex - requests []RequestRecord - responses map[string]MockResponse - verbose bool -} - -// NewMockServer creates a new mock HTTP server with canned responses. -// matches requests by HTTP method and path pattern. -func NewMockServer(name string, responses []MockResponse, verbose bool) *MockServer { - respMap := make(map[string]MockResponse) - for _, r := range responses { - key := fmt.Sprintf("%s:%s", r.Method, r.Path) - respMap[key] = r - } - return &MockServer{ - name: name, - requests: make([]RequestRecord, 0), - responses: respMap, - verbose: verbose, - } -} - -// ServeHTTP records the request and returns a matching mock response. -// implements http.Handler interface. -func (ms *MockServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - r.Body.Close() - - rec := RequestRecord{ - Timestamp: time.Now(), - Method: r.Method, - Host: r.Host, - Path: r.URL.Path, - Query: r.URL.RawQuery, - Headers: r.Header, - Body: string(body), - } - - ms.mu.Lock() - ms.requests = append(ms.requests, rec) - ms.mu.Unlock() - - if ms.verbose { - serviceName := fmt.Sprintf("%-6s", ms.name) - fmt.Printf(" → %s %-4s %s\n", serviceName, r.Method, r.URL.Path) - } - - key := fmt.Sprintf("%s:%s", r.Method, r.URL.Path) - if resp, ok := ms.responses[key]; ok { - for k, v := range resp.Headers { - w.Header().Set(k, v) - } - if w.Header().Get("Content-Type") == "" { - w.Header().Set("Content-Type", "application/json") - } - w.WriteHeader(resp.StatusCode) - w.Write([]byte(resp.Body)) - return - } - - for key, resp := range ms.responses { - parts := strings.Split(key, ":") - if len(parts) == 2 { - method, pattern := parts[0], parts[1] - if method == r.Method && matchPath(r.URL.Path, pattern) { - for k, v := range resp.Headers { - w.Header().Set(k, v) - } - if w.Header().Get("Content-Type") == "" { - w.Header().Set("Content-Type", "application/json") - } - w.WriteHeader(resp.StatusCode) - w.Write([]byte(resp.Body)) - return - } - } - } - - if ms.verbose { - serviceName := fmt.Sprintf("%-6s", ms.name) - fmt.Printf(" ✗ %s No mock response for: %s %s\n", serviceName, r.Method, r.URL.Path) - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusNotFound) - w.Write([]byte(`{"message":"not found in mock"}`)) -} - -// GetRequests returns all HTTP requests captured by the mock server. -// safe for concurrent use. -func (ms *MockServer) GetRequests() []RequestRecord { - ms.mu.Lock() - defer ms.mu.Unlock() - reqs := make([]RequestRecord, len(ms.requests)) - copy(reqs, ms.requests) - return reqs -} - -// Reset clears all recorded requests from the mock server. -// safe for concurrent use. -func (ms *MockServer) Reset() { - ms.mu.Lock() - defer ms.mu.Unlock() - ms.requests = make([]RequestRecord, 0) -} diff --git a/cmd/verify/scenario.go b/cmd/verify/scenario.go deleted file mode 100644 index 2ce8e8b..0000000 --- a/cmd/verify/scenario.go +++ /dev/null @@ -1,364 +0,0 @@ -package main - -import ( - "bytes" - "context" - "crypto/tls" - "encoding/json" - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "time" - - "github.com/cockroachdb/errors" - "github.com/cruxstack/github-ops-app/internal/app" - "github.com/cruxstack/github-ops-app/internal/config" - oktaclient "github.com/cruxstack/github-ops-app/internal/okta" -) - -// TestScenario defines a test case with input events and expected outcomes. -type TestScenario struct { - Name string `json:"name"` - Description string `json:"description,omitempty"` - EventType string `json:"event_type"` - EventPayload json.RawMessage `json:"event_payload,omitempty"` - WebhookType string `json:"webhook_type,omitempty"` - WebhookPayload json.RawMessage `json:"webhook_payload,omitempty"` - ConfigOverrides map[string]string `json:"config_overrides,omitempty"` - ExpectedCalls []ExpectedCall `json:"expected_calls"` - MockResponses []MockResponse `json:"mock_responses"` - ExpectError bool `json:"expect_error,omitempty"` -} - -// ExpectedCall defines an HTTP API call the test expects the application to -// make. -type ExpectedCall struct { - Service string `json:"service"` - Method string `json:"method"` - Path string `json:"path"` -} - -// runScenario executes a single test scenario with mock HTTP servers and -// validates that expected API calls were made. -func runScenario(ctx context.Context, scenario TestScenario, verbose bool, logger *slog.Logger) error { - startTime := time.Now() - - fmt.Printf("\n▶ Running: %s\n", scenario.Name) - if scenario.Description != "" { - fmt.Printf(" %s\n", scenario.Description) - } - - githubResponses := []MockResponse{} - oktaResponses := []MockResponse{} - slackResponses := []MockResponse{} - for _, resp := range scenario.MockResponses { - if resp.Service == "github" { - githubResponses = append(githubResponses, resp) - } else if resp.Service == "okta" { - oktaResponses = append(oktaResponses, resp) - } else if resp.Service == "slack" { - slackResponses = append(slackResponses, resp) - } - } - - githubMock := NewMockServer("GitHub", githubResponses, verbose) - oktaMock := NewMockServer("Okta", oktaResponses, verbose) - slackMock := NewMockServer("Slack", slackResponses, verbose) - - tlsCert, certPool, err := generateSelfSignedCert() - if err != nil { - return errors.Wrap(err, "failed to generate cert") - } - - githubAppKey, err := generateOAuthPrivateKey() - if err != nil { - return errors.Wrap(err, "failed to generate github app key") - } - - oauthKey, err := generateOAuthPrivateKey() - if err != nil { - return errors.Wrap(err, "failed to generate oauth key") - } - - // use dynamic ports: bind to :0 and extract the assigned port - tlsConfig := &tls.Config{Certificates: []tls.Certificate{tlsCert}} - - githubListener, err := tls.Listen("tcp", "localhost:0", tlsConfig) - if err != nil { - return errors.Wrap(err, "failed to listen github") - } - oktaListener, err := tls.Listen("tcp", "localhost:0", tlsConfig) - if err != nil { - githubListener.Close() - return errors.Wrap(err, "failed to listen okta") - } - slackListener, err := tls.Listen("tcp", "localhost:0", tlsConfig) - if err != nil { - githubListener.Close() - oktaListener.Close() - return errors.Wrap(err, "failed to listen slack") - } - - githubServer := &http.Server{Handler: githubMock} - oktaServer := &http.Server{Handler: oktaMock} - slackServer := &http.Server{Handler: slackMock} - - go func() { - if err := githubServer.Serve(githubListener); err != http.ErrServerClosed { - logger.Error("github mock server error", slog.String("error", err.Error())) - } - }() - go func() { - if err := oktaServer.Serve(oktaListener); err != http.ErrServerClosed { - logger.Error("okta mock server error", slog.String("error", err.Error())) - } - }() - go func() { - if err := slackServer.Serve(slackListener); err != http.ErrServerClosed { - logger.Error("slack mock server error", slog.String("error", err.Error())) - } - }() - - defer func() { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - githubServer.Shutdown(shutdownCtx) - oktaServer.Shutdown(shutdownCtx) - slackServer.Shutdown(shutdownCtx) - }() - - githubAddr := fmt.Sprintf("https://%s/", githubListener.Addr().String()) - oktaAddr := fmt.Sprintf("https://%s", oktaListener.Addr().String()) - slackAddr := fmt.Sprintf("https://%s/", slackListener.Addr().String()) - - // save and restore environment variables for isolation between scenarios - envKeys := []string{ - "APP_GITHUB_APP_PRIVATE_KEY", "APP_OKTA_CLIENT_ID", "APP_OKTA_PRIVATE_KEY", - "APP_GITHUB_BASE_URL", "APP_SLACK_API_URL", "APP_OKTA_BASE_URL", - "APP_OKTA_ORPHANED_USER_NOTIFICATIONS", - } - for key := range scenario.ConfigOverrides { - envKeys = append(envKeys, key) - } - savedEnv := make(map[string]string, len(envKeys)) - for _, key := range envKeys { - savedEnv[key] = os.Getenv(key) - } - defer func() { - for key, value := range savedEnv { - if value == "" { - os.Unsetenv(key) - } else { - os.Setenv(key, value) - } - } - }() - - os.Setenv("APP_GITHUB_APP_PRIVATE_KEY", string(githubAppKey)) - os.Setenv("APP_OKTA_CLIENT_ID", "test-client-id") - os.Setenv("APP_OKTA_PRIVATE_KEY", string(oauthKey)) - os.Setenv("APP_GITHUB_BASE_URL", githubAddr) - os.Setenv("APP_SLACK_API_URL", slackAddr) - os.Setenv("APP_OKTA_BASE_URL", oktaAddr) - - // save and restore http.DefaultTransport - savedTransport := http.DefaultTransport - defer func() { http.DefaultTransport = savedTransport }() - - http.DefaultTransport = &http.Transport{ - TLSClientConfig: &tls.Config{ - RootCAs: certPool, - }, - } - - ctx = oktaclient.WithCertPool(ctx, certPool) - - if os.Getenv("APP_OKTA_ORPHANED_USER_NOTIFICATIONS") == "" { - os.Setenv("APP_OKTA_ORPHANED_USER_NOTIFICATIONS", "false") - } - - for key, value := range scenario.ConfigOverrides { - os.Setenv(key, value) - } - - cfg, err := config.NewConfig() - if err != nil { - return errors.Wrap(err, "config creation failed") - } - - appLogger := slog.New(&testHandler{prefix: " ", verbose: verbose, w: os.Stdout}) - - a, err := app.NewApp(ctx, cfg, appLogger) - if err != nil { - return errors.Wrap(err, "app creation failed") - } - - if verbose { - fmt.Printf("\n Application Output:\n") - } - - router := a.Handler() - - var httpReq *http.Request - switch scenario.EventType { - case "scheduled_event": - var evt app.ScheduledEvent - if err := json.Unmarshal(scenario.EventPayload, &evt); err != nil { - return errors.Wrap(err, "failed to unmarshal event payload") - } - path := fmt.Sprintf("%s/scheduled/%s", cfg.BasePath, evt.Action) - var body []byte - if evt.Data != nil { - body = evt.Data - } - var err error - httpReq, err = http.NewRequestWithContext(ctx, http.MethodPost, path, bytes.NewReader(body)) - if err != nil { - return errors.Wrap(err, "failed to construct scheduled http request") - } - if cfg.AdminToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+cfg.AdminToken) - } - if len(body) > 0 { - httpReq.Header.Set("Content-Type", "application/json") - } - - case "webhook": - var err error - httpReq, err = http.NewRequestWithContext(ctx, http.MethodPost, cfg.BasePath+"/webhooks", bytes.NewReader(scenario.WebhookPayload)) - if err != nil { - return errors.Wrap(err, "failed to construct webhook http request") - } - httpReq.Header.Set("X-GitHub-Event", scenario.WebhookType) - httpReq.Header.Set("X-Hub-Signature-256", "") // signature validated separately in tests - - default: - return errors.Newf("unknown event type: %s", scenario.EventType) - } - - rec := httptest.NewRecorder() - router.ServeHTTP(rec, httpReq) - - var processErr error - if rec.Code >= 400 { - processErr = errors.Newf("request failed with status %d: %s", rec.Code, rec.Body.String()) - } - - if scenario.ExpectError { - if processErr == nil { - return errors.New("expected error but processing succeeded") - } - if verbose { - fmt.Printf(" ✓ Expected error occurred: %v\n", processErr) - } - } else { - if processErr != nil { - return errors.Wrap(processErr, "process event failed") - } - } - - time.Sleep(500 * time.Millisecond) - - githubReqs := githubMock.GetRequests() - oktaReqs := oktaMock.GetRequests() - slackReqs := slackMock.GetRequests() - - allReqs := make(map[string][]RequestRecord) - allReqs["github"] = githubReqs - allReqs["okta"] = oktaReqs - allReqs["slack"] = slackReqs - - totalCalls := len(githubReqs) + len(oktaReqs) + len(slackReqs) - - if verbose { - fmt.Printf("\n") - } - - if err := validateNoUnexpectedCalls(scenario.ExpectedCalls, allReqs); err != nil { - fmt.Printf("\n Validation:\n") - fmt.Printf(" ✗ FAILED: %v\n", err) - return err - } - - if err := validateExpectedCalls(scenario.ExpectedCalls, allReqs); err != nil { - fmt.Printf("\n Validation:\n") - fmt.Printf(" ✗ FAILED: %v\n", err) - fmt.Printf("\n All captured requests:\n") - if len(githubReqs) > 0 { - fmt.Printf(" GitHub (%d):\n", len(githubReqs)) - for i, req := range githubReqs { - fmt.Printf(" [%d] %s %s\n", i+1, req.Method, req.Path) - } - } - if len(oktaReqs) > 0 { - fmt.Printf(" Okta (%d):\n", len(oktaReqs)) - for i, req := range oktaReqs { - fmt.Printf(" [%d] %s %s\n", i+1, req.Method, req.Path) - } - } - if len(slackReqs) > 0 { - fmt.Printf(" Slack (%d):\n", len(slackReqs)) - for i, req := range slackReqs { - fmt.Printf(" [%d] %s %s\n", i+1, req.Method, req.Path) - } - } - return err - } - - duration := time.Since(startTime) - - if verbose { - fmt.Printf(" Validation:\n") - fmt.Printf(" ✓ All expected calls verified (%d total)\n", totalCalls) - fmt.Printf("\n") - } - - fmt.Printf("✓ PASSED (Duration: %.2fs)\n", duration.Seconds()) - return nil -} - -// validateExpectedCalls verifies that all expected HTTP calls were captured -// by the mock servers. -func validateExpectedCalls(expected []ExpectedCall, allReqs map[string][]RequestRecord) error { - for _, exp := range expected { - reqs := allReqs[exp.Service] - found := false - for _, req := range reqs { - if req.Method == exp.Method && matchPath(req.Path, exp.Path) { - found = true - break - } - } - if !found { - return errors.Newf("expected call not found: %s %s %s", exp.Service, exp.Method, exp.Path) - } - } - return nil -} - -// validateNoUnexpectedCalls checks that no unexpected destructive API calls -// were made. only flags DELETE calls to catch unintended member removal or -// resource deletion. -func validateNoUnexpectedCalls(expected []ExpectedCall, allReqs map[string][]RequestRecord) error { - for service, reqs := range allReqs { - for _, req := range reqs { - // only flag unexpected destructive calls - if req.Method != "DELETE" { - continue - } - matched := false - for _, exp := range expected { - if exp.Service == service && exp.Method == req.Method && matchPath(req.Path, exp.Path) { - matched = true - break - } - } - if !matched { - return errors.Newf("unexpected destructive call: %s %s %s", service, req.Method, req.Path) - } - } - } - return nil -} diff --git a/cmd/verify/tls.go b/cmd/verify/tls.go deleted file mode 100644 index acbb4a2..0000000 --- a/cmd/verify/tls.go +++ /dev/null @@ -1,86 +0,0 @@ -package main - -import ( - "crypto/rand" - "crypto/rsa" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "math/big" - "net" - "time" - - "github.com/cockroachdb/errors" -) - -// generateOAuthPrivateKey creates an RSA private key for OAuth testing. -// Returns the private key as PEM-encoded bytes. -func generateOAuthPrivateKey() ([]byte, error) { - priv, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return nil, errors.Wrap(err, "failed to generate oauth key") - } - - keyPEM := pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: x509.MarshalPKCS1PrivateKey(priv), - }) - - return keyPEM, nil -} - -// generateSelfSignedCert creates a self-signed TLS certificate for testing. -// Returns the certificate, certificate pool, and any error encountered. -func generateSelfSignedCert() (tls.Certificate, *x509.CertPool, error) { - priv, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return tls.Certificate{}, nil, errors.Wrap(err, "failed to generate key") - } - - notBefore := time.Now() - notAfter := notBefore.Add(24 * time.Hour) - - serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) - if err != nil { - return tls.Certificate{}, nil, errors.Wrap(err, "failed to generate serial") - } - - template := x509.Certificate{ - SerialNumber: serialNumber, - Subject: pkix.Name{ - Organization: []string{"E2E Test"}, - CommonName: "localhost", - }, - NotBefore: notBefore, - NotAfter: notAfter, - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - DNSNames: []string{"localhost"}, - IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, - } - - certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) - if err != nil { - return tls.Certificate{}, nil, errors.Wrap(err, "failed to create cert") - } - - certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) - keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) - - tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) - if err != nil { - return tls.Certificate{}, nil, errors.Wrap(err, "failed to create keypair") - } - - cert, err := x509.ParseCertificate(certDER) - if err != nil { - return tls.Certificate{}, nil, errors.Wrap(err, "failed to parse cert") - } - - certPool := x509.NewCertPool() - certPool.AddCert(cert) - - return tlsCert, certPool, nil -} diff --git a/compose.yaml b/compose.yaml index 28ef6a2..6632243 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,10 +1,26 @@ services: server: - build: . - container_name: github-ops-server + build: + context: . + args: + VERSION: ${VERSION:-dev} env_file: .env + environment: + APP_LISTEN_ADDR: ":8080" ports: - - "${APP_PORT:-8080}:${APP_PORT:-8080}" + - "${PORT:-8080}:8080" volumes: - - ./.local:/.local:ro + # private keys referenced by *_PRIVATE_KEY_PATH, such as + # /app/.local/github-private-key.pem + - ./.local:/app/.local:ro + # credentials saved by the installer (GITHUB_APP_INSTALLER_ENABLED) + - installer-data:/data + healthcheck: + test: ["CMD-SHELL", "wget -qO /dev/null \"http://127.0.0.1:8080$${APP_BASE_PATH%/}/healthz\""] + interval: 2s + timeout: 2s + retries: 15 restart: unless-stopped + +volumes: + installer-data: diff --git a/docs/upgrading-to-v1.md b/docs/upgrading-to-v1.md new file mode 100644 index 0000000..2465579 --- /dev/null +++ b/docs/upgrading-to-v1.md @@ -0,0 +1,136 @@ +# Upgrading to v1 + +v1 fixes security and correctness problems, some of which change behavior, +and validates the configuration at startup. Read this list before you +upgrade; the service refuses to start on most configuration problems and +logs every one. + +## Configuration + +| v0 | v1 | +| ------------------------ | ------------------------------------------------------- | +| `APP_DEBUG_ENABLED=true` | `APP_LOG_LEVEL=debug` | +| `APP_PORT=8080` | `APP_LISTEN_ADDR=:8080` | +| `APP_OKTA_KEY_ID` | `APP_OKTA_PRIVATE_KEY_ID`; the old name was never read | + +Setting a removed variable is an error, so it can't be silently ignored. + +- `APP_GITHUB_ORG`, `APP_GITHUB_APP_ID`, a private key, and + `APP_GITHUB_WEBHOOK_SECRET` are required. +- `APP_GITHUB_INSTALLATION_ID` is optional; the installation is looked up + from the organization. +- Invalid values are errors instead of falling back to defaults: booleans, + numbers, the safety threshold, the alert age and severity, URLs, and the + base path. +- A partly configured feature is an error instead of being disabled: any Okta + variable requires the domain, client ID, private key, and rules, and the + Slack token and channel are required together. +- Sync rules reject unknown fields, rules that set both or neither of + `okta_group_name` and `okta_group_pattern`, and `github_team_name` combined + with a pattern. +- `APP_ADMIN_TOKEN` must be at least 16 characters. +- `APP_PR_MONITORED_BRANCHES` accepts glob patterns, such as `release/*`. +- Logs are always JSON. + +## Security + +- **Webhooks must be signed.** v0 accepted unsigned webhooks when no secret was + set. +- **The admin endpoints are disabled without `APP_ADMIN_TOKEN`.** In v0 they + were open to anyone. EventBridge invocations in Lambda don't need the token. +- **Webhooks from other organizations are ignored.** +- **The app manifest is private** (`"public": false`), so other accounts can't + install it. If you created a public app from the v0 manifest, make it + private under **Advanced** → **Make private** in its settings. +- Slack messages escape text from GitHub and Okta, so a PR title can't + mention `@channel` or add a link. +- Okta usernames are validated as GitHub logins before use. + +## GitHub App Permissions + +The v0 manifest asked for permissions under names GitHub doesn't use, and +missed two the PR compliance check needs, so it silently skipped legacy branch +protection and commit statuses. Update an existing app's permissions to match +[the list](github-app-setup.md#permissions), then approve them on the +installation: + +- Add **Administration: read** and **Commit statuses: read**. +- Make sure **Dependabot alerts** and **Code scanning alerts** are granted, if + you use security alerts. +- Remove **Organization administration** and **Contents**, which nothing uses. + +Errors reading branch protection or rulesets now fail the check, with a +`502`, instead of treating the branch as unprotected. + +## Behavior + +### Okta Sync + +- `create_team_if_missing` is honored and defaults to `false`. v0 created + missing teams regardless. Set it to `true` on rules that should create + teams. +- Users whose Okta status is `PASSWORD_EXPIRED`, `LOCKED_OUT`, or `RECOVERY` + stay in their teams. v0 removed them until they signed in again. +- A group whose members can't be read is reported and not synced, and the + other groups of its rule still sync. +- Teams are found by name even when the slug differs, so `github_team_name` + can be a display name such as `Platform Team`. +- Pending invitations count as members, so they aren't invited again and + reported as added on every sync. +- Orphaned user detection doesn't run when the sync failed, and fails rather + than report the members of a team it couldn't read. +- Syncs requested while one is running are combined into one more sync. +- Webhooks only trigger a sync for teams a rule manages, and `team` events + only when a team is created, deleted, or renamed, not for description, + privacy, or repository permission changes. +- The server answers `team` and `membership` webhooks with `202` and syncs + afterwards, instead of making GitHub wait past its 10-second timeout. +- Members of a team's child teams, which GitHub also lists as members of the + parent team, are no longer removed from the parent team. + +### PR Compliance + +- Every merged PR that didn't meet its branch's requirements is reported, with + the merger's role when it is admin or maintain. v0 reported only merges by + admins, missing ruleset bypass actors; it also never recognized maintainers. +- Reviews are read across every page, and a comment after an approval keeps + the approval. +- Check runs that are neutral or skipped pass, as on GitHub, and check runs and + statuses are read across every page. + +### Security Alerts + +- Alerts are read across every page; v0 read only the first 100 of each kind. +- Code scanning alerts without a security severity use their rule's severity: + `error` as high, `warning` as medium, and `note` as low. +- A report is posted when a source fails, even with no alerts. + +## HTTP API + +- `GET /healthz` (liveness) and `GET /readyz` (readiness) are new. +- Responses are JSON: `{"status": ...}` or `{"error": ...}`. +- The `ping` event returns `200`; events the app doesn't handle, and events + from other organizations, return `202`. v0 returned `400`, which GitHub + shows as a failed delivery. +- An unknown scheduled action returns `404`, and a disabled feature `409`. +- Webhook payloads up to 25 MB are accepted, as GitHub sends. + +## Lambda + +- The EventBridge event's `detail` is `{"action": "..."}`; `data` is ignored. +- The binary is built with the `lambda.norpc` tag, for the `provided.al2023` + runtime; the deprecated `go1.x` runtime is not supported. +- A configuration that fails to load, such as from an SSM outage, is retried + on the next invocation. v0 failed every invocation until the container was + recycled. +- Webhooks and scheduled actions run for up to 10 minutes; raise the + function's timeout so a sync can finish. + +## Building + +- Go 1.27 or later is required. +- Releases attach server and Lambda binaries for amd64 and arm64. +- The container image runs as user `65532`, with `/data` for the installer's + env file. +- On `SIGTERM` the server waits up to 10 minutes for running syncs; set the + platform's grace period to match. diff --git a/e2e/mock_test.go b/e2e/mock_test.go new file mode 100644 index 0000000..47c6b06 --- /dev/null +++ b/e2e/mock_test.go @@ -0,0 +1,101 @@ +package e2e + +import ( + "fmt" + "io" + "net/http" + "strings" + "sync" +) + +// mockResponse is a canned response for matching requests. Responses are +// tried in order; the first whose method, path, and query match is used. +type mockResponse struct { + Description string `json:"description,omitempty"` + + Service string `json:"service"` + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query,omitempty"` + StatusCode int `json:"status_code"` + Headers map[string]string `json:"headers,omitempty"` + Body string `json:"body"` +} + +// request is a request a mock server received. +type request struct { + Method string + Path string + Query string + Body string +} + +// mockServer is a fake API that records requests and replies with canned +// responses. +type mockServer struct { + name string + responses []mockResponse + logf func(format string, args ...any) + + mu sync.Mutex + requests []request +} + +func newMockServer(name string, responses []mockResponse, logf func(string, ...any)) *mockServer { + return &mockServer{name: name, responses: responses, logf: logf} +} + +func (m *mockServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + m.mu.Lock() + m.requests = append(m.requests, request{Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery, Body: string(body)}) + m.mu.Unlock() + + for _, resp := range m.responses { + if resp.Method != r.Method || !matchPath(r.URL.Path, resp.Path) || !strings.Contains(r.URL.RawQuery, resp.Query) { + continue + } + m.logf("%-6s %s %s?%s -> %d", m.name, r.Method, r.URL.Path, r.URL.RawQuery, resp.StatusCode) + for k, v := range resp.Headers { + w.Header().Set(k, strings.ReplaceAll(v, "{host}", r.Host)) + } + if w.Header().Get("Content-Type") == "" { + w.Header().Set("Content-Type", "application/json") + } + w.WriteHeader(resp.StatusCode) + _, _ = w.Write([]byte(resp.Body)) + return + } + + m.logf("%-6s %s %s?%s -> no mock, 404", m.name, r.Method, r.URL.Path, r.URL.RawQuery) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = fmt.Fprint(w, `{"message":"not found in mock"}`) +} + +func (m *mockServer) received() []request { + m.mu.Lock() + defer m.mu.Unlock() + return append([]request(nil), m.requests...) +} + +// matchPath reports whether path matches pattern, in which "*" matches any +// run of characters. +func matchPath(path, pattern string) bool { + parts := strings.Split(pattern, "*") + if len(parts) == 1 { + return path == pattern + } + if !strings.HasPrefix(path, parts[0]) { + return false + } + path = path[len(parts[0]):] + for _, part := range parts[1 : len(parts)-1] { + i := strings.Index(path, part) + if i < 0 { + return false + } + path = path[i+len(part):] + } + return strings.HasSuffix(path, parts[len(parts)-1]) +} diff --git a/e2e/scenarios_test.go b/e2e/scenarios_test.go new file mode 100644 index 0000000..1d0a826 --- /dev/null +++ b/e2e/scenarios_test.go @@ -0,0 +1,326 @@ +// Package e2e runs the scenarios in fixtures/scenarios.json against the +// service with its real wiring and real HTTP servers, with the GitHub, Okta, +// and Slack APIs replaced by mock servers. +package e2e + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/cruxstack/github-ops-app/internal/app" + "github.com/cruxstack/github-ops-app/internal/config" + "github.com/cruxstack/github-ops-app/internal/github/webhooks" + "github.com/cruxstack/github-ops-app/internal/service" +) + +const ( + webhookSecret = "test_webhook_secret" + adminToken = "e2e-admin-token-0123456789" + unset = "" +) + +type call struct { + Service string `json:"service"` + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query,omitempty"` +} + +func (c call) String() string { + return fmt.Sprintf("%s %s %s?%s", c.Service, c.Method, c.Path, c.Query) +} + +func (c call) matches(service string, r request) bool { + return (c.Service == service || c.Service == "*") && + (c.Method == r.Method || c.Method == "*") && + matchPath(r.Path, c.Path) && + strings.Contains(r.Query, c.Query) +} + +type scenario struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + // EventType is "webhook" or "scheduled_event". + EventType string `json:"event_type"` + EventPayload json.RawMessage `json:"event_payload,omitempty"` + WebhookType string `json:"webhook_type,omitempty"` + WebhookPayload json.RawMessage `json:"webhook_payload,omitempty"` + // Signature is empty to sign webhooks, "invalid", or "none". + Signature string `json:"signature,omitempty"` + // AdminAuth is empty to send the admin token, or "none". + AdminAuth string `json:"admin_auth,omitempty"` + ConfigOverrides map[string]string `json:"config_overrides,omitempty"` + MockResponses []mockResponse `json:"mock_responses"` + ExpectedCalls []call `json:"expected_calls"` + ForbiddenCalls []call `json:"forbidden_calls,omitempty"` + ExpectError bool `json:"expect_error,omitempty"` + ExpectStatus int `json:"expect_status,omitempty"` +} + +var ( + keysOnce sync.Once + githubKey, oktaKey string +) + +func testKeys(t *testing.T) (string, string) { + t.Helper() + keysOnce.Do(func() { + gen := func() string { + k, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})) + } + githubKey, oktaKey = gen(), gen() + }) + return githubKey, oktaKey +} + +func loadScenarios(t *testing.T) []scenario { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "fixtures", "scenarios.json")) + if err != nil { + t.Fatal(err) + } + var scenarios []scenario + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&scenarios); err != nil { + t.Fatalf("invalid scenarios: %v", err) + } + return scenarios +} + +func TestScenarios(t *testing.T) { + for _, sc := range loadScenarios(t) { + t.Run(sc.Name, func(t *testing.T) { runScenario(t, sc) }) + } +} + +func runScenario(t *testing.T, sc scenario) { + if sc.Description != "" { + t.Log(sc.Description) + } + + byService := map[string][]mockResponse{} + for _, m := range sc.MockResponses { + byService[m.Service] = append(byService[m.Service], m) + } + mocks := map[string]*mockServer{} + urls := map[string]string{} + var client *http.Client + for _, name := range []string{"github", "okta", "slack"} { + mocks[name] = newMockServer(name, byService[name], t.Logf) + srv := httptest.NewTLSServer(mocks[name]) + t.Cleanup(srv.Close) + urls[name] = srv.URL + // every test server shares one certificate, so any of their clients + // trusts all of them + client = srv.Client() + } + + ghKey, oktaPEM := testKeys(t) + env := map[string]string{ + "APP_LOG_LEVEL": "debug", + "APP_ADMIN_TOKEN": adminToken, + "APP_GITHUB_ORG": "acme-ghorg", + "APP_GITHUB_APP_ID": "123456", + "APP_GITHUB_INSTALLATION_ID": "987654", + "APP_GITHUB_APP_PRIVATE_KEY": ghKey, + "APP_GITHUB_WEBHOOK_SECRET": webhookSecret, + "APP_GITHUB_BASE_URL": urls["github"], + "APP_PR_COMPLIANCE_ENABLED": "true", + "APP_PR_MONITORED_BRANCHES": "main,master", + "APP_OKTA_DOMAIN": "dev-12345.okta.com", + "APP_OKTA_BASE_URL": urls["okta"], + "APP_OKTA_CLIENT_ID": "test-client-id", + "APP_OKTA_PRIVATE_KEY": oktaPEM, + "APP_OKTA_GITHUB_USER_FIELD": "githubUsername", + "APP_OKTA_SYNC_RULES": `[{"okta_group_name":"Engineering","github_team_name":"engineering"}]`, + "APP_OKTA_ORPHANED_USER_NOTIFICATIONS": "false", + "APP_SLACK_TOKEN": "xoxb-test-token", + "APP_SLACK_CHANNEL": "C01234TEST", + "APP_SLACK_API_URL": urls["slack"], + } + for k, v := range sc.ConfigOverrides { + if v == unset { + delete(env, k) + } else { + env[k] = v + } + } + cfgEnv := config.Env{Lookup: func(k string) (string, bool) { + v, ok := env[k] + return v, ok + }} + + logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{Level: slog.LevelDebug})) + boot, err := config.LoadBootstrap(cfgEnv) + if err != nil { + t.Fatal(err) + } + svc, err := service.New(boot, service.Options{ + Mode: service.ModeServer, + Logger: logger, + Env: cfgEnv, + AppOptions: []app.Option{app.WithHTTPClient(client)}, + }) + if err != nil { + t.Fatal(err) + } + if err := svc.Start(t.Context()); err != nil { + t.Fatalf("service failed to start: %v", err) + } + appSrv := httptest.NewServer(svc.Handler()) + t.Cleanup(appSrv.Close) + + req := scenarioRequest(t, sc, appSrv.URL) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + respBody, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + // webhook-triggered syncs run after the response + if err := svc.Wait(t.Context()); err != nil { + t.Fatal(err) + } + + switch { + case sc.ExpectStatus != 0: + if resp.StatusCode != sc.ExpectStatus { + t.Errorf("status %d, want %d: %s", resp.StatusCode, sc.ExpectStatus, respBody) + } + case sc.ExpectError: + if resp.StatusCode < 400 { + t.Errorf("status %d, want an error: %s", resp.StatusCode, respBody) + } + default: + if resp.StatusCode >= 300 { + t.Errorf("status %d, want success: %s", resp.StatusCode, respBody) + } + } + + received := map[string][]request{} + for name, m := range mocks { + received[name] = m.received() + } + found := func(c call) bool { + for service, reqs := range received { + for _, r := range reqs { + if c.matches(service, r) { + return true + } + } + } + return false + } + + for _, c := range sc.ExpectedCalls { + if !found(c) { + t.Errorf("expected call not made: %s", c) + } + } + for _, c := range sc.ForbiddenCalls { + if found(c) { + t.Errorf("forbidden call made: %s", c) + } + } + // no request may delete anything a scenario doesn't expect deleted + for service, reqs := range received { + for _, r := range reqs { + if r.Method != http.MethodDelete { + continue + } + expected := false + for _, c := range sc.ExpectedCalls { + expected = expected || c.matches(service, r) + } + if !expected { + t.Errorf("unexpected destructive call: %s DELETE %s", service, r.Path) + } + } + } + if t.Failed() { + for service, reqs := range received { + for _, r := range reqs { + t.Logf("received %s %s %s?%s", service, r.Method, r.Path, r.Query) + } + } + } +} + +func scenarioRequest(t *testing.T, sc scenario, baseURL string) *http.Request { + t.Helper() + switch sc.EventType { + case "webhook": + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, baseURL+"/webhooks", bytes.NewReader(sc.WebhookPayload)) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-GitHub-Event", sc.WebhookType) + req.Header.Set("X-GitHub-Delivery", sc.Name) + switch sc.Signature { + case "": + req.Header.Set("X-Hub-Signature-256", webhooks.Sign(sc.WebhookPayload, webhookSecret)) + case "invalid": + req.Header.Set("X-Hub-Signature-256", webhooks.Sign(sc.WebhookPayload, "wrong-secret")) + } + return req + + case "scheduled_event": + var evt struct { + Action string `json:"action"` + } + if err := json.Unmarshal(sc.EventPayload, &evt); err != nil || evt.Action == "" { + t.Fatalf("scheduled event has no action: %s", sc.EventPayload) + } + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, baseURL+"/scheduled/"+evt.Action, nil) + if err != nil { + t.Fatal(err) + } + if sc.AdminAuth != "none" { + req.Header.Set("Authorization", "Bearer "+adminToken) + } + return req + + default: + t.Fatalf("unknown event type %q", sc.EventType) + return nil + } +} + +func TestMatchPath(t *testing.T) { + tests := []struct { + path, pattern string + want bool + }{ + {"/a/b", "/a/b", true}, + {"/a/b", "*", true}, + {"/orgs/x/teams/y", "/orgs/*/teams/*", true}, + {"/orgs/x/members", "/orgs/*/teams/*", false}, + {"/a/b/c", "/a/*/c", true}, + {"/a/b/d", "/a/*/c", false}, + } + for _, tt := range tests { + if got := matchPath(tt.path, tt.pattern); got != tt.want { + t.Errorf("matchPath(%q, %q) = %v", tt.path, tt.pattern, got) + } + } +} diff --git a/fixtures/scenarios.json b/fixtures/scenarios.json index 0fce9b4..dca263a 100644 --- a/fixtures/scenarios.json +++ b/fixtures/scenarios.json @@ -87,6 +87,20 @@ "status_code": 200, "body": "{\"ok\":true,\"channel\":\"C01234TEST\",\"ts\":\"1234567890.123456\"}", "description": "send sync report notification to slack" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/invitations", + "status_code": 200, + "body": "[]" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/teams", + "status_code": 200, + "body": "[]" } ] }, @@ -95,9 +109,6 @@ "description": "Detect when PR is merged bypassing branch protection and send Slack notification", "event_type": "webhook", "webhook_type": "pull_request", - "config_overrides": { - "APP_GITHUB_WEBHOOK_SECRET": "" - }, "webhook_payload": { "action": "closed", "number": 456, @@ -238,9 +249,6 @@ "description": "PR merged with proper approvals - no bypass", "event_type": "webhook", "webhook_type": "pull_request", - "config_overrides": { - "APP_GITHUB_WEBHOOK_SECRET": "" - }, "webhook_payload": { "action": "closed", "number": 789, @@ -343,9 +351,6 @@ "description": "Detect bypass when repo uses rulesets instead of legacy branch protection", "event_type": "webhook", "webhook_type": "pull_request", - "config_overrides": { - "APP_GITHUB_WEBHOOK_SECRET": "" - }, "webhook_payload": { "action": "closed", "number": 101, @@ -470,8 +475,8 @@ ] }, { - "name": "okta_sync_inactive_users", - "description": "Test that inactive Okta users are excluded from sync", + "name": "okta_sync_user_statuses", + "description": "Okta users who can still sign in (such as an expired password) stay in the team; suspended and deprovisioned users are not added and are removed", "event_type": "scheduled_event", "event_payload": { "action": "okta-sync" @@ -480,12 +485,12 @@ { "service": "okta", "method": "GET", - "path": "/api/v1/groups" + "path": "/api/v1/groups/00g1234567890abcdef/users" }, { "service": "github", - "method": "GET", - "path": "/orgs/*/teams/*" + "method": "DELETE", + "path": "/orgs/acme-ghorg/teams/engineering/memberships/inactive-user" }, { "service": "slack", @@ -499,64 +504,42 @@ "method": "POST", "path": "/app/installations/987654/access_tokens", "status_code": 201, - "body": "{\"token\":\"ghs_mock_installation_token\",\"expires_at\":\"2099-12-31T23:59:59Z\"}", - "description": "github app installation token authentication" + "body": "{\"token\":\"ghs_mock_installation_token\",\"expires_at\":\"2099-12-31T23:59:59Z\"}" }, { "service": "github", "method": "GET", "path": "/orgs/acme-ghorg/teams/engineering", "status_code": 200, - "body": "{\"id\":1,\"name\":\"Engineering\",\"slug\":\"engineering\",\"description\":\"Engineering team\"}", - "description": "fetch engineering team details" + "body": "{\"id\":1,\"name\":\"Engineering\",\"slug\":\"engineering\"}" }, { "service": "github", "method": "GET", "path": "/orgs/acme-ghorg/teams/engineering/members", "status_code": 200, - "body": "[{\"login\":\"inactive-user\"},{\"login\":\"suspended-user\"}]", - "description": "fetch current team members (inactive-user, suspended-user)" + "body": "[{\"login\":\"alice-gh\"},{\"login\":\"bob-gh\"},{\"login\":\"inactive-user\"}]" }, { "service": "github", "method": "GET", - "path": "/orgs/acme-ghorg/members/inactive-user/memberships/orgs/acme-ghorg", + "path": "/orgs/acme-ghorg/teams/engineering/invitations", "status_code": 200, - "body": "{\"state\":\"active\",\"role\":\"member\"}", - "description": "check inactive-user org membership" - }, - { - "service": "github", - "method": "DELETE", - "path": "/orgs/acme-ghorg/teams/engineering/memberships/inactive-user", - "status_code": 204, - "body": "", - "description": "remove inactive-user from team (deprovisioned in okta)" + "body": "[]" }, { "service": "github", "method": "GET", - "path": "/orgs/acme-ghorg/members/suspended-user/memberships/orgs/acme-ghorg", + "path": "/orgs/acme-ghorg/memberships/inactive-user", "status_code": 200, - "body": "{\"state\":\"active\",\"role\":\"member\"}", - "description": "check suspended-user org membership" + "body": "{\"state\":\"active\",\"role\":\"member\"}" }, { "service": "github", "method": "DELETE", - "path": "/orgs/acme-ghorg/teams/engineering/memberships/suspended-user", + "path": "/orgs/acme-ghorg/teams/engineering/memberships/inactive-user", "status_code": 204, - "body": "", - "description": "remove suspended-user from team (suspended in okta)" - }, - { - "service": "github", - "method": "PUT", - "path": "/orgs/acme-ghorg/teams/engineering/memberships/alice-gh", - "status_code": 200, - "body": "{\"state\":\"active\",\"role\":\"member\"}", - "description": "add alice-gh to team (active in okta)" + "body": "" }, { "service": "okta", @@ -579,16 +562,38 @@ "method": "GET", "path": "/api/v1/groups/00g1234567890abcdef/users", "status_code": 200, - "body": "[{\"id\": \"00u1111111111111111\", \"status\": \"ACTIVE\", \"created\": \"2020-01-01T00:00:00.000Z\", \"activated\": \"2020-01-01T00:00:00.000Z\", \"statusChanged\": \"2020-01-01T00:00:00.000Z\", \"lastLogin\": \"2020-01-01T00:00:00.000Z\", \"lastUpdated\": \"2020-01-01T00:00:00.000Z\", \"passwordChanged\": \"2020-01-01T00:00:00.000Z\", \"type\": {\"id\": \"oty1234567890\"}, \"profile\": {\"email\": \"alice@example.com\", \"githubUsername\": \"alice-gh\"}}, {\"id\": \"00u2222222222222222\", \"status\": \"SUSPENDED\", \"created\": \"2020-01-01T00:00:00.000Z\", \"activated\": \"2020-01-01T00:00:00.000Z\", \"statusChanged\": \"2020-01-01T00:00:00.000Z\", \"lastLogin\": \"2020-01-01T00:00:00.000Z\", \"lastUpdated\": \"2020-01-01T00:00:00.000Z\", \"passwordChanged\": \"2020-01-01T00:00:00.000Z\", \"type\": {\"id\": \"oty1234567890\"}, \"profile\": {\"email\": \"suspended@example.com\", \"githubUsername\": \"suspended-user\"}}, {\"id\": \"00u3333333333333333\", \"status\": \"DEPROVISIONED\", \"created\": \"2020-01-01T00:00:00.000Z\", \"activated\": \"2020-01-01T00:00:00.000Z\", \"statusChanged\": \"2020-01-01T00:00:00.000Z\", \"lastLogin\": \"2020-01-01T00:00:00.000Z\", \"lastUpdated\": \"2020-01-01T00:00:00.000Z\", \"passwordChanged\": \"2020-01-01T00:00:00.000Z\", \"type\": {\"id\": \"oty1234567890\"}, \"profile\": {\"email\": \"inactive@example.com\", \"githubUsername\": \"inactive-user\"}}]", - "description": "fetch users in engineering group (active, suspended, deprovisioned statuses)" + "body": "[{\"id\": \"00u1\", \"status\": \"ACTIVE\", \"created\": \"2020-01-01T00:00:00.000Z\", \"activated\": \"2020-01-01T00:00:00.000Z\", \"statusChanged\": \"2020-01-01T00:00:00.000Z\", \"lastLogin\": \"2020-01-01T00:00:00.000Z\", \"lastUpdated\": \"2020-01-01T00:00:00.000Z\", \"passwordChanged\": \"2020-01-01T00:00:00.000Z\", \"type\": {\"id\": \"oty1234567890\"}, \"profile\": {\"email\": \"alice@example.com\", \"githubUsername\": \"alice-gh\"}}, {\"id\": \"00u2\", \"status\": \"PASSWORD_EXPIRED\", \"created\": \"2020-01-01T00:00:00.000Z\", \"activated\": \"2020-01-01T00:00:00.000Z\", \"statusChanged\": \"2020-01-01T00:00:00.000Z\", \"lastLogin\": \"2020-01-01T00:00:00.000Z\", \"lastUpdated\": \"2020-01-01T00:00:00.000Z\", \"passwordChanged\": \"2020-01-01T00:00:00.000Z\", \"type\": {\"id\": \"oty1234567890\"}, \"profile\": {\"email\": \"bob@example.com\", \"githubUsername\": \"bob-gh\"}}, {\"id\": \"00u3\", \"status\": \"SUSPENDED\", \"created\": \"2020-01-01T00:00:00.000Z\", \"activated\": \"2020-01-01T00:00:00.000Z\", \"statusChanged\": \"2020-01-01T00:00:00.000Z\", \"lastLogin\": \"2020-01-01T00:00:00.000Z\", \"lastUpdated\": \"2020-01-01T00:00:00.000Z\", \"passwordChanged\": \"2020-01-01T00:00:00.000Z\", \"type\": {\"id\": \"oty1234567890\"}, \"profile\": {\"email\": \"suspended@example.com\", \"githubUsername\": \"suspended-user\"}}, {\"id\": \"00u4\", \"status\": \"DEPROVISIONED\", \"created\": \"2020-01-01T00:00:00.000Z\", \"activated\": \"2020-01-01T00:00:00.000Z\", \"statusChanged\": \"2020-01-01T00:00:00.000Z\", \"lastLogin\": \"2020-01-01T00:00:00.000Z\", \"lastUpdated\": \"2020-01-01T00:00:00.000Z\", \"passwordChanged\": \"2020-01-01T00:00:00.000Z\", \"type\": {\"id\": \"oty1234567890\"}, \"profile\": {\"email\": \"inactive@example.com\", \"githubUsername\": \"inactive-user\"}}]" }, { "service": "slack", "method": "POST", "path": "/chat.postMessage", "status_code": 200, - "body": "{\"ok\":true,\"channel\":\"C01234TEST\",\"ts\":\"1234567890.123456\"}", - "description": "send sync report notification to slack" + "body": "{\"ok\":true,\"channel\":\"C01234TEST\",\"ts\":\"1234567890.123456\"}" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/teams", + "status_code": 200, + "body": "[]" + } + ], + "forbidden_calls": [ + { + "service": "github", + "method": "PUT", + "path": "/orgs/acme-ghorg/teams/engineering/memberships/suspended-user" + }, + { + "service": "github", + "method": "PUT", + "path": "/orgs/acme-ghorg/teams/engineering/memberships/*" + }, + { + "service": "github", + "method": "DELETE", + "path": "/orgs/acme-ghorg/teams/engineering/memberships/bob-gh" } ] }, @@ -685,6 +690,27 @@ "status_code": 200, "body": "{\"ok\":true,\"channel\":\"C01234TEST\",\"ts\":\"1234567890.123456\"}", "description": "send sync report notification to slack" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/invitations", + "status_code": 200, + "body": "[]" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/teams", + "status_code": 200, + "body": "[]" + } + ], + "forbidden_calls": [ + { + "service": "github", + "method": "DELETE", + "path": "/orgs/acme-ghorg/teams/engineering/memberships/*" } ] }, @@ -768,6 +794,27 @@ "status_code": 200, "body": "{\"ok\":true,\"channel\":\"C01234TEST\",\"ts\":\"1234567890.123456\"}", "description": "send safety threshold alert to slack (5 removals blocked)" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/invitations", + "status_code": 200, + "body": "[]" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/teams", + "status_code": 200, + "body": "[]" + } + ], + "forbidden_calls": [ + { + "service": "github", + "method": "DELETE", + "path": "/orgs/acme-ghorg/teams/engineering/memberships/*" } ] }, @@ -819,6 +866,13 @@ "body": "{\"errorCode\":\"E0000009\",\"errorSummary\":\"Service Unavailable\"}", "description": "okta api error fetching group members (service unavailable)" } + ], + "forbidden_calls": [ + { + "service": "github", + "method": "DELETE", + "path": "*" + } ] }, { @@ -901,6 +955,27 @@ "status_code": 200, "body": "{\"ok\":true,\"channel\":\"C01234TEST\",\"ts\":\"1234567890.123456\"}", "description": "send sync report with errors to slack" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/invitations", + "status_code": 200, + "body": "[]" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/teams", + "status_code": 200, + "body": "[]" + } + ], + "forbidden_calls": [ + { + "service": "github", + "method": "DELETE", + "path": "*" } ] }, @@ -1024,6 +1099,27 @@ "status_code": 200, "body": "{\"ok\":true,\"channel\":\"C01234TEST\",\"ts\":\"1234567890.123457\"}", "description": "send orphaned users notification to slack (bob-gh and charlie-gh)" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/invitations", + "status_code": 200, + "body": "[]" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/teams", + "status_code": 200, + "body": "[]" + } + ], + "forbidden_calls": [ + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/memberships/*" } ] }, @@ -1123,6 +1219,20 @@ "status_code": 200, "body": "{\"ok\":true,\"channel\":\"C01234TEST\",\"ts\":\"1234567890.123456\"}", "description": "send sync report notification to slack (no orphaned users notification)" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/invitations", + "status_code": 200, + "body": "[]" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/teams", + "status_code": 200, + "body": "[]" } ] }, @@ -1209,6 +1319,20 @@ "status_code": 200, "body": "{\"ok\":true,\"channel\":\"C01234TEST\",\"ts\":\"1234567890.123456\"}", "description": "send sync report notification to slack (orphaned user check skipped)" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/invitations", + "status_code": 200, + "body": "[]" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/teams", + "status_code": 200, + "body": "[]" } ] }, @@ -1305,7 +1429,675 @@ "status_code": 200, "body": "{\"ok\":true,\"channel\":\"C01234TEST\",\"ts\":\"1234567890.123456\"}", "description": "send sync report to slack (should include skipped users count)" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/invitations", + "status_code": 200, + "body": "[]" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/teams", + "status_code": 200, + "body": "[]" + } + ], + "forbidden_calls": [ + { + "service": "github", + "method": "PUT", + "path": "/orgs/acme-ghorg/teams/engineering/memberships/bob*" + } + ] + }, + { + "name": "pr_webhook_required_checks", + "description": "Required checks that passed as a commit status, a successful check run, or a skipped or neutral check run make a merged PR compliant", + "event_type": "webhook", + "webhook_type": "pull_request", + "webhook_payload": { + "action": "closed", + "number": 202, + "pull_request": { + "id": 202, + "number": 202, + "state": "closed", + "locked": false, + "merged": true, + "merged_at": "2025-11-22T10:30:00Z", + "merged_by": { + "login": "admin-user", + "id": 2, + "type": "User" + }, + "head": { + "label": "acme-ghorg:feature-branch", + "ref": "feature-branch", + "sha": "abc123def456" + }, + "base": { + "label": "acme-ghorg:main", + "ref": "main", + "sha": "def456abc123" + }, + "html_url": "https://github.com/acme-ghorg/fake-app-repo/pull/456" + }, + "repository": { + "id": 1000, + "name": "checks-repo", + "full_name": "acme-ghorg/checks-repo", + "owner": { + "login": "acme-ghorg", + "id": 100, + "type": "Organization" + }, + "private": false + }, + "sender": { + "login": "admin-user", + "id": 2, + "type": "User" + } + }, + "mock_responses": [ + { + "service": "github", + "method": "POST", + "path": "/app/installations/987654/access_tokens", + "status_code": 201, + "body": "{\"token\":\"ghs_mock_installation_token\",\"expires_at\":\"2099-12-31T23:59:59Z\"}" + }, + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/checks-repo/pulls/202", + "status_code": 200, + "body": "{\"number\":202,\"merged\":true,\"base\":{\"ref\":\"main\"},\"head\":{\"sha\":\"abc123\"},\"merged_by\":{\"login\":\"dev\"}}" + }, + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/checks-repo/branches/main/protection", + "status_code": 200, + "body": "{\"required_status_checks\":{\"strict\":true,\"contexts\":[\"legacy/ci\"],\"checks\":[{\"context\":\"lint\"}]}}" + }, + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/checks-repo/rules/branches/main", + "status_code": 200, + "body": "[{\"type\":\"required_status_checks\",\"ruleset_source_type\":\"Repository\",\"ruleset_source\":\"acme-ghorg/checks-repo\",\"ruleset_id\":7,\"parameters\":{\"strict_required_status_checks_policy\":false,\"required_status_checks\":[{\"context\":\"docs\"},{\"context\":\"e2e\"}]}}]" + }, + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/checks-repo/commits/abc123/status", + "status_code": 200, + "body": "{\"state\":\"success\",\"statuses\":[{\"context\":\"legacy/ci\",\"state\":\"success\"}]}" + }, + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/checks-repo/commits/abc123/check-runs", + "status_code": 200, + "body": "{\"total_count\":3,\"check_runs\":[{\"name\":\"lint\",\"conclusion\":\"success\"},{\"name\":\"docs\",\"conclusion\":\"skipped\"},{\"name\":\"e2e\",\"conclusion\":\"neutral\"}]}" + } + ], + "expected_calls": [ + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/checks-repo/commits/abc123/status" + }, + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/checks-repo/commits/abc123/check-runs" + } + ], + "forbidden_calls": [ + { + "service": "slack", + "method": "POST", + "path": "*" + }, + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/checks-repo/collaborators/*" + } + ] + }, + { + "name": "pr_webhook_other_org_ignored", + "description": "Webhooks for repositories outside the configured organization are ignored", + "event_type": "webhook", + "webhook_type": "pull_request", + "webhook_payload": { + "action": "closed", + "number": 1, + "pull_request": { + "id": 1, + "number": 1, + "state": "closed", + "locked": false, + "merged": true, + "merged_at": "2025-11-22T10:30:00Z", + "merged_by": { + "login": "admin-user", + "id": 2, + "type": "User" + }, + "head": { + "label": "acme-ghorg:feature-branch", + "ref": "feature-branch", + "sha": "abc123def456" + }, + "base": { + "label": "acme-ghorg:main", + "ref": "main", + "sha": "def456abc123" + }, + "html_url": "https://github.com/acme-ghorg/fake-app-repo/pull/456" + }, + "repository": { + "id": 1000, + "name": "repo", + "full_name": "evil-org/repo", + "owner": { + "login": "evil-org", + "id": 100, + "type": "Organization" + }, + "private": false + }, + "sender": { + "login": "admin-user", + "id": 2, + "type": "User" + } + }, + "expect_status": 202, + "mock_responses": [], + "expected_calls": [], + "forbidden_calls": [ + { + "service": "github", + "method": "*", + "path": "*" + }, + { + "service": "slack", + "method": "*", + "path": "*" + } + ] + }, + { + "name": "webhook_ping", + "description": "GitHub's ping event, sent when a webhook is created, succeeds", + "event_type": "webhook", + "webhook_type": "ping", + "webhook_payload": { + "zen": "Keep it logically awesome.", + "hook_id": 1 + }, + "expect_status": 200, + "mock_responses": [], + "expected_calls": [], + "forbidden_calls": [ + { + "service": "github", + "method": "*", + "path": "*" + } + ] + }, + { + "name": "webhook_invalid_signature", + "description": "Webhooks with a wrong signature are rejected before anything is processed", + "event_type": "webhook", + "webhook_type": "pull_request", + "signature": "invalid", + "webhook_payload": { + "action": "closed", + "number": 456, + "pull_request": { + "id": 456, + "number": 456, + "state": "closed", + "locked": false, + "merged": true, + "merged_at": "2025-11-22T10:30:00Z", + "merged_by": { + "login": "admin-user", + "id": 2, + "type": "User" + }, + "head": { + "label": "acme-ghorg:feature-branch", + "ref": "feature-branch", + "sha": "abc123def456" + }, + "base": { + "label": "acme-ghorg:main", + "ref": "main", + "sha": "def456abc123" + }, + "html_url": "https://github.com/acme-ghorg/fake-app-repo/pull/456" + }, + "repository": { + "id": 1000, + "name": "fake-app-repo", + "full_name": "acme-ghorg/fake-app-repo", + "owner": { + "login": "acme-ghorg", + "id": 100, + "type": "Organization" + }, + "private": false + }, + "sender": { + "login": "admin-user", + "id": 2, + "type": "User" + } + }, + "expect_status": 401, + "mock_responses": [], + "expected_calls": [], + "forbidden_calls": [ + { + "service": "github", + "method": "*", + "path": "*" + }, + { + "service": "slack", + "method": "*", + "path": "*" + } + ] + }, + { + "name": "webhook_unsigned", + "description": "Webhooks without a signature are rejected", + "event_type": "webhook", + "webhook_type": "membership", + "signature": "none", + "webhook_payload": { + "action": "added", + "scope": "team", + "member": { + "login": "x" + }, + "team": { + "slug": "engineering" + }, + "organization": { + "login": "acme-ghorg" + }, + "sender": { + "login": "mallory", + "type": "User" + } + }, + "expect_status": 401, + "mock_responses": [], + "expected_calls": [], + "forbidden_calls": [ + { + "service": "okta", + "method": "*", + "path": "*" + } + ] + }, + { + "name": "membership_webhook_triggers_sync", + "description": "A person changing a team's members outside Okta queues a sync, which runs after the response", + "event_type": "webhook", + "webhook_type": "membership", + "webhook_payload": { + "action": "added", + "scope": "team", + "member": { + "login": "intruder" + }, + "team": { + "slug": "engineering" + }, + "organization": { + "login": "acme-ghorg" + }, + "sender": { + "login": "mallory", + "type": "User" + } + }, + "mock_responses": [ + { + "service": "github", + "method": "POST", + "path": "/app/installations/987654/access_tokens", + "status_code": 201, + "body": "{\"token\":\"ghs_mock_installation_token\",\"expires_at\":\"2099-12-31T23:59:59Z\"}", + "description": "github app installation token authentication" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering", + "status_code": 200, + "body": "{\"id\":1,\"name\":\"Engineering\",\"slug\":\"engineering\",\"description\":\"Engineering team\"}", + "description": "fetch engineering team details" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/members", + "status_code": 200, + "body": "[]", + "description": "engineering team has no existing members" + }, + { + "service": "github", + "method": "PUT", + "path": "/orgs/acme-ghorg/teams/engineering/memberships/*", + "status_code": 200, + "body": "{\"state\":\"active\",\"role\":\"member\"}", + "description": "add user to engineering team" + }, + { + "service": "okta", + "method": "POST", + "path": "/oauth2/v1/token", + "status_code": 200, + "body": "{\"token_type\":\"Bearer\",\"expires_in\":3600,\"access_token\":\"mock-access-token\",\"scope\":\"okta.groups.read okta.users.read\"}", + "description": "okta oauth 2.0 token authentication" + }, + { + "service": "okta", + "method": "GET", + "path": "/api/v1/groups", + "status_code": 200, + "body": "[{\"id\": \"00g1234567890abcdef\", \"created\": \"2020-01-01T00:00:00.000Z\", \"lastUpdated\": \"2020-01-01T00:00:00.000Z\", \"lastMembershipUpdated\": \"2020-01-01T00:00:00.000Z\", \"objectClass\": [\"okta:user_group\"], \"type\": \"OKTA_GROUP\", \"profile\": {\"name\": \"Engineering\", \"description\": \"Engineering team\"}}]", + "description": "fetch all okta groups (returns engineering group)" + }, + { + "service": "okta", + "method": "GET", + "path": "/api/v1/groups/00g1234567890abcdef/users", + "status_code": 200, + "body": "[{\"id\": \"00u1111111111111111\", \"status\": \"ACTIVE\", \"created\": \"2020-01-01T00:00:00.000Z\", \"activated\": \"2020-01-01T00:00:00.000Z\", \"statusChanged\": \"2020-01-01T00:00:00.000Z\", \"lastLogin\": \"2020-01-01T00:00:00.000Z\", \"lastUpdated\": \"2020-01-01T00:00:00.000Z\", \"passwordChanged\": \"2020-01-01T00:00:00.000Z\", \"type\": {\"id\": \"oty1234567890\"}, \"profile\": {\"email\": \"alice@example.com\", \"githubUsername\": \"alice-gh\"}}]", + "description": "fetch users in engineering group (returns alice-gh)" + }, + { + "service": "slack", + "method": "POST", + "path": "/chat.postMessage", + "status_code": 200, + "body": "{\"ok\":true,\"channel\":\"C01234TEST\",\"ts\":\"1234567890.123456\"}", + "description": "send sync report notification to slack" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/invitations", + "status_code": 200, + "body": "[]" + }, + { + "service": "github", + "method": "GET", + "path": "/app", + "status_code": 200, + "body": "{\"slug\":\"ops-app\"}" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/teams", + "status_code": 200, + "body": "[]" + } + ], + "expected_calls": [ + { + "service": "okta", + "method": "GET", + "path": "/api/v1/groups/00g1234567890abcdef/users" + }, + { + "service": "slack", + "method": "POST", + "path": "/chat.postMessage" + } + ], + "expect_status": 202 + }, + { + "name": "scheduled_requires_admin_token", + "description": "Scheduled actions over HTTP require the admin token", + "event_type": "scheduled_event", + "admin_auth": "none", + "event_payload": { + "action": "okta-sync" + }, + "expect_status": 401, + "mock_responses": [], + "expected_calls": [], + "forbidden_calls": [ + { + "service": "okta", + "method": "*", + "path": "*" + }, + { + "service": "github", + "method": "*", + "path": "*" + } + ] + }, + { + "name": "installation_discovered_from_org", + "description": "Without an installation id, the app's installation on the organization is looked up", + "event_type": "scheduled_event", + "event_payload": { + "action": "okta-sync" + }, + "config_overrides": { + "APP_GITHUB_INSTALLATION_ID": "" + }, + "mock_responses": [ + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/installation", + "status_code": 200, + "body": "{\"id\":987654}" + }, + { + "service": "github", + "method": "POST", + "path": "/app/installations/987654/access_tokens", + "status_code": 201, + "body": "{\"token\":\"ghs_mock_installation_token\",\"expires_at\":\"2099-12-31T23:59:59Z\"}", + "description": "github app installation token authentication" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering", + "status_code": 200, + "body": "{\"id\":1,\"name\":\"Engineering\",\"slug\":\"engineering\",\"description\":\"Engineering team\"}", + "description": "fetch engineering team details" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/members", + "status_code": 200, + "body": "[]", + "description": "engineering team has no existing members" + }, + { + "service": "github", + "method": "PUT", + "path": "/orgs/acme-ghorg/teams/engineering/memberships/*", + "status_code": 200, + "body": "{\"state\":\"active\",\"role\":\"member\"}", + "description": "add user to engineering team" + }, + { + "service": "okta", + "method": "POST", + "path": "/oauth2/v1/token", + "status_code": 200, + "body": "{\"token_type\":\"Bearer\",\"expires_in\":3600,\"access_token\":\"mock-access-token\",\"scope\":\"okta.groups.read okta.users.read\"}", + "description": "okta oauth 2.0 token authentication" + }, + { + "service": "okta", + "method": "GET", + "path": "/api/v1/groups", + "status_code": 200, + "body": "[{\"id\": \"00g1234567890abcdef\", \"created\": \"2020-01-01T00:00:00.000Z\", \"lastUpdated\": \"2020-01-01T00:00:00.000Z\", \"lastMembershipUpdated\": \"2020-01-01T00:00:00.000Z\", \"objectClass\": [\"okta:user_group\"], \"type\": \"OKTA_GROUP\", \"profile\": {\"name\": \"Engineering\", \"description\": \"Engineering team\"}}]", + "description": "fetch all okta groups (returns engineering group)" + }, + { + "service": "okta", + "method": "GET", + "path": "/api/v1/groups/00g1234567890abcdef/users", + "status_code": 200, + "body": "[{\"id\": \"00u1111111111111111\", \"status\": \"ACTIVE\", \"created\": \"2020-01-01T00:00:00.000Z\", \"activated\": \"2020-01-01T00:00:00.000Z\", \"statusChanged\": \"2020-01-01T00:00:00.000Z\", \"lastLogin\": \"2020-01-01T00:00:00.000Z\", \"lastUpdated\": \"2020-01-01T00:00:00.000Z\", \"passwordChanged\": \"2020-01-01T00:00:00.000Z\", \"type\": {\"id\": \"oty1234567890\"}, \"profile\": {\"email\": \"alice@example.com\", \"githubUsername\": \"alice-gh\"}}]", + "description": "fetch users in engineering group (returns alice-gh)" + }, + { + "service": "slack", + "method": "POST", + "path": "/chat.postMessage", + "status_code": 200, + "body": "{\"ok\":true,\"channel\":\"C01234TEST\",\"ts\":\"1234567890.123456\"}", + "description": "send sync report notification to slack" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/invitations", + "status_code": 200, + "body": "[]" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/teams/engineering/teams", + "status_code": 200, + "body": "[]" + } + ], + "expected_calls": [ + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/installation" + }, + { + "service": "github", + "method": "POST", + "path": "/app/installations/987654/access_tokens" + } + ] + }, + { + "name": "security_alerts_paginated", + "description": "Stale alerts are collected across every page of every source and reported to Slack", + "event_type": "scheduled_event", + "event_payload": { + "action": "security-alerts" + }, + "config_overrides": { + "APP_SECURITY_ALERTS_ENABLED": "true" + }, + "mock_responses": [ + { + "service": "github", + "method": "POST", + "path": "/app/installations/987654/access_tokens", + "status_code": 201, + "body": "{\"token\":\"ghs_mock_installation_token\",\"expires_at\":\"2099-12-31T23:59:59Z\"}" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/dependabot/alerts", + "status_code": 200, + "body": "[{\"number\":2,\"created_at\":\"2020-01-01T00:00:00Z\",\"repository\":{\"full_name\":\"acme-ghorg/api\"},\"security_advisory\":{\"severity\":\"critical\",\"summary\":\"second page\"}}]", + "query": "after=cursor-2" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/dependabot/alerts", + "status_code": 200, + "body": "[{\"number\":1,\"created_at\":\"2020-01-01T00:00:00Z\",\"repository\":{\"full_name\":\"acme-ghorg/api\"},\"security_advisory\":{\"severity\":\"high\",\"summary\":\"first page\"}}]", + "headers": { + "Link": "; rel=\"next\"" + } + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/code-scanning/alerts", + "status_code": 200, + "body": "[{\"number\":3,\"created_at\":\"2099-01-01T00:00:00Z\",\"repository\":{\"full_name\":\"acme-ghorg/api\"},\"rule\":{\"security_severity_level\":\"critical\"}}]" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/secret-scanning/alerts", + "status_code": 200, + "body": "[{\"number\":4,\"created_at\":\"2020-01-01T00:00:00Z\",\"repository\":{\"full_name\":\"acme-ghorg/api\"},\"secret_type_display_name\":\"AWS Access Key\"}]" + }, + { + "service": "slack", + "method": "POST", + "path": "/chat.postMessage", + "status_code": 200, + "body": "{\"ok\":true,\"channel\":\"C01234TEST\",\"ts\":\"1234567890.123456\"}" + } + ], + "expected_calls": [ + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/dependabot/alerts" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/dependabot/alerts", + "query": "after=cursor-2" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/code-scanning/alerts" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/secret-scanning/alerts" + }, + { + "service": "slack", + "method": "POST", + "path": "/chat.postMessage" } ] } -] \ No newline at end of file +] diff --git a/go.mod b/go.mod index 7f069fb..f26b65d 100644 --- a/go.mod +++ b/go.mod @@ -1,60 +1,54 @@ module github.com/cruxstack/github-ops-app -go 1.24.4 +go 1.27.3 require ( - github.com/aws/aws-lambda-go v1.50.0 - github.com/aws/aws-sdk-go-v2 v1.40.0 - github.com/aws/aws-sdk-go-v2/config v1.32.1 - github.com/aws/aws-sdk-go-v2/service/ssm v1.67.3 - github.com/cockroachdb/errors v1.12.0 - github.com/golang-jwt/jwt/v5 v5.3.0 - github.com/google/go-github/v79 v79.0.0 + github.com/aws/aws-lambda-go v1.55.1 + github.com/cruxstack/github-app-setup-go v0.7.1 + github.com/go-chi/chi/v5 v5.5.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/go-github/v93 v93.0.0 github.com/joho/godotenv v1.5.1 - github.com/okta/okta-sdk-golang/v6 v6.0.1 - github.com/slack-go/slack v0.17.3 - golang.org/x/oauth2 v0.33.0 + github.com/okta/okta-sdk-golang/v8 v8.0.0 + github.com/slack-go/slack v0.35.0 ) require ( - github.com/aws/aws-sdk-go-v2/credentials v1.19.1 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.1 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.4 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.41.1 // indirect - github.com/aws/smithy-go v1.23.2 // indirect + github.com/aws/aws-sdk-go-v2 v1.47.8 // indirect + github.com/aws/aws-sdk-go-v2/config v1.33.16 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.20.16 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.11 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.11 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.11 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.24 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.11 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.12.4 // indirect + github.com/aws/aws-sdk-go-v2/service/ssm v1.82.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.39.4 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.51.9 // indirect + github.com/aws/smithy-go v1.28.8 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect - github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect - github.com/cockroachdb/redact v1.1.5 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect - github.com/getsentry/sentry-go v0.27.0 // indirect - github.com/go-chi/chi/v5 v5.2.5 // indirect - github.com/go-jose/go-jose/v4 v4.1.3 // indirect - github.com/goccy/go-json v0.10.5 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/go-querystring v1.1.0 // indirect + github.com/chainguard-dev/clog v1.8.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.9 // indirect + github.com/goccy/go-json v0.12.0 // indirect + github.com/google/go-querystring v1.2.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/kelseyhightower/envconfig v1.4.0 // indirect github.com/kr/pretty v0.3.1 // indirect - github.com/kr/text v0.2.0 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect - github.com/lestrrat-go/httprc/v3 v3.0.1 // indirect - github.com/lestrrat-go/jwx/v3 v3.0.12 // indirect - github.com/lestrrat-go/option v1.0.1 // indirect + github.com/lestrrat-go/httprc/v3 v3.0.6 // indirect + github.com/lestrrat-go/jwx/v3 v3.4.0 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/segmentio/asm v1.2.1 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.30.0 // indirect + github.com/valyala/fastjson v1.6.10 // indirect + golang.org/x/oauth2 v0.37.0 // indirect + golang.org/x/sys v0.49.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ffd662f..d57f481 100644 --- a/go.sum +++ b/go.sum @@ -1,72 +1,62 @@ -github.com/aws/aws-lambda-go v1.50.0 h1:0GzY18vT4EsCvIyk3kn3ZH5Jg30NRlgYaai1w0aGPMU= -github.com/aws/aws-lambda-go v1.50.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= -github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc= -github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= -github.com/aws/aws-sdk-go-v2/config v1.32.1 h1:iODUDLgk3q8/flEC7ymhmxjfoAnBDwEEYEVyKZ9mzjU= -github.com/aws/aws-sdk-go-v2/config v1.32.1/go.mod h1:xoAgo17AGrPpJBSLg81W+ikM0cpOZG8ad04T2r+d5P0= -github.com/aws/aws-sdk-go-v2/credentials v1.19.1 h1:JeW+EwmtTE0yXFK8SmklrFh/cGTTXsQJumgMZNlbxfM= -github.com/aws/aws-sdk-go-v2/credentials v1.19.1/go.mod h1:BOoXiStwTF+fT2XufhO0Efssbi1CNIO/ZXpZu87N0pw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.1 h1:BDgIUYGEo5TkayOWv/oBLPphWwNm/A91AebUjAu5L5g= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.1/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk= -github.com/aws/aws-sdk-go-v2/service/ssm v1.67.3 h1:ofiQvKwka2E3T8FXBsU1iWj7Yvk2wd1p4ZCdS6qGiKQ= -github.com/aws/aws-sdk-go-v2/service/ssm v1.67.3/go.mod h1:+nlWvcgDPQ56mChEBzTC0puAMck+4onOFaHg5cE+Lgg= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.4 h1:U//SlnkE1wOQiIImxzdY5PXat4Wq+8rlfVEw4Y7J8as= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.4/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9 h1:LU8S9W/mPDAU9q0FjCLi0TrCheLMGwzbRpvUMwYspcA= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.9/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.1 h1:GdGmKtG+/Krag7VfyOXV17xjTCz0i9NT+JnqLTOI5nA= -github.com/aws/aws-sdk-go-v2/service/sts v1.41.1/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso= -github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= -github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/aws-lambda-go v1.55.1 h1:We2cCp4BwqqH/JW+bEEo1FhgG71rslvjfi4y7KmlrR0= +github.com/aws/aws-lambda-go v1.55.1/go.mod h1:V+NzkHNR6vBC8C1PDloqSLE+7jYWFiPvJJFiCiTm8nE= +github.com/aws/aws-sdk-go-v2 v1.47.8 h1:fuClo7Un+Bi0EDTnSIPWxVEoJcze+wkGzNA4Vo3jRcQ= +github.com/aws/aws-sdk-go-v2 v1.47.8/go.mod h1:I8lFSH4MGrZiw2m7miCrRYHsgpC1+HW8uiVGOiPYPcw= +github.com/aws/aws-sdk-go-v2/config v1.33.16 h1:fKRsu9Pj2IPIHzTcUdWB7JHY34q9+4XEGCy7WfZaLV4= +github.com/aws/aws-sdk-go-v2/config v1.33.16/go.mod h1:wdEdQ9RBH3fnD/vicCDUIJqv8Ck3zOpxNz8e3/NtkG8= +github.com/aws/aws-sdk-go-v2/credentials v1.20.16 h1:cqRWTY4fcctJGqZedMC1Khu4h2VsgRODUOIZwn39n3Y= +github.com/aws/aws-sdk-go-v2/credentials v1.20.16/go.mod h1:x8JgCOAnbnEVQOLVX6lOtsyTrG2EHgiRNqzku9jiVxA= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.8 h1:1bCo9/cf7dlTlbrZnz7g1Bki72b5thfuzFZCjdWCfVk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.8/go.mod h1:mzPw75GydqddJpfuwYO/ZDIl6xNxKjK86apitdLmvV0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.11 h1:LTxHSJ/rwTFfaXqi5mlKKHJOW+shFB+HQ7NrCa4OQ48= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.11/go.mod h1:GF0bVKZVQX4UeUtPmTuWZj3gw0eRCdkVhW0xFnSRLmU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.11 h1:DGUM0yztI3Zz5kePulT/mMP/gVZFGTTISUiCcqSQtT0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.11/go.mod h1:kbcdJgN9cgSzZlUqsMveDl9IfexJqOzCihXjQXTNaPk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.11 h1:kOHCwgWFyfrz9GqnJct8knQw7Apyl3xHH+OGnome77s= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.11/go.mod h1:854cZkT2fdkdrzQklD49uzxkZnajXoelpVNgT/yk4J0= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.24 h1:jGzHjc2jDx08Rji1JTBtMxuXUDG7muTEw+ExYXqFzPg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.24/go.mod h1:uJt75pK2cEyY80F5C+DK9YkCkJWsMkJViHxLYZRHl8Y= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.11 h1:PO7Tx443ecMI00nIxDrT99DVcbeMBQ6BUtJlDMRnY24= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.11/go.mod h1:n/9RixikO12YvNomZLP8k4TgUSoEkT1W23QIrpFAPvw= +github.com/aws/aws-sdk-go-v2/service/signin v1.12.4 h1:uo84u6f6YatFOBwk3DKM+ToqgVEB0cDjHQLsfrRm44A= +github.com/aws/aws-sdk-go-v2/service/signin v1.12.4/go.mod h1:PCRJucC12nFWle7wRGhSj0cw3OKB84M0EBwTrv34dhg= +github.com/aws/aws-sdk-go-v2/service/ssm v1.82.0 h1:Ev6Ncm630UzzxAZ9qqzNlUeXKKFC6aSdh+6q85d5x/s= +github.com/aws/aws-sdk-go-v2/service/ssm v1.82.0/go.mod h1:IzZJ1sK4pwgVvlTKAJmIzO4M5TlmSqDk552XYKO+nbs= +github.com/aws/aws-sdk-go-v2/service/sso v1.39.4 h1:qQ97XqrlFGRRhhrcDbeqUVeezamo1a1F7lH4HczU7EA= +github.com/aws/aws-sdk-go-v2/service/sso v1.39.4/go.mod h1:mPM3sWIm0iUGl5OWkR9cG23PRFQ0Ir6y+3Hg0lKBAAE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.4 h1:tbsL9+L+1SlD2W+9RjL7wD9ZVsxel7QXdQlIWt5tEi8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.4/go.mod h1:3L2pKVr2e3jaz9C26RXgjBIktSW1D7S0O38LhCdsAYc= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.9 h1:soG/8YBczEJwckK+e4HLt0RzfkvopGrdBjmbghG99Cc= +github.com/aws/aws-sdk-go-v2/service/sts v1.51.9/go.mod h1:We33qfrD3dQRAetZ+9qUH9w+ZjL1gwP2Qvp1YqQDdsk= +github.com/aws/smithy-go v1.28.8 h1:aEmdAq7gVCoGCnvf1NnELlu3I6D/nhqrC321FyFiQkQ= +github.com/aws/smithy-go v1.28.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/cockroachdb/errors v1.12.0 h1:d7oCs6vuIMUQRVbi6jWWWEJZahLCfJpnJSVobd1/sUo= -github.com/cockroachdb/errors v1.12.0/go.mod h1:SvzfYNNBshAVbZ8wzNc/UPK3w1vf0dKDUP41ucAIf7g= -github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= -github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= -github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= -github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/chainguard-dev/clog v1.8.1 h1:Lab3GEDsVm1J9XGlpWEBuzXX7eETRmd0vN5PYoNyT+Y= +github.com/chainguard-dev/clog v1.8.1/go.mod h1:5MQOZi+Iu7fV7GcJG8ag8rCB5elEOpqRMKEASgnGVdo= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cruxstack/github-app-setup-go v0.7.1 h1:LxhvlOmZzaN+zCBAK7I1kp9bUyMxjPkXD0OudWKiFIM= +github.com/cruxstack/github-app-setup-go v0.7.1/go.mod h1:zUcmElrUjvjAUQYnFj/P4spSAsaueT64tSkH+GvY58M= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= -github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= -github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= -github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= -github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= -github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= -github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= -github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= -github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/go-chi/chi/v5 v5.5.0 h1:xdKQ1dJmVJsmgahrXSKhUx8NyVPzpwdlyrrQFpMPJgg= +github.com/go-chi/chi/v5 v5.5.0/go.mod h1:d1IG7GtKPSjPyBP365PH8sAGVHk3qfB/IhjFNRH1wWM= +github.com/go-jose/go-jose/v4 v4.1.9 h1:pKfeQkig1NmgK4hJrZYXLEYURXfoJB61Larw0uIW4gY= +github.com/go-jose/go-jose/v4 v4.1.9/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-test/deep v1.1.2 h1:JUuyv17rKrqYfLFnP9A4Yh6hw0635ugB93bMg2bwuDQ= +github.com/go-test/deep v1.1.2/go.mod h1:lwVFiFFWPjydTsV3xGJ/YTQsnWwi8zxBFM+gOVcwINE= +github.com/goccy/go-json v0.12.0 h1:pD+oWouAiz4yruwXIt/iT+lOMeyhr1aTtxzaMP/RgVI= +github.com/goccy/go-json v0.12.0/go.mod h1:3NdmfEkZlB7YI5UFw/qdFKq8XN1aiWR0YyRPWZNQltY= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v79 v79.0.0 h1:MdodQojuFPBhmtwHiBcIGLw/e/wei2PvFX9ndxK0X4Y= -github.com/google/go-github/v79 v79.0.0/go.mod h1:OAFbNhq7fQwohojb06iIIQAB9CBGYLq999myfUFnrS4= -github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= -github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/go-github/v93 v93.0.0 h1:qElXPLv+XbSz1qrLdWyb0t3gSYGsWBTzr9T4/PZTCfk= +github.com/google/go-github/v93 v93.0.0/go.mod h1:l8I3rt4/mdjANKpg4Y84LEy9HJQCetUkZ8WTl2bPU8M= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -75,87 +65,51 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= -github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38= -github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= +github.com/lestrrat-go/dsig v1.4.0 h1:g7LUjK8cT74A5DzBXJI5HzsJuLhoYN0Wzj4nuOMIrH8= +github.com/lestrrat-go/dsig v1.4.0/go.mod h1:I8Nddg/vN2cUl/h8N7SRRApLnNNeyZPIqLYpvpOtGGo= github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY= github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= -github.com/lestrrat-go/httprc/v3 v3.0.1 h1:3n7Es68YYGZb2Jf+k//llA4FTZMl3yCwIjFIk4ubevI= -github.com/lestrrat-go/httprc/v3 v3.0.1/go.mod h1:2uAvmbXE4Xq8kAUjVrZOq1tZVYYYs5iP62Cmtru00xk= -github.com/lestrrat-go/jwx/v3 v3.0.12 h1:p25r68Y4KrbBdYjIsQweYxq794CtGCzcrc5dGzJIRjg= -github.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8= -github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= -github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI= +github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= +github.com/lestrrat-go/jwx/v3 v3.4.0 h1:2YSO4sfAj+M90XUuEJZsMibRNpoh1KXP1iL+C/tFebg= +github.com/lestrrat-go/jwx/v3 v3.4.0/go.mod h1:jrZZIM3dgHB780ZrD/3H+ARR5ou4w3X3wFjWE+JWGMU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= -github.com/okta/okta-sdk-golang/v6 v6.0.1 h1:i49fQkgTTSgVrXmLepwQy5wf99q9m8xMZ7b/DamyTcI= -github.com/okta/okta-sdk-golang/v6 v6.0.1/go.mod h1:EzV8yrIDarJfL8lAwUmfcuVQmaXe7gbhWyzKoCSb/WU= +github.com/okta/okta-sdk-golang/v8 v8.0.0 h1:YgNKanPK0jiQ0UF0Ak+xjwf+xogLjVZ3cSzJtKEQkDI= +github.com/okta/okta-sdk-golang/v8 v8.0.0/go.mod h1:f5CXc51rAeVANx2FrhSX8cioaZidxTuT82hWFrmX5E4= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= -github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= -github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= -github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= +github.com/slack-go/slack v0.35.0 h1:lxUmPzkYYlSHLMNdmgvtdzGFnKAJiApA5gZiMcGEYiw= +github.com/slack-go/slack v0.35.0/go.mod h1:swfK2lWHa2StZ3TXdUdK9sBzcC+oDXpW+jsHd5PoPRM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= -github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= -golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= +github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/app/app.go b/internal/app/app.go index 012b311..6d982e4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,94 +1,197 @@ -// Package app provides the core application logic for the GitHub bot. -// Coordinates webhook processing, Okta sync, and PR compliance checks. +// Package app is the application: it builds the GitHub, Okta, and Slack +// clients from the configuration, serves the webhook, admin, and scheduled +// endpoints, and runs the Okta sync, PR compliance, and security alert +// features. package app import ( "context" - "encoding/json" + "errors" + "fmt" "log/slog" "net/http" "sync" + "time" - "github.com/cockroachdb/errors" "github.com/cruxstack/github-ops-app/internal/config" "github.com/cruxstack/github-ops-app/internal/domain" + ghclient "github.com/cruxstack/github-ops-app/internal/github/client" + "github.com/cruxstack/github-ops-app/internal/notifiers" + "github.com/cruxstack/github-ops-app/internal/okta" ) -// App is the main application instance containing all clients and -// configuration. depends on domain interfaces, not concrete implementations. -type App struct { - Config *config.Config - Logger *slog.Logger - GitHubClient domain.GitHubClient - OktaClient domain.OktaClient - Notifier domain.Notifier - router http.Handler - routerOnce sync.Once +// Scheduled actions. +const ( + ActionOktaSync = "okta-sync" + ActionSecurityAlerts = "security-alerts" + ActionSlackTest = "slack-test" +) + +// JobTimeout bounds a sync or report, and a queued follow-up sync. +const JobTimeout = 10 * time.Minute + +// Background runs work after the response to a request is sent, with a +// context of its own. +type Background interface { + Go(f func(ctx context.Context)) +} + +// Option replaces a dependency that New would otherwise build. +type Option func(*deps) + +type deps struct { + httpClient *http.Client + github domain.GitHubClient + okta domain.OktaClient + notifier domain.Notifier + background Background +} + +// WithBackground runs webhook-triggered syncs in the background, so the +// webhook is answered at once. Without it they run before responding, as +// Lambda requires. +func WithBackground(b Background) Option { + return func(d *deps) { d.background = b } +} + +// WithHTTPClient sets the HTTP client every API client sends requests with. +func WithHTTPClient(c *http.Client) Option { + return func(d *deps) { d.httpClient = c } +} + +// WithGitHubClient replaces the GitHub client. +func WithGitHubClient(c domain.GitHubClient) Option { + return func(d *deps) { d.github = c } +} + +// WithOktaClient replaces the Okta client. +func WithOktaClient(c domain.OktaClient) Option { + return func(d *deps) { d.okta = c } } -// ScheduledEvent represents a generic scheduled event. -type ScheduledEvent struct { - Action string `json:"action"` - Data json.RawMessage `json:"data,omitempty"` +// WithNotifier replaces the Slack notifier. +func WithNotifier(n domain.Notifier) Option { + return func(d *deps) { d.notifier = n } } -// processScheduledEvent handles scheduled events (e.g., cron jobs). -// routes to appropriate handlers based on event action. -func (a *App) processScheduledEvent(ctx context.Context, evt ScheduledEvent) error { - if a.Config.DebugEnabled { - j, _ := json.Marshal(evt) - a.Logger.Debug("received scheduled event", slog.String("event", string(j))) +// App is the configured application. It is safe for concurrent use. +type App struct { + cfg *config.Config + logger *slog.Logger + github domain.GitHubClient + okta domain.OktaClient // nil when okta sync is not configured + notifier domain.Notifier // nil when slack is not configured + // background runs webhook-triggered syncs; nil runs them inline + background Background + handler http.Handler + + // syncMu guards syncRunning and syncPending, which coalesce okta syncs + // requested while one is running into a single follow-up sync, and + // managedTeams, the slugs the last complete sync managed (nil before + // one). + syncMu sync.Mutex + syncRunning bool + syncPending bool + managedTeams map[string]bool +} + +// New builds the application. It makes no network calls, so it fails only +// on invalid configuration. +func New(cfg *config.Config, logger *slog.Logger, opts ...Option) (*App, error) { + if cfg == nil { + return nil, errors.New("config is required") + } + if logger == nil { + logger = slog.Default() + } + var d deps + for _, opt := range opts { + opt(&d) } - switch evt.Action { - case "okta-sync": - return a.handleOktaSync(ctx) - case "security-alerts": - return a.handleSecurityAlerts(ctx) - case "slack-test": - return a.handleSlackTest(ctx) - default: - return errors.Newf("unknown scheduled action: %s", evt.Action) + a := &App{cfg: cfg, logger: logger, github: d.github, okta: d.okta, notifier: d.notifier, background: d.background} + + if a.github == nil { + gh, err := ghclient.New(ghclient.Options{ + AppID: cfg.GitHub.AppID, + InstallationID: cfg.GitHub.InstallationID, + PrivateKey: cfg.GitHub.PrivateKey, + Org: cfg.GitHub.Org, + BaseURL: cfg.GitHub.BaseURL, + HTTPClient: d.httpClient, + }) + if err != nil { + return nil, fmt.Errorf("failed to create github client: %w", err) + } + a.github = gh } -} -// processWebhook handles incoming GitHub webhook events. -// supports pull_request, team, and membership events. -func (a *App) processWebhook(ctx context.Context, payload []byte, eventType string) error { - if a.Config.DebugEnabled { - a.Logger.Debug("received webhook", slog.String("event_type", eventType)) + if a.okta == nil && cfg.Okta.Enabled { + oc, err := okta.New(okta.Options{ + Domain: cfg.Okta.Domain, + ClientID: cfg.Okta.ClientID, + PrivateKey: cfg.Okta.PrivateKey, + PrivateKeyID: cfg.Okta.PrivateKeyID, + Scopes: cfg.Okta.Scopes, + GitHubUserField: cfg.Okta.GitHubUserField, + BaseURL: cfg.Okta.BaseURL, + HTTPClient: d.httpClient, + }) + if err != nil { + return nil, fmt.Errorf("failed to create okta client: %w", err) + } + a.okta = oc } - switch eventType { - case "pull_request": - return a.handlePullRequestWebhook(ctx, payload) - case "team": - return a.handleTeamWebhook(ctx, payload) - case "membership": - return a.handleMembershipWebhook(ctx, payload) - default: - return errors.Wrapf(domain.ErrInvalidEventType, "%s", eventType) + if a.notifier == nil && cfg.Slack.Enabled { + a.notifier = notifiers.New(notifiers.Options{ + Token: cfg.Slack.Token, + Channels: notifiers.SlackChannels{ + Default: cfg.Slack.Channel, + PRBypass: cfg.Slack.ChannelPRBypass, + OktaSync: cfg.Slack.ChannelOktaSync, + OrphanedUsers: cfg.Slack.ChannelOrphanedUsers, + SecurityAlerts: cfg.Slack.ChannelSecurityAlerts, + }, + Messages: notifiers.SlackMessages{PRBypassFooterNote: cfg.Slack.PRBypassFooterNote}, + APIURL: cfg.Slack.APIURL, + GitHubWebURL: cfg.GitHub.WebURL, + HTTPClient: d.httpClient, + }) } + + a.handler = a.newRouter() + return a, nil +} + +// Config returns the application's configuration. +func (a *App) Config() *config.Config { + return a.cfg +} + +// Handler returns the application's HTTP handler. +func (a *App) Handler() http.Handler { + return a.handler } -// StatusResponse contains application status and feature flags. -type StatusResponse struct { +// Status describes the application and its enabled features. +type Status struct { Status string `json:"status"` - GitHubConfigured bool `json:"github_configured"` + GitHubOrg string `json:"github_org"` OktaSyncEnabled bool `json:"okta_sync_enabled"` - PRComplianceCheck bool `json:"pr_compliance_check"` + PRComplianceEnabled bool `json:"pr_compliance_enabled"` SecurityAlertsEnabled bool `json:"security_alerts_enabled"` SlackEnabled bool `json:"slack_enabled"` } -// GetStatus returns current application status and enabled features. -func (a *App) GetStatus() StatusResponse { - return StatusResponse{ +// Status returns the application's status. +func (a *App) Status() Status { + return Status{ Status: "ok", - GitHubConfigured: a.Config.IsGitHubConfigured(), - OktaSyncEnabled: a.Config.IsOktaSyncEnabled(), - PRComplianceCheck: a.Config.IsPRComplianceEnabled(), - SecurityAlertsEnabled: a.Config.IsSecurityAlertsEnabled(), - SlackEnabled: a.Config.SlackEnabled, + GitHubOrg: a.cfg.GitHub.Org, + OktaSyncEnabled: a.okta != nil, + PRComplianceEnabled: a.cfg.PRCompliance.Enabled, + SecurityAlertsEnabled: a.cfg.SecurityAlerts.Enabled, + SlackEnabled: a.notifier != nil, } } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 3ababf0..2b9c6fc 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -1,324 +1,553 @@ package app import ( + "bytes" "context" + "encoding/json" + "errors" "log/slog" "net/http" "net/http/httptest" - "os" + "strings" + "sync" "testing" + "time" + + "github.com/google/go-github/v93/github" "github.com/cruxstack/github-ops-app/internal/config" "github.com/cruxstack/github-ops-app/internal/domain" + "github.com/cruxstack/github-ops-app/internal/fakes" + "github.com/cruxstack/github-ops-app/internal/github/webhooks" +) + +const ( + testSecret = "webhook-secret" + testAdminToken = "admin-token-0123456789" ) -func TestHandleSlackTest_NotConfigured(t *testing.T) { - app := &App{ - Config: &config.Config{}, - Logger: slog.New(slog.NewTextHandler(os.Stderr, nil)), - Notifier: nil, +type harness struct { + app *App + github *fakes.GitHub + okta *fakes.Okta + notifier *fakes.Notifier +} + +func testConfig() *config.Config { + return &config.Config{ + AdminToken: testAdminToken, + GitHub: config.GitHub{ + Org: "acme", + AppID: 1, + WebhookSecret: testSecret, + WebURL: config.DefaultGitHubWebURL, + }, + PRCompliance: config.PRCompliance{Enabled: true, MonitoredBranches: []string{"main", "release/*"}}, + Okta: config.Okta{Enabled: true, SyncRules: []domain.SyncRule{{OktaGroupName: "eng"}}, SyncSafetyThreshold: 0.5, OrphanedUserNotifications: true}, + SecurityAlerts: config.SecurityAlerts{Enabled: true, MinAgeDays: 30, MinSeverity: "high"}, + } +} + +func newHarness(t *testing.T, cfg *config.Config, withOkta bool) *harness { + t.Helper() + h := &harness{github: &fakes.GitHub{}, okta: &fakes.Okta{}, notifier: &fakes.Notifier{}} + opts := []Option{WithGitHubClient(h.github), WithNotifier(h.notifier)} + if withOkta { + opts = append(opts, WithOktaClient(h.okta)) + } else { + cfg.Okta.Enabled = false + } + a, err := New(cfg, slog.New(slog.DiscardHandler), opts...) + if err != nil { + t.Fatal(err) + } + h.app = a + return h +} + +func (h *harness) do(t *testing.T, req *http.Request) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + h.app.Handler().ServeHTTP(rec, req) + return rec +} + +func webhookRequest(t *testing.T, event string, payload any, secret string) *http.Request { + t.Helper() + body, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/webhooks", bytes.NewReader(body)) + req.Header.Set("X-GitHub-Event", event) + if secret != "" { + req.Header.Set("X-Hub-Signature-256", webhooks.Sign(body, secret)) } + return req +} - err := app.handleSlackTest(context.Background()) - if err == nil { - t.Error("expected error when slack is not configured") +func adminRequest(method, target, auth string) *http.Request { + req := httptest.NewRequestWithContext(context.Background(), method, target, nil) + if auth != "" { + req.Header.Set("Authorization", auth) } + return req +} - if err.Error() != "slack is not configured" { - t.Errorf("expected 'slack is not configured' error, got: %v", err) +func mergedPR(org, branch string) map[string]any { + return map[string]any{ + "action": "closed", + "pull_request": map[string]any{ + "number": 7, + "merged": true, + "base": map[string]any{"ref": branch}, + }, + "repository": map[string]any{"name": "web", "full_name": org + "/web", "owner": map[string]any{"login": org}}, + "sender": map[string]any{"login": "alice", "type": "User"}, } } -func TestFakePRComplianceResult(t *testing.T) { - result := fakePRComplianceResult() +func TestWebhook_Signature(t *testing.T) { + h := newHarness(t, testConfig(), true) + payload := map[string]any{"zen": "hi"} - if result == nil { - t.Fatal("expected non-nil result") + if rec := h.do(t, webhookRequest(t, "ping", payload, "")); rec.Code != http.StatusUnauthorized { + t.Errorf("unsigned: status %d, want 401", rec.Code) + } + if rec := h.do(t, webhookRequest(t, "ping", payload, "wrong")); rec.Code != http.StatusUnauthorized { + t.Errorf("bad signature: status %d, want 401", rec.Code) + } + if rec := h.do(t, webhookRequest(t, "ping", payload, testSecret)); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "pong") { + t.Errorf("ping: status %d body %s, want 200 pong", rec.Code, rec.Body) } - if result.PR == nil { - t.Fatal("expected non-nil PR") + cfg := testConfig() + cfg.GitHub.WebhookSecret = "" + h = newHarness(t, cfg, true) + if rec := h.do(t, webhookRequest(t, "ping", payload, "")); rec.Code != http.StatusServiceUnavailable { + t.Errorf("no secret configured: status %d, want 503", rec.Code) } +} - if result.PR.Number == nil || *result.PR.Number != 42 { - t.Error("expected PR number to be 42") +func TestWebhook_UnhandledAndInvalid(t *testing.T) { + h := newHarness(t, testConfig(), true) + if rec := h.do(t, webhookRequest(t, "push", map[string]any{}, testSecret)); rec.Code != http.StatusAccepted { + t.Errorf("unhandled event: status %d, want 202", rec.Code) } + if rec := h.do(t, webhookRequest(t, "pull_request", map[string]any{"action": "closed"}, testSecret)); rec.Code != http.StatusBadRequest { + t.Errorf("invalid payload: status %d, want 400", rec.Code) + } +} - if result.PR.Title == nil || *result.PR.Title != "Add new authentication feature" { - t.Error("expected PR title to be 'Add new authentication feature'") +func TestWebhook_PullRequest(t *testing.T) { + bypassed := &domain.PRComplianceResult{ + PR: &github.PullRequest{Number: new(7), MergedBy: &github.User{Login: new("mallory")}}, + Violations: []domain.ComplianceViolation{{Description: "required check 'ci' did not pass"}}, } - if result.BaseBranch != "main" { - t.Errorf("expected base branch 'main', got %q", result.BaseBranch) + tests := []struct { + name string + payload map[string]any + result *domain.PRComplianceResult + resultErr error + wantStatus int + wantChecks int + wantNotify int + }{ + {"bypass is reported", mergedPR("acme", "main"), bypassed, nil, 200, 1, 1}, + {"glob branch is monitored", mergedPR("ACME", "release/1.0"), bypassed, nil, 200, 1, 1}, + {"compliant pr", mergedPR("acme", "main"), &domain.PRComplianceResult{}, nil, 200, 1, 0}, + {"unmonitored branch", mergedPR("acme", "dev"), bypassed, nil, 202, 0, 0}, + {"other org", mergedPR("evil", "main"), bypassed, nil, 202, 0, 0}, + {"api failure", mergedPR("acme", "main"), nil, errors.New("403 resource not accessible"), 502, 1, 0}, + {"app not installed", mergedPR("acme", "main"), nil, domain.ErrInstallationMissing, 503, 1, 0}, + {"not merged", func() map[string]any { + p := mergedPR("acme", "main") + p["pull_request"].(map[string]any)["merged"] = false + return p + }(), bypassed, nil, 202, 0, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newHarness(t, testConfig(), true) + h.github.CheckPRComplianceFn = func(context.Context, string, string, int) (*domain.PRComplianceResult, error) { + return tt.result, tt.resultErr + } + rec := h.do(t, webhookRequest(t, "pull_request", tt.payload, testSecret)) + if rec.Code != tt.wantStatus { + t.Errorf("status %d, want %d (%s)", rec.Code, tt.wantStatus, rec.Body) + } + if got := h.github.Count("CheckPRCompliance"); got != tt.wantChecks { + t.Errorf("compliance checks = %d, want %d", got, tt.wantChecks) + } + if got := len(h.notifier.PRBypasses); got != tt.wantNotify { + t.Errorf("notifications = %d, want %d", got, tt.wantNotify) + } + }) } +} - if !result.UserHasBypass { - t.Error("expected UserHasBypass to be true") +func teamEvent(event, action, senderLogin, senderType string) map[string]any { + p := map[string]any{ + "action": action, + "team": map[string]any{"slug": "eng"}, + "organization": map[string]any{"login": "acme"}, + "sender": map[string]any{"login": senderLogin, "type": senderType}, + } + if event == "membership" { + p["scope"] = "team" + p["member"] = map[string]any{"login": "carol"} } + return p +} - if result.UserBypassReason != "repository admin" { - t.Errorf("expected bypass reason 'repository admin', got %q", result.UserBypassReason) +func TestWebhook_TeamChangesTriggerSync(t *testing.T) { + tests := []struct { + name string + event string + payload map[string]any + withOkta bool + wantSync bool + }{ + {"human renames team", "team", func() map[string]any { + p := teamEvent("team", "edited", "alice", "User") + p["changes"] = map[string]any{"name": map[string]any{"from": "Eng"}} + return p + }(), true, true}, + {"human adds member", "membership", teamEvent("membership", "added", "alice", "User"), true, true}, + {"repository access change", "team", teamEvent("team", "added_to_repository", "alice", "User"), true, false}, + {"bot change", "membership", teamEvent("membership", "removed", "renovate[bot]", "Bot"), true, false}, + {"this app's change", "membership", teamEvent("membership", "removed", "ops-app[bot]", "User"), true, false}, + {"okta sync disabled", "team", teamEvent("team", "deleted", "alice", "User"), false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := newHarness(t, testConfig(), tt.withOkta) + rec := h.do(t, webhookRequest(t, tt.event, tt.payload, testSecret)) + if rec.Code >= 300 { + t.Fatalf("status %d (%s)", rec.Code, rec.Body) + } + if synced := h.okta.Count("GetGroupInfo") > 0; synced != tt.wantSync { + t.Errorf("synced = %v, want %v", synced, tt.wantSync) + } + }) } +} - if len(result.Violations) != 2 { - t.Errorf("expected 2 violations, got %d", len(result.Violations)) +func TestAdminAuth(t *testing.T) { + h := newHarness(t, testConfig(), true) + tests := []struct { + auth string + want int + }{ + {"", http.StatusUnauthorized}, + {"Bearer wrong", http.StatusUnauthorized}, + {"Basic " + testAdminToken, http.StatusUnauthorized}, + {"Bearer " + testAdminToken, http.StatusOK}, + {"bearer " + testAdminToken, http.StatusOK}, + } + for _, tt := range tests { + if rec := h.do(t, adminRequest(http.MethodGet, "/server/status", tt.auth)); rec.Code != tt.want { + t.Errorf("auth %q: status %d, want %d", tt.auth, rec.Code, tt.want) + } + } + + rec := h.do(t, adminRequest(http.MethodGet, "/server/config", "Bearer "+testAdminToken)) + if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), testSecret) || strings.Contains(rec.Body.String(), testAdminToken) { + t.Errorf("config: status %d body %s", rec.Code, rec.Body) } - // verify it passes the WasBypassed check (used by notifier) - if !result.WasBypassed() { - t.Error("expected WasBypassed() to return true") + cfg := testConfig() + cfg.AdminToken = "" + h = newHarness(t, cfg, true) + if rec := h.do(t, adminRequest(http.MethodPost, "/scheduled/okta-sync", "Bearer ")); rec.Code != http.StatusForbidden { + t.Errorf("no admin token configured: status %d, want 403", rec.Code) + } + if h.okta.Count("GetGroupInfo") != 0 { + t.Error("scheduled action ran without an admin token") } } -func TestFakeOktaSyncReports(t *testing.T) { - reports := fakeOktaSyncReports() +func TestScheduled(t *testing.T) { + auth := "Bearer " + testAdminToken - if len(reports) != 3 { - t.Fatalf("expected 3 reports, got %d", len(reports)) + h := newHarness(t, testConfig(), true) + if rec := h.do(t, adminRequest(http.MethodPost, "/scheduled/nope", auth)); rec.Code != http.StatusNotFound { + t.Errorf("unknown action: status %d, want 404", rec.Code) } - // first report: has changes - if !reports[0].HasChanges() { - t.Error("expected first report to have changes") + h.github.ListOrgMembersFn = func(context.Context) ([]string, error) { return []string{"eng", "orphan"}, nil } + h.github.GetTeamMembersFn = func(context.Context, string) ([]string, error) { return []string{"eng"}, nil } + if rec := h.do(t, adminRequest(http.MethodPost, "/scheduled/okta-sync", auth)); rec.Code != http.StatusOK { + t.Fatalf("okta sync: status %d (%s)", rec.Code, rec.Body) } - if len(reports[0].MembersAdded) != 2 { - t.Errorf("expected 2 members added, got %d", len(reports[0].MembersAdded)) - } - if len(reports[0].MembersRemoved) != 1 { - t.Errorf("expected 1 member removed, got %d", len(reports[0].MembersRemoved)) + if len(h.notifier.OktaSyncs) != 1 || len(h.notifier.OrphanedUsers) != 1 || h.notifier.OrphanedUsers[0].OrphanedUsers[0] != "orphan" { + t.Errorf("notifications: syncs %d, orphaned %+v", len(h.notifier.OktaSyncs), h.notifier.OrphanedUsers) } - // second report: no changes - if reports[1].HasChanges() { - t.Error("expected second report to have no changes") + h = newHarness(t, testConfig(), false) + if rec := h.do(t, adminRequest(http.MethodPost, "/scheduled/okta-sync", auth)); rec.Code != http.StatusConflict { + t.Errorf("okta sync disabled: status %d, want 409", rec.Code) } +} - // third report: has changes, errors, and skipped members - if !reports[2].HasChanges() { - t.Error("expected third report to have changes") +func TestOktaSync_TotalFailureIsReportedAndReturned(t *testing.T) { + h := newHarness(t, testConfig(), true) + h.okta.GetGroupInfoFn = func(context.Context, string) (*domain.GroupInfo, error) { + return nil, errors.New("okta down") } - if !reports[2].HasErrors() { - t.Error("expected third report to have errors") + if err := h.app.RunScheduled(context.Background(), ActionOktaSync); err == nil { + t.Fatal("total failure returned no error") } - if len(reports[2].MembersSkippedExternal) != 1 { - t.Errorf("expected 1 skipped external member, got %d", - len(reports[2].MembersSkippedExternal)) + if len(h.notifier.OktaSyncs) != 1 || !h.notifier.OktaSyncs[0][0].HasErrors() { + t.Error("failed sync was not reported") } - if len(reports[2].MembersSkippedNoGHUsername) != 1 { - t.Errorf("expected 1 skipped member without GH username, got %d", - len(reports[2].MembersSkippedNoGHUsername)) + if h.github.Count("ListOrgMembers") != 0 { + t.Error("orphan detection ran after a failed sync") } } -func TestFakeOrphanedUsersReport(t *testing.T) { - report := fakeOrphanedUsersReport() - - if report == nil { - t.Fatal("expected non-nil report") +func TestOktaSync_CoalescesConcurrentRequests(t *testing.T) { + h := newHarness(t, testConfig(), true) + started := make(chan struct{}, 2) + release := make(chan struct{}) + h.okta.GetGroupInfoFn = func(_ context.Context, name string) (*domain.GroupInfo, error) { + started <- struct{}{} + <-release + return &domain.GroupInfo{Name: name}, nil } - if len(report.OrphanedUsers) != 3 { - t.Errorf("expected 3 orphaned users, got %d", len(report.OrphanedUsers)) - } + var wg sync.WaitGroup + wg.Go(func() { + if err := h.app.RunScheduled(context.Background(), ActionOktaSync); err != nil { + t.Errorf("first sync: %v", err) + } + }) + <-started - expectedUsers := []string{"orphan-user-1", "orphan-user-2", "legacy-bot"} - for i, user := range report.OrphanedUsers { - if user != expectedUsers[i] { - t.Errorf("expected user %q at index %d, got %q", expectedUsers[i], i, user) + // requests during the running sync return at once and queue one more + for range 3 { + if err := h.app.RunScheduled(context.Background(), ActionOktaSync); err != nil { + t.Fatalf("queued sync: %v", err) } } -} + close(release) + wg.Wait() -func TestProcessScheduledEvent_SlackTest(t *testing.T) { - app := &App{ - Config: &config.Config{}, - Logger: slog.New(slog.NewTextHandler(os.Stderr, nil)), - Notifier: nil, + if got := h.okta.Count("GetGroupInfo"); got != 2 { + t.Errorf("syncs run = %d, want 2", got) } +} - evt := ScheduledEvent{Action: "slack-test"} - err := app.processScheduledEvent(context.Background(), evt) +func TestSecurityAlerts(t *testing.T) { + h := newHarness(t, testConfig(), true) + h.github.ListSecurityAlertsFn = func(_ context.Context, days int, sev string) (*domain.SecurityAlertsReport, error) { + if days != 30 || sev != "high" { + t.Errorf("ListSecurityAlerts(%d, %q)", days, sev) + } + return &domain.SecurityAlertsReport{TotalAlerts: 1, AlertsByRepo: map[string][]domain.SecurityAlert{"acme/web": {{CreatedAt: time.Now()}}}}, nil + } + if err := h.app.RunScheduled(context.Background(), ActionSecurityAlerts); err != nil { + t.Fatal(err) + } + if len(h.notifier.SecurityAlerts) != 1 { + t.Errorf("notifications = %d, want 1", len(h.notifier.SecurityAlerts)) + } - // should fail because slack is not configured - if err == nil { - t.Error("expected error when slack is not configured") + cfg := testConfig() + cfg.SecurityAlerts.Enabled = false + h = newHarness(t, cfg, true) + if err := h.app.RunScheduled(context.Background(), ActionSecurityAlerts); !errors.Is(err, domain.ErrFeatureDisabled) { + t.Errorf("disabled: error = %v, want ErrFeatureDisabled", err) } } -func TestProcessScheduledEvent_UnknownAction(t *testing.T) { - app := &App{ - Config: &config.Config{}, - Logger: slog.New(slog.NewTextHandler(os.Stderr, nil)), +func TestSlackTest(t *testing.T) { + h := newHarness(t, testConfig(), true) + if err := h.app.RunScheduled(context.Background(), ActionSlackTest); err != nil { + t.Fatal(err) } + for _, name := range []string{"NotifyPRBypass", "NotifyOktaSync", "NotifyOrphanedUsers", "NotifySecurityAlerts"} { + if h.notifier.Count(name) != 1 { + t.Errorf("%s calls = %d, want 1", name, h.notifier.Count(name)) + } + } + + cfg := testConfig() + cfg.Okta.Enabled = false + a, err := New(cfg, slog.New(slog.DiscardHandler), WithGitHubClient(&fakes.GitHub{})) + if err != nil { + t.Fatal(err) + } + if err := a.RunScheduled(context.Background(), ActionSlackTest); !errors.Is(err, domain.ErrFeatureDisabled) { + t.Errorf("without slack: error = %v, want ErrFeatureDisabled", err) + } +} - evt := ScheduledEvent{Action: "unknown-action"} - err := app.processScheduledEvent(context.Background(), evt) +func TestRouter_BasePathAndErrors(t *testing.T) { + cfg := testConfig() + cfg.Server.BasePath = "/prod" + h := newHarness(t, cfg, true) - if err == nil { - t.Error("expected error for unknown action") + req := webhookRequest(t, "ping", map[string]any{}, testSecret) + req.URL.Path = "/prod/webhooks" + if rec := h.do(t, req); rec.Code != http.StatusOK { + t.Errorf("based webhook: status %d", rec.Code) + } + if rec := h.do(t, webhookRequest(t, "ping", map[string]any{}, testSecret)); rec.Code != http.StatusNotFound { + t.Errorf("unbased webhook: status %d, want 404", rec.Code) + } + if rec := h.do(t, adminRequest(http.MethodGet, "/prod/webhooks", "")); rec.Code != http.StatusMethodNotAllowed { + t.Errorf("GET webhook: status %d, want 405", rec.Code) } } -// verify fake data types match expected domain types -func TestFakeDataTypes(t *testing.T) { - var _ *domain.PRComplianceResult = fakePRComplianceResult() - var _ []*domain.SyncReport = fakeOktaSyncReports() - var _ *domain.OrphanedUsersReport = fakeOrphanedUsersReport() +func TestNew_BuildsRealClients(t *testing.T) { + cfg := testConfig() + cfg.GitHub.PrivateKey = []byte("not a key") + if _, err := New(cfg, nil); err == nil { + t.Error("New accepted an invalid github private key") + } } -func TestAdminAuthMiddleware(t *testing.T) { - tests := []struct { - name string - adminToken string - authHeader string - expectError bool - }{ - { - name: "no token configured, no header", - adminToken: "", - authHeader: "", - expectError: false, - }, - { - name: "no token configured, with header", - adminToken: "", - authHeader: "Bearer some-token", - expectError: false, - }, - { - name: "token configured, no header", - adminToken: "secret-token", - authHeader: "", - expectError: true, - }, - { - name: "token configured, wrong token", - adminToken: "secret-token", - authHeader: "Bearer wrong-token", - expectError: true, - }, - { - name: "token configured, correct token", - adminToken: "secret-token", - authHeader: "Bearer secret-token", - expectError: false, - }, - { - name: "token configured, lowercase bearer", - adminToken: "secret-token", - authHeader: "bearer secret-token", - expectError: false, - }, +func TestOktaSync_FollowUpRunsAfterFailedSync(t *testing.T) { + h := newHarness(t, testConfig(), true) + started := make(chan struct{}, 2) + release := make(chan struct{}) + var calls int + var mu sync.Mutex + h.okta.GetGroupInfoFn = func(_ context.Context, name string) (*domain.GroupInfo, error) { + mu.Lock() + calls++ + first := calls == 1 + mu.Unlock() + if first { + started <- struct{}{} + <-release + return nil, errors.New("okta down") + } + return &domain.GroupInfo{Name: name}, nil } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - a := &App{ - Config: &config.Config{AdminToken: tt.adminToken}, - Logger: slog.New(slog.NewTextHandler(os.Stderr, nil)), - } + var firstErr error + var wg sync.WaitGroup + wg.Go(func() { firstErr = h.app.RunScheduled(context.Background(), ActionOktaSync) }) + <-started + if err := h.app.RunScheduled(context.Background(), ActionOktaSync); err != nil { + t.Fatalf("queued sync: %v", err) + } + close(release) + wg.Wait() - // test via /server/status which uses admin auth middleware - router := a.Handler() + if calls != 2 { + t.Errorf("syncs run = %d, want the queued follow-up to run after the failure", calls) + } + if firstErr == nil { + t.Error("the failed first sync's error was lost") + } +} - req := httptest.NewRequest(http.MethodGet, "/server/status", nil) - if tt.authHeader != "" { - req.Header.Set("Authorization", tt.authHeader) - } +// fakeBackground keeps background jobs so a test can run them. +type fakeBackground struct { + mu sync.Mutex + jobs []func(context.Context) +} - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) +func (b *fakeBackground) Go(f func(context.Context)) { + b.mu.Lock() + defer b.mu.Unlock() + b.jobs = append(b.jobs, f) +} - if tt.expectError && rec.Code != http.StatusUnauthorized { - t.Errorf("expected 401, got %d", rec.Code) - } - if !tt.expectError && rec.Code == http.StatusUnauthorized { - t.Errorf("expected success, got 401") - } - }) +func (b *fakeBackground) run() { + b.mu.Lock() + jobs := b.jobs + b.jobs = nil + b.mu.Unlock() + for _, job := range jobs { + job(context.Background()) } } -func TestRouter_AdminAuthOnProtectedEndpoints(t *testing.T) { - tests := []struct { - name string - path string - method string - adminToken string - authHeader string - expectedStatus int - }{ - { - name: "status endpoint, no token configured", - path: "/server/status", - method: http.MethodGet, - adminToken: "", - authHeader: "", - expectedStatus: 200, - }, - { - name: "status endpoint, token required, missing", - path: "/server/status", - method: http.MethodGet, - adminToken: "secret", - authHeader: "", - expectedStatus: 401, - }, - { - name: "status endpoint, token required, correct", - path: "/server/status", - method: http.MethodGet, - adminToken: "secret", - authHeader: "Bearer secret", - expectedStatus: 200, - }, - { - name: "config endpoint, token required, missing", - path: "/server/config", - method: http.MethodGet, - adminToken: "secret", - authHeader: "", - expectedStatus: 401, - }, - { - name: "config endpoint, token required, correct", - path: "/server/config", - method: http.MethodGet, - adminToken: "secret", - authHeader: "Bearer secret", - expectedStatus: 200, - }, - { - name: "scheduled endpoint, token required, missing", - path: "/scheduled/slack-test", - method: http.MethodPost, - adminToken: "secret", - authHeader: "", - expectedStatus: 401, - }, +func TestWebhook_SyncRunsInBackground(t *testing.T) { + gh, okta, bg := &fakes.GitHub{}, &fakes.Okta{}, &fakeBackground{} + a, err := New(testConfig(), slog.New(slog.DiscardHandler), + WithGitHubClient(gh), WithOktaClient(okta), WithNotifier(&fakes.Notifier{}), WithBackground(bg)) + if err != nil { + t.Fatal(err) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - a := &App{ - Config: &config.Config{AdminToken: tt.adminToken}, - Logger: slog.New(slog.NewTextHandler(os.Stderr, nil)), - } + rec := httptest.NewRecorder() + a.Handler().ServeHTTP(rec, webhookRequest(t, "membership", teamEvent("membership", "added", "alice", "User"), testSecret)) + if rec.Code != http.StatusAccepted || !strings.Contains(rec.Body.String(), "queued") { + t.Fatalf("status %d body %s, want 202 queued", rec.Code, rec.Body) + } + if okta.Count("GetGroupInfo") != 0 { + t.Fatal("sync ran before the response") + } + bg.run() + if okta.Count("GetGroupInfo") != 1 { + t.Error("background sync did not run") + } +} - router := a.Handler() +func TestWebhook_IgnoresTeamsNoRuleManages(t *testing.T) { + h := newHarness(t, testConfig(), true) + membership := func(team string) map[string]any { + p := teamEvent("membership", "added", "alice", "User") + p["team"] = map[string]any{"slug": team} + return p + } - req := httptest.NewRequest(tt.method, tt.path, nil) - if tt.authHeader != "" { - req.Header.Set("Authorization", tt.authHeader) - } + // before a sync, which teams are managed is unknown, so any change syncs + h.do(t, webhookRequest(t, "membership", membership("design"), testSecret)) + if got := h.okta.Count("GetGroupInfo"); got != 1 { + t.Fatalf("syncs = %d, want 1", got) + } + + // the sync managed only "eng" + rec := h.do(t, webhookRequest(t, "membership", membership("design"), testSecret)) + if rec.Code != http.StatusAccepted || h.okta.Count("GetGroupInfo") != 1 { + t.Errorf("unmanaged team: status %d, syncs %d; want 202 and no sync", rec.Code, h.okta.Count("GetGroupInfo")) + } + h.do(t, webhookRequest(t, "membership", membership("eng"), testSecret)) + if got := h.okta.Count("GetGroupInfo"); got != 2 { + t.Errorf("managed team: syncs = %d, want 2", got) + } - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) + // renaming a managed team is recognized by its previous name + renamed := teamEvent("team", "edited", "alice", "User") + renamed["team"] = map[string]any{"slug": "engineering"} + renamed["changes"] = map[string]any{"name": map[string]any{"from": "eng"}} + h.do(t, webhookRequest(t, "team", renamed, testSecret)) + if got := h.okta.Count("GetGroupInfo"); got != 3 { + t.Errorf("renamed team: syncs = %d, want 3", got) + } - if rec.Code != tt.expectedStatus { - t.Errorf("expected status %d, got %d (body: %s)", - tt.expectedStatus, rec.Code, rec.Body.String()) - } - }) + // a repository permission change is not a membership change + perms := teamEvent("team", "edited", "alice", "User") + perms["changes"] = map[string]any{"repository": map[string]any{"permissions": map[string]any{"from": map[string]any{"push": false}}}} + h.do(t, webhookRequest(t, "team", perms, testSecret)) + if got := h.okta.Count("GetGroupInfo"); got != 3 { + t.Errorf("permission change: syncs = %d, want 3", got) + } +} + +func TestOktaSync_SkipsOrphanDetectionWhenATeamIsUnknown(t *testing.T) { + cfg := testConfig() + cfg.Okta.SyncRules = []domain.SyncRule{{OktaGroupName: "eng"}, {OktaGroupName: "broken"}} + h := newHarness(t, cfg, true) + h.okta.GetGroupInfoFn = func(_ context.Context, name string) (*domain.GroupInfo, error) { + if name == "broken" { + return nil, errors.New("okta timeout") + } + return &domain.GroupInfo{Name: name, Members: []string{"alice"}}, nil + } + + if err := h.app.RunScheduled(context.Background(), ActionOktaSync); err != nil { + t.Fatal(err) + } + if h.github.Count("ListOrgMembers") != 0 || len(h.notifier.OrphanedUsers) != 0 { + t.Error("orphaned users were reported although the broken rule's team is unknown") } } diff --git a/internal/app/factory.go b/internal/app/factory.go deleted file mode 100644 index dd91e5f..0000000 --- a/internal/app/factory.go +++ /dev/null @@ -1,70 +0,0 @@ -package app - -import ( - "context" - "log/slog" - - "github.com/cruxstack/github-ops-app/internal/config" - ghclient "github.com/cruxstack/github-ops-app/internal/github/client" - "github.com/cruxstack/github-ops-app/internal/notifiers" - "github.com/cruxstack/github-ops-app/internal/okta" -) - -// NewApp creates a new App instance with configured clients (composition -// root). concrete implementations are instantiated here and wired as domain -// interfaces. this is the single shared factory used by all entry points. -func NewApp(ctx context.Context, cfg *config.Config, logger *slog.Logger) (*App, error) { - a := &App{ - Config: cfg, - Logger: logger, - } - - if cfg.IsGitHubConfigured() { - ghClient, err := ghclient.NewAppClientWithBaseURL( - cfg.GitHubAppID, - cfg.GitHubInstallationID, - cfg.GitHubAppPrivateKey, - cfg.GitHubOrg, - cfg.GitHubBaseURL, - ) - if err != nil { - return nil, err - } - a.GitHubClient = ghClient - } - - if cfg.IsOktaSyncEnabled() { - oktaClient, err := okta.NewClientWithContext(ctx, &okta.ClientConfig{ - Domain: cfg.OktaDomain, - ClientID: cfg.OktaClientID, - PrivateKey: cfg.OktaPrivateKey, - PrivateKeyID: cfg.OktaPrivateKeyID, - Scopes: cfg.OktaScopes, - GitHubUserField: cfg.OktaGitHubUserField, - BaseURL: cfg.OktaBaseURL, - Logger: logger, - }) - if err != nil { - return nil, err - } - a.OktaClient = oktaClient - } - - if cfg.SlackEnabled { - channels := notifiers.SlackChannels{ - Default: cfg.SlackChannel, - PRBypass: cfg.SlackChannelPRBypass, - OktaSync: cfg.SlackChannelOktaSync, - OrphanedUsers: cfg.SlackChannelOrphanedUsers, - SecurityAlerts: cfg.SlackChannelSecurityAlerts, - } - messages := notifiers.SlackMessages{ - PRBypassFooterNote: cfg.SlackPRBypassFooterNote, - } - a.Notifier = notifiers.NewSlackNotifierWithAPIURL( - cfg.SlackToken, channels, messages, cfg.SlackAPIURL, - ) - } - - return a, nil -} diff --git a/internal/app/handlers.go b/internal/app/handlers.go index 2ae3b54..f1cf254 100644 --- a/internal/app/handlers.go +++ b/internal/app/handlers.go @@ -2,280 +2,415 @@ package app import ( "context" - "log/slog" + "errors" + "fmt" + "strings" - "github.com/cockroachdb/errors" "github.com/cruxstack/github-ops-app/internal/domain" "github.com/cruxstack/github-ops-app/internal/github/webhooks" - "github.com/cruxstack/github-ops-app/internal/sync" + "github.com/cruxstack/github-ops-app/internal/teamsync" ) -// handleOktaSync executes Okta group synchronization to GitHub teams. -// sends Slack notification with sync results if configured. -func (a *App) handleOktaSync(ctx context.Context) error { - if !a.Config.IsOktaSyncEnabled() { - a.Logger.Info("okta sync is not enabled, skipping") - return nil +// RunScheduled runs a scheduled action. Callers authenticate the request +// first: the HTTP endpoint requires the admin token, and EventBridge +// invocations are authorized by AWS. +func (a *App) RunScheduled(ctx context.Context, action string) error { + a.logger.InfoContext(ctx, "running scheduled action", "action", action) + switch action { + case ActionOktaSync: + return a.runOktaSync(ctx) + case ActionSecurityAlerts: + return a.runSecurityAlerts(ctx) + case ActionSlackTest: + return a.runSlackTest(ctx) + default: + return fmt.Errorf("%w '%s'", domain.ErrUnknownAction, action) + } +} + +// WebhookResult says what was done with a webhook. +type WebhookResult string + +// Webhook results. +const ( + WebhookProcessed WebhookResult = "processed" + WebhookQueued WebhookResult = "queued" + WebhookIgnored WebhookResult = "ignored" + WebhookPong WebhookResult = "pong" +) + +// processWebhook handles a verified GitHub webhook event. Events the +// application does not use, and events from other organizations, are +// ignored. +func (a *App) processWebhook(ctx context.Context, eventType string, payload []byte) (WebhookResult, error) { + switch eventType { + case webhooks.EventPing: + return WebhookPong, nil + case webhooks.EventPullRequest: + return a.handlePullRequestWebhook(ctx, payload) + case webhooks.EventTeam: + return a.handleTeamWebhook(ctx, payload) + case webhooks.EventMembership: + return a.handleMembershipWebhook(ctx, payload) + default: + a.logger.DebugContext(ctx, "ignoring unhandled webhook event", "event_type", eventType) + return WebhookIgnored, nil } +} - if a.OktaClient == nil || a.GitHubClient == nil { - return errors.Wrap(domain.ErrClientNotInit, "okta or github client") +// isOwnOrg returns true if login is the configured organization. The app +// acts only on its own organization, even if it is installed elsewhere. +func (a *App) isOwnOrg(ctx context.Context, login, eventType string) bool { + if strings.EqualFold(login, a.cfg.GitHub.Org) { + return true } + a.logger.WarnContext(ctx, "ignoring webhook from another organization", + "event_type", eventType, + "org", login) + return false +} - syncer := sync.NewSyncer(a.OktaClient, a.GitHubClient, a.Config.OktaSyncRules, a.Config.OktaSyncSafetyThreshold, a.Logger) - syncResult, err := syncer.Sync(ctx) +// handlePullRequestWebhook checks merged PRs into monitored branches for +// compliance and reports the ones merged without meeting the requirements. +func (a *App) handlePullRequestWebhook(ctx context.Context, payload []byte) (WebhookResult, error) { + event, err := webhooks.ParsePullRequestEvent(payload) if err != nil { - return errors.Wrap(err, "okta sync failed") + return "", err + } + if !a.isOwnOrg(ctx, event.GetRepoOwner(), webhooks.EventPullRequest) { + return WebhookIgnored, nil } - a.Logger.Info("okta sync completed", slog.Int("report_count", len(syncResult.Reports))) + log := a.logger.With( + "repo", event.GetRepoFullName(), + "pr_number", event.GetNumber(), + "branch", event.GetBaseBranch()) - if a.Notifier != nil { - if err := a.Notifier.NotifyOktaSync(ctx, syncResult.Reports, a.Config.GitHubOrg); err != nil { - a.Logger.Warn("failed to send slack notification", slog.String("error", err.Error())) - } + if !event.IsMerged() { + log.DebugContext(ctx, "pr not merged, skipping", "action", event.Action) + return WebhookIgnored, nil + } + if !a.cfg.ShouldMonitorBranch(event.GetBaseBranch()) { + log.DebugContext(ctx, "branch not monitored, skipping") + return WebhookIgnored, nil } - if a.Config.OktaOrphanedUserNotifications { - syncedTeams := make([]string, 0, len(syncResult.Reports)) - for _, report := range syncResult.Reports { - syncedTeams = append(syncedTeams, report.GitHubTeam) - } + result, err := a.github.CheckPRCompliance(ctx, event.GetRepoOwner(), event.GetRepoName(), event.GetNumber()) + if err != nil { + return "", fmt.Errorf("failed to check compliance of pr #%d in %s: %w", + event.GetNumber(), event.GetRepoFullName(), errors.Join(domain.ErrAPI, err)) + } - orphanedReport, err := syncer.DetectOrphanedUsers(ctx, syncedTeams) - if err != nil { - a.Logger.Warn("failed to detect orphaned users", slog.String("error", err.Error())) - } else if orphanedReport != nil && len(orphanedReport.OrphanedUsers) > 0 { - a.Logger.Info("orphaned users detected", slog.Int("count", len(orphanedReport.OrphanedUsers))) + if !result.WasBypassed() { + log.DebugContext(ctx, "pr met branch requirements") + return WebhookProcessed, nil + } - if a.Notifier != nil { - if err := a.Notifier.NotifyOrphanedUsers(ctx, orphanedReport); err != nil { - a.Logger.Warn("failed to send orphaned users notification", slog.String("error", err.Error())) - } - } + violations := make([]string, 0, len(result.Violations)) + for _, v := range result.Violations { + violations = append(violations, v.Description) + } + log.WarnContext(ctx, "pr merged without meeting branch requirements", + "merged_by", result.PR.GetMergedBy().GetLogin(), + "bypass_reason", result.UserBypassReason, + "violations", violations) + + if a.notifier != nil { + if err := a.notifier.NotifyPRBypass(ctx, result, event.GetRepoFullName()); err != nil { + log.ErrorContext(ctx, "failed to send pr bypass notification", "error", err.Error()) } } - - return nil + return WebhookProcessed, nil } -// handlePullRequestWebhook processes GitHub pull request webhook events. -// checks merged PRs for branch protection compliance violations. -func (a *App) handlePullRequestWebhook(ctx context.Context, payload []byte) error { - prEvent, err := webhooks.ParsePullRequestEvent(payload) +// handleTeamWebhook syncs Okta groups when someone other than this app +// creates, deletes, or renames a team. +func (a *App) handleTeamWebhook(ctx context.Context, payload []byte) (WebhookResult, error) { + event, err := webhooks.ParseTeamEvent(payload) if err != nil { - return err + return "", err } - - if !prEvent.IsMerged() { - if a.Config.DebugEnabled { - a.Logger.Debug("pr not merged, skipping", slog.Int("pr_number", prEvent.Number)) - } - return nil + if !a.isOwnOrg(ctx, event.GetOrgLogin(), webhooks.EventTeam) || a.okta == nil || !event.AffectsMembership() { + return WebhookIgnored, nil } + return a.syncAfterExternalChange(ctx, webhooks.EventTeam, event.Action, event, event.GetTeamSlug(), event.GetPreviousTeamSlug()) +} - baseBranch := prEvent.GetBaseBranch() - if !a.Config.ShouldMonitorBranch(baseBranch) { - if a.Config.DebugEnabled { - a.Logger.Debug("branch not monitored, skipping", slog.String("branch", baseBranch)) - } - return nil +// handleMembershipWebhook syncs Okta groups when someone other than this +// app changes a team's members. +func (a *App) handleMembershipWebhook(ctx context.Context, payload []byte) (WebhookResult, error) { + event, err := webhooks.ParseMembershipEvent(payload) + if err != nil { + return "", err } - - if a.GitHubClient == nil { - return errors.Wrap(domain.ErrClientNotInit, "github client") + if !a.isOwnOrg(ctx, event.GetOrgLogin(), webhooks.EventMembership) || a.okta == nil || !event.IsTeamScope() { + return WebhookIgnored, nil } + return a.syncAfterExternalChange(ctx, webhooks.EventMembership, event.Action, event, event.GetTeamSlug()) +} - owner := prEvent.GetRepoOwner() - repo := prEvent.GetRepoName() +// sender identifies who triggered a webhook. +type sender interface { + GetSenderType() string + GetSenderLogin() string +} - result, err := a.GitHubClient.CheckPRCompliance(ctx, owner, repo, prEvent.Number) - if err != nil { - return errors.Wrapf(err, "failed to check pr #%d compliance", prEvent.Number) +// syncAfterExternalChange syncs Okta groups after a change to one of teams. +// Changes by bots, including this app, and to teams no sync rule manages are +// ignored. With a Background, the sync runs after the response, since GitHub +// waits only 10 seconds for one. +func (a *App) syncAfterExternalChange(ctx context.Context, eventType, action string, s sender, teams ...string) (WebhookResult, error) { + log := a.logger.With( + "event_type", eventType, + "action", action, + "team", teams[0], + "sender", s.GetSenderLogin()) + + if a.isOwnChange(ctx, s) { + log.DebugContext(ctx, "ignoring change made by a bot or this app") + return WebhookIgnored, nil + } + if !a.isManagedTeam(teams...) { + log.DebugContext(ctx, "ignoring change to a team no sync rule manages") + return WebhookIgnored, nil } - if result.WasBypassed() { - a.Logger.Info("pr bypassed branch protection", - slog.Int("pr_number", prEvent.Number), - slog.String("branch", baseBranch)) - - if a.Notifier != nil { - repoFullName := prEvent.GetRepoFullName() - if err := a.Notifier.NotifyPRBypass(ctx, result, repoFullName); err != nil { - a.Logger.Warn("failed to send slack notification", slog.String("error", err.Error())) + log.InfoContext(ctx, "external team change detected, syncing okta groups") + if a.background != nil { + a.background.Go(func(ctx context.Context) { + if err := a.runOktaSync(ctx); err != nil { + a.logger.ErrorContext(ctx, "okta sync failed", "trigger", eventType, "error", err.Error()) } - } - } else if a.Config.DebugEnabled { - a.Logger.Debug("pr complied with branch protection", slog.Int("pr_number", prEvent.Number)) + }) + return WebhookQueued, nil } - - return nil + if err := a.runOktaSync(ctx); err != nil { + return "", err + } + return WebhookProcessed, nil } -// handleTeamWebhook processes GitHub team webhook events. -// triggers Okta sync when team changes are made externally. -func (a *App) handleTeamWebhook(ctx context.Context, payload []byte) error { - teamEvent, err := webhooks.ParseTeamEvent(payload) +// isOwnChange returns true if a change was made by a bot, such as this app, +// so the sync's own changes don't trigger another sync. +func (a *App) isOwnChange(ctx context.Context, s sender) bool { + if s.GetSenderType() == "Bot" { + return true + } + slug, err := a.github.GetAppSlug(ctx) if err != nil { - return err + a.logger.WarnContext(ctx, "failed to get app slug", "error", err.Error()) + return false } + return strings.EqualFold(s.GetSenderLogin(), slug+"[bot]") +} - if !a.Config.IsOktaSyncEnabled() { - if a.Config.DebugEnabled { - a.Logger.Debug("okta sync not enabled, skipping team webhook") - } - return nil +// isManagedTeam returns true if any of the slugs is a team the last sync +// managed. Before a complete sync, which teams are managed is unknown, so +// every team counts. +func (a *App) isManagedTeam(slugs ...string) bool { + a.syncMu.Lock() + defer a.syncMu.Unlock() + if a.managedTeams == nil { + return true } - - if a.shouldIgnoreWebhookChange(ctx, teamEvent) { - if a.Config.DebugEnabled { - a.Logger.Debug("ignoring team change from bot/app", - slog.String("action", teamEvent.Action), - slog.String("sender", teamEvent.GetSenderLogin())) + for _, slug := range slugs { + if slug != "" && a.managedTeams[domain.TeamSlug(slug)] { + return true } - return nil } - - a.Logger.Info("external team change detected, triggering sync", - slog.String("action", teamEvent.Action), - slog.String("team", teamEvent.GetTeamSlug()), - slog.String("sender", teamEvent.GetSenderLogin())) - - return a.handleOktaSync(ctx) + return false } -// handleMembershipWebhook processes GitHub membership webhook events. -// triggers Okta sync when team memberships are changed externally. -func (a *App) handleMembershipWebhook(ctx context.Context, payload []byte) error { - membershipEvent, err := webhooks.ParseMembershipEvent(payload) - if err != nil { - return err - } - - if !membershipEvent.IsTeamScope() { - if a.Config.DebugEnabled { - a.Logger.Debug("membership event is not team scope, skipping") +// setManagedTeams records the teams a sync managed, or forgets them when the +// sync could not find every team. +func (a *App) setManagedTeams(result *domain.SyncResult) { + var managed map[string]bool + if result.Complete() { + managed = make(map[string]bool) + for _, slug := range result.ManagedTeams() { + managed[slug] = true } - return nil } + a.syncMu.Lock() + defer a.syncMu.Unlock() + a.managedTeams = managed +} - if !a.Config.IsOktaSyncEnabled() { - if a.Config.DebugEnabled { - a.Logger.Debug("okta sync not enabled, skipping membership webhook") - } - return nil +// runOktaSync syncs Okta groups to GitHub teams. A sync requested while +// another is running doesn't start a second one: the running sync runs once +// more when it finishes, whether or not it failed, so it picks up the latest +// changes. The follow-up gets its own JobTimeout. +func (a *App) runOktaSync(ctx context.Context) error { + if a.okta == nil { + return fmt.Errorf("okta sync: %w", domain.ErrFeatureDisabled) } - if a.shouldIgnoreWebhookChange(ctx, membershipEvent) { - if a.Config.DebugEnabled { - a.Logger.Debug("ignoring membership change from bot/app", - slog.String("action", membershipEvent.Action), - slog.String("team", membershipEvent.GetTeamSlug()), - slog.String("sender", membershipEvent.GetSenderLogin())) - } + a.syncMu.Lock() + if a.syncRunning { + a.syncPending = true + a.syncMu.Unlock() + a.logger.InfoContext(ctx, "okta sync already running, queued a follow-up sync") return nil } - - a.Logger.Info("external membership change detected, triggering sync", - slog.String("action", membershipEvent.Action), - slog.String("team", membershipEvent.GetTeamSlug()), - slog.String("sender", membershipEvent.GetSenderLogin())) - - return a.handleOktaSync(ctx) -} - -// webhookSender provides sender information for webhook events. -type webhookSender interface { - GetSenderType() string - GetSenderLogin() string + a.syncRunning = true + a.syncMu.Unlock() + + err := a.oktaSyncOnce(ctx) + for a.takePendingSync() { + a.logger.InfoContext(ctx, "running queued okta sync") + followUp, cancel := context.WithTimeout(context.WithoutCancel(ctx), JobTimeout) + err = errors.Join(err, a.oktaSyncOnce(followUp)) + cancel() + } + return err } -// shouldIgnoreWebhookChange checks if a webhook should be ignored. -// ignores changes made by bots or the GitHub App itself to prevent loops. -func (a *App) shouldIgnoreWebhookChange(ctx context.Context, event webhookSender) bool { - if event.GetSenderType() == "Bot" { +// takePendingSync returns true and clears the pending flag if a sync was +// queued; otherwise it marks the sync as finished. +func (a *App) takePendingSync() bool { + a.syncMu.Lock() + defer a.syncMu.Unlock() + if a.syncPending { + a.syncPending = false return true } + a.syncRunning = false + return false +} - if a.GitHubClient != nil { - appSlug, err := a.GitHubClient.GetAppSlug(ctx) - if err != nil { - a.Logger.Warn("failed to get app slug", slog.String("error", err.Error())) - return false +func (a *App) oktaSyncOnce(ctx context.Context) error { + syncer := teamsync.NewSyncer(a.okta, a.github, a.cfg.Okta.SyncRules, a.cfg.Okta.SyncSafetyThreshold, a.logger) + result, syncErr := syncer.Sync(ctx) + if result == nil { + return fmt.Errorf("okta sync failed: %w", syncErr) + } + a.setManagedTeams(result) + + var added, removed, failed int + for _, r := range result.Reports { + added += len(r.MembersAdded) + removed += len(r.MembersRemoved) + attrs := []any{ + "rule", r.Rule, + "okta_group", r.OktaGroup, + "team", r.GitHubTeam, + "added", r.MembersAdded, + "removed", r.MembersRemoved, + "skipped_external", r.MembersSkippedExternal, + "skipped_no_github_username", len(r.MembersSkippedNoGHUsername), } - if event.GetSenderLogin() == appSlug+"[bot]" { - return true + if r.HasErrors() { + failed++ + a.logger.WarnContext(ctx, "okta group synced with errors", append(attrs, "errors", r.Errors)...) + } else { + a.logger.InfoContext(ctx, "okta group synced", attrs...) + } + } + a.logger.InfoContext(ctx, "okta sync completed", + "teams", len(result.Reports), + "teams_with_errors", failed, + "members_added", added, + "members_removed", removed) + + if a.notifier != nil { + if err := a.notifier.NotifyOktaSync(ctx, result.Reports, a.cfg.GitHub.Org); err != nil { + a.logger.ErrorContext(ctx, "failed to send okta sync notification", "error", err.Error()) } } + if syncErr != nil { + return fmt.Errorf("okta sync failed: %w", syncErr) + } - return false + if a.cfg.Okta.OrphanedUserNotifications { + a.reportOrphanedUsers(ctx, syncer, result) + } + return nil } -// handleSecurityAlerts checks for stale security alerts across the org -// and sends a Slack notification if any are found. -func (a *App) handleSecurityAlerts(ctx context.Context) error { - if !a.Config.IsSecurityAlertsEnabled() { - a.Logger.Info("security alerts monitoring is not enabled, skipping") - return nil +// reportOrphanedUsers reports organization members in none of the synced +// teams. It is skipped when a team could not be found, since its members +// would be reported as orphaned. Failures are logged, since the sync itself +// succeeded. +func (a *App) reportOrphanedUsers(ctx context.Context, syncer *teamsync.Syncer, result *domain.SyncResult) { + if len(result.Reports) == 0 { + return } - - if a.GitHubClient == nil { - return errors.Wrap(domain.ErrClientNotInit, "github client") + if !result.Complete() { + a.logger.WarnContext(ctx, "skipping orphaned user detection because not every synced team was found") + return } - - report, err := a.GitHubClient.ListSecurityAlerts( - ctx, - a.Config.SecurityAlertsMinAgeDays, - a.Config.SecurityAlertsMinSeverity, - ) + report, err := syncer.DetectOrphanedUsers(ctx, result) if err != nil { - return errors.Wrap(err, "failed to fetch stale security alerts") + a.logger.ErrorContext(ctx, "failed to detect orphaned users", "error", err.Error()) + return + } + if len(report.OrphanedUsers) == 0 { + return } - a.Logger.Info("security alerts check completed", - slog.Int("total_alerts", report.TotalAlerts), - slog.Int("repos", report.RepoCount())) - - if report.HasAlerts() && a.Notifier != nil { - if err := a.Notifier.NotifySecurityAlerts(ctx, report, a.Config.GitHubOrg); err != nil { - a.Logger.Warn("failed to send security alerts notification", - slog.String("error", err.Error())) + a.logger.InfoContext(ctx, "orphaned users detected", + "count", len(report.OrphanedUsers), + "users", report.OrphanedUsers) + if a.notifier != nil { + if err := a.notifier.NotifyOrphanedUsers(ctx, report); err != nil { + a.logger.ErrorContext(ctx, "failed to send orphaned users notification", "error", err.Error()) } } - - return nil } -// handleSlackTest sends test notifications to Slack with sample data. -// useful for verifying Slack connectivity and previewing message formats. -func (a *App) handleSlackTest(ctx context.Context) error { - if a.Notifier == nil { - return errors.New("slack is not configured") +// runSecurityAlerts reports open security alerts older than the configured +// age across the organization. +func (a *App) runSecurityAlerts(ctx context.Context) error { + if !a.cfg.SecurityAlerts.Enabled { + return fmt.Errorf("security alerts: %w", domain.ErrFeatureDisabled) } - // test 1: PR bypass notification - if err := a.Notifier.NotifyPRBypass(ctx, fakePRComplianceResult(), "acme-corp/demo-repo"); err != nil { - return errors.Wrap(err, "failed to send test pr bypass notification") + report, err := a.github.ListSecurityAlerts(ctx, a.cfg.SecurityAlerts.MinAgeDays, a.cfg.SecurityAlerts.MinSeverity) + if err != nil { + return fmt.Errorf("failed to list security alerts for org '%s': %w", a.cfg.GitHub.Org, err) } - a.Logger.Info("sent test pr bypass notification") - // test 2: Okta sync notification - if err := a.Notifier.NotifyOktaSync(ctx, fakeOktaSyncReports(), "acme-corp"); err != nil { - return errors.Wrap(err, "failed to send test okta sync notification") + for _, e := range report.Errors { + a.logger.ErrorContext(ctx, "failed to fetch security alerts", "error", e) } - a.Logger.Info("sent test okta sync notification") - - // test 3: Orphaned users notification - if err := a.Notifier.NotifyOrphanedUsers(ctx, fakeOrphanedUsersReport()); err != nil { - return errors.Wrap(err, "failed to send test orphaned users notification") + a.logger.InfoContext(ctx, "security alerts check completed", + "alerts", report.TotalAlerts, + "repos", report.RepoCount(), + "errors", len(report.Errors)) + + if a.notifier != nil { + if err := a.notifier.NotifySecurityAlerts(ctx, report, a.cfg.GitHub.Org); err != nil { + a.logger.ErrorContext(ctx, "failed to send security alerts notification", "error", err.Error()) + } } - a.Logger.Info("sent test orphaned users notification") + return nil +} - // test 4: Security alerts notification - if err := a.Notifier.NotifySecurityAlerts(ctx, fakeSecurityAlertsReport(), "acme-corp"); err != nil { - return errors.Wrap(err, "failed to send test security alerts notification") +// runSlackTest sends every notification type with sample data, to check +// the Slack setup and preview the messages. +func (a *App) runSlackTest(ctx context.Context) error { + if a.notifier == nil { + return fmt.Errorf("slack: %w", domain.ErrFeatureDisabled) } - a.Logger.Info("sent test security alerts notification") + steps := []struct { + name string + send func() error + }{ + {"pr bypass", func() error { + return a.notifier.NotifyPRBypass(ctx, samplePRComplianceResult(), "acme-corp/demo-repo") + }}, + {"okta sync", func() error { return a.notifier.NotifyOktaSync(ctx, sampleOktaSyncReports(), "acme-corp") }}, + {"orphaned users", func() error { return a.notifier.NotifyOrphanedUsers(ctx, sampleOrphanedUsersReport()) }}, + {"security alerts", func() error { + return a.notifier.NotifySecurityAlerts(ctx, sampleSecurityAlertsReport(), "acme-corp") + }}, + } + for _, step := range steps { + if err := step.send(); err != nil { + return fmt.Errorf("failed to send test %s notification: %w", step.name, err) + } + a.logger.InfoContext(ctx, "sent test notification", "type", step.name) + } return nil } diff --git a/internal/app/handlers_test.go b/internal/app/handlers_test.go deleted file mode 100644 index 1c7c694..0000000 --- a/internal/app/handlers_test.go +++ /dev/null @@ -1,780 +0,0 @@ -package app - -import ( - "context" - "encoding/json" - "io" - "log/slog" - "net/http" - "net/http/httptest" - "testing" - - "github.com/cockroachdb/errors" - "github.com/cruxstack/github-ops-app/internal/config" - "github.com/cruxstack/github-ops-app/internal/domain" - "github.com/google/go-github/v79/github" -) - -// --- mock implementations --- - -type mockGitHubClient struct { - checkPRComplianceFn func(ctx context.Context, owner, repo string, prNumber int) (*domain.PRComplianceResult, error) - getOrCreateTeamFn func(ctx context.Context, teamName, privacy string) (*github.Team, error) - syncTeamMembersFn func(ctx context.Context, teamSlug string, desiredMembers []string, threshold float64) (*domain.TeamSyncResult, error) - getTeamMembersFn func(ctx context.Context, teamSlug string) ([]string, error) - listOrgMembersFn func(ctx context.Context) ([]string, error) - isExternalCollaboratorFn func(ctx context.Context, username string) (bool, error) - getAppSlugFn func(ctx context.Context) (string, error) - listSecurityAlertsFn func(ctx context.Context, minAgeDays int, minSeverity string) (*domain.SecurityAlertsReport, error) -} - -func (m *mockGitHubClient) CheckPRCompliance(ctx context.Context, owner, repo string, prNumber int) (*domain.PRComplianceResult, error) { - if m.checkPRComplianceFn != nil { - return m.checkPRComplianceFn(ctx, owner, repo, prNumber) - } - return &domain.PRComplianceResult{}, nil -} -func (m *mockGitHubClient) GetOrCreateTeam(ctx context.Context, teamName, privacy string) (*github.Team, error) { - if m.getOrCreateTeamFn != nil { - return m.getOrCreateTeamFn(ctx, teamName, privacy) - } - slug := teamName - return &github.Team{Slug: &slug}, nil -} -func (m *mockGitHubClient) SyncTeamMembers(ctx context.Context, teamSlug string, desiredMembers []string, threshold float64) (*domain.TeamSyncResult, error) { - if m.syncTeamMembersFn != nil { - return m.syncTeamMembersFn(ctx, teamSlug, desiredMembers, threshold) - } - return &domain.TeamSyncResult{TeamName: teamSlug, MembersAdded: desiredMembers}, nil -} -func (m *mockGitHubClient) GetTeamMembers(ctx context.Context, teamSlug string) ([]string, error) { - if m.getTeamMembersFn != nil { - return m.getTeamMembersFn(ctx, teamSlug) - } - return []string{}, nil -} -func (m *mockGitHubClient) ListOrgMembers(ctx context.Context) ([]string, error) { - if m.listOrgMembersFn != nil { - return m.listOrgMembersFn(ctx) - } - return []string{}, nil -} -func (m *mockGitHubClient) IsExternalCollaborator(ctx context.Context, username string) (bool, error) { - if m.isExternalCollaboratorFn != nil { - return m.isExternalCollaboratorFn(ctx, username) - } - return false, nil -} -func (m *mockGitHubClient) GetAppSlug(ctx context.Context) (string, error) { - if m.getAppSlugFn != nil { - return m.getAppSlugFn(ctx) - } - return "test-app", nil -} -func (m *mockGitHubClient) GetOrg() string { return "test-org" } -func (m *mockGitHubClient) ListSecurityAlerts(ctx context.Context, minAgeDays int, minSeverity string) (*domain.SecurityAlertsReport, error) { - if m.listSecurityAlertsFn != nil { - return m.listSecurityAlertsFn(ctx, minAgeDays, minSeverity) - } - return &domain.SecurityAlertsReport{AlertsByRepo: map[string][]domain.SecurityAlert{}}, nil -} - -type mockOktaClient struct { - getGroupsByPatternFn func(ctx context.Context, pattern string) ([]*domain.GroupInfo, error) - getGroupInfoFn func(ctx context.Context, groupName string) (*domain.GroupInfo, error) -} - -func (m *mockOktaClient) GetGroupsByPattern(ctx context.Context, pattern string) ([]*domain.GroupInfo, error) { - if m.getGroupsByPatternFn != nil { - return m.getGroupsByPatternFn(ctx, pattern) - } - return []*domain.GroupInfo{}, nil -} -func (m *mockOktaClient) GetGroupInfo(ctx context.Context, groupName string) (*domain.GroupInfo, error) { - if m.getGroupInfoFn != nil { - return m.getGroupInfoFn(ctx, groupName) - } - return &domain.GroupInfo{ID: "g1", Name: groupName, Members: []string{}}, nil -} - -type mockNotifier struct { - notifyPRBypassFn func(ctx context.Context, result *domain.PRComplianceResult, repoFullName string) error - notifyOktaSyncFn func(ctx context.Context, reports []*domain.SyncReport, githubOrg string) error - notifyOrphanedUsersFn func(ctx context.Context, report *domain.OrphanedUsersReport) error - notifySecurityAlertsFn func(ctx context.Context, report *domain.SecurityAlertsReport, githubOrg string) error -} - -func (m *mockNotifier) NotifyPRBypass(ctx context.Context, result *domain.PRComplianceResult, repoFullName string) error { - if m.notifyPRBypassFn != nil { - return m.notifyPRBypassFn(ctx, result, repoFullName) - } - return nil -} -func (m *mockNotifier) NotifyOktaSync(ctx context.Context, reports []*domain.SyncReport, githubOrg string) error { - if m.notifyOktaSyncFn != nil { - return m.notifyOktaSyncFn(ctx, reports, githubOrg) - } - return nil -} -func (m *mockNotifier) NotifyOrphanedUsers(ctx context.Context, report *domain.OrphanedUsersReport) error { - if m.notifyOrphanedUsersFn != nil { - return m.notifyOrphanedUsersFn(ctx, report) - } - return nil -} -func (m *mockNotifier) NotifySecurityAlerts(ctx context.Context, report *domain.SecurityAlertsReport, githubOrg string) error { - if m.notifySecurityAlertsFn != nil { - return m.notifySecurityAlertsFn(ctx, report, githubOrg) - } - return nil -} - -func discardLogger() *slog.Logger { - return slog.New(slog.NewTextHandler(io.Discard, nil)) -} - -// --- handler tests --- - -func TestHandleOktaSync_Disabled(t *testing.T) { - a := &App{ - Config: &config.Config{}, - Logger: discardLogger(), - } - - err := a.handleOktaSync(context.Background()) - if err != nil { - t.Fatalf("expected nil error when sync disabled, got: %v", err) - } -} - -func TestHandleOktaSync_ClientsNil(t *testing.T) { - a := &App{ - Config: &config.Config{ - OktaDomain: "test.okta.com", - OktaClientID: "cid", - OktaPrivateKey: []byte("key"), - OktaSyncRules: []domain.SyncRule{{OktaGroupName: "g1", GitHubTeamName: "t1"}}, - }, - Logger: discardLogger(), - } - - err := a.handleOktaSync(context.Background()) - if err == nil { - t.Fatal("expected error when clients are nil") - } - if !errors.Is(err, domain.ErrClientNotInit) { - t.Errorf("expected ErrClientNotInit, got: %v", err) - } -} - -func TestHandleOktaSync_Success(t *testing.T) { - notified := false - a := &App{ - Config: &config.Config{ - OktaDomain: "test.okta.com", - OktaClientID: "cid", - OktaPrivateKey: []byte("key"), - OktaSyncRules: []domain.SyncRule{{OktaGroupName: "Engineering", GitHubTeamName: "engineering"}}, - GitHubOrg: "test-org", - }, - Logger: discardLogger(), - GitHubClient: &mockGitHubClient{}, - OktaClient: &mockOktaClient{ - getGroupInfoFn: func(_ context.Context, name string) (*domain.GroupInfo, error) { - return &domain.GroupInfo{ID: "g1", Name: name, Members: []string{"alice"}}, nil - }, - }, - Notifier: &mockNotifier{ - notifyOktaSyncFn: func(_ context.Context, reports []*domain.SyncReport, org string) error { - notified = true - if len(reports) != 1 { - t.Errorf("expected 1 report, got %d", len(reports)) - } - return nil - }, - }, - } - - err := a.handleOktaSync(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !notified { - t.Error("expected slack notification to be sent") - } -} - -func TestHandleOktaSync_NotifierFailureDoesNotFail(t *testing.T) { - a := &App{ - Config: &config.Config{ - OktaDomain: "test.okta.com", - OktaClientID: "cid", - OktaPrivateKey: []byte("key"), - OktaSyncRules: []domain.SyncRule{{OktaGroupName: "Eng", GitHubTeamName: "eng"}}, - GitHubOrg: "org", - }, - Logger: discardLogger(), - GitHubClient: &mockGitHubClient{}, - OktaClient: &mockOktaClient{}, - Notifier: &mockNotifier{ - notifyOktaSyncFn: func(_ context.Context, _ []*domain.SyncReport, _ string) error { - return errors.New("slack api error") - }, - }, - } - - err := a.handleOktaSync(context.Background()) - if err != nil { - t.Fatalf("notifier failure should not propagate, got: %v", err) - } -} - -func TestHandlePullRequestWebhook_NotMerged(t *testing.T) { - prNumber := 10 - baseBranch := "main" - payload := map[string]any{ - "action": "opened", - "number": prNumber, - "pull_request": map[string]any{ - "number": prNumber, - "base": map[string]any{"ref": baseBranch}, - }, - "repository": map[string]any{ - "name": "repo", - "full_name": "org/repo", - "owner": map[string]any{"login": "org"}, - }, - } - data, _ := json.Marshal(payload) - - a := &App{ - Config: &config.Config{PRComplianceEnabled: true, PRMonitoredBranches: []string{"main"}, - GitHubOrg: "org", GitHubAppID: 1, GitHubAppPrivateKey: []byte("k"), GitHubInstallationID: 1}, - Logger: discardLogger(), - GitHubClient: &mockGitHubClient{}, - } - - err := a.handlePullRequestWebhook(context.Background(), data) - if err != nil { - t.Fatalf("non-merged PR should not error, got: %v", err) - } -} - -func TestHandlePullRequestWebhook_UnmonitoredBranch(t *testing.T) { - merged := true - prNumber := 10 - payload := map[string]any{ - "action": "closed", - "number": prNumber, - "pull_request": map[string]any{ - "number": prNumber, - "merged": merged, - "base": map[string]any{"ref": "develop"}, - }, - "repository": map[string]any{ - "name": "repo", - "full_name": "org/repo", - "owner": map[string]any{"login": "org"}, - }, - } - data, _ := json.Marshal(payload) - - a := &App{ - Config: &config.Config{PRComplianceEnabled: true, PRMonitoredBranches: []string{"main"}, - GitHubOrg: "org", GitHubAppID: 1, GitHubAppPrivateKey: []byte("k"), GitHubInstallationID: 1}, - Logger: discardLogger(), - GitHubClient: &mockGitHubClient{}, - } - - err := a.handlePullRequestWebhook(context.Background(), data) - if err != nil { - t.Fatalf("unmonitored branch should not error, got: %v", err) - } -} - -func TestHandlePullRequestWebhook_ComplianceCheck(t *testing.T) { - merged := true - prNumber := 42 - checked := false - payload := map[string]any{ - "action": "closed", - "number": prNumber, - "pull_request": map[string]any{ - "number": prNumber, - "merged": merged, - "base": map[string]any{"ref": "main"}, - }, - "repository": map[string]any{ - "name": "repo", - "full_name": "org/repo", - "owner": map[string]any{"login": "org"}, - }, - } - data, _ := json.Marshal(payload) - - a := &App{ - Config: &config.Config{ - PRComplianceEnabled: true, PRMonitoredBranches: []string{"main"}, - GitHubOrg: "org", GitHubAppID: 1, GitHubAppPrivateKey: []byte("k"), GitHubInstallationID: 1, - }, - Logger: discardLogger(), - GitHubClient: &mockGitHubClient{ - checkPRComplianceFn: func(_ context.Context, owner, repo string, num int) (*domain.PRComplianceResult, error) { - checked = true - if num != prNumber { - t.Errorf("expected pr %d, got %d", prNumber, num) - } - return &domain.PRComplianceResult{ - Violations: []domain.ComplianceViolation{}, - }, nil - }, - }, - } - - err := a.handlePullRequestWebhook(context.Background(), data) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !checked { - t.Error("expected compliance check to be called") - } -} - -func TestHandlePullRequestWebhook_BypassNotification(t *testing.T) { - merged := true - prNumber := 42 - notified := false - prURL := "https://github.com/org/repo/pull/42" - payload := map[string]any{ - "action": "closed", - "number": prNumber, - "pull_request": map[string]any{ - "number": prNumber, - "merged": merged, - "base": map[string]any{"ref": "main"}, - "html_url": prURL, - "merged_by": map[string]any{"login": "admin-user"}, - }, - "repository": map[string]any{ - "name": "repo", - "full_name": "org/repo", - "owner": map[string]any{"login": "org"}, - }, - } - data, _ := json.Marshal(payload) - - pr := &github.PullRequest{} - a := &App{ - Config: &config.Config{ - PRComplianceEnabled: true, PRMonitoredBranches: []string{"main"}, - GitHubOrg: "org", GitHubAppID: 1, GitHubAppPrivateKey: []byte("k"), GitHubInstallationID: 1, - }, - Logger: discardLogger(), - GitHubClient: &mockGitHubClient{ - checkPRComplianceFn: func(_ context.Context, _, _ string, _ int) (*domain.PRComplianceResult, error) { - return &domain.PRComplianceResult{ - PR: pr, - UserHasBypass: true, - Violations: []domain.ComplianceViolation{{Type: "test", Description: "test"}}, - }, nil - }, - }, - Notifier: &mockNotifier{ - notifyPRBypassFn: func(_ context.Context, result *domain.PRComplianceResult, repoName string) error { - notified = true - if repoName != "org/repo" { - t.Errorf("expected repo org/repo, got %s", repoName) - } - return nil - }, - }, - } - - err := a.handlePullRequestWebhook(context.Background(), data) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !notified { - t.Error("expected bypass notification to be sent") - } -} - -func TestHandleTeamWebhook_SyncDisabled(t *testing.T) { - payload := map[string]any{ - "action": "edited", - "team": map[string]any{"slug": "engineering"}, - "sender": map[string]any{"login": "user1", "type": "User"}, - } - data, _ := json.Marshal(payload) - - a := &App{ - Config: &config.Config{}, // no okta sync configured - Logger: discardLogger(), - } - - err := a.handleTeamWebhook(context.Background(), data) - if err != nil { - t.Fatalf("expected nil when sync disabled, got: %v", err) - } -} - -func TestHandleTeamWebhook_IgnoresBotSender(t *testing.T) { - payload := map[string]any{ - "action": "edited", - "team": map[string]any{"slug": "engineering"}, - "sender": map[string]any{"login": "dependabot", "type": "Bot"}, - } - data, _ := json.Marshal(payload) - - a := &App{ - Config: &config.Config{ - OktaDomain: "test.okta.com", OktaClientID: "cid", - OktaPrivateKey: []byte("key"), - OktaSyncRules: []domain.SyncRule{{OktaGroupName: "g", GitHubTeamName: "t"}}, - }, - Logger: discardLogger(), - GitHubClient: &mockGitHubClient{}, - OktaClient: &mockOktaClient{}, - } - - err := a.handleTeamWebhook(context.Background(), data) - if err != nil { - t.Fatalf("bot sender should be ignored, got: %v", err) - } -} - -func TestHandleMembershipWebhook_NonTeamScope(t *testing.T) { - payload := map[string]any{ - "action": "added", - "scope": "organization", - "member": map[string]any{"login": "user1"}, - "team": map[string]any{"slug": "eng"}, - "sender": map[string]any{"login": "admin", "type": "User"}, - } - data, _ := json.Marshal(payload) - - a := &App{ - Config: &config.Config{}, - Logger: discardLogger(), - } - - err := a.handleMembershipWebhook(context.Background(), data) - if err != nil { - t.Fatalf("non-team scope should be skipped, got: %v", err) - } -} - -func TestShouldIgnoreWebhookChange_BotSender(t *testing.T) { - a := &App{ - Config: &config.Config{}, - Logger: discardLogger(), - } - - event := &stubSender{senderType: "Bot", senderLogin: "some-bot"} - if !a.shouldIgnoreWebhookChange(context.Background(), event) { - t.Error("expected Bot sender to be ignored") - } -} - -func TestShouldIgnoreWebhookChange_AppSlugMatch(t *testing.T) { - a := &App{ - Config: &config.Config{}, - Logger: discardLogger(), - GitHubClient: &mockGitHubClient{ - getAppSlugFn: func(_ context.Context) (string, error) { - return "my-app", nil - }, - }, - } - - event := &stubSender{senderType: "User", senderLogin: "my-app[bot]"} - if !a.shouldIgnoreWebhookChange(context.Background(), event) { - t.Error("expected app slug match to be ignored") - } -} - -func TestShouldIgnoreWebhookChange_HumanUser(t *testing.T) { - a := &App{ - Config: &config.Config{}, - Logger: discardLogger(), - GitHubClient: &mockGitHubClient{ - getAppSlugFn: func(_ context.Context) (string, error) { - return "my-app", nil - }, - }, - } - - event := &stubSender{senderType: "User", senderLogin: "human-user"} - if a.shouldIgnoreWebhookChange(context.Background(), event) { - t.Error("expected human user NOT to be ignored") - } -} - -func TestProcessWebhook_UnknownEventType(t *testing.T) { - a := &App{ - Config: &config.Config{}, - Logger: discardLogger(), - } - - err := a.processWebhook(context.Background(), []byte(`{}`), "deployment") - if err == nil { - t.Fatal("expected error for unknown event type") - } - if !errors.Is(err, domain.ErrInvalidEventType) { - t.Errorf("expected ErrInvalidEventType, got: %v", err) - } -} - -func TestWriteErrorFromDomain(t *testing.T) { - tests := []struct { - name string - err error - wantStatus int - }{ - {"auth error direct", domain.ErrInvalidSignature, 401}, - {"auth error wrapped", errors.Wrap(domain.ErrInvalidSignature, "wrapped"), 401}, - {"validation error", domain.ErrMissingPRData, 400}, - {"validation error wrapped", errors.Wrap(domain.ErrInvalidEventType, "wrapped"), 400}, - {"config error", domain.ErrClientNotInit, 503}, - {"api error", errors.Wrap(domain.ErrTeamNotFound, "wrapped"), 502}, - {"generic error", errors.New("something"), 500}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - rec := httptest.NewRecorder() - writeErrorFromDomain(rec, tt.err, "fallback") - if rec.Code != tt.wantStatus { - t.Errorf("expected status %d, got %d", tt.wantStatus, rec.Code) - } - }) - } -} - -func TestRouter_NotFound(t *testing.T) { - a := &App{ - Config: &config.Config{}, - Logger: discardLogger(), - } - - router := a.Handler() - req := httptest.NewRequest(http.MethodGet, "/nonexistent", nil) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - if rec.Code != http.StatusNotFound { - t.Errorf("expected 404, got %d", rec.Code) - } -} - -func TestRouter_MethodNotAllowed(t *testing.T) { - a := &App{ - Config: &config.Config{}, - Logger: discardLogger(), - } - - router := a.Handler() - req := httptest.NewRequest(http.MethodDelete, "/webhooks", nil) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - if rec.Code != http.StatusMethodNotAllowed { - t.Errorf("expected 405, got %d", rec.Code) - } -} - -func TestRouter_BasePathStripping(t *testing.T) { - a := &App{ - Config: &config.Config{BasePath: "/api/v1"}, - Logger: discardLogger(), - } - - router := a.Handler() - req := httptest.NewRequest(http.MethodGet, "/api/v1/server/status", nil) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Errorf("expected 200 for base-path-stripped status, got %d (body: %s)", - rec.Code, rec.Body.String()) - } -} - -func TestHandleSecurityAlerts_Disabled(t *testing.T) { - a := &App{ - Config: &config.Config{}, - Logger: discardLogger(), - } - - err := a.handleSecurityAlerts(context.Background()) - if err != nil { - t.Fatalf("expected nil when disabled, got: %v", err) - } -} - -func TestHandleSecurityAlerts_ClientNil(t *testing.T) { - a := &App{ - Config: &config.Config{ - SecurityAlertsEnabled: true, - GitHubOrg: "org", - GitHubAppID: 1, - GitHubAppPrivateKey: []byte("k"), - GitHubInstallationID: 1, - }, - Logger: discardLogger(), - } - - err := a.handleSecurityAlerts(context.Background()) - if err == nil { - t.Fatal("expected error when client is nil") - } - if !errors.Is(err, domain.ErrClientNotInit) { - t.Errorf("expected ErrClientNotInit, got: %v", err) - } -} - -func TestHandleSecurityAlerts_Success(t *testing.T) { - notified := false - a := &App{ - Config: &config.Config{ - SecurityAlertsEnabled: true, - SecurityAlertsMinAgeDays: 30, - SecurityAlertsMinSeverity: "high", - GitHubOrg: "org", - GitHubAppID: 1, - GitHubAppPrivateKey: []byte("k"), - GitHubInstallationID: 1, - }, - Logger: discardLogger(), - GitHubClient: &mockGitHubClient{ - listSecurityAlertsFn: func(_ context.Context, minAge int, minSev string) (*domain.SecurityAlertsReport, error) { - if minAge != 30 { - t.Errorf("expected minAge 30, got %d", minAge) - } - if minSev != "high" { - t.Errorf("expected minSev high, got %s", minSev) - } - return &domain.SecurityAlertsReport{ - TotalAlerts: 2, - AlertsByRepo: map[string][]domain.SecurityAlert{ - "org/repo": {{Type: "dependabot", Severity: "critical"}}, - }, - }, nil - }, - }, - Notifier: &mockNotifier{ - notifySecurityAlertsFn: func(_ context.Context, report *domain.SecurityAlertsReport, org string) error { - notified = true - if report.TotalAlerts != 2 { - t.Errorf("expected 2 alerts, got %d", report.TotalAlerts) - } - if org != "org" { - t.Errorf("expected org, got %s", org) - } - return nil - }, - }, - } - - err := a.handleSecurityAlerts(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !notified { - t.Error("expected notification to be sent") - } -} - -func TestHandleSecurityAlerts_NoAlertsNoNotification(t *testing.T) { - notified := false - a := &App{ - Config: &config.Config{ - SecurityAlertsEnabled: true, - SecurityAlertsMinAgeDays: 30, - SecurityAlertsMinSeverity: "high", - GitHubOrg: "org", - GitHubAppID: 1, - GitHubAppPrivateKey: []byte("k"), - GitHubInstallationID: 1, - }, - Logger: discardLogger(), - GitHubClient: &mockGitHubClient{ - listSecurityAlertsFn: func(_ context.Context, _ int, _ string) (*domain.SecurityAlertsReport, error) { - return &domain.SecurityAlertsReport{ - TotalAlerts: 0, - AlertsByRepo: map[string][]domain.SecurityAlert{}, - }, nil - }, - }, - Notifier: &mockNotifier{ - notifySecurityAlertsFn: func(_ context.Context, _ *domain.SecurityAlertsReport, _ string) error { - notified = true - return nil - }, - }, - } - - err := a.handleSecurityAlerts(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if notified { - t.Error("should not notify when no alerts") - } -} - -func TestHandleSecurityAlerts_NotifierFailureDoesNotFail(t *testing.T) { - a := &App{ - Config: &config.Config{ - SecurityAlertsEnabled: true, - SecurityAlertsMinAgeDays: 30, - SecurityAlertsMinSeverity: "high", - GitHubOrg: "org", - GitHubAppID: 1, - GitHubAppPrivateKey: []byte("k"), - GitHubInstallationID: 1, - }, - Logger: discardLogger(), - GitHubClient: &mockGitHubClient{ - listSecurityAlertsFn: func(_ context.Context, _ int, _ string) (*domain.SecurityAlertsReport, error) { - return &domain.SecurityAlertsReport{ - TotalAlerts: 1, - AlertsByRepo: map[string][]domain.SecurityAlert{ - "org/repo": {{Type: "dependabot"}}, - }, - }, nil - }, - }, - Notifier: &mockNotifier{ - notifySecurityAlertsFn: func(_ context.Context, _ *domain.SecurityAlertsReport, _ string) error { - return errors.New("slack api error") - }, - }, - } - - err := a.handleSecurityAlerts(context.Background()) - if err != nil { - t.Fatalf("notifier failure should not propagate, got: %v", err) - } -} - -func TestProcessScheduledEvent_SecurityAlerts(t *testing.T) { - a := &App{ - Config: &config.Config{}, - Logger: discardLogger(), - } - - err := a.processScheduledEvent(context.Background(), ScheduledEvent{Action: "security-alerts"}) - if err != nil { - t.Fatalf("expected nil when feature disabled, got: %v", err) - } -} - -// stubSender is a simple webhookSender for testing shouldIgnoreWebhookChange -type stubSender struct { - senderType string - senderLogin string -} - -func (s *stubSender) GetSenderType() string { return s.senderType } -func (s *stubSender) GetSenderLogin() string { return s.senderLogin } diff --git a/internal/app/http.go b/internal/app/http.go new file mode 100644 index 0000000..8c148c7 --- /dev/null +++ b/internal/app/http.go @@ -0,0 +1,171 @@ +package app + +import ( + "crypto/sha256" + "crypto/subtle" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/cruxstack/github-ops-app/internal/domain" + "github.com/cruxstack/github-ops-app/internal/github/webhooks" +) + +// maxWebhookBytes is the largest webhook payload accepted. GitHub caps +// payloads at 25 MB. +const maxWebhookBytes = 25 << 20 + +// newRouter builds the application's routes under the base path: +// +// POST /webhooks GitHub webhooks, verified by signature +// GET /server/status status and enabled features (admin) +// GET /server/config configuration, secrets redacted (admin) +// POST /scheduled/{action} run a scheduled action (admin) +func (a *App) newRouter() http.Handler { + r := chi.NewRouter() + r.NotFound(func(w http.ResponseWriter, _ *http.Request) { + writeError(w, http.StatusNotFound, "not found") + }) + r.MethodNotAllowed(func(w http.ResponseWriter, _ *http.Request) { + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + }) + + mount := a.cfg.Server.BasePath + if mount == "" { + mount = "/" + } + r.Route(mount, func(r chi.Router) { + r.Post("/webhooks", a.handleWebhook) + + r.Group(func(r chi.Router) { + r.Use(a.requireAdmin) + r.Get("/server/status", a.handleStatus) + r.Get("/server/config", a.handleConfig) + r.Post("/scheduled/{action}", a.handleScheduled) + }) + }) + return r +} + +// requireAdmin allows a request only with the admin bearer token. When no +// token is configured the admin endpoints are disabled. +func (a *App) requireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if a.cfg.AdminToken == "" { + writeError(w, http.StatusForbidden, "admin api is disabled: set APP_ADMIN_TOKEN to enable it") + return + } + scheme, token, ok := strings.Cut(r.Header.Get("Authorization"), " ") + if !ok || !strings.EqualFold(scheme, "Bearer") || !tokensEqual(strings.TrimSpace(token), a.cfg.AdminToken) { + w.Header().Set("WWW-Authenticate", `Bearer realm="github-ops-app"`) + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + next.ServeHTTP(w, r) + }) +} + +// tokensEqual compares two tokens in constant time, including their length. +func tokensEqual(a, b string) bool { + ha, hb := sha256.Sum256([]byte(a)), sha256.Sum256([]byte(b)) + return subtle.ConstantTimeCompare(ha[:], hb[:]) == 1 +} + +func (a *App) handleStatus(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, a.Status()) +} + +func (a *App) handleConfig(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, a.cfg.Redacted()) +} + +func (a *App) handleWebhook(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + eventType := r.Header.Get("X-GitHub-Event") + log := a.logger.With( + "event_type", eventType, + "delivery_id", r.Header.Get("X-GitHub-Delivery")) + + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxWebhookBytes)) + if err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + writeError(w, http.StatusRequestEntityTooLarge, "payload too large") + return + } + writeError(w, http.StatusBadRequest, "failed to read request body") + return + } + + if err := webhooks.ValidateSignature(body, r.Header.Get("X-Hub-Signature-256"), a.cfg.GitHub.WebhookSecret); err != nil { + log.WarnContext(ctx, "rejected webhook with invalid signature", "error", err.Error()) + writeDomainError(w, err, "unauthorized") + return + } + + result, err := a.processWebhook(ctx, eventType, body) + if err != nil { + log.ErrorContext(ctx, "webhook processing failed", "error", err.Error()) + writeDomainError(w, err, "webhook processing failed") + return + } + + status := http.StatusOK + if result == WebhookIgnored || result == WebhookQueued { + status = http.StatusAccepted + } + writeJSON(w, status, map[string]string{"status": string(result)}) +} + +func (a *App) handleScheduled(w http.ResponseWriter, r *http.Request) { + action := chi.URLParam(r, "action") + if err := a.RunScheduled(r.Context(), action); err != nil { + a.logger.ErrorContext(r.Context(), "scheduled action failed", + "action", action, + "error", err.Error()) + writeDomainError(w, err, "scheduled action failed") + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "success", "action": action}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + body, err := json.Marshal(v) + if err != nil { + http.Error(w, "failed to encode response", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(status) + _, _ = w.Write(body) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +// writeDomainError responds with the status for err's kind. The message +// never includes err itself, which can hold internal details. +func writeDomainError(w http.ResponseWriter, err error, fallback string) { + switch { + case errors.Is(err, domain.ErrAuth): + writeError(w, http.StatusUnauthorized, "unauthorized") + case errors.Is(err, domain.ErrUnknownAction): + writeError(w, http.StatusNotFound, "unknown scheduled action") + case errors.Is(err, domain.ErrValidation): + writeError(w, http.StatusBadRequest, "invalid request") + case errors.Is(err, domain.ErrFeatureDisabled): + writeError(w, http.StatusConflict, "feature is not enabled") + case errors.Is(err, domain.ErrConfig): + writeError(w, http.StatusServiceUnavailable, "service not configured") + case errors.Is(err, domain.ErrAPI): + writeError(w, http.StatusBadGateway, fallback) + default: + writeError(w, http.StatusInternalServerError, fallback) + } +} diff --git a/internal/app/request.go b/internal/app/request.go deleted file mode 100644 index 351b24f..0000000 --- a/internal/app/request.go +++ /dev/null @@ -1,195 +0,0 @@ -package app - -import ( - "encoding/json" - "io" - "log/slog" - "net/http" - "strings" - - "github.com/cockroachdb/errors" - "github.com/cruxstack/github-ops-app/internal/domain" - "github.com/cruxstack/github-ops-app/internal/github/webhooks" - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" -) - -// Handler returns the HTTP handler for the application. -// this is the single entry point for all HTTP request processing. -// both the server and lambda entry points feed requests into this handler. -func (a *App) Handler() http.Handler { - a.routerOnce.Do(func() { - a.router = a.buildRouter() - }) - return a.router -} - -// buildRouter constructs the chi router with all routes and middleware. -func (a *App) buildRouter() http.Handler { - r := chi.NewRouter() - r.Use(middleware.Recoverer) - - basePath := a.Config.BasePath - if basePath == "" { - basePath = "/" - } - - r.Route(basePath, func(r chi.Router) { - r.Post("/webhooks", a.handleWebhookHTTP) - - r.Group(func(r chi.Router) { - r.Use(a.adminAuthMiddleware) - r.Get("/server/status", a.handleStatusHTTP) - r.Get("/server/config", a.handleConfigHTTP) - r.Post("/scheduled/{action}", a.handleScheduledHTTP) - }) - }) - - return r -} - -// adminAuthMiddleware validates the admin bearer token on protected routes. -// if no admin token is configured, all requests pass through. -func (a *App) adminAuthMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if a.Config.AdminToken == "" { - next.ServeHTTP(w, r) - return - } - - authHeader := r.Header.Get("Authorization") - if authHeader == "" { - writeError(w, http.StatusUnauthorized, "unauthorized") - return - } - - token := strings.TrimPrefix(authHeader, "Bearer ") - if token == authHeader { - token = strings.TrimPrefix(authHeader, "bearer ") - } - - if token != a.Config.AdminToken { - writeError(w, http.StatusUnauthorized, "unauthorized") - return - } - - next.ServeHTTP(w, r) - }) -} - -// handleStatusHTTP returns application status. -func (a *App) handleStatusHTTP(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, a.GetStatus()) -} - -// handleConfigHTTP returns redacted configuration. -func (a *App) handleConfigHTTP(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, a.Config.Redacted()) -} - -// handleWebhookHTTP processes GitHub webhook POST requests. -func (a *App) handleWebhookHTTP(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(io.LimitReader(r.Body, 10<<20)) // 10MB limit - if err != nil { - writeError(w, http.StatusBadRequest, "failed to read request body") - return - } - - eventType := r.Header.Get("X-GitHub-Event") - signature := r.Header.Get("X-Hub-Signature-256") - - if err := webhooks.ValidateWebhookSignature( - body, - signature, - a.Config.GitHubWebhookSecret, - ); err != nil { - a.Logger.Warn("webhook signature validation failed", - slog.String("error", err.Error())) - writeErrorFromDomain(w, err, "unauthorized") - return - } - - if err := a.processWebhook(r.Context(), body, eventType); err != nil { - a.Logger.Error("webhook processing failed", - slog.String("event_type", eventType), - slog.String("error", err.Error())) - writeErrorFromDomain(w, err, "webhook processing failed") - return - } - - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - w.Write([]byte("ok")) -} - -// handleScheduledHTTP processes scheduled events via HTTP POST. -// the action is extracted from the chi URL parameter. -func (a *App) handleScheduledHTTP(w http.ResponseWriter, r *http.Request) { - action := chi.URLParam(r, "action") - if action == "" { - writeError(w, http.StatusBadRequest, "missing scheduled action") - return - } - - evt := ScheduledEvent{Action: action} - - if r.Body != nil { - body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) - if err != nil { - writeError(w, http.StatusBadRequest, "failed to read request body") - return - } - if len(body) > 0 { - evt.Data = json.RawMessage(body) - } - } - - if err := a.processScheduledEvent(r.Context(), evt); err != nil { - a.Logger.Error("scheduled event processing failed", - slog.String("action", evt.Action), - slog.String("error", err.Error())) - writeErrorFromDomain(w, err, "scheduled event processing failed") - return - } - - writeJSON(w, http.StatusOK, map[string]string{ - "status": "success", - "message": evt.Action + " completed", - }) -} - -// writeJSON writes a JSON response with the given status code. -func writeJSON(w http.ResponseWriter, status int, data any) { - body, err := json.Marshal(data) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to marshal response") - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - w.Write(body) -} - -// writeError writes a plain text error response. -func writeError(w http.ResponseWriter, status int, message string) { - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(status) - w.Write([]byte(message)) -} - -// writeErrorFromDomain translates domain error types to HTTP status codes -// and writes the response. centralizes error-to-HTTP mapping. -func writeErrorFromDomain(w http.ResponseWriter, err error, fallbackMsg string) { - switch { - case errors.Is(err, domain.AuthError): - writeError(w, http.StatusUnauthorized, "unauthorized") - case errors.Is(err, domain.ValidationError): - writeError(w, http.StatusBadRequest, fallbackMsg) - case errors.Is(err, domain.ConfigError): - writeError(w, http.StatusServiceUnavailable, "service not configured") - case errors.Is(err, domain.APIError): - writeError(w, http.StatusBadGateway, fallbackMsg) - default: - writeError(w, http.StatusInternalServerError, fallbackMsg) - } -} diff --git a/internal/app/testdata.go b/internal/app/samples.go similarity index 85% rename from internal/app/testdata.go rename to internal/app/samples.go index 0e0c07e..0b07767 100644 --- a/internal/app/testdata.go +++ b/internal/app/samples.go @@ -3,12 +3,13 @@ package app import ( "time" + gh "github.com/google/go-github/v93/github" + "github.com/cruxstack/github-ops-app/internal/domain" - gh "github.com/google/go-github/v79/github" ) -// fakePRComplianceResult returns sample PR compliance data for testing. -func fakePRComplianceResult() *domain.PRComplianceResult { +// samplePRComplianceResult returns sample data for the slack-test action. +func samplePRComplianceResult() *domain.PRComplianceResult { prNumber := 42 prTitle := "Add new authentication feature" prURL := "https://github.com/acme-corp/demo-repo/pull/42" @@ -33,8 +34,8 @@ func fakePRComplianceResult() *domain.PRComplianceResult { } } -// fakeOktaSyncReports returns sample Okta sync reports for testing. -func fakeOktaSyncReports() []*domain.SyncReport { +// sampleOktaSyncReports returns sample data for the slack-test action. +func sampleOktaSyncReports() []*domain.SyncReport { return []*domain.SyncReport{ { Rule: "engineering-team", @@ -61,16 +62,15 @@ func fakeOktaSyncReports() []*domain.SyncReport { } } -// fakeOrphanedUsersReport returns sample orphaned users data for testing. -func fakeOrphanedUsersReport() *domain.OrphanedUsersReport { +// sampleOrphanedUsersReport returns sample data for the slack-test action. +func sampleOrphanedUsersReport() *domain.OrphanedUsersReport { return &domain.OrphanedUsersReport{ OrphanedUsers: []string{"orphan-user-1", "orphan-user-2", "legacy-bot"}, } } -// fakeSecurityAlertsReport returns sample security alerts data for -// testing. -func fakeSecurityAlertsReport() *domain.SecurityAlertsReport { +// sampleSecurityAlertsReport returns sample data for the slack-test action. +func sampleSecurityAlertsReport() *domain.SecurityAlertsReport { now := time.Now() return &domain.SecurityAlertsReport{ MinAgeDays: 30, diff --git a/internal/config/config.go b/internal/config/config.go index f631502..1e1b41e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,419 +1,675 @@ -// Package config provides application configuration loaded from environment -// variables. +// Package config reads and validates the configuration from environment +// variables. Invalid, incomplete, or contradictory settings are errors, all +// reported at once, so a misconfigured service fails at startup instead of +// silently running with a feature disabled or a default it was not given. +// +// Any value may be an AWS SSM Parameter Store ARN, which is replaced by the +// parameter's decrypted value. package config import ( + "bytes" "context" "encoding/json" + "errors" + "fmt" "log/slog" + "maps" + "net" + "net/url" "os" + "path" + "regexp" + "slices" "strconv" "strings" - "sync" - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/service/ssm" - "github.com/cockroachdb/errors" + "github.com/cruxstack/github-app-setup-go/configstore" + "github.com/cruxstack/github-app-setup-go/ssmresolver" + "github.com/cruxstack/github-ops-app/internal/domain" ) -// Config holds all application configuration loaded from environment -// variables. -type Config struct { - // General - DebugEnabled bool - BasePath string - AdminToken string - - // GitHub App - GitHubOrg string - GitHubAppID int64 - GitHubAppPrivateKey []byte - GitHubInstallationID int64 - GitHubWebhookSecret string - GitHubBaseURL string - - // PR Compliance - PRComplianceEnabled bool - PRMonitoredBranches []string - - // Okta - OktaDomain string - OktaClientID string - OktaPrivateKey []byte - OktaPrivateKeyID string - OktaScopes []string - OktaBaseURL string - OktaGitHubUserField string - OktaSyncRules []domain.SyncRule - OktaSyncSafetyThreshold float64 - OktaOrphanedUserNotifications bool - - // Security Alerts - SecurityAlertsEnabled bool - SecurityAlertsMinAgeDays int - SecurityAlertsMinSeverity string - - // Slack - SlackEnabled bool - SlackToken string - SlackChannel string - SlackChannelPRBypass string - SlackChannelOktaSync string - SlackChannelOrphanedUsers string - SlackChannelSecurityAlerts string - SlackPRBypassFooterNote string - SlackAPIURL string -} +// Defaults for unset variables. +const ( + DefaultListenAddr = ":8080" + DefaultGitHubWebURL = "https://github.com" + DefaultOktaGitHubUserField = "githubUsername" + DefaultOktaSyncSafetyThreshold = 0.5 + DefaultSecurityAlertsMinAgeDays = 30 + DefaultSecurityAlertsMinSeverity = domain.SeverityHigh + // MinAdminTokenLength is the shortest admin token accepted. + MinAdminTokenLength = 16 +) +// Default lists for unset variables. var ( - ssmClient *ssm.Client - ssmClientOnce sync.Once - ssmClientErr error + DefaultPRMonitoredBranches = []string{"main", "master"} + DefaultOktaScopes = []string{"okta.groups.read", "okta.users.read"} ) -// getSSMClient initializes and returns a cached SSM client. -// lazy initialization ensures we only create the client when SSM parameters -// are actually needed. -func getSSMClient(ctx context.Context) (*ssm.Client, error) { - ssmClientOnce.Do(func() { - cfg, err := config.LoadDefaultConfig(ctx) - if err != nil { - ssmClientErr = errors.Wrap(err, "failed to load aws config for ssm") - return - } - ssmClient = ssm.NewFromConfig(cfg) - }) - return ssmClient, ssmClientErr +// removed lists variables that are no longer read, with what to use +// instead. Setting one is an error so an old configuration is not silently +// ignored. +var removed = map[string]string{ + "APP_DEBUG_ENABLED": "APP_LOG_LEVEL=debug", + "APP_PORT": "APP_LISTEN_ADDR", + "APP_OKTA_KEY_ID": "APP_OKTA_PRIVATE_KEY_ID", } -// resolveEnvValue resolves an environment variable value. -// if the value starts with "arn:aws:ssm:", fetches the parameter from SSM. -// automatically decrypts SecureString parameters. -func resolveEnvValue(ctx context.Context, key, value string) (string, error) { - if value == "" { - return "", nil - } +// Config is the application's configuration. +type Config struct { + Bootstrap - if !strings.HasPrefix(value, "arn:aws:ssm:") { - return value, nil - } + // AdminToken guards the admin and scheduled endpoints. When empty, + // those endpoints are disabled. + AdminToken string - client, err := getSSMClient(ctx) - if err != nil { - return "", errors.Wrapf(err, "failed to init ssm client for %s", key) + GitHub GitHub + PRCompliance PRCompliance + Okta Okta + SecurityAlerts SecurityAlerts + Slack Slack +} + +// Bootstrap is the configuration needed before the rest can be loaded: how +// to log, serve, and run the GitHub App installer. It doesn't need the +// GitHub App's credentials, so it loads before the app is set up. Like the +// rest, its APP_ values may be SSM ARNs; the installer's own variables are +// read as github-app-setup-go reads them, without SSM resolution. +type Bootstrap struct { + LogLevel slog.Level + Server Server + Installer Installer +} + +// Server configures the HTTP server. +type Server struct { + // ListenAddr is the TCP address to listen on, such as ":8080". + ListenAddr string + // BasePath is the path every route is mounted under: empty, or a path + // with a leading slash and no trailing slash. + BasePath string + // PublicURL is the external URL of the service, including BasePath. It + // is used to build the installer's redirect and webhook URLs; when + // empty, they are derived from each request. + PublicURL string +} + +// Installer configures the GitHub App manifest installer. +type Installer struct { + Enabled bool + // StorageMode is where the installer saves credentials: envfile or + // aws-ssm. + StorageMode string + // StorageDir is the env file path for the envfile mode. + StorageDir string + // GitHubOrg is the organization the app is created in. + GitHubOrg string + // GitHubURL is the GitHub web URL the app is created on. + GitHubURL string +} + +// GitHub configures the GitHub App. +type GitHub struct { + Org string + AppID int64 + // InstallationID is the app's installation on Org. When zero it is + // looked up. + InstallationID int64 + PrivateKey []byte + WebhookSecret string + // BaseURL is the API URL for GitHub Enterprise Server, or empty. + BaseURL string + // WebURL is the web URL that links point to. + WebURL string +} + +// PRCompliance configures PR compliance monitoring. +type PRCompliance struct { + Enabled bool + // MonitoredBranches are branch names or glob patterns, such as + // "release/*". + MonitoredBranches []string +} + +// Okta configures Okta group sync. +type Okta struct { + Enabled bool + Domain string + ClientID string + PrivateKey []byte + PrivateKeyID string + Scopes []string + BaseURL string + GitHubUserField string + SyncRules []domain.SyncRule + SyncSafetyThreshold float64 + OrphanedUserNotifications bool +} + +// SecurityAlerts configures stale security alert reports. +type SecurityAlerts struct { + Enabled bool + MinAgeDays int + MinSeverity string +} + +// Slack configures notifications. +type Slack struct { + Enabled bool + Token string + Channel string + ChannelPRBypass string + ChannelOktaSync string + ChannelOrphanedUsers string + ChannelSecurityAlerts string + PRBypassFooterNote string + APIURL string +} + +// Env is where configuration is read from. +type Env struct { + // Lookup has the signature of os.LookupEnv. + Lookup func(key string) (string, bool) + // ResolveSSM returns the value of the SSM parameter an ARN names. When + // nil, parameters are read from AWS with the default credentials. + ResolveSSM func(ctx context.Context, arn string) (string, error) +} + +// ProcessEnv reads the process environment and resolves SSM ARNs in AWS. +func ProcessEnv() Env { + return Env{Lookup: os.LookupEnv} +} + +// LoadBootstrap reads the bootstrap configuration. +func LoadBootstrap(env Env) (*Bootstrap, error) { + r := newReader(context.Background(), env) + b := r.bootstrap() + if err := r.err(); err != nil { + return nil, err } + return b, nil +} - paramName := strings.TrimPrefix(value, "arn:aws:ssm:") - idx := strings.Index(paramName, ":parameter/") - if idx == -1 { - return "", errors.Newf("invalid ssm parameter arn format for %s: %s", key, value) +// Load reads the full configuration. All problems are reported together. +func Load(ctx context.Context, env Env) (*Config, error) { + r := newReader(ctx, env) + for _, key := range slices.Sorted(maps.Keys(removed)) { + if r.has(key) { + r.fail(fmt.Errorf("%s is no longer supported: use %s", key, removed[key])) + } } - paramName = "/" + paramName[idx+len(":parameter/"):] - input := &ssm.GetParameterInput{ - Name: ¶mName, - WithDecryption: aws.Bool(true), + cfg := &Config{Bootstrap: *r.bootstrap()} + cfg.AdminToken = r.str("APP_ADMIN_TOKEN") + if cfg.AdminToken != "" && len(cfg.AdminToken) < MinAdminTokenLength { + r.fail(fmt.Errorf("APP_ADMIN_TOKEN must be at least %d characters", MinAdminTokenLength)) } - result, err := client.GetParameter(ctx, input) - if err != nil { - return "", errors.Wrapf(err, "failed to get ssm parameter '%s' for %s", paramName, key) + cfg.GitHub = r.github() + cfg.PRCompliance = PRCompliance{ + Enabled: r.bool("APP_PR_COMPLIANCE_ENABLED", false), + MonitoredBranches: r.list("APP_PR_MONITORED_BRANCHES", DefaultPRMonitoredBranches), } + for _, pattern := range cfg.PRCompliance.MonitoredBranches { + if _, err := path.Match(pattern, ""); err != nil { + r.fail(fmt.Errorf("APP_PR_MONITORED_BRANCHES: invalid pattern %q", pattern)) + } + } + cfg.Okta = r.okta() + cfg.SecurityAlerts = r.securityAlerts() + cfg.Slack = r.slack() - if result.Parameter == nil || result.Parameter.Value == nil { - return "", errors.Newf("ssm parameter '%s' for %s returned nil value", paramName, key) + if err := r.err(); err != nil { + return nil, err } + return cfg, nil +} - return *result.Parameter.Value, nil +// reader reads variables and collects every problem it finds. +type reader struct { + ctx context.Context + env Env + errs []error + ssm *ssmresolver.Resolver } -// getEnv retrieves an environment variable and resolves SSM parameters if -// needed. -func getEnv(ctx context.Context, key string) (string, error) { - value := os.Getenv(key) - return resolveEnvValue(ctx, key, value) +func newReader(ctx context.Context, env Env) *reader { + if env.Lookup == nil { + env.Lookup = os.LookupEnv + } + return &reader{ctx: ctx, env: env} } -// NewConfig loads configuration from environment variables. -// returns error if required values are missing or invalid. -// supports SSM parameter references in format: -// arn:aws:ssm:REGION:ACCOUNT:parameter/path/to/param -func NewConfig() (*Config, error) { - return NewConfigWithContext(context.Background()) +func (r *reader) fail(err error) { + r.errs = append(r.errs, err) } -// NewConfigWithContext loads configuration from environment variables with -// the given context. supports SSM parameter resolution with automatic -// decryption. -func NewConfigWithContext(ctx context.Context) (*Config, error) { - debugEnabled, _ := strconv.ParseBool(os.Getenv("APP_DEBUG_ENABLED")) +func (r *reader) err() error { + if len(r.errs) == 0 { + return nil + } + return fmt.Errorf("invalid configuration: %w", errors.Join(r.errs...)) +} - oktaGitHubUserField := os.Getenv("APP_OKTA_GITHUB_USER_FIELD") - if oktaGitHubUserField == "" { - oktaGitHubUserField = "githubUsername" +func (r *reader) has(key string) bool { + _, ok := r.env.Lookup(key) + return ok +} + +// raw returns a variable's value, resolving an SSM ARN, without trimming. +func (r *reader) raw(key string) string { + v, _ := r.env.Lookup(key) + if !ssmresolver.IsSSMARN(strings.TrimSpace(v)) { + return v + } + resolved, err := r.resolveSSM(strings.TrimSpace(v)) + if err != nil { + r.fail(fmt.Errorf("%s: %w", key, err)) + return "" } + return resolved +} - oktaSyncSafetyThreshold := 0.5 - if thresholdStr := os.Getenv("APP_OKTA_SYNC_SAFETY_THRESHOLD"); thresholdStr != "" { - if threshold, err := strconv.ParseFloat(thresholdStr, 64); err == nil && threshold >= 0 && threshold <= 1 { - oktaSyncSafetyThreshold = threshold +func (r *reader) resolveSSM(arn string) (string, error) { + if r.env.ResolveSSM != nil { + return r.env.ResolveSSM(r.ctx, arn) + } + if r.ssm == nil { + resolver, err := ssmresolver.New(r.ctx) + if err != nil { + return "", err } + r.ssm = resolver } + return r.ssm.ResolveValue(r.ctx, arn) +} - githubWebhookSecret, err := getEnv(ctx, "APP_GITHUB_WEBHOOK_SECRET") - if err != nil { - return nil, err - } +// plain returns a variable's value without SSM resolution, trimmed. +func (r *reader) plain(key string) string { + v, _ := r.env.Lookup(key) + return strings.TrimSpace(v) +} - slackToken, err := getEnv(ctx, "APP_SLACK_TOKEN") +func (r *reader) str(key string) string { + return strings.TrimSpace(r.raw(key)) +} + +func (r *reader) bool(key string, def bool) bool { + v := r.str(key) + if v == "" { + return def + } + b, err := strconv.ParseBool(v) if err != nil { - return nil, err + r.fail(fmt.Errorf("%s: %q is not a boolean", key, v)) + return def } + return b +} - adminToken, err := getEnv(ctx, "APP_ADMIN_TOKEN") - if err != nil { - return nil, err +func (r *reader) int64(key string) int64 { + v := r.str(key) + if v == "" { + return 0 + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n <= 0 { + r.fail(fmt.Errorf("%s: %q is not a positive integer", key, v)) + return 0 } + return n +} - cfg := Config{ - DebugEnabled: debugEnabled, - AdminToken: adminToken, - GitHubOrg: os.Getenv("APP_GITHUB_ORG"), - GitHubWebhookSecret: githubWebhookSecret, - GitHubBaseURL: os.Getenv("APP_GITHUB_BASE_URL"), - OktaDomain: os.Getenv("APP_OKTA_DOMAIN"), - OktaClientID: os.Getenv("APP_OKTA_CLIENT_ID"), - OktaBaseURL: os.Getenv("APP_OKTA_BASE_URL"), - OktaGitHubUserField: oktaGitHubUserField, - OktaSyncSafetyThreshold: oktaSyncSafetyThreshold, - SlackToken: slackToken, - SlackChannel: os.Getenv("APP_SLACK_CHANNEL"), - SlackChannelPRBypass: os.Getenv("APP_SLACK_CHANNEL_PR_BYPASS"), - SlackChannelOktaSync: os.Getenv("APP_SLACK_CHANNEL_OKTA_SYNC"), - SlackChannelOrphanedUsers: os.Getenv("APP_SLACK_CHANNEL_ORPHANED_USERS"), - SlackChannelSecurityAlerts: os.Getenv("APP_SLACK_CHANNEL_SECURITY_ALERTS"), - SlackPRBypassFooterNote: os.Getenv("APP_SLACK_FOOTER_NOTE_PR_BYPASS"), - SlackAPIURL: os.Getenv("APP_SLACK_API_URL"), - } - - if appIDStr := os.Getenv("APP_GITHUB_APP_ID"); appIDStr != "" { - appID, err := strconv.ParseInt(appIDStr, 10, 64) - if err != nil { - return nil, errors.Wrapf(err, "failed to parse APP_GITHUB_APP_ID '%s'", appIDStr) +func (r *reader) list(key string, def []string) []string { + v := r.str(key) + if v == "" { + return slices.Clone(def) + } + var items []string + for item := range strings.SplitSeq(v, ",") { + if item = strings.TrimSpace(item); item != "" { + items = append(items, item) } - cfg.GitHubAppID = appID } + if len(items) == 0 { + r.fail(fmt.Errorf("%s: list is empty", key)) + } + return items +} - if privateKeyPath := os.Getenv("APP_GITHUB_APP_PRIVATE_KEY_PATH"); privateKeyPath != "" { - privateKey, err := os.ReadFile(privateKeyPath) - if err != nil { - return nil, errors.Wrapf(err, "failed to read private key from %s", privateKeyPath) - } - cfg.GitHubAppPrivateKey = privateKey - } else if privateKeyEnv, err := getEnv(ctx, "APP_GITHUB_APP_PRIVATE_KEY"); err != nil { - return nil, err - } else if privateKeyEnv != "" { - cfg.GitHubAppPrivateKey = []byte(privateKeyEnv) +func (r *reader) url(key string) string { + v := r.str(key) + if v == "" { + return "" + } + u, err := url.Parse(v) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.RawQuery != "" || u.Fragment != "" { + r.fail(fmt.Errorf("%s: %q is not an http(s) url", key, v)) + return "" } + return strings.TrimRight(v, "/") +} - if installIDStr := os.Getenv("APP_GITHUB_INSTALLATION_ID"); installIDStr != "" { - installID, err := strconv.ParseInt(installIDStr, 10, 64) +// pem returns a PEM value from key, or from the file named by key_PATH. +// Setting both is an error. +func (r *reader) pem(key string) []byte { + pathKey := key + "_PATH" + value, file := r.raw(key), r.plain(pathKey) + switch { + case strings.TrimSpace(value) != "" && file != "": + r.fail(fmt.Errorf("%s and %s are mutually exclusive", key, pathKey)) + return nil + case file != "": + b, err := os.ReadFile(file) if err != nil { - return nil, errors.Wrapf(err, "failed to parse APP_GITHUB_INSTALLATION_ID '%s'", installIDStr) + r.fail(fmt.Errorf("%s: %w", pathKey, err)) + return nil } - cfg.GitHubInstallationID = installID + return b + case strings.TrimSpace(value) != "": + // the installer's env file and many secret stores keep a pem on one + // line with escaped newlines + return []byte(strings.ReplaceAll(strings.TrimSpace(value), `\n`, "\n")) + default: + return nil } +} - if privateKeyPath := os.Getenv("APP_OKTA_PRIVATE_KEY_PATH"); privateKeyPath != "" { - privateKey, err := os.ReadFile(privateKeyPath) - if err != nil { - return nil, errors.Wrapf(err, "failed to read okta private key from %s", privateKeyPath) +var ( + basePathPattern = regexp.MustCompile(`^(/[A-Za-z0-9._~-]+)+$`) + // githubOrgPattern matches organization logins. + githubOrgPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$`) + hostnamePattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$`) +) + +func (r *reader) bootstrap() *Bootstrap { + b := &Bootstrap{LogLevel: slog.LevelInfo} + + if level := r.str("APP_LOG_LEVEL"); level != "" { + if err := b.LogLevel.UnmarshalText([]byte(level)); err != nil { + r.fail(fmt.Errorf("APP_LOG_LEVEL: %q is not one of debug, info, warn, error", level)) } - cfg.OktaPrivateKey = privateKey - } else if privateKeyEnv, err := getEnv(ctx, "APP_OKTA_PRIVATE_KEY"); err != nil { - return nil, err - } else if privateKeyEnv != "" { - cfg.OktaPrivateKey = []byte(privateKeyEnv) } - cfg.OktaPrivateKeyID = os.Getenv("APP_OKTA_PRIVATE_KEY_ID") + b.Server.ListenAddr = r.str("APP_LISTEN_ADDR") + if b.Server.ListenAddr == "" { + b.Server.ListenAddr = DefaultListenAddr + } + if _, port, err := net.SplitHostPort(b.Server.ListenAddr); err != nil || port == "" { + r.fail(fmt.Errorf("APP_LISTEN_ADDR: %q is not a host:port address", b.Server.ListenAddr)) + } - if scopesStr := os.Getenv("APP_OKTA_SCOPES"); scopesStr != "" { - scopes := strings.Split(scopesStr, ",") - for i := range scopes { - scopes[i] = strings.TrimSpace(scopes[i]) + basePath := strings.TrimRight(r.str("APP_BASE_PATH"), "/") + if basePath != "" { + if !strings.HasPrefix(basePath, "/") { + basePath = "/" + basePath + } + if !basePathPattern.MatchString(basePath) { + r.fail(fmt.Errorf("APP_BASE_PATH: %q is not a valid path", basePath)) } - cfg.OktaScopes = scopes - } else { - cfg.OktaScopes = []string{"okta.groups.read", "okta.users.read"} } + b.Server.BasePath = basePath - prComplianceEnabled, _ := strconv.ParseBool(os.Getenv("APP_PR_COMPLIANCE_ENABLED")) - cfg.PRComplianceEnabled = prComplianceEnabled - - monitoredBranchesStr := os.Getenv("APP_PR_MONITORED_BRANCHES") - if monitoredBranchesStr != "" { - branches := strings.Split(monitoredBranchesStr, ",") - for i := range branches { - branches[i] = strings.TrimSpace(branches[i]) + if v := r.str("APP_PUBLIC_URL"); v != "" { + u, err := url.Parse(v) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" { + r.fail(fmt.Errorf("APP_PUBLIC_URL: %q is not an http(s) url", v)) } - cfg.PRMonitoredBranches = branches - } else { - cfg.PRMonitoredBranches = []string{"main", "master"} + b.Server.PublicURL = strings.TrimRight(v, "/") } - syncRulesJSON := os.Getenv("APP_OKTA_SYNC_RULES") - if syncRulesJSON != "" { - var rules []domain.SyncRule - if err := json.Unmarshal([]byte(syncRulesJSON), &rules); err != nil { - return nil, errors.Wrap(err, "failed to parse APP_OKTA_SYNC_RULES") + // the installer's settings use the github-app-setup-go names + switch strings.ToLower(r.plain(configstore.EnvGitHubAppInstallerEnabled)) { + case "true", "1", "yes": + b.Installer.Enabled = true + } + b.Installer.StorageMode = r.plain(configstore.EnvStorageMode) + if b.Installer.StorageMode == "" { + b.Installer.StorageMode = configstore.StorageModeEnvFile + } + b.Installer.StorageDir = r.plain(configstore.EnvStorageDir) + if b.Installer.StorageDir == "" { + b.Installer.StorageDir = "./.env" + } + b.Installer.GitHubOrg = r.str("APP_GITHUB_ORG") + b.Installer.GitHubURL = DefaultGitHubWebURL + if api := r.str("APP_GITHUB_BASE_URL"); api != "" { + b.Installer.GitHubURL = webURLFromAPI(api) + } + if b.Installer.Enabled { + if b.Installer.GitHubOrg == "" { + r.fail(errors.New("APP_GITHUB_ORG is required when the installer is enabled")) + } + switch b.Installer.StorageMode { + case configstore.StorageModeEnvFile: + if r.has("AWS_LAMBDA_FUNCTION_NAME") { + r.fail(fmt.Errorf("%s=%s does not persist in lambda: use %s", configstore.EnvStorageMode, configstore.StorageModeEnvFile, configstore.StorageModeAWSSSM)) + } + case configstore.StorageModeAWSSSM: + if r.plain(configstore.EnvAWSSSMParameterPfx) == "" { + r.fail(fmt.Errorf("%s is required when %s is %s", configstore.EnvAWSSSMParameterPfx, configstore.EnvStorageMode, configstore.StorageModeAWSSSM)) + } + default: + r.fail(fmt.Errorf("%s: %q is not supported: use %s or %s", configstore.EnvStorageMode, + b.Installer.StorageMode, configstore.StorageModeEnvFile, configstore.StorageModeAWSSSM)) } - cfg.OktaSyncRules = rules } - cfg.SlackEnabled = cfg.SlackToken != "" && cfg.SlackChannel != "" + return b +} - basePath := os.Getenv("APP_BASE_PATH") - if basePath != "" { - basePath = "/" + strings.Trim(basePath, "/") +func (r *reader) github() GitHub { + g := GitHub{ + Org: r.str("APP_GITHUB_ORG"), + AppID: r.int64("APP_GITHUB_APP_ID"), + InstallationID: r.int64("APP_GITHUB_INSTALLATION_ID"), + PrivateKey: r.pem("APP_GITHUB_APP_PRIVATE_KEY"), + WebhookSecret: r.raw("APP_GITHUB_WEBHOOK_SECRET"), + BaseURL: r.url("APP_GITHUB_BASE_URL"), + WebURL: DefaultGitHubWebURL, } - cfg.BasePath = basePath - orphanedUserNotifications, _ := strconv.ParseBool(os.Getenv("APP_OKTA_ORPHANED_USER_NOTIFICATIONS")) - if os.Getenv("APP_OKTA_ORPHANED_USER_NOTIFICATIONS") == "" { - orphanedUserNotifications = cfg.IsOktaSyncEnabled() + switch { + case g.Org == "": + r.fail(errors.New("APP_GITHUB_ORG is required")) + case !githubOrgPattern.MatchString(g.Org): + r.fail(fmt.Errorf("APP_GITHUB_ORG: %q is not a valid organization name", g.Org)) } - cfg.OktaOrphanedUserNotifications = orphanedUserNotifications - - securityAlertsEnabled, _ := strconv.ParseBool( - os.Getenv("APP_SECURITY_ALERTS_ENABLED"), - ) - cfg.SecurityAlertsEnabled = securityAlertsEnabled - - cfg.SecurityAlertsMinAgeDays = 30 - if ageDaysStr := os.Getenv("APP_SECURITY_ALERTS_MIN_AGE_DAYS"); ageDaysStr != "" { - if ageDays, err := strconv.Atoi(ageDaysStr); err == nil && ageDays > 0 { - cfg.SecurityAlertsMinAgeDays = ageDays - } + if r.plain("APP_GITHUB_APP_ID") == "" { + r.fail(errors.New("APP_GITHUB_APP_ID is required")) + } + if len(g.PrivateKey) == 0 && r.plain("APP_GITHUB_APP_PRIVATE_KEY_PATH") == "" { + r.fail(errors.New("APP_GITHUB_APP_PRIVATE_KEY or APP_GITHUB_APP_PRIVATE_KEY_PATH is required")) + } + if g.WebhookSecret == "" { + r.fail(errors.New("APP_GITHUB_WEBHOOK_SECRET is required")) } - cfg.SecurityAlertsMinSeverity = "high" - if sev := os.Getenv("APP_SECURITY_ALERTS_MIN_SEVERITY"); sev != "" { - sev = strings.ToLower(strings.TrimSpace(sev)) - switch sev { - case "critical", "high", "medium", "low": - cfg.SecurityAlertsMinSeverity = sev - } + if g.BaseURL != "" { + g.WebURL = webURLFromAPI(g.BaseURL) } + return g +} - return &cfg, nil +// webURLFromAPI derives a GitHub web URL from its API URL: +// https://ghe.example.com/api/v3 becomes https://ghe.example.com, and +// https://api.example.com becomes https://example.com. +func webURLFromAPI(apiURL string) string { + u, err := url.Parse(apiURL) + if err != nil { + return DefaultGitHubWebURL + } + u.Path = strings.TrimSuffix(strings.TrimRight(u.Path, "/"), "/api/v3") + u.Host = strings.TrimPrefix(u.Host, "api.") + return strings.TrimRight(u.String(), "/") } -// NewLogger creates a new structured logger. -// uses JSON format in Lambda, text format elsewhere. -// sets log level to debug when APP_DEBUG_ENABLED is true. -func NewLogger() *slog.Logger { - var handler slog.Handler +func (r *reader) okta() Okta { + o := Okta{ + Domain: r.str("APP_OKTA_DOMAIN"), + ClientID: r.str("APP_OKTA_CLIENT_ID"), + PrivateKey: r.pem("APP_OKTA_PRIVATE_KEY"), + PrivateKeyID: r.str("APP_OKTA_PRIVATE_KEY_ID"), + Scopes: r.list("APP_OKTA_SCOPES", DefaultOktaScopes), + BaseURL: r.url("APP_OKTA_BASE_URL"), + GitHubUserField: r.str("APP_OKTA_GITHUB_USER_FIELD"), + } + rulesJSON := r.str("APP_OKTA_SYNC_RULES") - debugEnabled, _ := strconv.ParseBool(os.Getenv("APP_DEBUG_ENABLED")) + o.Enabled = o.Domain != "" || o.ClientID != "" || len(o.PrivateKey) > 0 || + r.plain("APP_OKTA_PRIVATE_KEY_PATH") != "" || rulesJSON != "" + if !o.Enabled { + return o + } - level := slog.LevelInfo - if debugEnabled { - level = slog.LevelDebug + if o.Domain == "" { + r.fail(errors.New("APP_OKTA_DOMAIN is required when okta sync is configured")) + } else if !hostnamePattern.MatchString(o.Domain) { + r.fail(fmt.Errorf("APP_OKTA_DOMAIN: %q must be a host name, such as example.okta.com", o.Domain)) + } + if o.ClientID == "" { + r.fail(errors.New("APP_OKTA_CLIENT_ID is required when okta sync is configured")) + } + if len(o.PrivateKey) == 0 && r.plain("APP_OKTA_PRIVATE_KEY_PATH") == "" { + r.fail(errors.New("APP_OKTA_PRIVATE_KEY or APP_OKTA_PRIVATE_KEY_PATH is required when okta sync is configured")) + } + if o.GitHubUserField == "" { + o.GitHubUserField = DefaultOktaGitHubUserField } - if os.Getenv("AWS_LAMBDA_FUNCTION_NAME") != "" { - handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ - Level: level, - }) + if rulesJSON == "" { + r.fail(errors.New("APP_OKTA_SYNC_RULES is required when okta sync is configured")) } else { - handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ - Level: level, - }) + dec := json.NewDecoder(bytes.NewReader([]byte(rulesJSON))) + dec.DisallowUnknownFields() + if err := dec.Decode(&o.SyncRules); err != nil { + r.fail(fmt.Errorf("APP_OKTA_SYNC_RULES: %w", err)) + } else if len(o.SyncRules) == 0 { + r.fail(errors.New("APP_OKTA_SYNC_RULES: no rules")) + } + for i, rule := range o.SyncRules { + if err := rule.Validate(); err != nil { + r.fail(fmt.Errorf("APP_OKTA_SYNC_RULES: rule %d (%s): %w", i, rule.GetName(), err)) + } + } } - return slog.New(handler) -} - -// IsOktaSyncEnabled returns true if Okta sync is fully configured. -func (c *Config) IsOktaSyncEnabled() bool { - return c.OktaDomain != "" && c.OktaClientID != "" && len(c.OktaPrivateKey) > 0 && len(c.OktaSyncRules) > 0 -} - -// IsPRComplianceEnabled returns true if PR compliance checking is enabled. -func (c *Config) IsPRComplianceEnabled() bool { - return c.PRComplianceEnabled && c.IsGitHubConfigured() + o.SyncSafetyThreshold = DefaultOktaSyncSafetyThreshold + if v := r.str("APP_OKTA_SYNC_SAFETY_THRESHOLD"); v != "" { + f, err := strconv.ParseFloat(v, 64) + if err != nil || f < 0 || f > 1 { + r.fail(fmt.Errorf("APP_OKTA_SYNC_SAFETY_THRESHOLD: %q is not a number from 0 to 1", v)) + } else { + o.SyncSafetyThreshold = f + } + } + o.OrphanedUserNotifications = r.bool("APP_OKTA_ORPHANED_USER_NOTIFICATIONS", true) + return o } -// IsGitHubConfigured returns true if GitHub App credentials are configured. -func (c *Config) IsGitHubConfigured() bool { - return c.GitHubOrg != "" && - c.GitHubAppID != 0 && - len(c.GitHubAppPrivateKey) > 0 && - c.GitHubInstallationID != 0 +func (r *reader) securityAlerts() SecurityAlerts { + s := SecurityAlerts{ + Enabled: r.bool("APP_SECURITY_ALERTS_ENABLED", false), + MinAgeDays: DefaultSecurityAlertsMinAgeDays, + MinSeverity: DefaultSecurityAlertsMinSeverity, + } + if v := r.str("APP_SECURITY_ALERTS_MIN_AGE_DAYS"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + r.fail(fmt.Errorf("APP_SECURITY_ALERTS_MIN_AGE_DAYS: %q is not a non-negative integer", v)) + } else { + s.MinAgeDays = n + } + } + if v := strings.ToLower(r.str("APP_SECURITY_ALERTS_MIN_SEVERITY")); v != "" { + if !domain.IsSeverity(v) { + r.fail(fmt.Errorf("APP_SECURITY_ALERTS_MIN_SEVERITY: %q is not one of critical, high, medium, low", v)) + } else { + s.MinSeverity = v + } + } + return s } -// IsSecurityAlertsEnabled returns true if security alerts monitoring is -// enabled and GitHub is configured. -func (c *Config) IsSecurityAlertsEnabled() bool { - return c.SecurityAlertsEnabled && c.IsGitHubConfigured() +func (r *reader) slack() Slack { + s := Slack{ + Token: r.raw("APP_SLACK_TOKEN"), + Channel: r.str("APP_SLACK_CHANNEL"), + ChannelPRBypass: r.str("APP_SLACK_CHANNEL_PR_BYPASS"), + ChannelOktaSync: r.str("APP_SLACK_CHANNEL_OKTA_SYNC"), + ChannelOrphanedUsers: r.str("APP_SLACK_CHANNEL_ORPHANED_USERS"), + ChannelSecurityAlerts: r.str("APP_SLACK_CHANNEL_SECURITY_ALERTS"), + PRBypassFooterNote: r.str("APP_SLACK_FOOTER_NOTE_PR_BYPASS"), + APIURL: r.url("APP_SLACK_API_URL"), + } + s.Token = strings.TrimSpace(s.Token) + s.Enabled = s.Token != "" || s.Channel != "" + if s.Enabled { + if s.Token == "" { + r.fail(errors.New("APP_SLACK_TOKEN is required when APP_SLACK_CHANNEL is set")) + } + if s.Channel == "" { + r.fail(errors.New("APP_SLACK_CHANNEL is required when APP_SLACK_TOKEN is set")) + } + } + if s.APIURL != "" { + // the slack client appends method names to this url + s.APIURL += "/" + } + return s } -// ShouldMonitorBranch returns true if the given branch should be monitored -// for PR compliance. +// ShouldMonitorBranch returns true if merges into branch are checked for +// compliance. func (c *Config) ShouldMonitorBranch(branch string) bool { - if !c.IsPRComplianceEnabled() { + if !c.PRCompliance.Enabled { return false } branch = strings.TrimPrefix(branch, "refs/heads/") - for _, monitored := range c.PRMonitoredBranches { - if branch == monitored { + for _, pattern := range c.PRCompliance.MonitoredBranches { + if ok, _ := path.Match(pattern, branch); ok { return true } } return false } -// RedactedConfig contains configuration with sensitive values redacted. -// safe for logging and API responses. +// RedactedConfig is the configuration with secrets redacted, safe to log or +// return from the admin API. type RedactedConfig struct { - // General - DebugEnabled bool `json:"debug_enabled"` - BasePath string `json:"base_path"` - AdminToken string `json:"admin_token"` + LogLevel string `json:"log_level"` + BasePath string `json:"base_path"` + PublicURL string `json:"public_url"` + InstallerEnabled bool `json:"installer_enabled"` + AdminToken string `json:"admin_token"` - // GitHub App GitHubOrg string `json:"github_org"` GitHubAppID int64 `json:"github_app_id"` - GitHubAppPrivateKey string `json:"github_app_private_key"` GitHubInstallationID int64 `json:"github_installation_id"` + GitHubAppPrivateKey string `json:"github_app_private_key"` GitHubWebhookSecret string `json:"github_webhook_secret"` GitHubBaseURL string `json:"github_base_url"` - // PR Compliance PRComplianceEnabled bool `json:"pr_compliance_enabled"` PRMonitoredBranches []string `json:"pr_monitored_branches"` - // Okta + OktaEnabled bool `json:"okta_enabled"` OktaDomain string `json:"okta_domain"` OktaClientID string `json:"okta_client_id"` OktaPrivateKey string `json:"okta_private_key"` OktaPrivateKeyID string `json:"okta_private_key_id"` OktaScopes []string `json:"okta_scopes"` - OktaBaseURL string `json:"okta_base_url"` OktaGitHubUserField string `json:"okta_github_user_field"` OktaSyncRules []domain.SyncRule `json:"okta_sync_rules"` OktaSyncSafetyThreshold float64 `json:"okta_sync_safety_threshold"` OktaOrphanedUserNotifications bool `json:"okta_orphaned_user_notifications"` - // Security Alerts SecurityAlertsEnabled bool `json:"security_alerts_enabled"` SecurityAlertsMinAgeDays int `json:"security_alerts_min_age_days"` SecurityAlertsMinSeverity string `json:"security_alerts_min_severity"` - // Slack SlackEnabled bool `json:"slack_enabled"` SlackToken string `json:"slack_token"` SlackChannel string `json:"slack_channel"` @@ -421,70 +677,57 @@ type RedactedConfig struct { SlackChannelOktaSync string `json:"slack_channel_okta_sync"` SlackChannelOrphanedUsers string `json:"slack_channel_orphaned_users"` SlackChannelSecurityAlerts string `json:"slack_channel_security_alerts"` - SlackPRBypassFooterNote string `json:"slack_pr_bypass_footer_note"` - SlackAPIURL string `json:"slack_api_url"` } -// Redacted returns a copy of the config with secrets redacted. -func (c *Config) Redacted() RedactedConfig { - redact := func(s string) string { - if s == "" { - return "" - } - return "***REDACTED***" - } +const redactedValue = "***REDACTED***" - redactBytes := func(b []byte) string { - if len(b) == 0 { - return "" - } - return "***REDACTED***" +func redact[T string | []byte](v T) string { + if len(v) == 0 { + return "" } + return redactedValue +} +// Redacted returns the configuration with secrets redacted. +func (c *Config) Redacted() RedactedConfig { return RedactedConfig{ - // General - DebugEnabled: c.DebugEnabled, - BasePath: c.BasePath, - AdminToken: redact(c.AdminToken), - - // GitHub App - GitHubOrg: c.GitHubOrg, - GitHubAppID: c.GitHubAppID, - GitHubAppPrivateKey: redactBytes(c.GitHubAppPrivateKey), - GitHubInstallationID: c.GitHubInstallationID, - GitHubWebhookSecret: redact(c.GitHubWebhookSecret), - GitHubBaseURL: c.GitHubBaseURL, - - // PR Compliance - PRComplianceEnabled: c.PRComplianceEnabled, - PRMonitoredBranches: c.PRMonitoredBranches, - - // Okta - OktaDomain: c.OktaDomain, - OktaClientID: redact(c.OktaClientID), - OktaPrivateKey: redactBytes(c.OktaPrivateKey), - OktaPrivateKeyID: c.OktaPrivateKeyID, - OktaScopes: c.OktaScopes, - OktaBaseURL: c.OktaBaseURL, - OktaGitHubUserField: c.OktaGitHubUserField, - OktaSyncRules: c.OktaSyncRules, - OktaSyncSafetyThreshold: c.OktaSyncSafetyThreshold, - OktaOrphanedUserNotifications: c.OktaOrphanedUserNotifications, - - // Security Alerts - SecurityAlertsEnabled: c.SecurityAlertsEnabled, - SecurityAlertsMinAgeDays: c.SecurityAlertsMinAgeDays, - SecurityAlertsMinSeverity: c.SecurityAlertsMinSeverity, - - // Slack - SlackEnabled: c.SlackEnabled, - SlackToken: redact(c.SlackToken), - SlackChannel: c.SlackChannel, - SlackChannelPRBypass: c.SlackChannelPRBypass, - SlackChannelOktaSync: c.SlackChannelOktaSync, - SlackChannelOrphanedUsers: c.SlackChannelOrphanedUsers, - SlackChannelSecurityAlerts: c.SlackChannelSecurityAlerts, - SlackPRBypassFooterNote: c.SlackPRBypassFooterNote, - SlackAPIURL: c.SlackAPIURL, + LogLevel: c.LogLevel.String(), + BasePath: c.Server.BasePath, + PublicURL: c.Server.PublicURL, + InstallerEnabled: c.Installer.Enabled, + AdminToken: redact(c.AdminToken), + + GitHubOrg: c.GitHub.Org, + GitHubAppID: c.GitHub.AppID, + GitHubInstallationID: c.GitHub.InstallationID, + GitHubAppPrivateKey: redact(c.GitHub.PrivateKey), + GitHubWebhookSecret: redact(c.GitHub.WebhookSecret), + GitHubBaseURL: c.GitHub.BaseURL, + + PRComplianceEnabled: c.PRCompliance.Enabled, + PRMonitoredBranches: c.PRCompliance.MonitoredBranches, + + OktaEnabled: c.Okta.Enabled, + OktaDomain: c.Okta.Domain, + OktaClientID: c.Okta.ClientID, + OktaPrivateKey: redact(c.Okta.PrivateKey), + OktaPrivateKeyID: c.Okta.PrivateKeyID, + OktaScopes: c.Okta.Scopes, + OktaGitHubUserField: c.Okta.GitHubUserField, + OktaSyncRules: c.Okta.SyncRules, + OktaSyncSafetyThreshold: c.Okta.SyncSafetyThreshold, + OktaOrphanedUserNotifications: c.Okta.OrphanedUserNotifications, + + SecurityAlertsEnabled: c.SecurityAlerts.Enabled, + SecurityAlertsMinAgeDays: c.SecurityAlerts.MinAgeDays, + SecurityAlertsMinSeverity: c.SecurityAlerts.MinSeverity, + + SlackEnabled: c.Slack.Enabled, + SlackToken: redact(c.Slack.Token), + SlackChannel: c.Slack.Channel, + SlackChannelPRBypass: c.Slack.ChannelPRBypass, + SlackChannelOktaSync: c.Slack.ChannelOktaSync, + SlackChannelOrphanedUsers: c.Slack.ChannelOrphanedUsers, + SlackChannelSecurityAlerts: c.Slack.ChannelSecurityAlerts, } } diff --git a/internal/config/config_helpers_test.go b/internal/config/config_helpers_test.go deleted file mode 100644 index 033cf95..0000000 --- a/internal/config/config_helpers_test.go +++ /dev/null @@ -1,330 +0,0 @@ -package config - -import ( - "testing" -) - -func TestIsGitHubConfigured(t *testing.T) { - tests := []struct { - name string - cfg Config - want bool - }{ - { - name: "fully configured", - cfg: Config{ - GitHubOrg: "org", GitHubAppID: 1, - GitHubAppPrivateKey: []byte("key"), GitHubInstallationID: 1, - }, - want: true, - }, - {name: "empty", cfg: Config{}, want: false}, - { - name: "missing org", - cfg: Config{GitHubAppID: 1, GitHubAppPrivateKey: []byte("k"), GitHubInstallationID: 1}, - want: false, - }, - { - name: "missing private key", - cfg: Config{GitHubOrg: "org", GitHubAppID: 1, GitHubInstallationID: 1}, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.cfg.IsGitHubConfigured(); got != tt.want { - t.Errorf("IsGitHubConfigured() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestIsOktaSyncEnabled(t *testing.T) { - tests := []struct { - name string - cfg Config - want bool - }{ - {name: "empty", cfg: Config{}, want: false}, - { - name: "missing rules", - cfg: Config{OktaDomain: "d", OktaClientID: "c", OktaPrivateKey: []byte("k")}, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.cfg.IsOktaSyncEnabled(); got != tt.want { - t.Errorf("IsOktaSyncEnabled() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestIsPRComplianceEnabled(t *testing.T) { - tests := []struct { - name string - cfg Config - want bool - }{ - {name: "disabled flag", cfg: Config{PRComplianceEnabled: false}, want: false}, - { - name: "enabled but no github", - cfg: Config{PRComplianceEnabled: true}, - want: false, - }, - { - name: "enabled with github", - cfg: Config{ - PRComplianceEnabled: true, - GitHubOrg: "org", GitHubAppID: 1, - GitHubAppPrivateKey: []byte("k"), GitHubInstallationID: 1, - }, - want: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.cfg.IsPRComplianceEnabled(); got != tt.want { - t.Errorf("IsPRComplianceEnabled() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestShouldMonitorBranch(t *testing.T) { - cfg := Config{ - PRComplianceEnabled: true, - PRMonitoredBranches: []string{"main", "master"}, - GitHubOrg: "org", - GitHubAppID: 1, - GitHubAppPrivateKey: []byte("k"), - GitHubInstallationID: 1, - } - - tests := []struct { - branch string - want bool - }{ - {"main", true}, - {"master", true}, - {"develop", false}, - {"refs/heads/main", true}, - {"", false}, - } - - for _, tt := range tests { - t.Run(tt.branch, func(t *testing.T) { - if got := cfg.ShouldMonitorBranch(tt.branch); got != tt.want { - t.Errorf("ShouldMonitorBranch(%q) = %v, want %v", tt.branch, got, tt.want) - } - }) - } -} - -func TestIsSecurityAlertsEnabled(t *testing.T) { - tests := []struct { - name string - cfg Config - want bool - }{ - {name: "disabled flag", cfg: Config{SecurityAlertsEnabled: false}, want: false}, - { - name: "enabled but no github", - cfg: Config{SecurityAlertsEnabled: true}, - want: false, - }, - { - name: "enabled with github", - cfg: Config{ - SecurityAlertsEnabled: true, - GitHubOrg: "org", GitHubAppID: 1, - GitHubAppPrivateKey: []byte("k"), GitHubInstallationID: 1, - }, - want: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.cfg.IsSecurityAlertsEnabled(); got != tt.want { - t.Errorf("IsSecurityAlertsEnabled() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestRedacted(t *testing.T) { - cfg := Config{ - GitHubOrg: "my-org", - GitHubAppPrivateKey: []byte("secret-key"), - GitHubWebhookSecret: "webhook-secret", - AdminToken: "admin-secret", - SlackToken: "xoxb-token", - OktaClientID: "client-id", - OktaPrivateKey: []byte("okta-key"), - DebugEnabled: true, - } - - redacted := cfg.Redacted() - - // non-secrets should be preserved - if redacted.GitHubOrg != "my-org" { - t.Errorf("expected org to be preserved, got %q", redacted.GitHubOrg) - } - if !redacted.DebugEnabled { - t.Error("expected DebugEnabled to be preserved") - } - - // secrets should be redacted - if redacted.GitHubAppPrivateKey != "***REDACTED***" { - t.Errorf("expected private key to be redacted, got %q", redacted.GitHubAppPrivateKey) - } - if redacted.GitHubWebhookSecret != "***REDACTED***" { - t.Errorf("expected webhook secret to be redacted, got %q", redacted.GitHubWebhookSecret) - } - if redacted.AdminToken != "***REDACTED***" { - t.Errorf("expected admin token to be redacted, got %q", redacted.AdminToken) - } - if redacted.SlackToken != "***REDACTED***" { - t.Errorf("expected slack token to be redacted, got %q", redacted.SlackToken) - } - if redacted.OktaClientID != "***REDACTED***" { - t.Errorf("expected okta client id to be redacted, got %q", redacted.OktaClientID) - } -} - -func TestRedacted_EmptyValues(t *testing.T) { - cfg := Config{} - redacted := cfg.Redacted() - - if redacted.GitHubAppPrivateKey != "" { - t.Error("expected empty private key to remain empty") - } - if redacted.SlackToken != "" { - t.Error("expected empty slack token to remain empty") - } -} - -func TestNewConfigWithContext_Defaults(t *testing.T) { - // clear all env vars that NewConfig reads - envKeys := []string{ - "APP_DEBUG_ENABLED", "APP_GITHUB_ORG", "APP_GITHUB_APP_ID", - "APP_GITHUB_APP_PRIVATE_KEY", "APP_GITHUB_APP_PRIVATE_KEY_PATH", - "APP_GITHUB_INSTALLATION_ID", "APP_GITHUB_WEBHOOK_SECRET", - "APP_GITHUB_BASE_URL", "APP_PR_COMPLIANCE_ENABLED", - "APP_PR_MONITORED_BRANCHES", "APP_OKTA_DOMAIN", "APP_OKTA_CLIENT_ID", - "APP_OKTA_PRIVATE_KEY", "APP_OKTA_PRIVATE_KEY_PATH", - "APP_OKTA_PRIVATE_KEY_ID", "APP_OKTA_SCOPES", - "APP_OKTA_BASE_URL", "APP_OKTA_GITHUB_USER_FIELD", - "APP_OKTA_SYNC_RULES", "APP_OKTA_SYNC_SAFETY_THRESHOLD", - "APP_OKTA_ORPHANED_USER_NOTIFICATIONS", "APP_SLACK_TOKEN", - "APP_SLACK_CHANNEL", "APP_SLACK_API_URL", "APP_BASE_PATH", - "APP_ADMIN_TOKEN", - } - for _, key := range envKeys { - t.Setenv(key, "") - } - - cfg, err := NewConfig() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if cfg.OktaGitHubUserField != "githubUsername" { - t.Errorf("expected default github user field, got %q", cfg.OktaGitHubUserField) - } - if cfg.OktaSyncSafetyThreshold != 0.5 { - t.Errorf("expected default safety threshold 0.5, got %f", cfg.OktaSyncSafetyThreshold) - } - if len(cfg.PRMonitoredBranches) != 2 || cfg.PRMonitoredBranches[0] != "main" { - t.Errorf("expected default monitored branches [main, master], got %v", cfg.PRMonitoredBranches) - } - if len(cfg.OktaScopes) != 2 { - t.Errorf("expected default okta scopes, got %v", cfg.OktaScopes) - } -} - -func TestNewConfigWithContext_ParsesEnvVars(t *testing.T) { - t.Setenv("APP_DEBUG_ENABLED", "true") - t.Setenv("APP_GITHUB_ORG", "test-org") - t.Setenv("APP_GITHUB_APP_ID", "12345") - t.Setenv("APP_GITHUB_APP_PRIVATE_KEY", "test-key-data") - t.Setenv("APP_GITHUB_INSTALLATION_ID", "67890") - t.Setenv("APP_GITHUB_WEBHOOK_SECRET", "whsec") - t.Setenv("APP_PR_COMPLIANCE_ENABLED", "true") - t.Setenv("APP_PR_MONITORED_BRANCHES", "main,release") - t.Setenv("APP_OKTA_SYNC_SAFETY_THRESHOLD", "0.3") - t.Setenv("APP_BASE_PATH", "/api/v1") - t.Setenv("APP_OKTA_SYNC_RULES", `[{"okta_group_name":"Eng","github_team_name":"eng"}]`) - // clear keys that could interfere - t.Setenv("APP_GITHUB_APP_PRIVATE_KEY_PATH", "") - t.Setenv("APP_OKTA_PRIVATE_KEY_PATH", "") - t.Setenv("APP_OKTA_PRIVATE_KEY", "") - t.Setenv("APP_SLACK_TOKEN", "") - t.Setenv("APP_SLACK_CHANNEL", "") - t.Setenv("APP_ADMIN_TOKEN", "") - t.Setenv("APP_OKTA_DOMAIN", "") - t.Setenv("APP_OKTA_CLIENT_ID", "") - - cfg, err := NewConfig() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if !cfg.DebugEnabled { - t.Error("expected debug enabled") - } - if cfg.GitHubOrg != "test-org" { - t.Errorf("expected org test-org, got %q", cfg.GitHubOrg) - } - if cfg.GitHubAppID != 12345 { - t.Errorf("expected app id 12345, got %d", cfg.GitHubAppID) - } - if string(cfg.GitHubAppPrivateKey) != "test-key-data" { - t.Errorf("expected key data, got %q", string(cfg.GitHubAppPrivateKey)) - } - if cfg.GitHubInstallationID != 67890 { - t.Errorf("expected installation 67890, got %d", cfg.GitHubInstallationID) - } - if cfg.OktaSyncSafetyThreshold != 0.3 { - t.Errorf("expected threshold 0.3, got %f", cfg.OktaSyncSafetyThreshold) - } - if cfg.BasePath != "/api/v1" { - t.Errorf("expected base path /api/v1, got %q", cfg.BasePath) - } - if len(cfg.PRMonitoredBranches) != 2 || cfg.PRMonitoredBranches[1] != "release" { - t.Errorf("expected [main, release], got %v", cfg.PRMonitoredBranches) - } - if len(cfg.OktaSyncRules) != 1 { - t.Errorf("expected 1 sync rule, got %d", len(cfg.OktaSyncRules)) - } -} - -func TestNewConfigWithContext_InvalidAppID(t *testing.T) { - t.Setenv("APP_GITHUB_APP_ID", "not-a-number") - t.Setenv("APP_GITHUB_WEBHOOK_SECRET", "") - t.Setenv("APP_SLACK_TOKEN", "") - t.Setenv("APP_ADMIN_TOKEN", "") - - _, err := NewConfig() - if err == nil { - t.Fatal("expected error for invalid app id") - } -} - -func TestNewConfigWithContext_InvalidSyncRulesJSON(t *testing.T) { - t.Setenv("APP_OKTA_SYNC_RULES", "not-json") - t.Setenv("APP_GITHUB_APP_ID", "") - t.Setenv("APP_GITHUB_WEBHOOK_SECRET", "") - t.Setenv("APP_SLACK_TOKEN", "") - t.Setenv("APP_ADMIN_TOKEN", "") - - _, err := NewConfig() - if err == nil { - t.Fatal("expected error for invalid sync rules JSON") - } -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 423efab..a9815ab 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2,149 +2,319 @@ package config import ( "context" + "encoding/json" + "errors" + "log/slog" + "os" + "path/filepath" + "strings" "testing" ) -func TestSecurityAlertsConfigDefaults(t *testing.T) { - t.Setenv("APP_SECURITY_ALERTS_ENABLED", "") - t.Setenv("APP_SECURITY_ALERTS_MIN_AGE_DAYS", "") - t.Setenv("APP_SECURITY_ALERTS_MIN_SEVERITY", "") - t.Setenv("APP_SLACK_CHANNEL_SECURITY_ALERTS", "") - // clear keys that could interfere - t.Setenv("APP_GITHUB_APP_ID", "") - t.Setenv("APP_GITHUB_WEBHOOK_SECRET", "") - t.Setenv("APP_SLACK_TOKEN", "") - t.Setenv("APP_ADMIN_TOKEN", "") - t.Setenv("APP_OKTA_SYNC_RULES", "") - - cfg, err := NewConfig() - if err != nil { - t.Fatalf("unexpected error: %v", err) +const testPEM = "-----BEGIN RSA PRIVATE KEY-----\nMIIB\n-----END RSA PRIVATE KEY-----" + +// baseEnv is the smallest valid configuration. +func baseEnv() map[string]string { + return map[string]string{ + "APP_GITHUB_ORG": "acme", + "APP_GITHUB_APP_ID": "123", + "APP_GITHUB_APP_PRIVATE_KEY": testPEM, + "APP_GITHUB_WEBHOOK_SECRET": "whsec", } +} + +func envOf(m map[string]string) Env { + return Env{Lookup: func(k string) (string, bool) { + v, ok := m[k] + return v, ok + }} +} - if cfg.SecurityAlertsEnabled { - t.Error("expected security alerts disabled by default") +func load(t *testing.T, m map[string]string) (*Config, error) { + t.Helper() + return Load(context.Background(), envOf(m)) +} + +func with(overrides map[string]string) map[string]string { + m := baseEnv() + for k, v := range overrides { + if v == "" { + delete(m, k) + continue + } + m[k] = v } - if cfg.SecurityAlertsMinAgeDays != 30 { - t.Errorf("expected default min age 30, got %d", - cfg.SecurityAlertsMinAgeDays) + return m +} + +func TestLoad_Defaults(t *testing.T) { + cfg, err := load(t, baseEnv()) + if err != nil { + t.Fatal(err) + } + if cfg.LogLevel != slog.LevelInfo || cfg.Server.ListenAddr != ":8080" || cfg.Server.BasePath != "" { + t.Errorf("bootstrap = %+v", cfg.Bootstrap) + } + if cfg.GitHub.WebURL != "https://github.com" || cfg.GitHub.InstallationID != 0 || string(cfg.GitHub.PrivateKey) != testPEM { + t.Errorf("github = %+v", cfg.GitHub) + } + if cfg.PRCompliance.Enabled || strings.Join(cfg.PRCompliance.MonitoredBranches, ",") != "main,master" { + t.Errorf("pr compliance = %+v", cfg.PRCompliance) } - if cfg.SecurityAlertsMinSeverity != "high" { - t.Errorf("expected default severity high, got %q", - cfg.SecurityAlertsMinSeverity) + if cfg.Okta.Enabled || cfg.Slack.Enabled || cfg.SecurityAlerts.Enabled || cfg.Installer.Enabled { + t.Error("optional feature enabled by default") + } + if cfg.SecurityAlerts.MinAgeDays != 30 || cfg.SecurityAlerts.MinSeverity != "high" { + t.Errorf("security alerts = %+v", cfg.SecurityAlerts) } } -func TestSecurityAlertsConfigParsing(t *testing.T) { - t.Setenv("APP_SECURITY_ALERTS_ENABLED", "true") - t.Setenv("APP_SECURITY_ALERTS_MIN_AGE_DAYS", "14") - t.Setenv("APP_SECURITY_ALERTS_MIN_SEVERITY", "critical") - t.Setenv("APP_SLACK_CHANNEL_SECURITY_ALERTS", "C_SEC_ALERTS") - // clear keys that could interfere - t.Setenv("APP_GITHUB_APP_ID", "") - t.Setenv("APP_GITHUB_WEBHOOK_SECRET", "") - t.Setenv("APP_SLACK_TOKEN", "") - t.Setenv("APP_ADMIN_TOKEN", "") - t.Setenv("APP_OKTA_SYNC_RULES", "") - - cfg, err := NewConfig() +func TestLoad_AllFeatures(t *testing.T) { + rules := `[{"name":"eng","okta_group_pattern":"^eng-","github_team_prefix":"eng-","create_team_if_missing":true,"team_privacy":"secret"}]` + cfg, err := load(t, with(map[string]string{ + "APP_LOG_LEVEL": "debug", + "APP_LISTEN_ADDR": "127.0.0.1:9000", + "APP_BASE_PATH": "prod/", + "APP_PUBLIC_URL": "https://ops.example.com/prod/", + "APP_ADMIN_TOKEN": "0123456789abcdef", + "APP_GITHUB_INSTALLATION_ID": "456", + "APP_GITHUB_BASE_URL": "https://ghe.example.com/api/v3/", + "APP_PR_COMPLIANCE_ENABLED": "true", + "APP_PR_MONITORED_BRANCHES": "main, release/* ,", + "APP_OKTA_DOMAIN": "acme.okta.com", + "APP_OKTA_CLIENT_ID": "client", + "APP_OKTA_PRIVATE_KEY": strings.ReplaceAll(testPEM, "\n", `\n`), + "APP_OKTA_SYNC_RULES": rules, + "APP_OKTA_SYNC_SAFETY_THRESHOLD": "0.25", + "APP_OKTA_ORPHANED_USER_NOTIFICATIONS": "false", + "APP_SECURITY_ALERTS_ENABLED": "1", + "APP_SECURITY_ALERTS_MIN_AGE_DAYS": "7", + "APP_SECURITY_ALERTS_MIN_SEVERITY": "CRITICAL", + "APP_SLACK_TOKEN": "xoxb-1", + "APP_SLACK_CHANNEL": "C1", + "APP_SLACK_API_URL": "https://slack.test/api", + })) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatal(err) + } + if cfg.LogLevel != slog.LevelDebug || cfg.Server.BasePath != "/prod" || cfg.Server.PublicURL != "https://ops.example.com/prod" { + t.Errorf("bootstrap = %+v", cfg.Bootstrap) + } + if cfg.GitHub.InstallationID != 456 || cfg.GitHub.WebURL != "https://ghe.example.com" { + t.Errorf("github = %+v", cfg.GitHub) + } + if !cfg.Okta.Enabled || cfg.Okta.SyncSafetyThreshold != 0.25 || cfg.Okta.OrphanedUserNotifications || + cfg.Okta.GitHubUserField != "githubUsername" || string(cfg.Okta.PrivateKey) != testPEM { + t.Errorf("okta = %+v", cfg.Okta) } + if len(cfg.Okta.SyncRules) != 1 || !cfg.Okta.SyncRules[0].CreateTeamIfMissing { + t.Errorf("rules = %+v", cfg.Okta.SyncRules) + } + if cfg.SecurityAlerts.MinAgeDays != 7 || cfg.SecurityAlerts.MinSeverity != "critical" { + t.Errorf("security alerts = %+v", cfg.SecurityAlerts) + } + if !cfg.Slack.Enabled || cfg.Slack.APIURL != "https://slack.test/api/" { + t.Errorf("slack = %+v", cfg.Slack) + } + for branch, want := range map[string]bool{"main": true, "refs/heads/release/1.2": true, "release/1/2": false, "dev": false} { + if got := cfg.ShouldMonitorBranch(branch); got != want { + t.Errorf("ShouldMonitorBranch(%q) = %v, want %v", branch, got, want) + } + } +} - if !cfg.SecurityAlertsEnabled { - t.Error("expected security alerts enabled") +func TestLoad_Errors(t *testing.T) { + okta := map[string]string{ + "APP_OKTA_DOMAIN": "acme.okta.com", + "APP_OKTA_CLIENT_ID": "client", + "APP_OKTA_PRIVATE_KEY": testPEM, + "APP_OKTA_SYNC_RULES": `[{"okta_group_name":"eng"}]`, } - if cfg.SecurityAlertsMinAgeDays != 14 { - t.Errorf("expected min age 14, got %d", - cfg.SecurityAlertsMinAgeDays) + withOkta := func(overrides map[string]string) map[string]string { + m := with(okta) + for k, v := range overrides { + if v == "" { + delete(m, k) + } else { + m[k] = v + } + } + return m } - if cfg.SecurityAlertsMinSeverity != "critical" { - t.Errorf("expected severity critical, got %q", - cfg.SecurityAlertsMinSeverity) + + tests := []struct { + name string + env map[string]string + want string + }{ + {"missing org", with(map[string]string{"APP_GITHUB_ORG": ""}), "APP_GITHUB_ORG is required"}, + {"bad org", with(map[string]string{"APP_GITHUB_ORG": "acme/x"}), "not a valid organization"}, + {"empty app id", with(map[string]string{"APP_GITHUB_APP_ID": ""}), "APP_GITHUB_APP_ID is required"}, + {"bad app id", with(map[string]string{"APP_GITHUB_APP_ID": "abc"}), "not a positive integer"}, + {"missing key", with(map[string]string{"APP_GITHUB_APP_PRIVATE_KEY": ""}), "APP_GITHUB_APP_PRIVATE_KEY or"}, + {"key and path", with(map[string]string{"APP_GITHUB_APP_PRIVATE_KEY_PATH": "/k.pem"}), "mutually exclusive"}, + {"missing webhook secret", with(map[string]string{"APP_GITHUB_WEBHOOK_SECRET": ""}), "APP_GITHUB_WEBHOOK_SECRET is required"}, + {"bad base url", with(map[string]string{"APP_GITHUB_BASE_URL": "ghe.example.com"}), "not an http(s) url"}, + {"short admin token", with(map[string]string{"APP_ADMIN_TOKEN": "short"}), "at least 16"}, + {"bad bool", with(map[string]string{"APP_PR_COMPLIANCE_ENABLED": "yes please"}), "not a boolean"}, + {"bad branch glob", with(map[string]string{"APP_PR_MONITORED_BRANCHES": "release/["}), "invalid pattern"}, + {"bad log level", with(map[string]string{"APP_LOG_LEVEL": "loud"}), "APP_LOG_LEVEL"}, + {"bad listen addr", with(map[string]string{"APP_LISTEN_ADDR": "8080"}), "APP_LISTEN_ADDR"}, + {"bad base path", with(map[string]string{"APP_BASE_PATH": "/a b"}), "APP_BASE_PATH"}, + {"removed debug var", with(map[string]string{"APP_DEBUG_ENABLED": "true"}), "APP_LOG_LEVEL=debug"}, + {"removed port var", with(map[string]string{"APP_PORT": "8080"}), "APP_LISTEN_ADDR"}, + {"removed okta key var", with(map[string]string{"APP_OKTA_KEY_ID": "kid"}), "APP_OKTA_PRIVATE_KEY_ID"}, + {"partial okta", with(map[string]string{"APP_OKTA_DOMAIN": "acme.okta.com"}), "APP_OKTA_CLIENT_ID is required"}, + {"okta domain with scheme", withOkta(map[string]string{"APP_OKTA_DOMAIN": "https://acme.okta.com"}), "must be a host name"}, + {"okta rules missing", withOkta(map[string]string{"APP_OKTA_SYNC_RULES": ""}), "APP_OKTA_SYNC_RULES is required"}, + {"okta rules not json", withOkta(map[string]string{"APP_OKTA_SYNC_RULES": "{"}), "APP_OKTA_SYNC_RULES"}, + {"okta rules empty", withOkta(map[string]string{"APP_OKTA_SYNC_RULES": "[]"}), "no rules"}, + {"okta rule typo", withOkta(map[string]string{"APP_OKTA_SYNC_RULES": `[{"okta_group":"eng"}]`}), "unknown field"}, + {"okta rule invalid", withOkta(map[string]string{"APP_OKTA_SYNC_RULES": `[{"okta_group_pattern":"("}]`}), "rule 0"}, + {"okta threshold", withOkta(map[string]string{"APP_OKTA_SYNC_SAFETY_THRESHOLD": "1.5"}), "from 0 to 1"}, + {"alert age", with(map[string]string{"APP_SECURITY_ALERTS_MIN_AGE_DAYS": "-1"}), "non-negative"}, + {"alert severity", with(map[string]string{"APP_SECURITY_ALERTS_MIN_SEVERITY": "urgent"}), "not one of"}, + {"slack token only", with(map[string]string{"APP_SLACK_TOKEN": "xoxb"}), "APP_SLACK_CHANNEL is required"}, + {"slack channel only", with(map[string]string{"APP_SLACK_CHANNEL": "C1"}), "APP_SLACK_TOKEN is required"}, + {"installer files mode", with(map[string]string{"GITHUB_APP_INSTALLER_ENABLED": "true", "STORAGE_MODE": "files"}), "not supported"}, + {"installer ssm without prefix", with(map[string]string{"GITHUB_APP_INSTALLER_ENABLED": "true", "STORAGE_MODE": "aws-ssm"}), "AWS_SSM_PARAMETER_PREFIX"}, + {"installer envfile in lambda", with(map[string]string{"GITHUB_APP_INSTALLER_ENABLED": "true", "AWS_LAMBDA_FUNCTION_NAME": "fn"}), "does not persist in lambda"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := load(t, tt.env) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %v, want %q", err, tt.want) + } + }) + } +} + +func TestLoad_ReportsEveryProblem(t *testing.T) { + _, err := load(t, map[string]string{"APP_LOG_LEVEL": "loud"}) + if err == nil { + t.Fatal("empty configuration loaded") } - if cfg.SlackChannelSecurityAlerts != "C_SEC_ALERTS" { - t.Errorf("expected channel C_SEC_ALERTS, got %q", - cfg.SlackChannelSecurityAlerts) + for _, want := range []string{"APP_LOG_LEVEL", "APP_GITHUB_ORG", "APP_GITHUB_APP_ID", "APP_GITHUB_WEBHOOK_SECRET"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error lacks %s: %v", want, err) + } } } -func TestSecurityAlertsConfigInvalidSeverity(t *testing.T) { - t.Setenv("APP_SECURITY_ALERTS_MIN_SEVERITY", "INVALID") - t.Setenv("APP_SECURITY_ALERTS_ENABLED", "") - t.Setenv("APP_SECURITY_ALERTS_MIN_AGE_DAYS", "") - t.Setenv("APP_GITHUB_APP_ID", "") - t.Setenv("APP_GITHUB_WEBHOOK_SECRET", "") - t.Setenv("APP_SLACK_TOKEN", "") - t.Setenv("APP_ADMIN_TOKEN", "") - t.Setenv("APP_OKTA_SYNC_RULES", "") - - cfg, err := NewConfig() +func TestLoad_ResolvesSSMParameters(t *testing.T) { + arn := "arn:aws-us-gov:ssm:us-gov-west-1:123456789012:parameter/ops/webhook" + m := with(map[string]string{"APP_GITHUB_WEBHOOK_SECRET": arn, "APP_GITHUB_APP_ID": "arn:aws:ssm:us-east-1:1:parameter/ops/app-id"}) + env := envOf(m) + env.ResolveSSM = func(_ context.Context, got string) (string, error) { + switch got { + case arn: + return "from-ssm", nil + case m["APP_GITHUB_APP_ID"]: + return "999", nil + } + return "", errors.New("unexpected arn " + got) + } + + cfg, err := Load(context.Background(), env) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatal(err) + } + if cfg.GitHub.WebhookSecret != "from-ssm" || cfg.GitHub.AppID != 999 { + t.Errorf("github = %+v", cfg.GitHub) } - if cfg.SecurityAlertsMinSeverity != "high" { - t.Errorf("expected default high for invalid severity, got %q", - cfg.SecurityAlertsMinSeverity) + env.ResolveSSM = func(context.Context, string) (string, error) { return "", errors.New("access denied") } + if _, err := Load(context.Background(), env); err == nil || !strings.Contains(err.Error(), "access denied") { + t.Errorf("error = %v, want the ssm failure", err) } } -func TestResolveEnvValue(t *testing.T) { - ctx := context.Background() +func TestLoad_PrivateKeyPath(t *testing.T) { + file := filepath.Join(t.TempDir(), "key.pem") + if err := os.WriteFile(file, []byte(testPEM), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := load(t, with(map[string]string{"APP_GITHUB_APP_PRIVATE_KEY": "", "APP_GITHUB_APP_PRIVATE_KEY_PATH": file})) + if err != nil { + t.Fatal(err) + } + if string(cfg.GitHub.PrivateKey) != testPEM { + t.Errorf("private key = %q", cfg.GitHub.PrivateKey) + } - tests := []struct { - name string - key string - value string - wantSSM bool - wantError bool - }{ - { - name: "empty value", - key: "TEST_KEY", - value: "", - wantSSM: false, - wantError: false, - }, - { - name: "plain text value", - key: "TEST_KEY", - value: "plain-text-secret", - wantSSM: false, - wantError: false, - }, - { - name: "valid ssm arn", - key: "TEST_KEY", - value: "arn:aws:ssm:us-east-1:123456789012:parameter/test/param", - wantSSM: true, - wantError: true, // will error in test env without AWS creds - }, - { - name: "invalid ssm arn missing parameter prefix", - key: "TEST_KEY", - value: "arn:aws:ssm:us-east-1:123456789012:test/param", - wantSSM: true, - wantError: true, - }, + _, err = load(t, with(map[string]string{"APP_GITHUB_APP_PRIVATE_KEY": "", "APP_GITHUB_APP_PRIVATE_KEY_PATH": file + ".missing"})) + if err == nil { + t.Error("missing key file loaded") } +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := resolveEnvValue(ctx, tt.key, tt.value) +func TestLoadBootstrap_IgnoresAppSettings(t *testing.T) { + env := map[string]string{ + "GITHUB_APP_INSTALLER_ENABLED": "yes", + "STORAGE_DIR": "/data/.env", + "APP_GITHUB_ORG": "acme", + "APP_GITHUB_BASE_URL": "https://ghe.example.com/api/v3", + "APP_GITHUB_APP_ID": "not checked here", + } + b, err := LoadBootstrap(envOf(env)) + if err != nil { + t.Fatal(err) + } + if !b.Installer.Enabled || b.Installer.StorageMode != "envfile" || b.Installer.StorageDir != "/data/.env" || + b.Installer.GitHubOrg != "acme" || b.Installer.GitHubURL != "https://ghe.example.com" { + t.Errorf("installer = %+v", b.Installer) + } - if tt.wantError && err == nil { - t.Errorf("expected error but got none") - } + delete(env, "APP_GITHUB_ORG") + if _, err := LoadBootstrap(envOf(env)); err == nil || !strings.Contains(err.Error(), "APP_GITHUB_ORG") { + t.Errorf("error = %v, want the installer to require the org", err) + } +} - if !tt.wantError && err != nil { - t.Errorf("unexpected error: %v", err) - } +func TestLoadBootstrap_ResolvesSSMParameters(t *testing.T) { + env := envOf(map[string]string{ + "GITHUB_APP_INSTALLER_ENABLED": "true", + "APP_GITHUB_ORG": "arn:aws:ssm:us-east-1:123456789012:parameter/ops/org", + }) + env.ResolveSSM = func(context.Context, string) (string, error) { return "acme", nil } + b, err := LoadBootstrap(env) + if err != nil { + t.Fatal(err) + } + if b.Installer.GitHubOrg != "acme" { + t.Errorf("installer org = %q, want the parameter's value", b.Installer.GitHubOrg) + } +} - if !tt.wantSSM && err == nil && result != tt.value { - t.Errorf("expected result %q, got %q", tt.value, result) - } - }) +func TestWebURLFromAPI(t *testing.T) { + for api, want := range map[string]string{ + "https://ghe.example.com/api/v3": "https://ghe.example.com", + "https://ghe.example.com/api/v3/": "https://ghe.example.com", + "https://api.github.com": "https://github.com", + "https://127.0.0.1:8443": "https://127.0.0.1:8443", + } { + if got := webURLFromAPI(api); got != want { + t.Errorf("webURLFromAPI(%q) = %q, want %q", api, got, want) + } + } +} + +func TestRedacted(t *testing.T) { + cfg, err := load(t, with(map[string]string{"APP_ADMIN_TOKEN": "0123456789abcdef", "APP_SLACK_TOKEN": "xoxb-secret", "APP_SLACK_CHANNEL": "C1"})) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(cfg.Redacted()) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{"0123456789abcdef", "xoxb-secret", "whsec", "BEGIN RSA"} { + if strings.Contains(string(raw), secret) { + t.Errorf("redacted config leaks %q", secret) + } + } + if !strings.Contains(string(raw), `"github_org":"acme"`) { + t.Errorf("redacted config lacks non-secret values: %s", raw) } } diff --git a/internal/domain/domain_test.go b/internal/domain/domain_test.go new file mode 100644 index 0000000..9952337 --- /dev/null +++ b/internal/domain/domain_test.go @@ -0,0 +1,117 @@ +package domain + +import ( + "errors" + "fmt" + "strings" + "testing" +) + +func TestSyncRuleValidate(t *testing.T) { + tests := []struct { + name string + rule SyncRule + wantErr string + }{ + {"group name", SyncRule{OktaGroupName: "eng"}, ""}, + {"pattern", SyncRule{OktaGroupPattern: "^eng-", TeamPrivacy: "secret"}, ""}, + {"neither", SyncRule{}, "one of okta_group_pattern or okta_group_name"}, + {"both", SyncRule{OktaGroupName: "a", OktaGroupPattern: "b"}, "mutually exclusive"}, + {"bad pattern", SyncRule{OktaGroupPattern: "("}, "invalid okta_group_pattern"}, + {"pattern with team name", SyncRule{OktaGroupPattern: ".*", GitHubTeamName: "all"}, "cannot be combined"}, + {"bad privacy", SyncRule{OktaGroupName: "a", TeamPrivacy: "public"}, "invalid team_privacy"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.rule.Validate() + if tt.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want %q", err, tt.wantErr) + } + }) + } +} + +func TestSyncRuleDefaults(t *testing.T) { + r := SyncRule{OktaGroupPattern: "^x"} + if !r.IsEnabled() || !r.ShouldSyncMembers() || r.Privacy() != TeamPrivacyClosed || r.GetName() != "^x" { + t.Errorf("defaults = %v %v %q %q", r.IsEnabled(), r.ShouldSyncMembers(), r.Privacy(), r.GetName()) + } +} + +func TestIsValidGitHubLogin(t *testing.T) { + valid := []string{"octocat", "a", "octo-cat", "user_acme", "Old-Account-", strings.Repeat("a", 63)} + invalid := []string{"", "-lead", "has space", "a/b", "../x", "a?b", "a#b", "a%2f", "a.b", strings.Repeat("a", 64)} + for _, s := range valid { + if !IsValidGitHubLogin(s) { + t.Errorf("%q rejected", s) + } + } + for _, s := range invalid { + if IsValidGitHubLogin(s) { + t.Errorf("%q accepted", s) + } + } +} + +func TestSeverity(t *testing.T) { + if !MeetsSeverity("Critical", "high") || MeetsSeverity("medium", "high") || MeetsSeverity("", "low") { + t.Error("MeetsSeverity ordering is wrong") + } + if !IsSeverity("LOW") || IsSeverity("warning") { + t.Error("IsSeverity is wrong") + } + if got := HighestSeverity([]SecurityAlert{{Severity: "low"}, {Severity: "HIGH"}}); got != "high" { + t.Errorf("HighestSeverity = %q", got) + } + if got := HighestSeverity(nil); got != "unknown" { + t.Errorf("HighestSeverity(nil) = %q", got) + } +} + +func TestErrorKinds(t *testing.T) { + err := fmt.Errorf("context: %w", ErrInvalidSignature) + if !errors.Is(err, ErrInvalidSignature) || !errors.Is(err, ErrAuth) || errors.Is(err, ErrValidation) { + t.Error("error kinds do not match through wrapping") + } +} + +func TestSyncResult(t *testing.T) { + r := &SyncResult{Reports: []*SyncReport{ + {GitHubTeam: "a", TeamResolved: true}, + {GitHubTeam: "Team B", TeamResolved: true, Errors: []string{"x"}}, + {GitHubTeam: "a", TeamResolved: true}, + {GitHubTeam: ""}, + }} + if got := strings.Join(r.ManagedTeams(), ","); got != "a,team-b" { + t.Errorf("ManagedTeams = %q, want a,team-b", got) + } + if r.Complete() { + t.Error("result with an unresolved team is complete") + } + r.Reports = r.Reports[:3] + if !r.Complete() { + t.Error("result with every team resolved is incomplete") + } +} + +func TestTeamSlug(t *testing.T) { + tests := map[string]string{ + "Platform Team": "platform-team", + "ops & infra": "ops-infra", + "--eng--": "eng", + "snake_case": "snake_case", + " spaced out ": "spaced-out", + "already-a-slug": "already-a-slug", + } + for name, want := range tests { + if got := TeamSlug(name); got != want { + t.Errorf("TeamSlug(%q) = %q, want %q", name, got, want) + } + } +} diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 18249f0..56abbba 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -1,43 +1,44 @@ -// Package domain defines shared business types, errors, and interfaces. -// this package has zero internal imports and serves as the dependency -// inversion layer for the application. +// Package domain defines the types, interfaces, and errors shared by the +// rest of the application. It imports no other internal package. package domain -import "github.com/cockroachdb/errors" +import "errors" -// error domain markers enable error classification and monitoring by type -// rather than comparing specific sentinel errors. -type ( - validationError struct{} - authError struct{} - apiError struct{} - configError struct{} +// Error kinds classify failures so callers, such as the HTTP layer, can react +// to a kind of failure without knowing every specific error. Check them with +// errors.Is. +var ( + ErrValidation = errors.New("validation error") + ErrAuth = errors.New("auth error") + ErrAPI = errors.New("api error") + ErrConfig = errors.New("config error") ) -func (validationError) Error() string { return "validation error" } -func (authError) Error() string { return "auth error" } -func (apiError) Error() string { return "api error" } -func (configError) Error() string { return "config error" } +// kindError is a sentinel error that belongs to one of the error kinds, so +// errors.Is matches both the sentinel and its kind. +type kindError struct { + kind error + msg string +} -// domain type instances for error marking -var ( - ValidationError = validationError{} - AuthError = authError{} - APIError = apiError{} - ConfigError = configError{} -) +func (e *kindError) Error() string { return e.msg } +func (e *kindError) Unwrap() error { return e.kind } + +func newKindError(kind error, msg string) error { + return &kindError{kind: kind, msg: msg} +} -// sentinel errors for common failure cases +// Sentinel errors for common failures. var ( - ErrMissingPRData = errors.Mark(errors.New("pr data missing"), ValidationError) - ErrInvalidSignature = errors.Mark(errors.New("invalid webhook signature"), AuthError) - ErrMissingSignature = errors.Mark(errors.New("signature missing but secret configured"), AuthError) - ErrUnexpectedSignature = errors.Mark(errors.New("signature provided but secret not configured"), AuthError) - ErrTeamNotFound = errors.Mark(errors.New("github team not found"), APIError) - ErrGroupNotFound = errors.Mark(errors.New("okta group not found"), APIError) - ErrInvalidPattern = errors.Mark(errors.New("invalid regex pattern"), ValidationError) - ErrEmptyPattern = errors.Mark(errors.New("pattern cannot be empty"), ValidationError) - ErrClientNotInit = errors.Mark(errors.New("client not initialized"), ConfigError) - ErrInvalidEventType = errors.Mark(errors.New("unknown event type"), ValidationError) - ErrMissingOAuthCreds = errors.Mark(errors.New("must provide either api token or oauth credentials"), ConfigError) + ErrMissingPRData = newKindError(ErrValidation, "pr data missing") + ErrInvalidPayload = newKindError(ErrValidation, "invalid webhook payload") + ErrInvalidSignature = newKindError(ErrAuth, "invalid webhook signature") + ErrMissingSignature = newKindError(ErrAuth, "webhook signature missing") + ErrInvalidPattern = newKindError(ErrValidation, "invalid regex pattern") + ErrEmptyPattern = newKindError(ErrValidation, "pattern cannot be empty") + ErrUnknownAction = newKindError(ErrValidation, "unknown scheduled action") + ErrFeatureDisabled = newKindError(ErrConfig, "feature not enabled") + ErrGroupNotFound = newKindError(ErrAPI, "okta group not found") + ErrTeamNotFound = newKindError(ErrAPI, "github team not found") + ErrInstallationMissing = newKindError(ErrConfig, "github app is not installed on the organization") ) diff --git a/internal/domain/interfaces.go b/internal/domain/interfaces.go index eb81cb5..5d4c8ae 100644 --- a/internal/domain/interfaces.go +++ b/internal/domain/interfaces.go @@ -1,50 +1,39 @@ package domain -import ( - "context" +import "context" - "github.com/google/go-github/v79/github" -) - -// GitHubClient defines the interface for GitHub API operations. -// implemented by internal/github/client.Client. +// GitHubClient defines the GitHub API operations the application uses. +// Implemented by internal/github/client.Client. type GitHubClient interface { - // CheckPRCompliance verifies if a merged PR met branch protection - // requirements. + // CheckPRCompliance verifies whether a merged PR met the branch + // protection and ruleset requirements of its base branch. CheckPRCompliance(ctx context.Context, owner, repo string, prNumber int) (*PRComplianceResult, error) - // GetOrCreateTeam fetches an existing team by slug or creates it if - // missing. - GetOrCreateTeam(ctx context.Context, teamName, privacy string) (*github.Team, error) + // ResolveTeam returns the slug of the team with the given name. If the + // team does not exist, it is created when create is true, and + // ErrTeamNotFound is returned otherwise. + ResolveTeam(ctx context.Context, name, privacy string, create bool) (string, error) // SyncTeamMembers adds and removes members to match desired state. SyncTeamMembers(ctx context.Context, teamSlug string, desiredMembers []string, safetyThreshold float64) (*TeamSyncResult, error) - // GetTeamMembers returns GitHub usernames of all team members. + // GetTeamMembers returns the logins of all team members. GetTeamMembers(ctx context.Context, teamSlug string) ([]string, error) - // ListOrgMembers returns all organization members excluding external - // collaborators. + // ListOrgMembers returns the logins of all organization members. ListOrgMembers(ctx context.Context) ([]string, error) - // IsExternalCollaborator checks if a user is an outside collaborator - // rather than an organization member. - IsExternalCollaborator(ctx context.Context, username string) (bool, error) - - // GetAppSlug fetches the GitHub App slug identifier. + // GetAppSlug returns the GitHub App's slug. GetAppSlug(ctx context.Context) (string, error) - // GetOrg returns the GitHub organization name. - GetOrg() string - - // ListSecurityAlerts fetches open security alerts across the org - // that are older than minAgeDays and at or above minSeverity. - // covers dependabot, code scanning, and secret scanning alerts. + // ListSecurityAlerts fetches open security alerts across the org that + // are older than minAgeDays and at or above minSeverity. It covers + // dependabot, code scanning, and secret scanning alerts. ListSecurityAlerts(ctx context.Context, minAgeDays int, minSeverity string) (*SecurityAlertsReport, error) } -// OktaClient defines the interface for Okta API operations. -// implemented by internal/okta.Client. +// OktaClient defines the Okta API operations the application uses. +// Implemented by internal/okta.Client. type OktaClient interface { // GetGroupsByPattern fetches all Okta groups matching a regex pattern. GetGroupsByPattern(ctx context.Context, pattern string) ([]*GroupInfo, error) @@ -54,7 +43,7 @@ type OktaClient interface { } // Notifier defines the interface for sending notifications. -// implemented by internal/notifiers.SlackNotifier. +// Implemented by internal/notifiers.SlackNotifier. type Notifier interface { // NotifyPRBypass sends a notification when branch protection is // bypassed. diff --git a/internal/domain/types.go b/internal/domain/types.go index 169c413..c903c21 100644 --- a/internal/domain/types.go +++ b/internal/domain/types.go @@ -1,9 +1,19 @@ package domain import ( + "errors" + "fmt" + "regexp" + "strings" "time" - "github.com/google/go-github/v79/github" + "github.com/google/go-github/v93/github" +) + +// Team privacy values accepted by the GitHub API. +const ( + TeamPrivacyClosed = "closed" + TeamPrivacySecret = "secret" ) // SyncRule defines how to sync Okta groups to GitHub teams. @@ -31,15 +41,63 @@ func (r SyncRule) ShouldSyncMembers() bool { return r.SyncMembers == nil || *r.SyncMembers } -// GetName returns the rule name, defaulting to GitHubTeamName if not set. +// GetName returns the rule name, defaulting to the team or group name. func (r SyncRule) GetName() string { - if r.Name != "" { + switch { + case r.Name != "": return r.Name - } - if r.GitHubTeamName != "" { + case r.GitHubTeamName != "": return r.GitHubTeamName + case r.OktaGroupName != "": + return r.OktaGroupName + default: + return r.OktaGroupPattern + } +} + +// Privacy returns the privacy for teams the rule creates. +func (r SyncRule) Privacy() string { + if r.TeamPrivacy == "" { + return TeamPrivacyClosed + } + return r.TeamPrivacy +} + +// Validate reports every problem with the rule. +func (r SyncRule) Validate() error { + var errs []error + switch { + case r.OktaGroupPattern == "" && r.OktaGroupName == "": + errs = append(errs, errors.New("one of okta_group_pattern or okta_group_name is required")) + case r.OktaGroupPattern != "" && r.OktaGroupName != "": + errs = append(errs, errors.New("okta_group_pattern and okta_group_name are mutually exclusive")) + } + if r.OktaGroupPattern != "" { + if _, err := regexp.Compile(r.OktaGroupPattern); err != nil { + errs = append(errs, fmt.Errorf("invalid okta_group_pattern %q: %w", r.OktaGroupPattern, err)) + } + // every matched group would sync into the one team, each removing + // the members of the others + if r.GitHubTeamName != "" { + errs = append(errs, errors.New("github_team_name cannot be combined with okta_group_pattern; use github_team_prefix and strip_prefix")) + } } - return r.OktaGroupName + switch r.TeamPrivacy { + case "", TeamPrivacyClosed, TeamPrivacySecret: + default: + errs = append(errs, fmt.Errorf("invalid team_privacy %q: must be %q or %q", r.TeamPrivacy, TeamPrivacyClosed, TeamPrivacySecret)) + } + return errors.Join(errs...) +} + +// githubLoginPattern matches GitHub logins, including the underscore suffix +// of enterprise managed users. It rejects anything that could change the +// path of an API request. +var githubLoginPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$`) + +// IsValidGitHubLogin returns true if s can be a GitHub login. +func IsValidGitHubLogin(s string) bool { + return githubLoginPattern.MatchString(s) } // ComplianceViolation represents a single branch protection rule violation. @@ -48,8 +106,14 @@ type ComplianceViolation struct { Description string } +// Compliance violation types. +const ( + ViolationInsufficientReviews = "insufficient_reviews" + ViolationMissingStatusCheck = "missing_status_check" +) + // PRComplianceResult contains PR compliance check results including -// violations and user bypass permissions. +// violations and the merger's bypass permissions. type PRComplianceResult struct { PR *github.PullRequest BaseBranch string @@ -65,10 +129,12 @@ func (r *PRComplianceResult) HasViolations() bool { return len(r.Violations) > 0 } -// WasBypassed returns true if violations exist and user had bypass -// permission. +// WasBypassed returns true if the PR was merged even though it did not meet +// the branch's requirements. GitHub blocks such merges unless someone +// bypasses the rules: an admin, a ruleset bypass actor, or a role allowed to +// skip branch protection. func (r *PRComplianceResult) WasBypassed() bool { - return r.HasViolations() && r.UserHasBypass + return r.HasViolations() } // TeamSyncResult contains the results of syncing team membership. @@ -78,6 +144,9 @@ type TeamSyncResult struct { MembersRemoved []string MembersSkippedExternal []string Errors []string + // Members are the team's members after the sync, including members of + // its child teams. + Members []string } // GroupInfo contains Okta group details and member list. @@ -86,6 +155,9 @@ type GroupInfo struct { Name string Members []string SkippedNoGitHubUsername []string + // Err is set when the group's members could not be fetched. Such a + // group must not be synced, since its member list is incomplete. + Err error } // GroupMembersResult contains the results of fetching group members. @@ -94,7 +166,7 @@ type GroupMembersResult struct { SkippedNoGitHubUsername []string } -// SyncReport contains the results of syncing a single Okta group to GitHub +// SyncReport contains the results of syncing a single Okta group to a GitHub // team. type SyncReport struct { Rule string @@ -105,6 +177,11 @@ type SyncReport struct { MembersSkippedExternal []string MembersSkippedNoGHUsername []string Errors []string + // TeamResolved is true when GitHubTeam is the slug of an existing team. + TeamResolved bool + // Members are the team's members after the sync, when its members were + // synced. + Members []string } // HasErrors returns true if any errors occurred during sync. @@ -123,10 +200,77 @@ type OrphanedUsersReport struct { OrphanedUsers []string } -// SyncResult contains all sync reports and orphaned users report. +// SyncResult contains the report for every synced group. type SyncResult struct { - Reports []*SyncReport - OrphanedUsers *OrphanedUsersReport + Reports []*SyncReport +} + +// ManagedTeams returns the slug of every team the sync targeted, whether or +// not its sync succeeded. +func (r *SyncResult) ManagedTeams() []string { + seen := make(map[string]bool) + var teams []string + for _, report := range r.Reports { + slug := TeamSlug(report.GitHubTeam) + if slug != "" && !seen[slug] { + seen[slug] = true + teams = append(teams, slug) + } + } + return teams +} + +// Complete returns true if every report's team was resolved, so the result +// names every team the rules manage. A rule or group that failed before its +// team was found leaves the result incomplete. +func (r *SyncResult) Complete() bool { + for _, report := range r.Reports { + if !report.TeamResolved { + return false + } + } + return true +} + +// slugInvalidChars matches the runs of characters GitHub replaces with a +// hyphen when it derives a team's slug from its name. +var slugInvalidChars = regexp.MustCompile(`[^a-z0-9_-]+`) + +// TeamSlug returns the slug GitHub gives a team with the given name. A slug +// is returned unchanged. +func TeamSlug(name string) string { + return strings.Trim(slugInvalidChars.ReplaceAllString(strings.ToLower(name), "-"), "-") +} + +// Security alert severities, from lowest to highest. +const ( + SeverityLow = "low" + SeverityMedium = "medium" + SeverityHigh = "high" + SeverityCritical = "critical" +) + +var severityRank = map[string]int{ + SeverityLow: 1, + SeverityMedium: 2, + SeverityHigh: 3, + SeverityCritical: 4, +} + +// SeverityRank returns a number that orders severities, or 0 for an unknown +// severity. +func SeverityRank(severity string) int { + return severityRank[strings.ToLower(severity)] +} + +// IsSeverity returns true if s is one of the known severities. +func IsSeverity(s string) bool { + return SeverityRank(s) > 0 +} + +// MeetsSeverity returns true if severity is at or above minSeverity. +func MeetsSeverity(severity, minSeverity string) bool { + return SeverityRank(severity) >= SeverityRank(minSeverity) } // SecurityAlert represents a normalized security alert from any source @@ -165,3 +309,17 @@ func (r *SecurityAlertsReport) HasErrors() bool { func (r *SecurityAlertsReport) RepoCount() int { return len(r.AlertsByRepo) } + +// HighestSeverity returns the highest severity among alerts, or "unknown". +func HighestSeverity(alerts []SecurityAlert) string { + best := "" + for _, a := range alerts { + if SeverityRank(a.Severity) > SeverityRank(best) { + best = strings.ToLower(a.Severity) + } + } + if best == "" { + return "unknown" + } + return best +} diff --git a/internal/fakes/fakes.go b/internal/fakes/fakes.go new file mode 100644 index 0000000..0045589 --- /dev/null +++ b/internal/fakes/fakes.go @@ -0,0 +1,184 @@ +// Package fakes provides in-memory implementations of the domain interfaces +// for tests. Each method calls its function field when set and otherwise +// returns a harmless default. Calls are recorded and safe for concurrent use. +package fakes + +import ( + "context" + "sync" + + "github.com/cruxstack/github-ops-app/internal/domain" +) + +// calls records method calls. +type calls struct { + mu sync.Mutex + names []string +} + +func (c *calls) record(name string) { + c.mu.Lock() + defer c.mu.Unlock() + c.names = append(c.names, name) +} + +// Count returns how many times the named method was called. +func (c *calls) Count(name string) int { + c.mu.Lock() + defer c.mu.Unlock() + n := 0 + for _, got := range c.names { + if got == name { + n++ + } + } + return n +} + +// GitHub is a fake domain.GitHubClient. +type GitHub struct { + calls + + CheckPRComplianceFn func(ctx context.Context, owner, repo string, prNumber int) (*domain.PRComplianceResult, error) + ResolveTeamFn func(ctx context.Context, name, privacy string, create bool) (string, error) + SyncTeamMembersFn func(ctx context.Context, teamSlug string, desired []string, threshold float64) (*domain.TeamSyncResult, error) + GetTeamMembersFn func(ctx context.Context, teamSlug string) ([]string, error) + ListOrgMembersFn func(ctx context.Context) ([]string, error) + GetAppSlugFn func(ctx context.Context) (string, error) + ListSecurityAlertsFn func(ctx context.Context, minAgeDays int, minSeverity string) (*domain.SecurityAlertsReport, error) +} + +var _ domain.GitHubClient = (*GitHub)(nil) + +func (f *GitHub) CheckPRCompliance(ctx context.Context, owner, repo string, prNumber int) (*domain.PRComplianceResult, error) { + f.record("CheckPRCompliance") + if f.CheckPRComplianceFn != nil { + return f.CheckPRComplianceFn(ctx, owner, repo, prNumber) + } + return &domain.PRComplianceResult{}, nil +} + +func (f *GitHub) ResolveTeam(ctx context.Context, name, privacy string, create bool) (string, error) { + f.record("ResolveTeam") + if f.ResolveTeamFn != nil { + return f.ResolveTeamFn(ctx, name, privacy, create) + } + return name, nil +} + +func (f *GitHub) SyncTeamMembers(ctx context.Context, teamSlug string, desired []string, threshold float64) (*domain.TeamSyncResult, error) { + f.record("SyncTeamMembers") + if f.SyncTeamMembersFn != nil { + return f.SyncTeamMembersFn(ctx, teamSlug, desired, threshold) + } + return &domain.TeamSyncResult{TeamName: teamSlug, MembersAdded: desired, Members: desired}, nil +} + +func (f *GitHub) GetTeamMembers(ctx context.Context, teamSlug string) ([]string, error) { + f.record("GetTeamMembers") + if f.GetTeamMembersFn != nil { + return f.GetTeamMembersFn(ctx, teamSlug) + } + return nil, nil +} + +func (f *GitHub) ListOrgMembers(ctx context.Context) ([]string, error) { + f.record("ListOrgMembers") + if f.ListOrgMembersFn != nil { + return f.ListOrgMembersFn(ctx) + } + return nil, nil +} + +func (f *GitHub) GetAppSlug(ctx context.Context) (string, error) { + f.record("GetAppSlug") + if f.GetAppSlugFn != nil { + return f.GetAppSlugFn(ctx) + } + return "ops-app", nil +} + +func (f *GitHub) ListSecurityAlerts(ctx context.Context, minAgeDays int, minSeverity string) (*domain.SecurityAlertsReport, error) { + f.record("ListSecurityAlerts") + if f.ListSecurityAlertsFn != nil { + return f.ListSecurityAlertsFn(ctx, minAgeDays, minSeverity) + } + return &domain.SecurityAlertsReport{ + MinAgeDays: minAgeDays, + MinSeverity: minSeverity, + AlertsByRepo: map[string][]domain.SecurityAlert{}, + }, nil +} + +// Okta is a fake domain.OktaClient. +type Okta struct { + calls + + GetGroupsByPatternFn func(ctx context.Context, pattern string) ([]*domain.GroupInfo, error) + GetGroupInfoFn func(ctx context.Context, name string) (*domain.GroupInfo, error) +} + +var _ domain.OktaClient = (*Okta)(nil) + +func (f *Okta) GetGroupsByPattern(ctx context.Context, pattern string) ([]*domain.GroupInfo, error) { + f.record("GetGroupsByPattern") + if f.GetGroupsByPatternFn != nil { + return f.GetGroupsByPatternFn(ctx, pattern) + } + return nil, nil +} + +func (f *Okta) GetGroupInfo(ctx context.Context, name string) (*domain.GroupInfo, error) { + f.record("GetGroupInfo") + if f.GetGroupInfoFn != nil { + return f.GetGroupInfoFn(ctx, name) + } + return &domain.GroupInfo{ID: "id-" + name, Name: name}, nil +} + +// Notifier is a fake domain.Notifier that keeps what it was asked to send. +type Notifier struct { + calls + mu sync.Mutex + + Err error + + PRBypasses []*domain.PRComplianceResult + OktaSyncs [][]*domain.SyncReport + OrphanedUsers []*domain.OrphanedUsersReport + SecurityAlerts []*domain.SecurityAlertsReport +} + +var _ domain.Notifier = (*Notifier)(nil) + +func (f *Notifier) NotifyPRBypass(_ context.Context, result *domain.PRComplianceResult, _ string) error { + f.record("NotifyPRBypass") + f.mu.Lock() + defer f.mu.Unlock() + f.PRBypasses = append(f.PRBypasses, result) + return f.Err +} + +func (f *Notifier) NotifyOktaSync(_ context.Context, reports []*domain.SyncReport, _ string) error { + f.record("NotifyOktaSync") + f.mu.Lock() + defer f.mu.Unlock() + f.OktaSyncs = append(f.OktaSyncs, reports) + return f.Err +} + +func (f *Notifier) NotifyOrphanedUsers(_ context.Context, report *domain.OrphanedUsersReport) error { + f.record("NotifyOrphanedUsers") + f.mu.Lock() + defer f.mu.Unlock() + f.OrphanedUsers = append(f.OrphanedUsers, report) + return f.Err +} + +func (f *Notifier) NotifySecurityAlerts(_ context.Context, report *domain.SecurityAlertsReport, _ string) error { + f.record("NotifySecurityAlerts") + f.mu.Lock() + defer f.mu.Unlock() + f.SecurityAlerts = append(f.SecurityAlerts, report) + return f.Err +} diff --git a/internal/github/client/client.go b/internal/github/client/client.go index 93ec3a3..305cc34 100644 --- a/internal/github/client/client.go +++ b/internal/github/client/client.go @@ -1,6 +1,6 @@ -// Package client provides a GitHub API client with App authentication. -// Handles JWT generation, installation token management, PR compliance -// checking, and team membership synchronization. +// Package client is the GitHub API client. It authenticates as a GitHub App, +// checks merged pull requests for compliance, syncs team membership, and +// lists security alerts. package client import ( @@ -8,258 +8,270 @@ import ( "crypto/rsa" "crypto/x509" "encoding/pem" + "errors" "fmt" "net/http" + "net/url" + "strconv" + "strings" "sync" "time" - "github.com/cockroachdb/errors" "github.com/golang-jwt/jwt/v5" - "github.com/google/go-github/v79/github" - "golang.org/x/oauth2" -) + "github.com/google/go-github/v93/github" -// Client wraps the GitHub API client with App authentication. -// automatically refreshes installation tokens before expiry. -// implements domain.GitHubClient. -type Client struct { - client *github.Client - org string - baseURL string + "github.com/cruxstack/github-ops-app/internal/domain" +) - appID int64 - privateKey *rsa.PrivateKey - installationID int64 +const ( + // jwtLifetime is how long an app JWT is valid. GitHub rejects JWTs that + // expire more than 10 minutes after they are issued. + jwtLifetime = 9 * time.Minute + // jwtClockSkew backdates a JWT's issue time to allow for clock drift. + jwtClockSkew = 60 * time.Second + // tokenRefreshMargin is how long before expiry a JWT or installation + // token is replaced, so no request carries one that expires in flight. + tokenRefreshMargin = 5 * time.Minute + // defaultTimeout bounds every GitHub API request. + defaultTimeout = 30 * time.Second +) - tokenMu sync.RWMutex - token string - tokenExpAt time.Time +// Options configures a Client. +type Options struct { + AppID int64 + // InstallationID is the app's installation on Org. When zero, the + // installation is looked up on first use. + InstallationID int64 + // PrivateKey is the app's PEM-encoded RSA private key. + PrivateKey []byte + Org string + // BaseURL is the API base URL, such as https://ghe.example.com/api/v3 + // for GitHub Enterprise Server. Empty means https://api.github.com. + BaseURL string + // HTTPClient is the client requests are sent with. Its transport is + // wrapped to add authentication. Defaults to a client with a timeout. + HTTPClient *http.Client } -// NewAppClient creates a GitHub App client with default base URL. -func NewAppClient(appID, installationID int64, privateKeyPEM []byte, org string) (*Client, error) { - return NewAppClientWithBaseURL(appID, installationID, privateKeyPEM, org, "") +// Client is a GitHub API client authenticated as a GitHub App installation. +// It is safe for concurrent use. Implements domain.GitHubClient. +type Client struct { + org string + // gh authenticates as the installation; app authenticates as the app + // itself, for the few endpoints that require a JWT. + gh *github.Client + app *github.Client + + slugMu sync.Mutex + slug string } -// NewAppClientWithBaseURL creates a GitHub App client with custom base URL. -// supports GitHub Enterprise Server instances. -func NewAppClientWithBaseURL(appID, installationID int64, privateKeyPEM []byte, org, baseURL string) (*Client, error) { - privateKey, err := parsePrivateKey(privateKeyPEM) +var _ domain.GitHubClient = (*Client)(nil) + +// New creates a Client. It makes no API calls: credentials are exchanged for +// an installation token on first use and refreshed before they expire. +func New(opts Options) (*Client, error) { + if opts.AppID <= 0 { + return nil, errors.New("github app id is required") + } + if opts.Org == "" { + return nil, errors.New("github org is required") + } + key, err := ParsePrivateKey(opts.PrivateKey) if err != nil { - return nil, errors.Wrap(err, "failed to parse private key") + return nil, err } - c := &Client{ - org: org, - appID: appID, - privateKey: privateKey, - installationID: installationID, - baseURL: baseURL, + httpClient := opts.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: defaultTimeout} } - - if err := c.refreshToken(context.Background()); err != nil { - return nil, errors.Wrap(err, "failed to get initial token") + base := httpClient.Transport + if base == nil { + base = http.DefaultTransport } - return c, nil -} + var baseURL *string + apiHost := "api.github.com" + if opts.BaseURL != "" { + u, err := url.Parse(opts.BaseURL) + if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") { + return nil, fmt.Errorf("invalid github base url %q", opts.BaseURL) + } + apiHost = u.Host + baseURL = &opts.BaseURL + } -// parsePrivateKey parses RSA private key from PEM format. -// supports both PKCS1 and PKCS8 formats. -func parsePrivateKey(privateKeyPEM []byte) (*rsa.PrivateKey, error) { - block, _ := pem.Decode(privateKeyPEM) - if block == nil { - return nil, errors.New("failed to decode pem block: invalid format") + newGitHubClient := func(rt http.RoundTripper) (*github.Client, error) { + hc := *httpClient + hc.Transport = rt + return github.NewClient(github.WithHTTPClient(&hc), github.WithURLs(baseURL, nil)) } - key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + jwts := &appJWTs{appID: opts.AppID, key: key, now: time.Now} + app, err := newGitHubClient(&authTransport{base: base, host: apiHost, token: jwts.Token}) if err != nil { - pkcs8Key, err2 := x509.ParsePKCS8PrivateKey(block.Bytes) - if err2 != nil { - return nil, errors.Wrap(err2, "failed to parse private key as pkcs1 or pkcs8") - } - rsaKey, ok := pkcs8Key.(*rsa.PrivateKey) - if !ok { - return nil, errors.New("private key is not rsa format") - } - return rsaKey, nil + return nil, fmt.Errorf("failed to create github app client: %w", err) } - return key, nil -} - -// createJWT generates a JWT token for GitHub App authentication. -// token is valid for 10 minutes and backdated by 60 seconds for clock skew. -func (c *Client) createJWT() (string, error) { - now := time.Now() - claims := jwt.RegisteredClaims{ - IssuedAt: jwt.NewNumericDate(now.Add(-60 * time.Second)), - ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), - Issuer: fmt.Sprintf("%d", c.appID), + tokens := &installationTokens{app: app, org: opts.Org, id: opts.InstallationID, now: time.Now} + gh, err := newGitHubClient(&authTransport{base: base, host: apiHost, token: tokens.Token}) + if err != nil { + return nil, fmt.Errorf("failed to create github installation client: %w", err) } - token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) - return token.SignedString(c.privateKey) + return &Client{org: opts.Org, gh: gh, app: app}, nil } -// refreshToken exchanges JWT for installation token and updates client. -// installation tokens are valid for 1 hour. -func (c *Client) refreshToken(ctx context.Context) error { - jwtToken, err := c.createJWT() - if err != nil { - return errors.Wrap(err, "failed to create JWT") +// ParsePrivateKey parses a PEM-encoded RSA private key in PKCS#1 or PKCS#8 +// form. Escaped newlines ("\n"), as found in single-line environment +// variables, are accepted. +func ParsePrivateKey(privateKeyPEM []byte) (*rsa.PrivateKey, error) { + if len(privateKeyPEM) == 0 { + return nil, errors.New("github app private key is required") } - - ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: jwtToken}) - tc := oauth2.NewClient(ctx, ts) - appClient := github.NewClient(tc) - if c.baseURL != "" { - appClient.BaseURL, _ = appClient.BaseURL.Parse(c.baseURL) + pemBytes := []byte(strings.ReplaceAll(string(privateKeyPEM), `\n`, "\n")) + block, _ := pem.Decode(pemBytes) + if block == nil { + return nil, errors.New("failed to decode github app private key: not pem encoded") } - installToken, resp, err := appClient.Apps.CreateInstallationToken( - ctx, - c.installationID, - &github.InstallationTokenOptions{}, - ) + if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return key, nil + } + parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { - return errors.WithDetailf( - errors.Wrap(err, "failed to create installation token"), - "installation_id=%d app_id=%d", c.installationID, c.appID, - ) + return nil, fmt.Errorf("failed to parse github app private key as pkcs1 or pkcs8: %w", err) } - defer resp.Body.Close() - - c.tokenMu.Lock() - c.token = installToken.GetToken() - c.tokenExpAt = installToken.GetExpiresAt().Time - ts2 := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: c.token}) - tc2 := oauth2.NewClient(ctx, ts2) - c.client = github.NewClient(tc2) - if c.baseURL != "" { - c.client.BaseURL, _ = c.client.BaseURL.Parse(c.baseURL) + key, ok := parsed.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("github app private key is not an rsa key") } - c.tokenMu.Unlock() + return key, nil +} - return nil +// authTransport adds a bearer token to requests for the GitHub API host, and +// only that host, so a redirect elsewhere never carries the token. +type authTransport struct { + base http.RoundTripper + host string + token func(ctx context.Context) (string, error) } -// ensureValidToken refreshes the installation token if it expires within 5 -// minutes. uses double-check pattern to avoid redundant refreshes under -// concurrent access. -func (c *Client) ensureValidToken(ctx context.Context) error { - c.tokenMu.RLock() - needsRefresh := time.Now().Add(5 * time.Minute).After(c.tokenExpAt) - c.tokenMu.RUnlock() - - if needsRefresh { - c.tokenMu.Lock() - // double-check after acquiring write lock - if time.Now().Add(5 * time.Minute).After(c.tokenExpAt) { - c.tokenMu.Unlock() - return c.refreshToken(ctx) +func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL.Host != t.host { + return t.base.RoundTrip(req) + } + token, err := t.token(req.Context()) + if err != nil { + if req.Body != nil { + _ = req.Body.Close() } - c.tokenMu.Unlock() + return nil, err } - - return nil + authed := req.Clone(req.Context()) + authed.Header.Set("Authorization", "Bearer "+token) + return t.base.RoundTrip(authed) } -// GetOrg returns the GitHub organization name. -func (c *Client) GetOrg() string { - return c.org -} - -// GetClient returns the underlying go-github client. -func (c *Client) GetClient() *github.Client { - return c.client -} +// appJWTs signs and caches the JWT that authenticates as the app. +type appJWTs struct { + appID int64 + key *rsa.PrivateKey + now func() time.Time -// Do executes an HTTP request with authentication. -// ensures token is valid before executing request. -func (c *Client) Do(ctx context.Context, req *http.Request) (*http.Response, error) { - if err := c.ensureValidToken(ctx); err != nil { - return nil, err - } - req = req.WithContext(ctx) - return c.client.Client().Do(req) + mu sync.Mutex + token string + expires time.Time } -// GetAppSlug fetches the GitHub App slug identifier. -// used to detect changes made by the app itself. -// requires JWT authentication (not installation token). -func (c *Client) GetAppSlug(ctx context.Context) (string, error) { - jwtToken, err := c.createJWT() - if err != nil { - return "", errors.Wrap(err, "failed to create jwt for app slug fetch") - } +func (j *appJWTs) Token(context.Context) (string, error) { + j.mu.Lock() + defer j.mu.Unlock() - ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: jwtToken}) - tc := oauth2.NewClient(ctx, ts) - appClient := github.NewClient(tc) - if c.baseURL != "" { - appClient.BaseURL, _ = appClient.BaseURL.Parse(c.baseURL) + now := j.now() + if j.token != "" && now.Add(time.Minute).Before(j.expires) { + return j.token, nil } - app, _, err := appClient.Apps.Get(ctx, "") + expires := now.Add(jwtLifetime) + token, err := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now.Add(-jwtClockSkew)), + ExpiresAt: jwt.NewNumericDate(expires), + Issuer: strconv.FormatInt(j.appID, 10), + }).SignedString(j.key) if err != nil { - return "", errors.Wrapf(err, "failed to fetch app info for app id %d", c.appID) - } - - if app.Slug == nil { - return "", errors.Newf("app slug missing for app id %d", c.appID) + return "", fmt.Errorf("failed to sign jwt for github app %d: %w", j.appID, err) } + j.token, j.expires = token, expires + return token, nil +} - return *app.Slug, nil +// installationTokens exchanges the app JWT for installation tokens and caches +// them until shortly before they expire. Concurrent callers share a single +// refresh. +type installationTokens struct { + app *github.Client + org string + now func() time.Time + + mu sync.Mutex + id int64 + token string + expires time.Time } -// IsExternalCollaborator checks if a user is an outside collaborator rather -// than an organization member. returns true if user is not a full org member. -func (c *Client) IsExternalCollaborator(ctx context.Context, username string) (bool, error) { - if err := c.ensureValidToken(ctx); err != nil { - return false, err +func (s *installationTokens) Token(ctx context.Context) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.token != "" && s.now().Add(tokenRefreshMargin).Before(s.expires) { + return s.token, nil } - membership, resp, err := c.client.Organizations.GetOrgMembership(ctx, username, c.org) - if err != nil { - if resp != nil && resp.StatusCode == 404 { - return true, nil + if s.id == 0 { + inst, resp, err := s.app.Apps.GetOrganizationInstallation(ctx, s.org) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return "", fmt.Errorf("failed to find installation for org '%s': %w", s.org, domain.ErrInstallationMissing) + } + return "", fmt.Errorf("failed to find installation for org '%s': %w", s.org, err) } - return false, errors.Wrapf(err, "failed to check org membership for user '%s'", username) + s.id = inst.GetID() } - return membership == nil, nil + token, _, err := s.app.Apps.CreateInstallationToken(ctx, s.id, nil) + if err != nil { + return "", fmt.Errorf("failed to create token for installation %d: %w", s.id, err) + } + if token.GetToken() == "" { + return "", fmt.Errorf("github returned an empty token for installation %d", s.id) + } + s.token, s.expires = token.GetToken(), token.GetExpiresAt().Time + return s.token, nil } -// ListOrgMembers returns all organization members excluding external -// collaborators. -func (c *Client) ListOrgMembers(ctx context.Context) ([]string, error) { - if err := c.ensureValidToken(ctx); err != nil { - return nil, err +// GetAppSlug returns the GitHub App's slug, used to recognize changes the app +// made itself. It is fetched once and cached. +func (c *Client) GetAppSlug(ctx context.Context) (string, error) { + c.slugMu.Lock() + defer c.slugMu.Unlock() + if c.slug != "" { + return c.slug, nil } - opts := &github.ListMembersOptions{ - ListOptions: github.ListOptions{PerPage: 100}, + app, _, err := c.app.Apps.Get(ctx, "") + if err != nil { + return "", fmt.Errorf("failed to fetch github app: %w", err) } - - var allMembers []string - for { - members, resp, err := c.client.Organizations.ListMembers(ctx, c.org, opts) - if err != nil { - return nil, errors.Wrapf(err, "failed to list members for org '%s'", c.org) - } - - for _, member := range members { - if member.Login != nil { - allMembers = append(allMembers, *member.Login) - } - } - - if resp.NextPage == 0 { - break - } - opts.Page = resp.NextPage + if app.GetSlug() == "" { + return "", errors.New("github app has no slug") } + c.slug = app.GetSlug() + return c.slug, nil +} - return allMembers, nil +// isNotFound returns true if resp is a 404 response. +func isNotFound(resp *github.Response) bool { + return resp != nil && resp.StatusCode == http.StatusNotFound } diff --git a/internal/github/client/client_test.go b/internal/github/client/client_test.go new file mode 100644 index 0000000..5d9202d --- /dev/null +++ b/internal/github/client/client_test.go @@ -0,0 +1,674 @@ +package client + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + + "github.com/cruxstack/github-ops-app/internal/domain" +) + +const ( + testOrg = "acme" + testAppID = 1234 + testToken = "ghs_installation_token" +) + +var ( + testKeyOnce sync.Once + testKey *rsa.PrivateKey +) + +func rsaKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + testKeyOnce.Do(func() { + k, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + testKey = k + }) + return testKey +} + +func pkcs1PEM(t *testing.T) []byte { + return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(rsaKey(t))}) +} + +// fakeGitHub is a GitHub API server. It serves the installation token +// endpoint itself and checks that every other request carries that token. +type fakeGitHub struct { + t *testing.T + mux *http.ServeMux + srv *httptest.Server + + tokenRequests atomic.Int32 + mu sync.Mutex + requests []string + handlers map[string]http.HandlerFunc +} + +func newFakeGitHub(t *testing.T) *fakeGitHub { + t.Helper() + f := &fakeGitHub{t: t, mux: http.NewServeMux(), handlers: map[string]http.HandlerFunc{}} + f.handle("POST /app/installations/{id}/access_tokens", func(w http.ResponseWriter, r *http.Request) { + f.requireJWT(r) + f.tokenRequests.Add(1) + writeJSON(w, http.StatusCreated, map[string]any{ + "token": testToken, + "expires_at": time.Now().Add(time.Hour).UTC().Format(time.RFC3339), + }) + }) + f.srv = httptest.NewServer(http.HandlerFunc(f.serve)) + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeGitHub) serve(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + f.requests = append(f.requests, r.Method+" "+r.URL.Path) + f.mu.Unlock() + + usesJWT := strings.HasPrefix(r.URL.Path, "/app") || strings.HasSuffix(r.URL.Path, "/installation") + if !usesJWT && r.Header.Get("Authorization") != "Bearer "+testToken { + f.t.Errorf("%s %s: authorization = %q, want the installation token", r.Method, r.URL.Path, r.Header.Get("Authorization")) + } + f.mux.ServeHTTP(w, r) +} + +// requireJWT fails the test unless r carries a JWT signed by the app key. +func (f *fakeGitHub) requireJWT(r *http.Request) { + raw, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") + if !ok { + f.t.Errorf("%s: missing bearer jwt", r.URL.Path) + return + } + claims := &jwt.RegisteredClaims{} + if _, err := jwt.ParseWithClaims(raw, claims, func(*jwt.Token) (any, error) { + return &testKey.PublicKey, nil + }, jwt.WithValidMethods([]string{"RS256"})); err != nil { + f.t.Errorf("%s: invalid jwt: %v", r.URL.Path, err) + return + } + if claims.Issuer != fmt.Sprint(testAppID) { + f.t.Errorf("jwt issuer = %q, want %d", claims.Issuer, testAppID) + } +} + +// handle serves pattern with h, replacing any earlier handler for it. +func (f *fakeGitHub) handle(pattern string, h http.HandlerFunc) { + f.mu.Lock() + defer f.mu.Unlock() + if _, ok := f.handlers[pattern]; !ok { + f.mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + h := f.handlers[pattern] + f.mu.Unlock() + h(w, r) + }) + } + f.handlers[pattern] = h +} + +func (f *fakeGitHub) json(pattern string, status int, body any) { + f.handle(pattern, func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, status, body) }) +} + +func (f *fakeGitHub) called(methodPath string) int { + f.mu.Lock() + defer f.mu.Unlock() + n := 0 + for _, r := range f.requests { + if r == methodPath { + n++ + } + } + return n +} + +func (f *fakeGitHub) client(t *testing.T, installationID int64) *Client { + t.Helper() + c, err := New(Options{ + AppID: testAppID, + InstallationID: installationID, + PrivateKey: pkcs1PEM(t), + Org: testOrg, + BaseURL: f.srv.URL, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + return c +} + +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +// nextLink sets a Link header pointing at the next page. +func nextLink(w http.ResponseWriter, r *http.Request, query string) { + w.Header().Set("Link", fmt.Sprintf(`; rel="next"`, r.Host, r.URL.Path, query)) +} + +func TestNew_Validation(t *testing.T) { + key := pkcs1PEM(t) + tests := []struct { + name string + opts Options + }{ + {"missing app id", Options{PrivateKey: key, Org: testOrg}}, + {"missing org", Options{AppID: 1, PrivateKey: key}}, + {"missing key", Options{AppID: 1, Org: testOrg}}, + {"bad key", Options{AppID: 1, Org: testOrg, PrivateKey: []byte("not a key")}}, + {"bad base url", Options{AppID: 1, Org: testOrg, PrivateKey: key, BaseURL: "ftp://example.com"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := New(tt.opts); err == nil { + t.Error("New succeeded, want error") + } + }) + } +} + +func TestParsePrivateKey(t *testing.T) { + key := rsaKey(t) + pkcs8, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + ecPKCS8, err := x509.MarshalPKCS8PrivateKey(ecKey) + if err != nil { + t.Fatal(err) + } + escaped := strings.ReplaceAll(string(pkcs1PEM(t)), "\n", `\n`) + + tests := []struct { + name string + pem []byte + wantErr bool + }{ + {"pkcs1", pkcs1PEM(t), false}, + {"pkcs8", pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8}), false}, + {"escaped newlines", []byte(escaped), false}, + {"ec key", pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: ecPKCS8}), true}, + {"not pem", []byte("garbage"), true}, + {"empty", nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParsePrivateKey(tt.pem) + if (err != nil) != tt.wantErr { + t.Errorf("ParsePrivateKey() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestClient_DiscoversInstallationAndCachesToken(t *testing.T) { + f := newFakeGitHub(t) + f.handle("GET /orgs/acme/installation", func(w http.ResponseWriter, r *http.Request) { + f.requireJWT(r) + writeJSON(w, http.StatusOK, map[string]any{"id": 77}) + }) + f.json("GET /orgs/acme/members", http.StatusOK, []map[string]any{{"login": "alice"}}) + c := f.client(t, 0) + + var wg sync.WaitGroup + for range 10 { + wg.Go(func() { + if _, err := c.ListOrgMembers(context.Background()); err != nil { + t.Errorf("ListOrgMembers: %v", err) + } + }) + } + wg.Wait() + + if got := f.called("GET /orgs/acme/installation"); got != 1 { + t.Errorf("installation lookups = %d, want 1", got) + } + if got := f.called("POST /app/installations/77/access_tokens"); got != 1 { + t.Errorf("token requests = %d, want 1", got) + } +} + +func TestClient_InstallationMissing(t *testing.T) { + f := newFakeGitHub(t) + f.json("GET /orgs/acme/installation", http.StatusNotFound, map[string]any{"message": "Not Found"}) + c := f.client(t, 0) + + _, err := c.ListOrgMembers(context.Background()) + if !errors.Is(err, domain.ErrInstallationMissing) { + t.Fatalf("error = %v, want ErrInstallationMissing", err) + } + if !errors.Is(err, domain.ErrConfig) { + t.Errorf("error kind is not ErrConfig") + } +} + +func TestAuthTransport_OnlyAuthenticatesAPIHost(t *testing.T) { + var gotAuth string + other := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + })) + defer other.Close() + + tr := &authTransport{ + base: http.DefaultTransport, + host: "api.github.com", + token: func(context.Context) (string, error) { return "secret", nil }, + } + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, other.URL, nil) + resp, err := tr.RoundTrip(req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + if gotAuth != "" { + t.Errorf("token sent to another host: %q", gotAuth) + } +} + +func TestGetAppSlug_Cached(t *testing.T) { + f := newFakeGitHub(t) + f.handle("GET /app", func(w http.ResponseWriter, r *http.Request) { + f.requireJWT(r) + writeJSON(w, http.StatusOK, map[string]any{"slug": "ops-bot"}) + }) + c := f.client(t, 1) + + for range 3 { + slug, err := c.GetAppSlug(context.Background()) + if err != nil || slug != "ops-bot" { + t.Fatalf("GetAppSlug = %q, %v", slug, err) + } + } + if got := f.called("GET /app"); got != 1 { + t.Errorf("app fetches = %d, want 1", got) + } +} + +// prFixture serves a merged PR and lets each test add its rules, reviews, +// statuses, and check runs. +func prFixture(t *testing.T) *fakeGitHub { + f := newFakeGitHub(t) + f.json("GET /repos/acme/web/pulls/7", http.StatusOK, map[string]any{ + "number": 7, + "merged": true, + "base": map[string]any{"ref": "main"}, + "head": map[string]any{"sha": "abc123"}, + "merged_by": map[string]any{"login": "mallory"}, + }) + f.json("GET /repos/acme/web/rules/branches/main", http.StatusOK, []any{}) + f.json("GET /repos/acme/web/collaborators/mallory/permission", http.StatusOK, map[string]any{ + "permission": "write", + "role_name": "maintain", + }) + return f +} + +func notProtected(f *fakeGitHub) { + f.json("GET /repos/acme/web/branches/main/protection", http.StatusNotFound, map[string]any{"message": "Branch not protected"}) +} + +func TestCheckPRCompliance_NoProtection(t *testing.T) { + f := prFixture(t) + notProtected(f) + c := f.client(t, 1) + + result, err := c.CheckPRCompliance(context.Background(), "acme", "web", 7) + if err != nil { + t.Fatal(err) + } + if result.HasViolations() { + t.Errorf("violations = %v, want none", result.Violations) + } + if f.called("GET /repos/acme/web/pulls/7/reviews") != 0 { + t.Error("reviews fetched although none are required") + } +} + +func TestCheckPRCompliance_ProtectionErrorIsReturned(t *testing.T) { + f := prFixture(t) + f.json("GET /repos/acme/web/branches/main/protection", http.StatusForbidden, map[string]any{"message": "Resource not accessible by integration"}) + c := f.client(t, 1) + + if _, err := c.CheckPRCompliance(context.Background(), "acme", "web", 7); err == nil { + t.Fatal("CheckPRCompliance succeeded, want the permission error") + } +} + +func TestCheckPRCompliance_Reviews(t *testing.T) { + review := func(login, state string) map[string]any { + return map[string]any{"user": map[string]any{"login": login}, "state": state} + } + tests := []struct { + name string + pages [][]map[string]any + wantErr bool + }{ + { + name: "comment after approval keeps the approval", + pages: [][]map[string]any{ + {review("alice", "APPROVED"), review("bob", "APPROVED")}, + {review("alice", "COMMENTED")}, + }, + }, + { + name: "changes requested after approval revokes it", + pages: [][]map[string]any{ + {review("alice", "APPROVED"), review("bob", "APPROVED")}, + {review("Alice", "CHANGES_REQUESTED")}, + }, + wantErr: true, + }, + { + name: "dismissed approval does not count", + pages: [][]map[string]any{ + {review("alice", "APPROVED"), review("bob", "DISMISSED")}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := prFixture(t) + f.json("GET /repos/acme/web/branches/main/protection", http.StatusOK, map[string]any{ + "required_pull_request_reviews": map[string]any{"required_approving_review_count": 2}, + }) + f.handle("GET /repos/acme/web/pulls/7/reviews", func(w http.ResponseWriter, r *http.Request) { + page := 0 + if r.URL.Query().Get("page") == "2" { + page = 1 + } + if page+1 < len(tt.pages) { + nextLink(w, r, "page=2&per_page=100") + } + writeJSON(w, http.StatusOK, tt.pages[page]) + }) + c := f.client(t, 1) + + result, err := c.CheckPRCompliance(context.Background(), "acme", "web", 7) + if err != nil { + t.Fatal(err) + } + if result.HasViolations() != tt.wantErr { + t.Errorf("violations = %v, want violation: %v", result.Violations, tt.wantErr) + } + }) + } +} + +func TestCheckPRCompliance_StatusChecks(t *testing.T) { + f := prFixture(t) + f.json("GET /repos/acme/web/branches/main/protection", http.StatusOK, map[string]any{ + "required_status_checks": map[string]any{ + "strict": true, + "contexts": []string{"legacy/ci"}, + "checks": []map[string]any{{"context": "lint"}}, + }, + }) + f.json("GET /repos/acme/web/rules/branches/main", http.StatusOK, []map[string]any{{ + "type": "required_status_checks", + "parameters": map[string]any{ + "required_status_checks": []map[string]any{{"context": "docs"}, {"context": "e2e"}, {"context": "build"}}, + }, + }}) + f.json("GET /repos/acme/web/commits/abc123/status", http.StatusOK, map[string]any{ + "statuses": []map[string]any{{"context": "legacy/ci", "state": "success"}}, + }) + f.json("GET /repos/acme/web/commits/abc123/check-runs", http.StatusOK, map[string]any{ + "total_count": 4, + "check_runs": []map[string]any{ + {"name": "lint", "conclusion": "success"}, + {"name": "docs", "conclusion": "skipped"}, + {"name": "e2e", "conclusion": "neutral"}, + {"name": "build", "conclusion": "failure"}, + }, + }) + c := f.client(t, 1) + + result, err := c.CheckPRCompliance(context.Background(), "acme", "web", 7) + if err != nil { + t.Fatal(err) + } + if len(result.Violations) != 1 || !strings.Contains(result.Violations[0].Description, "'build'") { + t.Fatalf("violations = %v, want only the failed build check", result.Violations) + } + if !result.WasBypassed() || !result.UserHasBypass || result.UserBypassReason != "repository maintainer" { + t.Errorf("bypass = %v %v %q, want a maintainer bypass", result.WasBypassed(), result.UserHasBypass, result.UserBypassReason) + } +} + +func TestListSecurityAlerts_PaginatesAndFilters(t *testing.T) { + f := newFakeGitHub(t) + old := time.Now().AddDate(0, 0, -90).UTC().Format(time.RFC3339) + recent := time.Now().UTC().Format(time.RFC3339) + repo := map[string]any{"full_name": "acme/web"} + + // dependabot pages with an "after" cursor + f.handle("GET /orgs/acme/dependabot/alerts", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("after") == "" { + nextLink(w, r, "after=cursor1&per_page=100") + writeJSON(w, http.StatusOK, []map[string]any{ + {"number": 1, "created_at": old, "repository": repo, "security_advisory": map[string]any{"severity": "critical", "summary": "rce"}}, + {"number": 2, "created_at": old, "repository": repo, "security_advisory": map[string]any{"severity": "low"}}, + }) + return + } + writeJSON(w, http.StatusOK, []map[string]any{ + {"number": 3, "created_at": old, "repository": repo, "security_vulnerability": map[string]any{"severity": "high"}}, + {"number": 4, "created_at": recent, "repository": repo, "security_advisory": map[string]any{"severity": "critical"}}, + }) + }) + // code scanning pages by number + f.handle("GET /orgs/acme/code-scanning/alerts", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") == "" { + nextLink(w, r, "page=2&per_page=100") + writeJSON(w, http.StatusOK, []map[string]any{ + {"number": 5, "created_at": old, "repository": repo, "rule": map[string]any{"severity": "error"}}, + }) + return + } + writeJSON(w, http.StatusOK, []map[string]any{ + {"number": 6, "created_at": old, "repository": repo, "rule": map[string]any{"security_severity_level": "medium"}}, + }) + }) + f.json("GET /orgs/acme/secret-scanning/alerts", http.StatusForbidden, map[string]any{"message": "Advanced Security is not enabled"}) + c := f.client(t, 1) + + report, err := c.ListSecurityAlerts(context.Background(), 30, domain.SeverityHigh) + if err != nil { + t.Fatal(err) + } + + var numbers []int + for _, a := range report.AlertsByRepo["acme/web"] { + numbers = append(numbers, a.Number) + } + if fmt.Sprint(numbers) != "[1 3 5]" || report.TotalAlerts != 3 { + t.Errorf("alerts = %v (total %d), want [1 3 5]", numbers, report.TotalAlerts) + } + if len(report.Errors) != 1 || !strings.Contains(report.Errors[0], "secret scanning") { + t.Errorf("errors = %v, want the secret scanning failure", report.Errors) + } +} + +func TestListSecurityAlerts_SkipsSecretScanningBelowSeverity(t *testing.T) { + f := newFakeGitHub(t) + f.json("GET /orgs/acme/dependabot/alerts", http.StatusOK, []any{}) + f.json("GET /orgs/acme/code-scanning/alerts", http.StatusOK, []any{}) + c := f.client(t, 1) + + if _, err := c.ListSecurityAlerts(context.Background(), 30, domain.SeverityCritical); err != nil { + t.Fatal(err) + } + if f.called("GET /orgs/acme/secret-scanning/alerts") != 0 { + t.Error("secret scanning alerts fetched although their severity is below critical") + } +} + +func TestResolveTeam(t *testing.T) { + t.Run("existing team", func(t *testing.T) { + f := newFakeGitHub(t) + f.json("GET /orgs/acme/teams/platform-team", http.StatusOK, map[string]any{"slug": "platform-team"}) + slug, err := f.client(t, 1).ResolveTeam(context.Background(), "Platform Team", "closed", false) + if err != nil || slug != "platform-team" { + t.Fatalf("ResolveTeam = %q, %v", slug, err) + } + }) + + t.Run("finds a team whose slug differs from its name", func(t *testing.T) { + f := newFakeGitHub(t) + f.json("GET /orgs/acme/teams/{slug}", http.StatusNotFound, map[string]any{"message": "Not Found"}) + f.json("GET /orgs/acme/teams", http.StatusOK, []map[string]any{{"name": "Ops & Infra", "slug": "ops-infra"}}) + slug, err := f.client(t, 1).ResolveTeam(context.Background(), "ops & infra", "closed", false) + if err != nil || slug != "ops-infra" { + t.Fatalf("ResolveTeam = %q, %v", slug, err) + } + }) + + t.Run("missing team without create", func(t *testing.T) { + f := newFakeGitHub(t) + f.json("GET /orgs/acme/teams/{slug}", http.StatusNotFound, map[string]any{"message": "Not Found"}) + f.json("GET /orgs/acme/teams", http.StatusOK, []any{}) + _, err := f.client(t, 1).ResolveTeam(context.Background(), "eng", "closed", false) + if !errors.Is(err, domain.ErrTeamNotFound) { + t.Fatalf("error = %v, want ErrTeamNotFound", err) + } + if f.called("POST /orgs/acme/teams") != 0 { + t.Error("team created although create is false") + } + }) + + t.Run("missing team with create", func(t *testing.T) { + f := newFakeGitHub(t) + f.json("GET /orgs/acme/teams/{slug}", http.StatusNotFound, map[string]any{"message": "Not Found"}) + f.json("GET /orgs/acme/teams", http.StatusOK, []any{}) + f.handle("POST /orgs/acme/teams", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), `"privacy":"secret"`) { + t.Errorf("create body = %s, want secret privacy", body) + } + writeJSON(w, http.StatusCreated, map[string]any{"slug": "eng"}) + }) + slug, err := f.client(t, 1).ResolveTeam(context.Background(), "eng", "secret", true) + if err != nil || slug != "eng" { + t.Fatalf("ResolveTeam = %q, %v", slug, err) + } + }) +} + +func TestSyncTeamMembers(t *testing.T) { + f := newFakeGitHub(t) + // github lists child team members as members of the parent team + f.json("GET /orgs/acme/teams/eng/members", http.StatusOK, []map[string]any{ + {"login": "alice"}, {"login": "carol"}, {"login": "dave"}, {"login": "erin"}, {"login": "gone"}, {"login": "subby"}, + }) + f.json("GET /orgs/acme/teams/eng/invitations", http.StatusOK, []map[string]any{{"login": "frank"}}) + f.json("GET /orgs/acme/teams/eng/teams", http.StatusOK, []map[string]any{{"slug": "eng-sub"}}) + f.json("GET /orgs/acme/teams/eng-sub/members", http.StatusOK, []map[string]any{{"login": "subby"}}) + f.json("PUT /orgs/acme/teams/eng/memberships/{user}", http.StatusOK, map[string]any{"state": "active"}) + f.json("GET /orgs/acme/memberships/carol", http.StatusOK, map[string]any{"state": "active"}) + f.json("GET /orgs/acme/memberships/gone", http.StatusOK, map[string]any{"state": "active"}) + f.json("GET /orgs/acme/memberships/dave", http.StatusNotFound, map[string]any{"message": "Not Found"}) + f.handle("DELETE /orgs/acme/teams/eng/memberships/{user}", func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("user") == "gone" { + writeJSON(w, http.StatusNotFound, map[string]any{"message": "Not Found"}) + return + } + w.WriteHeader(http.StatusNoContent) + }) + c := f.client(t, 1) + + desired := []string{"Alice", "bob", "BOB", "frank", "erin", "bad/../name"} + result, err := c.SyncTeamMembers(context.Background(), "eng", desired, 0.75) + if err != nil { + t.Fatal(err) + } + + if fmt.Sprint(result.MembersAdded) != "[bob]" { + t.Errorf("added = %v, want [bob]", result.MembersAdded) + } + if fmt.Sprint(result.MembersRemoved) != "[carol]" { + t.Errorf("removed = %v, want [carol]; a 404 is not a removal", result.MembersRemoved) + } + if fmt.Sprint(result.MembersSkippedExternal) != "[dave]" { + t.Errorf("skipped external = %v, want [dave]", result.MembersSkippedExternal) + } + if len(result.Errors) != 1 || !strings.Contains(result.Errors[0], "bad/../name") { + t.Errorf("errors = %v, want the invalid username", result.Errors) + } + if fmt.Sprint(result.Members) != "[alice dave erin gone subby bob]" { + t.Errorf("members = %v", result.Members) + } + if f.called("PUT /orgs/acme/teams/eng/memberships/frank") != 0 { + t.Error("pending invitee re-invited") + } + if f.called("DELETE /orgs/acme/teams/eng/memberships/subby") != 0 { + t.Error("child team member removed from the parent team") + } +} + +func TestSyncTeamMembers_SafetyThreshold(t *testing.T) { + f := newFakeGitHub(t) + f.json("GET /orgs/acme/teams/eng/members", http.StatusOK, []map[string]any{ + {"login": "alice"}, {"login": "bob"}, {"login": "carol"}, + }) + f.json("GET /orgs/acme/teams/eng/invitations", http.StatusOK, []any{}) + f.json("GET /orgs/acme/teams/eng/teams", http.StatusOK, []any{}) + c := f.client(t, 1) + + result, err := c.SyncTeamMembers(context.Background(), "eng", nil, 0.5) + if err != nil { + t.Fatal(err) + } + if len(result.MembersRemoved) != 0 || len(result.Errors) != 1 || !strings.Contains(result.Errors[0], "safety threshold") { + t.Fatalf("result = %+v, want removal refused", result) + } + if len(result.Members) != 3 { + t.Errorf("members = %v, want the unchanged team", result.Members) + } +} + +func TestTruncate(t *testing.T) { + tests := []struct { + in string + max int + want string + }{ + {"short", 10, "short"}, + {"exactly ten", 11, "exactly ten"}, + {"this is too long", 10, "this is..."}, + {"héllo wörld", 8, "héllo..."}, + {"abcdef", 3, "abc"}, + } + for _, tt := range tests { + if got := truncate(tt.in, tt.max); got != tt.want { + t.Errorf("truncate(%q, %d) = %q, want %q", tt.in, tt.max, got, tt.want) + } + } +} diff --git a/internal/github/client/pr.go b/internal/github/client/pr.go index 065c851..38d4388 100644 --- a/internal/github/client/pr.go +++ b/internal/github/client/pr.go @@ -2,212 +2,263 @@ package client import ( "context" + "errors" "fmt" + "slices" + "strings" + + "github.com/google/go-github/v93/github" - "github.com/cockroachdb/errors" "github.com/cruxstack/github-ops-app/internal/domain" - "github.com/google/go-github/v79/github" ) -// CheckPRCompliance verifies if a merged PR met branch protection -// requirements. checks review requirements, status checks, and user bypass -// permissions. +// CheckPRCompliance verifies whether a merged PR met the requirements of its +// base branch: the approving reviews and status checks required by legacy +// branch protection and by repository rulesets. When it did not, the +// merger's repository role is recorded as the likely reason it could merge. func (c *Client) CheckPRCompliance(ctx context.Context, owner, repo string, prNumber int) (*domain.PRComplianceResult, error) { - if err := c.ensureValidToken(ctx); err != nil { - return nil, err - } - - pr, _, err := c.client.PullRequests.Get(ctx, owner, repo, prNumber) + pr, _, err := c.gh.PullRequests.Get(ctx, owner, repo, prNumber) if err != nil { - return nil, errors.Wrapf(err, "failed to fetch pr #%d from %s/%s", prNumber, owner, repo) + return nil, fmt.Errorf("failed to fetch pr #%d from %s/%s: %w", prNumber, owner, repo, err) } - - if pr == nil { - return nil, errors.Wrapf(domain.ErrMissingPRData, "pr #%d returned nil", prNumber) - } - - if pr.Base == nil || pr.Base.Ref == nil { - return nil, errors.Wrapf(domain.ErrMissingPRData, "pr #%d missing base branch", prNumber) + if pr.GetBase().GetRef() == "" { + return nil, fmt.Errorf("pr #%d in %s/%s has no base branch: %w", prNumber, owner, repo, domain.ErrMissingPRData) } - baseBranch := *pr.Base.Ref - result := &domain.PRComplianceResult{ PR: pr, - BaseBranch: baseBranch, - Violations: []domain.ComplianceViolation{}, + BaseBranch: pr.GetBase().GetRef(), } - // fetch legacy branch protection rules - protection, _, err := c.client.Repositories.GetBranchProtection(ctx, owner, repo, baseBranch) - if err == nil { + protection, _, err := c.gh.Repositories.GetBranchProtection(ctx, owner, repo, result.BaseBranch) + switch { + case err == nil: result.Protection = protection + case errors.Is(err, github.ErrBranchNotProtected): + default: + return nil, fmt.Errorf("failed to fetch branch protection for '%s' in %s/%s: %w", result.BaseBranch, owner, repo, err) } - // fetch repository rulesets for the branch - branchRules, _, err := c.client.Repositories.GetRulesForBranch(ctx, owner, repo, baseBranch, nil) - if err == nil { - result.BranchRules = branchRules + result.BranchRules, err = c.branchRules(ctx, owner, repo, result.BaseBranch) + if err != nil { + return nil, err } if err := c.checkReviewRequirements(ctx, owner, repo, pr, result); err != nil { - return nil, errors.Wrapf(err, "failed to check review requirements for pr #%d", prNumber) + return nil, err } - if err := c.checkStatusRequirements(ctx, owner, repo, pr, result); err != nil { - return nil, errors.Wrapf(err, "failed to check status requirements for pr #%d", prNumber) + return nil, err + } + if result.HasViolations() { + c.checkUserBypassPermission(ctx, owner, repo, pr, result) } - - c.checkUserBypassPermission(ctx, owner, repo, pr, result) return result, nil } -// checkReviewRequirements validates that PR had required approving reviews. -// checks both legacy branch protection and repository rulesets. -func (c *Client) checkReviewRequirements(ctx context.Context, owner, repo string, pr *github.PullRequest, result *domain.PRComplianceResult) error { - requiredApprovals := 0 - - // check legacy branch protection - if result.Protection != nil && result.Protection.RequiredPullRequestReviews != nil { - requiredApprovals = result.Protection.RequiredPullRequestReviews.RequiredApprovingReviewCount +// branchRules returns the active ruleset rules for a branch, across all +// pages. +func (c *Client) branchRules(ctx context.Context, owner, repo, branch string) (*github.BranchRules, error) { + all := &github.BranchRules{} + opts := &github.ListOptions{PerPage: 100} + for { + rules, resp, err := c.gh.Repositories.ListRulesForBranch(ctx, owner, repo, branch, opts) + if err != nil { + return nil, fmt.Errorf("failed to fetch rules for branch '%s' in %s/%s: %w", branch, owner, repo, err) + } + if rules != nil { + all.PullRequest = append(all.PullRequest, rules.PullRequest...) + all.RequiredStatusChecks = append(all.RequiredStatusChecks, rules.RequiredStatusChecks...) + } + if resp.NextPage == 0 { + return all, nil + } + opts.Page = resp.NextPage } +} - // check repository rulesets (use the highest requirement) +// requiredApprovals returns the highest approving review count required by +// branch protection or any ruleset. +func requiredApprovals(result *domain.PRComplianceResult) int { + required := 0 + if reviews := result.Protection.GetRequiredPullRequestReviews(); reviews != nil { + required = reviews.RequiredApprovingReviewCount + } if result.BranchRules != nil { for _, rule := range result.BranchRules.PullRequest { - if rule.Parameters.RequiredApprovingReviewCount > requiredApprovals { - requiredApprovals = rule.Parameters.RequiredApprovingReviewCount - } + required = max(required, rule.Parameters.RequiredApprovingReviewCount) } } + return required +} - if requiredApprovals == 0 { +// checkReviewRequirements records a violation if the PR had fewer approving +// reviews than required. A reviewer counts once, by their latest review that +// approved, requested changes, or was dismissed; comments don't change it. +func (c *Client) checkReviewRequirements(ctx context.Context, owner, repo string, pr *github.PullRequest, result *domain.PRComplianceResult) error { + required := requiredApprovals(result) + if required == 0 { return nil } - reviews, _, err := c.client.PullRequests.ListReviews(ctx, owner, repo, *pr.Number, &github.ListOptions{PerPage: 100}) - if err != nil { - return errors.Wrapf(err, "failed to list reviews for pr #%d in %s/%s", *pr.Number, owner, repo) - } - - // deduplicate reviews per user, keeping only the latest state - latestReviewByUser := make(map[string]string) - for _, review := range reviews { - if review.User == nil || review.User.Login == nil || review.State == nil { - continue + latest := make(map[string]string) + opts := &github.ListOptions{PerPage: 100} + for review, err := range c.gh.PullRequests.ListReviewsIter(ctx, owner, repo, pr.GetNumber(), opts) { + if err != nil { + return fmt.Errorf("failed to list reviews for pr #%d in %s/%s: %w", pr.GetNumber(), owner, repo, err) + } + login := strings.ToLower(review.GetUser().GetLogin()) + switch state := review.GetState(); state { + case "APPROVED", "CHANGES_REQUESTED", "DISMISSED": + if login != "" { + latest[login] = state + } } - latestReviewByUser[*review.User.Login] = *review.State } - approvedCount := 0 - for _, state := range latestReviewByUser { + approved := 0 + for _, state := range latest { if state == "APPROVED" { - approvedCount++ + approved++ } } - if approvedCount < requiredApprovals { + if approved < required { result.Violations = append(result.Violations, domain.ComplianceViolation{ - Type: "insufficient_reviews", - Description: fmt.Sprintf("required %d approving reviews, had %d", requiredApprovals, approvedCount), + Type: domain.ViolationInsufficientReviews, + Description: fmt.Sprintf("required %d approving reviews, had %d", required, approved), }) } - return nil } -// checkStatusRequirements validates that required status checks passed. -// checks both legacy branch protection and repository rulesets. -func (c *Client) checkStatusRequirements(ctx context.Context, owner, repo string, pr *github.PullRequest, result *domain.PRComplianceResult) error { - if pr.Head == nil || pr.Head.SHA == nil { - return nil - } - - // collect required checks from both sources - requiredChecks := make(map[string]bool) - - // check legacy branch protection - if result.Protection != nil && - result.Protection.RequiredStatusChecks != nil && - result.Protection.RequiredStatusChecks.Contexts != nil { - for _, check := range *result.Protection.RequiredStatusChecks.Contexts { - requiredChecks[check] = true +// requiredChecks returns the names of the status checks required by branch +// protection or any ruleset, sorted. +func requiredChecks(result *domain.PRComplianceResult) []string { + set := make(map[string]bool) + if checks := result.Protection.GetRequiredStatusChecks(); checks != nil { + if checks.Contexts != nil { + for _, name := range *checks.Contexts { + set[name] = true + } + } + if checks.Checks != nil { + for _, check := range *checks.Checks { + if check != nil { + set[check.Context] = true + } + } } } - - // check repository rulesets if result.BranchRules != nil { for _, rule := range result.BranchRules.RequiredStatusChecks { for _, check := range rule.Parameters.RequiredStatusChecks { - requiredChecks[check.Context] = true + if check != nil { + set[check.Context] = true + } } } } + delete(set, "") - if len(requiredChecks) == 0 { + names := make([]string, 0, len(set)) + for name := range set { + names = append(names, name) + } + slices.Sort(names) + return names +} + +// passingCheckConclusions are the check run conclusions GitHub accepts for a +// required check. +var passingCheckConclusions = map[string]bool{ + "success": true, + "neutral": true, + "skipped": true, +} + +// checkStatusRequirements records a violation for each required status check +// that did not pass on the PR's head commit, as a commit status or a check +// run. +func (c *Client) checkStatusRequirements(ctx context.Context, owner, repo string, pr *github.PullRequest, result *domain.PRComplianceResult) error { + required := requiredChecks(result) + if len(required) == 0 { return nil } - combinedStatus, _, err := c.client.Repositories.GetCombinedStatus(ctx, owner, repo, *pr.Head.SHA, nil) - if err != nil { - return errors.Wrapf(err, "failed to get combined status for sha '%s' in %s/%s", *pr.Head.SHA, owner, repo) + sha := pr.GetHead().GetSHA() + if sha == "" { + return fmt.Errorf("pr #%d in %s/%s has no head sha: %w", pr.GetNumber(), owner, repo, domain.ErrMissingPRData) } - passedChecks := make(map[string]bool) - for _, status := range combinedStatus.Statuses { - if status.Context != nil && status.State != nil && *status.State == "success" { - passedChecks[*status.Context] = true + passed := make(map[string]bool) + + opts := &github.ListOptions{PerPage: 100} + for { + combined, resp, err := c.gh.Repositories.GetCombinedStatus(ctx, owner, repo, sha, opts) + if err != nil { + return fmt.Errorf("failed to get combined status for sha '%s' in %s/%s: %w", sha, owner, repo, err) + } + for _, status := range combined.Statuses { + if status.GetState() == "success" { + passed[status.GetContext()] = true + } + } + if resp.NextPage == 0 { + break } + opts.Page = resp.NextPage } - // also check GitHub Actions check runs (modern repos use these instead - // of commit statuses) - checkRuns, _, err := c.client.Checks.ListCheckRunsForRef(ctx, owner, repo, *pr.Head.SHA, &github.ListCheckRunsOptions{ + runOpts := &github.ListCheckRunsOptions{ + Filter: new("latest"), ListOptions: github.ListOptions{PerPage: 100}, - }) - if err == nil && checkRuns != nil { - for _, run := range checkRuns.CheckRuns { - if run.Name != nil && run.Conclusion != nil && *run.Conclusion == "success" { - passedChecks[*run.Name] = true - } + } + for run, err := range c.gh.Checks.ListCheckRunsForRefIter(ctx, owner, repo, sha, runOpts) { + if err != nil { + return fmt.Errorf("failed to list check runs for sha '%s' in %s/%s: %w", sha, owner, repo, err) + } + if passingCheckConclusions[run.GetConclusion()] { + passed[run.GetName()] = true } } - for required := range requiredChecks { - if !passedChecks[required] { + for _, name := range required { + if !passed[name] { result.Violations = append(result.Violations, domain.ComplianceViolation{ - Type: "missing_status_check", - Description: fmt.Sprintf("required check '%s' did not pass", required), + Type: domain.ViolationMissingStatusCheck, + Description: fmt.Sprintf("required check '%s' did not pass", name), }) } } - return nil } -// checkUserBypassPermission checks if the user who merged the PR has admin or -// maintainer permissions allowing bypass. +// checkUserBypassPermission records whether the user who merged the PR has a +// repository role that can bypass branch protection. It is best effort: the +// merger may instead be a ruleset bypass actor, or a bot. func (c *Client) checkUserBypassPermission(ctx context.Context, owner, repo string, pr *github.PullRequest, result *domain.PRComplianceResult) { - if pr.MergedBy == nil || pr.MergedBy.Login == nil { + mergedBy := pr.GetMergedBy().GetLogin() + if mergedBy == "" || !domain.IsValidGitHubLogin(mergedBy) { return } - mergedBy := *pr.MergedBy.Login - - permissionLevel, _, err := c.client.Repositories.GetPermissionLevel(ctx, owner, repo, mergedBy) + level, _, err := c.gh.Repositories.GetPermissionLevel(ctx, owner, repo, mergedBy) if err != nil { return } - if permissionLevel.Permission != nil { - switch perm := *permissionLevel.Permission; perm { - case "admin": - result.UserHasBypass = true - result.UserBypassReason = "repository admin" - case "maintain": - result.UserHasBypass = true - result.UserBypassReason = "repository maintainer" - } + role := level.GetRoleName() + if role == "" { + role = level.GetPermission() + } + switch role { + case "admin": + result.UserHasBypass = true + result.UserBypassReason = "repository admin" + case "maintain": + result.UserHasBypass = true + result.UserBypassReason = "repository maintainer" } } diff --git a/internal/github/client/security_alerts.go b/internal/github/client/security_alerts.go index fdeca32..502c6c1 100644 --- a/internal/github/client/security_alerts.go +++ b/internal/github/client/security_alerts.go @@ -2,283 +2,193 @@ package client import ( "context" - "math" + "fmt" + "strings" "time" "unicode/utf8" - "github.com/cockroachdb/errors" + "github.com/google/go-github/v93/github" + "github.com/cruxstack/github-ops-app/internal/domain" - "github.com/google/go-github/v79/github" ) -// severityRank maps severity strings to numeric values for comparison. -// higher values indicate greater severity. -var severityRank = map[string]int{ - "critical": 4, - "high": 3, - "medium": 2, - "low": 1, -} +// Alert types in a security alerts report. +const ( + AlertTypeDependabot = "dependabot" + AlertTypeCodeScanning = "code_scanning" + AlertTypeSecretScanning = "secret_scanning" +) + +// secretScanningSeverity is the severity given to secret scanning alerts, +// which have none in the GitHub API. They are reported unless the minimum +// severity is critical. +const secretScanningSeverity = domain.SeverityHigh -// meetsMinSeverity returns true if the alert severity is at or above -// the minimum threshold. -func meetsMinSeverity(alertSeverity, minSeverity string) bool { - return severityRank[alertSeverity] >= severityRank[minSeverity] +// maxSummaryLen is the longest alert summary kept, in runes. +const maxSummaryLen = 120 + +// codeScanningRuleSeverity maps the severity of a code scanning rule without +// a security severity level to one. +var codeScanningRuleSeverity = map[string]string{ + "error": domain.SeverityHigh, + "warning": domain.SeverityMedium, + "note": domain.SeverityLow, } -// ListSecurityAlerts fetches open security alerts across the org that -// are older than minAgeDays and at or above minSeverity. covers -// dependabot, code scanning, and secret scanning alerts. +// ListSecurityAlerts fetches open security alerts across the org that were +// created more than minAgeDays ago and are at or above minSeverity. It +// covers dependabot, code scanning, and secret scanning alerts. A source that +// fails is recorded in the report's errors and the others still run. func (c *Client) ListSecurityAlerts(ctx context.Context, minAgeDays int, minSeverity string) (*domain.SecurityAlertsReport, error) { - if err := c.ensureValidToken(ctx); err != nil { - return nil, err - } - - cutoff := time.Now().AddDate(0, 0, -minAgeDays) + now := time.Now() + cutoff := now.AddDate(0, 0, -minAgeDays) report := &domain.SecurityAlertsReport{ MinAgeDays: minAgeDays, MinSeverity: minSeverity, AlertsByRepo: make(map[string][]domain.SecurityAlert), } + add := func(alert domain.SecurityAlert) { + alert.AgeDays = int(now.Sub(alert.CreatedAt).Hours() / 24) + alert.Summary = truncate(alert.Summary, maxSummaryLen) + report.AlertsByRepo[alert.Repo] = append(report.AlertsByRepo[alert.Repo], alert) + report.TotalAlerts++ + } - c.fetchDependabotAlerts(ctx, cutoff, minSeverity, report) - c.fetchCodeScanningAlerts(ctx, cutoff, minSeverity, report) - c.fetchSecretScanningAlerts(ctx, cutoff, report) - - total := 0 - for _, alerts := range report.AlertsByRepo { - total += len(alerts) + for _, fetch := range []func(context.Context, time.Time, string, func(domain.SecurityAlert)) error{ + c.fetchDependabotAlerts, + c.fetchCodeScanningAlerts, + c.fetchSecretScanningAlerts, + } { + if err := fetch(ctx, cutoff, minSeverity, add); err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + report.Errors = append(report.Errors, err.Error()) + } } - report.TotalAlerts = total return report, nil } -// fetchDependabotAlerts fetches open dependabot alerts for the org, -// filtering by severity and age. results are sorted by creation date -// ascending so the pagination early-exit works correctly: once a page -// contains only alerts newer than the cutoff, all subsequent pages -// will also be newer. -func (c *Client) fetchDependabotAlerts(ctx context.Context, cutoff time.Time, minSeverity string, report *domain.SecurityAlertsReport) { - state := "open" +// The fetchers below request alerts oldest first, so they stop paging at +// the first alert created after the cutoff. + +func (c *Client) fetchDependabotAlerts(ctx context.Context, cutoff time.Time, minSeverity string, add func(domain.SecurityAlert)) error { opts := &github.ListAlertsOptions{ - State: &state, - Sort: github.Ptr("created"), - Direction: github.Ptr("asc"), - ListCursorOptions: github.ListCursorOptions{ - PerPage: 100, - }, + State: new("open"), + Sort: new("created"), + Direction: new("asc"), + ListCursorOptions: github.ListCursorOptions{PerPage: 100}, } - - for { - alerts, resp, err := c.client.Dependabot.ListOrgAlerts(ctx, c.org, opts) + for alert, err := range c.gh.Dependabot.ListOrgAlertsIter(ctx, c.org, opts) { if err != nil { - report.Errors = append(report.Errors, - errors.Wrapf(err, "failed to fetch dependabot alerts for org '%s'", c.org).Error()) - return + return fmt.Errorf("failed to fetch dependabot alerts for org '%s': %w", c.org, err) } - - pastCutoff := false - for _, alert := range alerts { - created := alert.GetCreatedAt().Time - if !created.Before(cutoff) { - pastCutoff = true - break - } - - sev := "" - if alert.SecurityAdvisory != nil { - sev = alert.SecurityAdvisory.GetSeverity() - } - if sev == "" && alert.SecurityVulnerability != nil && - alert.SecurityVulnerability.Severity != nil { - sev = *alert.SecurityVulnerability.Severity - } - - if !meetsMinSeverity(sev, minSeverity) { - continue - } - - repo := "" - if alert.Repository != nil { - repo = alert.Repository.GetFullName() - } - - ageDays := int(math.Floor( - time.Since(created).Hours() / 24, - )) - - report.AlertsByRepo[repo] = append( - report.AlertsByRepo[repo], - domain.SecurityAlert{ - Type: "dependabot", - Repo: repo, - Number: alert.GetNumber(), - Severity: sev, - Summary: truncate(alert.SecurityAdvisory.GetSummary(), 120), - HTMLURL: alert.GetHTMLURL(), - CreatedAt: created, - AgeDays: ageDays, - }, - ) + created := alert.GetCreatedAt().Time + if !created.Before(cutoff) { + break } - if resp.NextPageToken == "" || pastCutoff { - break + severity := alert.GetSecurityAdvisory().GetSeverity() + if severity == "" { + severity = alert.GetSecurityVulnerability().GetSeverity() + } + if !domain.MeetsSeverity(severity, minSeverity) { + continue } - opts.ListCursorOptions.After = resp.NextPageToken + + add(domain.SecurityAlert{ + Type: AlertTypeDependabot, + Repo: alert.GetRepository().GetFullName(), + Number: alert.GetNumber(), + Severity: strings.ToLower(severity), + Summary: alert.GetSecurityAdvisory().GetSummary(), + HTMLURL: alert.GetHTMLURL(), + CreatedAt: created, + }) } + return nil } -// fetchCodeScanningAlerts fetches open code scanning alerts for the -// org, filtering by severity and age. results are sorted ascending -// by creation date so the early-exit optimization works correctly. -// the code scanning API only accepts a single severity value, so we -// fetch all severities and filter client-side via meetsMinSeverity. -func (c *Client) fetchCodeScanningAlerts(ctx context.Context, cutoff time.Time, minSeverity string, report *domain.SecurityAlertsReport) { - opts := &github.AlertListOptions{ - State: "open", - Sort: "created", - Direction: "asc", - ListCursorOptions: github.ListCursorOptions{ - PerPage: 100, - }, +// codeScanningSeverity returns an alert's security severity level, or a +// severity mapped from its rule's severity for rules without one. +func codeScanningSeverity(alert *github.Alert) string { + if level := alert.GetRule().GetSecuritySeverityLevel(); level != "" { + return strings.ToLower(level) } + return codeScanningRuleSeverity[strings.ToLower(alert.GetRule().GetSeverity())] +} - for { - alerts, resp, err := c.client.CodeScanning.ListAlertsForOrg(ctx, c.org, opts) +func (c *Client) fetchCodeScanningAlerts(ctx context.Context, cutoff time.Time, minSeverity string, add func(domain.SecurityAlert)) error { + // the API filters by a single severity, so severities are filtered here + opts := &github.AlertListOptions{ + State: "open", + Sort: "created", + Direction: "asc", + ListCursorOptions: github.ListCursorOptions{PerPage: 100}, + } + for alert, err := range c.gh.CodeScanning.ListAlertsForOrgIter(ctx, c.org, opts) { if err != nil { - report.Errors = append(report.Errors, - errors.Wrapf(err, "failed to fetch code scanning alerts for org '%s'", c.org).Error()) - return + return fmt.Errorf("failed to fetch code scanning alerts for org '%s': %w", c.org, err) } - - pastCutoff := false - for _, alert := range alerts { - created := alert.GetCreatedAt().Time - if !created.Before(cutoff) { - pastCutoff = true - break - } - - sev := "" - if alert.Rule != nil { - sev = alert.Rule.GetSecuritySeverityLevel() - if sev == "" { - sev = alert.Rule.GetSeverity() - } - } - - // alerts with unknown severity are included - // conservatively; we only exclude alerts that have a - // known severity below the threshold. - if sev != "" && !meetsMinSeverity(sev, minSeverity) { - continue - } - - repo := "" - if alert.Repository != nil { - repo = alert.Repository.GetFullName() - } - - summary := "" - if alert.Rule != nil { - summary = alert.Rule.GetDescription() - } - - ageDays := int(math.Floor( - time.Since(created).Hours() / 24, - )) - - report.AlertsByRepo[repo] = append( - report.AlertsByRepo[repo], - domain.SecurityAlert{ - Type: "code_scanning", - Repo: repo, - Number: int(alert.GetNumber()), - Severity: sev, - Summary: truncate(summary, 120), - HTMLURL: alert.GetHTMLURL(), - CreatedAt: created, - AgeDays: ageDays, - }, - ) + created := alert.GetCreatedAt().Time + if !created.Before(cutoff) { + break } - if resp.NextPageToken == "" || pastCutoff { - break + severity := codeScanningSeverity(alert) + if !domain.MeetsSeverity(severity, minSeverity) { + continue } - opts.ListCursorOptions.After = resp.NextPageToken + + add(domain.SecurityAlert{ + Type: AlertTypeCodeScanning, + Repo: alert.GetRepository().GetFullName(), + Number: alert.GetNumber(), + Severity: severity, + Summary: alert.GetRule().GetDescription(), + HTMLURL: alert.GetHTMLURL(), + CreatedAt: created, + }) } + return nil } -// fetchSecretScanningAlerts fetches open secret scanning alerts for -// the org, filtering by age only. results are sorted ascending by -// creation date so the early-exit optimization works correctly. -// secret scanning alerts have no severity in the GitHub API, so we -// assign "high" to ensure they are included when minSeverity is -// "high" or below but excluded when minSeverity is "critical". -func (c *Client) fetchSecretScanningAlerts(ctx context.Context, cutoff time.Time, report *domain.SecurityAlertsReport) { - opts := &github.SecretScanningAlertListOptions{ - State: "open", - Sort: "created", - Direction: "asc", - ListCursorOptions: github.ListCursorOptions{ - PerPage: 100, - }, +func (c *Client) fetchSecretScanningAlerts(ctx context.Context, cutoff time.Time, minSeverity string, add func(domain.SecurityAlert)) error { + if !domain.MeetsSeverity(secretScanningSeverity, minSeverity) { + return nil } - for { - alerts, resp, err := c.client.SecretScanning.ListAlertsForOrg(ctx, c.org, opts) + opts := &github.SecretScanningAlertListOptions{ + State: "open", + Sort: "created", + Direction: "asc", + ListCursorOptions: github.ListCursorOptions{PerPage: 100}, + } + for alert, err := range c.gh.SecretScanning.ListAlertsForOrgIter(ctx, c.org, opts) { if err != nil { - report.Errors = append(report.Errors, - errors.Wrapf(err, "failed to fetch secret scanning alerts for org '%s'", c.org).Error()) - return - } - - pastCutoff := false - for _, alert := range alerts { - created := alert.GetCreatedAt().Time - if !created.Before(cutoff) { - pastCutoff = true - break - } - - repo := "" - if alert.Repository != nil { - repo = alert.Repository.GetFullName() - } - - ageDays := int(math.Floor( - time.Since(created).Hours() / 24, - )) - - report.AlertsByRepo[repo] = append( - report.AlertsByRepo[repo], - domain.SecurityAlert{ - Type: "secret_scanning", - Repo: repo, - Number: alert.GetNumber(), - Severity: "high", - Summary: truncate(alert.GetSecretTypeDisplayName(), 120), - HTMLURL: alert.GetHTMLURL(), - CreatedAt: created, - AgeDays: ageDays, - }, - ) + return fmt.Errorf("failed to fetch secret scanning alerts for org '%s': %w", c.org, err) } - - if resp.NextPageToken == "" || pastCutoff { + created := alert.GetCreatedAt().Time + if !created.Before(cutoff) { break } - opts.ListCursorOptions.After = resp.NextPageToken + + add(domain.SecurityAlert{ + Type: AlertTypeSecretScanning, + Repo: alert.GetRepository().GetFullName(), + Number: alert.GetNumber(), + Severity: secretScanningSeverity, + Summary: alert.GetSecretTypeDisplayName(), + HTMLURL: alert.GetHTMLURL(), + CreatedAt: created, + }) } + return nil } -// truncate shortens a string to max runes, appending "..." if -// needed. operates on rune count so multi-byte characters are not -// split mid-codepoint. +// truncate shortens s to maxLen runes, ending with "..." when it was cut. func truncate(s string, maxLen int) string { if utf8.RuneCountInString(s) <= maxLen { return s diff --git a/internal/github/client/security_alerts_test.go b/internal/github/client/security_alerts_test.go deleted file mode 100644 index fba2e80..0000000 --- a/internal/github/client/security_alerts_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package client - -import "testing" - -func TestMeetsMinSeverity(t *testing.T) { - tests := []struct { - alertSev string - minSev string - want bool - }{ - {"critical", "critical", true}, - {"critical", "high", true}, - {"critical", "medium", true}, - {"critical", "low", true}, - {"high", "critical", false}, - {"high", "high", true}, - {"high", "medium", true}, - {"high", "low", true}, - {"medium", "critical", false}, - {"medium", "high", false}, - {"medium", "medium", true}, - {"medium", "low", true}, - {"low", "critical", false}, - {"low", "high", false}, - {"low", "medium", false}, - {"low", "low", true}, - {"", "high", false}, - {"high", "", true}, - {"unknown", "high", false}, - } - - for _, tt := range tests { - t.Run(tt.alertSev+"_vs_"+tt.minSev, func(t *testing.T) { - got := meetsMinSeverity(tt.alertSev, tt.minSev) - if got != tt.want { - t.Errorf("meetsMinSeverity(%q, %q) = %v, want %v", - tt.alertSev, tt.minSev, got, tt.want) - } - }) - } -} - -func TestTruncate(t *testing.T) { - tests := []struct { - name string - input string - maxLen int - want string - }{ - {"short", "short", 10, "short"}, - {"exact", "exactly10!", 10, "exactly10!"}, - {"long", "this is too long", 10, "this is..."}, - {"exact_boundary", "abc", 3, "abc"}, - {"over_boundary", "abcd", 3, "abc"}, - {"empty", "", 5, ""}, - {"unicode_under", "héllo", 10, "héllo"}, - {"unicode_truncate", "日本語のテキスト", 5, "日本..."}, - {"emoji", "🔥🔥🔥🔥🔥", 4, "🔥..."}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := truncate(tt.input, tt.maxLen) - if got != tt.want { - t.Errorf("truncate(%q, %d) = %q, want %q", - tt.input, tt.maxLen, got, tt.want) - } - }) - } -} diff --git a/internal/github/client/teams.go b/internal/github/client/teams.go index 35a9971..3562b21 100644 --- a/internal/github/client/teams.go +++ b/internal/github/client/teams.go @@ -5,82 +5,147 @@ import ( "fmt" "strings" - "github.com/cockroachdb/errors" + "github.com/google/go-github/v93/github" + "github.com/cruxstack/github-ops-app/internal/domain" - "github.com/google/go-github/v79/github" ) -// compile-time assertion -var _ domain.GitHubClient = (*Client)(nil) - -// GetOrCreateTeam fetches an existing team by slug or creates it if missing. -func (c *Client) GetOrCreateTeam(ctx context.Context, teamName, privacy string) (*github.Team, error) { - if err := c.ensureValidToken(ctx); err != nil { - return nil, err +// ResolveTeam returns the slug of the team with the given name, creating the +// team when it is missing and create is true. +func (c *Client) ResolveTeam(ctx context.Context, name, privacy string, create bool) (string, error) { + slug := domain.TeamSlug(name) + if slug == "" { + return "", fmt.Errorf("invalid team name '%s'", name) } - team, resp, err := c.client.Teams.GetTeamBySlug(ctx, c.org, teamName) + team, resp, err := c.gh.Teams.GetTeamBySlug(ctx, c.org, slug) if err == nil { - return team, nil + return team.GetSlug(), nil + } + if !isNotFound(resp) { + return "", fmt.Errorf("failed to fetch team '%s' from org '%s': %w", slug, c.org, err) } - if resp == nil || resp.StatusCode != 404 { - return nil, errors.Wrapf(err, "failed to fetch team '%s' from org '%s'", teamName, c.org) + // the slug guess can miss for names GitHub slugifies differently + if found, err := c.findTeamByName(ctx, name); err != nil || found != "" { + return found, err } - newTeam := &github.NewTeam{ - Name: teamName, - Privacy: &privacy, + if !create { + return "", fmt.Errorf("team '%s' in org '%s' does not exist and create_team_if_missing is false: %w", name, c.org, domain.ErrTeamNotFound) } - team, _, err = c.client.Teams.CreateTeam(ctx, c.org, *newTeam) + + team, _, err = c.gh.Teams.CreateTeam(ctx, c.org, github.CreateTeamRequest{ + Name: name, + Privacy: new(privacy), + }) if err != nil { - return nil, errors.Wrapf(err, "failed to create team '%s' in org '%s'", teamName, c.org) + return "", fmt.Errorf("failed to create team '%s' in org '%s': %w", name, c.org, err) } - return team, nil + return team.GetSlug(), nil } -// GetTeamMembers returns GitHub usernames of all team members. -// paginates through all results to handle large teams. -func (c *Client) GetTeamMembers(ctx context.Context, teamSlug string) ([]string, error) { - if err := c.ensureValidToken(ctx); err != nil { - return nil, err +// findTeamByName returns the slug of the team named name, ignoring case, or +// "" if there is none. +func (c *Client) findTeamByName(ctx context.Context, name string) (string, error) { + for team, err := range c.gh.Teams.ListTeamsIter(ctx, c.org, &github.ListOptions{PerPage: 100}) { + if err != nil { + return "", fmt.Errorf("failed to list teams in org '%s': %w", c.org, err) + } + if strings.EqualFold(team.GetName(), name) { + return team.GetSlug(), nil + } } + return "", nil +} - opts := &github.TeamListTeamMembersOptions{ - ListOptions: github.ListOptions{PerPage: 100}, +// GetTeamMembers returns the logins of all team members. +func (c *Client) GetTeamMembers(ctx context.Context, teamSlug string) ([]string, error) { + opts := &github.TeamListTeamMembersOptions{ListOptions: github.ListOptions{PerPage: 100}} + var members []string + for member, err := range c.gh.Teams.ListTeamMembersBySlugIter(ctx, c.org, teamSlug, opts) { + if err != nil { + return nil, fmt.Errorf("failed to list members for team '%s': %w", teamSlug, err) + } + if login := member.GetLogin(); login != "" { + members = append(members, login) + } } + return members, nil +} - var allMembers []string - for { - members, resp, err := c.client.Teams.ListTeamMembersBySlug(ctx, c.org, teamSlug, opts) +// pendingTeamInvitations returns the logins, lowercased, of users invited to +// the org through the team who have not accepted yet. +func (c *Client) pendingTeamInvitations(ctx context.Context, teamSlug string) (map[string]bool, error) { + pending := make(map[string]bool) + opts := &github.ListOptions{PerPage: 100} + for inv, err := range c.gh.Teams.ListPendingTeamInvitationsBySlugIter(ctx, c.org, teamSlug, opts) { if err != nil { - return nil, errors.Wrapf(err, "failed to list members for team '%s'", teamSlug) + return nil, fmt.Errorf("failed to list pending invitations for team '%s': %w", teamSlug, err) } - - for _, member := range members { - if member.Login != nil { - allMembers = append(allMembers, *member.Login) - } + if login := inv.GetLogin(); login != "" { + pending[strings.ToLower(login)] = true } + } + return pending, nil +} - if resp.NextPage == 0 { - break +// ListOrgMembers returns the logins of all organization members. Outside +// collaborators are not members, so they are not included. +func (c *Client) ListOrgMembers(ctx context.Context) ([]string, error) { + opts := &github.ListMembersOptions{ListOptions: github.ListOptions{PerPage: 100}} + var members []string + for member, err := range c.gh.Organizations.ListMembersIter(ctx, c.org, opts) { + if err != nil { + return nil, fmt.Errorf("failed to list members for org '%s': %w", c.org, err) + } + if login := member.GetLogin(); login != "" { + members = append(members, login) } - opts.Page = resp.NextPage } + return members, nil +} - return allMembers, nil +// isOrgMember returns true if the user is a member of the org. +func (c *Client) isOrgMember(ctx context.Context, login string) (bool, error) { + membership, resp, err := c.gh.Organizations.GetOrgMembership(ctx, login, c.org) + if err != nil { + if isNotFound(resp) { + return false, nil + } + return false, fmt.Errorf("failed to check org membership for user '%s': %w", login, err) + } + return membership != nil, nil } -// SyncTeamMembers adds and removes members to match desired state. -// collects errors for individual operations but continues processing. skips -// removal of external collaborators (outside org members). applies safety -// threshold to prevent mass removal during outages. -func (c *Client) SyncTeamMembers(ctx context.Context, teamSlug string, desiredMembers []string, safetyThreshold float64) (*domain.TeamSyncResult, error) { - if err := c.ensureValidToken(ctx); err != nil { - return nil, err +// childTeamMembers returns the logins, lowercased, of the members of a +// team's child teams. GitHub lists them as members of the parent team, but +// they can only be removed from the child team. +func (c *Client) childTeamMembers(ctx context.Context, teamSlug string) (map[string]bool, error) { + members := make(map[string]bool) + for child, err := range c.gh.Teams.ListChildTeamsByParentSlugIter(ctx, c.org, teamSlug, &github.ListOptions{PerPage: 100}) { + if err != nil { + return nil, fmt.Errorf("failed to list child teams of team '%s': %w", teamSlug, err) + } + // a child team's member list includes its own child teams + logins, err := c.GetTeamMembers(ctx, child.GetSlug()) + if err != nil { + return nil, err + } + for _, login := range logins { + members[strings.ToLower(login)] = true + } } + return members, nil +} +// SyncTeamMembers adds and removes members so the team matches +// desiredMembers. Errors for individual users are collected and the rest +// continue. Members of child teams and users who are not org members are not +// removed, and no one is removed if more than safetyThreshold of the team's +// direct members would be, which guards against an empty or partial member +// list from Okta. +func (c *Client) SyncTeamMembers(ctx context.Context, teamSlug string, desiredMembers []string, safetyThreshold float64) (*domain.TeamSyncResult, error) { result := &domain.TeamSyncResult{ TeamName: teamSlug, MembersAdded: []string{}, @@ -89,71 +154,110 @@ func (c *Client) SyncTeamMembers(ctx context.Context, teamSlug string, desiredMe Errors: []string{}, } - currentMembers, err := c.GetTeamMembers(ctx, teamSlug) + listed, err := c.GetTeamMembers(ctx, teamSlug) if err != nil { - return nil, errors.Wrapf(err, "failed to fetch current members for team '%s'", teamSlug) + return nil, err + } + pending, err := c.pendingTeamInvitations(ctx, teamSlug) + if err != nil { + return nil, err + } + inChildTeam, err := c.childTeamMembers(ctx, teamSlug) + if err != nil { + return nil, err } - currentSet := make(map[string]bool) - for _, member := range currentMembers { - currentSet[strings.ToLower(member)] = true + current := make(map[string]bool, len(listed)) + var direct []string + for _, member := range listed { + key := strings.ToLower(member) + current[key] = true + if !inChildTeam[key] { + direct = append(direct, member) + } } - desiredSet := make(map[string]bool) + desired := make(map[string]bool, len(desiredMembers)) + var toAdd []string for _, member := range desiredMembers { - desiredSet[strings.ToLower(member)] = true + key := strings.ToLower(member) + if desired[key] { + continue + } + desired[key] = true + if !domain.IsValidGitHubLogin(member) { + result.Errors = append(result.Errors, fmt.Sprintf("skipped invalid github username '%s'", member)) + continue + } + if !current[key] && !pending[key] { + toAdd = append(toAdd, member) + } } - for _, desired := range desiredMembers { - if !currentSet[strings.ToLower(desired)] { - _, _, err := c.client.Teams.AddTeamMembershipBySlug(ctx, c.org, teamSlug, desired, nil) - if err != nil { - errMsg := fmt.Sprintf("failed to add '%s' to team '%s': %v", desired, teamSlug, err) - result.Errors = append(result.Errors, errMsg) - } else { - result.MembersAdded = append(result.MembersAdded, desired) - } + var toRemove []string + for _, member := range direct { + if !desired[strings.ToLower(member)] { + toRemove = append(toRemove, member) } } - var toRemove []string - for _, current := range currentMembers { - if !desiredSet[strings.ToLower(current)] { - toRemove = append(toRemove, current) + for _, login := range toAdd { + if _, _, err := c.gh.Teams.AddTeamMembershipBySlug(ctx, c.org, teamSlug, login, github.AddTeamMembershipRequest{}); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("failed to add '%s' to team '%s': %v", login, teamSlug, err)) + continue } + result.MembersAdded = append(result.MembersAdded, login) } - if len(currentMembers) > 0 { - removalRatio := float64(len(toRemove)) / float64(len(currentMembers)) - if removalRatio > safetyThreshold { - errMsg := fmt.Sprintf("refusing to remove %d of %d members (%.0f%%) as it exceeds safety threshold of %.0f%%", - len(toRemove), len(currentMembers), removalRatio*100, safetyThreshold*100) - result.Errors = append(result.Errors, errMsg) + removed := make(map[string]bool) + defer func() { + result.Members = finalMembers(listed, result.MembersAdded, removed) + }() + + if len(direct) > 0 && len(toRemove) > 0 { + ratio := float64(len(toRemove)) / float64(len(direct)) + if ratio > safetyThreshold { + result.Errors = append(result.Errors, fmt.Sprintf( + "refusing to remove %d of %d members (%.0f%%) from team '%s' as it exceeds the safety threshold of %.0f%%", + len(toRemove), len(direct), ratio*100, teamSlug, safetyThreshold*100)) return result, nil } } - for _, username := range toRemove { - isExternal, err := c.IsExternalCollaborator(ctx, username) + for _, login := range toRemove { + member, err := c.isOrgMember(ctx, login) if err != nil { - errMsg := fmt.Sprintf("failed to check if '%s' is external: %v", username, err) - result.Errors = append(result.Errors, errMsg) + result.Errors = append(result.Errors, err.Error()) continue } - - if isExternal { - result.MembersSkippedExternal = append(result.MembersSkippedExternal, username) + if !member { + result.MembersSkippedExternal = append(result.MembersSkippedExternal, login) continue } - _, err = c.client.Teams.RemoveTeamMembershipBySlug(ctx, c.org, teamSlug, username) + resp, err := c.gh.Teams.RemoveTeamMembershipBySlug(ctx, c.org, teamSlug, login) if err != nil { - errMsg := fmt.Sprintf("failed to remove '%s' from team '%s': %v", username, teamSlug, err) - result.Errors = append(result.Errors, errMsg) - } else { - result.MembersRemoved = append(result.MembersRemoved, username) + // a 404 means they already left; nothing was removed + if !isNotFound(resp) { + result.Errors = append(result.Errors, fmt.Sprintf("failed to remove '%s' from team '%s': %v", login, teamSlug, err)) + } + continue } + removed[strings.ToLower(login)] = true + result.MembersRemoved = append(result.MembersRemoved, login) } return result, nil } + +// finalMembers returns the members listed before the sync, plus those added +// and minus those removed. +func finalMembers(listed, added []string, removed map[string]bool) []string { + members := make([]string, 0, len(listed)+len(added)) + for _, login := range listed { + if !removed[strings.ToLower(login)] { + members = append(members, login) + } + } + return append(members, added...) +} diff --git a/internal/github/webhooks/webhooks.go b/internal/github/webhooks/webhooks.go index 979c36a..f803777 100644 --- a/internal/github/webhooks/webhooks.go +++ b/internal/github/webhooks/webhooks.go @@ -1,5 +1,5 @@ -// Package webhooks provides GitHub webhook event parsing and signature -// validation. Supports pull_request, team, and membership event types. +// Package webhooks validates GitHub webhook signatures and parses the +// pull_request, team, and membership events the application handles. package webhooks import ( @@ -7,250 +7,245 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" + "fmt" "strings" - "github.com/cockroachdb/errors" + "github.com/google/go-github/v93/github" + "github.com/cruxstack/github-ops-app/internal/domain" - "github.com/google/go-github/v79/github" ) +// Event types the application handles. +const ( + EventPing = "ping" + EventPullRequest = "pull_request" + EventTeam = "team" + EventMembership = "membership" +) + +// ValidateSignature verifies the X-Hub-Signature-256 HMAC of a payload. An +// empty secret is rejected, so a missing configuration never accepts +// unsigned requests. +func ValidateSignature(payload []byte, signature, secret string) error { + if secret == "" { + return fmt.Errorf("webhook secret is not configured: %w", domain.ErrConfig) + } + if signature == "" { + return domain.ErrMissingSignature + } + + got, ok := strings.CutPrefix(signature, "sha256=") + if !ok { + return fmt.Errorf("signature must start with 'sha256=': %w", domain.ErrInvalidSignature) + } + gotMAC, err := hex.DecodeString(got) + if err != nil { + return fmt.Errorf("signature is not hex: %w", domain.ErrInvalidSignature) + } + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + if !hmac.Equal(gotMAC, mac.Sum(nil)) { + return domain.ErrInvalidSignature + } + return nil +} + +// Sign returns the X-Hub-Signature-256 header value for a payload. +func Sign(payload []byte, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +} + +// sender is the part of an event that identifies who triggered it. +type sender struct { + Sender *github.User `json:"sender"` +} + +// GetSenderLogin returns the login of the user who triggered the event. +func (e sender) GetSenderLogin() string { + return e.Sender.GetLogin() +} + +// GetSenderType returns the type of the sender, such as User or Bot. +func (e sender) GetSenderType() string { + return e.Sender.GetType() +} + // PullRequestEvent represents a GitHub pull_request webhook payload. type PullRequestEvent struct { - Action string `json:"action"` - Number int `json:"number"` - PullRequest *github.PullRequest `json:"pull_request"` - Repository *github.Repository `json:"repository"` - Sender *github.User `json:"sender"` - Installation *github.Installation `json:"installation"` + sender + Action string `json:"action"` + Number int `json:"number"` + PullRequest *github.PullRequest `json:"pull_request"` + Repository *github.Repository `json:"repository"` } // TeamEvent represents a GitHub team webhook payload. type TeamEvent struct { + sender Action string `json:"action"` Team *github.Team `json:"team"` Changes *TeamChanges `json:"changes,omitempty"` - Repository *github.Repository `json:"repository,omitempty"` Organization *github.Organization `json:"organization"` - Sender *github.User `json:"sender"` - Installation *github.Installation `json:"installation"` } -// TeamChanges contains details about what changed in a team event. +// TeamChanges holds the previous values of an edited team's fields. type TeamChanges struct { - Name *TeamChangeDetail `json:"name,omitempty"` - Description *TeamChangeDetail `json:"description,omitempty"` - Privacy *TeamChangeDetail `json:"privacy,omitempty"` - Repository *TeamChangeDetail `json:"repository,omitempty"` -} - -// TeamChangeDetail contains the previous value before a change. -type TeamChangeDetail struct { - From string `json:"from"` + Name *struct { + From string `json:"from"` + } `json:"name,omitempty"` } // MembershipEvent represents a GitHub membership webhook payload. type MembershipEvent struct { + sender Action string `json:"action"` Scope string `json:"scope"` Member *github.User `json:"member"` Team *github.Team `json:"team"` Organization *github.Organization `json:"organization"` - Sender *github.User `json:"sender"` - Installation *github.Installation `json:"installation"` } -// ValidateWebhookSignature verifies HMAC-SHA256 webhook signature. -// returns error if signature is invalid or missing when required. -func ValidateWebhookSignature(payload []byte, signature string, secret string) error { - if secret == "" { - if signature != "" { - return domain.ErrUnexpectedSignature - } - return nil +func parse(payload []byte, event any, kind string) error { + if err := json.Unmarshal(payload, event); err != nil { + return fmt.Errorf("failed to unmarshal %s event: %w", kind, errors.Join(domain.ErrInvalidPayload, err)) } - - if signature == "" { - return domain.ErrMissingSignature - } - - if !strings.HasPrefix(signature, "sha256=") { - return errors.Wrap(domain.ErrInvalidSignature, "must start with 'sha256='") - } - - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write(payload) - expectedMAC := hex.EncodeToString(mac.Sum(nil)) - expectedSignature := "sha256=" + expectedMAC - - if !hmac.Equal([]byte(signature), []byte(expectedSignature)) { - return errors.Wrap(domain.ErrInvalidSignature, "computed signature does not match") - } - return nil } +func missing(kind, field string) error { + return fmt.Errorf("%s event is missing %s: %w", kind, field, domain.ErrInvalidPayload) +} + // ParsePullRequestEvent unmarshals and validates a pull_request webhook. -// returns error if required fields are missing. func ParsePullRequestEvent(payload []byte) (*PullRequestEvent, error) { var event PullRequestEvent - if err := json.Unmarshal(payload, &event); err != nil { - return nil, errors.Wrap(err, "failed to unmarshal pull request event") - } - if event.PullRequest == nil { - return nil, errors.Wrap(domain.ErrMissingPRData, "missing pull_request field") - } - if event.PullRequest.Number == nil { - return nil, errors.Wrap(domain.ErrMissingPRData, "missing pr number") - } - if event.PullRequest.Base == nil || event.PullRequest.Base.Ref == nil { - return nil, errors.Wrap(domain.ErrMissingPRData, "missing base branch") - } - if event.Repository == nil { - return nil, errors.Wrap(domain.ErrMissingPRData, "missing repository") + if err := parse(payload, &event, EventPullRequest); err != nil { + return nil, err + } + switch { + case event.PullRequest == nil: + return nil, missing(EventPullRequest, "pull_request") + case event.PullRequest.Number == nil: + return nil, missing(EventPullRequest, "pull_request.number") + case event.PullRequest.GetBase().GetRef() == "": + return nil, missing(EventPullRequest, "pull_request.base.ref") + case event.Repository.GetName() == "" || event.Repository.GetOwner().GetLogin() == "": + return nil, missing(EventPullRequest, "repository") } return &event, nil } // IsMerged returns true if the PR was closed via merge. func (e *PullRequestEvent) IsMerged() bool { - return e.Action == "closed" && e.PullRequest != nil && e.PullRequest.Merged != nil && *e.PullRequest.Merged + return e.Action == "closed" && e.PullRequest.GetMerged() +} + +// GetNumber returns the PR number. +func (e *PullRequestEvent) GetNumber() int { + return e.PullRequest.GetNumber() } // GetBaseBranch returns the target branch name. func (e *PullRequestEvent) GetBaseBranch() string { - if e.PullRequest != nil && e.PullRequest.Base != nil && e.PullRequest.Base.Ref != nil { - return *e.PullRequest.Base.Ref - } - return "" + return e.PullRequest.GetBase().GetRef() } // GetRepoFullName returns the repository in owner/name format. func (e *PullRequestEvent) GetRepoFullName() string { - if e.Repository != nil && e.Repository.FullName != nil { - return *e.Repository.FullName - } - return "" + return e.Repository.GetFullName() } // GetRepoOwner returns the repository owner login. func (e *PullRequestEvent) GetRepoOwner() string { - if e.Repository != nil && e.Repository.Owner != nil && e.Repository.Owner.Login != nil { - return *e.Repository.Owner.Login - } - return "" + return e.Repository.GetOwner().GetLogin() } // GetRepoName returns the repository name without owner. func (e *PullRequestEvent) GetRepoName() string { - if e.Repository != nil && e.Repository.Name != nil { - return *e.Repository.Name - } - return "" -} - -// GetInstallationID returns the GitHub App installation ID. -func (e *PullRequestEvent) GetInstallationID() int64 { - if e.Installation != nil && e.Installation.ID != nil { - return *e.Installation.ID - } - return 0 + return e.Repository.GetName() } // ParseTeamEvent unmarshals and validates a team webhook. func ParseTeamEvent(payload []byte) (*TeamEvent, error) { var event TeamEvent - if err := json.Unmarshal(payload, &event); err != nil { - return nil, errors.Wrap(err, "failed to unmarshal team event") - } - if event.Team == nil { - return nil, errors.New("missing team field in event") + if err := parse(payload, &event, EventTeam); err != nil { + return nil, err } - if event.Sender == nil { - return nil, errors.New("missing sender field in event") + switch { + case event.Team == nil: + return nil, missing(EventTeam, "team") + case event.Sender == nil: + return nil, missing(EventTeam, "sender") + case event.Organization.GetLogin() == "": + return nil, missing(EventTeam, "organization") } return &event, nil } -// ParseMembershipEvent unmarshals and validates a membership webhook. -func ParseMembershipEvent(payload []byte) (*MembershipEvent, error) { - var event MembershipEvent - if err := json.Unmarshal(payload, &event); err != nil { - return nil, errors.Wrap(err, "failed to unmarshal membership event") - } - if event.Team == nil { - return nil, errors.New("missing team field in event") - } - if event.Member == nil { - return nil, errors.New("missing member field in event") - } - if event.Sender == nil { - return nil, errors.New("missing sender field in event") - } - return &event, nil -} - -// GetInstallationID returns the GitHub App installation ID. -func (e *TeamEvent) GetInstallationID() int64 { - if e.Installation != nil && e.Installation.ID != nil { - return *e.Installation.ID - } - return 0 -} - -// GetTeamSlug returns the team's URL-friendly identifier. +// GetTeamSlug returns the team's slug. func (e *TeamEvent) GetTeamSlug() string { - if e.Team != nil && e.Team.Slug != nil { - return *e.Team.Slug - } - return "" + return e.Team.GetSlug() } -// GetSenderLogin returns the username of the user who triggered the event. -func (e *TeamEvent) GetSenderLogin() string { - if e.Sender != nil && e.Sender.Login != nil { - return *e.Sender.Login - } - return "" +// GetOrgLogin returns the login of the organization the team belongs to. +func (e *TeamEvent) GetOrgLogin() string { + return e.Organization.GetLogin() } -// GetSenderType returns the sender's type (User or Bot). -func (e *TeamEvent) GetSenderType() string { - if e.Sender != nil && e.Sender.Type != nil { - return *e.Sender.Type +// AffectsMembership returns true if the change can affect which team the +// sync maps a group to: a team was created, deleted, or renamed. Changes to +// a team's description, privacy, or repository access cannot. +func (e *TeamEvent) AffectsMembership() bool { + switch e.Action { + case "created", "deleted": + return true + case "edited": + return e.Changes != nil && e.Changes.Name != nil + default: + return false } - return "" } -// GetInstallationID returns the GitHub App installation ID. -func (e *MembershipEvent) GetInstallationID() int64 { - if e.Installation != nil && e.Installation.ID != nil { - return *e.Installation.ID +// GetPreviousTeamSlug returns the slug the team had before it was renamed, +// or "". +func (e *TeamEvent) GetPreviousTeamSlug() string { + if e.Changes == nil || e.Changes.Name == nil { + return "" } - return 0 + return domain.TeamSlug(e.Changes.Name.From) } -// GetTeamSlug returns the team's URL-friendly identifier. -func (e *MembershipEvent) GetTeamSlug() string { - if e.Team != nil && e.Team.Slug != nil { - return *e.Team.Slug +// ParseMembershipEvent unmarshals and validates a membership webhook. +func ParseMembershipEvent(payload []byte) (*MembershipEvent, error) { + var event MembershipEvent + if err := parse(payload, &event, EventMembership); err != nil { + return nil, err + } + switch { + case event.Team == nil: + return nil, missing(EventMembership, "team") + case event.Member == nil: + return nil, missing(EventMembership, "member") + case event.Sender == nil: + return nil, missing(EventMembership, "sender") + case event.Organization.GetLogin() == "": + return nil, missing(EventMembership, "organization") } - return "" + return &event, nil } -// GetSenderLogin returns the username of the user who triggered the event. -func (e *MembershipEvent) GetSenderLogin() string { - if e.Sender != nil && e.Sender.Login != nil { - return *e.Sender.Login - } - return "" +// GetTeamSlug returns the team's slug. +func (e *MembershipEvent) GetTeamSlug() string { + return e.Team.GetSlug() } -// GetSenderType returns the sender's type (User or Bot). -func (e *MembershipEvent) GetSenderType() string { - if e.Sender != nil && e.Sender.Type != nil { - return *e.Sender.Type - } - return "" +// GetOrgLogin returns the login of the organization the team belongs to. +func (e *MembershipEvent) GetOrgLogin() string { + return e.Organization.GetLogin() } // IsTeamScope returns true if the membership event is for a team. diff --git a/internal/github/webhooks/webhooks_test.go b/internal/github/webhooks/webhooks_test.go index ed9169b..4caab91 100644 --- a/internal/github/webhooks/webhooks_test.go +++ b/internal/github/webhooks/webhooks_test.go @@ -1,18 +1,15 @@ package webhooks import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" "encoding/json" + "errors" "testing" - "github.com/cockroachdb/errors" "github.com/cruxstack/github-ops-app/internal/domain" - "github.com/google/go-github/v79/github" ) -func TestValidateWebhookSignature(t *testing.T) { +func TestValidateSignature(t *testing.T) { + payload := []byte(`{"test": true}`) tests := []struct { name string payload []byte @@ -20,336 +17,228 @@ func TestValidateWebhookSignature(t *testing.T) { secret string wantErr error }{ - { - name: "no secret, no signature", - payload: []byte(`{"test": true}`), - signature: "", - secret: "", - wantErr: nil, - }, - { - name: "no secret, unexpected signature", - payload: []byte(`{"test": true}`), - signature: "sha256=abc", - secret: "", - wantErr: domain.ErrUnexpectedSignature, - }, - { - name: "secret configured, missing signature", - payload: []byte(`{"test": true}`), - signature: "", - secret: "mysecret", - wantErr: domain.ErrMissingSignature, - }, - { - name: "wrong prefix", - payload: []byte(`{"test": true}`), - signature: "sha1=abc123", - secret: "mysecret", - wantErr: domain.ErrInvalidSignature, - }, - { - name: "wrong signature value", - payload: []byte(`{"test": true}`), - signature: "sha256=0000000000000000000000000000000000000000000000000000000000000000", - secret: "mysecret", - wantErr: domain.ErrInvalidSignature, - }, - { - name: "valid signature", - payload: []byte(`{}`), - signature: computeSignature([]byte(`{}`), "test-secret"), - secret: "test-secret", - wantErr: nil, - }, - { - name: "empty payload with valid signature", - payload: []byte{}, - signature: computeSignature([]byte{}, "key"), - secret: "key", - wantErr: nil, - }, - { - name: "truncated signature", - payload: []byte(`test`), - signature: "sha256=abc", - secret: "mysecret", - wantErr: domain.ErrInvalidSignature, - }, + {"valid", payload, Sign(payload, "s3cret"), "s3cret", nil}, + {"valid empty payload", []byte{}, Sign([]byte{}, "key"), "key", nil}, + {"no secret configured", payload, "", "", domain.ErrConfig}, + {"no secret configured with signature", payload, Sign(payload, "x"), "", domain.ErrConfig}, + {"missing signature", payload, "", "s3cret", domain.ErrMissingSignature}, + {"wrong prefix", payload, "sha1=abc123", "s3cret", domain.ErrInvalidSignature}, + {"not hex", payload, "sha256=zz", "s3cret", domain.ErrInvalidSignature}, + {"truncated", payload, "sha256=abcd", "s3cret", domain.ErrInvalidSignature}, + {"wrong secret", payload, Sign(payload, "other"), "s3cret", domain.ErrInvalidSignature}, + {"tampered payload", []byte(`{"test": false}`), Sign(payload, "s3cret"), "s3cret", domain.ErrInvalidSignature}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := ValidateWebhookSignature(tt.payload, tt.signature, tt.secret) + err := ValidateSignature(tt.payload, tt.signature, tt.secret) if tt.wantErr == nil { if err != nil { - t.Errorf("expected no error, got: %v", err) + t.Fatalf("unexpected error: %v", err) } return } - if err == nil { - t.Errorf("expected error %v, got nil", tt.wantErr) - return - } if !errors.Is(err, tt.wantErr) { - t.Errorf("expected error %v, got: %v", tt.wantErr, err) + t.Fatalf("error = %v, want %v", err, tt.wantErr) } }) } } -// computeSignature generates a valid HMAC-SHA256 signature for testing. -func computeSignature(payload []byte, secret string) string { - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write(payload) - return "sha256=" + hex.EncodeToString(mac.Sum(nil)) +func mustJSON(t *testing.T, v any) []byte { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return b } -func TestParsePullRequestEvent_Valid(t *testing.T) { - prNumber := 42 - baseBranch := "main" - payload := map[string]any{ +func prPayload() map[string]any { + return map[string]any{ "action": "closed", - "number": prNumber, + "number": 42, "pull_request": map[string]any{ - "number": prNumber, + "number": 42, "merged": true, - "base": map[string]any{"ref": baseBranch}, + "base": map[string]any{"ref": "main"}, }, "repository": map[string]any{ - "name": "test-repo", - "full_name": "owner/test-repo", - "owner": map[string]any{"login": "owner"}, + "name": "web", + "full_name": "acme/web", + "owner": map[string]any{"login": "acme"}, }, + "sender": map[string]any{"login": "alice", "type": "User"}, } +} - data, _ := json.Marshal(payload) - event, err := ParsePullRequestEvent(data) +func TestParsePullRequestEvent(t *testing.T) { + event, err := ParsePullRequestEvent(mustJSON(t, prPayload())) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatal(err) } - - if event.Number != prNumber { - t.Errorf("expected number %d, got %d", prNumber, event.Number) + if !event.IsMerged() || event.GetNumber() != 42 || event.GetBaseBranch() != "main" { + t.Errorf("event = merged %v, #%d, %q", event.IsMerged(), event.GetNumber(), event.GetBaseBranch()) } - if !event.IsMerged() { - t.Error("expected IsMerged() to return true") + if event.GetRepoOwner() != "acme" || event.GetRepoName() != "web" || event.GetRepoFullName() != "acme/web" { + t.Errorf("repo = %q %q %q", event.GetRepoOwner(), event.GetRepoName(), event.GetRepoFullName()) } - if event.GetBaseBranch() != baseBranch { - t.Errorf("expected base branch %q, got %q", baseBranch, event.GetBaseBranch()) + if event.GetSenderLogin() != "alice" || event.GetSenderType() != "User" { + t.Errorf("sender = %q %q", event.GetSenderLogin(), event.GetSenderType()) } } -func TestParsePullRequestEvent_MissingFields(t *testing.T) { - tests := []struct { - name string - payload map[string]any - }{ - { - name: "missing pull_request", - payload: map[string]any{"action": "closed"}, - }, - { - name: "missing pr number", - payload: map[string]any{ - "action": "closed", - "pull_request": map[string]any{ - "base": map[string]any{"ref": "main"}, - }, - "repository": map[string]any{}, - }, - }, - { - name: "missing base branch", - payload: map[string]any{ - "action": "closed", - "pull_request": map[string]any{ - "number": 1, - }, - "repository": map[string]any{}, - }, - }, - { - name: "missing repository", - payload: map[string]any{ - "action": "closed", - "pull_request": map[string]any{ - "number": 1, - "base": map[string]any{"ref": "main"}, - }, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data, _ := json.Marshal(tt.payload) - _, err := ParsePullRequestEvent(data) - if err == nil { - t.Error("expected error for missing fields") - } - if !errors.Is(err, domain.ErrMissingPRData) { - t.Errorf("expected ErrMissingPRData, got: %v", err) +func TestParsePullRequestEvent_Invalid(t *testing.T) { + tests := map[string]func(map[string]any){ + "missing pull_request": func(p map[string]any) { delete(p, "pull_request") }, + "missing number": func(p map[string]any) { delete(p["pull_request"].(map[string]any), "number") }, + "missing base": func(p map[string]any) { delete(p["pull_request"].(map[string]any), "base") }, + "missing repository": func(p map[string]any) { delete(p, "repository") }, + "missing repo owner": func(p map[string]any) { delete(p["repository"].(map[string]any), "owner") }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + p := prPayload() + mutate(p) + _, err := ParsePullRequestEvent(mustJSON(t, p)) + if !errors.Is(err, domain.ErrInvalidPayload) || !errors.Is(err, domain.ErrValidation) { + t.Fatalf("error = %v, want an invalid payload validation error", err) } }) } -} -func TestParsePullRequestEvent_InvalidJSON(t *testing.T) { - _, err := ParsePullRequestEvent([]byte(`{invalid`)) - if err == nil { - t.Error("expected error for invalid JSON") - } + t.Run("invalid json", func(t *testing.T) { + _, err := ParsePullRequestEvent([]byte(`{`)) + if !errors.Is(err, domain.ErrValidation) { + t.Fatalf("error = %v, want a validation error", err) + } + }) } -func TestParseTeamEvent_Valid(t *testing.T) { - payload := map[string]any{ - "action": "edited", - "team": map[string]any{"slug": "engineering"}, - "sender": map[string]any{"login": "user1", "type": "User"}, - } - - data, _ := json.Marshal(payload) - event, err := ParseTeamEvent(data) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if event.Action != "edited" { - t.Errorf("expected action 'edited', got %q", event.Action) - } - if event.GetTeamSlug() != "engineering" { - t.Errorf("expected team slug 'engineering', got %q", event.GetTeamSlug()) - } - if event.GetSenderType() != "User" { - t.Errorf("expected sender type 'User', got %q", event.GetSenderType()) - } -} - -func TestParseTeamEvent_MissingFields(t *testing.T) { +func TestPullRequestEvent_IsMerged(t *testing.T) { tests := []struct { - name string - payload map[string]any + action string + merged bool + want bool }{ - { - name: "missing team", - payload: map[string]any{"action": "edited", "sender": map[string]any{"login": "u1"}}, - }, - { - name: "missing sender", - payload: map[string]any{"action": "edited", "team": map[string]any{"slug": "t1"}}, - }, + {"closed", true, true}, + {"closed", false, false}, + {"opened", false, false}, + {"synchronize", true, false}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data, _ := json.Marshal(tt.payload) - _, err := ParseTeamEvent(data) - if err == nil { - t.Error("expected error for missing fields") - } - }) + p := prPayload() + p["action"] = tt.action + p["pull_request"].(map[string]any)["merged"] = tt.merged + event, err := ParsePullRequestEvent(mustJSON(t, p)) + if err != nil { + t.Fatal(err) + } + if event.IsMerged() != tt.want { + t.Errorf("action %q merged %v: IsMerged = %v", tt.action, tt.merged, !tt.want) + } } } -func TestParseMembershipEvent_Valid(t *testing.T) { - payload := map[string]any{ - "action": "added", - "scope": "team", - "member": map[string]any{"login": "newuser"}, - "team": map[string]any{"slug": "engineering"}, - "sender": map[string]any{"login": "admin", "type": "User"}, +func teamPayload(action string) map[string]any { + return map[string]any{ + "action": action, + "team": map[string]any{"slug": "eng"}, + "organization": map[string]any{"login": "acme"}, + "sender": map[string]any{"login": "bob", "type": "User"}, } +} - data, _ := json.Marshal(payload) - event, err := ParseMembershipEvent(data) +func TestParseTeamEvent(t *testing.T) { + event, err := ParseTeamEvent(mustJSON(t, teamPayload("created"))) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatal(err) } - if !event.IsTeamScope() { - t.Error("expected IsTeamScope() to return true") + if event.GetTeamSlug() != "eng" || event.GetOrgLogin() != "acme" || event.GetSenderLogin() != "bob" { + t.Errorf("event = %q %q %q", event.GetTeamSlug(), event.GetOrgLogin(), event.GetSenderLogin()) } - if event.GetSenderLogin() != "admin" { - t.Errorf("expected sender login 'admin', got %q", event.GetSenderLogin()) + + for _, field := range []string{"team", "sender", "organization"} { + p := teamPayload("created") + delete(p, field) + if _, err := ParseTeamEvent(mustJSON(t, p)); !errors.Is(err, domain.ErrInvalidPayload) { + t.Errorf("missing %s: error = %v, want ErrInvalidPayload", field, err) + } } } -func TestParseMembershipEvent_MissingFields(t *testing.T) { +func TestTeamEvent_AffectsMembership(t *testing.T) { tests := []struct { - name string - payload map[string]any + action string + changes map[string]any + want bool }{ - { - name: "missing team", - payload: map[string]any{"action": "added", "member": map[string]any{"login": "u"}, "sender": map[string]any{"login": "s"}}, - }, - { - name: "missing member", - payload: map[string]any{"action": "added", "team": map[string]any{"slug": "t"}, "sender": map[string]any{"login": "s"}}, - }, - { - name: "missing sender", - payload: map[string]any{"action": "added", "team": map[string]any{"slug": "t"}, "member": map[string]any{"login": "u"}}, - }, + {"created", nil, true}, + {"deleted", nil, true}, + {"edited", map[string]any{"name": map[string]any{"from": "Old Eng"}}, true}, + {"edited", map[string]any{"privacy": map[string]any{"from": "secret"}}, false}, + {"edited", map[string]any{"repository": map[string]any{"permissions": map[string]any{"from": map[string]any{"push": false}}}}, false}, + {"added_to_repository", nil, false}, + {"removed_from_repository", nil, false}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data, _ := json.Marshal(tt.payload) - _, err := ParseMembershipEvent(data) - if err == nil { - t.Error("expected error for missing fields") - } - }) + p := teamPayload(tt.action) + if tt.changes != nil { + p["changes"] = tt.changes + } + event, err := ParseTeamEvent(mustJSON(t, p)) + if err != nil { + t.Fatal(err) + } + if got := event.AffectsMembership(); got != tt.want { + t.Errorf("%s %v: AffectsMembership = %v, want %v", tt.action, tt.changes, got, tt.want) + } + } + + p := teamPayload("edited") + p["changes"] = map[string]any{"name": map[string]any{"from": "Old Eng"}} + event, err := ParseTeamEvent(mustJSON(t, p)) + if err != nil { + t.Fatal(err) + } + if got := event.GetPreviousTeamSlug(); got != "old-eng" { + t.Errorf("GetPreviousTeamSlug = %q, want old-eng", got) } } -func TestPullRequestEvent_IsMerged(t *testing.T) { - merged := true - notMerged := false - - tests := []struct { - name string - action string - merged *bool - want bool - }{ - {"closed and merged", "closed", &merged, true}, - {"closed not merged", "closed", ¬Merged, false}, - {"opened", "opened", nil, false}, - {"closed with nil merged", "closed", nil, false}, +func membershipPayload(scope string) map[string]any { + return map[string]any{ + "action": "added", + "scope": scope, + "member": map[string]any{"login": "carol"}, + "team": map[string]any{"slug": "eng"}, + "organization": map[string]any{"login": "acme"}, + "sender": map[string]any{"login": "bob", "type": "User"}, } +} - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - event := &PullRequestEvent{ - Action: tt.action, - PullRequest: &github.PullRequest{ - Merged: tt.merged, - }, - } - if got := event.IsMerged(); got != tt.want { - t.Errorf("IsMerged() = %v, want %v", got, tt.want) - } - }) +func TestParseMembershipEvent(t *testing.T) { + event, err := ParseMembershipEvent(mustJSON(t, membershipPayload("team"))) + if err != nil { + t.Fatal(err) + } + if !event.IsTeamScope() || event.GetTeamSlug() != "eng" || event.GetOrgLogin() != "acme" { + t.Errorf("event = %v %q %q", event.IsTeamScope(), event.GetTeamSlug(), event.GetOrgLogin()) } -} -func TestMembershipEvent_IsTeamScope(t *testing.T) { - tests := []struct { - scope string - want bool - }{ - {"team", true}, - {"organization", false}, - {"", false}, + other, err := ParseMembershipEvent(mustJSON(t, membershipPayload("organization"))) + if err != nil { + t.Fatal(err) + } + if other.IsTeamScope() { + t.Error("organization scope reported as team scope") } - for _, tt := range tests { - t.Run(tt.scope, func(t *testing.T) { - event := &MembershipEvent{Scope: tt.scope} - if got := event.IsTeamScope(); got != tt.want { - t.Errorf("IsTeamScope() = %v, want %v", got, tt.want) - } - }) + for _, field := range []string{"team", "member", "sender", "organization"} { + p := membershipPayload("team") + delete(p, field) + if _, err := ParseMembershipEvent(mustJSON(t, p)); !errors.Is(err, domain.ErrInvalidPayload) { + t.Errorf("missing %s: error = %v, want ErrInvalidPayload", field, err) + } } } diff --git a/internal/notifiers/slack.go b/internal/notifiers/slack.go index 2d895f4..91b1e4e 100644 --- a/internal/notifiers/slack.go +++ b/internal/notifiers/slack.go @@ -1,12 +1,32 @@ -// Package notifiers provides Slack notification formatting and delivery. +// Package notifiers formats and sends Slack notifications. package notifiers import ( + "fmt" + "net/http" + "strings" + "time" + "github.com/slack-go/slack" + + "github.com/cruxstack/github-ops-app/internal/domain" +) + +// requestTimeout bounds every Slack API request. +const requestTimeout = 30 * time.Second + +// Slack limits a message to 50 blocks and a section's text to 3000 +// characters. Messages stay under both so long reports are never rejected. +const ( + maxBlocks = 50 + maxSectionText = 3000 + // maxListItems is the most items listed under one heading; the rest are + // summarized as "...and N more". + maxListItems = 100 ) // SlackChannels holds channel IDs for different notification types. -// empty values fall back to the default channel. +// Empty values fall back to the default channel. type SlackChannels struct { Default string PRBypass string @@ -15,43 +35,142 @@ type SlackChannels struct { SecurityAlerts string } -// SlackMessages holds optional custom messages for different notification -// types. empty values are excluded from the notification. +// SlackMessages holds optional custom text for notifications. Empty values +// are left out. type SlackMessages struct { + // PRBypassFooterNote is Slack mrkdwn written by the operator, so it is + // sent as is. PRBypassFooterNote string } +// Options configures a SlackNotifier. +type Options struct { + Token string + Channels SlackChannels + Messages SlackMessages + // APIURL overrides the Slack API URL, for tests. + APIURL string + // GitHubWebURL is the web URL of GitHub links, such as + // https://github.com or a GitHub Enterprise Server URL. + GitHubWebURL string + HTTPClient *http.Client +} + // SlackNotifier sends formatted messages to Slack channels. type SlackNotifier struct { - client *slack.Client - channels SlackChannels - messages SlackMessages + client *slack.Client + channels SlackChannels + messages SlackMessages + githubWebURL string } -// NewSlackNotifier creates a Slack notifier with default API URL. -func NewSlackNotifier(token string, channels SlackChannels, messages SlackMessages) *SlackNotifier { - return NewSlackNotifierWithAPIURL(token, channels, messages, "") -} +var _ domain.Notifier = (*SlackNotifier)(nil) -// NewSlackNotifierWithAPIURL creates a Slack notifier with custom API URL. -// useful for testing with mock servers. -func NewSlackNotifierWithAPIURL(token string, channels SlackChannels, messages SlackMessages, apiURL string) *SlackNotifier { - var opts []slack.Option - if apiURL != "" { - opts = append(opts, slack.OptionAPIURL(apiURL)) +// New creates a SlackNotifier. +func New(opts Options) *SlackNotifier { + var slackOpts []slack.Option + if opts.APIURL != "" { + slackOpts = append(slackOpts, slack.OptionAPIURL(opts.APIURL)) + } + httpClient := opts.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: requestTimeout} + } + slackOpts = append(slackOpts, slack.OptionHTTPClient(httpClient)) + webURL := strings.TrimRight(opts.GitHubWebURL, "/") + if webURL == "" { + webURL = "https://github.com" } return &SlackNotifier{ - client: slack.New(token, opts...), - channels: channels, - messages: messages, + client: slack.New(opts.Token, slackOpts...), + channels: opts.Channels, + messages: opts.Messages, + githubWebURL: webURL, } } // channelFor returns the channel for a notification type, falling back to -// default if the type-specific channel is empty. +// the default if the type-specific channel is empty. func (s *SlackNotifier) channelFor(typeChannel string) string { if typeChannel != "" { return typeChannel } return s.channels.Default } + +// escape makes untrusted text safe to include in Slack mrkdwn, so text such +// as a PR title cannot mention channels or users or add links. +func escape(s string) string { + return strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(s) +} + +// link returns a mrkdwn link with escaped text. +func link(url, text string) string { + if url == "" { + return escape(text) + } + return fmt.Sprintf("<%s|%s>", escape(url), escape(text)) +} + +func mrkdwn(text string) *slack.TextBlockObject { + return slack.NewTextBlockObject(slack.MarkdownType, text, false, false) +} + +func section(text string) slack.Block { + return slack.NewSectionBlock(mrkdwn(text), nil, nil) +} + +// listSections returns sections with a heading followed by one line per +// item, split so each section's text fits Slack's limit. Items beyond +// maxListItems are summarized. +func listSections(heading string, lines []string) []slack.Block { + if len(lines) == 0 { + return nil + } + if len(lines) > maxListItems { + more := len(lines) - maxListItems + lines = append(lines[:maxListItems:maxListItems], fmt.Sprintf("_...and %d more_", more)) + } + + var blocks []slack.Block + var b strings.Builder + b.WriteString(heading) + for _, line := range lines { + line = truncateRunes(line, maxSectionText-len(heading)-2) + if b.Len()+len(line)+1 > maxSectionText { + blocks = append(blocks, section(b.String())) + b.Reset() + } + if b.Len() > 0 { + b.WriteString("\n") + } + b.WriteString(line) + } + return append(blocks, section(b.String())) +} + +// truncateRunes shortens s to at most n bytes without splitting a rune. +func truncateRunes(s string, n int) string { + if len(s) <= n { + return s + } + for n > 0 && !isRuneStart(s[n]) { + n-- + } + return s[:n] +} + +func isRuneStart(b byte) bool { + return b&0xC0 != 0x80 +} + +// limitBlocks keeps a message under Slack's block limit, replacing the +// blocks that don't fit with a note. +func limitBlocks(blocks []slack.Block) []slack.Block { + if len(blocks) <= maxBlocks { + return blocks + } + kept := blocks[: maxBlocks-1 : maxBlocks-1] + return append(kept, slack.NewContextBlock("", mrkdwn( + fmt.Sprintf("_%d more sections were left out; see the application logs for the full report._", len(blocks)-len(kept))))) +} diff --git a/internal/notifiers/slack_messages.go b/internal/notifiers/slack_messages.go index 99a221f..57724b0 100644 --- a/internal/notifiers/slack_messages.go +++ b/internal/notifiers/slack_messages.go @@ -3,88 +3,62 @@ package notifiers import ( "context" "fmt" - "sort" + "slices" + "strings" - "github.com/cockroachdb/errors" - "github.com/cruxstack/github-ops-app/internal/domain" "github.com/slack-go/slack" + + "github.com/cruxstack/github-ops-app/internal/domain" ) -// NotifyPRBypass sends a Slack notification when branch protection is -// bypassed. +// post sends blocks to a channel with a plain-text fallback. +func (s *SlackNotifier) post(ctx context.Context, channel, fallback string, blocks []slack.Block) error { + _, _, err := s.client.PostMessageContext(ctx, channel, + slack.MsgOptionBlocks(limitBlocks(blocks)...), + slack.MsgOptionText(fallback, true), + ) + return err +} + +// NotifyPRBypass sends a Slack notification when a PR was merged without +// meeting its branch's requirements. func (s *SlackNotifier) NotifyPRBypass(ctx context.Context, result *domain.PRComplianceResult, repoFullName string) error { - if result.PR == nil { - return errors.Wrap(domain.ErrMissingPRData, "pr result missing") + if result == nil || result.PR == nil { + return fmt.Errorf("pr result missing: %w", domain.ErrMissingPRData) } + pr := result.PR - prURL := "" - prTitle := "unknown pr" - prNumber := 0 - mergedBy := "unknown" - - if result.PR.HTMLURL != nil { - prURL = *result.PR.HTMLURL - } - if result.PR.Title != nil { - prTitle = *result.PR.Title + mergedBy := pr.GetMergedBy().GetLogin() + if mergedBy == "" { + mergedBy = "unknown" } - if result.PR.Number != nil { - prNumber = *result.PR.Number - } - if result.PR.MergedBy != nil && result.PR.MergedBy.Login != nil { - mergedBy = *result.PR.MergedBy.Login - } - - // build merged by line with optional bypass reason - mergedByText := fmt.Sprintf("Merged by %s", mergedBy) - if result.UserHasBypass { - mergedByText = fmt.Sprintf("Merged by %s (%s)", mergedBy, result.UserBypassReason) + mergedByText := "Merged by " + escape(mergedBy) + if result.UserHasBypass && result.UserBypassReason != "" { + mergedByText += " (" + escape(result.UserBypassReason) + ")" } + title := fmt.Sprintf("%s#%d", repoFullName, pr.GetNumber()) blocks := []slack.Block{ - slack.NewHeaderBlock( - slack.NewTextBlockObject("plain_text", "🚨 Branch Protection Bypassed", false, false), - ), - slack.NewSectionBlock( - slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("<%s|%s#%d> — %s", prURL, repoFullName, prNumber, prTitle), false, false), - nil, nil, - ), - slack.NewSectionBlock( - slack.NewTextBlockObject("mrkdwn", mergedByText, false, false), - nil, nil, - ), + slack.NewHeaderBlock(slack.NewTextBlockObject(slack.PlainTextType, "🚨 Branch Protection Bypassed", false, false)), + section(fmt.Sprintf("%s — %s", link(pr.GetHTMLURL(), title), escape(pr.GetTitle()))), + section(mergedByText + " into `" + escape(result.BaseBranch) + "`"), } - if len(result.Violations) > 0 { - violationText := "*Violations:*\n" - for _, v := range result.Violations { - violationText += fmt.Sprintf("• %s\n", v.Description) - } - blocks = append(blocks, slack.NewSectionBlock( - slack.NewTextBlockObject("mrkdwn", violationText, false, false), - nil, nil, - )) + violations := make([]string, 0, len(result.Violations)) + for _, v := range result.Violations { + violations = append(violations, "• "+escape(v.Description)) } + blocks = append(blocks, listSections("*Violations:*", violations)...) if s.messages.PRBypassFooterNote != "" { - blocks = append(blocks, slack.NewContextBlock( - "footer", - slack.NewTextBlockObject("mrkdwn", s.messages.PRBypassFooterNote, false, false), - )) + blocks = append(blocks, slack.NewContextBlock("footer", mrkdwn(s.messages.PRBypassFooterNote))) } - channel := s.channelFor(s.channels.PRBypass) - _, _, err := s.client.PostMessageContext( - ctx, - channel, - slack.MsgOptionBlocks(blocks...), - slack.MsgOptionText(fmt.Sprintf("branch protection bypassed on pr #%d", prNumber), false), - ) - + err := s.post(ctx, s.channelFor(s.channels.PRBypass), + fmt.Sprintf("Branch protection bypassed on %s", title), blocks) if err != nil { - return errors.Wrap(err, "failed to post pr bypass notification to slack") + return fmt.Errorf("failed to post pr bypass notification for %s to slack: %w", title, err) } - return nil } @@ -94,148 +68,72 @@ func (s *SlackNotifier) NotifyOktaSync(ctx context.Context, reports []*domain.Sy return nil } - // aggregate stats var totalAdded, totalRemoved int - var rulesWithChanges, rulesWithoutChanges []*domain.SyncReport - var allErrors []string - var allSkippedExternal, allSkippedNoGHUsername []string - + var changed, unchanged, errs, skippedExternal, skippedNoUsername []string for _, report := range reports { totalAdded += len(report.MembersAdded) totalRemoved += len(report.MembersRemoved) - if report.HasChanges() { - rulesWithChanges = append(rulesWithChanges, report) - } else if !report.HasErrors() { - // only list as "no changes" if it didn't fail entirely - rulesWithoutChanges = append(rulesWithoutChanges, report) + team := s.teamLink(githubOrg, report.GitHubTeam) + switch { + case report.HasChanges(): + changed = append(changed, fmt.Sprintf("• %s (+%d, -%d)", team, len(report.MembersAdded), len(report.MembersRemoved))) + case !report.HasErrors(): + unchanged = append(unchanged, "• "+team) } - for _, err := range report.Errors { - // use rule name as identifier since team/group may be empty on failure - allErrors = append(allErrors, fmt.Sprintf("%s: %s", report.Rule, err)) - } - - allSkippedExternal = append(allSkippedExternal, report.MembersSkippedExternal...) - allSkippedNoGHUsername = append(allSkippedNoGHUsername, report.MembersSkippedNoGHUsername...) - } - - blocks := []slack.Block{ - slack.NewHeaderBlock( - slack.NewTextBlockObject("plain_text", "Okta GitHub Team Sync Complete", false, false), - ), - } - - // summary stats (slack allows max 2 columns per row) - rulesProcessedFields := []*slack.TextBlockObject{ - slack.NewTextBlockObject("mrkdwn", "*Rules Processed*", false, false), - slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("%d", len(reports)), false, false), - } - memberChangesFields := []*slack.TextBlockObject{ - slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*Members Added*\n%d", totalAdded), false, false), - slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*Members Removed*\n%d", totalRemoved), false, false), - } - blocks = append(blocks, slack.NewSectionBlock(nil, rulesProcessedFields, nil)) - blocks = append(blocks, slack.NewSectionBlock(nil, memberChangesFields, nil)) - - // helper to build team URL - teamURL := func(teamSlug string) string { - return fmt.Sprintf("https://github.com/orgs/%s/teams/%s", githubOrg, teamSlug) - } - - // list of rules with changes - if len(rulesWithChanges) > 0 { - blocks = append(blocks, slack.NewDividerBlock()) - - changesText := "*Rules With Changes*\n" - for _, report := range rulesWithChanges { - changesText += fmt.Sprintf("- <%s|%s> (+%d, -%d)\n", - teamURL(report.GitHubTeam), - report.GitHubTeam, - len(report.MembersAdded), - len(report.MembersRemoved)) + errs = append(errs, fmt.Sprintf("• %s: %s", escape(report.Rule), escape(err))) } - - blocks = append(blocks, slack.NewSectionBlock( - slack.NewTextBlockObject("mrkdwn", changesText, false, false), - nil, nil, - )) - } - - // list of rules without changes - if len(rulesWithoutChanges) > 0 { - blocks = append(blocks, slack.NewDividerBlock()) - - noChangesText := "*Rules With No Changes*\n" - for _, report := range rulesWithoutChanges { - noChangesText += fmt.Sprintf("- <%s|%s>\n", teamURL(report.GitHubTeam), report.GitHubTeam) + for _, m := range report.MembersSkippedExternal { + skippedExternal = append(skippedExternal, "• "+escape(m)) } - - blocks = append(blocks, slack.NewSectionBlock( - slack.NewTextBlockObject("mrkdwn", noChangesText, false, false), - nil, nil, - )) - } - - // errors section - if len(allErrors) > 0 { - blocks = append(blocks, slack.NewDividerBlock()) - - errorsText := "*Errors*\n" - for _, err := range allErrors { - errorsText += fmt.Sprintf("- %s\n", err) + for _, m := range report.MembersSkippedNoGHUsername { + skippedNoUsername = append(skippedNoUsername, "• "+escape(m)) } - - blocks = append(blocks, slack.NewSectionBlock( - slack.NewTextBlockObject("mrkdwn", errorsText, false, false), - nil, nil, - )) } - // skipped members section - if len(allSkippedExternal) > 0 || len(allSkippedNoGHUsername) > 0 { - blocks = append(blocks, slack.NewDividerBlock()) - - skippedText := "*Skipped Members*\n" - - if len(allSkippedExternal) > 0 { - skippedText += "_External Collaborators_\n" - for _, member := range allSkippedExternal { - skippedText += fmt.Sprintf("- %s\n", member) - } - } - - if len(allSkippedNoGHUsername) > 0 { - if len(allSkippedExternal) > 0 { - skippedText += "\n" - } - skippedText += "_No GitHub Username In Okta:_\n" - for _, member := range allSkippedNoGHUsername { - skippedText += fmt.Sprintf("- %s\n", member) - } + blocks := []slack.Block{ + slack.NewHeaderBlock(slack.NewTextBlockObject(slack.PlainTextType, "Okta GitHub Team Sync Complete", false, false)), + slack.NewSectionBlock(nil, []*slack.TextBlockObject{ + mrkdwn(fmt.Sprintf("*Teams Processed*\n%d", len(reports))), + mrkdwn(fmt.Sprintf("*Errors*\n%d", len(errs))), + mrkdwn(fmt.Sprintf("*Members Added*\n%d", totalAdded)), + mrkdwn(fmt.Sprintf("*Members Removed*\n%d", totalRemoved)), + }, nil), + } + + for _, list := range []struct { + heading string + lines []string + }{ + {"*Teams With Changes*", changed}, + {"*Teams With No Changes*", unchanged}, + {"*Errors*", errs}, + {"*Skipped: Not Organization Members*", skippedExternal}, + {"*Skipped: No GitHub Username In Okta*", skippedNoUsername}, + } { + if len(list.lines) > 0 { + blocks = append(blocks, slack.NewDividerBlock()) + blocks = append(blocks, listSections(list.heading, list.lines)...) } - - blocks = append(blocks, slack.NewSectionBlock( - slack.NewTextBlockObject("mrkdwn", skippedText, false, false), - nil, nil, - )) } - channel := s.channelFor(s.channels.OktaSync) - _, _, err := s.client.PostMessageContext( - ctx, - channel, - slack.MsgOptionBlocks(blocks...), - slack.MsgOptionText(fmt.Sprintf("okta sync: %d rules, +%d/-%d members", len(reports), totalAdded, totalRemoved), false), - ) - + err := s.post(ctx, s.channelFor(s.channels.OktaSync), + fmt.Sprintf("Okta sync: %d teams, +%d/-%d members, %d errors", len(reports), totalAdded, totalRemoved, len(errs)), blocks) if err != nil { - return errors.Wrap(err, "failed to post okta sync notification to slack") + return fmt.Errorf("failed to post okta sync notification to slack: %w", err) } - return nil } +// teamLink returns a mrkdwn link to a team. +func (s *SlackNotifier) teamLink(org, slug string) string { + if slug == "" { + return "_unknown team_" + } + return link(fmt.Sprintf("%s/orgs/%s/teams/%s", s.githubWebURL, org, slug), slug) +} + // NotifyOrphanedUsers sends a Slack notification about organization members // not in any synced teams. func (s *SlackNotifier) NotifyOrphanedUsers(ctx context.Context, report *domain.OrphanedUsersReport) error { @@ -243,180 +141,80 @@ func (s *SlackNotifier) NotifyOrphanedUsers(ctx context.Context, report *domain. return nil } - blocks := []slack.Block{ - slack.NewHeaderBlock( - slack.NewTextBlockObject("plain_text", "⚠️ Orphaned GitHub Users Detected", false, false), - ), - slack.NewSectionBlock( - slack.NewTextBlockObject("mrkdwn", - fmt.Sprintf("Found *%d* organization member(s) not in any Okta-synced GitHub teams:", len(report.OrphanedUsers)), - false, false), - nil, nil, - ), + users := slices.Clone(report.OrphanedUsers) + slices.SortFunc(users, func(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) }) + lines := make([]string, 0, len(users)) + for _, user := range users { + lines = append(lines, "• `"+escape(user)+"`") } - userList := "" - for _, user := range report.OrphanedUsers { - userList += fmt.Sprintf("• `%s`\n", user) + blocks := []slack.Block{ + slack.NewHeaderBlock(slack.NewTextBlockObject(slack.PlainTextType, "⚠️ Orphaned GitHub Users Detected", false, false)), + section(fmt.Sprintf("Found *%d* organization member(s) not in any Okta-synced GitHub team:", len(users))), } + blocks = append(blocks, listSections("", lines)...) + blocks = append(blocks, slack.NewContextBlock("context", + mrkdwn("_These users may need to be added to Okta groups or removed from the organization._"))) - blocks = append(blocks, slack.NewSectionBlock( - slack.NewTextBlockObject("mrkdwn", userList, false, false), - nil, nil, - )) - - blocks = append(blocks, slack.NewContextBlock( - "context", - slack.NewTextBlockObject("mrkdwn", "_These users may need to be added to Okta groups or removed from the organization._", false, false), - )) - - channel := s.channelFor(s.channels.OrphanedUsers) - _, _, err := s.client.PostMessageContext( - ctx, - channel, - slack.MsgOptionBlocks(blocks...), - slack.MsgOptionText(fmt.Sprintf("orphaned github users detected: %d users", len(report.OrphanedUsers)), false), - ) - + err := s.post(ctx, s.channelFor(s.channels.OrphanedUsers), + fmt.Sprintf("Orphaned GitHub users detected: %d users", len(users)), blocks) if err != nil { - return errors.Wrap(err, "failed to post orphaned users notification to slack") + return fmt.Errorf("failed to post orphaned users notification to slack: %w", err) } - return nil } -// maxReposInNotification limits the number of repositories shown in a -// single Slack message to avoid exceeding block limits. -const maxReposInNotification = 15 - -// NotifySecurityAlerts sends a Slack notification summarizing open +// NotifySecurityAlerts sends a Slack notification summarizing stale open // security alerts across the organization. func (s *SlackNotifier) NotifySecurityAlerts(ctx context.Context, report *domain.SecurityAlertsReport, githubOrg string) error { - if report == nil || !report.HasAlerts() { + if report == nil || (!report.HasAlerts() && !report.HasErrors()) { return nil } blocks := []slack.Block{ - slack.NewHeaderBlock( - slack.NewTextBlockObject("plain_text", - "Security Alerts Report", false, false), - ), - slack.NewSectionBlock( - slack.NewTextBlockObject("mrkdwn", - fmt.Sprintf( - "*%d* open alert(s) across *%d* repository(s) in `%s`", - report.TotalAlerts, - report.RepoCount(), - githubOrg, - ), - false, false), - nil, nil, - ), + slack.NewHeaderBlock(slack.NewTextBlockObject(slack.PlainTextType, "Security Alerts Report", false, false)), + section(fmt.Sprintf("*%d* open alert(s) across *%d* repository(s) in `%s`", + report.TotalAlerts, report.RepoCount(), escape(githubOrg))), } - // repo list sorted by most alerts first repos := sortedReposByAlertCount(report.AlertsByRepo) - - repoLines := "" - for i, repo := range repos { - if i >= maxReposInNotification { - repoLines += fmt.Sprintf( - "\n_…and %d more_", len(repos)-i) - break - } - + lines := make([]string, 0, len(repos)) + for _, repo := range repos { alerts := report.AlertsByRepo[repo] - highest := highestSeverity(alerts) - secURL := fmt.Sprintf( - "https://github.com/%s/security", repo) - - repoLines += fmt.Sprintf("• <%s|%s> — %d alert(s), %s severity\n", - secURL, repo, len(alerts), highest) + lines = append(lines, fmt.Sprintf("• %s — %d alert(s), %s severity", + link(fmt.Sprintf("%s/%s/security", s.githubWebURL, repo), repo), len(alerts), domain.HighestSeverity(alerts))) } + blocks = append(blocks, listSections("", lines)...) - blocks = append(blocks, slack.NewSectionBlock( - slack.NewTextBlockObject("mrkdwn", repoLines, false, false), - nil, nil, - )) - - if report.HasErrors() { - errText := "*Errors:*\n" - for _, e := range report.Errors { - errText += fmt.Sprintf("• %s\n", e) - } - blocks = append(blocks, slack.NewSectionBlock( - slack.NewTextBlockObject("mrkdwn", errText, - false, false), - nil, nil, - )) + errs := make([]string, 0, len(report.Errors)) + for _, e := range report.Errors { + errs = append(errs, "• "+escape(e)) } + blocks = append(blocks, listSections("*Errors:*", errs)...) - blocks = append(blocks, slack.NewContextBlock("", - slack.NewTextBlockObject("mrkdwn", - fmt.Sprintf( - "Filtered to open alerts older than %d days · severity %s or above", - report.MinAgeDays, - report.MinSeverity, - ), - false, false), - )) - - channel := s.channelFor(s.channels.SecurityAlerts) - _, _, err := s.client.PostMessageContext( - ctx, - channel, - slack.MsgOptionBlocks(blocks...), - slack.MsgOptionText( - fmt.Sprintf("security alerts: %d open alerts across %d repos in %s", - report.TotalAlerts, report.RepoCount(), githubOrg), - false), - ) + blocks = append(blocks, slack.NewContextBlock("context", mrkdwn(fmt.Sprintf( + "Open alerts older than %d days with severity %s or above", report.MinAgeDays, escape(report.MinSeverity))))) + err := s.post(ctx, s.channelFor(s.channels.SecurityAlerts), + fmt.Sprintf("Security alerts: %d open alerts across %d repos in %s", report.TotalAlerts, report.RepoCount(), githubOrg), blocks) if err != nil { - return errors.Wrap(err, - "failed to post security alerts notification to slack") + return fmt.Errorf("failed to post security alerts notification to slack: %w", err) } - return nil } -// sortedReposByAlertCount returns repo names sorted by descending -// alert count, with alphabetical tiebreak. +// sortedReposByAlertCount returns repo names sorted by descending alert +// count, then by name. func sortedReposByAlertCount(alertsByRepo map[string][]domain.SecurityAlert) []string { repos := make([]string, 0, len(alertsByRepo)) for repo := range alertsByRepo { repos = append(repos, repo) } - sort.Slice(repos, func(i, j int) bool { - ci := len(alertsByRepo[repos[i]]) - cj := len(alertsByRepo[repos[j]]) - if ci != cj { - return ci > cj + slices.SortFunc(repos, func(a, b string) int { + if d := len(alertsByRepo[b]) - len(alertsByRepo[a]); d != 0 { + return d } - return repos[i] < repos[j] + return strings.Compare(a, b) }) return repos } - -// highestSeverity returns the highest severity label among alerts. -func highestSeverity(alerts []domain.SecurityAlert) string { - best := 0 - for _, a := range alerts { - if r := severityRank[a.Severity]; r > best { - best = r - } - } - for _, sev := range []string{"critical", "high", "medium", "low"} { - if severityRank[sev] == best { - return sev - } - } - return "unknown" -} - -var severityRank = map[string]int{ - "critical": 4, - "high": 3, - "medium": 2, - "low": 1, -} diff --git a/internal/notifiers/slack_messages_test.go b/internal/notifiers/slack_messages_test.go deleted file mode 100644 index 48421c5..0000000 --- a/internal/notifiers/slack_messages_test.go +++ /dev/null @@ -1,353 +0,0 @@ -package notifiers - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/cruxstack/github-ops-app/internal/domain" - "github.com/google/go-github/v79/github" -) - -// slackTestServer creates a mock Slack API server that captures posted -// messages and returns them via the messages channel. -func slackTestServer(t *testing.T) (*httptest.Server, chan map[string]any) { - t.Helper() - messages := make(chan map[string]any, 10) - - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - r.Body.Close() - - // parse form-encoded body from slack client - var msg map[string]any - if strings.Contains(r.Header.Get("Content-Type"), "json") { - json.Unmarshal(body, &msg) - } else { - msg = map[string]any{"raw_body": string(body), "path": r.URL.Path} - } - - messages <- msg - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - w.Write([]byte(`{"ok":true,"channel":"C123","ts":"1234.5678"}`)) - })) - - return srv, messages -} - -func TestNotifyPRBypass_Success(t *testing.T) { - srv, messages := slackTestServer(t) - defer srv.Close() - - notifier := NewSlackNotifierWithAPIURL( - "xoxb-test", - SlackChannels{Default: "C_DEFAULT"}, - SlackMessages{PRBypassFooterNote: "review this"}, - srv.URL+"/", - ) - - prNumber := 42 - prTitle := "fix: patch auth" - prURL := "https://github.com/org/repo/pull/42" - mergedBy := "admin-user" - result := &domain.PRComplianceResult{ - PR: &github.PullRequest{ - Number: &prNumber, - Title: &prTitle, - HTMLURL: &prURL, - MergedBy: &github.User{Login: &mergedBy}, - }, - UserHasBypass: true, - UserBypassReason: "repository admin", - Violations: []domain.ComplianceViolation{ - {Type: "insufficient_reviews", Description: "required 2, had 0"}, - }, - } - - err := notifier.NotifyPRBypass(context.Background(), result, "org/repo") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - msg := <-messages - if msg == nil { - t.Fatal("expected a message to be posted") - } -} - -func TestNotifyPRBypass_NilPR(t *testing.T) { - notifier := NewSlackNotifier("xoxb-test", SlackChannels{Default: "C"}, SlackMessages{}) - - result := &domain.PRComplianceResult{PR: nil} - err := notifier.NotifyPRBypass(context.Background(), result, "org/repo") - if err == nil { - t.Fatal("expected error for nil PR") - } -} - -func TestNotifyOktaSync_EmptyReports(t *testing.T) { - notifier := NewSlackNotifier("xoxb-test", SlackChannels{Default: "C"}, SlackMessages{}) - - err := notifier.NotifyOktaSync(context.Background(), nil, "org") - if err != nil { - t.Fatalf("expected nil for empty reports, got: %v", err) - } - - err = notifier.NotifyOktaSync(context.Background(), []*domain.SyncReport{}, "org") - if err != nil { - t.Fatalf("expected nil for zero reports, got: %v", err) - } -} - -func TestNotifyOktaSync_Success(t *testing.T) { - srv, messages := slackTestServer(t) - defer srv.Close() - - notifier := NewSlackNotifierWithAPIURL( - "xoxb-test", - SlackChannels{Default: "C_DEFAULT", OktaSync: "C_OKTA"}, - SlackMessages{}, - srv.URL+"/", - ) - - reports := []*domain.SyncReport{ - { - Rule: "eng", OktaGroup: "Engineering", GitHubTeam: "engineering", - MembersAdded: []string{"alice"}, MembersRemoved: []string{"bob"}, - }, - { - Rule: "platform", OktaGroup: "Platform", GitHubTeam: "platform", - }, - } - - err := notifier.NotifyOktaSync(context.Background(), reports, "my-org") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - msg := <-messages - if msg == nil { - t.Fatal("expected a message to be posted") - } -} - -func TestNotifyOrphanedUsers_NilReport(t *testing.T) { - notifier := NewSlackNotifier("xoxb-test", SlackChannels{Default: "C"}, SlackMessages{}) - - err := notifier.NotifyOrphanedUsers(context.Background(), nil) - if err != nil { - t.Fatalf("expected nil for nil report, got: %v", err) - } -} - -func TestNotifyOrphanedUsers_EmptyUsers(t *testing.T) { - notifier := NewSlackNotifier("xoxb-test", SlackChannels{Default: "C"}, SlackMessages{}) - - err := notifier.NotifyOrphanedUsers(context.Background(), &domain.OrphanedUsersReport{}) - if err != nil { - t.Fatalf("expected nil for empty users, got: %v", err) - } -} - -func TestNotifyOrphanedUsers_Success(t *testing.T) { - srv, messages := slackTestServer(t) - defer srv.Close() - - notifier := NewSlackNotifierWithAPIURL( - "xoxb-test", - SlackChannels{Default: "C_DEFAULT"}, - SlackMessages{}, - srv.URL+"/", - ) - - report := &domain.OrphanedUsersReport{ - OrphanedUsers: []string{"orphan-1", "orphan-2"}, - } - - err := notifier.NotifyOrphanedUsers(context.Background(), report) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - msg := <-messages - if msg == nil { - t.Fatal("expected a message to be posted") - } -} - -func TestNotifyOktaSync_WithErrors(t *testing.T) { - srv, messages := slackTestServer(t) - defer srv.Close() - - notifier := NewSlackNotifierWithAPIURL( - "xoxb-test", - SlackChannels{Default: "C"}, - SlackMessages{}, - srv.URL+"/", - ) - - reports := []*domain.SyncReport{ - { - Rule: "eng", OktaGroup: "Engineering", GitHubTeam: "engineering", - MembersAdded: []string{"alice"}, - Errors: []string{"rate limited"}, - MembersSkippedExternal: []string{"external-1"}, - MembersSkippedNoGHUsername: []string{"newhire@co.com"}, - }, - } - - err := notifier.NotifyOktaSync(context.Background(), reports, "org") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - msg := <-messages - if msg == nil { - t.Fatal("expected a message") - } -} - -func TestNotifySecurityAlerts_NilReport(t *testing.T) { - notifier := NewSlackNotifier("xoxb-test", SlackChannels{Default: "C"}, SlackMessages{}) - - err := notifier.NotifySecurityAlerts(context.Background(), nil, "org") - if err != nil { - t.Fatalf("expected nil for nil report, got: %v", err) - } -} - -func TestNotifySecurityAlerts_NoAlerts(t *testing.T) { - notifier := NewSlackNotifier("xoxb-test", SlackChannels{Default: "C"}, SlackMessages{}) - - report := &domain.SecurityAlertsReport{ - TotalAlerts: 0, - AlertsByRepo: map[string][]domain.SecurityAlert{}, - } - err := notifier.NotifySecurityAlerts(context.Background(), report, "org") - if err != nil { - t.Fatalf("expected nil for empty report, got: %v", err) - } -} - -func TestNotifySecurityAlerts_Success(t *testing.T) { - srv, messages := slackTestServer(t) - defer srv.Close() - - notifier := NewSlackNotifierWithAPIURL( - "xoxb-test", - SlackChannels{Default: "C_DEFAULT", SecurityAlerts: "C_SEC"}, - SlackMessages{}, - srv.URL+"/", - ) - - report := &domain.SecurityAlertsReport{ - MinAgeDays: 30, - MinSeverity: "high", - TotalAlerts: 3, - AlertsByRepo: map[string][]domain.SecurityAlert{ - "org/api": { - {Type: "dependabot", Severity: "critical", Number: 1}, - {Type: "code_scanning", Severity: "high", Number: 5}, - }, - "org/web": { - {Type: "secret_scanning", Severity: "high", Number: 3}, - }, - }, - } - - err := notifier.NotifySecurityAlerts(context.Background(), report, "my-org") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - msg := <-messages - if msg == nil { - t.Fatal("expected a message to be posted") - } -} - -func TestNotifySecurityAlerts_WithErrors(t *testing.T) { - srv, messages := slackTestServer(t) - defer srv.Close() - - notifier := NewSlackNotifierWithAPIURL( - "xoxb-test", - SlackChannels{Default: "C"}, - SlackMessages{}, - srv.URL+"/", - ) - - report := &domain.SecurityAlertsReport{ - MinAgeDays: 14, - MinSeverity: "medium", - TotalAlerts: 1, - AlertsByRepo: map[string][]domain.SecurityAlert{ - "org/repo": { - {Type: "dependabot", Severity: "high", Number: 10}, - }, - }, - Errors: []string{"failed to fetch code scanning alerts"}, - } - - err := notifier.NotifySecurityAlerts(context.Background(), report, "org") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - msg := <-messages - if msg == nil { - t.Fatal("expected a message") - } -} - -func TestChannelFor_SecurityAlerts(t *testing.T) { - n := &SlackNotifier{ - channels: SlackChannels{ - Default: "C_DEFAULT", - SecurityAlerts: "C_SEC", - }, - } - - if got := n.channelFor(n.channels.SecurityAlerts); got != "C_SEC" { - t.Errorf("expected C_SEC, got %s", got) - } -} - -func TestChannelFor_SecurityAlertsFallback(t *testing.T) { - n := &SlackNotifier{ - channels: SlackChannels{ - Default: "C_DEFAULT", - }, - } - - if got := n.channelFor(n.channels.SecurityAlerts); got != "C_DEFAULT" { - t.Errorf("expected C_DEFAULT fallback, got %s", got) - } -} - -func TestChannelFor_CustomChannels(t *testing.T) { - n := &SlackNotifier{ - channels: SlackChannels{ - Default: "C_DEFAULT", - PRBypass: "C_PR", - OktaSync: "C_OKTA", - OrphanedUsers: "C_ORPHAN", - }, - } - - if got := n.channelFor(n.channels.PRBypass); got != "C_PR" { - t.Errorf("expected C_PR, got %s", got) - } - if got := n.channelFor(n.channels.OktaSync); got != "C_OKTA" { - t.Errorf("expected C_OKTA, got %s", got) - } - if got := n.channelFor(n.channels.OrphanedUsers); got != "C_ORPHAN" { - t.Errorf("expected C_ORPHAN, got %s", got) - } -} diff --git a/internal/notifiers/slack_test.go b/internal/notifiers/slack_test.go index 7d9c5ca..a8dd240 100644 --- a/internal/notifiers/slack_test.go +++ b/internal/notifiers/slack_test.go @@ -1,76 +1,258 @@ package notifiers -import "testing" +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" -func TestChannelFor(t *testing.T) { - defaultChannel := "C_DEFAULT" - prBypassChannel := "C_PR_BYPASS" - oktaSyncChannel := "C_OKTA_SYNC" - - tests := []struct { - name string - channels SlackChannels - typeChannel string - want string - }{ - { - name: "uses type-specific channel when set", - channels: SlackChannels{ - Default: defaultChannel, - PRBypass: prBypassChannel, - }, - typeChannel: prBypassChannel, - want: prBypassChannel, - }, - { - name: "falls back to default when type-specific is empty", - channels: SlackChannels{ - Default: defaultChannel, - PRBypass: "", - }, - typeChannel: "", - want: defaultChannel, - }, - { - name: "all channels set independently", - channels: SlackChannels{ - Default: defaultChannel, - PRBypass: prBypassChannel, - OktaSync: oktaSyncChannel, - OrphanedUsers: "C_ORPHANED", - }, - typeChannel: oktaSyncChannel, - want: oktaSyncChannel, + "github.com/google/go-github/v93/github" + + "github.com/cruxstack/github-ops-app/internal/domain" +) + +// posted is a message the fake Slack API received. +type posted struct { + Channel string + Text string + Blocks []map[string]any +} + +// allText returns the text of every block, joined. +func (p posted) allText() string { + var b strings.Builder + enc := json.NewEncoder(&b) + enc.SetEscapeHTML(false) + _ = enc.Encode(p.Blocks) + return b.String() +} + +func fakeSlack(t *testing.T) (*SlackNotifier, func() []posted) { + t.Helper() + var msgs []posted + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Errorf("parse form: %v", err) + } + p := posted{Channel: r.PostForm.Get("channel"), Text: r.PostForm.Get("text")} + if err := json.Unmarshal([]byte(r.PostForm.Get("blocks")), &p.Blocks); err != nil { + t.Errorf("decode blocks: %v", err) + } + msgs = append(msgs, p) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"channel":"C1","ts":"1.2"}`)) + })) + t.Cleanup(srv.Close) + + n := New(Options{ + Token: "xoxb-test", + Channels: SlackChannels{Default: "C_DEFAULT", OktaSync: "C_SYNC"}, + Messages: SlackMessages{PRBypassFooterNote: "_see _"}, + APIURL: srv.URL + "/", + GitHubWebURL: "https://ghe.example.com/", + }) + return n, func() []posted { return msgs } +} + +// checkLimits fails the test if a message breaks Slack's limits. +func checkLimits(t *testing.T, p posted) { + t.Helper() + if len(p.Blocks) > maxBlocks { + t.Errorf("message has %d blocks, limit is %d", len(p.Blocks), maxBlocks) + } + for _, b := range p.Blocks { + if text, ok := b["text"].(map[string]any); ok { + if s, _ := text["text"].(string); len(s) > maxSectionText { + t.Errorf("section text is %d chars, limit is %d", len(s), maxSectionText) + } + } + } +} + +func TestEscape(t *testing.T) { + got := escape(" & ") + want := "<!channel> & <https://evil.example|click>" + if got != want { + t.Errorf("escape = %q, want %q", got, want) + } +} + +func TestNotifyPRBypass(t *testing.T) { + n, msgs := fakeSlack(t) + result := &domain.PRComplianceResult{ + PR: &github.PullRequest{ + Number: new(42), + Title: new(" urgent "), + HTMLURL: new("https://ghe.example.com/acme/web/pull/42"), + MergedBy: &github.User{Login: new("mallory")}, }, + BaseBranch: "main", + UserHasBypass: true, + UserBypassReason: "repository admin", + Violations: []domain.ComplianceViolation{{Description: "required check 'ci' did not pass"}}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - n := &SlackNotifier{channels: tt.channels} - got := n.channelFor(tt.typeChannel) - if got != tt.want { - t.Errorf("channelFor() = %q, want %q", got, tt.want) - } - }) + if err := n.NotifyPRBypass(context.Background(), result, "acme/web"); err != nil { + t.Fatal(err) + } + p := msgs()[0] + text := p.allText() + if p.Channel != "C_DEFAULT" { + t.Errorf("channel = %q, want the default", p.Channel) + } + if strings.Contains(text, "") || strings.Contains(text, ""} { + if !strings.Contains(text, want) { + t.Errorf("message lacks %q: %s", want, text) + } + } +} + +func TestNotifyPRBypass_MissingPR(t *testing.T) { + n, _ := fakeSlack(t) + if err := n.NotifyPRBypass(context.Background(), &domain.PRComplianceResult{}, "acme/web"); err == nil { + t.Fatal("NotifyPRBypass succeeded without a pr") + } +} + +func TestNotifyOktaSync(t *testing.T) { + n, msgs := fakeSlack(t) + reports := []*domain.SyncReport{ + {Rule: "eng", GitHubTeam: "eng", MembersAdded: []string{"alice"}, MembersRemoved: []string{"bob"}}, + {Rule: "ops", GitHubTeam: "ops"}, + {Rule: "", GitHubTeam: "sec", Errors: []string{"boom <@U123>"}, MembersSkippedNoGHUsername: []string{"new@x.com"}}, + } + + if err := n.NotifyOktaSync(context.Background(), reports, "acme"); err != nil { + t.Fatal(err) + } + p := msgs()[0] + text := p.allText() + checkLimits(t, p) + if p.Channel != "C_SYNC" { + t.Errorf("channel = %q, want the okta sync channel", p.Channel) + } + for _, want := range []string{ + " (+1, -1)", + "", + "<!here>: boom <@U123>", + "new@x.com", + } { + if !strings.Contains(text, want) { + t.Errorf("message lacks %q: %s", want, text) + } + } + if n.NotifyOktaSync(context.Background(), nil, "acme") != nil || len(msgs()) != 1 { + t.Error("empty reports posted a message") + } +} + +func TestNotifyOktaSync_LargeReportFitsSlackLimits(t *testing.T) { + n, msgs := fakeSlack(t) + var reports []*domain.SyncReport + for i := range 400 { + r := &domain.SyncReport{Rule: fmt.Sprint("rule-", i), GitHubTeam: fmt.Sprintf("team-with-a-long-name-%03d", i)} + if i%2 == 0 { + r.MembersAdded = []string{"someone"} + r.Errors = []string{strings.Repeat("x", 200)} + } + reports = append(reports, r) + } + + if err := n.NotifyOktaSync(context.Background(), reports, "acme"); err != nil { + t.Fatal(err) + } + p := msgs()[0] + checkLimits(t, p) + if !strings.Contains(p.allText(), "...and 100 more") { + t.Error("long list was not summarized") } } -func TestSlackChannels_AllFallbackToDefault(t *testing.T) { - defaultChannel := "C_DEFAULT" - n := &SlackNotifier{ - channels: SlackChannels{ - Default: defaultChannel, +func TestNotifyOrphanedUsers(t *testing.T) { + n, msgs := fakeSlack(t) + if err := n.NotifyOrphanedUsers(context.Background(), &domain.OrphanedUsersReport{}); err != nil || len(msgs()) != 0 { + t.Fatalf("empty report: err %v, %d messages", err, len(msgs())) + } + + var users []string + for i := range 1000 { + users = append(users, fmt.Sprintf("user-%04d", i)) + } + if err := n.NotifyOrphanedUsers(context.Background(), &domain.OrphanedUsersReport{OrphanedUsers: users}); err != nil { + t.Fatal(err) + } + p := msgs()[0] + checkLimits(t, p) + if !strings.Contains(p.allText(), "*1000* organization member(s)") || !strings.Contains(p.allText(), "user-0000") { + t.Errorf("message = %s", p.allText()) + } +} + +func TestNotifySecurityAlerts(t *testing.T) { + n, msgs := fakeSlack(t) + if err := n.NotifySecurityAlerts(context.Background(), &domain.SecurityAlertsReport{}, "acme"); err != nil || len(msgs()) != 0 { + t.Fatalf("empty report: err %v, %d messages", err, len(msgs())) + } + + report := &domain.SecurityAlertsReport{ + MinAgeDays: 30, + MinSeverity: "high", + TotalAlerts: 3, + AlertsByRepo: map[string][]domain.SecurityAlert{ + "acme/api": {{Severity: "high"}}, + "acme/web": {{Severity: "high"}, {Severity: "critical", CreatedAt: time.Now()}}, }, + Errors: []string{"failed to fetch secret scanning alerts"}, + } + if err := n.NotifySecurityAlerts(context.Background(), report, "acme"); err != nil { + t.Fatal(err) } + text := msgs()[0].allText() + web := strings.Index(text, "|acme/web>") + api := strings.Index(text, "|acme/api>") + if web < 0 || api < 0 || web > api { + t.Errorf("repos not listed by alert count: %s", text) + } + for _, want := range []string{"https://ghe.example.com/acme/web/security", "2 alert(s), critical severity", "secret scanning"} { + if !strings.Contains(text, want) { + t.Errorf("message lacks %q: %s", want, text) + } + } +} - // all type-specific channels should fall back to default - if got := n.channelFor(n.channels.PRBypass); got != defaultChannel { - t.Errorf("PRBypass channel = %q, want %q", got, defaultChannel) +func TestNotifySecurityAlerts_ErrorsOnly(t *testing.T) { + n, msgs := fakeSlack(t) + report := &domain.SecurityAlertsReport{AlertsByRepo: map[string][]domain.SecurityAlert{}, Errors: []string{"forbidden"}} + if err := n.NotifySecurityAlerts(context.Background(), report, "acme"); err != nil { + t.Fatal(err) + } + if len(msgs()) != 1 || !strings.Contains(msgs()[0].allText(), "forbidden") { + t.Error("report with only errors was not posted") } - if got := n.channelFor(n.channels.OktaSync); got != defaultChannel { - t.Errorf("OktaSync channel = %q, want %q", got, defaultChannel) +} + +func TestChannelFor(t *testing.T) { + n := New(Options{Channels: SlackChannels{Default: "C_DEFAULT", SecurityAlerts: "C_SEC"}}) + if got := n.channelFor(n.channels.SecurityAlerts); got != "C_SEC" { + t.Errorf("channelFor(set) = %q", got) + } + if got := n.channelFor(n.channels.PRBypass); got != "C_DEFAULT" { + t.Errorf("channelFor(unset) = %q, want the default", got) + } +} + +func TestTruncateRunes(t *testing.T) { + if got := truncateRunes("héllo", 2); got != "h" { + t.Errorf("truncateRunes split a rune: %q", got) } - if got := n.channelFor(n.channels.OrphanedUsers); got != defaultChannel { - t.Errorf("OrphanedUsers channel = %q, want %q", got, defaultChannel) + if got := truncateRunes("abc", 10); got != "abc" { + t.Errorf("truncateRunes = %q", got) } } diff --git a/internal/okta/client.go b/internal/okta/client.go index d7f085f..eb0c879 100644 --- a/internal/okta/client.go +++ b/internal/okta/client.go @@ -1,314 +1,161 @@ -// Package okta provides Okta API client for group and user management. -// Uses OAuth 2.0 with private key authentication. +// Package okta is the Okta API client. It authenticates with OAuth 2.0 +// private key JWTs and reads groups and their members' GitHub usernames. package okta import ( - "context" "crypto/rsa" - "crypto/tls" "crypto/x509" "encoding/pem" + "errors" "fmt" - "log/slog" "net/http" "net/url" + "strings" + "time" + + "github.com/okta/okta-sdk-golang/v8/okta" - "github.com/cockroachdb/errors" "github.com/cruxstack/github-ops-app/internal/domain" - "github.com/okta/okta-sdk-golang/v6/okta" ) -// DefaultScopes defines the required OAuth scopes for the Okta API. -// these scopes are necessary for group sync functionality. +// DefaultScopes are the OAuth scopes group sync needs. var DefaultScopes = []string{"okta.groups.read", "okta.users.read"} -// certPoolKey is a typed context key for TLS certificate pool injection. -type certPoolKey struct{} - -// WithCertPool returns a new context with the given TLS certificate pool. -// used by integration tests to inject self-signed certs. -func WithCertPool(ctx context.Context, pool *x509.CertPool) context.Context { - return context.WithValue(ctx, certPoolKey{}, pool) -} - -// convertToPKCS1 converts a PEM-encoded private key to PKCS#1 format if needed. -// the Okta SDK requires PKCS#1 format (BEGIN RSA PRIVATE KEY), but Okta's -// console generates PKCS#8 keys (BEGIN PRIVATE KEY). this function detects the -// format and converts PKCS#8 to PKCS#1 automatically. -func convertToPKCS1(keyPEM []byte) ([]byte, error) { - block, _ := pem.Decode(keyPEM) - if block == nil { - return nil, errors.New("failed to decode pem block from private key") - } - - // already in PKCS#1 format - if block.Type == "RSA PRIVATE KEY" { - return keyPEM, nil - } - - // convert PKCS#8 to PKCS#1 - if block.Type == "PRIVATE KEY" { - key, err := x509.ParsePKCS8PrivateKey(block.Bytes) - if err != nil { - return nil, errors.Wrap(err, "failed to parse pkcs#8 private key") - } +// requestTimeout bounds every Okta API request, in seconds. +const requestTimeout = 30 - rsaKey, ok := key.(*rsa.PrivateKey) - if !ok { - return nil, errors.New("private key is not an rsa key") - } - - pkcs1Bytes := x509.MarshalPKCS1PrivateKey(rsaKey) - pkcs1PEM := pem.EncodeToMemory(&pem.Block{ - Type: "RSA PRIVATE KEY", - Bytes: pkcs1Bytes, - }) - - return pkcs1PEM, nil - } +// Rate-limited requests are retried after the reset time Okta returns, up to +// this many times and this many seconds apart. +const ( + rateLimitMaxRetries = 3 + rateLimitMaxBackoff = 30 +) - return nil, errors.Newf("unsupported private key type: %s", block.Type) +// Options configures a Client. +type Options struct { + // Domain is the Okta org's host, such as example.okta.com. + Domain string + ClientID string + // PrivateKey is the PEM-encoded RSA key registered with the OAuth app. + PrivateKey []byte + PrivateKeyID string + Scopes []string + // GitHubUserField is the user profile attribute holding the GitHub + // username. + GitHubUserField string + // BaseURL overrides https://, for tests. + BaseURL string + // HTTPClient is the client requests are sent with, for tests. + HTTPClient *http.Client } -// Client wraps the Okta SDK client with custom configuration. -// implements domain.OktaClient. +// Client reads Okta groups and members. Implements domain.OktaClient. type Client struct { - apiClient *okta.APIClient + api *okta.APIClient githubUserField string - logger *slog.Logger } -// compile-time assertion var _ domain.OktaClient = (*Client)(nil) -// ClientConfig contains Okta client configuration. -type ClientConfig struct { - Domain string - ClientID string - PrivateKey []byte - PrivateKeyID string - Scopes []string - GitHubUserField string - BaseURL string - Logger *slog.Logger -} - -// NewClient creates an Okta client with background context. -func NewClient(cfg *ClientConfig) (*Client, error) { - return NewClientWithContext(context.Background(), cfg) -} - -// NewClientWithContext creates an Okta client with OAuth 2.0 private key -// authentication. supports custom TLS certificate pools via context for -// testing. -func NewClientWithContext(ctx context.Context, cfg *ClientConfig) (*Client, error) { - if cfg.ClientID == "" || len(cfg.PrivateKey) == 0 { - return nil, domain.ErrMissingOAuthCreds +// New creates a Client. It makes no API calls. +func New(opts Options) (*Client, error) { + if opts.ClientID == "" { + return nil, errors.New("okta client id is required") } - - orgURL := cfg.BaseURL - if orgURL == "" { - orgURL = fmt.Sprintf("https://%s", cfg.Domain) + if opts.GitHubUserField == "" { + return nil, errors.New("okta github user field is required") } - - privateKey, err := convertToPKCS1(cfg.PrivateKey) + privateKey, err := toPKCS1(opts.PrivateKey) if err != nil { - return nil, errors.Wrap(err, "failed to convert private key") + return nil, err } - scopes := cfg.Scopes + orgURL := opts.BaseURL + if orgURL == "" { + orgURL = "https://" + opts.Domain + } + scopes := opts.Scopes if len(scopes) == 0 { scopes = DefaultScopes } - // v6 uses NewConfiguration which returns (config, error) - opts := []okta.ConfigSetter{ + // set every option that matters explicitly, so OKTA_CLIENT_* variables + // and okta.yaml files can't change how the client behaves + setters := []okta.ConfigSetter{ okta.WithOrgUrl(orgURL), okta.WithAuthorizationMode("PrivateKey"), - okta.WithClientId(cfg.ClientID), + okta.WithClientId(opts.ClientID), okta.WithPrivateKey(string(privateKey)), + okta.WithPrivateKeyId(opts.PrivateKeyID), okta.WithScopes(scopes), + okta.WithToken(""), + okta.WithCache(false), + okta.WithRequestTimeout(requestTimeout), + okta.WithRateLimitMaxRetries(rateLimitMaxRetries), + okta.WithRateLimitMaxBackOff(rateLimitMaxBackoff), } - - if cfg.PrivateKeyID != "" { - opts = append(opts, okta.WithPrivateKeyId(cfg.PrivateKeyID)) + if opts.HTTPClient != nil { + setters = append(setters, okta.WithHttpClientPtr(opts.HTTPClient)) + } else { + setters = append(setters, okta.WithHttpClientPtr(&http.Client{Timeout: requestTimeout * time.Second})) } - if certPool, ok := ctx.Value(certPoolKey{}).(*x509.CertPool); ok && certPool != nil { - httpClient := &http.Client{ - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - RootCAs: certPool, - }, - }, - } - opts = append(opts, okta.WithHttpClientPtr(httpClient)) - } - - oktaCfg, err := okta.NewConfiguration(opts...) + cfg, err := okta.NewConfiguration(setters...) if err != nil { - return nil, errors.Wrap(err, "failed to create okta configuration") + return nil, fmt.Errorf("failed to create okta configuration: %w", err) } - // v6 SDK parses OrgUrl and uses url.Parse().Hostname() which strips the port - // for testing with custom ports, we need to override the server configuration - // the SDK uses Servers[0].URL for building API call URLs - if cfg.BaseURL != "" { - parsedURL, err := url.Parse(cfg.BaseURL) - if err == nil { - // Override the default server configuration with full URL including port - oktaCfg.Servers = okta.ServerConfigurations{ - okta.ServerConfiguration{ - URL: cfg.BaseURL, - Description: "Custom Okta server (test mode)", - }, - } - // Also update Host and Scheme for consistency - // Include port in Host if present - if parsedURL.Port() != "" { - oktaCfg.Host = parsedURL.Host // Host includes port - } else { - oktaCfg.Host = parsedURL.Hostname() - } - oktaCfg.Scheme = parsedURL.Scheme + // the SDK builds request URLs from the host name alone, dropping any + // port, so a base URL with one (a test server) replaces the server list + if opts.BaseURL != "" { + u, err := url.Parse(opts.BaseURL) + if err != nil || u.Host == "" { + return nil, fmt.Errorf("invalid okta base url %q", opts.BaseURL) } - } - - apiClient := okta.NewAPIClient(oktaCfg) - - logger := cfg.Logger - if logger == nil { - logger = slog.Default() + cfg.Servers = okta.ServerConfigurations{{URL: strings.TrimRight(opts.BaseURL, "/")}} + cfg.Host = u.Host + cfg.Scheme = u.Scheme } return &Client{ - apiClient: apiClient, - githubUserField: cfg.GitHubUserField, - logger: logger, + api: okta.NewAPIClient(cfg), + githubUserField: opts.GitHubUserField, }, nil } -// GetAPIClient returns the underlying Okta SDK API client. -func (c *Client) GetAPIClient() *okta.APIClient { - return c.apiClient -} - -// ListGroups fetches all Okta groups with pagination. -func (c *Client) ListGroups(ctx context.Context) ([]okta.Group, error) { - groups, resp, err := c.apiClient.GroupAPI.ListGroups(ctx).Limit(200).Execute() - if err != nil { - return nil, errors.Wrap(err, "failed to list groups") - } - - allGroups := make([]okta.Group, 0, len(groups)) - allGroups = append(allGroups, groups...) - - for resp != nil && resp.HasNextPage() { - var nextGroups []okta.Group - resp, err = resp.Next(&nextGroups) - if err != nil { - return nil, errors.Wrap(err, "failed to fetch next page of groups") - } - allGroups = append(allGroups, nextGroups...) - } - - return allGroups, nil -} - -// GetGroupByName searches for an Okta group by exact name match. -// paginates through results in case the group is not on the first page. -func (c *Client) GetGroupByName(ctx context.Context, name string) (*okta.Group, error) { - groups, resp, err := c.apiClient.GroupAPI.ListGroups(ctx).Q(name).Limit(200).Execute() - if err != nil { - return nil, errors.Wrapf(err, "failed to search for group '%s'", name) - } - - for { - for i := range groups { - group := &groups[i] - groupName := extractGroupName(group) - if groupName == name { - return group, nil - } - } - - if resp == nil || !resp.HasNextPage() { - break - } - - var nextGroups []okta.Group - resp, err = resp.Next(&nextGroups) - if err != nil { - return nil, errors.Wrapf(err, "failed to fetch next page while searching for group '%s'", name) - } - groups = nextGroups +// toPKCS1 returns a PEM-encoded RSA private key in PKCS#1 form, which the +// Okta SDK requires. Okta's console generates PKCS#8 keys, so those are +// converted. Escaped newlines ("\n") are accepted. +func toPKCS1(keyPEM []byte) ([]byte, error) { + if len(keyPEM) == 0 { + return nil, errors.New("okta private key is required") } - - return nil, errors.Newf("group '%s' not found", name) -} - -// GetGroupMembers fetches GitHub usernames for all active members of an Okta -// group. paginates through all members. only includes users with status -// "ACTIVE" to exclude suspended/deprovisioned users. skips users without a -// GitHub username in their profile and tracks them separately. -func (c *Client) GetGroupMembers(ctx context.Context, groupID string) (*domain.GroupMembersResult, error) { - users, resp, err := c.apiClient.GroupAPI.ListGroupUsers(ctx, groupID).Limit(200).Execute() - if err != nil { - return nil, errors.Wrapf(err, "failed to list members for group '%s'", groupID) - } - - result := &domain.GroupMembersResult{ - Members: make([]string, 0, len(users)), - SkippedNoGitHubUsername: []string{}, + keyPEM = []byte(strings.ReplaceAll(string(keyPEM), `\n`, "\n")) + block, _ := pem.Decode(keyPEM) + if block == nil { + return nil, errors.New("failed to decode okta private key: not pem encoded") } - for { - c.processGroupUsers(users, result) - - if resp == nil || !resp.HasNextPage() { - break + switch block.Type { + case "RSA PRIVATE KEY": + if _, err := x509.ParsePKCS1PrivateKey(block.Bytes); err != nil { + return nil, fmt.Errorf("failed to parse okta private key: %w", err) } - - var nextUsers []okta.User - resp, err = resp.Next(&nextUsers) + return keyPEM, nil + case "PRIVATE KEY": + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { - return nil, errors.Wrapf(err, "failed to fetch next page of members for group '%s'", groupID) + return nil, fmt.Errorf("failed to parse okta private key: %w", err) } - users = nextUsers - } - - return result, nil -} - -// processGroupUsers extracts GitHub usernames from a batch of Okta users -// and appends them to the result. -func (c *Client) processGroupUsers(users []okta.User, result *domain.GroupMembersResult) { - for _, user := range users { - if user.GetStatus() != "ACTIVE" { - continue - } - - profile := user.GetProfile() - additionalProps := profile.AdditionalProperties - if additionalProps == nil { - continue - } - - githubUsername, ok := additionalProps[c.githubUserField] - if ok { - if username, ok := githubUsername.(string); ok && username != "" { - result.Members = append(result.Members, username) - continue - } - } - - // user doesn't have github username, track by email - if email, ok := additionalProps["email"].(string); ok && email != "" { - result.SkippedNoGitHubUsername = append( - result.SkippedNoGitHubUsername, email) - } else if profile.GetEmail() != "" { - result.SkippedNoGitHubUsername = append( - result.SkippedNoGitHubUsername, profile.GetEmail()) + rsaKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("okta private key is not an rsa key") } + return pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(rsaKey), + }), nil + default: + return nil, fmt.Errorf("unsupported okta private key type '%s'", block.Type) } } diff --git a/internal/okta/groups.go b/internal/okta/groups.go index a0a5acb..a7603d7 100644 --- a/internal/okta/groups.go +++ b/internal/okta/groups.go @@ -2,116 +2,213 @@ package okta import ( "context" - "log/slog" + "encoding/json" + "fmt" "regexp" + "strings" + + "github.com/okta/okta-sdk-golang/v8/okta" - "github.com/cockroachdb/errors" "github.com/cruxstack/github-ops-app/internal/domain" - oktasdk "github.com/okta/okta-sdk-golang/v6/okta" ) -// extractGroupName returns the group name from either profile type. -func extractGroupName(group *oktasdk.Group) string { +// pageSize is the number of groups or users requested per page. +const pageSize = 200 + +// syncedStatuses are the user statuses whose members belong in GitHub teams. +// They are the statuses of users who can sign in, or will once they reset +// their password or are unlocked, so a password expiring does not remove +// anyone. STAGED and PROVISIONED users have not activated yet, and +// SUSPENDED and DEPROVISIONED users have been offboarded. +var syncedStatuses = map[string]bool{ + "ACTIVE": true, + "PASSWORD_EXPIRED": true, + "LOCKED_OUT": true, + "RECOVERY": true, +} + +// groupName returns the group name from either profile type. +func groupName(group *okta.Group) string { if group == nil || group.Profile == nil { return "" } - if group.Profile.OktaUserGroupProfile != nil { - return group.Profile.OktaUserGroupProfile.GetName() + if p := group.Profile.OktaUserGroupProfile; p != nil { + return p.GetName() } - if group.Profile.OktaActiveDirectoryGroupProfile != nil { - return group.Profile.OktaActiveDirectoryGroupProfile.GetName() + if p := group.Profile.OktaActiveDirectoryGroupProfile; p != nil { + return p.GetName() } return "" } -// GetGroupsByPattern fetches all Okta groups matching a regex pattern. +// listGroups fetches every Okta group. +func (c *Client) listGroups(ctx context.Context) ([]okta.Group, error) { + groups, resp, err := c.api.GroupAPI.ListGroups(ctx).Limit(pageSize).Execute() + if err != nil { + return nil, fmt.Errorf("failed to list okta groups: %w", err) + } + for resp != nil && resp.HasNextPage() { + var next []okta.Group + if resp, err = resp.Next(&next); err != nil { + return nil, fmt.Errorf("failed to list okta groups: %w", err) + } + groups = append(groups, next...) + } + return groups, nil +} + +// getGroupByName finds the group with exactly the given name. +func (c *Client) getGroupByName(ctx context.Context, name string) (*okta.Group, error) { + groups, resp, err := c.api.GroupAPI.ListGroups(ctx).Q(name).Limit(pageSize).Execute() + if err != nil { + return nil, fmt.Errorf("failed to search for okta group '%s': %w", name, err) + } + for { + for i := range groups { + if groupName(&groups[i]) == name { + return &groups[i], nil + } + } + if resp == nil || !resp.HasNextPage() { + break + } + groups = nil + if resp, err = resp.Next(&groups); err != nil { + return nil, fmt.Errorf("failed to search for okta group '%s': %w", name, err) + } + } + return nil, fmt.Errorf("okta group '%s': %w", name, domain.ErrGroupNotFound) +} + +// getGroupMembers fetches the GitHub usernames of a group's members whose +// status is synced. Members without a valid GitHub username are listed by +// email in SkippedNoGitHubUsername. +func (c *Client) getGroupMembers(ctx context.Context, groupID string) (*domain.GroupMembersResult, error) { + users, resp, err := c.api.GroupAPI.ListGroupUsers(ctx, groupID).Limit(pageSize).Execute() + if err != nil { + return nil, fmt.Errorf("failed to list members of okta group '%s': %w", groupID, err) + } + + result := &domain.GroupMembersResult{ + Members: []string{}, + SkippedNoGitHubUsername: []string{}, + } + for { + for i := range users { + c.addMember(&users[i], result) + } + if resp == nil || !resp.HasNextPage() { + break + } + users = nil + if resp, err = resp.Next(&users); err != nil { + return nil, fmt.Errorf("failed to list members of okta group '%s': %w", groupID, err) + } + } + return result, nil +} + +// addMember adds a user's GitHub username to result, or records why it was +// skipped. +func (c *Client) addMember(user *okta.User, result *domain.GroupMembersResult) { + if !syncedStatuses[user.GetStatus()] { + return + } + + profile := user.GetProfile() + username := normalizeUsername(c.profileString(&profile, c.githubUserField)) + if domain.IsValidGitHubLogin(username) { + result.Members = append(result.Members, username) + return + } + + who := profile.GetEmail() + if who == "" { + who = profile.GetLogin() + } + if username != "" { + who = fmt.Sprintf("%s (invalid github username '%s')", who, username) + } + result.SkippedNoGitHubUsername = append(result.SkippedNoGitHubUsername, who) +} + +// profileString returns a profile attribute as a string. It covers both +// standard attributes, which the SDK decodes into fields, and custom ones, +// which it keeps in AdditionalProperties. +func (c *Client) profileString(profile *okta.UserProfile, field string) string { + if v, ok := profile.AdditionalProperties[field].(string); ok { + return v + } + raw, err := json.Marshal(profile) + if err != nil { + return "" + } + var attrs map[string]any + if err := json.Unmarshal(raw, &attrs); err != nil { + return "" + } + v, _ := attrs[field].(string) + return v +} + +// normalizeUsername trims whitespace and a leading "@" that people often +// include when they type a GitHub username. +func normalizeUsername(s string) string { + return strings.TrimPrefix(strings.TrimSpace(s), "@") +} + +// GetGroupsByPattern fetches every Okta group whose name matches pattern, +// with its members. A group whose members can't be fetched is returned with +// Err set, so the caller can report it without syncing it. func (c *Client) GetGroupsByPattern(ctx context.Context, pattern string) ([]*domain.GroupInfo, error) { if pattern == "" { return nil, domain.ErrEmptyPattern } - re, err := regexp.Compile(pattern) if err != nil { - return nil, errors.Wrapf(domain.ErrInvalidPattern, "'%s'", pattern) + return nil, fmt.Errorf("pattern '%s': %w", pattern, domain.ErrInvalidPattern) } - allGroups, err := c.ListGroups(ctx) + groups, err := c.listGroups(ctx) if err != nil { return nil, err } var matched []*domain.GroupInfo - for _, group := range allGroups { - groupName := extractGroupName(&group) - if groupName == "" { + for i := range groups { + name := groupName(&groups[i]) + if name == "" || !re.MatchString(name) { continue } - if re.MatchString(groupName) { - result, err := c.GetGroupMembers(ctx, group.GetId()) - if err != nil { - c.logger.Warn("failed to get group members, skipping", - slog.String("group", groupName), - slog.String("error", err.Error())) - continue - } - - matched = append(matched, &domain.GroupInfo{ - ID: group.GetId(), - Name: groupName, - Members: result.Members, - SkippedNoGitHubUsername: result.SkippedNoGitHubUsername, - }) + info := &domain.GroupInfo{ID: groups[i].GetId(), Name: name} + members, err := c.getGroupMembers(ctx, info.ID) + if err != nil { + info.Err = err + } else { + info.Members = members.Members + info.SkippedNoGitHubUsername = members.SkippedNoGitHubUsername } + matched = append(matched, info) } - return matched, nil } -// GetGroupInfo fetches details for a single Okta group by name. -func (c *Client) GetGroupInfo(ctx context.Context, groupName string) (*domain.GroupInfo, error) { - group, err := c.GetGroupByName(ctx, groupName) +// GetGroupInfo fetches a single Okta group by its exact name, with its +// members. +func (c *Client) GetGroupInfo(ctx context.Context, name string) (*domain.GroupInfo, error) { + group, err := c.getGroupByName(ctx, name) if err != nil { return nil, err } - - result, err := c.GetGroupMembers(ctx, group.GetId()) + members, err := c.getGroupMembers(ctx, group.GetId()) if err != nil { return nil, err } - - name := extractGroupName(group) - if name == "" { - name = groupName - } - return &domain.GroupInfo{ ID: group.GetId(), Name: name, - Members: result.Members, - SkippedNoGitHubUsername: result.SkippedNoGitHubUsername, + Members: members.Members, + SkippedNoGitHubUsername: members.SkippedNoGitHubUsername, }, nil } - -// FilterEnabledGroups filters Okta groups to only those in the enabled list. -// returns all groups if enabled list is empty. -func FilterEnabledGroups(groups []oktasdk.Group, enabledNames []string) []oktasdk.Group { - if len(enabledNames) == 0 { - return groups - } - - enabledMap := make(map[string]bool) - for _, name := range enabledNames { - enabledMap[name] = true - } - - var filtered []oktasdk.Group - for _, group := range groups { - groupName := extractGroupName(&group) - if groupName != "" && enabledMap[groupName] { - filtered = append(filtered, group) - } - } - - return filtered -} diff --git a/internal/okta/okta_test.go b/internal/okta/okta_test.go new file mode 100644 index 0000000..1a9c340 --- /dev/null +++ b/internal/okta/okta_test.go @@ -0,0 +1,200 @@ +package okta + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + "net/http/httptest" + "slices" + "strings" + "testing" +) + +func testKeyPEM(t *testing.T, pkcs8 bool) []byte { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + if !pkcs8 { + return pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}) +} + +func TestToPKCS1(t *testing.T) { + pkcs1 := testKeyPEM(t, false) + pkcs8 := testKeyPEM(t, true) + escaped := []byte(strings.ReplaceAll(string(pkcs8), "\n", `\n`)) + + for name, in := range map[string][]byte{"pkcs1": pkcs1, "pkcs8": pkcs8, "escaped": escaped} { + out, err := toPKCS1(in) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + block, _ := pem.Decode(out) + if block == nil || block.Type != "RSA PRIVATE KEY" { + t.Fatalf("%s: output is not a pkcs1 pem block", name) + } + } + + for name, in := range map[string][]byte{ + "empty": nil, + "garbage": []byte("nope"), + "cert": pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: []byte{1}}), + } { + if _, err := toPKCS1(in); err == nil { + t.Errorf("%s: toPKCS1 succeeded, want error", name) + } + } +} + +func TestNormalizeUsername(t *testing.T) { + tests := []struct{ in, want string }{ + {"octocat", "octocat"}, + {"\t@octocat\n", "octocat"}, + {"", ""}, + } + for _, tt := range tests { + if got := normalizeUsername(tt.in); got != tt.want { + t.Errorf("normalizeUsername(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +// fakeOkta serves the token endpoint, two pages of groups, and group members. +func fakeOkta(t *testing.T, members map[string][]map[string]any) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + writeJSON := func(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) + } + group := func(id, name string) map[string]any { + return map[string]any{"id": id, "type": "OKTA_GROUP", "objectClass": []string{"okta:user_group"}, "profile": map[string]any{"name": name}} + } + + mux.HandleFunc("POST /oauth2/v1/token", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, map[string]any{"token_type": "Bearer", "expires_in": 3600, "access_token": "okta-token", "scope": "okta.groups.read okta.users.read"}) + }) + mux.HandleFunc("GET /api/v1/groups", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer okta-token" { + t.Errorf("groups request authorization = %q", r.Header.Get("Authorization")) + } + if q := r.URL.Query().Get("q"); q != "" { + writeJSON(w, []any{group("g-eng", "eng-platform"), group("g-eng2", q)}) + return + } + if r.URL.Query().Get("after") == "" { + w.Header().Set("Link", fmt.Sprintf(`; rel="next"`, r.Host)) + writeJSON(w, []any{group("g-eng", "eng-platform"), group("g-sales", "sales")}) + return + } + writeJSON(w, []any{group("g-broken", "eng-broken"), group("g-data", "eng-data")}) + }) + mux.HandleFunc("GET /api/v1/groups/{id}/users", func(w http.ResponseWriter, r *http.Request) { + users, ok := members[r.PathValue("id")] + if !ok { + w.WriteHeader(http.StatusInternalServerError) + writeJSON(w, map[string]any{"errorCode": "E0000009", "errorSummary": "internal error"}) + return + } + writeJSON(w, users) + }) + + srv := httptest.NewTLSServer(mux) + t.Cleanup(srv.Close) + return srv +} + +func user(status, email string, profile map[string]any) map[string]any { + p := map[string]any{"email": email, "login": email} + for k, v := range profile { + p[k] = v + } + return map[string]any{"id": "u-" + email, "status": status, "profile": p} +} + +func newTestClient(t *testing.T, srv *httptest.Server, field string) *Client { + t.Helper() + c, err := New(Options{ + Domain: "example.okta.com", + ClientID: "client", + PrivateKey: testKeyPEM(t, true), + GitHubUserField: field, + BaseURL: srv.URL, + HTTPClient: srv.Client(), + }) + if err != nil { + t.Fatal(err) + } + return c +} + +func TestGetGroupsByPattern(t *testing.T) { + srv := fakeOkta(t, map[string][]map[string]any{ + "g-eng": { + user("ACTIVE", "a@x.com", map[string]any{"githubUsername": "alice"}), + user("PASSWORD_EXPIRED", "b@x.com", map[string]any{"githubUsername": " @bob "}), + user("LOCKED_OUT", "c@x.com", map[string]any{"githubUsername": "carol"}), + user("SUSPENDED", "d@x.com", map[string]any{"githubUsername": "dave"}), + user("DEPROVISIONED", "e@x.com", map[string]any{"githubUsername": "erin"}), + user("STAGED", "f@x.com", map[string]any{"githubUsername": "frank"}), + user("ACTIVE", "g@x.com", nil), + user("ACTIVE", "h@x.com", map[string]any{"githubUsername": "../../admin"}), + }, + "g-data": {user("ACTIVE", "i@x.com", map[string]any{"githubUsername": "ivan"})}, + }) + c := newTestClient(t, srv, "githubUsername") + + groups, err := c.GetGroupsByPattern(context.Background(), "^eng-") + if err != nil { + t.Fatal(err) + } + if len(groups) != 3 { + t.Fatalf("matched %d groups, want 3 across both pages", len(groups)) + } + + byName := map[string]int{} + for i, g := range groups { + byName[g.Name] = i + } + eng := groups[byName["eng-platform"]] + if got := strings.Join(eng.Members, ","); got != "alice,bob,carol" { + t.Errorf("members = %s, want alice,bob,carol", got) + } + if len(eng.SkippedNoGitHubUsername) != 2 || !slices.Contains(eng.SkippedNoGitHubUsername, "g@x.com") || + !strings.Contains(eng.SkippedNoGitHubUsername[1], "invalid github username") { + t.Errorf("skipped = %v, want the user without a username and the invalid one", eng.SkippedNoGitHubUsername) + } + if groups[byName["eng-broken"]].Err == nil { + t.Error("group whose members failed to load has no error") + } + if groups[byName["eng-data"]].Err != nil { + t.Errorf("eng-data error = %v", groups[byName["eng-data"]].Err) + } +} + +func TestGetGroupInfo_StandardProfileField(t *testing.T) { + srv := fakeOkta(t, map[string][]map[string]any{ + "g-eng2": {user("ACTIVE", "a@x.com", map[string]any{"nickName": "alice"})}, + }) + c := newTestClient(t, srv, "nickName") + + group, err := c.GetGroupInfo(context.Background(), "Engineering") + if err != nil { + t.Fatal(err) + } + if group.ID != "g-eng2" || strings.Join(group.Members, ",") != "alice" { + t.Errorf("group = %+v, want g-eng2 with alice", group) + } +} diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..58b8e1d --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,414 @@ +// Package service runs the application for both entry points, the HTTP +// server and the Lambda function. It loads the configuration through a +// github-app-setup-go runtime, so the configuration can be loaded lazily, +// retried, and reloaded after the installer saves new credentials, and it +// serves the health, installer, and application routes. +package service + +import ( + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "slices" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/cruxstack/github-app-setup-go/configstore" + "github.com/cruxstack/github-app-setup-go/ghappsetup" + "github.com/cruxstack/github-app-setup-go/installer" + "github.com/go-chi/chi/v5" + "github.com/joho/godotenv" + + "github.com/cruxstack/github-ops-app/internal/app" + "github.com/cruxstack/github-ops-app/internal/config" + "github.com/cruxstack/github-ops-app/internal/setup" +) + +// JobTimeout bounds a webhook, a scheduled action, or a background job. +// Requests run detached from the client, so a scheduled sync is not cut +// short when its caller disconnects. +const JobTimeout = app.JobTimeout + +// Mode is how the service runs. +type Mode int + +const ( + // ModeServer loads the configuration at startup and on reload. + ModeServer Mode = iota + // ModeLambda loads the configuration on first use, retrying in later + // invocations until it succeeds. + ModeLambda +) + +// Options configures a Service. +type Options struct { + Mode Mode + Logger *slog.Logger + // Env is where configuration is read from. Defaults to the process + // environment. + Env config.Env + // Store replaces the installer's credential store, for tests. + Store configstore.Store + // AppOptions are passed to app.New. + AppOptions []app.Option + // RetryInterval is the wait between load attempts in ModeLambda. + // Defaults to one second. + RetryInterval time.Duration +} + +// Service serves the application and keeps its configuration loaded. +type Service struct { + boot *config.Bootstrap + opts Options + appOpts []app.Option + logger *slog.Logger + runtime *ghappsetup.Runtime + current atomic.Pointer[app.App] + handler http.Handler + jobs *jobs // nil in ModeLambda + + // envFile is the installer's env file, read on every load, and + // explicit are the variables set in the process environment at startup, + // which take precedence over it + envFile string + explicit map[string]bool +} + +// New creates a Service. It does not load the application configuration; +// see Start and EnsureLoaded. +func New(boot *config.Bootstrap, opts Options) (*Service, error) { + if opts.Logger == nil { + opts.Logger = slog.Default() + } + if opts.Env.Lookup == nil { + opts.Env = config.ProcessEnv() + } + s := &Service{boot: boot, opts: opts, logger: opts.Logger} + s.appOpts = slices.Clone(opts.AppOptions) + if opts.Mode == ModeServer { + // answer webhooks at once and sync afterwards + s.jobs = &jobs{logger: opts.Logger} + s.appOpts = append(s.appOpts, app.WithBackground(s.jobs)) + + if boot.Installer.Enabled && boot.Installer.StorageMode == configstore.StorageModeEnvFile { + s.envFile = boot.Installer.StorageDir + s.explicit = make(map[string]bool) + for _, kv := range os.Environ() { + key, _, _ := strings.Cut(kv, "=") + s.explicit[key] = true + } + } + } + + store := opts.Store + if store == nil { + store = setup.NopStore{} + if boot.Installer.Enabled { + var err error + if store, err = configstore.NewFromEnv(); err != nil { + return nil, fmt.Errorf("failed to create installer credential store: %w", err) + } + } + } + store = setup.Store{Store: store} + + runtimeCfg := ghappsetup.Config{Store: store, LoadFunc: s.load} + if opts.Mode == ModeLambda { + runtimeCfg.MaxRetries = 3 + runtimeCfg.RetryInterval = cmp.Or(opts.RetryInterval, time.Second) + } + runtime, err := ghappsetup.NewRuntime(runtimeCfg) + if err != nil { + return nil, fmt.Errorf("failed to create runtime: %w", err) + } + s.runtime = runtime + + var installerHandler http.Handler + if boot.Installer.Enabled { + if installerHandler, err = s.newInstaller(); err != nil { + return nil, err + } + } + s.handler = s.newRouter(installerHandler) + return s, nil +} + +// load reads the configuration and replaces the running application. +func (s *Service) load(ctx context.Context) error { + env := s.opts.Env + if s.envFile != "" { + // credentials the installer saved, read again on every load so a + // reload picks up changes to the file + values, err := godotenv.Read(s.envFile) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("failed to read installer env file '%s': %w", s.envFile, err) + } + base := env.Lookup + env.Lookup = func(key string) (string, bool) { + if !s.explicit[key] { + if v, ok := values[key]; ok { + return v, true + } + } + return base(key) + } + } + + cfg, err := config.Load(ctx, env) + if err != nil { + return err + } + a, err := app.New(cfg, s.logger, s.appOpts...) + if err != nil { + return err + } + s.current.Store(a) + s.logger.InfoContext(ctx, "configuration loaded", + "github_org", cfg.GitHub.Org, + "okta_sync", cfg.Okta.Enabled, + "pr_compliance", cfg.PRCompliance.Enabled, + "security_alerts", cfg.SecurityAlerts.Enabled, + "slack", cfg.Slack.Enabled, + "admin_api", cfg.AdminToken != "") + return nil +} + +// Start loads the configuration and then reloads it on SIGHUP or when the +// installer saves credentials, until ctx is done. It is for ModeServer. If +// the first load fails, Start returns the error, unless the installer is +// enabled: then the service keeps running so the app can be set up. +func (s *Service) Start(ctx context.Context) error { + if err := s.runtime.Reload(ctx); err != nil { + if !s.boot.Installer.Enabled { + return err + } + s.logger.WarnContext(ctx, "configuration not loaded; waiting for the github app to be set up", + "setup_url", s.boot.Server.BasePath+"/setup", + "error", err.Error()) + } + s.runtime.ListenForReloads(ctx) + return nil +} + +// EnsureLoaded loads the configuration if it is not loaded yet. It is for +// ModeLambda: a failed load is retried on the next call. +func (s *Service) EnsureLoaded(ctx context.Context) (*app.App, error) { + if a := s.current.Load(); a != nil { + return a, nil + } + if s.opts.Mode != ModeLambda { + return nil, errors.New("configuration is not loaded") + } + if err := s.runtime.EnsureLoaded(ctx); err != nil { + return nil, err + } + return s.current.Load(), nil +} + +// Wait waits until background jobs, such as webhook-triggered syncs, finish +// or ctx is done. +func (s *Service) Wait(ctx context.Context) error { + if s.jobs == nil { + return nil + } + done := make(chan struct{}) + go func() { + s.jobs.wg.Wait() + close(done) + }() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// jobs runs background jobs, each with its own JobTimeout, and tracks them +// so shutdown can wait for them. +type jobs struct { + wg sync.WaitGroup + logger *slog.Logger +} + +func (j *jobs) Go(f func(ctx context.Context)) { + j.wg.Go(func() { + defer func() { + if v := recover(); v != nil { + j.logger.Error("panic in background job", "panic", v) + } + }() + ctx, cancel := context.WithTimeout(context.Background(), JobTimeout) + defer cancel() + f(ctx) + }) +} + +// Ready returns true when the configuration is loaded. +func (s *Service) Ready() bool { + return s.current.Load() != nil +} + +// Handler returns the service's HTTP handler. +func (s *Service) Handler() http.Handler { + return s.handler +} + +// RunScheduled runs a scheduled action with a detached, bounded context. +func (s *Service) RunScheduled(ctx context.Context, action string) error { + a, err := s.EnsureLoaded(ctx) + if err != nil { + return fmt.Errorf("service not ready: %w", err) + } + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), JobTimeout) + defer cancel() + return a.RunScheduled(ctx, action) +} + +func (s *Service) newInstaller() (http.Handler, error) { + cfg := installer.Config{ + Store: s.runtime.Store(), + Manifest: setup.Manifest(), + AppDisplayName: "GitHub Ops App", + GitHubURL: s.boot.Installer.GitHubURL, + GitHubOrg: s.boot.Installer.GitHubOrg, + OnReloadNeeded: s.runtime.ReloadCallback(), + } + if public := s.boot.Server.PublicURL; public != "" { + cfg.RedirectURL = public + cfg.WebhookURL = public + "/webhooks" + } + h, err := installer.New(cfg) + if err != nil { + return nil, fmt.Errorf("failed to create installer: %w", err) + } + return h, nil +} + +// newRouter serves, under the base path: +// +// GET /healthz liveness, always 200 +// GET /readyz 200 once the configuration is loaded +// GET /setup, /callback, ... the installer, when enabled +// everything else the application, once loaded +func (s *Service) newRouter(installerHandler http.Handler) http.Handler { + base := s.boot.Server.BasePath + r := chi.NewRouter() + r.Use(s.recoverer, s.detach) + + r.Get(base+"/healthz", func(w http.ResponseWriter, _ *http.Request) { + writeStatus(w, http.StatusOK, "ok") + }) + r.Get(base+"/readyz", func(w http.ResponseWriter, _ *http.Request) { + if !s.Ready() { + writeStatus(w, http.StatusServiceUnavailable, "not ready") + return + } + writeStatus(w, http.StatusOK, "ready") + }) + + if installerHandler != nil { + h := http.StripPrefix(base, s.guardCallback(installerHandler)) + r.Get(base+"/setup", h.ServeHTTP) + r.Get(base+"/setup/", h.ServeHTTP) + r.Post(base+"/setup/disable", h.ServeHTTP) + r.Get(base+"/callback", h.ServeHTTP) + } + + r.NotFound(s.serveApp) + r.MethodNotAllowed(s.serveApp) + return r +} + +// serveApp passes a request to the loaded application. +func (s *Service) serveApp(w http.ResponseWriter, r *http.Request) { + a, err := s.EnsureLoaded(r.Context()) + if err != nil { + s.logger.WarnContext(r.Context(), "request received before the configuration loaded", + "path", r.URL.Path, "error", err.Error()) + w.Header().Set("Retry-After", "30") + writeStatus(w, http.StatusServiceUnavailable, "service not configured") + return + } + a.Handler().ServeHTTP(w, r) +} + +// guardCallback refuses to exchange installer codes once an app is +// registered or the configuration is loaded, so whoever can reach the +// service cannot replace the app's credentials with their own after setup, +// even before the installer is disabled. +func (s *Service) guardCallback(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.TrimSuffix(r.URL.Path, "/") != "/callback" { + next.ServeHTTP(w, r) + return + } + if s.Ready() { + writeStatus(w, http.StatusConflict, "the github app is already configured") + return + } + status, err := s.runtime.Store().Status(r.Context()) + if err != nil { + s.logger.ErrorContext(r.Context(), "failed to read installer status", "error", err.Error()) + writeStatus(w, http.StatusInternalServerError, "failed to read installer status") + return + } + if status != nil && status.Registered { + writeStatus(w, http.StatusConflict, "the github app is already configured") + return + } + next.ServeHTTP(w, r) + }) +} + +// detach gives requests that run jobs a context that is not canceled when +// the client disconnects. +func (s *Service) detach(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + next.ServeHTTP(w, r) + return + } + ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), JobTimeout) + defer cancel() + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// recoverer turns a panic into a 500 response and an error log. +func (s *Service) recoverer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if v := recover(); v != nil { + if err, ok := v.(error); ok && errors.Is(err, http.ErrAbortHandler) { + panic(v) + } + s.logger.ErrorContext(r.Context(), "panic while serving request", + "method", r.Method, + "path", r.URL.Path, + "panic", v) + writeStatus(w, http.StatusInternalServerError, "internal error") + } + }() + next.ServeHTTP(w, r) + }) +} + +func writeStatus(w http.ResponseWriter, status int, message string) { + key := "status" + if status >= 400 { + key = "error" + } + body, _ := json.Marshal(map[string]string{key: message}) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(status) + _, _ = w.Write(body) +} diff --git a/internal/service/service_test.go b/internal/service/service_test.go new file mode 100644 index 0000000..6471e25 --- /dev/null +++ b/internal/service/service_test.go @@ -0,0 +1,345 @@ +package service + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + "time" + + "github.com/cruxstack/github-app-setup-go/configstore" + + "github.com/cruxstack/github-ops-app/internal/app" + "github.com/cruxstack/github-ops-app/internal/config" + "github.com/cruxstack/github-ops-app/internal/fakes" +) + +var discard = slog.New(slog.DiscardHandler) + +func keyPEM(t *testing.T) string { + t.Helper() + k, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)})) +} + +// mapEnv is a config.Env backed by a map that tests can change. +type mapEnv struct { + mu sync.Mutex + m map[string]string +} + +func (e *mapEnv) set(k, v string) { + e.mu.Lock() + defer e.mu.Unlock() + e.m[k] = v +} + +func (e *mapEnv) env() config.Env { + return config.Env{Lookup: func(k string) (string, bool) { + e.mu.Lock() + defer e.mu.Unlock() + v, ok := e.m[k] + return v, ok + }} +} + +func get(t *testing.T, h http.Handler, method, path string) *httptest.ResponseRecorder { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), method, path, nil)) + return rec +} + +func newService(t *testing.T, env config.Env, mode Mode, store configstore.Store) *Service { + t.Helper() + boot, err := config.LoadBootstrap(env) + if err != nil { + t.Fatal(err) + } + svc, err := New(boot, Options{ + Mode: mode, + Logger: discard, + Env: env, + Store: store, + AppOptions: []app.Option{app.WithGitHubClient(&fakes.GitHub{})}, + RetryInterval: time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + return svc +} + +func TestServerMode_FailsFastWithoutInstaller(t *testing.T) { + e := &mapEnv{m: map[string]string{"APP_GITHUB_ORG": "acme"}} + svc := newService(t, e.env(), ModeServer, nil) + if err := svc.Start(t.Context()); err == nil { + t.Fatal("Start succeeded without credentials") + } +} + +func TestServerMode_Ready(t *testing.T) { + e := &mapEnv{m: map[string]string{ + "APP_GITHUB_ORG": "acme", + "APP_GITHUB_APP_ID": "1", + "APP_GITHUB_APP_PRIVATE_KEY": keyPEM(t), + "APP_GITHUB_WEBHOOK_SECRET": "s", + "APP_BASE_PATH": "/prod", + }} + svc := newService(t, e.env(), ModeServer, nil) + h := svc.Handler() + + if rec := get(t, h, http.MethodGet, "/prod/readyz"); rec.Code != http.StatusServiceUnavailable { + t.Errorf("readyz before start: %d", rec.Code) + } + if rec := get(t, h, http.MethodPost, "/prod/webhooks"); rec.Code != http.StatusServiceUnavailable { + t.Errorf("webhook before start: %d", rec.Code) + } + if err := svc.Start(t.Context()); err != nil { + t.Fatal(err) + } + if rec := get(t, h, http.MethodGet, "/prod/readyz"); rec.Code != http.StatusOK { + t.Errorf("readyz after start: %d", rec.Code) + } + if rec := get(t, h, http.MethodGet, "/prod/healthz"); rec.Code != http.StatusOK { + t.Errorf("healthz: %d", rec.Code) + } + // reaches the application, which requires a signature + if rec := get(t, h, http.MethodPost, "/prod/webhooks"); rec.Code != http.StatusUnauthorized { + t.Errorf("unsigned webhook: %d, want 401 from the app", rec.Code) + } + if rec := get(t, h, http.MethodGet, "/prod/setup"); rec.Code != http.StatusNotFound { + t.Errorf("setup without installer: %d, want 404", rec.Code) + } +} + +// installerEnv sets up the process environment for the installer with an +// env file store. The installer's store writes the process environment, so +// every variable it writes is restored after the test. +func installerEnv(t *testing.T) string { + t.Helper() + for _, k := range []string{ + "APP_GITHUB_APP_ID", "APP_GITHUB_APP_PRIVATE_KEY", "APP_GITHUB_WEBHOOK_SECRET", + configstore.EnvGitHubAppID, configstore.EnvGitHubAppPrivateKey, configstore.EnvGitHubWebhookSecret, + configstore.EnvGitHubClientID, configstore.EnvGitHubClientSecret, + configstore.EnvGitHubAppSlug, configstore.EnvGitHubAppHTMLURL, + } { + t.Setenv(k, "") + if err := os.Unsetenv(k); err != nil { + t.Fatal(err) + } + } + envFile := filepath.Join(t.TempDir(), ".env") + t.Setenv("APP_GITHUB_ORG", "acme") + t.Setenv("APP_BASE_PATH", "/prod") + t.Setenv("APP_PUBLIC_URL", "https://ops.example.com/prod") + t.Setenv(configstore.EnvGitHubAppInstallerEnabled, "true") + t.Setenv(configstore.EnvStorageMode, configstore.StorageModeEnvFile) + t.Setenv(configstore.EnvStorageDir, envFile) + return envFile +} + +func waitReady(t *testing.T, svc *Service) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !svc.Ready() { + if time.Now().After(deadline) { + t.Fatal("service did not become ready") + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestInstaller_SetupSaveReloadAndRestart(t *testing.T) { + envFile := installerEnv(t) + svc := newService(t, config.ProcessEnv(), ModeServer, nil) + h := svc.Handler() + + if err := svc.Start(t.Context()); err != nil { + t.Fatalf("Start with the installer enabled must not fail: %v", err) + } + if svc.Ready() { + t.Fatal("ready without credentials") + } + + rec := get(t, h, http.MethodGet, "/prod/setup") + if rec.Code != http.StatusOK { + t.Fatalf("setup page: %d", rec.Code) + } + page := rec.Body.String() + for _, want := range []string{ + `https://ops.example.com/prod/callback`, + `https://ops.example.com/prod/webhooks`, + `"public":false`, + `"vulnerability_alerts":"read"`, + `"administration":"read"`, + `/organizations/acme/settings/apps/new`, + } { + if !strings.Contains(page, want) { + t.Errorf("setup page lacks %s", want) + } + } + + // what the installer's callback does after github creates the app + key := keyPEM(t) + creds := &configstore.AppCredentials{AppID: 42, AppSlug: "ops", PrivateKey: key, WebhookSecret: "whsec", ClientID: "cid", ClientSecret: "csec"} + if err := svc.runtime.Store().Save(t.Context(), creds); err != nil { + t.Fatal(err) + } + svc.runtime.ReloadCallback()() + waitReady(t, svc) + + if got := svc.current.Load().Config().GitHub.AppID; got != 42 { + t.Errorf("app id after reload = %d", got) + } + if rec := get(t, h, http.MethodGet, "/prod/callback?code=abcdefghij0123"); rec.Code != http.StatusConflict { + t.Errorf("callback after setup: %d, want 409", rec.Code) + } + + file, err := os.ReadFile(envFile) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(file), "APP_GITHUB_APP_ID") || !strings.Contains(string(file), "APP_GITHUB_WEBHOOK_SECRET") { + t.Errorf("env file lacks the app's credential names:\n%s", file) + } + + // a reload picks up a secret rotated in the env file + rotated := regexp.MustCompile(`(?m)^APP_GITHUB_WEBHOOK_SECRET=.*$`).ReplaceAll(file, []byte(`APP_GITHUB_WEBHOOK_SECRET="rotated"`)) + if err := os.WriteFile(envFile, rotated, 0o600); err != nil { + t.Fatal(err) + } + svc.runtime.ReloadCallback()() + waitFor(t, func() bool { return svc.current.Load().Config().GitHub.WebhookSecret == "rotated" }) + + // a restarted service reads the credentials from the env file + for _, k := range []string{"APP_GITHUB_APP_ID", "APP_GITHUB_APP_PRIVATE_KEY", "APP_GITHUB_WEBHOOK_SECRET"} { + if err := os.Unsetenv(k); err != nil { + t.Fatal(err) + } + } + restarted := newService(t, config.ProcessEnv(), ModeServer, nil) + if err := restarted.Start(t.Context()); err != nil { + t.Fatal(err) + } + if !restarted.Ready() || restarted.current.Load().Config().GitHub.WebhookSecret != "rotated" { + t.Error("restarted service did not load the saved credentials") + } + + // variables set in the environment take precedence over the file + t.Setenv("APP_GITHUB_WEBHOOK_SECRET", "from-env") + explicit := newService(t, config.ProcessEnv(), ModeServer, nil) + if err := explicit.Start(t.Context()); err != nil { + t.Fatal(err) + } + if got := explicit.current.Load().Config().GitHub.WebhookSecret; got != "from-env" { + t.Errorf("webhook secret = %q, want the environment's value", got) + } +} + +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !cond() { + if time.Now().After(deadline) { + t.Fatal("condition not met in time") + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestWait_BackgroundJobs(t *testing.T) { + svc := &Service{jobs: &jobs{logger: discard}} + release := make(chan struct{}) + svc.jobs.Go(func(context.Context) { <-release }) + svc.jobs.Go(func(context.Context) { panic("boom") }) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if err := svc.Wait(ctx); err == nil { + t.Fatal("Wait returned before the job finished") + } + close(release) + if err := svc.Wait(context.Background()); err != nil { + t.Fatal(err) + } + if err := (&Service{}).Wait(context.Background()); err != nil { + t.Errorf("Wait without jobs: %v", err) + } +} + +// registeredStore reports an app as already registered. +type registeredStore struct{ configstore.Store } + +func (registeredStore) Status(context.Context) (*configstore.InstallerStatus, error) { + return &configstore.InstallerStatus{Registered: true, AppID: 1}, nil +} + +func TestInstaller_CallbackRefusedOnceRegistered(t *testing.T) { + installerEnv(t) + svc := newService(t, config.ProcessEnv(), ModeServer, registeredStore{}) + if rec := get(t, svc.Handler(), http.MethodGet, "/prod/callback?code=abcdefghij0123"); rec.Code != http.StatusConflict { + t.Errorf("callback with a registered app: %d, want 409", rec.Code) + } +} + +func TestLambdaMode_RetriesLoadAfterFailure(t *testing.T) { + e := &mapEnv{m: map[string]string{"APP_GITHUB_ORG": "acme"}} + svc := newService(t, e.env(), ModeLambda, nil) + h := svc.Handler() + + if rec := get(t, h, http.MethodPost, "/webhooks"); rec.Code != http.StatusServiceUnavailable { + t.Fatalf("webhook without config: %d, want 503", rec.Code) + } + if err := svc.RunScheduled(t.Context(), app.ActionOktaSync); err == nil { + t.Fatal("scheduled action ran without config") + } + + // the credentials appear, as when the installer saves them to ssm + e.set("APP_GITHUB_APP_ID", "1") + e.set("APP_GITHUB_APP_PRIVATE_KEY", keyPEM(t)) + e.set("APP_GITHUB_WEBHOOK_SECRET", "s") + + if rec := get(t, h, http.MethodPost, "/webhooks"); rec.Code != http.StatusUnauthorized { + t.Errorf("webhook after config: %d, want 401 from the app", rec.Code) + } + if !svc.Ready() { + t.Error("not ready after a successful load") + } +} + +func TestRecoverer(t *testing.T) { + svc := &Service{logger: discard} + h := svc.recoverer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("boom") })) + if rec := get(t, h, http.MethodGet, "/"); rec.Code != http.StatusInternalServerError { + t.Errorf("panic: %d, want 500", rec.Code) + } +} + +func TestDetach_SurvivesClientCancel(t *testing.T) { + svc := &Service{logger: discard} + var jobErr error + h := svc.detach(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + jobErr = r.Context().Err() + })) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/webhooks", nil) + h.ServeHTTP(httptest.NewRecorder(), req) + if jobErr != nil { + t.Errorf("job context canceled with the request: %v", jobErr) + } +} diff --git a/internal/setup/setup.go b/internal/setup/setup.go new file mode 100644 index 0000000..f0eda12 --- /dev/null +++ b/internal/setup/setup.go @@ -0,0 +1,86 @@ +// Package setup integrates the github-app-setup-go installer, which creates +// the GitHub App from a manifest and saves its credentials. +package setup + +import ( + "context" + "strconv" + "strings" + + "github.com/cruxstack/github-app-setup-go/configstore" + "github.com/cruxstack/github-app-setup-go/installer" +) + +// AppName is the default name of the GitHub App. +const AppName = "github-ops-app" + +// Permissions are the GitHub App permissions the application needs, and no +// more. +var Permissions = map[string]string{ + // repository permissions + "administration": "read", // branch protection rules + "checks": "read", // check runs on merged pr heads + "metadata": "read", // rulesets and repository permissions + "pull_requests": "read", // pr details and reviews + "statuses": "read", // commit statuses on merged pr heads + "vulnerability_alerts": "read", // dependabot alerts + "security_events": "read", // code scanning alerts + "secret_scanning_alerts": "read", // secret scanning alerts + // organization permissions + "members": "write", // teams and team membership +} + +// Events are the webhook events the application handles. +var Events = []string{"pull_request", "team", "membership"} + +// Manifest returns the GitHub App manifest. The app is private, so only the +// organization that creates it can install it. +func Manifest() installer.Manifest { + return installer.Manifest{ + Name: AppName, + URL: "https://github.com/cruxstack/github-ops-app", + Public: false, + DefaultPerms: Permissions, + DefaultEvents: Events, + } +} + +// The names the application reads the GitHub App credentials from. +const ( + EnvAppID = "APP_GITHUB_APP_ID" + EnvPrivateKey = "APP_GITHUB_APP_PRIVATE_KEY" + EnvWebhookSecret = "APP_GITHUB_WEBHOOK_SECRET" +) + +// Store wraps a credential store so the credentials are also saved under the +// names the application reads. The store's own names, such as GITHUB_APP_ID, +// are still written, since its status checks read them. +type Store struct { + configstore.Store +} + +// Save adds the application's names for the credentials and saves them. +func (s Store) Save(ctx context.Context, creds *configstore.AppCredentials) error { + if creds != nil { + if creds.CustomFields == nil { + creds.CustomFields = make(map[string]string) + } + creds.CustomFields[EnvAppID] = strconv.FormatInt(creds.AppID, 10) + // one line, since the env file store writes a value per line; the + // application turns escaped newlines back into newlines + creds.CustomFields[EnvPrivateKey] = strings.ReplaceAll(creds.PrivateKey, "\n", `\n`) + creds.CustomFields[EnvWebhookSecret] = creds.WebhookSecret + } + return s.Store.Save(ctx, creds) +} + +// NopStore is a credential store for when the installer is disabled. +type NopStore struct{} + +func (NopStore) Save(context.Context, *configstore.AppCredentials) error { return nil } + +func (NopStore) Status(context.Context) (*configstore.InstallerStatus, error) { + return &configstore.InstallerStatus{}, nil +} + +func (NopStore) DisableInstaller(context.Context) error { return nil } diff --git a/internal/setup/setup_test.go b/internal/setup/setup_test.go new file mode 100644 index 0000000..8c359ab --- /dev/null +++ b/internal/setup/setup_test.go @@ -0,0 +1,72 @@ +package setup + +import ( + "context" + "encoding/json" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/cruxstack/github-app-setup-go/configstore" +) + +// TestManifestMatchesAsset keeps the manifest in assets/github, for creating +// the app by hand, the same as the one the installer uses. +func TestManifestMatchesAsset(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "assets", "github", "manifest.json")) + if err != nil { + t.Fatal(err) + } + var asset struct { + Name string `json:"name"` + Public bool `json:"public"` + Permissions map[string]string `json:"default_permissions"` + Events []string `json:"default_events"` + } + if err := json.Unmarshal(raw, &asset); err != nil { + t.Fatal(err) + } + + m := Manifest() + if asset.Name != m.Name || asset.Public != m.Public { + t.Errorf("asset name/public = %q/%v, manifest %q/%v", asset.Name, asset.Public, m.Name, m.Public) + } + if !maps.Equal(asset.Permissions, m.DefaultPerms) { + t.Errorf("asset permissions = %v\nmanifest permissions = %v", asset.Permissions, m.DefaultPerms) + } + if !slices.Equal(asset.Events, m.DefaultEvents) { + t.Errorf("asset events = %v, manifest events = %v", asset.Events, m.DefaultEvents) + } + if m.Public { + t.Error("the app must be private so other accounts cannot install it") + } +} + +type recordingStore struct { + NopStore + saved *configstore.AppCredentials +} + +func (s *recordingStore) Save(_ context.Context, creds *configstore.AppCredentials) error { + s.saved = creds + return nil +} + +func TestStoreSavesTheAppsNames(t *testing.T) { + inner := &recordingStore{} + creds := &configstore.AppCredentials{AppID: 42, PrivateKey: "-----BEGIN-----\nabc\n-----END-----\n", WebhookSecret: "whsec"} + if err := (Store{Store: inner}).Save(context.Background(), creds); err != nil { + t.Fatal(err) + } + + got := inner.saved.CustomFields + if got[EnvAppID] != "42" || got[EnvWebhookSecret] != "whsec" { + t.Errorf("custom fields = %v", got) + } + if strings.Contains(got[EnvPrivateKey], "\n") || !strings.Contains(got[EnvPrivateKey], `\n`) { + t.Errorf("private key is not on one line: %q", got[EnvPrivateKey]) + } +} diff --git a/internal/sync/sync.go b/internal/sync/sync.go deleted file mode 100644 index d2ea4c5..0000000 --- a/internal/sync/sync.go +++ /dev/null @@ -1,237 +0,0 @@ -// Package sync coordinates synchronization of Okta groups to GitHub teams. -// depends only on domain interfaces, not concrete client implementations. -package sync - -import ( - "context" - "fmt" - "log/slog" - "regexp" - "strings" - - "github.com/cockroachdb/errors" - "github.com/cruxstack/github-ops-app/internal/domain" -) - -// teamNameNormalizer replaces non-alphanumeric characters (except hyphens) -// in team names. compiled once at package init. -var teamNameNormalizer = regexp.MustCompile(`[^a-z0-9-]+`) - -// Syncer coordinates synchronization of Okta groups to GitHub teams. -type Syncer struct { - oktaClient domain.OktaClient - githubClient domain.GitHubClient - rules []domain.SyncRule - safetyThreshold float64 - logger *slog.Logger -} - -// NewSyncer creates a new Okta to GitHub syncer. -func NewSyncer(oktaClient domain.OktaClient, githubClient domain.GitHubClient, rules []domain.SyncRule, safetyThreshold float64, logger *slog.Logger) *Syncer { - return &Syncer{ - oktaClient: oktaClient, - githubClient: githubClient, - rules: rules, - safetyThreshold: safetyThreshold, - logger: logger, - } -} - -// Sync executes all enabled sync rules and returns reports. -// continues processing remaining rules even if some fail. -func (s *Syncer) Sync(ctx context.Context) (*domain.SyncResult, error) { - var reports []*domain.SyncReport - var enabledRuleCount int - var failedRuleCount int - - for _, rule := range s.rules { - if !rule.IsEnabled() { - continue - } - - enabledRuleCount++ - - ruleReports, err := s.syncRule(ctx, rule) - if err != nil { - failedRuleCount++ - s.logger.Error("sync rule failed", - slog.String("rule", rule.GetName()), - slog.String("error", err.Error())) - - reports = append(reports, &domain.SyncReport{ - Rule: rule.GetName(), - OktaGroup: rule.OktaGroupName, - GitHubTeam: rule.GitHubTeamName, - Errors: []string{err.Error()}, - }) - continue - } - - reports = append(reports, ruleReports...) - } - - if enabledRuleCount > 0 && failedRuleCount == enabledRuleCount { - return nil, errors.Newf("all sync rules failed: %d errors", failedRuleCount) - } - - return &domain.SyncResult{ - Reports: reports, - OrphanedUsers: nil, - }, nil -} - -// DetectOrphanedUsers finds organization members not in any synced teams. -// excludes external collaborators. -func (s *Syncer) DetectOrphanedUsers(ctx context.Context, syncedTeams []string) (*domain.OrphanedUsersReport, error) { - orgMembers, err := s.githubClient.ListOrgMembers(ctx) - if err != nil { - return nil, errors.Wrap(err, "failed to list organization members") - } - - syncedUsers := make(map[string]bool) - for _, teamSlug := range syncedTeams { - members, err := s.githubClient.GetTeamMembers(ctx, teamSlug) - if err != nil { - s.logger.Warn("failed to get team members for orphaned user check", - slog.String("team", teamSlug), - slog.String("error", err.Error())) - continue - } - for _, member := range members { - syncedUsers[member] = true - } - } - - var orphanedUsers []string - for _, member := range orgMembers { - if !syncedUsers[member] { - isExternal, err := s.githubClient.IsExternalCollaborator(ctx, member) - if err != nil { - s.logger.Warn("failed to check if user is external for orphaned user check", - slog.String("user", member), - slog.String("error", err.Error())) - continue - } - - if !isExternal { - orphanedUsers = append(orphanedUsers, member) - } - } - } - - return &domain.OrphanedUsersReport{ - OrphanedUsers: orphanedUsers, - }, nil -} - -// syncRule executes a single sync rule. -// supports both pattern matching and exact group name matching. -func (s *Syncer) syncRule(ctx context.Context, rule domain.SyncRule) ([]*domain.SyncReport, error) { - var reports []*domain.SyncReport - - if rule.OktaGroupPattern != "" { - groups, err := s.oktaClient.GetGroupsByPattern(ctx, rule.OktaGroupPattern) - if err != nil { - return nil, errors.Wrapf(err, "failed to match groups with pattern '%s'", rule.OktaGroupPattern) - } - - for _, group := range groups { - teamName := computeTeamName(group.Name, rule) - report := s.syncGroupToTeam(ctx, rule, group, teamName) - reports = append(reports, report) - } - } else if rule.OktaGroupName != "" { - group, err := s.oktaClient.GetGroupInfo(ctx, rule.OktaGroupName) - if err != nil { - return nil, errors.Wrapf(err, "failed to fetch group '%s'", rule.OktaGroupName) - } - - teamName := computeTeamName(group.Name, rule) - report := s.syncGroupToTeam(ctx, rule, group, teamName) - reports = append(reports, report) - } - - return reports, nil -} - -// computeTeamName generates GitHub team name from Okta group name. -// applies prefix stripping, prefix addition, and normalization. -func computeTeamName(oktaGroupName string, rule domain.SyncRule) string { - if rule.GitHubTeamName != "" { - return rule.GitHubTeamName - } - - teamName := oktaGroupName - - if rule.StripPrefix != "" { - teamName = strings.TrimPrefix(teamName, rule.StripPrefix) - } - - if rule.GitHubTeamPrefix != "" { - teamName = rule.GitHubTeamPrefix + teamName - } - - teamName = strings.ToLower(teamName) - teamName = teamNameNormalizer.ReplaceAllString(teamName, "-") - teamName = strings.Trim(teamName, "-") - - return teamName -} - -// syncGroupToTeam synchronizes a single Okta group to a GitHub team. -// creates team if missing and syncs members if enabled. -func (s *Syncer) syncGroupToTeam(ctx context.Context, rule domain.SyncRule, group *domain.GroupInfo, teamName string) *domain.SyncReport { - report := &domain.SyncReport{ - Rule: rule.GetName(), - OktaGroup: group.Name, - GitHubTeam: teamName, - MembersSkippedNoGHUsername: group.SkippedNoGitHubUsername, - Errors: []string{}, - } - - if len(group.SkippedNoGitHubUsername) > 0 { - s.logger.Warn("okta users skipped due to missing github username", - slog.String("group", group.Name), - slog.Int("count", len(group.SkippedNoGitHubUsername))) - } - - privacy := "closed" - if rule.TeamPrivacy != "" { - privacy = rule.TeamPrivacy - } - - team, err := s.githubClient.GetOrCreateTeam(ctx, teamName, privacy) - if err != nil { - errMsg := fmt.Sprintf("failed to get/create team '%s': %v", teamName, err) - report.Errors = append(report.Errors, errMsg) - return report - } - - if team == nil { - errMsg := fmt.Sprintf("team '%s' is nil after get/create", teamName) - report.Errors = append(report.Errors, errMsg) - return report - } - - if !rule.ShouldSyncMembers() { - return report - } - - teamSlug := teamName - if team.Slug != nil { - teamSlug = *team.Slug - } - - syncResult, err := s.githubClient.SyncTeamMembers(ctx, teamSlug, group.Members, s.safetyThreshold) - if err != nil { - report.Errors = append(report.Errors, fmt.Sprintf("failed to sync members for team '%s': %v", teamSlug, err)) - return report - } - - report.MembersAdded = syncResult.MembersAdded - report.MembersRemoved = syncResult.MembersRemoved - report.MembersSkippedExternal = syncResult.MembersSkippedExternal - report.Errors = append(report.Errors, syncResult.Errors...) - - return report -} diff --git a/internal/sync/sync_test.go b/internal/sync/sync_test.go deleted file mode 100644 index 6e68a8d..0000000 --- a/internal/sync/sync_test.go +++ /dev/null @@ -1,394 +0,0 @@ -package sync - -import ( - "context" - "log/slog" - "os" - "testing" - - "github.com/cockroachdb/errors" - "github.com/cruxstack/github-ops-app/internal/domain" - "github.com/google/go-github/v79/github" -) - -// mockGitHubClient implements domain.GitHubClient with overridable function -// fields for testing. -type mockGitHubClient struct { - getOrCreateTeamFn func(ctx context.Context, teamName, privacy string) (*github.Team, error) - syncTeamMembersFn func(ctx context.Context, teamSlug string, desiredMembers []string, threshold float64) (*domain.TeamSyncResult, error) - getTeamMembersFn func(ctx context.Context, teamSlug string) ([]string, error) - listOrgMembersFn func(ctx context.Context) ([]string, error) - isExternalCollaboratorFn func(ctx context.Context, username string) (bool, error) - getAppSlugFn func(ctx context.Context) (string, error) - checkPRComplianceFn func(ctx context.Context, owner, repo string, prNumber int) (*domain.PRComplianceResult, error) -} - -func (m *mockGitHubClient) CheckPRCompliance(ctx context.Context, owner, repo string, prNumber int) (*domain.PRComplianceResult, error) { - if m.checkPRComplianceFn != nil { - return m.checkPRComplianceFn(ctx, owner, repo, prNumber) - } - return &domain.PRComplianceResult{}, nil -} - -func (m *mockGitHubClient) GetOrCreateTeam(ctx context.Context, teamName, privacy string) (*github.Team, error) { - if m.getOrCreateTeamFn != nil { - return m.getOrCreateTeamFn(ctx, teamName, privacy) - } - slug := teamName - return &github.Team{Slug: &slug}, nil -} - -func (m *mockGitHubClient) SyncTeamMembers(ctx context.Context, teamSlug string, desiredMembers []string, threshold float64) (*domain.TeamSyncResult, error) { - if m.syncTeamMembersFn != nil { - return m.syncTeamMembersFn(ctx, teamSlug, desiredMembers, threshold) - } - return &domain.TeamSyncResult{ - TeamName: teamSlug, - MembersAdded: desiredMembers, - }, nil -} - -func (m *mockGitHubClient) GetTeamMembers(ctx context.Context, teamSlug string) ([]string, error) { - if m.getTeamMembersFn != nil { - return m.getTeamMembersFn(ctx, teamSlug) - } - return []string{}, nil -} - -func (m *mockGitHubClient) ListOrgMembers(ctx context.Context) ([]string, error) { - if m.listOrgMembersFn != nil { - return m.listOrgMembersFn(ctx) - } - return []string{}, nil -} - -func (m *mockGitHubClient) IsExternalCollaborator(ctx context.Context, username string) (bool, error) { - if m.isExternalCollaboratorFn != nil { - return m.isExternalCollaboratorFn(ctx, username) - } - return false, nil -} - -func (m *mockGitHubClient) GetAppSlug(ctx context.Context) (string, error) { - if m.getAppSlugFn != nil { - return m.getAppSlugFn(ctx) - } - return "test-app", nil -} - -func (m *mockGitHubClient) ListSecurityAlerts(ctx context.Context, minAgeDays int, minSeverity string) (*domain.SecurityAlertsReport, error) { - return &domain.SecurityAlertsReport{AlertsByRepo: map[string][]domain.SecurityAlert{}}, nil -} - -func (m *mockGitHubClient) GetOrg() string { - return "test-org" -} - -// mockOktaClient implements domain.OktaClient with overridable function -// fields for testing. -type mockOktaClient struct { - getGroupsByPatternFn func(ctx context.Context, pattern string) ([]*domain.GroupInfo, error) - getGroupInfoFn func(ctx context.Context, groupName string) (*domain.GroupInfo, error) -} - -func (m *mockOktaClient) GetGroupsByPattern(ctx context.Context, pattern string) ([]*domain.GroupInfo, error) { - if m.getGroupsByPatternFn != nil { - return m.getGroupsByPatternFn(ctx, pattern) - } - return []*domain.GroupInfo{}, nil -} - -func (m *mockOktaClient) GetGroupInfo(ctx context.Context, groupName string) (*domain.GroupInfo, error) { - if m.getGroupInfoFn != nil { - return m.getGroupInfoFn(ctx, groupName) - } - return &domain.GroupInfo{ - ID: "group-1", - Name: groupName, - Members: []string{}, - }, nil -} - -func testLogger() *slog.Logger { - return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) -} - -func TestSync_SingleRule(t *testing.T) { - oktaClient := &mockOktaClient{ - getGroupInfoFn: func(_ context.Context, name string) (*domain.GroupInfo, error) { - return &domain.GroupInfo{ - ID: "g1", - Name: "Engineering", - Members: []string{"alice", "bob"}, - }, nil - }, - } - - ghClient := &mockGitHubClient{ - syncTeamMembersFn: func(_ context.Context, teamSlug string, members []string, _ float64) (*domain.TeamSyncResult, error) { - return &domain.TeamSyncResult{ - TeamName: teamSlug, - MembersAdded: members, - }, nil - }, - } - - rules := []domain.SyncRule{ - { - OktaGroupName: "Engineering", - GitHubTeamName: "engineering", - }, - } - - syncer := NewSyncer(oktaClient, ghClient, rules, 0.5, testLogger()) - result, err := syncer.Sync(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(result.Reports) != 1 { - t.Fatalf("expected 1 report, got %d", len(result.Reports)) - } - - report := result.Reports[0] - if report.GitHubTeam != "engineering" { - t.Errorf("expected team 'engineering', got %q", report.GitHubTeam) - } - if len(report.MembersAdded) != 2 { - t.Errorf("expected 2 members added, got %d", len(report.MembersAdded)) - } -} - -func TestSync_DisabledRule(t *testing.T) { - disabled := false - rules := []domain.SyncRule{ - { - Enabled: &disabled, - OktaGroupName: "Engineering", - GitHubTeamName: "engineering", - }, - } - - syncer := NewSyncer(&mockOktaClient{}, &mockGitHubClient{}, rules, 0.5, testLogger()) - result, err := syncer.Sync(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(result.Reports) != 0 { - t.Errorf("expected 0 reports for disabled rule, got %d", len(result.Reports)) - } -} - -func TestSync_OktaError_PartialSuccess(t *testing.T) { - callCount := 0 - oktaClient := &mockOktaClient{ - getGroupInfoFn: func(_ context.Context, name string) (*domain.GroupInfo, error) { - callCount++ - if callCount == 1 { - return nil, errors.New("okta api error") - } - return &domain.GroupInfo{ID: "g2", Name: name, Members: []string{"charlie"}}, nil - }, - } - - rules := []domain.SyncRule{ - {OktaGroupName: "BadGroup", GitHubTeamName: "bad-team"}, - {OktaGroupName: "GoodGroup", GitHubTeamName: "good-team"}, - } - - syncer := NewSyncer(oktaClient, &mockGitHubClient{}, rules, 0.5, testLogger()) - result, err := syncer.Sync(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(result.Reports) != 2 { - t.Fatalf("expected 2 reports, got %d", len(result.Reports)) - } - - // first report should have errors - if !result.Reports[0].HasErrors() { - t.Error("expected first report to have errors") - } - // second report should succeed - if result.Reports[1].HasErrors() { - t.Errorf("expected second report to have no errors, got: %v", result.Reports[1].Errors) - } -} - -func TestSync_AllRulesFail(t *testing.T) { - oktaClient := &mockOktaClient{ - getGroupInfoFn: func(_ context.Context, name string) (*domain.GroupInfo, error) { - return nil, errors.New("api failure") - }, - } - - rules := []domain.SyncRule{ - {OktaGroupName: "Group1", GitHubTeamName: "team1"}, - } - - syncer := NewSyncer(oktaClient, &mockGitHubClient{}, rules, 0.5, testLogger()) - _, err := syncer.Sync(context.Background()) - if err == nil { - t.Fatal("expected error when all rules fail") - } -} - -func TestSync_PatternMatching(t *testing.T) { - oktaClient := &mockOktaClient{ - getGroupsByPatternFn: func(_ context.Context, pattern string) ([]*domain.GroupInfo, error) { - return []*domain.GroupInfo{ - {ID: "g1", Name: "eng-frontend", Members: []string{"alice"}}, - {ID: "g2", Name: "eng-backend", Members: []string{"bob"}}, - }, nil - }, - } - - rules := []domain.SyncRule{ - {OktaGroupPattern: "^eng-.*$"}, - } - - syncer := NewSyncer(oktaClient, &mockGitHubClient{}, rules, 0.5, testLogger()) - result, err := syncer.Sync(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(result.Reports) != 2 { - t.Fatalf("expected 2 reports for pattern match, got %d", len(result.Reports)) - } -} - -func TestSync_SkippedUsersTracked(t *testing.T) { - oktaClient := &mockOktaClient{ - getGroupInfoFn: func(_ context.Context, name string) (*domain.GroupInfo, error) { - return &domain.GroupInfo{ - ID: "g1", - Name: name, - Members: []string{"alice"}, - SkippedNoGitHubUsername: []string{"new-hire@example.com"}, - }, nil - }, - } - - rules := []domain.SyncRule{ - {OktaGroupName: "Engineering", GitHubTeamName: "engineering"}, - } - - syncer := NewSyncer(oktaClient, &mockGitHubClient{}, rules, 0.5, testLogger()) - result, err := syncer.Sync(context.Background()) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(result.Reports[0].MembersSkippedNoGHUsername) != 1 { - t.Errorf("expected 1 skipped user, got %d", len(result.Reports[0].MembersSkippedNoGHUsername)) - } -} - -func TestDetectOrphanedUsers(t *testing.T) { - ghClient := &mockGitHubClient{ - listOrgMembersFn: func(_ context.Context) ([]string, error) { - return []string{"alice", "bob", "charlie"}, nil - }, - getTeamMembersFn: func(_ context.Context, teamSlug string) ([]string, error) { - return []string{"alice", "bob"}, nil - }, - isExternalCollaboratorFn: func(_ context.Context, username string) (bool, error) { - return false, nil - }, - } - - syncer := NewSyncer(&mockOktaClient{}, ghClient, nil, 0.5, testLogger()) - report, err := syncer.DetectOrphanedUsers(context.Background(), []string{"engineering"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(report.OrphanedUsers) != 1 { - t.Fatalf("expected 1 orphaned user, got %d", len(report.OrphanedUsers)) - } - if report.OrphanedUsers[0] != "charlie" { - t.Errorf("expected orphaned user 'charlie', got %q", report.OrphanedUsers[0]) - } -} - -func TestDetectOrphanedUsers_ExternalCollaboratorExcluded(t *testing.T) { - ghClient := &mockGitHubClient{ - listOrgMembersFn: func(_ context.Context) ([]string, error) { - return []string{"alice", "external-user"}, nil - }, - getTeamMembersFn: func(_ context.Context, _ string) ([]string, error) { - return []string{"alice"}, nil - }, - isExternalCollaboratorFn: func(_ context.Context, username string) (bool, error) { - return username == "external-user", nil - }, - } - - syncer := NewSyncer(&mockOktaClient{}, ghClient, nil, 0.5, testLogger()) - report, err := syncer.DetectOrphanedUsers(context.Background(), []string{"engineering"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if len(report.OrphanedUsers) != 0 { - t.Errorf("expected 0 orphaned users (external excluded), got %d", len(report.OrphanedUsers)) - } -} - -func TestComputeTeamName(t *testing.T) { - tests := []struct { - name string - groupName string - rule domain.SyncRule - want string - }{ - { - name: "explicit team name", - groupName: "Engineering", - rule: domain.SyncRule{GitHubTeamName: "custom-team"}, - want: "custom-team", - }, - { - name: "lowercase and normalize", - groupName: "My Team Name", - rule: domain.SyncRule{}, - want: "my-team-name", - }, - { - name: "strip prefix", - groupName: "DEPT-Engineering", - rule: domain.SyncRule{StripPrefix: "DEPT-"}, - want: "engineering", - }, - { - name: "add prefix", - groupName: "frontend", - rule: domain.SyncRule{GitHubTeamPrefix: "eng-"}, - want: "eng-frontend", - }, - { - name: "strip and add prefix", - groupName: "OKTA-backend", - rule: domain.SyncRule{StripPrefix: "OKTA-", GitHubTeamPrefix: "gh-"}, - want: "gh-backend", - }, - { - name: "special characters replaced and dashes collapsed", - groupName: "Team (US) & EU", - rule: domain.SyncRule{}, - want: "team-us-eu", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := computeTeamName(tt.groupName, tt.rule) - if got != tt.want { - t.Errorf("computeTeamName(%q) = %q, want %q", tt.groupName, got, tt.want) - } - }) - } -} diff --git a/internal/teamsync/teamsync.go b/internal/teamsync/teamsync.go new file mode 100644 index 0000000..a0b19d4 --- /dev/null +++ b/internal/teamsync/teamsync.go @@ -0,0 +1,214 @@ +// Package teamsync syncs the members of Okta groups to GitHub teams. It +// depends only on the domain interfaces. +package teamsync + +import ( + "context" + "errors" + "fmt" + "log/slog" + "regexp" + "strings" + + "github.com/cruxstack/github-ops-app/internal/domain" +) + +// teamNameInvalidChars matches the characters replaced with a hyphen when a +// team name is derived from an Okta group name. +var teamNameInvalidChars = regexp.MustCompile(`[^a-z0-9-]+`) + +// Syncer syncs Okta groups to GitHub teams according to a set of rules. +type Syncer struct { + okta domain.OktaClient + github domain.GitHubClient + rules []domain.SyncRule + safetyThreshold float64 + logger *slog.Logger +} + +// NewSyncer creates a Syncer. +func NewSyncer(okta domain.OktaClient, github domain.GitHubClient, rules []domain.SyncRule, safetyThreshold float64, logger *slog.Logger) *Syncer { + return &Syncer{ + okta: okta, + github: github, + rules: rules, + safetyThreshold: safetyThreshold, + logger: logger, + } +} + +// Sync runs every enabled rule and returns a report per synced group. A +// rule that fails is reported and the others still run; Sync returns an +// error only when every enabled rule failed. +func (s *Syncer) Sync(ctx context.Context) (*domain.SyncResult, error) { + result := &domain.SyncResult{} + // claimed maps each team synced in this run to the group synced into + // it, since a second group would remove the first group's members + claimed := make(map[string]string) + var enabled, failed int + + for _, rule := range s.rules { + if !rule.IsEnabled() { + continue + } + enabled++ + + reports, err := s.syncRule(ctx, rule, claimed) + if err != nil { + if ctx.Err() != nil { + return nil, ctx.Err() + } + failed++ + s.logger.ErrorContext(ctx, "sync rule failed", "rule", rule.GetName(), "error", err.Error()) + result.Reports = append(result.Reports, &domain.SyncReport{ + Rule: rule.GetName(), + OktaGroup: rule.OktaGroupName, + Errors: []string{err.Error()}, + }) + continue + } + result.Reports = append(result.Reports, reports...) + } + + if enabled > 0 && failed == enabled { + return result, fmt.Errorf("all %d sync rules failed", failed) + } + return result, nil +} + +// syncRule syncs every group a rule matches. +func (s *Syncer) syncRule(ctx context.Context, rule domain.SyncRule, claimed map[string]string) ([]*domain.SyncReport, error) { + var groups []*domain.GroupInfo + switch { + case rule.OktaGroupPattern != "": + matched, err := s.okta.GetGroupsByPattern(ctx, rule.OktaGroupPattern) + if err != nil { + return nil, fmt.Errorf("failed to match okta groups with pattern '%s': %w", rule.OktaGroupPattern, err) + } + groups = matched + case rule.OktaGroupName != "": + group, err := s.okta.GetGroupInfo(ctx, rule.OktaGroupName) + if err != nil { + return nil, fmt.Errorf("failed to fetch okta group '%s': %w", rule.OktaGroupName, err) + } + groups = []*domain.GroupInfo{group} + default: + return nil, fmt.Errorf("rule '%s' has neither okta_group_pattern nor okta_group_name", rule.GetName()) + } + + reports := make([]*domain.SyncReport, 0, len(groups)) + for _, group := range groups { + reports = append(reports, s.syncGroup(ctx, rule, group, claimed)) + } + return reports, nil +} + +// TeamName derives the GitHub team name for an Okta group under a rule. +func TeamName(oktaGroupName string, rule domain.SyncRule) string { + if rule.GitHubTeamName != "" { + return rule.GitHubTeamName + } + name := strings.TrimPrefix(oktaGroupName, rule.StripPrefix) + name = strings.ToLower(rule.GitHubTeamPrefix + name) + name = teamNameInvalidChars.ReplaceAllString(name, "-") + return strings.Trim(name, "-") +} + +// syncGroup syncs one Okta group to its GitHub team and reports the result. +func (s *Syncer) syncGroup(ctx context.Context, rule domain.SyncRule, group *domain.GroupInfo, claimed map[string]string) *domain.SyncReport { + teamName := TeamName(group.Name, rule) + report := &domain.SyncReport{ + Rule: rule.GetName(), + OktaGroup: group.Name, + GitHubTeam: teamName, + MembersSkippedNoGHUsername: group.SkippedNoGitHubUsername, + Errors: []string{}, + } + fail := func(format string, args ...any) *domain.SyncReport { + report.Errors = append(report.Errors, fmt.Sprintf(format, args...)) + return report + } + + if group.Err != nil { + return fail("failed to fetch members of okta group '%s': %v", group.Name, group.Err) + } + if teamName == "" { + return fail("okta group '%s' maps to an empty team name", group.Name) + } + if len(group.SkippedNoGitHubUsername) > 0 { + s.logger.WarnContext(ctx, "okta users skipped without a valid github username", + "group", group.Name, + "count", len(group.SkippedNoGitHubUsername)) + } + + slug, err := s.github.ResolveTeam(ctx, teamName, rule.Privacy(), rule.CreateTeamIfMissing) + if err != nil { + return fail("failed to resolve team '%s': %v", teamName, err) + } + report.GitHubTeam = slug + report.TeamResolved = true + + if other, ok := claimed[slug]; ok { + return fail("team '%s' is already synced from okta group '%s' in this run; skipping okta group '%s'", slug, other, group.Name) + } + claimed[slug] = group.Name + + if !rule.ShouldSyncMembers() { + return report + } + + result, err := s.github.SyncTeamMembers(ctx, slug, group.Members, s.safetyThreshold) + if err != nil { + return fail("failed to sync members of team '%s': %v", slug, err) + } + report.MembersAdded = result.MembersAdded + report.MembersRemoved = result.MembersRemoved + report.MembersSkippedExternal = result.MembersSkippedExternal + report.Members = result.Members + report.Errors = append(report.Errors, result.Errors...) + return report +} + +// DetectOrphanedUsers finds organization members who are in none of the +// teams a sync covered. It uses the member lists the sync ended with, and +// reads the members of teams whose members were not synced. It returns an +// error rather than report a team's members as orphaned when the result is +// incomplete or a team's members can't be read. +func (s *Syncer) DetectOrphanedUsers(ctx context.Context, result *domain.SyncResult) (*domain.OrphanedUsersReport, error) { + if !result.Complete() { + return nil, errors.New("not every synced team was found, so its members are unknown") + } + + known := make(map[string][]string) + for _, report := range result.Reports { + if report.Members != nil { + known[report.GitHubTeam] = report.Members + } + } + + covered := make(map[string]bool) + for _, team := range result.ManagedTeams() { + members, ok := known[team] + if !ok { + var err error + if members, err = s.github.GetTeamMembers(ctx, team); err != nil { + return nil, err + } + } + for _, member := range members { + covered[strings.ToLower(member)] = true + } + } + + orgMembers, err := s.github.ListOrgMembers(ctx) + if err != nil { + return nil, err + } + report := &domain.OrphanedUsersReport{OrphanedUsers: []string{}} + for _, member := range orgMembers { + if !covered[strings.ToLower(member)] { + report.OrphanedUsers = append(report.OrphanedUsers, member) + } + } + return report, nil +} diff --git a/internal/teamsync/teamsync_test.go b/internal/teamsync/teamsync_test.go new file mode 100644 index 0000000..0309afc --- /dev/null +++ b/internal/teamsync/teamsync_test.go @@ -0,0 +1,202 @@ +package teamsync + +import ( + "context" + "errors" + "log/slog" + "strings" + "testing" + + "github.com/cruxstack/github-ops-app/internal/domain" + "github.com/cruxstack/github-ops-app/internal/fakes" +) + +var discard = slog.New(slog.DiscardHandler) + +func ptr[T any](v T) *T { return &v } + +func TestSync_NamedGroup(t *testing.T) { + okta := &fakes.Okta{GetGroupInfoFn: func(_ context.Context, name string) (*domain.GroupInfo, error) { + return &domain.GroupInfo{Name: name, Members: []string{"alice"}, SkippedNoGitHubUsername: []string{"new@x.com"}}, nil + }} + var gotCreate bool + var gotPrivacy string + gh := &fakes.GitHub{ResolveTeamFn: func(_ context.Context, name, privacy string, create bool) (string, error) { + gotPrivacy, gotCreate = privacy, create + return "platform-team", nil + }} + rules := []domain.SyncRule{{Name: "r", OktaGroupName: "Platform", GitHubTeamName: "Platform Team", CreateTeamIfMissing: true}} + + result, err := NewSyncer(okta, gh, rules, 0.5, discard).Sync(context.Background()) + if err != nil { + t.Fatal(err) + } + report := result.Reports[0] + if report.GitHubTeam != "platform-team" || strings.Join(report.MembersAdded, ",") != "alice" { + t.Errorf("report = %+v, want the resolved slug and alice added", report) + } + if !gotCreate || gotPrivacy != domain.TeamPrivacyClosed { + t.Errorf("ResolveTeam create=%v privacy=%q, want true closed", gotCreate, gotPrivacy) + } + if len(report.MembersSkippedNoGHUsername) != 1 { + t.Errorf("skipped = %v", report.MembersSkippedNoGHUsername) + } +} + +func TestSync_DisabledRuleAndNoMemberSync(t *testing.T) { + gh := &fakes.GitHub{} + rules := []domain.SyncRule{ + {OktaGroupName: "off", Enabled: ptr(false)}, + {OktaGroupName: "teams-only", SyncMembers: ptr(false)}, + } + result, err := NewSyncer(&fakes.Okta{}, gh, rules, 0.5, discard).Sync(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(result.Reports) != 1 || gh.Count("SyncTeamMembers") != 0 { + t.Errorf("reports = %d, member syncs = %d; want 1 and 0", len(result.Reports), gh.Count("SyncTeamMembers")) + } +} + +func TestSync_MissingTeamWithoutCreateIsReported(t *testing.T) { + gh := &fakes.GitHub{ResolveTeamFn: func(context.Context, string, string, bool) (string, error) { + return "", domain.ErrTeamNotFound + }} + rules := []domain.SyncRule{{OktaGroupName: "eng"}} + result, err := NewSyncer(&fakes.Okta{}, gh, rules, 0.5, discard).Sync(context.Background()) + if err != nil { + t.Fatal(err) + } + if !result.Reports[0].HasErrors() || gh.Count("SyncTeamMembers") != 0 { + t.Errorf("report = %+v, want an error and no member sync", result.Reports[0]) + } + if result.Complete() || result.Reports[0].TeamResolved { + t.Error("result with a missing team is complete") + } +} + +func TestSync_PatternWithFailedGroupAndDuplicateTeam(t *testing.T) { + okta := &fakes.Okta{GetGroupsByPatternFn: func(context.Context, string) ([]*domain.GroupInfo, error) { + return []*domain.GroupInfo{ + {Name: "gh-eng", Members: []string{"a"}}, + {Name: "gh-ENG", Members: []string{"b"}}, + {Name: "gh-ops", Err: errors.New("okta timeout")}, + {Name: "gh-data", Members: []string{"c"}}, + }, nil + }} + gh := &fakes.GitHub{} + rules := []domain.SyncRule{{OktaGroupPattern: "^gh-", StripPrefix: "gh-", GitHubTeamPrefix: "t-"}} + + result, err := NewSyncer(okta, gh, rules, 0.5, discard).Sync(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(result.Reports) != 4 { + t.Fatalf("reports = %d, want 4", len(result.Reports)) + } + // the failed group's team was never resolved + if result.Complete() { + t.Error("result with a failed group is complete") + } + if got := strings.Join(result.ManagedTeams(), ","); got != "t-eng,t-ops,t-data" { + t.Errorf("managed teams = %v, want t-eng,t-ops,t-data", got) + } + if got := strings.Join(result.Reports[0].Members, ","); got != "a" { + t.Errorf("t-eng members = %q, want the synced member list", got) + } + if gh.Count("SyncTeamMembers") != 2 { + t.Errorf("member syncs = %d, want 2", gh.Count("SyncTeamMembers")) + } + if !strings.Contains(result.Reports[1].Errors[0], "already synced") { + t.Errorf("duplicate team error = %v", result.Reports[1].Errors) + } + if !strings.Contains(result.Reports[2].Errors[0], "okta timeout") { + t.Errorf("failed group error = %v", result.Reports[2].Errors) + } +} + +func TestSync_PartialAndTotalFailure(t *testing.T) { + okta := &fakes.Okta{GetGroupInfoFn: func(_ context.Context, name string) (*domain.GroupInfo, error) { + if name == "bad" { + return nil, errors.New("okta down") + } + return &domain.GroupInfo{Name: name}, nil + }} + + result, err := NewSyncer(okta, &fakes.GitHub{}, []domain.SyncRule{{OktaGroupName: "bad"}, {OktaGroupName: "good"}}, 0.5, discard).Sync(context.Background()) + if err != nil { + t.Fatalf("partial failure returned error: %v", err) + } + if len(result.Reports) != 2 || !result.Reports[0].HasErrors() { + t.Errorf("reports = %+v", result.Reports) + } + + result, err = NewSyncer(okta, &fakes.GitHub{}, []domain.SyncRule{{OktaGroupName: "bad"}}, 0.5, discard).Sync(context.Background()) + if err == nil { + t.Fatal("total failure returned no error") + } + if result == nil || len(result.Reports) != 1 { + t.Error("total failure did not return the failed reports") + } +} + +func TestDetectOrphanedUsers(t *testing.T) { + gh := &fakes.GitHub{ + ListOrgMembersFn: func(context.Context) ([]string, error) { + return []string{"Alice", "bob", "carol", "dave"}, nil + }, + GetTeamMembersFn: func(_ context.Context, team string) ([]string, error) { + if team == "broken" { + return nil, errors.New("api down") + } + return []string{"carol"}, nil + }, + } + s := NewSyncer(&fakes.Okta{}, gh, nil, 0.5, discard) + result := &domain.SyncResult{Reports: []*domain.SyncReport{ + // a team with sync errors still covers its members + {GitHubTeam: "eng", TeamResolved: true, Members: []string{"alice"}, Errors: []string{"failed to add x"}}, + // a team whose members weren't synced is read + {GitHubTeam: "ops", TeamResolved: true}, + }} + + report, err := s.DetectOrphanedUsers(context.Background(), result) + if err != nil { + t.Fatal(err) + } + if strings.Join(report.OrphanedUsers, ",") != "bob,dave" { + t.Errorf("orphaned = %v, want bob,dave", report.OrphanedUsers) + } + if gh.Count("GetTeamMembers") != 1 { + t.Errorf("team member reads = %d, want 1 (only the team without a member list)", gh.Count("GetTeamMembers")) + } + + result.Reports = append(result.Reports, &domain.SyncReport{GitHubTeam: "broken", TeamResolved: true}) + if _, err := s.DetectOrphanedUsers(context.Background(), result); err == nil { + t.Error("unreadable team did not fail orphan detection") + } + + result.Reports = append(result.Reports[:2], &domain.SyncReport{GitHubTeam: "missing"}) + if _, err := s.DetectOrphanedUsers(context.Background(), result); err == nil { + t.Error("incomplete result did not fail orphan detection") + } +} + +func TestTeamName(t *testing.T) { + tests := []struct { + group string + rule domain.SyncRule + want string + }{ + {"Engineering", domain.SyncRule{}, "engineering"}, + {"github-eng-platform", domain.SyncRule{StripPrefix: "github-eng-", GitHubTeamPrefix: "eng-"}, "eng-platform"}, + {"Data & ML", domain.SyncRule{}, "data-ml"}, + {"anything", domain.SyncRule{GitHubTeamName: "Exact Name"}, "Exact Name"}, + {"___", domain.SyncRule{}, ""}, + } + for _, tt := range tests { + if got := TeamName(tt.group, tt.rule); got != tt.want { + t.Errorf("TeamName(%q) = %q, want %q", tt.group, got, tt.want) + } + } +} From 2f8a70eccc4e2f945251d0e16a6006cabf2fbab1 Mon Sep 17 00:00:00 2001 From: Brian Ojeda <9335829+sgtoj@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:31:10 -0500 Subject: [PATCH 3/3] docs: document v1 configuration, installer, and permissions - README: the endpoints and who may call them, every variable with its default, the sync rule fields, sync safety, and how each feature works - github app setup: the installer, the permissions the app needs and what each is for, and the optional installation id - okta setup: make the github username attribute read only for users, the synced user statuses, rule validation, and create_team_if_missing - slack setup: drop scopes the app doesn't use, and describe escaping, message limits, and the slack-test action - lambda: the events, retried configuration loads, timeouts, and the installer with SSM storage - AGENTS.md: the current packages, make targets, standard library errors, slog key-value pairs, configuration rules, and security rules Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 159 ++++++------- README.md | 482 ++++++++++++++++++++++----------------- cmd/lambda/README.md | 290 ++++++----------------- docs/github-app-setup.md | 255 +++++++++------------ docs/okta-setup.md | 79 ++++--- docs/slack-setup.md | 37 +-- 6 files changed, 595 insertions(+), 707 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b867f26..a0da8ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,117 +1,96 @@ # Agent Guidelines ## Architecture Overview -- **Deployment**: Standard HTTP server OR AWS Lambda (both supported) -- **GitHub**: GitHub App required (JWT + installation token authentication) -- **Features**: Okta group sync + PR compliance monitoring (both optional) +- **Deployment**: HTTP server (container, K8s, VM) or AWS Lambda; both serve the same router +- **GitHub**: GitHub App (JWT + installation tokens); created by hand or with the github-app-setup-go installer +- **Features**: Okta group sync, PR compliance monitoring, stale security alert reports, Slack notifications (all optional) - **Entry points**: - - `cmd/server/main.go` - Standard HTTP server (VPS, container, K8s) - - `cmd/lambda/main.go` - Lambda adapter (API Gateway + EventBridge) - - `cmd/verify/main.go` - Integration tests with HTTP mock servers + - `cmd/server/main.go` - HTTP server with graceful shutdown + - `cmd/lambda/` - Lambda adapter: API Gateway HTTP API (2.0) requests and EventBridge scheduled events - `cmd/sample/main.go` - **DO NOT RUN** (requires live credentials) - **Packages**: - - `internal/app/` - Core logic and unified request handling via - `HandleRequest()` (no AWS dependencies) - - `internal/github/` - API client, webhooks, PR checks, team mgmt, auth - - `internal/okta/` - API client, group sync - - `internal/notifiers/` - Slack formatting for events and reports - - `internal/errors/` - Sentinel errors + - `internal/service/` - composition root shared by both entry points: github-app-setup-go runtime (load, retry, reload), health and installer routes, swaps the app on reload + - `internal/app/` - the application: builds clients from config, router (`/webhooks`, admin, `/scheduled/{action}`), feature handlers + - `internal/config/` - reads and validates all configuration; resolves SSM parameter ARNs + - `internal/domain/` - shared types, interfaces, and error kinds; imports no internal package + - `internal/github/client/` - GitHub API client: auth, PR compliance, teams, security alerts + - `internal/github/webhooks/` - webhook signature validation and event parsing + - `internal/okta/` - Okta API client: groups and members + - `internal/teamsync/` - Okta group to GitHub team sync and orphaned user detection + - `internal/notifiers/` - Slack messages + - `internal/setup/` - GitHub App manifest and installer credential store + - `internal/fakes/` - in-memory domain fakes for tests +- **Tests**: unit tests beside each package; `e2e/` runs `fixtures/scenarios.json` against the real service with mock GitHub, Okta, and Slack servers ## Build & Test -- **Build server**: `make build-server` (creates `dist/server`) +- **List targets**: `make help` +- **Build server**: `make build` (creates `dist/github-ops-app`) - **Build Lambda**: `make build-lambda` (creates `dist/bootstrap`) -- **Run server locally**: `make server` +- **Run server locally**: `make run` (reads `.env`) - **Test all**: `make test` (runs with `-race -count=1`) -- **Test single package**: `go test -race -count=1 ./internal/github` -- **Test single function**: `go test -race -count=1 ./internal/okta -run - TestGroupSync` -- **Integration tests**: `make test-verify` (offline tests using HTTP mock - servers) -- **Verbose integration tests**: `make test-verify-verbose` (shows all HTTP - requests during testing) -- **Lint**: `go vet ./...` and `gofmt -l .` +- **Test single package**: `go test -race -count=1 ./internal/github/client` +- **Test single function**: `go test -race -count=1 ./internal/teamsync -run TestSync_NamedGroup` +- **End-to-end scenarios**: `make test-e2e` +- **Lint**: `make lint` (golangci-lint), `make fmt` +- **Vulnerabilities**: `make vuln` (govulncheck) +- **Everything CI runs**: `make check` -IMPORTANT: DO NOT run `go run cmd/sample/main.go` as it requires live -credentials and makes real API calls to GitHub/Okta/Slack. +IMPORTANT: DO NOT run `go run ./cmd/sample` or `make sample` as they require +live credentials and make real API calls to GitHub/Okta/Slack. ## Code Style -- **Imports**: stdlib, blank line, third-party, local (e.g., `internal/`) -- **Naming**: `PascalCase` exports, `camelCase` private, `ALL_CAPS` env vars (prefixed `APP_`) -- **Structs**: define types in package; constructors as `New()` or `NewTypeName()`; methods public (PascalCase) -- **Formatting**: `gofmt` (tabs for indentation) -- **Comments**: rare, lowercase, short; prefer self-documenting code -- **Error handling**: return errors up stack; wrap with `fmt.Errorf` (see Error Handling below) +- **Imports**: stdlib, blank line, third-party, blank line, local (`github.com/cruxstack/github-ops-app/...`); `make fmt` enforces it +- **Naming**: `PascalCase` exports, `camelCase` private +- **Constructors**: `New(Options)` or `New(cfg, ...Option)`; constructors make no network calls +- **Comments**: lowercase inline comments; doc comments are full sentences +- **Logging**: `log/slog` with key-value pairs (`"team", slug`), snake_case keys, `*Context` methods where a context exists +- **Formatting**: `gofmt` and `goimports` (tabs for indentation) + +## Configuration +- All variables are read in `internal/config`; nothing else calls `os.Getenv` +- App settings use the `APP_` prefix; installer settings keep github-app-setup-go's names (`GITHUB_APP_INSTALLER_ENABLED`, `STORAGE_MODE`, ...) +- Validate at load time and report every problem at once; invalid or partial configuration is an error, never a silent default +- Renamed variables go in `removed` in `config.go`, so setting the old name is an error +- Any `APP_` value may be an SSM parameter ARN; installer variables are read as github-app-setup-go reads them ## Error Handling -### Error Message Format +### Error Messages - **Style**: lowercase, action-focused, concise - **Pattern**: `"failed to {action} {object}: {context}"` - **Always include**: specific identifiers (PR numbers, team names, IDs) - **Examples**: - - ✅ `"failed to fetch pr #123 from owner/repo: %w"` - - ✅ `"failed to create team 'engineers' in org 'myorg': %w"` - - ✅ `"required check 'ci' did not pass"` - - ❌ `"PR is nil"` (no context) - - ❌ `"Failed to Get Team"` (capitalized, generic) + - ✅ `fmt.Errorf("failed to fetch pr #%d from %s/%s: %w", n, owner, repo, err)` + - ✅ `fmt.Errorf("failed to create team '%s' in org '%s': %w", name, org, err)` + - ❌ `errors.New("PR is nil")` (no context) + - ❌ `errors.New("Failed to Get Team")` (capitalized, generic) -### Error Wrapping -- Use `github.com/cockroachdb/errors` package for all error handling -- **Wrap errors**: `errors.Wrap(err, "context")` or `errors.Wrapf(err, - "failed to sync team '%s'", teamName)` -- **Create new errors**: `errors.New("message")` or `errors.Newf("error: - %s", context)` -- Automatically captures stack traces for debugging Lambda issues -- Preserve original error context while adding specific details +### Wrapping +- Use the standard library: `fmt.Errorf("...: %w", err)`, `errors.Is`, `errors.As`, `errors.Join` +- Return errors up the stack with context; don't log and return the same error -### Sentinel Errors -- Define common errors in `internal/errors/errors.go` -- Each sentinel error is marked with a domain type (ValidationError, - AuthError, APIError, ConfigError) -- Domain markers enable error classification and monitoring -- Use `errors.Is()` to check for sentinel errors in tests -- Use `errors.HasType()` to check for error domains -- Examples: `ErrMissingPRData`, `ErrInvalidSignature`, `ErrClientNotInit` +### Error Kinds +- `internal/domain/errors.go` defines kinds (`ErrValidation`, `ErrAuth`, `ErrAPI`, `ErrConfig`) and sentinels that belong to a kind (`ErrInvalidSignature`, `ErrTeamNotFound`, ...) +- `errors.Is(err, domain.ErrAuth)` matches any auth sentinel, through wrapping +- The HTTP layer maps kinds to status codes in `writeDomainError` and never returns error details to clients -### Stack Traces -- Automatically captured when wrapping errors with cockroachdb/errors -- No performance overhead unless error is formatted -- Critical for debugging serverless Lambda executions -- Use `errors.WithDetailf()` to add structured context to auth/API errors - -### Validation -- Validate at parse time, not during processing -- Webhook events validated in `ParseXxxEvent()` functions -- Return detailed validation errors immediately -- Prevents nil pointer issues downstream - -### Error Logging vs Returning +### Logging vs Returning - **Fatal errors** (config, init): return immediately -- **Recoverable errors** (individual items in batch): collect and continue -- **Optional features** (notifications): log only, don't fail parent operation -- Lambda handlers: log detailed errors, return sanitized messages to client - -### Batch Operation Errors -- Collect errors in result structs (e.g., `SyncReport.Errors`) -- Continue processing remaining items -- Return aggregated results with partial success -- Helper methods: `HasErrors()`, `HasChanges()` - -## Authentication & Integration - -### GitHub -- **Required**: GitHub App (JWT + installation tokens, automatic rotation) -- **Auth flow**: JWT signed with private key → exchange for installation token → cached with auto-refresh -- **Webhooks**: HMAC-SHA256 signature verification - -### Okta -- OAuth 2.0 with private key authentication -- **Required scopes**: `okta.groups.read` and `okta.users.read` -- Sync uses slug-based GitHub Teams API - -### Slack -- Optional notifications for PR events and sync reports -- Configuration in `internal/notifiers/` +- **Recoverable errors** (one user, team, or alert source in a batch): collect in the result (`SyncReport.Errors`, `SecurityAlertsReport.Errors`) and continue +- **Optional features** (notifications): log, don't fail the parent operation + +### Batch Operations +- Collect errors in result structs and continue with the remaining items +- Never remove team members based on incomplete data: a failed Okta read skips the group, the safety threshold caps removals, and child-team members are never removed from a parent +- Never report orphaned users from incomplete data: detection is skipped unless every rule's team was found + +## Security +- Webhooks are always signature-checked; there is no unsigned mode +- Admin endpoints require `APP_ADMIN_TOKEN` (constant-time compare) and are disabled without it +- Only events from `APP_GITHUB_ORG` are acted on +- Escape GitHub and Okta text in Slack mrkdwn with `escape`/`link` in `internal/notifiers` +- Validate GitHub logins (`domain.IsValidGitHubLogin`) before putting them in API paths +- Auth headers are only sent to the configured GitHub API host ## Markdown Style - **Line length**: Max 80 characters for prose text diff --git a/README.md b/README.md index 413a683..fd07602 100644 --- a/README.md +++ b/README.md @@ -1,248 +1,271 @@ # github-ops-app -Bot that automates GitHub operations with Okta integration and Slack -notifications. Deploy as AWS Lambda, standard HTTP server, or container. +A GitHub App that syncs Okta groups to GitHub teams, reports pull requests +merged without meeting their branch's requirements, and reports stale security +alerts, with Slack notifications. Run it as an HTTP server, a container, or an +AWS Lambda function. ## Features -- **Okta group sync** - Automatically sync Okta groups to GitHub teams -- **Orphaned user detection** - Identify org members not in any synced teams -- **PR compliance monitoring** - Detect and notify when PRs bypass branch - protection -- **Security alerts monitoring** - Report stale Dependabot, code scanning, and - secret scanning alerts across the org -- **Automatic reconciliation** - Detects external team changes and triggers sync -- **Flexible configuration** - Enable only what you need via environment - variables -- **Slack notifications** - Rich messages for violations and sync reports +- **Okta group sync** - Sync Okta group members to GitHub teams, by group name + or name pattern +- **Orphaned user detection** - Report organization members in no synced team +- **Automatic reconciliation** - Sync again when someone changes a team outside + Okta +- **PR compliance monitoring** - Report PRs merged into monitored branches + without the reviews or status checks that branch protection and rulesets + require +- **Security alerts monitoring** - Report open Dependabot, code scanning, and + secret scanning alerts older than a threshold +- **GitHub App installer** - Optionally create the GitHub App from a manifest + with one click, using + [github-app-setup-go](https://github.com/cruxstack/github-app-setup-go) +- **Slack notifications** - Reports for each feature, in per-feature channels ## Quick Start ### Prerequisites -- Go ≥ 1.24 -- GitHub App ([setup guide](docs/github-app-setup.md)) -- **Optional**: Okta API Service app ([setup guide](docs/okta-setup.md)) -- **Optional**: Slack app ([setup guide](docs/slack-setup.md)) +- Go 1.27 or later, to build from source +- A GitHub App ([setup guide](docs/github-app-setup.md)), or the built-in + installer +- **Optional**: an Okta API Services app ([setup guide](docs/okta-setup.md)) +- **Optional**: a Slack app ([setup guide](docs/slack-setup.md)) -### Deployment Options +Prebuilt binaries for the server and the Lambda function are attached to each +[release](https://github.com/cruxstack/github-ops-app/releases). -The bot can be deployed in multiple ways: +### HTTP Server or Container -#### Option 1: Standard HTTP Server +```bash +cp .env.example .env # then edit it +make run # or: make up, to run in docker compose +``` -Run as a long-lived HTTP server on any VPS, VM, or container platform: +Or build the binary or image: ```bash -# build -make build-server - -# run (or use systemd, Docker, Kubernetes, etc.) -./dist/server - -# server listens on APP_PORT (default: 8080) -# endpoints: -# POST /webhooks - GitHub webhook receiver -# POST /scheduled/okta-sync - Trigger Okta sync (call via cron) -# POST /scheduled/security-alerts - Trigger security alerts check -# POST /scheduled/slack-test - Send test notification to Slack -# GET /server/status - Health check -# GET /server/config - Config (secrets redacted) +make build # dist/github-ops-app +make image # github-ops-app:dev ``` -**Scheduling Okta Sync**: Use any cron service or scheduler to POST to -`/scheduled/okta-sync` periodically. No EventBridge required. +On shutdown the server lets running syncs finish, for up to 10 minutes; give +it a grace period that long, such as Kubernetes' +`terminationGracePeriodSeconds: 630`, or a sync may stop partway and finish on +the next run. + +Schedule the Okta sync and security alert reports with any scheduler, such as +a Kubernetes CronJob, by calling the scheduled endpoints with the admin token: -#### Option 2: AWS Lambda +```bash +curl -X POST -H "Authorization: Bearer $APP_ADMIN_TOKEN" \ + https://github-ops.example.com/scheduled/okta-sync +``` -Deploy as serverless function with automatic scaling: +### AWS Lambda ```bash -# build for Lambda -make build-lambda # creates dist/bootstrap +make build-lambda # dist/bootstrap, for the provided.al2023 runtime ``` -See [cmd/lambda/README.md](cmd/lambda/README.md) for complete Lambda deployment -instructions including API Gateway and EventBridge configuration. +API Gateway delivers webhooks and EventBridge runs the scheduled actions. See +[cmd/lambda/README.md](cmd/lambda/README.md), or deploy with +[terraform-aws-github-ops-app](https://github.com/cruxstack/terraform-aws-github-ops-app). + +## Endpoints + +Every route is served under `APP_BASE_PATH`, if set. + +| Method | Path | Auth | Description | +| ------ | ---------------------------- | -------------- | ------------------------------------ | +| POST | `/webhooks` | Signature | GitHub webhooks | +| GET | `/healthz` | None | Liveness; always `200` | +| GET | `/readyz` | None | `200` once the configuration loads | +| POST | `/scheduled/okta-sync` | Admin token | Sync Okta groups to teams | +| POST | `/scheduled/security-alerts` | Admin token | Report stale security alerts | +| POST | `/scheduled/slack-test` | Admin token | Send a sample of every notification | +| GET | `/server/status` | Admin token | Status and enabled features | +| GET | `/server/config` | Admin token | Configuration, secrets redacted | +| GET | `/setup` | None | GitHub App installer, when enabled | + +Webhooks must carry a valid `X-Hub-Signature-256`; there is no unsigned mode. +The admin endpoints require `Authorization: Bearer ` and are +disabled when no admin token is set. Webhooks from organizations other than +`APP_GITHUB_ORG` are ignored. ## Configuration -See [`.env.example`](.env.example) for a complete configuration reference. +See [`.env.example`](.env.example) for a complete example. The configuration +is validated at startup: an invalid value, or a partly configured feature, is +an error, and every problem is reported at once. -All configuration values support direct values or AWS SSM parameter references. -For sensitive values like secrets and private keys, use SSM parameters with -automatic decryption: +Any `APP_` value may be an AWS SSM Parameter Store ARN, in any partition, +which is replaced by the parameter's decrypted value. The role needs +`ssm:GetParameter` (and `kms:Decrypt` for a customer managed key). ```bash -# Direct value -APP_GITHUB_WEBHOOK_SECRET=my-secret - -# SSM parameter (automatically decrypted if SecureString) -APP_GITHUB_WEBHOOK_SECRET=arn:aws:ssm:us-east-1:123456789012:parameter/github-bot/webhook-secret +APP_GITHUB_WEBHOOK_SECRET=arn:aws:ssm:us-east-1:123456789012:parameter/github-ops-app/webhook-secret ``` -**Requirements for SSM parameters**: - -- Valid AWS credentials with `ssm:GetParameter` permission -- Full SSM parameter ARN in format: - `arn:aws:ssm:REGION:ACCOUNT:parameter/path/to/param` -- SecureString parameters are automatically decrypted - -### Required: GitHub - -| Variable | Description | -| --------------------------------- | ------------------------ | -| `APP_GITHUB_APP_ID` | GitHub App ID | -| `APP_GITHUB_APP_PRIVATE_KEY` | Private key (PEM) | -| `APP_GITHUB_APP_PRIVATE_KEY_PATH` | Path to private key file | -| `APP_GITHUB_INSTALLATION_ID` | Installation ID | -| `APP_GITHUB_ORG` | Organization name | -| `APP_GITHUB_WEBHOOK_SECRET` | Webhook signature secret | - -### Optional: Okta Sync - -| Variable | Description | -| -------------------------------------- | --------------------------------------------- | -| `APP_OKTA_DOMAIN` | Okta domain | -| `APP_OKTA_CLIENT_ID` | OAuth 2.0 client ID | -| `APP_OKTA_PRIVATE_KEY` | Private key (PEM) or use | -| `APP_OKTA_PRIVATE_KEY_PATH` | Path to private key file | -| `APP_OKTA_GITHUB_USER_FIELD` | User profile field for username | -| `APP_OKTA_SYNC_RULES` | JSON array (see [examples](#okta-sync-rules)) | -| `APP_OKTA_SYNC_SAFETY_THRESHOLD` | Max removal ratio (default: `0.5` = 50%) | -| `APP_OKTA_ORPHANED_USER_NOTIFICATIONS` | Notify about orphaned users | - -### Optional: PR Compliance - -| Variable | Description | -| --------------------------- | ----------------------------------------- | -| `APP_PR_COMPLIANCE_ENABLED` | Enable monitoring (`true`) | -| `APP_PR_MONITORED_BRANCHES` | Branches to monitor (e.g., `main,master`) | - -### Optional: Security Alerts - -| Variable | Description | -| ---------------------------------- | ---------------------------------------- | -| `APP_SECURITY_ALERTS_ENABLED` | Enable monitoring (`true`) | -| `APP_SECURITY_ALERTS_MIN_AGE_DAYS` | Min alert age in days (default: `30`) | -| `APP_SECURITY_ALERTS_MIN_SEVERITY` | Min severity threshold (default: `high`) | - -Severity options: `critical`, `high`, `medium`, `low`. - -### Optional: Slack - -| Variable | Description | -| ----------------------------------- | --------------------------------------- | -| `APP_SLACK_TOKEN` | Bot token (`xoxb-...`) | -| `APP_SLACK_CHANNEL` | Default channel ID | -| `APP_SLACK_CHANNEL_PR_BYPASS` | Channel for PR bypass alerts (optional) | -| `APP_SLACK_CHANNEL_OKTA_SYNC` | Channel for sync reports (optional) | -| `APP_SLACK_CHANNEL_ORPHANED_USERS` | Channel for orphan alerts (optional) | -| `APP_SLACK_CHANNEL_SECURITY_ALERTS` | Channel for security alerts (optional) | - -### Other - -| Variable | Description | -| ------------------- | ------------------------------------- | -| `APP_PORT` | Server port (default: `8080`) | -| `APP_DEBUG_ENABLED` | Verbose logging (default: `false`) | -| `APP_BASE_PATH` | URL prefix to strip (e.g., `/api/v1`) | +### GitHub App (required) + +| Variable | Description | +| --------------------------------- | -------------------------------------------------- | +| `APP_GITHUB_ORG` | Organization the app manages | +| `APP_GITHUB_APP_ID` | GitHub App ID | +| `APP_GITHUB_APP_PRIVATE_KEY` | Private key (PEM; `\n` escapes are accepted) | +| `APP_GITHUB_APP_PRIVATE_KEY_PATH` | Or a path to the private key file | +| `APP_GITHUB_WEBHOOK_SECRET` | Webhook secret | +| `APP_GITHUB_INSTALLATION_ID` | Installation ID; looked up from the org when unset | +| `APP_GITHUB_BASE_URL` | API URL for GitHub Enterprise Server | + +### GitHub App Installer (optional) + +With the installer enabled, the service starts without GitHub App credentials +and serves `/setup`, which creates the app from a manifest with the right +permissions and saves its credentials. The service then loads them without a +restart. See [GitHub App Setup](docs/github-app-setup.md#option-a-installer). +These settings use the names of github-app-setup-go. + +| Variable | Description | +| ------------------------------ | ---------------------------------------------------- | +| `GITHUB_APP_INSTALLER_ENABLED` | `true` to serve the installer | +| `STORAGE_MODE` | `envfile` (default) or `aws-ssm` | +| `STORAGE_DIR` | Env file the `envfile` mode writes (`./.env`) | +| `AWS_SSM_PARAMETER_PREFIX` | Parameter path prefix for `aws-ssm` | +| `AWS_SSM_KMS_KEY_ID` | KMS key for `aws-ssm` (default: AWS managed key) | +| `APP_PUBLIC_URL` | External URL of the service, including the base path | + +### Okta Sync (optional) + +Setting any of these enables the sync and requires the domain, client ID, +private key, and rules. + +| Variable | Description | +| -------------------------------------- | ---------------------------------------------------- | +| `APP_OKTA_DOMAIN` | Okta domain, such as `acme.okta.com` | +| `APP_OKTA_CLIENT_ID` | OAuth 2.0 client ID | +| `APP_OKTA_PRIVATE_KEY` | Private key (PEM, PKCS#1 or PKCS#8) | +| `APP_OKTA_PRIVATE_KEY_PATH` | Or a path to the private key file | +| `APP_OKTA_PRIVATE_KEY_ID` | Key ID (`kid`) registered with the OAuth app | +| `APP_OKTA_SCOPES` | Scopes (default: `okta.groups.read,okta.users.read`) | +| `APP_OKTA_GITHUB_USER_FIELD` | Profile attribute with the GitHub username | +| `APP_OKTA_SYNC_RULES` | JSON array of rules (see [below](#okta-sync-rules)) | +| `APP_OKTA_SYNC_SAFETY_THRESHOLD` | Max share of a team to remove (default: `0.5`) | +| `APP_OKTA_ORPHANED_USER_NOTIFICATIONS` | Report orphaned users (default: `true`) | + +### PR Compliance (optional) + +| Variable | Description | +| --------------------------- | ----------------------------------------------------------- | +| `APP_PR_COMPLIANCE_ENABLED` | `true` to check merged PRs | +| `APP_PR_MONITORED_BRANCHES` | Branches or globs, such as `main,release/*` (`main,master`) | + +### Security Alerts (optional) + +| Variable | Description | +| ---------------------------------- | ------------------------------------------------ | +| `APP_SECURITY_ALERTS_ENABLED` | `true` to enable the report | +| `APP_SECURITY_ALERTS_MIN_AGE_DAYS` | Minimum alert age in days (default: `30`) | +| `APP_SECURITY_ALERTS_MIN_SEVERITY` | `critical`, `high` (default), `medium`, or `low` | + +Code scanning alerts use their security severity, or for rules without one, +`error` as high, `warning` as medium, and `note` as low. Secret scanning alerts +have no severity and count as high. + +### Slack (optional) + +The token and channel are required together. + +| Variable | Description | +| ----------------------------------- | ----------------------------------------- | +| `APP_SLACK_TOKEN` | Bot token (`xoxb-...`) | +| `APP_SLACK_CHANNEL` | Default channel ID | +| `APP_SLACK_CHANNEL_PR_BYPASS` | Channel for PR bypass reports | +| `APP_SLACK_CHANNEL_OKTA_SYNC` | Channel for sync reports | +| `APP_SLACK_CHANNEL_ORPHANED_USERS` | Channel for orphaned user reports | +| `APP_SLACK_CHANNEL_SECURITY_ALERTS` | Channel for security alert reports | +| `APP_SLACK_FOOTER_NOTE_PR_BYPASS` | Footer for PR bypass reports, in mrkdwn | + +### Service + +| Variable | Description | +| ----------------- | ------------------------------------------------------------ | +| `APP_ADMIN_TOKEN` | Token for the admin endpoints, at least 16 characters | +| `APP_LOG_LEVEL` | `debug`, `info` (default), `warn`, or `error` | +| `APP_LISTEN_ADDR` | Server address (default: `:8080`) | +| `APP_BASE_PATH` | Path every route is served under, such as an API stage | ### Okta Sync Rules -Map Okta groups to GitHub teams using JSON rules: +Each rule maps one Okta group, by name, or every group matching a pattern, to +a GitHub team: ```json [ { - "name": "sync-engineering-teams", - "enabled": true, + "name": "engineering-teams", "okta_group_pattern": "^github-eng-.*", - "github_team_prefix": "eng-", "strip_prefix": "github-eng-", - "sync_members": true, + "github_team_prefix": "eng-", "create_team_if_missing": true }, { - "name": "sync-platform-team", - "enabled": true, - "okta_group_name": "platform-team", + "name": "platform-team", + "okta_group_name": "Platform Engineers", "github_team_name": "platform", - "sync_members": true, - "team_privacy": "closed" + "team_privacy": "secret" } ] ``` -See [Okta Setup - Sync Rules](docs/okta-setup.md#step-10-configure-sync-rules) -for detailed rule field documentation. - -**Sync Safety Features**: - -- Only syncs `ACTIVE` Okta users; never removes outside collaborators -- Safety threshold (default 50%) aborts sync if too many removals detected -- Orphaned user detection alerts when org members aren't in any synced teams - -## Integration Setup - -Detailed setup guides for each integration: - -- [GitHub App Setup](docs/github-app-setup.md) - Create and install the GitHub - App with required permissions -- [Okta Setup](docs/okta-setup.md) - Configure API Services app for group sync -- [Slack Setup](docs/slack-setup.md) - Create Slack app for notifications - -## Development - -```bash -# run server locally -make server - -# run all tests -make test - -# integration tests (offline, uses mock servers) -make test-verify - -# specific package -go test -race -count=1 ./internal/github - -# specific test -go test -race -count=1 ./internal/okta -run TestGroupSync -``` - -### Docker Deployment - -```dockerfile -FROM golang:1.24-alpine AS builder -WORKDIR /app -COPY . . -RUN apk add --no-cache make && make build-server - -FROM alpine:latest -RUN apk --no-cache add ca-certificates -COPY --from=builder /app/dist/server /server -EXPOSE 8080 -CMD ["/server"] -``` +| Field | Description | +| ------------------------ | -------------------------------------------------------------------- | +| `name` | Name used in reports | +| `enabled` | `false` to skip the rule (default: `true`) | +| `okta_group_name` | Exact name of one Okta group | +| `okta_group_pattern` | Regular expression matching Okta group names | +| `github_team_name` | Team name, for `okta_group_name` rules | +| `strip_prefix` | Prefix removed from the group name to derive the team name | +| `github_team_prefix` | Prefix added to the derived team name | +| `sync_members` | `false` to only create the team (default: `true`) | +| `create_team_if_missing` | Create missing teams; otherwise they are reported (default: `false`) | +| `team_privacy` | `closed` (default) or `secret`, for created teams | + +A rule sets exactly one of `okta_group_name` and `okta_group_pattern`. Unknown +fields are rejected, so a misspelled field is an error instead of being +ignored. + +**Sync safety**: + +- Only members who can sign in to Okta are synced: `ACTIVE`, and + `PASSWORD_EXPIRED`, `LOCKED_OUT`, and `RECOVERY`, so an expired password + does not remove anyone. Suspended and deprovisioned users are removed. +- No one is removed from a team if more than the safety threshold of its + members would be, which guards against an empty or partial group from Okta. +- Users who are not organization members are never removed. +- A group whose members cannot be read from Okta is reported and not synced. +- Two groups that map to the same team in one run are reported; only the + first is synced, so they cannot remove each other's members. +- Okta usernames are validated as GitHub logins before they are used. ## How It Works ``` +------------+ +------------------------------------+ +-----------+ | GitHub | ---> | github-ops-app | ---> | Slack | -| webhooks | | | | alerts | +| webhooks | | | | reports | +------------+ | +------------------------------+ | +-----------+ | | PR Compliance Check | | -+------------+ | | - verify branch protection | | +-----------+ -| Okta | ---> | | - detect bypasses | | ---> | GitHub | ++------------+ | | - required reviews | | +-----------+ +| Okta | ---> | | - required status checks | | ---> | GitHub | | groups | | +------------------------------+ | | Teams API | +------------+ | | +-----------+ | +------------------------------+ | - | | Okta Sync Engine | | - | | - map groups to teams | | - | | - sync membership | | - | +------------------------------+ | ++------------+ | | Okta Sync Engine | | +| Scheduler | ---> | | - map groups to teams | | +| (cron/EB) | | | - sync membership | | ++------------+ | +------------------------------+ | | | | +------------------------------+ | | | Security Alerts Monitor | | @@ -253,41 +276,76 @@ CMD ["/server"] +------------------------------------+ ``` -### Okta Sync Flow +### Okta Sync -1. **Trigger**: Scheduled cron/EventBridge or team membership webhook -2. **Fetch**: Query Okta groups matching configured rules -3. **Match**: Apply sync rules to map Okta groups → GitHub teams -4. **Sync**: Add/remove GitHub team members (ACTIVE Okta users only) -5. **Safety**: Abort if removal ratio exceeds threshold (default 50%) -6. **Report**: Send Slack notification with changes and orphaned users +1. **Trigger**: the schedule, or a `team` or `membership` webhook from a + person, not a bot, about a team a rule manages +2. **Fetch**: read the Okta groups each rule matches, with their members +3. **Map**: derive each group's GitHub team, creating it if allowed +4. **Sync**: add and remove team members to match the group +5. **Report**: post the changes, errors, and orphaned users to Slack -### PR Compliance Flow +A sync requested while one is running does not run concurrently; the running +sync runs once more when it finishes. The server answers webhooks at once +(`202`) and syncs afterwards, since GitHub waits only 10 seconds; Lambda syncs +before it answers. Members of a team's child teams are never removed from the +parent team, where GitHub also lists them. -1. **Receive**: GitHub webhook on PR merge to monitored branch -2. **Verify**: Validate webhook signature (HMAC-SHA256) -3. **Check**: Query branch protection rules and required status checks -4. **Detect**: Identify bypasses (admin override, missing reviews, failed - checks) -5. **Notify**: Send Slack alert with violation details +Orphaned user detection is skipped when a rule's team could not be found, +since its members would otherwise be reported as orphaned. -### Security Alerts Flow +### PR Compliance -1. **Trigger**: Scheduled cron/EventBridge (`POST /scheduled/security-alerts`) -2. **Fetch**: Query Dependabot, code scanning, and secret scanning alerts across - the org -3. **Filter**: Keep open alerts older than threshold (default 30 days) at or - above minimum severity (default high) -4. **Report**: Send Slack notification summarizing stale alerts by repo +1. **Receive**: a `pull_request` webhook for a PR merged into a monitored + branch of the organization's repositories +2. **Check**: compare the PR's approving reviews and its head commit's status + checks with what branch protection and rulesets require +3. **Report**: post the PR to Slack when it did not meet them, with the + merger's repository role when it explains the bypass -## Troubleshooting +A reviewer counts once, by their latest review that approved, requested +changes, or was dismissed. Check runs that are successful, neutral, or skipped +pass, as on GitHub. + +### Security Alerts + +1. **Trigger**: the schedule +2. **Fetch**: read every page of open Dependabot, code scanning, and secret + scanning alerts in the organization, oldest first +3. **Filter**: keep alerts older than the minimum age at or above the minimum + severity +4. **Report**: post the alerts by repository to Slack, with any source that + failed + +## Development -**Common issues**: +```bash +make help # list targets +make test # all tests, with the race detector +make test-e2e # the scenarios in fixtures/scenarios.json against mock apis +make lint # golangci-lint +make vuln # govulncheck +make check # everything ci runs +``` + +The end-to-end tests run the real service against mock GitHub, Okta, and +Slack APIs. Add a scenario to [fixtures/scenarios.json](fixtures/scenarios.json) +to cover a new behavior. + +## Troubleshooting -- Unauthorized from GitHub: Check app installation and permissions -- Group not found from Okta: Verify domain and scopes -- Webhook signature fails: Verify `APP_GITHUB_WEBHOOK_SECRET` matches -- No Slack notifications: Verify token has `chat:write` and bot is in channel +- **`401` on webhooks**: `APP_GITHUB_WEBHOOK_SECRET` doesn't match the app's + webhook secret. +- **`503 service not configured`**: the configuration did not load; the logs + list every problem. With the installer enabled, finish the setup at `/setup`. +- **`403` from GitHub when checking a PR**: the app lacks a permission, such + as **Administration: read** for branch protection. See the + [permissions](docs/github-app-setup.md#permissions). +- **Group not found from Okta**: check the domain, scopes, and the group name. +- **No Slack messages**: check the token has `chat:write` and the bot is in + the channel, or add `chat:write.public`. + +Upgrading from v0? See [Upgrading to v1](docs/upgrading-to-v1.md). ## License diff --git a/cmd/lambda/README.md b/cmd/lambda/README.md index d8a6d3b..67f5e75 100644 --- a/cmd/lambda/README.md +++ b/cmd/lambda/README.md @@ -1,258 +1,104 @@ # AWS Lambda Deployment -This directory contains the AWS Lambda adapter for the GitHub bot. Lambda is one -of several deployment options - see the [main README](../../README.md) for -alternatives like standard HTTP servers. +This command runs github-ops-app as an AWS Lambda function. It serves the same +routes as the HTTP server; see the [main README](../../README.md) for the +configuration and the other ways to run it. The +[terraform-aws-github-ops-app](https://github.com/cruxstack/terraform-aws-github-ops-app) +module deploys everything below. -## Overview +## Events -The Lambda adapter translates AWS-specific events into the unified -`app.HandleRequest()` interface: +- **API Gateway HTTP API** (payload format 2.0) requests carry GitHub webhooks + and admin requests. They are converted to HTTP requests, with their query + string, headers, cookies, and base64 bodies, and served by the same router + as the server. +- **EventBridge** events run a scheduled action named in their detail. AWS + authorizes these invocations, so they don't need the admin token. -- **API Gateway** (webhooks, status, config) → `app.Request{Type: HTTP}` -- **EventBridge** (scheduled sync) → `app.Request{Type: Scheduled}` +The configuration loads on the first invocation. If it fails, such as when an +SSM parameter is unavailable, it is retried on the next invocation instead of +leaving the container broken until it is recycled. ## Build ```bash -# from repository root +# from the repository root; GOARCH=arm64 for arm64 functions make build-lambda - -# creates dist/bootstrap (Lambda requires this exact name) +cd dist && zip ../bootstrap.zip bootstrap ``` -## Deploy to AWS Lambda - -### 1. Create Function - -- **Runtime**: `provided.al2023` -- **Handler**: `bootstrap` -- **Architecture**: `x86_64` -- **Memory**: 256 MB -- **Timeout**: 30 seconds -- **IAM Role**: `AWSLambdaBasicExecutionRole` (no additional permissions needed) - -### 2. Upload Code - -Upload `dist/bootstrap` to your Lambda function via: - -- AWS Console (function code upload) -- AWS CLI: - `aws lambda update-function-code --function-name github-ops-app --zip-file fileb://dist/bootstrap.zip` -- Infrastructure as Code (Terraform, CDK, CloudFormation) - -### 3. Configure Environment Variables - -Set all required environment variables (see -[Configuration](../../README.md#configuration) in main README). - -### 4. Setup Triggers +Releases attach `github-ops-app_lambda_amd64.zip` and +`github-ops-app_lambda_arm64.zip`. -#### API Gateway (for GitHub Webhooks) +## Deploy -Create an HTTP API Gateway: +### Function -1. **Create API**: HTTP API (not REST API) -2. **Add Route**: `POST /webhooks` (or `ANY /{proxy+}` for universal handler) -3. **Integration**: Lambda function (proxy integration enabled) -4. **Deploy**: Note the invoke URL -5. **GitHub App**: Set webhook URL to `https:///webhooks` +| Setting | Value | +| ------------ | ------------------------------------------------------ | +| Runtime | `provided.al2023` | +| Handler | `bootstrap` | +| Architecture | `x86_64` or `arm64`, matching the build | +| Memory | 256 MB | +| Timeout | 5 minutes or more, so a full Okta sync can finish | +| Role | `AWSLambdaBasicExecutionRole`, plus `ssm:GetParameter` | -**Headers**: API Gateway automatically forwards all headers including -`X-GitHub-Event` and `X-Hub-Signature-256`. +Set the [configuration](../../README.md#configuration) as environment +variables. Keep secrets in SSM Parameter Store and set their variables to the +parameter ARNs, so they don't appear in the function's configuration. -#### EventBridge (for Scheduled Tasks) +### API Gateway -Create EventBridge rules for each scheduled feature you use: - -**Okta Sync** (sync Okta groups to GitHub teams): - -1. **Rule Type**: Schedule -2. **Schedule**: `rate(1 hour)` or `rate(6 hours)` -3. **Target**: Lambda function -4. **Input**: Configure constant (JSON): - -```json -{ - "source": "aws.events", - "detail-type": "Scheduled Event", - "detail": { - "action": "okta-sync" - } -} -``` +1. Create an **HTTP API** with a Lambda proxy integration. +2. Add the route `ANY /{proxy+}`, or at least `POST /webhooks`. +3. If the stage is not `$default`, set `APP_BASE_PATH` to `/`. +4. Set the GitHub App's webhook URL to `https:////webhooks`. -**Security Alerts** (report stale security alerts): +### EventBridge -1. **Rule Type**: Schedule -2. **Schedule**: `rate(1 day)` or `cron(0 9 ? * MON *)` (weekly Monday 9 AM) -3. **Target**: Lambda function -4. **Input**: Configure constant (JSON): +Add a schedule rule per scheduled feature, with the Lambda function as the +target and an input whose detail names the action: ```json { "source": "aws.events", "detail-type": "Scheduled Event", - "detail": { - "action": "security-alerts" - } + "detail": { "action": "okta-sync" } } ``` -## Architecture - -### Universal Handler - -The Lambda function uses a universal handler that detects event types and -converts them to the unified `app.Request` format: - -```go -func UniversalHandler(ctx context.Context, event json.RawMessage) (any, error) -``` - -**Supported Events**: - -- `APIGatewayV2HTTPRequest` → Converts to `app.Request{Type: HTTP}` -- `CloudWatchEvent` (EventBridge) → Converts to `app.Request{Type: Scheduled}` - -All requests are then processed by `app.HandleRequest()` which routes based on -request type and path. - -### Endpoints - -When invoked via API Gateway: - -| Method | Path | Description | -| ------ | ---------------------------- | ---------------------------------- | -| POST | `/webhooks` | GitHub webhook receiver | -| POST | `/scheduled/okta-sync` | Trigger Okta sync | -| POST | `/scheduled/security-alerts` | Trigger security alerts check | -| POST | `/scheduled/slack-test` | Send test notification to Slack | -| GET | `/server/status` | Health check and feature flags | -| GET | `/server/config` | Config inspection (secrets hidden) | - -## Monitoring - -### CloudWatch Logs - -View logs: - -```bash -aws logs tail /aws/lambda/your-function-name --follow -``` - -### Metrics - -Lambda automatically tracks: - -- Invocations -- Duration -- Errors -- Throttles - -### Debug Mode - -Enable verbose logging: - -```bash -aws lambda update-function-configuration \ - --function-name github-ops-app \ - --environment Variables="{APP_DEBUG_ENABLED=true,...}" -``` - -## Cost Optimization - -### Memory Sizing - -Start with 256 MB and adjust based on CloudWatch metrics: +| Action | Suggested schedule | +| ----------------- | ---------------------------- | +| `okta-sync` | `rate(1 hour)` | +| `security-alerts` | `cron(0 13 ? * MON *)` | -- **Under-provisioned**: Slow response times, timeouts -- **Over-provisioned**: Wasted cost -- **Right-sized**: \<100ms execution time for webhooks, \<5s for sync +A failed action fails the invocation, so EventBridge retries it and it shows +in the function's error metrics. -### Timeout +## Installer -- **Webhooks**: 15 seconds (GitHub timeout is 10s) -- **Scheduled sync**: 30-60 seconds (depends on org size) +The [installer](../../docs/github-app-setup.md#option-a-installer) works in +Lambda with `STORAGE_MODE=aws-ssm`: -### Invocation Frequency +1. Set `GITHUB_APP_INSTALLER_ENABLED=true`, `STORAGE_MODE=aws-ssm`, and + `AWS_SSM_PARAMETER_PREFIX`, such as `/github-ops-app/`. +2. Set `APP_GITHUB_APP_ID`, `APP_GITHUB_APP_PRIVATE_KEY`, and + `APP_GITHUB_WEBHOOK_SECRET` to the ARNs of the parameters the installer + will create, such as + `arn:aws:ssm:us-east-1:123456789012:parameter/github-ops-app/APP_GITHUB_APP_ID`. +3. Grant the role `ssm:GetParameter` and `ssm:PutParameter` (and + `ssm:AddTagsToResource` with `AWS_SSM_TAGS`) on the prefix. +4. Visit `https:////setup` and create the app. -**Webhooks**: Pay-per-use (only when events occur) - -**Scheduled Sync**: Runs on schedule regardless of changes - -- Hourly sync: ~730 invocations/month -- 6-hour sync: ~120 invocations/month - -**Cost Example** (us-east-1, 256MB, 5s avg): - -- Free tier: 1M requests, 400,000 GB-seconds/month -- Typical usage: \<1000 invocations/month → **free** +The next invocation after the app is created loads its credentials. When +`APP_BASE_PATH` is set, the installer's "disable" button posts to a path +without the stage; set `GITHUB_APP_INSTALLER_ENABLED=false` instead. ## Troubleshooting -### Webhook Signature Validation Fails - -**Symptom**: `401 unauthorized` responses, logs show "webhook signature -validation failed" - -**Solutions**: - -- Verify `APP_GITHUB_WEBHOOK_SECRET` matches GitHub App settings -- Check API Gateway forwards `X-Hub-Signature-256` header -- Ensure payload isn't modified by API Gateway (use proxy integration) - -### EventBridge Sync Not Running - -**Symptom**: No sync activity in logs - -**Solutions**: - -- Verify EventBridge rule is **enabled** -- Check rule target is configured correctly -- Verify input payload has correct structure -- Check Lambda has no concurrent execution limits - -### Lambda Timeout - -**Symptom**: Function execution exceeds configured timeout - -**Solutions**: - -- Increase timeout (max 15 minutes) -- Reduce Okta sync scope (fewer rules/groups) -- Check for slow API responses from GitHub/Okta -- Enable debug logging to identify bottleneck - -### Cold Start Latency - -**Symptom**: First webhook after idle period is slow - -**Solutions**: - -- Accept it (typically 100-500ms, GitHub webhooks tolerate this) -- Use Lambda provisioned concurrency (increases cost) -- Consider standard HTTP server for consistently low latency - -## Migration from Lambda - -To migrate to a standard HTTP server deployment: - -1. Build server: `make build-server` -2. Deploy to VPS/container/K8s -3. Update GitHub webhook URL to new server -4. Setup external cron for `/scheduled/okta-sync` endpoint -5. Delete Lambda function and API Gateway - -No code changes needed - the core app is deployment-agnostic. - -## Alternatives - -Consider non-Lambda deployments if you: - -- Need consistently low latency (\<50ms) -- Want to avoid AWS vendor lock-in -- Prefer traditional server management -- Have existing container infrastructure - -See [main README](../../README.md) for standard HTTP server deployment. +- **`401` on webhooks**: check `APP_GITHUB_WEBHOOK_SECRET`, and that API + Gateway uses a proxy integration, which passes the body unchanged. +- **`503 service not configured`**: the configuration did not load; the logs + list every problem. +- **`404` on every route**: `APP_BASE_PATH` doesn't match the stage. +- **Timeouts**: raise the timeout; a sync of many large groups takes minutes. diff --git a/docs/github-app-setup.md b/docs/github-app-setup.md index a2c3b41..254cbec 100644 --- a/docs/github-app-setup.md +++ b/docs/github-app-setup.md @@ -1,202 +1,173 @@ # GitHub App Setup -This guide walks through creating and installing a GitHub App for -github-ops-app. +github-ops-app runs as a GitHub App installed on your organization. Create +the app with the built-in installer, or by hand. ## Prerequisites -- GitHub organization with admin access +- Owner access to the GitHub organization +- The URL where GitHub can reach the service, for webhooks -## Step 1: Create the GitHub App +## Option A: Installer -1. Navigate to your organization's settings: +The installer, from +[github-app-setup-go](https://github.com/cruxstack/github-app-setup-go), +creates the app from a manifest with the permissions and events below, then +saves its credentials where the service reads them. The service loads them +without a restart. - - Go to `https://github.com/organizations/YOUR_ORG/settings/apps` - - Or: **Organization** → **Settings** → **Developer settings** → **GitHub - Apps** +1. Configure the service without GitHub App credentials, and enable the + installer: -2. Click **New GitHub App** + ```bash + APP_GITHUB_ORG=your-org + GITHUB_APP_INSTALLER_ENABLED=true + # where the credentials are saved: envfile (default) or aws-ssm + STORAGE_MODE=envfile + STORAGE_DIR=./.env + # the external url of the service, including APP_BASE_PATH + APP_PUBLIC_URL=https://github-ops.example.com + ``` -3. Fill in the basic information: + With `STORAGE_MODE=aws-ssm`, also set `AWS_SSM_PARAMETER_PREFIX`, and set + `APP_GITHUB_APP_ID`, `APP_GITHUB_APP_PRIVATE_KEY`, and + `APP_GITHUB_WEBHOOK_SECRET` to the ARNs of the parameters the installer + will create under that prefix, such as + `arn:aws:ssm:us-east-1:123456789012:parameter/github-ops-app/APP_GITHUB_APP_ID`. - | Field | Value | - | --------------------- | ----------------------------------------------- | - | GitHub App name | `github-ops-app` (must be unique across GitHub) | - | Homepage URL | Your organization's URL or repo URL | - | Webhook > Webhook URL | Leave blank for now | - | Webhook > Secret | Generate a strong secret (save this for later) | - | Webhook > Active | **Uncheck** to disable webhooks initially | +2. Start the service. It serves the installer while it waits for credentials, + and `/readyz` returns `503` until they load. - > **Note**: Disable webhooks during creation since you may not know your - > endpoint URL until after deployment. You'll configure webhooks and - > subscribe to events in [Step 7](#step-7-configure-webhook-and-events). +3. Visit `https://github-ops.example.com/setup`, check the webhook URL ends in + `/webhooks`, and create the app. GitHub asks you to confirm. -### Configure Permissions +4. On the success page, follow the link to install the app on your + organization. -Under **Permissions**, set the following: +5. Disable the installer: use the button on the success page, or unset + `GITHUB_APP_INSTALLER_ENABLED`. -### Repository Permissions - -| Permission | Access | Purpose | -| ---------------------- | ------ | --------------------------------- | -| Checks | Read | Read status checks on PRs | -| Code scanning alerts | Read | Fetch open code scanning alerts | -| Contents | Read | Read branch protection rules | -| Dependabot alerts | Read | Fetch open Dependabot alerts | -| Pull requests | Read | Access PR details for compliance | -| Secret scanning alerts | Read | Fetch open secret scanning alerts | - -#### Organization Permissions - -| Permission | Access | Purpose | -| -------------- | ---------- | -------------------------- | -| Members | Read/Write | Manage team membership | -| Administration | Read | Read organization settings | +Once an app is registered, the service refuses to exchange new installer +codes, so no one can replace its credentials. Still, only enable the +installer while you set the app up, and keep `/setup` off the public internet +if you can. -> **Note**: The three security alert permissions are only required if you enable -> security alerts monitoring (`APP_SECURITY_ALERTS_ENABLED=true`). You can omit -> them if you don't use that feature. +The installer saves the credentials under both the names github-app-setup-go +uses, such as `GITHUB_APP_ID`, and the names this service reads, such as +`APP_GITHUB_APP_ID`. -4. Set installation scope: +## Option B: By Hand - | Setting | Value | - | --------------------------------------- | -------------------- | - | Where can this GitHub App be installed? | Only on this account | +### 1. Create the App -5. Click **Create GitHub App** +1. Go to `https://github.com/organizations/YOUR_ORG/settings/apps/new`. +2. Fill in: -## Step 2: Generate Private Key + | Field | Value | + | --------------------------------------- | ------------------------------------ | + | GitHub App name | `github-ops-app` (must be unique) | + | Homepage URL | Your organization's or the repo URL | + | Webhook URL | `https://YOUR_HOST/webhooks` | + | Webhook secret | A long random string; save it | + | Where can this GitHub App be installed? | **Only on this account** | -After creating the app: +3. Set the [permissions](#permissions) and [events](#events) below. +4. Click **Create GitHub App**, and note the **App ID**. -1. Scroll to **Private keys** section -2. Click **Generate a private key** -3. Save the downloaded `.pem` file securely -4. This file is used for `APP_GITHUB_APP_PRIVATE_KEY` or - `APP_GITHUB_APP_PRIVATE_KEY_PATH` +[assets/github/manifest.json](../assets/github/manifest.json) holds the same +settings as a manifest. -## Step 3: Note Your App ID +### 2. Generate a Private Key -On the app's settings page, find and save: +Under **Private keys**, click **Generate a private key** and keep the `.pem` +file secret. -- **App ID** - numeric ID displayed near the top (e.g., `123456`) +### 3. Install the App -## Step 4: Install the App +Click **Install App**, choose your organization, and give it access to all +repositories, or the ones whose PRs and alerts it should check. -1. In the left sidebar, click **Install App** -2. Select your organization -3. Choose repository access: - - **All repositories** - recommended for org-wide PR compliance - - **Only select repositories** - if limiting scope -4. Click **Install** - -## Step 5: Get Installation ID - -After installation, you'll be redirected to a URL like: - -``` -https://github.com/organizations/YOUR_ORG/settings/installations/12345678 -``` - -The number at the end (`12345678`) is your **Installation ID**. - -Alternatively, use the GitHub API: +### 4. Configure the Service ```bash -# List installations (requires app JWT authentication) -curl -H "Authorization: Bearer YOUR_JWT" \ - https://api.github.com/app/installations -``` - -## Step 6: Configure Environment Variables - -Set these environment variables in your deployment: - -```bash -# Required GitHub configuration +APP_GITHUB_ORG=your-org APP_GITHUB_APP_ID=123456 -APP_GITHUB_INSTALLATION_ID=12345678 -APP_GITHUB_ORG=your-org-name APP_GITHUB_WEBHOOK_SECRET=your-webhook-secret -# Private key (choose one method) -APP_GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY----- -... ------END RSA PRIVATE KEY-----" - -# Or use a file path +# the private key: a file, the pem itself, or an ssm parameter arn APP_GITHUB_APP_PRIVATE_KEY_PATH=/path/to/private-key.pem - -# Or use AWS SSM parameter -APP_GITHUB_APP_PRIVATE_KEY=arn:aws:ssm:us-east-1:123456789:parameter/github-bot/private-key +# APP_GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" +# APP_GITHUB_APP_PRIVATE_KEY=arn:aws:ssm:us-east-1:123456789012:parameter/github-ops-app/private-key ``` -## Step 7: Configure Webhook and Events - -After deploying your server, configure and enable webhooks: +The installation ID is looked up from the organization. To skip the lookup, +set `APP_GITHUB_INSTALLATION_ID` to the number at the end of the installation +page's URL, `https://github.com/organizations/YOUR_ORG/settings/installations/12345678`. -1. Go to your GitHub App settings: - `https://github.com/organizations/YOUR_ORG/settings/apps/YOUR_APP` +## Permissions -2. Set **Webhook URL** to your endpoint: +The app needs these permissions and no others. Grant only those of the +features you use, if you prefer. - - Lambda: `https://xxx.execute-api.region.amazonaws.com/webhooks` - - Server: `https://your-domain.com/webhooks` - -3. Check **Active** to enable webhooks - -4. Click **Save changes** +### Repository Permissions -5. Under **Subscribe to events**, check: +| Permission | Access | Used for | +| ---------------------- | ------ | ---------------------------------------------- | +| Administration | Read | PR compliance: branch protection rules | +| Checks | Read | PR compliance: check runs on the merged commit | +| Commit statuses | Read | PR compliance: statuses on the merged commit | +| Metadata | Read | PR compliance: rulesets and merger's role | +| Pull requests | Read | PR compliance: PRs and their reviews | +| Code scanning alerts | Read | Security alerts | +| Dependabot alerts | Read | Security alerts | +| Secret scanning alerts | Read | Security alerts | - - [x] **Pull request** - PR open, close, merge events - - [x] **Team** - Team creation, deletion, changes - - [x] **Membership** - Team membership changes +### Organization Permissions -6. Click **Save changes** +| Permission | Access | Used for | +| ---------- | ---------- | --------------------------------------- | +| Members | Read/Write | Okta sync: teams and team membership | -## Using the App Manifest (Alternative) +Without **Administration: read**, the PR compliance check cannot read branch +protection, and fails with a `403` instead of silently treating the branch as +unprotected. -For automated setup, use the manifest at `assets/github/manifest.json`: +## Events -1. Go to `https://github.com/settings/apps/new` -2. Append `?manifest=` with URL-encoded manifest JSON -3. Or use the manifest creation API +| Event | Used for | +| ------------ | ------------------------------------------------------------------- | +| Pull request | PR compliance | +| Team | Okta sync after a team is created, renamed, or deleted outside Okta | +| Membership | Okta sync after a team's members change outside Okta | -The manifest pre-configures all required permissions and events. +The service answers GitHub's `ping` event, and accepts and ignores events it +doesn't handle and events from other organizations. ## Verification -Test your setup: - -1. **Webhook delivery**: Check **Settings** → **Developer settings** → **GitHub - Apps** → your app → **Advanced** → **Recent Deliveries** - -2. **Create a test PR**: Open and merge a PR to a monitored branch to verify - webhook reception - -3. **Check logs**: Verify your application receives and processes the webhook +1. In the app's settings, open **Advanced** → **Recent Deliveries**; the + `ping` delivery should have succeeded. +2. Merge a PR into a monitored branch and check the service's logs. +3. With the admin token set, call `GET /server/status`. ## Troubleshooting ### Webhook signature verification failed -- Verify `APP_GITHUB_WEBHOOK_SECRET` matches the secret in GitHub App settings -- Check for whitespace or encoding issues in the secret +- `APP_GITHUB_WEBHOOK_SECRET` must match the app's webhook secret exactly. -### 401 Unauthorized from GitHub API +### `403` or `404` from the GitHub API -- Verify the private key matches the one generated for this app -- Check that the app is installed on the target organization -- Ensure `APP_GITHUB_INSTALLATION_ID` is correct +- Check the app has the [permissions](#permissions) above. After changing + them, an organization owner must approve them on the installation. +- Check the app is installed on `APP_GITHUB_ORG` and can access the repository. -### Missing permissions error +### `github app is not installed on the organization` -- Re-check the app's permission settings -- After changing permissions, organization admins may need to re-approve +- Install the app on `APP_GITHUB_ORG`, or set `APP_GITHUB_INSTALLATION_ID`. ### Webhook not received -- Verify the webhook URL is accessible from the internet -- Check the webhook URL doesn't have a trailing slash mismatch -- Review recent deliveries in GitHub App settings for error details +- Check the webhook URL is reachable from the internet and ends in + `/webhooks`, after any `APP_BASE_PATH`. +- Review **Recent Deliveries** for the response GitHub got. diff --git a/docs/okta-setup.md b/docs/okta-setup.md index ba660b9..56d548e 100644 --- a/docs/okta-setup.md +++ b/docs/okta-setup.md @@ -141,7 +141,17 @@ profile field contains GitHub usernames: - Description: `User's GitHub username for team sync` 5. Click **Save** -Then set `APP_OKTA_GITHUB_USER_FIELD=githubUsername`. +Then set `APP_OKTA_GITHUB_USER_FIELD=githubUsername`. Standard attributes, +such as `nickName`, work too. + +> **Security**: the sync adds whoever the attribute names to the user's teams, +> and GitHub invites them to the organization if they aren't a member. Make +> the attribute **read only** for users (in the Profile Editor, under the +> attribute's **User permission**), and fill it from a trusted source, so no +> one can point it at another GitHub account. + +Values are trimmed, and a leading `@` is removed. A value that isn't a valid +GitHub username is skipped and reported, with the user's email. ## Step 9: Prepare Okta Groups @@ -180,6 +190,9 @@ APP_OKTA_PRIVATE_KEY_PATH=/path/to/okta-private-key.pem # Or use AWS SSM parameter APP_OKTA_PRIVATE_KEY=arn:aws:ssm:us-east-1:123456789:parameter/github-bot/okta-key + +# Key ID (kid) of the public key, if you registered more than one +APP_OKTA_PRIVATE_KEY_ID=your-key-id ``` ## Step 11: Configure Sync Rules @@ -202,39 +215,45 @@ APP_OKTA_SYNC_RULES='[ ### Rule Fields -| Field | Description | -| ------------------------ | ------------------------------------------------------ | -| `name` | Rule identifier (for logging) | -| `enabled` | Enable/disable rule (default: `true`) | -| `okta_group_pattern` | Regex to match Okta groups | -| `okta_group_name` | Exact Okta group name (alternative to pattern) | -| `github_team_prefix` | Prefix for generated GitHub team names | -| `github_team_name` | Exact GitHub team name (overrides pattern) | -| `strip_prefix` | Remove this prefix from Okta group name | -| `sync_members` | Sync members between Okta and GitHub (default: `true`) | -| `create_team_if_missing` | Auto-create GitHub teams if they don't exist | -| `team_privacy` | GitHub team visibility: `secret` or `closed` | +| Field | Description | +| ------------------------ | ------------------------------------------------------- | +| `name` | Rule identifier (for logging) | +| `enabled` | Enable/disable rule (default: `true`) | +| `okta_group_pattern` | Regex to match Okta groups | +| `okta_group_name` | Exact Okta group name (alternative to pattern) | +| `github_team_prefix` | Prefix for generated GitHub team names | +| `github_team_name` | Exact GitHub team name, for `okta_group_name` rules | +| `strip_prefix` | Remove this prefix from Okta group name | +| `sync_members` | Sync members between Okta and GitHub (default: `true`) | +| `create_team_if_missing` | Create missing GitHub teams (default: `false`) | +| `team_privacy` | Created teams' visibility: `closed` (default), `secret` | + +A rule sets exactly one of `okta_group_pattern` and `okta_group_name`. +`github_team_name` can't be combined with a pattern, since every matched group +would sync into one team. Names derived from a pattern are lowercased, with +other characters replaced by hyphens. A team is found by name even when its +slug differs, such as `Platform Team` and `platform-team`. + +When `create_team_if_missing` is `false`, a missing team is reported as an +error and its group is skipped. Unknown fields are rejected, so a typo is an +error at startup instead of being ignored. See the [main README](../README.md#okta-sync-rules) for additional examples. ## Verification -Test your Okta configuration: +The service authenticates with Okta on the first sync. Trigger one and check +the result: ```bash -# Test OAuth token retrieval (manual verification) -# The app will automatically authenticate on startup - -# Check app logs for: -# - "okta client initialized" -# - No authentication errors during sync +curl -X POST -H "Authorization: Bearer $APP_ADMIN_TOKEN" \ + https://github-ops.example.com/scheduled/okta-sync ``` -Trigger a sync and verify: - -1. POST to `/scheduled/okta-sync` endpoint -2. Check logs for groups discovered and teams synced -3. Verify GitHub team memberships match Okta groups +1. The logs show an `okta group synced` line per group, with the members added + and removed, and any errors. +2. The Slack report, if configured, lists the same. +3. GitHub team memberships match the Okta groups. ## Troubleshooting @@ -255,14 +274,20 @@ Trigger a sync and verify: - Verify `okta.users.read` scope is granted - Check `APP_OKTA_GITHUB_USER_FIELD` points to a valid profile field - Ensure users have the GitHub username field populated -- Only `ACTIVE` users are synced - suspended users are skipped +- Only users who can sign in are synced: `ACTIVE`, `PASSWORD_EXPIRED`, + `LOCKED_OUT`, and `RECOVERY`. `STAGED`, `PROVISIONED`, `SUSPENDED`, and + `DEPROVISIONED` users are not, and are removed from their teams. +- No one is removed from a team when more than + `APP_OKTA_SYNC_SAFETY_THRESHOLD` of its members would be; the report says + so. ### Rate limiting Okta has API rate limits. If you hit limits: - Reduce sync frequency -- The app handles rate limit responses gracefully +- Rate-limited requests are retried up to 3 times, after the reset time Okta + returns, waiting at most 30 seconds ### Permission denied errors diff --git a/docs/slack-setup.md b/docs/slack-setup.md index f7dc94b..1190b11 100644 --- a/docs/slack-setup.md +++ b/docs/slack-setup.md @@ -44,8 +44,9 @@ If you used the manifest, scopes are pre-configured. Otherwise: | ------------------- | ------------------------------------------ | | `chat:write` | Post messages to channels bot is member of | | `chat:write.public` | Post to public channels without joining | - | `channels:read` | View basic channel info | - | `channels:join` | Join public channels | + + The app only posts messages, so it needs no other scopes. Leave out + `chat:write.public` to limit it to channels it is invited to. ## Step 3: Install to Workspace @@ -164,17 +165,27 @@ Expected response: } ``` +Then send a sample of every notification from the running service: + +```bash +curl -X POST -H "Authorization: Bearer $APP_ADMIN_TOKEN" \ + https://github-ops.example.com/scheduled/slack-test +``` + ## Notification Types The bot sends these notification types: -| Event | Description | -| ---------------------- | ------------------------------------- | -| PR Compliance Alert | PR merged bypassing branch protection | -| Okta Sync Report | Summary of team membership changes | -| Orphaned Users Alert | Org members not in any synced teams | -| Security Alerts Report | Stale security alerts across the org | -| Sync Error | Errors during Okta sync process | +| Event | Description | +| ---------------------- | --------------------------------------------------- | +| PR Compliance Alert | PR merged without meeting its branch's requirements | +| Okta Sync Report | Team membership changes, errors, and skipped users | +| Orphaned Users Alert | Org members not in any synced teams | +| Security Alerts Report | Stale security alerts, and sources that failed | + +Text from GitHub and Okta, such as PR titles and group names, is escaped, so +it can't mention channels or add links. Long reports are cut to fit Slack's +message limits; the logs have the full report. ## Troubleshooting @@ -210,11 +221,9 @@ The bot sends these notification types: ### Rate limiting -Slack has rate limits (typically 1 message per second per channel). The app -handles rate limits gracefully, but if you see delays: - -- Notifications are queued and retried -- Consider consolidating notifications for high-volume events +Slack allows about one message per second per channel. The app posts one +message per report, so it rarely reaches the limit; a rate-limited message is +logged as a failure and not retried. ## Security Considerations