diff --git a/.env.example b/.env.example index bb03817..5c76fbf 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,14 @@ # debug APP_DEBUG_ENABLED=false -# githup app (required) +# github 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) +# github pr compliance (optional) APP_PR_COMPLIANCE_ENABLED=true APP_PR_MONITORED_BRANCHES=main,master @@ -33,3 +33,6 @@ APP_SLACK_CHANNEL=C01234ABCDE # api gateway base path (optional, for lambda deployments with stage prefix) # APP_BASE_PATH=v1 + +# server port (optional, for cmd/server only, default: 8080) +# APP_PORT=8080 diff --git a/cmd/server/main.go b/cmd/server/main.go index e5b8e6b..cc4b84c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -39,7 +39,7 @@ func main() { mux := http.NewServeMux() mux.HandleFunc("/", httpHandler) - port := os.Getenv("PORT") + port := os.Getenv("APP_PORT") if port == "" { port = "8080" } diff --git a/internal/app/app.go b/internal/app/app.go index 2125b1c..57ff04b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,5 +1,5 @@ // Package app provides the core application logic for the GitHub bot. -// coordinates webhook processing, Okta sync, and PR compliance checks. +// Coordinates webhook processing, Okta sync, and PR compliance checks. package app import ( @@ -10,10 +10,9 @@ import ( "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/github/client" "github.com/cruxstack/github-ops-app/internal/notifiers" "github.com/cruxstack/github-ops-app/internal/okta" - gh "github.com/google/go-github/v79/github" ) // App is the main application instance containing all clients and @@ -21,13 +20,13 @@ import ( type App struct { Config *config.Config Logger *slog.Logger - GitHubClient *github.Client + GitHubClient *client.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. +// Initializes GitHub, Okta, and Slack clients based on config. func New(ctx context.Context, cfg *config.Config) (*App, error) { logger := config.NewLogger() @@ -37,9 +36,9 @@ func New(ctx context.Context, cfg *config.Config) (*App, error) { } if cfg.IsGitHubConfigured() { - ghClient, err := github.NewAppClientWithBaseURL( + ghClient, err := client.NewAppClientWithBaseURL( cfg.GitHubAppID, - cfg.GitHubInstallID, + cfg.GitHubInstallationID, cfg.GitHubAppPrivateKey, cfg.GitHubOrg, cfg.GitHubBaseURL, @@ -86,7 +85,7 @@ type ScheduledEvent struct { } // ProcessScheduledEvent handles scheduled events (e.g., cron jobs). -// routes to appropriate handlers based on event action. +// 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) @@ -104,7 +103,7 @@ func (a *App) ProcessScheduledEvent(ctx context.Context, evt ScheduledEvent) err } // ProcessWebhook handles incoming GitHub webhook events. -// supports pull_request, team, and membership 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)) @@ -122,332 +121,6 @@ func (a *App) ProcessWebhook(ctx context.Context, payload []byte, eventType stri } } -// 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, a.Config.GitHubOrg); 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 -} - -// handleSlackTest sends test notifications to Slack with sample data. -// useful for verifying Slack connectivity and previewing message formats. -func (a *App) handleSlackTest(ctx context.Context) error { - if a.Notifier == nil { - return errors.New("slack is not configured") - } - - // test 1: PR bypass notification - if err := a.Notifier.NotifyPRBypass(ctx, fakePRComplianceResult(), "acme-corp/demo-repo"); err != nil { - return errors.Wrap(err, "failed to send test pr bypass notification") - } - a.Logger.Info("sent test pr bypass notification") - - // test 2: Okta sync notification - if err := a.Notifier.NotifyOktaSync(ctx, fakeOktaSyncReports(), "acme-corp"); err != nil { - return errors.Wrap(err, "failed to send test okta sync notification") - } - a.Logger.Info("sent test okta sync notification") - - // test 3: Orphaned users notification - if err := a.Notifier.NotifyOrphanedUsers(ctx, fakeOrphanedUsersReport()); err != nil { - return errors.Wrap(err, "failed to send test orphaned users notification") - } - a.Logger.Info("sent test orphaned users notification") - - return nil -} - -// fakePRComplianceResult returns sample PR compliance data for testing. -func fakePRComplianceResult() *github.PRComplianceResult { - prNumber := 42 - prTitle := "Add new authentication feature" - prURL := "https://github.com/acme-corp/demo-repo/pull/42" - mergedByLogin := "test-user" - - return &github.PRComplianceResult{ - PR: &gh.PullRequest{ - Number: &prNumber, - Title: &prTitle, - HTMLURL: &prURL, - MergedBy: &gh.User{ - Login: &mergedByLogin, - }, - }, - BaseBranch: "main", - UserHasBypass: true, - UserBypassReason: "repository admin", - Violations: []github.ComplianceViolation{ - {Type: "insufficient_reviews", Description: "required 2 approving reviews, had 0"}, - {Type: "missing_status_check", Description: "required check 'ci/build' did not pass"}, - }, - } -} - -// fakeOktaSyncReports returns sample Okta sync reports for testing. -func fakeOktaSyncReports() []*okta.SyncReport { - return []*okta.SyncReport{ - { - Rule: "engineering-team", - OktaGroup: "Engineering", - GitHubTeam: "engineering", - MembersAdded: []string{"alice", "bob"}, - MembersRemoved: []string{"charlie"}, - }, - { - Rule: "platform-team", - OktaGroup: "Platform", - GitHubTeam: "platform", - // no changes - }, - { - Rule: "security-team", - OktaGroup: "Security", - GitHubTeam: "security", - MembersAdded: []string{"dave"}, - MembersSkippedExternal: []string{"external-contractor"}, - MembersSkippedNoGHUsername: []string{"new-hire@example.com"}, - Errors: []string{"failed to fetch group members: rate limited"}, - }, - } -} - -// fakeOrphanedUsersReport returns sample orphaned users data for testing. -func fakeOrphanedUsersReport() *okta.OrphanedUsersReport { - return &okta.OrphanedUsersReport{ - OrphanedUsers: []string{"orphan-user-1", "orphan-user-2", "legacy-bot"}, - } -} - // StatusResponse contains application status and feature flags. type StatusResponse struct { Status string `json:"status"` diff --git a/internal/app/app_test.go b/internal/app/app_test.go index f76247c..66e31e9 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -7,7 +7,7 @@ import ( "testing" "github.com/cruxstack/github-ops-app/internal/config" - "github.com/cruxstack/github-ops-app/internal/github" + "github.com/cruxstack/github-ops-app/internal/github/client" "github.com/cruxstack/github-ops-app/internal/okta" ) @@ -161,7 +161,7 @@ func TestProcessScheduledEvent_UnknownAction(t *testing.T) { // verify fake data types match expected interfaces func TestFakeDataTypes(t *testing.T) { // ensure fake PR result is compatible with notifier - var _ *github.PRComplianceResult = fakePRComplianceResult() + var _ *client.PRComplianceResult = fakePRComplianceResult() // ensure fake sync reports are compatible with notifier var _ []*okta.SyncReport = fakeOktaSyncReports() diff --git a/internal/app/handlers.go b/internal/app/handlers.go new file mode 100644 index 0000000..6fa6c01 --- /dev/null +++ b/internal/app/handlers.go @@ -0,0 +1,257 @@ +package app + +import ( + "context" + "log/slog" + + "github.com/cockroachdb/errors" + internalerrors "github.com/cruxstack/github-ops-app/internal/errors" + "github.com/cruxstack/github-ops-app/internal/github/client" + "github.com/cruxstack/github-ops-app/internal/github/webhooks" + "github.com/cruxstack/github-ops-app/internal/okta" +) + +// 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, a.Config.GitHubOrg); 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 := webhooks.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.GitHubInstallationID { + installClient, err := client.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 := webhooks.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.shouldIgnoreWebhookChange(ctx, teamEvent) { + if a.Config.DebugEnabled { + a.Logger.Debug("ignoring team change from bot/app", + slog.String("action", teamEvent.Action), + slog.String("sender", teamEvent.GetSenderLogin())) + } + 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 := webhooks.ParseMembershipEvent(payload) + if err != nil { + return err + } + + if !membershipEvent.IsTeamScope() { + if a.Config.DebugEnabled { + a.Logger.Debug("membership event is not team scope, skipping") + } + return nil + } + + if !a.Config.IsOktaSyncEnabled() { + if a.Config.DebugEnabled { + a.Logger.Debug("okta sync not enabled, skipping membership webhook") + } + return nil + } + + if a.shouldIgnoreWebhookChange(ctx, membershipEvent) { + if a.Config.DebugEnabled { + a.Logger.Debug("ignoring membership change from bot/app", + slog.String("action", membershipEvent.Action), + slog.String("team", membershipEvent.GetTeamSlug()), + slog.String("sender", membershipEvent.GetSenderLogin())) + } + return nil + } + + a.Logger.Info("external membership change detected, triggering sync", + slog.String("action", membershipEvent.Action), + slog.String("team", membershipEvent.GetTeamSlug()), + slog.String("sender", membershipEvent.GetSenderLogin())) + + return a.handleOktaSync(ctx) +} + +// webhookSender provides sender information for webhook events. +type webhookSender interface { + GetSenderType() string + GetSenderLogin() string +} + +// shouldIgnoreWebhookChange checks if a webhook should be ignored. +// ignores changes made by bots or the GitHub App itself to prevent loops. +func (a *App) shouldIgnoreWebhookChange(ctx context.Context, event webhookSender) bool { + if event.GetSenderType() == "Bot" { + 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 + } + if event.GetSenderLogin() == appSlug+"[bot]" { + return true + } + } + + return false +} + +// handleSlackTest sends test notifications to Slack with sample data. +// useful for verifying Slack connectivity and previewing message formats. +func (a *App) handleSlackTest(ctx context.Context) error { + if a.Notifier == nil { + return errors.New("slack is not configured") + } + + // test 1: PR bypass notification + if err := a.Notifier.NotifyPRBypass(ctx, fakePRComplianceResult(), "acme-corp/demo-repo"); err != nil { + return errors.Wrap(err, "failed to send test pr bypass notification") + } + a.Logger.Info("sent test pr bypass notification") + + // test 2: Okta sync notification + if err := a.Notifier.NotifyOktaSync(ctx, fakeOktaSyncReports(), "acme-corp"); err != nil { + return errors.Wrap(err, "failed to send test okta sync notification") + } + a.Logger.Info("sent test okta sync notification") + + // test 3: Orphaned users notification + if err := a.Notifier.NotifyOrphanedUsers(ctx, fakeOrphanedUsersReport()); err != nil { + return errors.Wrap(err, "failed to send test orphaned users notification") + } + a.Logger.Info("sent test orphaned users notification") + + return nil +} diff --git a/internal/app/request.go b/internal/app/request.go index 53342cd..c7a1a37 100644 --- a/internal/app/request.go +++ b/internal/app/request.go @@ -1,4 +1,3 @@ -// Package app provides unified request/response handling for all runtimes. package app import ( @@ -7,7 +6,7 @@ import ( "log/slog" "strings" - "github.com/cruxstack/github-ops-app/internal/github" + "github.com/cruxstack/github-ops-app/internal/github/webhooks" ) // RequestType identifies the category of incoming request. @@ -133,7 +132,7 @@ func (a *App) handleWebhookRequest(ctx context.Context, req Request) Response { eventType := req.Headers["x-github-event"] signature := req.Headers["x-hub-signature-256"] - if err := github.ValidateWebhookSignature( + if err := webhooks.ValidateWebhookSignature( req.Body, signature, a.Config.GitHubWebhookSecret, diff --git a/internal/app/testdata.go b/internal/app/testdata.go new file mode 100644 index 0000000..6c264ef --- /dev/null +++ b/internal/app/testdata.go @@ -0,0 +1,68 @@ +package app + +import ( + "github.com/cruxstack/github-ops-app/internal/github/client" + "github.com/cruxstack/github-ops-app/internal/okta" + gh "github.com/google/go-github/v79/github" +) + +// fakePRComplianceResult returns sample PR compliance data for testing. +func fakePRComplianceResult() *client.PRComplianceResult { + prNumber := 42 + prTitle := "Add new authentication feature" + prURL := "https://github.com/acme-corp/demo-repo/pull/42" + mergedByLogin := "test-user" + + return &client.PRComplianceResult{ + PR: &gh.PullRequest{ + Number: &prNumber, + Title: &prTitle, + HTMLURL: &prURL, + MergedBy: &gh.User{ + Login: &mergedByLogin, + }, + }, + BaseBranch: "main", + UserHasBypass: true, + UserBypassReason: "repository admin", + Violations: []client.ComplianceViolation{ + {Type: "insufficient_reviews", Description: "required 2 approving reviews, had 0"}, + {Type: "missing_status_check", Description: "required check 'ci/build' did not pass"}, + }, + } +} + +// fakeOktaSyncReports returns sample Okta sync reports for testing. +func fakeOktaSyncReports() []*okta.SyncReport { + return []*okta.SyncReport{ + { + Rule: "engineering-team", + OktaGroup: "Engineering", + GitHubTeam: "engineering", + MembersAdded: []string{"alice", "bob"}, + MembersRemoved: []string{"charlie"}, + }, + { + Rule: "platform-team", + OktaGroup: "Platform", + GitHubTeam: "platform", + // no changes + }, + { + Rule: "security-team", + OktaGroup: "Security", + GitHubTeam: "security", + MembersAdded: []string{"dave"}, + MembersSkippedExternal: []string{"external-contractor"}, + MembersSkippedNoGHUsername: []string{"new-hire@example.com"}, + Errors: []string{"failed to fetch group members: rate limited"}, + }, + } +} + +// fakeOrphanedUsersReport returns sample orphaned users data for testing. +func fakeOrphanedUsersReport() *okta.OrphanedUsersReport { + return &okta.OrphanedUsersReport{ + OrphanedUsers: []string{"orphan-user-1", "orphan-user-2", "legacy-bot"}, + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 414634a..c088026 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,37 +15,41 @@ import ( "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" + "github.com/cruxstack/github-ops-app/internal/types" ) // Config holds all application configuration loaded from environment // variables. type Config struct { + // General DebugEnabled bool + BasePath string - GitHubOrg string - GitHubWebhookSecret string - GitHubBaseURL string - - GitHubAppID int64 - GitHubAppPrivateKey []byte - GitHubInstallID int64 - - OktaDomain string - OktaClientID string - OktaPrivateKey []byte - OktaPrivateKeyID string - OktaScopes []string - OktaBaseURL string - OktaSyncRules []okta.SyncRule - OktaGitHubUserField string - OktaSyncSafetyThreshold float64 + // GitHub App + GitHubOrg string + GitHubAppID int64 + GitHubAppPrivateKey []byte + GitHubInstallationID int64 + GitHubWebhookSecret string + GitHubBaseURL string + // PR Compliance PRComplianceEnabled bool PRMonitoredBranches []string + // Okta + OktaDomain string + OktaClientID string + OktaPrivateKey []byte + OktaPrivateKeyID string + OktaScopes []string + OktaBaseURL string + OktaGitHubUserField string + OktaSyncRules []types.SyncRule + OktaSyncSafetyThreshold float64 OktaOrphanedUserNotifications bool + // Slack SlackEnabled bool SlackToken string SlackChannel string @@ -53,8 +57,6 @@ type Config struct { SlackChannelOktaSync string SlackChannelOrphanedUsers string SlackAPIURL string - - BasePath string } var ( @@ -205,7 +207,7 @@ func NewConfigWithContext(ctx context.Context) (*Config, error) { if err != nil { return nil, errors.Wrapf(err, "failed to parse APP_GITHUB_INSTALLATION_ID '%s'", installIDStr) } - cfg.GitHubInstallID = installID + cfg.GitHubInstallationID = installID } if privateKeyPath := os.Getenv("APP_OKTA_PRIVATE_KEY_PATH"); privateKeyPath != "" { @@ -229,7 +231,7 @@ func NewConfigWithContext(ctx context.Context) (*Config, error) { } cfg.OktaScopes = scopes } else { - cfg.OktaScopes = okta.DefaultScopes + cfg.OktaScopes = []string{"okta.groups.read", "okta.users.read"} } prComplianceEnabled, _ := strconv.ParseBool(os.Getenv("APP_PR_COMPLIANCE_ENABLED")) @@ -248,7 +250,7 @@ func NewConfigWithContext(ctx context.Context) (*Config, error) { syncRulesJSON := os.Getenv("APP_OKTA_SYNC_RULES") if syncRulesJSON != "" { - var rules []okta.SyncRule + var rules []types.SyncRule if err := json.Unmarshal([]byte(syncRulesJSON), &rules); err != nil { return nil, errors.Wrap(err, "failed to parse APP_OKTA_SYNC_RULES") } @@ -313,7 +315,7 @@ func (c *Config) IsGitHubConfigured() bool { return c.GitHubOrg != "" && c.GitHubAppID != 0 && len(c.GitHubAppPrivateKey) > 0 && - c.GitHubInstallID != 0 + c.GitHubInstallationID != 0 } // ShouldMonitorBranch returns true if the given branch should be monitored @@ -334,31 +336,35 @@ func (c *Config) ShouldMonitorBranch(branch string) bool { // 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"` - OktaPrivateKeyID string `json:"okta_private_key_id"` - 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"` - + // General + DebugEnabled bool `json:"debug_enabled"` + BasePath string `json:"base_path"` + + // GitHub App + GitHubOrg string `json:"github_org"` + GitHubAppID int64 `json:"github_app_id"` + GitHubAppPrivateKey string `json:"github_app_private_key"` + GitHubInstallationID int64 `json:"github_installation_id"` + GitHubWebhookSecret string `json:"github_webhook_secret"` + GitHubBaseURL string `json:"github_base_url"` + + // PR Compliance PRComplianceEnabled bool `json:"pr_compliance_enabled"` PRMonitoredBranches []string `json:"pr_monitored_branches"` - OktaOrphanedUserNotifications bool `json:"okta_orphaned_user_notifications"` - + // Okta + OktaDomain string `json:"okta_domain"` + OktaClientID string `json:"okta_client_id"` + OktaPrivateKey string `json:"okta_private_key"` + OktaPrivateKeyID string `json:"okta_private_key_id"` + OktaScopes []string `json:"okta_scopes"` + OktaBaseURL string `json:"okta_base_url"` + OktaGitHubUserField string `json:"okta_github_user_field"` + OktaSyncRules []types.SyncRule `json:"okta_sync_rules"` + OktaSyncSafetyThreshold float64 `json:"okta_sync_safety_threshold"` + OktaOrphanedUserNotifications bool `json:"okta_orphaned_user_notifications"` + + // Slack SlackEnabled bool `json:"slack_enabled"` SlackToken string `json:"slack_token"` SlackChannel string `json:"slack_channel"` @@ -366,8 +372,6 @@ type RedactedConfig struct { SlackChannelOktaSync string `json:"slack_channel_okta_sync"` SlackChannelOrphanedUsers string `json:"slack_channel_orphaned_users"` SlackAPIURL string `json:"slack_api_url"` - - BasePath string `json:"base_path"` } // Redacted returns a copy of the config with secrets redacted. @@ -387,32 +391,41 @@ func (c *Config) Redacted() RedactedConfig { } 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, + // General + DebugEnabled: c.DebugEnabled, + BasePath: c.BasePath, + + // GitHub App + GitHubOrg: c.GitHubOrg, + GitHubAppID: c.GitHubAppID, + GitHubAppPrivateKey: redactBytes(c.GitHubAppPrivateKey), + GitHubInstallationID: c.GitHubInstallationID, + GitHubWebhookSecret: redact(c.GitHubWebhookSecret), + GitHubBaseURL: c.GitHubBaseURL, + + // PR Compliance + PRComplianceEnabled: c.PRComplianceEnabled, + PRMonitoredBranches: c.PRMonitoredBranches, + + // Okta OktaDomain: c.OktaDomain, OktaClientID: redact(c.OktaClientID), OktaPrivateKey: redactBytes(c.OktaPrivateKey), OktaPrivateKeyID: c.OktaPrivateKeyID, OktaScopes: c.OktaScopes, OktaBaseURL: c.OktaBaseURL, - OktaSyncRules: c.OktaSyncRules, OktaGitHubUserField: c.OktaGitHubUserField, + OktaSyncRules: c.OktaSyncRules, OktaSyncSafetyThreshold: c.OktaSyncSafetyThreshold, - PRComplianceEnabled: c.PRComplianceEnabled, - PRMonitoredBranches: c.PRMonitoredBranches, OktaOrphanedUserNotifications: c.OktaOrphanedUserNotifications, - SlackEnabled: c.SlackEnabled, - SlackToken: redact(c.SlackToken), - SlackChannel: c.SlackChannel, - SlackChannelPRBypass: c.SlackChannelPRBypass, - SlackChannelOktaSync: c.SlackChannelOktaSync, - SlackChannelOrphanedUsers: c.SlackChannelOrphanedUsers, - SlackAPIURL: c.SlackAPIURL, - BasePath: c.BasePath, + + // Slack + SlackEnabled: c.SlackEnabled, + SlackToken: redact(c.SlackToken), + SlackChannel: c.SlackChannel, + SlackChannelPRBypass: c.SlackChannelPRBypass, + SlackChannelOktaSync: c.SlackChannelOktaSync, + SlackChannelOrphanedUsers: c.SlackChannelOrphanedUsers, + SlackAPIURL: c.SlackAPIURL, } } diff --git a/internal/github/client.go b/internal/github/client/client.go similarity index 97% rename from internal/github/client.go rename to internal/github/client/client.go index 6d05f39..fd3e7ce 100644 --- a/internal/github/client.go +++ b/internal/github/client/client.go @@ -1,7 +1,7 @@ -// Package github provides GitHub API client with App authentication. -// handles JWT generation, installation token management, and automatic token -// refresh. -package github +// Package client provides a GitHub API client with App authentication. +// Handles JWT generation, installation token management, PR compliance +// checking, and team membership synchronization. +package client import ( "context" diff --git a/internal/github/pr.go b/internal/github/client/pr.go similarity index 96% rename from internal/github/pr.go rename to internal/github/client/pr.go index 495036d..a0d745f 100644 --- a/internal/github/pr.go +++ b/internal/github/client/pr.go @@ -1,6 +1,4 @@ -// Package github provides PR compliance checking against branch protection -// rules. -package github +package client import ( "context" @@ -189,11 +187,11 @@ func (c *Client) checkUserBypassPermission(ctx context.Context, owner, repo stri } if permissionLevel.Permission != nil { - perm := *permissionLevel.Permission - if perm == "admin" { + switch perm := *permissionLevel.Permission; perm { + case "admin": result.UserHasBypass = true result.UserBypassReason = "repository admin" - } else if perm == "maintain" { + case "maintain": result.UserHasBypass = true result.UserBypassReason = "repository maintainer" } diff --git a/internal/github/teams.go b/internal/github/client/teams.go similarity index 98% rename from internal/github/teams.go rename to internal/github/client/teams.go index 93c7c44..b2a8657 100644 --- a/internal/github/teams.go +++ b/internal/github/client/teams.go @@ -1,5 +1,4 @@ -// Package github provides GitHub team management and membership sync. -package github +package client import ( "context" diff --git a/internal/github/webhook.go b/internal/github/webhooks/webhooks.go similarity index 98% rename from internal/github/webhook.go rename to internal/github/webhooks/webhooks.go index 46c0e9a..5d5d6c5 100644 --- a/internal/github/webhook.go +++ b/internal/github/webhooks/webhooks.go @@ -1,5 +1,6 @@ -// Package github provides webhook event parsing and signature validation. -package github +// Package webhooks provides GitHub webhook event parsing and signature +// validation. Supports pull_request, team, and membership event types. +package webhooks import ( "crypto/hmac" diff --git a/internal/notifiers/slack.go b/internal/notifiers/slack.go index 82645a5..0cca41b 100644 --- a/internal/notifiers/slack.go +++ b/internal/notifiers/slack.go @@ -1,4 +1,4 @@ -// Package notifiers provides Slack notification formatting and sending. +// Package notifiers provides Slack notification formatting and delivery. package notifiers import ( diff --git a/internal/notifiers/github_slack.go b/internal/notifiers/slack_messages.go similarity index 93% rename from internal/notifiers/github_slack.go rename to internal/notifiers/slack_messages.go index 489947a..64b7b19 100644 --- a/internal/notifiers/github_slack.go +++ b/internal/notifiers/slack_messages.go @@ -1,22 +1,21 @@ -// 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/cockroachdb/errors" + internalerrors "github.com/cruxstack/github-ops-app/internal/errors" + "github.com/cruxstack/github-ops-app/internal/github/client" "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 { +func (s *SlackNotifier) NotifyPRBypass(ctx context.Context, result *client.PRComplianceResult, repoFullName string) error { if result.PR == nil { - return fmt.Errorf("%w: pr result missing", errors.ErrMissingPRData) + return errors.Wrap(internalerrors.ErrMissingPRData, "pr result missing") } prURL := "" @@ -77,7 +76,7 @@ func (s *SlackNotifier) NotifyPRBypass(ctx context.Context, result *github.PRCom ) if err != nil { - return fmt.Errorf("failed to post pr bypass notification to slack: %w", err) + return errors.Wrap(err, "failed to post pr bypass notification to slack") } return nil @@ -225,7 +224,7 @@ func (s *SlackNotifier) NotifyOktaSync(ctx context.Context, reports []*okta.Sync ) if err != nil { - return fmt.Errorf("failed to post okta sync notification to slack: %w", err) + return errors.Wrap(err, "failed to post okta sync notification to slack") } return nil @@ -274,7 +273,7 @@ func (s *SlackNotifier) NotifyOrphanedUsers(ctx context.Context, report *okta.Or ) if err != nil { - return fmt.Errorf("failed to post orphaned users notification to slack: %w", err) + return errors.Wrap(err, "failed to post orphaned users notification to slack") } return nil diff --git a/internal/okta/client.go b/internal/okta/client.go index 3667bdf..3adba78 100644 --- a/internal/okta/client.go +++ b/internal/okta/client.go @@ -1,5 +1,5 @@ -// Package okta provides Okta API client with OAuth 2.0 private key -// authentication. +// Package okta provides Okta API client and group synchronization to GitHub +// teams. Uses OAuth 2.0 with private key authentication. package okta import ( diff --git a/internal/okta/groups.go b/internal/okta/groups.go index 6396ec8..c97aa65 100644 --- a/internal/okta/groups.go +++ b/internal/okta/groups.go @@ -1,4 +1,3 @@ -// Package okta provides Okta group querying and filtering. package okta import ( diff --git a/internal/okta/sync.go b/internal/okta/sync.go index 1333997..a2f4794 100644 --- a/internal/okta/sync.go +++ b/internal/okta/sync.go @@ -1,4 +1,3 @@ -// Package okta provides Okta group to GitHub team synchronization. package okta import ( @@ -9,43 +8,12 @@ import ( "strings" "github.com/cockroachdb/errors" - "github.com/cruxstack/github-ops-app/internal/github" + "github.com/cruxstack/github-ops-app/internal/github/client" + "github.com/cruxstack/github-ops-app/internal/types" ) -// SyncRule defines how to sync Okta groups to GitHub teams. -type SyncRule struct { - Name string `json:"name"` - Enabled *bool `json:"enabled,omitempty"` - 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,omitempty"` - CreateTeamIfMissing bool `json:"create_team_if_missing"` - TeamPrivacy string `json:"team_privacy,omitempty"` -} - -// IsEnabled returns true if the rule is enabled (defaults to true). -func (r SyncRule) IsEnabled() bool { - return r.Enabled == nil || *r.Enabled -} - -// ShouldSyncMembers returns true if members should be synced (defaults to true). -func (r SyncRule) ShouldSyncMembers() bool { - return r.SyncMembers == nil || *r.SyncMembers -} - -// GetName returns the rule name, defaulting to GitHubTeamName if not set. -func (r SyncRule) GetName() string { - if r.Name != "" { - return r.Name - } - if r.GitHubTeamName != "" { - return r.GitHubTeamName - } - return r.OktaGroupName -} +// SyncRule is an alias to types.SyncRule for convenience. +type SyncRule = types.SyncRule // SyncReport contains the results of syncing a single Okta group to GitHub // team. @@ -79,14 +47,14 @@ func (r *SyncReport) HasChanges() bool { // Syncer coordinates synchronization of Okta groups to GitHub teams. type Syncer struct { oktaClient *Client - githubClient *github.Client + githubClient *client.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 { +func NewSyncer(oktaClient *Client, githubClient *client.Client, rules []SyncRule, safetyThreshold float64, logger *slog.Logger) *Syncer { return &Syncer{ oktaClient: oktaClient, githubClient: githubClient, diff --git a/internal/types/sync.go b/internal/types/sync.go new file mode 100644 index 0000000..303f1c6 --- /dev/null +++ b/internal/types/sync.go @@ -0,0 +1,38 @@ +// Package types provides shared type definitions used across packages. +package types + +// SyncRule defines how to sync Okta groups to GitHub teams. +type SyncRule struct { + Name string `json:"name"` + Enabled *bool `json:"enabled,omitempty"` + 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,omitempty"` + CreateTeamIfMissing bool `json:"create_team_if_missing"` + TeamPrivacy string `json:"team_privacy,omitempty"` +} + +// IsEnabled returns true if the rule is enabled (defaults to true). +func (r SyncRule) IsEnabled() bool { + return r.Enabled == nil || *r.Enabled +} + +// ShouldSyncMembers returns true if members should be synced (defaults to +// true). +func (r SyncRule) ShouldSyncMembers() bool { + return r.SyncMembers == nil || *r.SyncMembers +} + +// GetName returns the rule name, defaulting to GitHubTeamName if not set. +func (r SyncRule) GetName() string { + if r.Name != "" { + return r.Name + } + if r.GitHubTeamName != "" { + return r.GitHubTeamName + } + return r.OktaGroupName +}