diff --git a/.env.example b/.env.example index a3868a7..bb03817 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,10 @@ APP_OKTA_SYNC_RULES=[{"name":"sync-eng","enabled":true,"okta_group_pattern":"^gi # slack configuration (optional) APP_SLACK_TOKEN=xoxb-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx APP_SLACK_CHANNEL=C01234ABCDE +# optional: per-notification-type channels (fall back to APP_SLACK_CHANNEL) +# APP_SLACK_CHANNEL_PR_BYPASS=C01234ABCDE +# APP_SLACK_CHANNEL_OKTA_SYNC=C01234ABCDE +# APP_SLACK_CHANNEL_ORPHANED_USERS=C01234ABCDE # api gateway base path (optional, for lambda deployments with stage prefix) # APP_BASE_PATH=v1 diff --git a/internal/app/app.go b/internal/app/app.go index d13c66f..2125b1c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -67,7 +67,13 @@ func New(ctx context.Context, cfg *config.Config) (*App, error) { } if cfg.SlackEnabled { - app.Notifier = notifiers.NewSlackNotifierWithAPIURL(cfg.SlackToken, cfg.SlackChannel, cfg.SlackAPIURL) + channels := notifiers.SlackChannels{ + Default: cfg.SlackChannel, + PRBypass: cfg.SlackChannelPRBypass, + OktaSync: cfg.SlackChannelOktaSync, + OrphanedUsers: cfg.SlackChannelOrphanedUsers, + } + app.Notifier = notifiers.NewSlackNotifierWithAPIURL(cfg.SlackToken, channels, cfg.SlackAPIURL) } return app, nil diff --git a/internal/config/config.go b/internal/config/config.go index 0660877..414634a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,10 +46,13 @@ type Config struct { OktaOrphanedUserNotifications bool - SlackEnabled bool - SlackToken string - SlackChannel string - SlackAPIURL string + SlackEnabled bool + SlackToken string + SlackChannel string + SlackChannelPRBypass string + SlackChannelOktaSync string + SlackChannelOrphanedUsers string + SlackAPIURL string BasePath string } @@ -160,18 +163,21 @@ func NewConfigWithContext(ctx context.Context) (*Config, error) { } 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"), + 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"), + SlackChannelPRBypass: os.Getenv("APP_SLACK_CHANNEL_PR_BYPASS"), + SlackChannelOktaSync: os.Getenv("APP_SLACK_CHANNEL_OKTA_SYNC"), + SlackChannelOrphanedUsers: os.Getenv("APP_SLACK_CHANNEL_ORPHANED_USERS"), + SlackAPIURL: os.Getenv("APP_SLACK_API_URL"), } if appIDStr := os.Getenv("APP_GITHUB_APP_ID"); appIDStr != "" { @@ -353,10 +359,13 @@ type RedactedConfig struct { 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"` + SlackEnabled bool `json:"slack_enabled"` + SlackToken string `json:"slack_token"` + SlackChannel string `json:"slack_channel"` + SlackChannelPRBypass string `json:"slack_channel_pr_bypass"` + 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"` } @@ -400,6 +409,9 @@ func (c *Config) Redacted() RedactedConfig { 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, } diff --git a/internal/notifiers/github_slack.go b/internal/notifiers/github_slack.go index 6bb7af4..489947a 100644 --- a/internal/notifiers/github_slack.go +++ b/internal/notifiers/github_slack.go @@ -68,9 +68,10 @@ func (s *SlackNotifier) NotifyPRBypass(ctx context.Context, result *github.PRCom )) } + channel := s.channelFor(s.channels.PRBypass) _, _, err := s.client.PostMessageContext( ctx, - s.channel, + channel, slack.MsgOptionBlocks(blocks...), slack.MsgOptionText(fmt.Sprintf("branch protection bypassed on pr #%d", prNumber), false), ) @@ -215,9 +216,10 @@ func (s *SlackNotifier) NotifyOktaSync(ctx context.Context, reports []*okta.Sync )) } + channel := s.channelFor(s.channels.OktaSync) _, _, err := s.client.PostMessageContext( ctx, - s.channel, + channel, slack.MsgOptionBlocks(blocks...), slack.MsgOptionText(fmt.Sprintf("okta sync: %d rules, +%d/-%d members", len(reports), totalAdded, totalRemoved), false), ) @@ -263,9 +265,10 @@ func (s *SlackNotifier) NotifyOrphanedUsers(ctx context.Context, report *okta.Or slack.NewTextBlockObject("mrkdwn", "_These users may need to be added to Okta groups or removed from the organization._", false, false), )) + channel := s.channelFor(s.channels.OrphanedUsers) _, _, err := s.client.PostMessageContext( ctx, - s.channel, + channel, slack.MsgOptionBlocks(blocks...), slack.MsgOptionText(fmt.Sprintf("orphaned github users detected: %d users", len(report.OrphanedUsers)), false), ) diff --git a/internal/notifiers/slack.go b/internal/notifiers/slack.go index 656455d..82645a5 100644 --- a/internal/notifiers/slack.go +++ b/internal/notifiers/slack.go @@ -5,26 +5,44 @@ import ( "github.com/slack-go/slack" ) +// SlackChannels holds channel IDs for different notification types. +// empty values fall back to the default channel. +type SlackChannels struct { + Default string + PRBypass string + OktaSync string + OrphanedUsers string +} + // SlackNotifier sends formatted messages to Slack channels. type SlackNotifier struct { - client *slack.Client - channel string + client *slack.Client + channels SlackChannels } // NewSlackNotifier creates a Slack notifier with default API URL. -func NewSlackNotifier(token, channel string) *SlackNotifier { - return NewSlackNotifierWithAPIURL(token, channel, "") +func NewSlackNotifier(token string, channels SlackChannels) *SlackNotifier { + return NewSlackNotifierWithAPIURL(token, channels, "") } // NewSlackNotifierWithAPIURL creates a Slack notifier with custom API URL. // useful for testing with mock servers. -func NewSlackNotifierWithAPIURL(token, channel, apiURL string) *SlackNotifier { +func NewSlackNotifierWithAPIURL(token string, channels SlackChannels, apiURL string) *SlackNotifier { var opts []slack.Option if apiURL != "" { opts = append(opts, slack.OptionAPIURL(apiURL)) } return &SlackNotifier{ - client: slack.New(token, opts...), - channel: channel, + client: slack.New(token, opts...), + channels: channels, + } +} + +// channelFor returns the channel for a notification type, falling back to +// default if the type-specific channel is empty. +func (s *SlackNotifier) channelFor(typeChannel string) string { + if typeChannel != "" { + return typeChannel } + return s.channels.Default } diff --git a/internal/notifiers/slack_test.go b/internal/notifiers/slack_test.go new file mode 100644 index 0000000..7d9c5ca --- /dev/null +++ b/internal/notifiers/slack_test.go @@ -0,0 +1,76 @@ +package notifiers + +import "testing" + +func TestChannelFor(t *testing.T) { + defaultChannel := "C_DEFAULT" + prBypassChannel := "C_PR_BYPASS" + oktaSyncChannel := "C_OKTA_SYNC" + + tests := []struct { + name string + channels SlackChannels + typeChannel string + want string + }{ + { + name: "uses type-specific channel when set", + channels: SlackChannels{ + Default: defaultChannel, + PRBypass: prBypassChannel, + }, + typeChannel: prBypassChannel, + want: prBypassChannel, + }, + { + name: "falls back to default when type-specific is empty", + channels: SlackChannels{ + Default: defaultChannel, + PRBypass: "", + }, + typeChannel: "", + want: defaultChannel, + }, + { + name: "all channels set independently", + channels: SlackChannels{ + Default: defaultChannel, + PRBypass: prBypassChannel, + OktaSync: oktaSyncChannel, + OrphanedUsers: "C_ORPHANED", + }, + typeChannel: oktaSyncChannel, + want: oktaSyncChannel, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + n := &SlackNotifier{channels: tt.channels} + got := n.channelFor(tt.typeChannel) + if got != tt.want { + t.Errorf("channelFor() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestSlackChannels_AllFallbackToDefault(t *testing.T) { + defaultChannel := "C_DEFAULT" + n := &SlackNotifier{ + channels: SlackChannels{ + Default: defaultChannel, + }, + } + + // all type-specific channels should fall back to default + if got := n.channelFor(n.channels.PRBypass); got != defaultChannel { + t.Errorf("PRBypass channel = %q, want %q", got, defaultChannel) + } + if got := n.channelFor(n.channels.OktaSync); got != defaultChannel { + t.Errorf("OktaSync channel = %q, want %q", got, defaultChannel) + } + if got := n.channelFor(n.channels.OrphanedUsers); got != defaultChannel { + t.Errorf("OrphanedUsers channel = %q, want %q", got, defaultChannel) + } +}