diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0cd22c7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,26 @@ +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +[{Dockerfile,Dockerfile.*}] +indent_size = 4 +tab_width = 4 + +[{Makefile,makefile,GNUmakefile}] +indent_style = tab +indent_size = 4 + +[Makefile.*] +indent_style = tab +indent_size = 4 + +[**/*.{go,mod,sum}] +indent_style = tab +indent_size = unset + +[**/*.py] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c0fe879 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# debug +APP_DEBUG_ENABLED=false + +# githup app (required) +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 + +# githu pr compliance (optional) +APP_PR_COMPLIANCE_ENABLED=true +APP_PR_MONITORED_BRANCHES=main,master + +# okta (optional) +APP_OKTA_DOMAIN=company.okta.com +APP_OKTA_CLIENT_ID=0oaxxxxxxxxxxxxxxxxxxxxx +APP_OKTA_PRIVATE_KEY_PATH=./.local/okta-private-key.pem +# 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) + +# slack configuration (optional) +APP_SLACK_TOKEN=xoxb-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx +APP_SLACK_CHANNEL=C01234ABCDE diff --git a/.github/.dependabot.yaml b/.github/.dependabot.yaml new file mode 100644 index 0000000..1230149 --- /dev/null +++ b/.github/.dependabot.yaml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..9ba4e80 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,67 @@ +name: ci + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + 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 + + test: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Run tests + run: make test + + - name: Run tests + run: make test-verify-verbose + + build: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + + - name: Build Lambda + run: GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -C cmd/lambda -o ../../dist/bootstrap diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..1f3031f --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,29 @@ +name: release + +on: + push: + branches: + - main + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v3 + - name: Bump Version + id: tag_version + uses: mathieudutour/github-tag-action@v6.1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + 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 + - name: Create Release + uses: ncipollo/release-action@v1.12.0 + with: + tag: ${{ steps.tag_version.outputs.new_tag }} + name: ${{ steps.tag_version.outputs.new_tag }} + body: ${{ steps.tag_version.outputs.changelog }} diff --git a/.github/workflows/semantic-check.yaml b/.github/workflows/semantic-check.yaml new file mode 100644 index 0000000..2e5d44f --- /dev/null +++ b/.github/workflows/semantic-check.yaml @@ -0,0 +1,26 @@ +name: semantic-check +on: + pull_request_target: + types: + - opened + - edited + - synchronize + +permissions: + contents: read + pull-requests: read + +jobs: + main: + 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 + name: Check PR for Semantic Commit Message + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + requireScope: false + validateSingleCommit: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ebcac20 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +!**/.gitkeep + +tmp/ +dist/ +.DS_Store + +.local/ +.env + +cognito-hooks-go +main + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9316f5d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,123 @@ +# 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) +- **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/sample/main.go` - **DO NOT RUN** (requires live credentials) +- **Packages**: + - `internal/app/` - Core logic, configuration, and HTTP handlers (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 + +## Build & Test +- **Build server**: `make build-server` (creates `dist/server`) +- **Build Lambda**: `make build-lambda` (creates `dist/bootstrap`) +- **Run server locally**: `make server` +- **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 .` + +IMPORTANT: DO NOT run `go run cmd/sample/main.go` as it requires live +credentials and makes 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) + +## Error Handling + +### Error Message Format +- **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) + +### 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 + +### 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` + +### 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 +- **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/` + +## Markdown Style +- **Line length**: Max 80 characters for prose text +- **Exceptions**: Code blocks, links, ASCII art, tables +- **Table alignment**: Align columns in plaintext for readability +- **Wrapping**: Break at natural boundaries (commas, periods, conjunctions) +- **Lists**: Indent continuation lines with 2 spaces + +NOTE: AGENTS.md itself is exempt from the 80-character line limit. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8622f50 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 CruxStack + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..121fa72 --- /dev/null +++ b/Makefile @@ -0,0 +1,45 @@ +# 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 + +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 + +.PHONY: build-server +build-server: + CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o dist/server ./cmd/server + +.PHONY: build-debug +build-debug: + GOOS=$(GOOS) GOARCH=$(GOARCH) go build -trimpath -ldflags "-s -w" -o dist/sample ./cmd/sample + +.PHONY: debug +debug: + go run ./cmd/sample + +.PHONY: server +server: + go run ./cmd/server + +.PHONY: test +test: + $(TEST_ENV) go test $(TEST_FLAGS) ./... + +.PHONY: test-unit +test-unit: + $(TEST_ENV) go test $(TEST_FLAGS) ./internal/... + +.PHONY: test-verify +test-verify: + go run ./cmd/verify + +.PHONY: test-verify-verbose +test-verify-verbose: + go run ./cmd/verify -verbose + diff --git a/README.md b/README.md index 5c69315..67b0e1a 100644 --- a/README.md +++ b/README.md @@ -1 +1,267 @@ # github-ops-app + +Bot that automates GitHub operations with Okta integration and Slack +notifications. Deploy as AWS Lambda, standard HTTP server, or container. + +## 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 +* **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 + +## Quick Start + +### Prerequisites + +* GitHub App ([setup guide](#github-app-setup)) +* Go ≥ 1.24 +* **Optional**: Okta API Service app for group sync +* **Optional**: Slack app for notifications + +### Deployment Options + +The bot can be deployed in multiple ways: + +#### Option 1: Standard HTTP Server + +Run as a long-lived HTTP server on any VPS, VM, or container platform: + +```bash +# build +make build-server + +# run (or use systemd, Docker, Kubernetes, etc.) +./dist/server + +# server listens on PORT (default: 8080) +# endpoints: +# POST /webhooks - GitHub webhook receiver +# POST /scheduled/okta-sync - Trigger Okta sync (call via cron) +# GET /server/status - Health check +# GET /server/config - Config (secrets redacted) +``` + +**Scheduling Okta Sync**: Use any cron service or scheduler to POST to +`/scheduled/okta-sync` periodically. No EventBridge required. + +#### Option 2: AWS Lambda + +Deploy as serverless function with automatic scaling: + +```bash +# build for Lambda +make build-lambda # creates dist/bootstrap +``` + +See [cmd/lambda/README.md](cmd/lambda/README.md) for complete Lambda deployment +instructions including API Gateway and EventBridge configuration. + +## Configuration + +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: + +```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 +``` + +**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: Slack + +| Variable | Description | +|--------------------------|---------------------------------| +| `APP_SLACK_TOKEN` | Bot token (`xoxb-...`) | +| `APP_SLACK_CHANNEL` | Default channel ID | + +### Other + +| Variable | Description | +|--------------------------|------------------------------------| +| `APP_DEBUG_ENABLED` | Verbose logging (default: `false`) | + +### Okta Sync Rules + +Map Okta groups to GitHub teams using JSON rules: + +```json +[ + { + "name": "sync-engineering-teams", + "enabled": true, + "okta_group_pattern": "^github-eng-.*", + "github_team_prefix": "eng-", + "strip_prefix": "github-eng-", + "sync_members": true, + "create_team_if_missing": true + }, + { + "name": "sync-platform-team", + "enabled": true, + "okta_group_name": "platform-team", + "github_team_name": "platform", + "sync_members": true, + "team_privacy": "closed" + } +] +``` + +**Rule Fields**: +- `name` - Rule identifier +- `enabled` - Enable/disable rule +- `okta_group_pattern` - Regex to match Okta groups +- `okta_group_name` - Exact Okta group name (alternative to pattern) +- `github_team_prefix` - Prefix for 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 +- `create_team_if_missing` - Auto-create GitHub teams +- `team_privacy` - `secret` or `closed` + +**Sync Safety Features**: +- **Active users only**: Only syncs users with `ACTIVE` status in Okta, + automatically excluding suspended or deprovisioned accounts +- **External collaborator protection**: Never removes outside collaborators + (non-org members), preserving contractors and partner access +- **Outage protection**: Safety threshold (default 50%) prevents mass removal + if Okta/GitHub is experiencing issues. Sync aborts if removal ratio exceeds + threshold +- **Orphaned user detection**: Identifies organization members not in any + Okta-synced teams and sends Slack notifications. Enabled by default when + sync is enabled. + +## Okta Setup + +Create an API Services application in Okta Admin Console: + +1. **Applications** → **Create App Integration** → **API Services** +2. Name: `github-bot-api-service` +3. **Client Credentials**: + - Authentication: **Public key / Private key** + - Generate and download private key (PEM format) + - Note the Client ID +4. **Okta API Scopes**: Grant `okta.groups.read` and `okta.users.read` + +Use the Client ID and private key in your environment variables. + +## GitHub App Setup + +Create a GitHub App in your organization settings: + +1. **Developer settings** → **GitHub Apps** → **New GitHub App** +2. **Basic info**: + - Name: `github-ops-app` + - Webhook URL: Your API Gateway URL + - Webhook secret: Generate and save for `APP_GITHUB_WEBHOOK_SECRET` +3. **Permissions**: + - Repository: Pull requests (Read), Contents (Read) + - Organization: Members (Read & write), Administration (Read) +4. **Events**: Subscribe to Pull request, Team, Membership +5. Generate and download private key (`.pem` file) +6. Install app to your organization +7. Note: **App ID**, **Installation ID** (from install URL), **Private key** + +## 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"] +``` + +## How It Works + +**Okta Sync**: EventBridge triggers sync → Fetch Okta groups → Apply rules → +Update GitHub teams → Detect orphaned users → Send Slack reports. Automatically +reconciles when external team changes are detected. Only syncs ACTIVE Okta +users, skips external collaborators, and prevents mass removal during outages +via safety threshold. Orphaned user detection identifies org members not in any +synced teams. + +**PR Compliance**: Webhook on PR merge → Verify signature → Check branch +protection rules → Detect bypasses → Notify Slack if violations found. + +## Troubleshooting + +**Common issues**: +- 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 + +## License + +MIT + diff --git a/assets/github/manafiest.json b/assets/github/manafiest.json new file mode 100644 index 0000000..d15687e --- /dev/null +++ b/assets/github/manafiest.json @@ -0,0 +1,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", + "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, + "default_permissions": { + "organization_administration": "read", + "members": "write", + "contents": "read", + "pull_requests": "read" + }, + "default_events": [ + "pull_request", + "team", + "membership" + ] +} diff --git a/assets/slack/manifest.json b/assets/slack/manifest.json new file mode 100644 index 0000000..3c7e266 --- /dev/null +++ b/assets/slack/manifest.json @@ -0,0 +1,28 @@ +{ + "display_information": { + "name": "GitHub Bot", + "description": "GitHub automation bot with Okta sync and PR compliance monitoring", + "background_color": "#24292e" + }, + "features": { + "bot_user": { + "display_name": "GitHub Bot", + "always_online": false + } + }, + "oauth_config": { + "scopes": { + "bot": [ + "channels:join", + "channels:read", + "chat:write.public", + "chat:write" + ] + } + }, + "settings": { + "org_deploy_enabled": false, + "socket_mode_enabled": false, + "token_rotation_enabled": false + } +} diff --git a/cmd/lambda/README.md b/cmd/lambda/README.md new file mode 100644 index 0000000..ff25a20 --- /dev/null +++ b/cmd/lambda/README.md @@ -0,0 +1,219 @@ +# 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. + +## Overview + +The Lambda adapter translates AWS-specific events into standard application +calls: +- **API Gateway** (webhooks) → HTTP handlers +- **EventBridge** (scheduled sync) → `ProcessScheduledEvent()` + +## Build + +```bash +# from repository root +make build-lambda + +# creates dist/bootstrap (Lambda requires this exact name) +``` + +## 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](#configuration) +section in main README). + +### 4. Setup Triggers + +#### API Gateway (for GitHub Webhooks) + +Create an HTTP API Gateway: + +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` + +**Headers**: API Gateway automatically forwards all headers including +`X-GitHub-Event` and `X-Hub-Signature-256`. + +#### EventBridge (for Scheduled Okta Sync) + +Create an EventBridge rule: + +1. **Rule Type**: Schedule +2. **Schedule**: + - Rate: `rate(1 hour)` or `rate(6 hours)` + - Cron: `cron(0 */6 * * ? *)` (every 6 hours) +3. **Target**: Lambda function +4. **Input**: Configure constant (JSON): +```json +{ + "source": "aws.events", + "detail-type": "Scheduled Event", + "detail": { + "action": "okta-sync" + } +} +``` + +## Architecture + +### Universal Handler + +The Lambda function uses a universal handler that detects event types: + +```go +func UniversalHandler(ctx context.Context, event json.RawMessage) (any, error) +``` + +**Supported Events**: +- `APIGatewayProxyRequest` → Routes to webhook/status/config handlers +- `CloudWatchEvent` (EventBridge) → Routes to scheduled event processor + +### Endpoints + +When invoked via API Gateway: + +| Method | Path | Description | +|--------|-----------------------|-----------------------------------| +| POST | `/webhooks` | GitHub webhook receiver | +| 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: +- **Under-provisioned**: Slow response times, timeouts +- **Over-provisioned**: Wasted cost +- **Right-sized**: <100ms execution time for webhooks, <5s for sync + +### Timeout + +- **Webhooks**: 15 seconds (GitHub timeout is 10s) +- **Scheduled sync**: 30-60 seconds (depends on org size) + +### Invocation Frequency + +**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** + +## 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. diff --git a/cmd/lambda/main.go b/cmd/lambda/main.go new file mode 100644 index 0000000..8a1375c --- /dev/null +++ b/cmd/lambda/main.go @@ -0,0 +1,211 @@ +// Package main provides the AWS Lambda entry point for the GitHub bot. +// This Lambda handler supports both API Gateway (webhooks) and EventBridge +// (scheduled sync) events. +package main + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "sync" + + awsevents "github.com/aws/aws-lambda-go/events" + "github.com/aws/aws-lambda-go/lambda" + "github.com/cruxstack/github-ops-app/internal/app" + "github.com/cruxstack/github-ops-app/internal/config" + "github.com/cruxstack/github-ops-app/internal/github" +) + +var ( + initOnce sync.Once + appInst *app.App + logger *slog.Logger + initErr error +) + +// initApp initializes the application instance once using sync.Once. +// stores any initialization error in the initErr global variable. +func initApp() { + initOnce.Do(func() { + logger = config.NewLogger() + + cfg, err := config.NewConfig() + if err != nil { + initErr = fmt.Errorf("config init failed: %w", err) + return + } + appInst, initErr = app.New(context.Background(), cfg) + }) +} + +// APIGatewayHandler processes incoming API Gateway requests. +// handles GitHub webhook events, status checks, and config endpoints. +// validates webhook signatures before processing events. +func APIGatewayHandler(ctx context.Context, req awsevents.APIGatewayProxyRequest) (awsevents.APIGatewayProxyResponse, error) { + initApp() + if initErr != nil { + logger.Error("initialization failed", slog.String("error", initErr.Error())) + return awsevents.APIGatewayProxyResponse{ + 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))) + } + + if req.Path == "/server/status" { + return handleServerStatus(ctx, req) + } + + if req.Path == "/server/config" { + return handleServerConfig(ctx, req) + } + + if req.HTTPMethod != "POST" { + return awsevents.APIGatewayProxyResponse{ + StatusCode: 405, + Body: "method not allowed", + }, nil + } + + eventType := req.Headers["X-GitHub-Event"] + if eventType == "" { + eventType = req.Headers["x-github-event"] + } + + signature := req.Headers["X-Hub-Signature-256"] + if signature == "" { + signature = req.Headers["x-hub-signature-256"] + } + + if err := github.ValidateWebhookSignature( + []byte(req.Body), + signature, + appInst.Config.GitHubWebhookSecret, + ); err != nil { + logger.Warn("webhook signature validation failed", slog.String("error", err.Error())) + return awsevents.APIGatewayProxyResponse{ + StatusCode: 401, + Body: "unauthorized", + }, nil + } + + if err := appInst.ProcessWebhook(ctx, []byte(req.Body), eventType); err != nil { + logger.Error("webhook processing failed", + slog.String("event_type", eventType), + slog.String("error", err.Error())) + return awsevents.APIGatewayProxyResponse{ + StatusCode: 500, + Body: "webhook processing failed", + }, nil + } + + return awsevents.APIGatewayProxyResponse{ + StatusCode: 200, + Body: "ok", + }, nil +} + +// handleServerStatus returns the application status and feature flags. +// responds with JSON containing configuration state and enabled features. +func handleServerStatus(ctx context.Context, req awsevents.APIGatewayProxyRequest) (awsevents.APIGatewayProxyResponse, error) { + if req.HTTPMethod != "GET" { + return awsevents.APIGatewayProxyResponse{ + StatusCode: 405, + Body: "method not allowed", + }, nil + } + + status := appInst.GetStatus() + body, err := json.Marshal(status) + if err != nil { + logger.Error("failed to marshal status response", slog.String("error", err.Error())) + return awsevents.APIGatewayProxyResponse{ + StatusCode: 500, + Body: "failed to generate status response", + }, nil + } + + return awsevents.APIGatewayProxyResponse{ + StatusCode: 200, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: string(body), + }, nil +} + +// handleServerConfig returns the application configuration with secrets +// redacted. useful for debugging and verifying environment settings. +func handleServerConfig(ctx context.Context, req awsevents.APIGatewayProxyRequest) (awsevents.APIGatewayProxyResponse, error) { + if req.HTTPMethod != "GET" { + return awsevents.APIGatewayProxyResponse{ + StatusCode: 405, + Body: "method not allowed", + }, nil + } + + redacted := appInst.Config.Redacted() + body, err := json.Marshal(redacted) + if err != nil { + logger.Error("failed to marshal config response", slog.String("error", err.Error())) + return awsevents.APIGatewayProxyResponse{ + StatusCode: 500, + Body: "failed to generate config response", + }, nil + } + + return awsevents.APIGatewayProxyResponse{ + StatusCode: 200, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: string(body), + }, nil +} + +// EventBridgeHandler processes EventBridge scheduled events. +// typically handles scheduled Okta group sync operations. +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 + } + + return appInst.ProcessScheduledEvent(ctx, detail) +} + +// UniversalHandler detects the event type and routes to the appropriate +// handler. supports both API Gateway and EventBridge events. +func UniversalHandler(ctx context.Context, event json.RawMessage) (any, error) { + if initErr != nil { + return nil, initErr + } + + var apiGatewayReq awsevents.APIGatewayProxyRequest + if err := json.Unmarshal(event, &apiGatewayReq); err == nil && apiGatewayReq.RequestContext.RequestID != "" { + 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, fmt.Errorf("unknown lambda event type") +} + +func main() { + lambda.Start(UniversalHandler) +} diff --git a/cmd/sample/main.go b/cmd/sample/main.go new file mode 100644 index 0000000..e7fa906 --- /dev/null +++ b/cmd/sample/main.go @@ -0,0 +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. +package main + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "path/filepath" + + "github.com/joho/godotenv" + + "github.com/cruxstack/github-ops-app/internal/app" + "github.com/cruxstack/github-ops-app/internal/config" +) + +func main() { + logger := config.NewLogger() + ctx := context.Background() + + envpath := filepath.Join(".env") + logger.Debug("loading environment", slog.String("path", envpath)) + if _, err := os.Stat(envpath); err == nil { + _ = godotenv.Load(envpath) + } + + cfg, err := config.NewConfig() + if err != nil { + logger.Error("failed to load config", slog.String("error", err.Error())) + os.Exit(1) + } + + a, err := app.New(ctx, cfg) + if err != nil { + logger.Error("failed to initialize app", slog.String("error", err.Error())) + os.Exit(1) + } + + path := filepath.Join("fixtures", "samples.json") + raw, err := os.ReadFile(path) + if err != nil { + logger.Error("failed to read samples file", slog.String("error", err.Error())) + os.Exit(1) + } + + var samples []map[string]any + if err := json.Unmarshal(raw, &samples); err != nil { + logger.Error("failed to parse samples json", slog.String("error", err.Error())) + os.Exit(1) + } + + for i, sample := range samples { + eventType := sample["event_type"].(string) + + switch eventType { + case "okta_sync": + evt := app.ScheduledEvent{ + Action: "okta-sync", + } + if err := a.ProcessScheduledEvent(ctx, evt); err != nil { + logger.Error("failed to process okta_sync sample", + slog.Int("sample", i), + slog.String("error", err.Error())) + os.Exit(1) + } + + case "pr_webhook": + payload, _ := json.Marshal(sample["payload"]) + if err := a.ProcessWebhook(ctx, payload, "pull_request"); err != nil { + logger.Error("failed to process pr_webhook sample", + slog.Int("sample", i), + slog.String("error", err.Error())) + os.Exit(1) + } + + default: + logger.Info("skipping unknown event type", slog.String("event_type", eventType)) + } + + logger.Info("processed sample successfully", slog.Int("sample", i)) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..4e15554 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,119 @@ +// Package main provides a standard HTTP server entry point for the GitHub bot. +// runs as a long-lived HTTP server suitable for deployment on any VPS, container, +// or Kubernetes cluster. +package main + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/cruxstack/github-ops-app/internal/app" + "github.com/cruxstack/github-ops-app/internal/config" +) + +var ( + appInst *app.App + logger *slog.Logger +) + +func main() { + logger = config.NewLogger() + ctx := context.Background() + + cfg, err := config.NewConfig() + if err != nil { + logger.Error("config init failed", slog.String("error", err.Error())) + os.Exit(1) + } + + appInst, err = app.New(ctx, cfg) + if err != nil { + logger.Error("app init failed", slog.String("error", err.Error())) + os.Exit(1) + } + + mux := http.NewServeMux() + mux.HandleFunc("/webhooks", appInst.WebhookHandler) + mux.HandleFunc("/server/status", appInst.StatusHandler) + mux.HandleFunc("/server/config", appInst.ConfigHandler) + mux.HandleFunc("/scheduled/okta-sync", scheduledOktaSyncHandler) + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + srv := &http.Server{ + Addr: ":" + port, + Handler: mux, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + 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) + }() + + 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) + } + + <-done + logger.Info("server stopped") +} + +// scheduledOktaSyncHandler handles HTTP-triggered Okta sync operations. +// can be invoked by external cron services or schedulers. +func scheduledOktaSyncHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + evt := app.ScheduledEvent{ + Action: "okta-sync", + } + + if err := appInst.ProcessScheduledEvent(r.Context(), evt); err != nil { + logger.Error("scheduled event processing failed", + slog.String("action", evt.Action), + slog.String("error", err.Error())) + http.Error(w, "event processing failed", http.StatusInternalServerError) + return + } + + response := map[string]string{ + "status": "success", + "message": "okta sync completed", + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(response); err != nil { + logger.Error("failed to encode response", slog.String("error", err.Error())) + } +} diff --git a/cmd/verify/.env.test b/cmd/verify/.env.test new file mode 100644 index 0000000..d070bb9 --- /dev/null +++ b/cmd/verify/.env.test @@ -0,0 +1,31 @@ +# config used during offline verification testing +# +# - all api calls are mocked all config is fake +# - private keys is dynamically generated during test setup +# - endpoints are https with tls configured during setup + +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 +APP_GITHUB_BASE_URL=https://localhost:9001/ + +# 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 +APP_OKTA_CLIENT_ID=test-client-id + +# 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 +APP_SLACK_API_URL=https://localhost:9003/ diff --git a/cmd/verify/README.md b/cmd/verify/README.md new file mode 100644 index 0000000..ed81a37 --- /dev/null +++ b/cmd/verify/README.md @@ -0,0 +1,121 @@ +# 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 + +``` +Event → App Logic → SDK Client → Mock Server (localhost:9001-9003) + ↓ + Record Request + ↓ + Return Mock Response + ↓ + Validate Expected Calls Made +``` + +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 new file mode 100644 index 0000000..1780d7d --- /dev/null +++ b/cmd/verify/logger.go @@ -0,0 +1,56 @@ +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 true +} + +// 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 new file mode 100644 index 0000000..d13b6c9 --- /dev/null +++ b/cmd/verify/main.go @@ -0,0 +1,88 @@ +// 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 new file mode 100644 index 0000000..febaf25 --- /dev/null +++ b/cmd/verify/match.go @@ -0,0 +1,49 @@ +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 new file mode 100644 index 0000000..43ee535 --- /dev/null +++ b/cmd/verify/mock.go @@ -0,0 +1,142 @@ +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 new file mode 100644 index 0000000..b5c0ccf --- /dev/null +++ b/cmd/verify/scenario.go @@ -0,0 +1,281 @@ +package main + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "time" + + "github.com/cruxstack/github-ops-app/internal/app" + "github.com/cruxstack/github-ops-app/internal/config" +) + +// 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 fmt.Errorf("generate cert: %w", err) + } + + githubAppKey, err := generateOAuthPrivateKey() + if err != nil { + return fmt.Errorf("generate github app key: %w", err) + } + os.Setenv("APP_GITHUB_APP_PRIVATE_KEY", string(githubAppKey)) + + oauthKey, err := generateOAuthPrivateKey() + if err != nil { + return fmt.Errorf("generate oauth key: %w", err) + } + os.Setenv("APP_OKTA_CLIENT_ID", "test-client-id") + os.Setenv("APP_OKTA_PRIVATE_KEY", string(oauthKey)) + + githubServer := &http.Server{ + Addr: "localhost:9001", + Handler: githubMock, + TLSConfig: &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + }, + } + oktaServer := &http.Server{ + Addr: "localhost:9002", + Handler: oktaMock, + TLSConfig: &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + }, + } + slackServer := &http.Server{ + Addr: "localhost:9003", + Handler: slackMock, + TLSConfig: &tls.Config{ + Certificates: []tls.Certificate{tlsCert}, + }, + } + + githubReady := make(chan bool) + oktaReady := make(chan bool) + slackReady := make(chan bool) + + go func() { + githubReady <- true + if err := githubServer.ListenAndServeTLS("", ""); err != http.ErrServerClosed { + logger.Error("github mock server error", slog.String("error", err.Error())) + } + }() + + go func() { + oktaReady <- true + if err := oktaServer.ListenAndServeTLS("", ""); err != http.ErrServerClosed { + logger.Error("okta mock server error", slog.String("error", err.Error())) + } + }() + + go func() { + slackReady <- true + if err := slackServer.ListenAndServeTLS("", ""); err != http.ErrServerClosed { + logger.Error("slack mock server error", slog.String("error", err.Error())) + } + }() + + <-githubReady + <-oktaReady + <-slackReady + time.Sleep(100 * time.Millisecond) + + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + githubServer.Shutdown(shutdownCtx) + oktaServer.Shutdown(shutdownCtx) + slackServer.Shutdown(shutdownCtx) + }() + + http.DefaultTransport = &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: certPool, + }, + } + + os.Setenv("APP_GITHUB_BASE_URL", "https://localhost:9001/") + os.Setenv("APP_SLACK_API_URL", "https://localhost:9003/") + os.Setenv("APP_OKTA_BASE_URL", "https://localhost:9002") + + ctx = context.WithValue(ctx, "okta_tls_cert_pool", 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 fmt.Errorf("config creation failed: %w", err) + } + + a, err := app.New(ctx, cfg) + if err != nil { + return fmt.Errorf("app creation failed: %w", err) + } + + if verbose { + fmt.Printf("\n Application Output:\n") + } + + appLogger := slog.New(&testHandler{prefix: " ", verbose: verbose, w: os.Stdout}) + a.Logger = appLogger + + var processErr error + switch scenario.EventType { + case "scheduled_event": + var evt app.ScheduledEvent + if err := json.Unmarshal(scenario.EventPayload, &evt); err != nil { + return fmt.Errorf("unmarshal event payload failed: %w", err) + } + processErr = a.ProcessScheduledEvent(ctx, evt) + + case "webhook": + processErr = a.ProcessWebhook(ctx, scenario.WebhookPayload, scenario.WebhookType) + + default: + return fmt.Errorf("unknown event type: %s", scenario.EventType) + } + + if scenario.ExpectError { + if processErr == nil { + return fmt.Errorf("expected error but processing succeeded") + } + if verbose { + fmt.Printf(" ✓ Expected error occurred: %v\n", processErr) + } + } else { + if processErr != nil { + return fmt.Errorf("process event failed: %w", processErr) + } + } + + 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 := 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 fmt.Errorf("expected call not found: %s %s %s", exp.Service, exp.Method, exp.Path) + } + } + return nil +} diff --git a/cmd/verify/tls.go b/cmd/verify/tls.go new file mode 100644 index 0000000..0cab7d2 --- /dev/null +++ b/cmd/verify/tls.go @@ -0,0 +1,83 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "time" +) + +// 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, fmt.Errorf("generate oauth key: %w", err) + } + + 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, fmt.Errorf("generate key: %w", err) + } + + 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, fmt.Errorf("generate serial: %w", err) + } + + 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"}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return tls.Certificate{}, nil, fmt.Errorf("create cert: %w", err) + } + + 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, fmt.Errorf("create keypair: %w", err) + } + + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return tls.Certificate{}, nil, fmt.Errorf("parse cert: %w", err) + } + + certPool := x509.NewCertPool() + certPool.AddCert(cert) + + return tlsCert, certPool, nil +} diff --git a/fixtures/samples.json b/fixtures/samples.json new file mode 100644 index 0000000..864f1a3 --- /dev/null +++ b/fixtures/samples.json @@ -0,0 +1,75 @@ +[ + { + "event_type": "okta_sync", + "description": "Scheduled Okta group sync" + }, + { + "event_type": "pr_webhook", + "description": "PR merged with bypass", + "payload": { + "action": "closed", + "number": 123, + "pull_request": { + "id": 1, + "number": 123, + "state": "closed", + "locked": false, + "title": "Emergency hotfix for production bug", + "user": { + "login": "developer", + "id": 1, + "type": "User" + }, + "body": "Critical fix needed immediately", + "created_at": "2025-11-22T10:00:00Z", + "updated_at": "2025-11-22T10:30:00Z", + "closed_at": "2025-11-22T10:30:00Z", + "merged_at": "2025-11-22T10:30:00Z", + "merge_commit_sha": "abc123def456", + "merged": true, + "merged_by": { + "login": "admin-user", + "id": 2, + "type": "User" + }, + "head": { + "label": "cruxstack:hotfix", + "ref": "hotfix", + "sha": "abc123", + "user": { + "login": "developer", + "id": 1 + } + }, + "base": { + "label": "cruxstack:main", + "ref": "main", + "sha": "def456", + "user": { + "login": "cruxstack", + "id": 100 + } + }, + "html_url": "https://github.com/cruxstack/api-service/pull/123" + }, + "repository": { + "id": 1000, + "name": "api-service", + "full_name": "cruxstack/api-service", + "owner": { + "login": "cruxstack", + "id": 100, + "type": "Organization" + }, + "private": false, + "html_url": "https://github.com/cruxstack/api-service", + "description": "API service repository" + }, + "sender": { + "login": "admin-user", + "id": 2, + "type": "User" + } + } + } +] diff --git a/fixtures/scenarios.json b/fixtures/scenarios.json new file mode 100644 index 0000000..adb876d --- /dev/null +++ b/fixtures/scenarios.json @@ -0,0 +1,1052 @@ +[ + { + "name": "okta_sync_single_team", + "description": "Test Okta sync with one team and one user", + "event_type": "scheduled_event", + "event_payload": { + "action": "okta-sync" + }, + "expected_calls": [ + { + "service": "okta", + "method": "GET", + "path": "/api/v1/groups" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/*/teams/*" + }, + { + "service": "slack", + "method": "POST", + "path": "/chat.postMessage" + } + ], + "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\",\"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\",\"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" + } + ] + }, + { + "name": "pr_webhook_with_bypass", + "description": "Detect when PR is merged bypassing branch protection and send Slack notification", + "event_type": "webhook", + "webhook_type": "pull_request", + "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" + } + }, + "expected_calls": [ + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/fake-app-repo/pulls/456" + }, + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/fake-app-repo/branches/main/protection" + }, + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/fake-app-repo/pulls/456/reviews" + }, + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/fake-app-repo/collaborators/admin-user/permission" + }, + { + "service": "slack", + "method": "POST", + "path": "/chat.postMessage" + } + ], + "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": "/repos/acme-ghorg/fake-app-repo/pulls/456", + "status_code": 200, + "body": "{\"number\":456,\"state\":\"closed\",\"merged\":true,\"merged_at\":\"2025-11-22T10:30:00Z\",\"merged_by\":{\"login\":\"admin-user\"},\"base\":{\"ref\":\"main\"}}", + "description": "fetch pr #456 details (merged by admin-user)" + }, + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/fake-app-repo/branches/main/protection", + "status_code": 200, + "body": "{\"required_pull_request_reviews\":{\"required_approving_review_count\":2,\"dismiss_stale_reviews\":true},\"enforce_admins\":{\"enabled\":true}}", + "description": "fetch branch protection rules (requires 2 approvals, enforce for admins)" + }, + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/fake-app-repo/pulls/456/reviews", + "status_code": 200, + "body": "[{\"id\":1,\"user\":{\"login\":\"reviewer1\"},\"state\":\"APPROVED\"}]", + "description": "fetch pr reviews (only 1 approval, insufficient)" + }, + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/fake-app-repo/collaborators/admin-user/permission", + "status_code": 200, + "body": "{\"permission\":\"admin\",\"user\":{\"login\":\"admin-user\"}}", + "description": "check merger permissions (admin-user has admin permission)" + }, + { + "service": "slack", + "method": "POST", + "path": "/chat.postMessage", + "status_code": 200, + "body": "{\"ok\":true,\"channel\":\"C01234TEST\",\"ts\":\"1234567890.123456\"}", + "description": "send bypass alert notification to slack" + } + ] + }, + { + "name": "pr_webhook_compliant", + "description": "PR merged with proper approvals - no bypass", + "event_type": "webhook", + "webhook_type": "pull_request", + "webhook_payload": { + "action": "closed", + "number": 789, + "pull_request": { + "id": 789, + "number": 789, + "state": "closed", + "merged": true, + "merged_at": "2025-11-22T11:00:00Z", + "merged_by": { + "login": "developer", + "id": 3, + "type": "User" + }, + "head": { + "ref": "bugfix", + "sha": "xyz789" + }, + "base": { + "ref": "main", + "sha": "abc456" + }, + "html_url": "https://github.com/acme-ghorg/fake-app-repo/pull/789" + }, + "repository": { + "name": "fake-app-repo", + "full_name": "acme-ghorg/fake-app-repo", + "owner": { + "login": "acme-ghorg" + } + } + }, + "expected_calls": [ + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/fake-app-repo/pulls/789" + }, + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/fake-app-repo/branches/main/protection" + }, + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/fake-app-repo/pulls/789/reviews" + } + ], + "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": "/repos/acme-ghorg/fake-app-repo/pulls/789", + "status_code": 200, + "body": "{\"number\":789,\"state\":\"closed\",\"merged\":true,\"base\":{\"ref\":\"main\"}}", + "description": "fetch pr #789 details (merged)" + }, + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/fake-app-repo/branches/main/protection", + "status_code": 200, + "body": "{\"required_pull_request_reviews\":{\"required_approving_review_count\":2}}", + "description": "fetch branch protection rules (requires 2 approvals)" + }, + { + "service": "github", + "method": "GET", + "path": "/repos/acme-ghorg/fake-app-repo/pulls/789/reviews", + "status_code": 200, + "body": "[{\"state\":\"APPROVED\",\"user\":{\"login\":\"reviewer1\"}},{\"state\":\"APPROVED\",\"user\":{\"login\":\"reviewer2\"}}]", + "description": "fetch pr reviews (2 approvals, meets requirements)" + } + ] + }, + { + "name": "okta_sync_inactive_users", + "description": "Test that inactive Okta users are excluded from sync", + "event_type": "scheduled_event", + "event_payload": { + "action": "okta-sync" + }, + "expected_calls": [ + { + "service": "okta", + "method": "GET", + "path": "/api/v1/groups" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/*/teams/*" + }, + { + "service": "slack", + "method": "POST", + "path": "/chat.postMessage" + } + ], + "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": "[{\"login\":\"inactive-user\"},{\"login\":\"suspended-user\"}]", + "description": "fetch current team members (inactive-user, suspended-user)" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/members/inactive-user/memberships/orgs/acme-ghorg", + "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)" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/members/suspended-user/memberships/orgs/acme-ghorg", + "status_code": 200, + "body": "{\"state\":\"active\",\"role\":\"member\"}", + "description": "check suspended-user org membership" + }, + { + "service": "github", + "method": "DELETE", + "path": "/orgs/acme-ghorg/teams/engineering/memberships/suspended-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)" + }, + { + "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\",\"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\",\"profile\":{\"email\":\"alice@example.com\",\"githubUsername\":\"alice-gh\"}},{\"id\":\"00u2222222222222222\",\"status\":\"SUSPENDED\",\"profile\":{\"email\":\"suspended@example.com\",\"githubUsername\":\"suspended-user\"}},{\"id\":\"00u3333333333333333\",\"status\":\"DEPROVISIONED\",\"profile\":{\"email\":\"inactive@example.com\",\"githubUsername\":\"inactive-user\"}}]", + "description": "fetch users in engineering group (active, suspended, deprovisioned statuses)" + }, + { + "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" + } + ] + }, + { + "name": "okta_sync_external_collaborators", + "description": "Test that external collaborators are not removed from teams", + "event_type": "scheduled_event", + "event_payload": { + "action": "okta-sync" + }, + "expected_calls": [ + { + "service": "okta", + "method": "GET", + "path": "/api/v1/groups" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/*/teams/*" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/memberships/external-contractor" + }, + { + "service": "slack", + "method": "POST", + "path": "/chat.postMessage" + } + ], + "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": "[{\"login\":\"alice-gh\"},{\"login\":\"external-contractor\"}]", + "description": "fetch current team members (alice-gh, external-contractor)" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/memberships/external-contractor", + "status_code": 404, + "body": "{\"message\":\"Not Found\"}", + "description": "check external-contractor org membership (not an org member)" + }, + { + "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\",\"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\",\"profile\":{\"email\":\"alice@example.com\",\"githubUsername\":\"alice-gh\"}}]", + "description": "fetch users in engineering group (only 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" + } + ] + }, + { + "name": "okta_sync_safety_threshold", + "description": "Test that safety threshold prevents mass removal during outages", + "event_type": "scheduled_event", + "event_payload": { + "action": "okta-sync" + }, + "expected_calls": [ + { + "service": "okta", + "method": "GET", + "path": "/api/v1/groups" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/*/teams/*" + }, + { + "service": "slack", + "method": "POST", + "path": "/chat.postMessage" + } + ], + "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": "[{\"login\":\"user1\"},{\"login\":\"user2\"},{\"login\":\"user3\"},{\"login\":\"user4\"},{\"login\":\"user5\"}]", + "description": "fetch current team members (5 users)" + }, + { + "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\",\"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": "[]", + "description": "fetch users in engineering group (empty - potential outage)" + }, + { + "service": "slack", + "method": "POST", + "path": "/chat.postMessage", + "status_code": 200, + "body": "{\"ok\":true,\"channel\":\"C01234TEST\",\"ts\":\"1234567890.123456\"}", + "description": "send safety threshold alert to slack (5 removals blocked)" + } + ] + }, + { + "name": "okta_sync_api_error_no_removal", + "description": "Test that users are not removed when Okta API returns error fetching group members", + "event_type": "scheduled_event", + "event_payload": { + "action": "okta-sync" + }, + "expect_error": true, + "expected_calls": [ + { + "service": "okta", + "method": "GET", + "path": "/api/v1/groups" + } + ], + "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": "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\",\"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": 503, + "body": "{\"errorCode\":\"E0000009\",\"errorSummary\":\"Service Unavailable\"}", + "description": "okta api error fetching group members (service unavailable)" + } + ] + }, + { + "name": "okta_sync_github_api_error_no_removal", + "description": "Test that users are not removed when GitHub API returns error fetching team members", + "event_type": "scheduled_event", + "event_payload": { + "action": "okta-sync" + }, + "expected_calls": [ + { + "service": "okta", + "method": "GET", + "path": "/api/v1/groups" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/*/teams/*" + }, + { + "service": "slack", + "method": "POST", + "path": "/chat.postMessage" + } + ], + "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": 500, + "body": "{\"message\":\"Internal Server Error\"}", + "description": "github api error fetching team members (server error)" + }, + { + "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\",\"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\",\"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 with errors to slack" + } + ] + }, + { + "name": "okta_sync_with_orphaned_users", + "description": "Test Okta sync that detects orphaned users (org members not in any synced teams)", + "event_type": "scheduled_event", + "event_payload": { + "action": "okta-sync" + }, + "config_overrides": { + "APP_OKTA_ORPHANED_USER_NOTIFICATIONS": "true" + }, + "expected_calls": [ + { + "service": "okta", + "method": "GET", + "path": "/api/v1/groups" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/*/teams/*" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/*/members" + }, + { + "service": "slack", + "method": "POST", + "path": "/chat.postMessage" + } + ], + "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": "[{\"login\":\"alice-gh\",\"id\":1,\"type\":\"User\"}]", + "description": "engineering team has alice-gh" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/members", + "status_code": 200, + "body": "[{\"login\":\"alice-gh\",\"id\":1,\"type\":\"User\"},{\"login\":\"bob-gh\",\"id\":2,\"type\":\"User\"},{\"login\":\"charlie-gh\",\"id\":3,\"type\":\"User\"}]", + "description": "org has three members: alice-gh, bob-gh, charlie-gh" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/memberships/bob-gh", + "status_code": 200, + "body": "{\"state\":\"active\",\"role\":\"member\"}", + "description": "bob-gh is an org member (not external)" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/memberships/charlie-gh", + "status_code": 200, + "body": "{\"state\":\"active\",\"role\":\"member\"}", + "description": "charlie-gh is an org member (not external)" + }, + { + "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\",\"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\",\"profile\":{\"email\":\"alice@example.com\",\"githubUsername\":\"alice-gh\"}}]", + "description": "fetch users in engineering group (returns only 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": "slack", + "method": "POST", + "path": "/chat.postMessage", + "status_code": 200, + "body": "{\"ok\":true,\"channel\":\"C01234TEST\",\"ts\":\"1234567890.123457\"}", + "description": "send orphaned users notification to slack (bob-gh and charlie-gh)" + } + ] + }, + { + "name": "okta_sync_no_orphaned_users", + "description": "Test Okta sync with no orphaned users detected (all org members are in synced teams)", + "event_type": "scheduled_event", + "event_payload": { + "action": "okta-sync" + }, + "config_overrides": { + "APP_OKTA_ORPHANED_USER_NOTIFICATIONS": "true" + }, + "expected_calls": [ + { + "service": "okta", + "method": "GET", + "path": "/api/v1/groups" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/*/teams/*" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/*/members" + }, + { + "service": "slack", + "method": "POST", + "path": "/chat.postMessage" + } + ], + "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": "[{\"login\":\"alice-gh\",\"id\":1,\"type\":\"User\"},{\"login\":\"bob-gh\",\"id\":2,\"type\":\"User\"}]", + "description": "engineering team has alice-gh and bob-gh" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/acme-ghorg/members", + "status_code": 200, + "body": "[{\"login\":\"alice-gh\",\"id\":1,\"type\":\"User\"},{\"login\":\"bob-gh\",\"id\":2,\"type\":\"User\"}]", + "description": "org has two members: alice-gh and bob-gh (both in synced teams)" + }, + { + "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\",\"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\",\"profile\":{\"email\":\"alice@example.com\",\"githubUsername\":\"alice-gh\"}},{\"id\":\"00u2222222222222222\",\"status\":\"ACTIVE\",\"profile\":{\"email\":\"bob@example.com\",\"githubUsername\":\"bob-gh\"}}]", + "description": "fetch users in engineering group (returns alice-gh and bob-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 (no orphaned users notification)" + } + ] + }, + { + "name": "okta_sync_orphaned_users_disabled", + "description": "Test Okta sync with orphaned user detection disabled via config", + "event_type": "scheduled_event", + "event_payload": { + "action": "okta-sync" + }, + "config_overrides": { + "APP_OKTA_ORPHANED_USER_NOTIFICATIONS": "false" + }, + "expected_calls": [ + { + "service": "okta", + "method": "GET", + "path": "/api/v1/groups" + }, + { + "service": "github", + "method": "GET", + "path": "/orgs/*/teams/*" + }, + { + "service": "slack", + "method": "POST", + "path": "/chat.postMessage" + } + ], + "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": "[{\"login\":\"alice-gh\",\"id\":1,\"type\":\"User\"}]", + "description": "engineering team has alice-gh" + }, + { + "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\",\"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\",\"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 (orphaned user check skipped)" + } + ] + } +] diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b107641 --- /dev/null +++ b/go.mod @@ -0,0 +1,51 @@ +module github.com/cruxstack/github-ops-app + +go 1.24.4 + +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/joho/godotenv v1.5.1 + github.com/okta/okta-sdk-golang/v2 v2.20.0 + github.com/slack-go/slack v0.17.3 + golang.org/x/oauth2 v0.33.0 +) + +require ( + github.com/BurntSushi/toml v1.2.1 // indirect + 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/cenkalti/backoff/v4 v4.1.3 // indirect + github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect + github.com/cockroachdb/redact v1.1.5 // indirect + github.com/getsentry/sentry-go v0.27.0 // indirect + github.com/go-jose/go-jose/v3 v3.0.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/go-querystring v1.1.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/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 + golang.org/x/crypto v0.7.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9f83337 --- /dev/null +++ b/go.sum @@ -0,0 +1,139 @@ +github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= +github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +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/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4= +github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= +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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +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/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= +github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +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/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo= +github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8= +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/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.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +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.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/okta/okta-sdk-golang/v2 v2.20.0 h1:EDKM+uOPfihOMNwgHMdno+NAsIfyXkVnoFAYVPay0YU= +github.com/okta/okta-sdk-golang/v2 v2.20.0/go.mod h1:FMy5hN5G8Rd/VoS0XrfyPPhIfOVo78ZK7lvwiQRS2+U= +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/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/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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +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.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +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.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +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= +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= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..c71d68a --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,369 @@ +// Package app provides the core application logic for the GitHub bot. +// coordinates webhook processing, Okta sync, and PR compliance checks. +package app + +import ( + "context" + "encoding/json" + "log/slog" + + "github.com/cockroachdb/errors" + "github.com/cruxstack/github-ops-app/internal/config" + internalerrors "github.com/cruxstack/github-ops-app/internal/errors" + "github.com/cruxstack/github-ops-app/internal/github" + "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. +type App struct { + Config *config.Config + Logger *slog.Logger + GitHubClient *github.Client + OktaClient *okta.Client + Notifier *notifiers.SlackNotifier +} + +// New creates a new App instance with configured clients. +// initializes GitHub, Okta, and Slack clients based on config. +func New(ctx context.Context, cfg *config.Config) (*App, error) { + logger := config.NewLogger() + + app := &App{ + Config: cfg, + Logger: logger, + } + + if cfg.IsGitHubConfigured() { + ghClient, err := github.NewAppClientWithBaseURL( + cfg.GitHubAppID, + cfg.GitHubInstallID, + cfg.GitHubAppPrivateKey, + cfg.GitHubOrg, + cfg.GitHubBaseURL, + ) + if err != nil { + return nil, errors.Wrap(err, "failed to create github app client") + } + app.GitHubClient = ghClient + } + + if cfg.IsOktaSyncEnabled() { + oktaClient, err := okta.NewClientWithContext(ctx, &okta.ClientConfig{ + Domain: cfg.OktaDomain, + ClientID: cfg.OktaClientID, + PrivateKey: cfg.OktaPrivateKey, + Scopes: cfg.OktaScopes, + GitHubUserField: cfg.OktaGitHubUserField, + BaseURL: cfg.OktaBaseURL, + }) + if err != nil { + return nil, errors.Wrap(err, "failed to create okta client") + } + app.OktaClient = oktaClient + } + + if cfg.SlackEnabled { + app.Notifier = notifiers.NewSlackNotifierWithAPIURL(cfg.SlackToken, cfg.SlackChannel, cfg.SlackAPIURL) + } + + return app, nil +} + +// ScheduledEvent represents a generic scheduled event. +type ScheduledEvent struct { + Action string `json:"action"` + Data json.RawMessage `json:"data,omitempty"` +} + +// 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))) + } + + if evt.Action == "okta-sync" { + return a.handleOktaSync(ctx) + } + + return errors.Newf("unknown scheduled action: %s", evt.Action) +} + +// 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)) + } + + 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(internalerrors.ErrInvalidEventType, "%s", eventType) + } +} + +// 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 + } + + if a.OktaClient == nil || a.GitHubClient == nil { + return errors.Wrap(internalerrors.ErrClientNotInit, "okta or github client") + } + + syncer := okta.NewSyncer(a.OktaClient, a.GitHubClient, a.Config.OktaSyncRules, a.Config.OktaSyncSafetyThreshold, a.Logger) + syncResult, err := syncer.Sync(ctx) + if err != nil { + return errors.Wrap(err, "okta sync failed") + } + + a.Logger.Info("okta sync completed", slog.Int("report_count", len(syncResult.Reports))) + + if a.Notifier != nil { + if err := a.Notifier.NotifyOktaSync(ctx, syncResult.Reports); err != nil { + a.Logger.Warn("failed to send slack notification", slog.String("error", err.Error())) + } + } + + if a.Config.OktaOrphanedUserNotifications { + syncedTeams := make([]string, 0, len(syncResult.Reports)) + for _, report := range syncResult.Reports { + syncedTeams = append(syncedTeams, report.GitHubTeam) + } + + 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 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())) + } + } + } + } + + return 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 := github.ParsePullRequestEvent(payload) + if err != nil { + return err + } + + if !prEvent.IsMerged() { + if a.Config.DebugEnabled { + a.Logger.Debug("pr not merged, skipping", slog.Int("pr_number", prEvent.Number)) + } + return nil + } + + 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 + } + + ghClient := a.GitHubClient + + if prEvent.GetInstallationID() != 0 && prEvent.GetInstallationID() != a.Config.GitHubInstallID { + installClient, err := github.NewAppClientWithBaseURL( + a.Config.GitHubAppID, + prEvent.GetInstallationID(), + a.Config.GitHubAppPrivateKey, + a.Config.GitHubOrg, + a.Config.GitHubBaseURL, + ) + if err != nil { + return errors.Wrapf(err, "failed to create client for installation %d", prEvent.GetInstallationID()) + } + ghClient = installClient + } + + if ghClient == nil { + return errors.Wrap(internalerrors.ErrClientNotInit, "github client") + } + + owner := prEvent.GetRepoOwner() + repo := prEvent.GetRepoName() + + result, err := ghClient.CheckPRCompliance(ctx, owner, repo, prEvent.Number) + if err != nil { + return errors.Wrapf(err, "failed to check pr #%d compliance", prEvent.Number) + } + + 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())) + } + } + } else if a.Config.DebugEnabled { + a.Logger.Debug("pr complied with branch protection", slog.Int("pr_number", prEvent.Number)) + } + + return 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 := github.ParseTeamEvent(payload) + if err != nil { + return err + } + + if !a.Config.IsOktaSyncEnabled() { + if a.Config.DebugEnabled { + a.Logger.Debug("okta sync not enabled, skipping team webhook") + } + return nil + } + + if a.shouldIgnoreTeamChange(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())) + } + 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) +} + +// 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 := github.ParseMembershipEvent(payload) + if err != nil { + return err + } + + if !membershipEvent.IsTeamScope() { + if a.Config.DebugEnabled { + a.Logger.Debug("membership event is not team scope, skipping") + } + return nil + } + + if !a.Config.IsOktaSyncEnabled() { + if a.Config.DebugEnabled { + a.Logger.Debug("okta sync not enabled, skipping membership webhook") + } + return nil + } + + if a.shouldIgnoreMembershipChange(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())) + } + 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) +} + +// shouldIgnoreTeamChange checks if a team webhook should be ignored. +// ignores changes made by bots or the GitHub App itself to prevent loops. +func (a *App) shouldIgnoreTeamChange(ctx context.Context, event *github.TeamEvent) bool { + senderType := event.GetSenderType() + if senderType == "Bot" { + return true + } + + 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 + } + senderLogin := event.GetSenderLogin() + if senderLogin == appSlug+"[bot]" { + return true + } + } + + return false +} + +// shouldIgnoreMembershipChange checks if a membership webhook should be +// ignored. ignores changes made by bots or the GitHub App itself to prevent +// loops. +func (a *App) shouldIgnoreMembershipChange(ctx context.Context, event *github.MembershipEvent) bool { + senderType := event.GetSenderType() + if senderType == "Bot" { + return true + } + + 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 + } + senderLogin := event.GetSenderLogin() + if senderLogin == appSlug+"[bot]" { + return true + } + } + + return false +} + +// StatusResponse contains application status and feature flags. +type StatusResponse struct { + Status string `json:"status"` + GitHubConfigured bool `json:"github_configured"` + OktaSyncEnabled bool `json:"okta_sync_enabled"` + PRComplianceCheck bool `json:"pr_compliance_check"` + SlackEnabled bool `json:"slack_enabled"` +} + +// GetStatus returns current application status and enabled features. +func (a *App) GetStatus() StatusResponse { + return StatusResponse{ + Status: "ok", + GitHubConfigured: a.Config.IsGitHubConfigured(), + OktaSyncEnabled: a.Config.IsOktaSyncEnabled(), + PRComplianceCheck: a.Config.IsPRComplianceEnabled(), + SlackEnabled: a.Config.SlackEnabled, + } +} diff --git a/internal/app/handlers.go b/internal/app/handlers.go new file mode 100644 index 0000000..b03d486 --- /dev/null +++ b/internal/app/handlers.go @@ -0,0 +1,85 @@ +// Package app provides HTTP handlers for webhook and status endpoints. +package app + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + + "github.com/cruxstack/github-ops-app/internal/github" +) + +// WebhookHandler processes incoming GitHub webhook POST requests. +// validates webhook signatures before processing events. +func (a *App) WebhookHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + eventType := r.Header.Get("X-GitHub-Event") + signature := r.Header.Get("X-Hub-Signature-256") + + body, err := io.ReadAll(r.Body) + if err != nil { + a.Logger.Warn("failed to read request body", slog.String("error", err.Error())) + http.Error(w, "failed to read request body", http.StatusBadRequest) + return + } + + if err := github.ValidateWebhookSignature( + body, + signature, + a.Config.GitHubWebhookSecret, + ); err != nil { + a.Logger.Warn("webhook signature validation failed", slog.String("error", err.Error())) + http.Error(w, "unauthorized", http.StatusUnauthorized) + 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())) + http.Error(w, "webhook processing failed", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +} + +// StatusHandler returns the application status and feature flags. +// responds with JSON containing configuration state and enabled features. +func (a *App) StatusHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + status := a.GetStatus() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + if err := json.NewEncoder(w).Encode(status); err != nil { + a.Logger.Error("failed to encode status response", slog.String("error", err.Error())) + } +} + +// ConfigHandler returns the application configuration with secrets redacted. +// useful for debugging and verifying environment settings. +func (a *App) ConfigHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + redacted := a.Config.Redacted() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + if err := json.NewEncoder(w).Encode(redacted); err != nil { + a.Logger.Error("failed to encode config response", slog.String("error", err.Error())) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4d8abab --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,382 @@ +// Package config provides application configuration loaded from environment +// variables. +package config + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "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-ops-app/internal/okta" +) + +// Config holds all application configuration loaded from environment +// variables. +type Config struct { + DebugEnabled bool + + GitHubOrg string + GitHubWebhookSecret string + GitHubBaseURL string + + GitHubAppID int64 + GitHubAppPrivateKey []byte + GitHubInstallID int64 + + OktaDomain string + OktaClientID string + OktaPrivateKey []byte + OktaScopes []string + OktaBaseURL string + OktaSyncRules []okta.SyncRule + OktaGitHubUserField string + OktaSyncSafetyThreshold float64 + + PRComplianceEnabled bool + PRMonitoredBranches []string + + OktaOrphanedUserNotifications bool + + SlackEnabled bool + SlackToken string + SlackChannel string + SlackAPIURL string +} + +var ( + ssmClient *ssm.Client + ssmClientOnce sync.Once + ssmClientErr error +) + +// 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 +} + +// 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 + } + + if !strings.HasPrefix(value, "arn:aws:ssm:") { + return value, nil + } + + client, err := getSSMClient(ctx) + if err != nil { + return "", errors.Wrapf(err, "failed to init ssm client for %s", key) + } + + 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) + } + paramName = paramName[idx+len(":parameter/"):] + + input := &ssm.GetParameterInput{ + Name: ¶mName, + WithDecryption: aws.Bool(true), + } + + result, err := client.GetParameter(ctx, input) + if err != nil { + return "", errors.Wrapf(err, "failed to get ssm parameter '%s' for %s", paramName, key) + } + + if result.Parameter == nil || result.Parameter.Value == nil { + return "", errors.Newf("ssm parameter '%s' for %s returned nil value", paramName, key) + } + + return *result.Parameter.Value, nil +} + +// 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) +} + +// 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()) +} + +// 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")) + + oktaGitHubUserField := os.Getenv("APP_OKTA_GITHUB_USER_FIELD") + if oktaGitHubUserField == "" { + oktaGitHubUserField = "githubUsername" + } + + 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 + } + } + + githubWebhookSecret, err := getEnv(ctx, "APP_GITHUB_WEBHOOK_SECRET") + if err != nil { + return nil, err + } + + slackToken, err := getEnv(ctx, "APP_SLACK_TOKEN") + if err != nil { + return nil, err + } + + cfg := Config{ + DebugEnabled: debugEnabled, + 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"), + 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) + } + cfg.GitHubAppID = appID + } + + 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) + } + + if installIDStr := os.Getenv("APP_GITHUB_INSTALLATION_ID"); installIDStr != "" { + installID, err := strconv.ParseInt(installIDStr, 10, 64) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse APP_GITHUB_INSTALLATION_ID '%s'", installIDStr) + } + cfg.GitHubInstallID = installID + } + + 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) + } + 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) + } + + if scopesStr := os.Getenv("APP_OKTA_SCOPES"); scopesStr != "" { + scopes := strings.Split(scopesStr, ",") + for i := range scopes { + scopes[i] = strings.TrimSpace(scopes[i]) + } + cfg.OktaScopes = scopes + } else { + cfg.OktaScopes = okta.DefaultScopes + } + + 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]) + } + cfg.PRMonitoredBranches = branches + } else { + cfg.PRMonitoredBranches = []string{"main", "master"} + } + + syncRulesJSON := os.Getenv("APP_OKTA_SYNC_RULES") + if syncRulesJSON != "" { + var rules []okta.SyncRule + if err := json.Unmarshal([]byte(syncRulesJSON), &rules); err != nil { + return nil, errors.Wrap(err, "failed to parse APP_OKTA_SYNC_RULES") + } + cfg.OktaSyncRules = rules + } + + cfg.SlackEnabled = cfg.SlackToken != "" && cfg.SlackChannel != "" + + orphanedUserNotifications, _ := strconv.ParseBool(os.Getenv("APP_OKTA_ORPHANED_USER_NOTIFICATIONS")) + if os.Getenv("APP_OKTA_ORPHANED_USER_NOTIFICATIONS") == "" { + orphanedUserNotifications = cfg.IsOktaSyncEnabled() + } + cfg.OktaOrphanedUserNotifications = orphanedUserNotifications + + return &cfg, nil +} + +// NewLogger creates a new structured logger. +// uses JSON format in Lambda, text format elsewhere. +func NewLogger() *slog.Logger { + var handler slog.Handler + + if os.Getenv("AWS_LAMBDA_FUNCTION_NAME") != "" { + handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }) + } else { + handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }) + } + + 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() +} + +// 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.GitHubInstallID != 0 +} + +// ShouldMonitorBranch returns true if the given branch should be monitored +// for PR compliance. +func (c *Config) ShouldMonitorBranch(branch string) bool { + if !c.IsPRComplianceEnabled() { + return false + } + branch = strings.TrimPrefix(branch, "refs/heads/") + for _, monitored := range c.PRMonitoredBranches { + if branch == monitored { + return true + } + } + return false +} + +// RedactedConfig contains configuration with sensitive values redacted. +// safe for logging and API responses. +type RedactedConfig struct { + DebugEnabled bool `json:"debug_enabled"` + + GitHubOrg string `json:"github_org"` + GitHubWebhookSecret string `json:"github_webhook_secret"` + GitHubBaseURL string `json:"github_base_url"` + + GitHubAppID int64 `json:"github_app_id"` + GitHubAppPrivateKey string `json:"github_app_private_key"` + GitHubInstallID int64 `json:"github_install_id"` + + OktaDomain string `json:"okta_domain"` + OktaClientID string `json:"okta_client_id"` + OktaPrivateKey string `json:"okta_private_key"` + OktaScopes []string `json:"okta_scopes"` + OktaBaseURL string `json:"okta_base_url"` + OktaSyncRules []okta.SyncRule `json:"okta_sync_rules"` + OktaGitHubUserField string `json:"okta_github_user_field"` + OktaSyncSafetyThreshold float64 `json:"okta_sync_safety_threshold"` + + PRComplianceEnabled bool `json:"pr_compliance_enabled"` + PRMonitoredBranches []string `json:"pr_monitored_branches"` + + OktaOrphanedUserNotifications bool `json:"okta_orphaned_user_notifications"` + + SlackEnabled bool `json:"slack_enabled"` + SlackToken string `json:"slack_token"` + SlackChannel string `json:"slack_channel"` + 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***" + } + + redactBytes := func(b []byte) string { + if len(b) == 0 { + return "" + } + return "***REDACTED***" + } + + return RedactedConfig{ + DebugEnabled: c.DebugEnabled, + GitHubOrg: c.GitHubOrg, + GitHubWebhookSecret: redact(c.GitHubWebhookSecret), + GitHubBaseURL: c.GitHubBaseURL, + GitHubAppID: c.GitHubAppID, + GitHubAppPrivateKey: redactBytes(c.GitHubAppPrivateKey), + GitHubInstallID: c.GitHubInstallID, + OktaDomain: c.OktaDomain, + OktaClientID: redact(c.OktaClientID), + OktaPrivateKey: redactBytes(c.OktaPrivateKey), + OktaScopes: c.OktaScopes, + OktaBaseURL: c.OktaBaseURL, + OktaSyncRules: c.OktaSyncRules, + OktaGitHubUserField: c.OktaGitHubUserField, + OktaSyncSafetyThreshold: c.OktaSyncSafetyThreshold, + PRComplianceEnabled: c.PRComplianceEnabled, + PRMonitoredBranches: c.PRMonitoredBranches, + OktaOrphanedUserNotifications: c.OktaOrphanedUserNotifications, + SlackEnabled: c.SlackEnabled, + SlackToken: redact(c.SlackToken), + SlackChannel: c.SlackChannel, + SlackAPIURL: c.SlackAPIURL, + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..957c425 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,65 @@ +package config + +import ( + "context" + "testing" +) + +func TestResolveEnvValue(t *testing.T) { + ctx := context.Background() + + 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, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := resolveEnvValue(ctx, tt.key, tt.value) + + if tt.wantError && err == nil { + t.Errorf("expected error but got none") + } + + if !tt.wantError && err != nil { + t.Errorf("unexpected error: %v", err) + } + + if !tt.wantSSM && err == nil && result != tt.value { + t.Errorf("expected result %q, got %q", tt.value, result) + } + }) + } +} diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 0000000..3a05f20 --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,43 @@ +// Package errors defines sentinel errors and domain types for the +// application. uses cockroachdb/errors for automatic stack trace capture. +package errors + +import "github.com/cockroachdb/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{} +) + +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" } + +// domain type instances for error marking +var ( + ValidationError = validationError{} + AuthError = authError{} + APIError = apiError{} + ConfigError = configError{} +) + +// sentinel errors for common failure cases +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) + ErrOAuthTokenExpired = errors.Mark(errors.New("oauth token expired"), AuthError) +) diff --git a/internal/github/client.go b/internal/github/client.go new file mode 100644 index 0000000..246cde9 --- /dev/null +++ b/internal/github/client.go @@ -0,0 +1,248 @@ +// Package github provides GitHub API client with App authentication. +// handles JWT generation, installation token management, and automatic token +// refresh. +package github + +import ( + "context" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "net/http" + "sync" + "time" + + "github.com/cockroachdb/errors" + "github.com/golang-jwt/jwt/v5" + "github.com/google/go-github/v79/github" + "golang.org/x/oauth2" +) + +// Client wraps the GitHub API client with App authentication. +// automatically refreshes installation tokens before expiry. +type Client struct { + client *github.Client + org string + baseURL string + + appID int64 + privateKey *rsa.PrivateKey + installationID int64 + + tokenMu sync.RWMutex + token string + tokenExpAt time.Time +} + +// 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, "") +} + +// 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) + if err != nil { + return nil, errors.Wrap(err, "failed to parse private key") + } + + c := &Client{ + org: org, + appID: appID, + privateKey: privateKey, + installationID: installationID, + baseURL: baseURL, + } + + if err := c.refreshToken(context.Background()); err != nil { + return nil, errors.Wrap(err, "failed to get initial token") + } + + return c, nil +} + +// 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") + } + + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + 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 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), + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + return token.SignedString(c.privateKey) +} + +// 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") + } + + 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) + } + + installToken, resp, err := appClient.Apps.CreateInstallationToken( + ctx, + c.installationID, + &github.InstallationTokenOptions{}, + ) + if err != nil { + return errors.WithDetailf( + errors.Wrap(err, "failed to create installation token"), + "installation_id=%d app_id=%d", c.installationID, c.appID, + ) + } + 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) + } + c.tokenMu.Unlock() + + return nil +} + +// ensureValidToken refreshes the installation token if it expires within 5 +// minutes. +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 { + return c.refreshToken(ctx) + } + + return nil +} + +// 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 +} + +// 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) +} + +// GetAppSlug fetches the GitHub App slug identifier. +// used to detect changes made by the app itself. +func (c *Client) GetAppSlug(ctx context.Context) (string, error) { + if err := c.ensureValidToken(ctx); err != nil { + return "", err + } + + app, _, err := c.client.Apps.Get(ctx, "") + 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 *app.Slug, nil +} + +// 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 + } + + membership, resp, err := c.client.Organizations.GetOrgMembership(ctx, username, c.org) + if err != nil { + if resp != nil && resp.StatusCode == 404 { + return true, nil + } + return false, errors.Wrapf(err, "failed to check org membership for user '%s'", username) + } + + return membership == nil, 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 + } + + opts := &github.ListMembersOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + } + + 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 + } + + return allMembers, nil +} diff --git a/internal/github/pr.go b/internal/github/pr.go new file mode 100644 index 0000000..caaa060 --- /dev/null +++ b/internal/github/pr.go @@ -0,0 +1,178 @@ +// Package github provides PR compliance checking against branch protection +// rules. +package github + +import ( + "context" + "fmt" + + "github.com/cockroachdb/errors" + internalerrors "github.com/cruxstack/github-ops-app/internal/errors" + "github.com/google/go-github/v79/github" +) + +// ComplianceViolation represents a single branch protection rule violation. +type ComplianceViolation struct { + Type string + Description string +} + +// PRComplianceResult contains PR compliance check results including +// violations and user bypass permissions. +type PRComplianceResult struct { + PR *github.PullRequest + BaseBranch string + Protection *github.Protection + Violations []ComplianceViolation + UserHasBypass bool + UserBypassReason string +} + +// CheckPRCompliance verifies if a merged PR met branch protection +// requirements. checks review requirements, status checks, and user bypass +// permissions. +func (c *Client) CheckPRCompliance(ctx context.Context, owner, repo string, prNumber int) (*PRComplianceResult, error) { + if err := c.ensureValidToken(ctx); err != nil { + return nil, err + } + + pr, _, err := c.client.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) + } + + if pr == nil { + return nil, errors.Wrapf(internalerrors.ErrMissingPRData, "pr #%d returned nil", prNumber) + } + + if pr.Base == nil || pr.Base.Ref == nil { + return nil, errors.Wrapf(internalerrors.ErrMissingPRData, "pr #%d missing base branch", prNumber) + } + + baseBranch := *pr.Base.Ref + + protection, _, err := c.client.Repositories.GetBranchProtection(ctx, owner, repo, baseBranch) + if err != nil { + return &PRComplianceResult{ + PR: pr, + BaseBranch: baseBranch, + Violations: []ComplianceViolation{}, + }, nil + } + + result := &PRComplianceResult{ + PR: pr, + BaseBranch: baseBranch, + Protection: protection, + Violations: []ComplianceViolation{}, + } + + c.checkReviewRequirements(ctx, owner, repo, pr, protection, result) + c.checkStatusRequirements(ctx, owner, repo, pr, protection, result) + c.checkUserBypassPermission(ctx, owner, repo, pr, result) + + return result, nil +} + +// checkReviewRequirements validates that PR had required approving reviews. +func (c *Client) checkReviewRequirements(ctx context.Context, owner, repo string, pr *github.PullRequest, protection *github.Protection, result *PRComplianceResult) { + if protection.RequiredPullRequestReviews == nil { + return + } + + requiredApprovals := protection.RequiredPullRequestReviews.RequiredApprovingReviewCount + + if requiredApprovals == 0 { + return + } + + reviews, _, err := c.client.PullRequests.ListReviews(ctx, owner, repo, *pr.Number, nil) + if err != nil { + return + } + + approvedCount := 0 + for _, review := range reviews { + if review.State != nil && *review.State == "APPROVED" { + approvedCount++ + } + } + + if approvedCount < requiredApprovals { + result.Violations = append(result.Violations, ComplianceViolation{ + Type: "insufficient_reviews", + Description: fmt.Sprintf("required %d approving reviews, had %d", requiredApprovals, approvedCount), + }) + } +} + +// checkStatusRequirements validates that required status checks passed. +func (c *Client) checkStatusRequirements(ctx context.Context, owner, repo string, pr *github.PullRequest, protection *github.Protection, result *PRComplianceResult) { + if protection.RequiredStatusChecks == nil || protection.RequiredStatusChecks.Contexts == nil || len(*protection.RequiredStatusChecks.Contexts) == 0 { + return + } + + if pr.Head == nil || pr.Head.SHA == nil { + return + } + + requiredChecks := *protection.RequiredStatusChecks.Contexts + + combinedStatus, _, err := c.client.Repositories.GetCombinedStatus(ctx, owner, repo, *pr.Head.SHA, nil) + if err != nil { + return + } + + passedChecks := make(map[string]bool) + for _, status := range combinedStatus.Statuses { + if status.Context != nil && status.State != nil && *status.State == "success" { + passedChecks[*status.Context] = true + } + } + + for _, required := range requiredChecks { + if !passedChecks[required] { + result.Violations = append(result.Violations, ComplianceViolation{ + Type: "missing_status_check", + Description: fmt.Sprintf("required check '%s' did not pass", required), + }) + } + } +} + +// checkUserBypassPermission checks if the user who merged the PR has admin or +// maintainer permissions allowing bypass. +func (c *Client) checkUserBypassPermission(ctx context.Context, owner, repo string, pr *github.PullRequest, result *PRComplianceResult) { + if pr.MergedBy == nil || pr.MergedBy.Login == nil { + return + } + + mergedBy := *pr.MergedBy.Login + + permissionLevel, _, err := c.client.Repositories.GetPermissionLevel(ctx, owner, repo, mergedBy) + if err != nil { + return + } + + if permissionLevel.Permission != nil { + perm := *permissionLevel.Permission + if perm == "admin" { + result.UserHasBypass = true + result.UserBypassReason = "repository admin" + } else if perm == "maintain" { + result.UserHasBypass = true + result.UserBypassReason = "repository maintainer" + } + } +} + +// HasViolations returns true if any compliance violations were detected. +func (r *PRComplianceResult) HasViolations() bool { + return len(r.Violations) > 0 +} + +// WasBypassed returns true if violations exist and user had bypass +// permission. +func (r *PRComplianceResult) WasBypassed() bool { + return r.HasViolations() && r.UserHasBypass +} diff --git a/internal/github/teams.go b/internal/github/teams.go new file mode 100644 index 0000000..93c7c44 --- /dev/null +++ b/internal/github/teams.go @@ -0,0 +1,153 @@ +// Package github provides GitHub team management and membership sync. +package github + +import ( + "context" + "fmt" + + "github.com/cockroachdb/errors" + internalerrors "github.com/cruxstack/github-ops-app/internal/errors" + "github.com/google/go-github/v79/github" +) + +// TeamSyncResult contains the results of syncing team membership. +type TeamSyncResult struct { + TeamName string + MembersAdded []string + MembersRemoved []string + MembersSkippedExternal []string + Errors []string +} + +// 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 + } + + team, resp, err := c.client.Teams.GetTeamBySlug(ctx, c.org, teamName) + if err == nil { + return team, nil + } + + if resp != nil && resp.StatusCode == 404 { + newTeam := &github.NewTeam{ + Name: teamName, + Privacy: &privacy, + } + team, _, err = c.client.Teams.CreateTeam(ctx, c.org, *newTeam) + if err != nil { + return nil, errors.Wrapf(err, "failed to create team '%s' in org '%s'", teamName, c.org) + } + return team, nil + } + + return nil, errors.Wrapf(internalerrors.ErrTeamNotFound, "failed to fetch team '%s' from org '%s'", teamName, c.org) +} + +// GetTeamMembers returns GitHub usernames of all team members. +func (c *Client) GetTeamMembers(ctx context.Context, teamSlug string) ([]string, error) { + if err := c.ensureValidToken(ctx); err != nil { + return nil, err + } + + members, _, err := c.client.Teams.ListTeamMembersBySlug(ctx, c.org, teamSlug, nil) + if err != nil { + return nil, errors.Wrapf(err, "failed to list members for team '%s'", teamSlug) + } + + logins := make([]string, 0, len(members)) + for _, member := range members { + if member.Login != nil { + logins = append(logins, *member.Login) + } + } + + return logins, 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) (*TeamSyncResult, error) { + if err := c.ensureValidToken(ctx); err != nil { + return nil, err + } + + result := &TeamSyncResult{ + TeamName: teamSlug, + MembersAdded: []string{}, + MembersRemoved: []string{}, + MembersSkippedExternal: []string{}, + Errors: []string{}, + } + + currentMembers, err := c.GetTeamMembers(ctx, teamSlug) + if err != nil { + return nil, errors.Wrapf(err, "failed to fetch current members for team '%s'", teamSlug) + } + + currentSet := make(map[string]bool) + for _, member := range currentMembers { + currentSet[member] = true + } + + desiredSet := make(map[string]bool) + for _, member := range desiredMembers { + desiredSet[member] = true + } + + for _, desired := range desiredMembers { + if !currentSet[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 _, current := range currentMembers { + if !desiredSet[current] { + toRemove = append(toRemove, current) + } + } + + 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) + return result, nil + } + } + + for _, username := range toRemove { + isExternal, err := c.IsExternalCollaborator(ctx, username) + if err != nil { + errMsg := fmt.Sprintf("failed to check if '%s' is external: %v", username, err) + result.Errors = append(result.Errors, errMsg) + continue + } + + if isExternal { + result.MembersSkippedExternal = append(result.MembersSkippedExternal, username) + continue + } + + _, err = c.client.Teams.RemoveTeamMembershipBySlug(ctx, c.org, teamSlug, username) + 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) + } + } + + return result, nil +} diff --git a/internal/github/webhook.go b/internal/github/webhook.go new file mode 100644 index 0000000..46c0e9a --- /dev/null +++ b/internal/github/webhook.go @@ -0,0 +1,258 @@ +// Package github provides webhook event parsing and signature validation. +package github + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "strings" + + "github.com/cockroachdb/errors" + internalerrors "github.com/cruxstack/github-ops-app/internal/errors" + "github.com/google/go-github/v79/github" +) + +// 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"` +} + +// TeamEvent represents a GitHub team webhook payload. +type TeamEvent struct { + 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. +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"` +} + +// MembershipEvent represents a GitHub membership webhook payload. +type MembershipEvent struct { + 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 internalerrors.ErrUnexpectedSignature + } + return nil + } + + if signature == "" { + return internalerrors.ErrMissingSignature + } + + if !strings.HasPrefix(signature, "sha256=") { + return errors.Wrap(internalerrors.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(internalerrors.ErrInvalidSignature, "computed signature does not match") + } + + return nil +} + +// 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(internalerrors.ErrMissingPRData, "missing pull_request field") + } + if event.PullRequest.Number == nil { + return nil, errors.Wrap(internalerrors.ErrMissingPRData, "missing pr number") + } + if event.PullRequest.Base == nil || event.PullRequest.Base.Ref == nil { + return nil, errors.Wrap(internalerrors.ErrMissingPRData, "missing base branch") + } + if event.Repository == nil { + return nil, errors.Wrap(internalerrors.ErrMissingPRData, "missing 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 +} + +// 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 "" +} + +// 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 "" +} + +// 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 "" +} + +// 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 +} + +// 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 event.Sender == nil { + return nil, errors.New("missing sender field in event") + } + 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. +func (e *TeamEvent) GetTeamSlug() string { + if e.Team != nil && e.Team.Slug != nil { + return *e.Team.Slug + } + return "" +} + +// 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 "" +} + +// 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 + } + 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 + } + return 0 +} + +// 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 + } + return "" +} + +// 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 "" +} + +// 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 "" +} + +// IsTeamScope returns true if the membership event is for a team. +func (e *MembershipEvent) IsTeamScope() bool { + return e.Scope == "team" +} diff --git a/internal/notifiers/github_slack.go b/internal/notifiers/github_slack.go new file mode 100644 index 0000000..3ce6b24 --- /dev/null +++ b/internal/notifiers/github_slack.go @@ -0,0 +1,190 @@ +// Package notifiers provides Slack notification formatting for GitHub and +// Okta events. +package notifiers + +import ( + "context" + "fmt" + + "github.com/cruxstack/github-ops-app/internal/errors" + "github.com/cruxstack/github-ops-app/internal/github" + "github.com/cruxstack/github-ops-app/internal/okta" + "github.com/slack-go/slack" +) + +// NotifyPRBypass sends a Slack notification when branch protection is +// bypassed. +func (s *SlackNotifier) NotifyPRBypass(ctx context.Context, result *github.PRComplianceResult, repoFullName string) error { + if result.PR == nil { + return fmt.Errorf("%w: pr result missing", errors.ErrMissingPRData) + } + + 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 + } + if result.PR.Number != nil { + prNumber = *result.PR.Number + } + if result.PR.MergedBy != nil && result.PR.MergedBy.Login != nil { + mergedBy = *result.PR.MergedBy.Login + } + + blocks := []slack.Block{ + slack.NewHeaderBlock( + slack.NewTextBlockObject("plain_text", "🚨 Branch Protection Bypassed", false, false), + ), + slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*PR #%d*: %s", prNumber, prTitle), false, false), + nil, nil, + ), + } + + detailsFields := []*slack.TextBlockObject{ + slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*Repository*\n%s", repoFullName), false, false), + slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*Branch*\n%s", result.BaseBranch), false, false), + slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*Merged by*\n%s", mergedBy), false, false), + } + + if result.UserHasBypass { + detailsFields = append(detailsFields, + slack.NewTextBlockObject("mrkdwn", fmt.Sprintf("*User Permission*\n%s", result.UserBypassReason), false, false), + ) + } + + blocks = append(blocks, slack.NewSectionBlock(nil, detailsFields, nil)) + + 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, + )) + } + + buttons := slack.NewActionBlock( + "actions", + slack.NewButtonBlockElement("view_pr", "view", slack.NewTextBlockObject("plain_text", "View PR", false, false)).WithStyle(slack.StylePrimary).WithURL(prURL), + ) + blocks = append(blocks, buttons) + + _, _, err := s.client.PostMessageContext( + ctx, + s.channel, + slack.MsgOptionBlocks(blocks...), + slack.MsgOptionText(fmt.Sprintf("branch protection bypassed on pr #%d", prNumber), false), + ) + + if err != nil { + return fmt.Errorf("failed to post pr bypass notification to slack: %w", err) + } + + return nil +} + +// NotifyOktaSync sends a Slack notification with Okta sync results. +func (s *SlackNotifier) NotifyOktaSync(ctx context.Context, reports []*okta.SyncReport) error { + if len(reports) == 0 { + return nil + } + + blocks := []slack.Block{ + slack.NewHeaderBlock( + slack.NewTextBlockObject("plain_text", "✅ Okta Group Sync Complete", false, false), + ), + } + + for _, report := range reports { + sectionText := fmt.Sprintf("*Rule:* %s\n*Okta Group:* %s\n*GitHub Team:* %s", report.Rule, report.OktaGroup, report.GitHubTeam) + + if len(report.MembersAdded) > 0 { + sectionText += fmt.Sprintf("\n*Added:* %d members", len(report.MembersAdded)) + } + if len(report.MembersRemoved) > 0 { + sectionText += fmt.Sprintf("\n*Removed:* %d members", len(report.MembersRemoved)) + } + if len(report.MembersSkippedExternal) > 0 { + sectionText += fmt.Sprintf("\n*Skipped (External):* %d members", len(report.MembersSkippedExternal)) + } + if len(report.Errors) > 0 { + sectionText += fmt.Sprintf("\n*Errors:* %d", len(report.Errors)) + } + + blocks = append(blocks, slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", sectionText, false, false), + nil, nil, + )) + blocks = append(blocks, slack.NewDividerBlock()) + } + + _, _, err := s.client.PostMessageContext( + ctx, + s.channel, + slack.MsgOptionBlocks(blocks...), + slack.MsgOptionText("okta group sync completed", false), + ) + + if err != nil { + return fmt.Errorf("failed to post okta sync notification to slack: %w", err) + } + + return nil +} + +// NotifyOrphanedUsers sends a Slack notification about organization members +// not in any synced teams. +func (s *SlackNotifier) NotifyOrphanedUsers(ctx context.Context, report *okta.OrphanedUsersReport) error { + if report == nil || len(report.OrphanedUsers) == 0 { + 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, + ), + } + + userList := "" + for _, user := range report.OrphanedUsers { + userList += fmt.Sprintf("• `%s`\n", user) + } + + 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), + )) + + _, _, err := s.client.PostMessageContext( + ctx, + s.channel, + slack.MsgOptionBlocks(blocks...), + slack.MsgOptionText(fmt.Sprintf("orphaned github users detected: %d users", len(report.OrphanedUsers)), false), + ) + + if err != nil { + return fmt.Errorf("failed to post orphaned users notification to slack: %w", err) + } + + return nil +} diff --git a/internal/notifiers/slack.go b/internal/notifiers/slack.go new file mode 100644 index 0000000..656455d --- /dev/null +++ b/internal/notifiers/slack.go @@ -0,0 +1,30 @@ +// Package notifiers provides Slack notification formatting and sending. +package notifiers + +import ( + "github.com/slack-go/slack" +) + +// SlackNotifier sends formatted messages to Slack channels. +type SlackNotifier struct { + client *slack.Client + channel string +} + +// NewSlackNotifier creates a Slack notifier with default API URL. +func NewSlackNotifier(token, channel string) *SlackNotifier { + return NewSlackNotifierWithAPIURL(token, channel, "") +} + +// NewSlackNotifierWithAPIURL creates a Slack notifier with custom API URL. +// useful for testing with mock servers. +func NewSlackNotifierWithAPIURL(token, channel, apiURL string) *SlackNotifier { + var opts []slack.Option + if apiURL != "" { + opts = append(opts, slack.OptionAPIURL(apiURL)) + } + return &SlackNotifier{ + client: slack.New(token, opts...), + channel: channel, + } +} diff --git a/internal/okta/client.go b/internal/okta/client.go new file mode 100644 index 0000000..82e7279 --- /dev/null +++ b/internal/okta/client.go @@ -0,0 +1,160 @@ +// Package okta provides Okta API client with OAuth 2.0 private key +// authentication. +package okta + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + + "github.com/cockroachdb/errors" + internalerrors "github.com/cruxstack/github-ops-app/internal/errors" + "github.com/okta/okta-sdk-golang/v2/okta" + "github.com/okta/okta-sdk-golang/v2/okta/query" +) + +// DefaultScopes defines the required OAuth scopes for the Okta API. +// these scopes are necessary for group sync functionality. +var DefaultScopes = []string{"okta.groups.read", "okta.users.read"} + +// Client wraps the Okta SDK client with custom configuration. +type Client struct { + client *okta.Client + ctx context.Context + githubUserField string +} + +// ClientConfig contains Okta client configuration. +type ClientConfig struct { + Domain string + ClientID string + PrivateKey []byte + Scopes []string + GitHubUserField string + BaseURL string +} + +// 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, internalerrors.ErrMissingOAuthCreds + } + + orgURL := cfg.BaseURL + if orgURL == "" { + orgURL = fmt.Sprintf("https://%s", cfg.Domain) + } + + opts := []okta.ConfigSetter{ + okta.WithOrgUrl(orgURL), + okta.WithAuthorizationMode("PrivateKey"), + okta.WithClientId(cfg.ClientID), + okta.WithPrivateKey(string(cfg.PrivateKey)), + } + + if len(cfg.Scopes) > 0 { + opts = append(opts, okta.WithScopes(cfg.Scopes)) + } else { + opts = append(opts, okta.WithScopes(DefaultScopes)) + } + + if certPool, ok := ctx.Value("okta_tls_cert_pool").(*x509.CertPool); ok && certPool != nil { + httpClient := http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: certPool, + }, + }, + } + opts = append(opts, okta.WithHttpClient(httpClient)) + } + + _, client, err := okta.NewClient(ctx, opts...) + if err != nil { + return nil, errors.Wrap(err, "failed to create okta client") + } + + return &Client{ + client: client, + ctx: ctx, + githubUserField: cfg.GitHubUserField, + }, nil +} + +// GetClient returns the underlying Okta SDK client. +func (c *Client) GetClient() *okta.Client { + return c.client +} + +// GetContext returns the context used for API requests. +func (c *Client) GetContext() context.Context { + return c.ctx +} + +// ListGroups fetches all Okta groups. +func (c *Client) ListGroups() ([]*okta.Group, error) { + groups, _, err := c.client.Group.ListGroups(c.ctx, &query.Params{}) + if err != nil { + return nil, errors.Wrap(err, "failed to list groups") + } + return groups, nil +} + +// GetGroupByName searches for an Okta group by exact name match. +func (c *Client) GetGroupByName(name string) (*okta.Group, error) { + groups, _, err := c.client.Group.ListGroups(c.ctx, &query.Params{ + Q: name, + }) + if err != nil { + return nil, errors.Wrapf(err, "failed to search for group '%s'", name) + } + + for _, group := range groups { + if group.Profile.Name == name { + return group, nil + } + } + + return nil, errors.Newf("group '%s' not found", name) +} + +// GetGroupMembers fetches GitHub usernames for all active members of an Okta +// group. only includes users with status "ACTIVE" to exclude +// suspended/deprovisioned users. falls back to email if GitHub username field +// is not set. +func (c *Client) GetGroupMembers(groupID string) ([]string, error) { + users, _, err := c.client.Group.ListGroupUsers(c.ctx, groupID, &query.Params{}) + if err != nil { + return nil, errors.Wrapf(err, "failed to list members for group '%s'", groupID) + } + + logins := make([]string, 0, len(users)) + for _, user := range users { + if user.Status != "ACTIVE" { + continue + } + + if user.Profile != nil { + githubUsername := (*user.Profile)[c.githubUserField] + if username, ok := githubUsername.(string); ok && username != "" { + logins = append(logins, username) + } else { + email := (*user.Profile)["email"] + if emailStr, ok := email.(string); ok && emailStr != "" { + logins = append(logins, emailStr) + } + } + } + } + + return logins, nil +} diff --git a/internal/okta/groups.go b/internal/okta/groups.go new file mode 100644 index 0000000..4c8034a --- /dev/null +++ b/internal/okta/groups.go @@ -0,0 +1,97 @@ +// Package okta provides Okta group querying and filtering. +package okta + +import ( + "regexp" + + "github.com/cockroachdb/errors" + internalerrors "github.com/cruxstack/github-ops-app/internal/errors" + "github.com/okta/okta-sdk-golang/v2/okta" +) + +// GroupInfo contains Okta group details and member list. +type GroupInfo struct { + ID string + Name string + Members []string +} + +// GetGroupsByPattern fetches all Okta groups matching a regex pattern. +func (c *Client) GetGroupsByPattern(pattern string) ([]*GroupInfo, error) { + if pattern == "" { + return nil, internalerrors.ErrEmptyPattern + } + + re, err := regexp.Compile(pattern) + if err != nil { + return nil, errors.Wrapf(internalerrors.ErrInvalidPattern, "'%s'", pattern) + } + + allGroups, err := c.ListGroups() + if err != nil { + return nil, err + } + + var matched []*GroupInfo + for _, group := range allGroups { + if group == nil || group.Profile == nil { + continue + } + + if re.MatchString(group.Profile.Name) { + members, err := c.GetGroupMembers(group.Id) + if err != nil { + continue + } + + matched = append(matched, &GroupInfo{ + ID: group.Id, + Name: group.Profile.Name, + Members: members, + }) + } + } + + return matched, nil +} + +// GetGroupInfo fetches details for a single Okta group by name. +func (c *Client) GetGroupInfo(groupName string) (*GroupInfo, error) { + group, err := c.GetGroupByName(groupName) + if err != nil { + return nil, err + } + + members, err := c.GetGroupMembers(group.Id) + if err != nil { + return nil, err + } + + return &GroupInfo{ + ID: group.Id, + Name: group.Profile.Name, + Members: members, + }, nil +} + +// FilterEnabledGroups filters Okta groups to only those in the enabled list. +// returns all groups if enabled list is empty. +func FilterEnabledGroups(groups []*okta.Group, enabledNames []string) []*okta.Group { + if len(enabledNames) == 0 { + return groups + } + + enabledMap := make(map[string]bool) + for _, name := range enabledNames { + enabledMap[name] = true + } + + var filtered []*okta.Group + for _, group := range groups { + if enabledMap[group.Profile.Name] { + filtered = append(filtered, group) + } + } + + return filtered +} diff --git a/internal/okta/sync.go b/internal/okta/sync.go new file mode 100644 index 0000000..1c35ec0 --- /dev/null +++ b/internal/okta/sync.go @@ -0,0 +1,263 @@ +// Package okta provides Okta group to GitHub team synchronization. +package okta + +import ( + "context" + "fmt" + "log/slog" + "regexp" + "strings" + + "github.com/cockroachdb/errors" + "github.com/cruxstack/github-ops-app/internal/github" +) + +// SyncRule defines how to sync Okta groups to GitHub teams. +type SyncRule struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + OktaGroupPattern string `json:"okta_group_pattern,omitempty"` + OktaGroupName string `json:"okta_group_name,omitempty"` + GitHubTeamPrefix string `json:"github_team_prefix,omitempty"` + GitHubTeamName string `json:"github_team_name,omitempty"` + StripPrefix string `json:"strip_prefix,omitempty"` + SyncMembers bool `json:"sync_members"` + CreateTeamIfMissing bool `json:"create_team_if_missing"` + TeamPrivacy string `json:"team_privacy,omitempty"` +} + +// SyncReport contains the results of syncing a single Okta group to GitHub +// team. +type SyncReport struct { + Rule string + OktaGroup string + GitHubTeam string + MembersAdded []string + MembersRemoved []string + MembersSkippedExternal []string + Errors []string +} + +// OrphanedUsersReport contains users who are org members but not in any synced +// teams. +type OrphanedUsersReport struct { + OrphanedUsers []string +} + +// HasErrors returns true if any errors occurred during sync. +func (r *SyncReport) HasErrors() bool { + return len(r.Errors) > 0 +} + +// HasChanges returns true if members were added or removed. +func (r *SyncReport) HasChanges() bool { + return len(r.MembersAdded) > 0 || len(r.MembersRemoved) > 0 +} + +// Syncer coordinates synchronization of Okta groups to GitHub teams. +type Syncer struct { + oktaClient *Client + githubClient *github.Client + rules []SyncRule + safetyThreshold float64 + logger *slog.Logger +} + +// NewSyncer creates a new Okta to GitHub syncer. +func NewSyncer(oktaClient *Client, githubClient *github.Client, rules []SyncRule, safetyThreshold float64, logger *slog.Logger) *Syncer { + return &Syncer{ + oktaClient: oktaClient, + githubClient: githubClient, + rules: rules, + safetyThreshold: safetyThreshold, + logger: logger, + } +} + +// SyncResult contains all sync reports and orphaned users report. +type SyncResult struct { + Reports []*SyncReport + OrphanedUsers *OrphanedUsersReport +} + +// Sync executes all enabled sync rules and returns reports. +// continues processing remaining rules even if some fail. +func (s *Syncer) Sync(ctx context.Context) (*SyncResult, error) { + var reports []*SyncReport + var syncErrors []string + + for _, rule := range s.rules { + if !rule.Enabled { + continue + } + + ruleReports, err := s.syncRule(ctx, rule) + if err != nil { + errMsg := fmt.Sprintf("rule '%s' failed: %v", rule.Name, err) + syncErrors = append(syncErrors, errMsg) + s.logger.Error("sync rule failed", + slog.String("rule", rule.Name), + slog.String("error", err.Error())) + continue + } + + reports = append(reports, ruleReports...) + } + + if len(syncErrors) > 0 && len(reports) == 0 { + return nil, errors.Newf("all sync rules failed: %d errors", len(syncErrors)) + } + + return &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) (*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 &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 SyncRule) ([]*SyncReport, error) { + var reports []*SyncReport + + if rule.OktaGroupPattern != "" { + groups, err := s.oktaClient.GetGroupsByPattern(rule.OktaGroupPattern) + if err != nil { + return nil, errors.Wrapf(err, "failed to match groups with pattern '%s'", rule.OktaGroupPattern) + } + + for _, group := range groups { + teamName := s.computeTeamName(group.Name, rule) + report := s.syncGroupToTeam(ctx, rule, group, teamName) + reports = append(reports, report) + } + } else if rule.OktaGroupName != "" { + group, err := s.oktaClient.GetGroupInfo(rule.OktaGroupName) + if err != nil { + return nil, errors.Wrapf(err, "failed to fetch group '%s'", rule.OktaGroupName) + } + + teamName := s.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 (s *Syncer) computeTeamName(oktaGroupName string, rule 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 = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(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 SyncRule, group *GroupInfo, teamName string) *SyncReport { + report := &SyncReport{ + Rule: rule.Name, + OktaGroup: group.Name, + GitHubTeam: teamName, + Errors: []string{}, + } + + 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.SyncMembers { + 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 +}