Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 7 additions & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 32 additions & 20 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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,
}
Expand Down
9 changes: 6 additions & 3 deletions internal/notifiers/github_slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
Expand Down Expand Up @@ -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),
)
Expand Down Expand Up @@ -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),
)
Expand Down
32 changes: 25 additions & 7 deletions internal/notifiers/slack.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
76 changes: 76 additions & 0 deletions internal/notifiers/slack_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}