Skip to content

Commit e2b746b

Browse files
pharaujoSuperQ
andauthored
Slack app support (#4211)
* Slack app support While it's possible to configure Slack app bot tokens using a combination of http_configs authorization credentials and setting the Slack API URL to the a specific endpoint, doing so at a global level leaks the token to any other notification receiver configured, as http_configs are not specific to notifiers. Slack has also restricted webhook URLs to only be able to post to a single channel (with the legacy webhooks being [marked as deprecated and not recommended][1]), which reduces their usefulness when set at the global level. This PR adds a way of easily setting Slack App bot tokens at the global level, as well as overriding at the individual receiver level, while keeping compatibility with existing configurations. The decision to have a separate config field for the URL was to be able to provide a default API URL for Slack apps as well as differentiate when a webhook url is provided. Ideally we'd change the `slack_api_url` to be `slack_webhook_url` so as to avoid confusion, but that would be an unnecessary breaking change. More context in issue #2513 [1]: https://api.slack.com/legacy/custom-integrations/messaging/webhooks Signed-off-by: Pedro Araujo <[email protected]> * Support transition from workaround to new config The [Slack app support issue][1] suggested setting the slack API URL to the `chat.PostMessage` endpoint instead of a webhook URL, and so people migrating from this workaround to the the new configuration might encounter a situation where they want to set the `slack_app_token` at the global level while still retaining the `slack_api_url` while dynamically configured receivers (such as those set by prometheus operator) are migrated. Signed-off-by: Pedro Araujo <[email protected]> [1]: #2513 * Allow override from receiver-level webhook url Signed-off-by: Pedro Araujo <[email protected]> * Fix linter errors Fixed by running `make common-lint-fix` Signed-off-by: Pedro Araujo <[email protected]> --------- Signed-off-by: Pedro Araujo <[email protected]> Co-authored-by: Ben Kochie <[email protected]>
1 parent 2da9906 commit e2b746b

File tree

8 files changed

+263
-11
lines changed

8 files changed

+263
-11
lines changed

config/config.go

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -380,10 +380,23 @@ func (c *Config) UnmarshalYAML(unmarshal func(any) error) error {
380380
*c.Global = DefaultGlobalConfig()
381381
}
382382

383+
if c.Global.SlackAppToken != "" && len(c.Global.SlackAppTokenFile) > 0 {
384+
return errors.New("at most one of slack_app_token & slack_app_token_file must be configured")
385+
}
386+
383387
if c.Global.SlackAPIURL != nil && len(c.Global.SlackAPIURLFile) > 0 {
384388
return errors.New("at most one of slack_api_url & slack_api_url_file must be configured")
385389
}
386390

391+
if (c.Global.SlackAppToken != "" || len(c.Global.SlackAppTokenFile) > 0) && (c.Global.SlackAPIURL != nil || len(c.Global.SlackAPIURLFile) > 0) {
392+
// Support transition from workaround suggested in https://github.com/prometheus/alertmanager/issues/2513,
393+
// where users might set `slack_api_url` at the top level and then have `http_config` with individual
394+
// bearer tokens in the receivers.
395+
if c.Global.SlackAPIURL.String() != c.Global.SlackAppURL.String() {
396+
return errors.New("at most one of slack_app_token/slack_app_token_file & slack_api_url/slack_api_url_file must be configured")
397+
}
398+
}
399+
387400
if c.Global.OpsGenieAPIKey != "" && len(c.Global.OpsGenieAPIKeyFile) > 0 {
388401
return errors.New("at most one of opsgenie_api_key & opsgenie_api_key_file must be configured")
389402
}
@@ -453,16 +466,40 @@ func (c *Config) UnmarshalYAML(unmarshal func(any) error) error {
453466
}
454467
}
455468
for _, sc := range rcv.SlackConfigs {
456-
if sc.HTTPConfig == nil {
457-
sc.HTTPConfig = c.Global.HTTPConfig
469+
if sc.AppURL == nil {
470+
if c.Global.SlackAppURL == nil {
471+
return errors.New("no global Slack App URL set")
472+
}
473+
sc.AppURL = c.Global.SlackAppURL
474+
}
475+
// we only want to set the app token from global if there's no local authorization or webhook url
476+
if sc.AppToken == "" && len(sc.AppTokenFile) == 0 && (sc.HTTPConfig == nil || sc.HTTPConfig.Authorization == nil) && sc.APIURL == nil {
477+
sc.AppToken = c.Global.SlackAppToken
478+
sc.AppTokenFile = c.Global.SlackAppTokenFile
458479
}
459480
if sc.APIURL == nil && len(sc.APIURLFile) == 0 {
460-
if c.Global.SlackAPIURL == nil && len(c.Global.SlackAPIURLFile) == 0 {
461-
return errors.New("no global Slack API URL set either inline or in a file")
462-
}
463481
sc.APIURL = c.Global.SlackAPIURL
464482
sc.APIURLFile = c.Global.SlackAPIURLFile
465483
}
484+
if sc.APIURL == nil && len(sc.APIURLFile) == 0 && sc.AppToken == "" && len(sc.AppTokenFile) == 0 {
485+
return errors.New("no Slack API URL nor App token set either inline or in a file")
486+
}
487+
if sc.HTTPConfig == nil {
488+
// we don't want to change the global http config when setting the receiver's http config, do we do a copy
489+
httpconfig := *c.Global.HTTPConfig
490+
sc.HTTPConfig = &httpconfig
491+
}
492+
if sc.AppToken != "" || len(sc.AppTokenFile) != 0 {
493+
if sc.HTTPConfig.Authorization != nil {
494+
return errors.New("http authorization can't be set when using Slack App tokens")
495+
}
496+
sc.HTTPConfig.Authorization = &commoncfg.Authorization{
497+
Type: "Bearer",
498+
Credentials: commoncfg.Secret(sc.AppToken),
499+
CredentialsFile: sc.AppTokenFile,
500+
}
501+
sc.APIURL = (*SecretURL)(sc.AppURL)
502+
}
466503
}
467504
for _, poc := range rcv.PushoverConfigs {
468505
if poc.HTTPConfig == nil {
@@ -749,6 +786,7 @@ func DefaultGlobalConfig() GlobalConfig {
749786
TelegramAPIUrl: mustParseURL("https://api.telegram.org"),
750787
WebexAPIURL: mustParseURL("https://webexapis.com/v1/messages"),
751788
RocketchatAPIURL: mustParseURL("https://open.rocket.chat/"),
789+
SlackAppURL: mustParseURL("https://slack.com/api/chat.postMessage"),
752790
}
753791
}
754792

@@ -863,6 +901,9 @@ type GlobalConfig struct {
863901
SMTPTLSConfig *commoncfg.TLSConfig `yaml:"smtp_tls_config,omitempty" json:"smtp_tls_config,omitempty"`
864902
SlackAPIURL *SecretURL `yaml:"slack_api_url,omitempty" json:"slack_api_url,omitempty"`
865903
SlackAPIURLFile string `yaml:"slack_api_url_file,omitempty" json:"slack_api_url_file,omitempty"`
904+
SlackAppToken Secret `yaml:"slack_app_token,omitempty" json:"slack_app_token,omitempty"`
905+
SlackAppTokenFile string `yaml:"slack_app_token_file,omitempty" json:"slack_app_token_file,omitempty"`
906+
SlackAppURL *URL `yaml:"slack_app_url,omitempty" json:"slack_app_url,omitempty"`
866907
PagerdutyURL *URL `yaml:"pagerduty_url,omitempty" json:"pagerduty_url,omitempty"`
867908
OpsGenieAPIURL *URL `yaml:"opsgenie_api_url,omitempty" json:"opsgenie_api_url,omitempty"`
868909
OpsGenieAPIKey Secret `yaml:"opsgenie_api_key,omitempty" json:"opsgenie_api_key,omitempty"`

config/config_test.go

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,7 @@ func TestEmptyFieldsAndRegex(t *testing.T) {
934934
InsecureSkipVerify: false,
935935
},
936936
SlackAPIURL: (*SecretURL)(mustParseURL("http://slack.example.com/")),
937+
SlackAppURL: mustParseURL("https://slack.com/api/chat.postMessage"),
937938
SMTPRequireTLS: true,
938939
PagerdutyURL: mustParseURL("https://events.pagerduty.com/v2/enqueue"),
939940
OpsGenieAPIURL: mustParseURL("https://api.opsgenie.com/"),
@@ -1212,13 +1213,116 @@ func TestSlackBothAPIURLAndFile(t *testing.T) {
12121213
}
12131214
}
12141215

1216+
func TestSlackBothAppTokenAndFile(t *testing.T) {
1217+
_, err := LoadFile("testdata/conf.slack-both-file-and-token.yml")
1218+
if err == nil {
1219+
t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.slack-both-file-and-token.yml", err)
1220+
}
1221+
if err.Error() != "at most one of slack_app_token & slack_app_token_file must be configured" {
1222+
t.Errorf("Expected: %s\nGot: %s", "at most one of slack_app_token & slack_app_token_file must be configured", err.Error())
1223+
}
1224+
}
1225+
1226+
func TestSlackBothAppTokenAndAPIURL(t *testing.T) {
1227+
_, err := LoadFile("testdata/conf.slack-both-url-and-token.yml")
1228+
if err == nil {
1229+
t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.slack-both-url-and-token.yml", err)
1230+
}
1231+
if err.Error() != "at most one of slack_app_token/slack_app_token_file & slack_api_url/slack_api_url_file must be configured" {
1232+
t.Errorf("Expected: %s\nGot: %s", "at most one of slack_app_token/slack_app_token_file & slack_api_url/slack_api_url_file must be configured", err.Error())
1233+
}
1234+
}
1235+
1236+
func TestSlackGlobalAppToken(t *testing.T) {
1237+
conf, err := LoadFile("testdata/conf.slack-default-app-token.yml")
1238+
if err != nil {
1239+
t.Fatalf("Error parsing %s: %s", "testdata/conf.slack-default-app-token.yml", err)
1240+
}
1241+
1242+
// no override
1243+
defaultToken := conf.Global.SlackAppToken
1244+
firstAuth := commoncfg.Authorization{
1245+
Type: "Bearer",
1246+
Credentials: commoncfg.Secret(defaultToken),
1247+
}
1248+
firstConfig := conf.Receivers[0].SlackConfigs[0]
1249+
if firstConfig.AppToken != defaultToken {
1250+
t.Fatalf("Invalid Slack App token: %s\nExpected: %s", firstConfig.AppToken, defaultToken)
1251+
}
1252+
if firstConfig.APIURL.String() != conf.Global.SlackAppURL.String() {
1253+
t.Fatalf("Expected API URL: %s\nGot: %s", conf.Global.SlackAppURL.String(), firstConfig.APIURL.String())
1254+
}
1255+
if firstConfig.HTTPConfig == nil || firstConfig.HTTPConfig.Authorization == nil {
1256+
t.Fatalf("Error configuring Slack App authorization: %s", firstConfig.HTTPConfig)
1257+
}
1258+
if firstConfig.HTTPConfig.Authorization.Type != firstAuth.Type {
1259+
t.Fatalf("Error configuring Slack App authorization type: %s\nExpected: %s", firstConfig.HTTPConfig.Authorization.Type, firstAuth.Type)
1260+
}
1261+
if firstConfig.HTTPConfig.Authorization.Credentials != firstAuth.Credentials {
1262+
t.Fatalf("Error configuring Slack App authorization credentials: %s\nExpected: %s", firstConfig.HTTPConfig.Authorization.Credentials, firstAuth.Credentials)
1263+
}
1264+
1265+
// inline override
1266+
inlineToken := "xoxb-1234-xxxxxx"
1267+
secondAuth := commoncfg.Authorization{
1268+
Type: "Bearer",
1269+
Credentials: commoncfg.Secret(inlineToken),
1270+
}
1271+
secondConfig := conf.Receivers[0].SlackConfigs[1]
1272+
if secondConfig.AppToken != Secret(inlineToken) {
1273+
t.Fatalf("Invalid Slack App token: %s\nExpected: %s", secondConfig.AppToken, inlineToken)
1274+
}
1275+
if secondConfig.HTTPConfig == nil || secondConfig.HTTPConfig.Authorization == nil {
1276+
t.Fatalf("Error configuring Slack App authorization: %s", secondConfig.HTTPConfig)
1277+
}
1278+
if secondConfig.HTTPConfig.Authorization.Type != secondAuth.Type {
1279+
t.Fatalf("Error configuring Slack App authorization type: %s\nExpected: %s", secondConfig.HTTPConfig.Authorization.Type, secondAuth.Type)
1280+
}
1281+
if secondConfig.HTTPConfig.Authorization.Credentials != secondAuth.Credentials {
1282+
t.Fatalf("Error configuring Slack App authorization credentials: %s\nExpected: %s", secondConfig.HTTPConfig.Authorization.Credentials, secondAuth.Credentials)
1283+
}
1284+
1285+
// custom app url
1286+
thirdConfig := conf.Receivers[0].SlackConfigs[2]
1287+
if thirdConfig.AppURL.String() != "http://api.fakeslack.example/" {
1288+
t.Fatalf("Invalid Slack URL: %s\nExpected: %s", thirdConfig.APIURL.String(), "http://mysecret.example.com/")
1289+
}
1290+
1291+
// workaround override
1292+
workaroundToken := "xoxb-my-bot-token"
1293+
fourthAuth := commoncfg.Authorization{
1294+
Type: "Bearer",
1295+
Credentials: commoncfg.Secret(workaroundToken),
1296+
}
1297+
fourthConfig := conf.Receivers[0].SlackConfigs[3]
1298+
if fourthConfig.AppToken != "" {
1299+
t.Fatalf("Invalid Slack App token: %q\nExpected: %q", fourthConfig.AppToken, "")
1300+
}
1301+
if fourthConfig.HTTPConfig == nil || fourthConfig.HTTPConfig.Authorization == nil {
1302+
t.Fatalf("Error configuring Slack App authorization: %s", fourthConfig.HTTPConfig)
1303+
}
1304+
if fourthConfig.HTTPConfig.Authorization.Type != fourthAuth.Type {
1305+
t.Fatalf("Error configuring Slack App authorization type: %s\nExpected: %s", fourthConfig.HTTPConfig.Authorization.Type, fourthAuth.Type)
1306+
}
1307+
if fourthConfig.HTTPConfig.Authorization.Credentials != fourthAuth.Credentials {
1308+
t.Fatalf("Error configuring Slack App authorization credentials: %s\nExpected: %s", fourthConfig.HTTPConfig.Authorization.Credentials, fourthAuth.Credentials)
1309+
}
1310+
1311+
// override the global file with an inline webhook URL
1312+
apiURL := "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"
1313+
fifthConfig := conf.Receivers[0].SlackConfigs[4]
1314+
if fifthConfig.APIURL.String() != apiURL || fifthConfig.APIURLFile != "" {
1315+
t.Fatalf("Invalid Slack URL: %s\nExpected: %s", fifthConfig.APIURL.String(), apiURL)
1316+
}
1317+
}
1318+
12151319
func TestSlackNoAPIURL(t *testing.T) {
1216-
_, err := LoadFile("testdata/conf.slack-no-api-url.yml")
1320+
_, err := LoadFile("testdata/conf.slack-no-api-url-or-token.yml")
12171321
if err == nil {
1218-
t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.slack-no-api-url.yml", err)
1322+
t.Fatalf("Expected an error parsing %s: %s", "testdata/conf.slack-no-api-url-or-token.yml", err)
12191323
}
1220-
if err.Error() != "no global Slack API URL set either inline or in a file" {
1221-
t.Errorf("Expected: %s\nGot: %s", "no global Slack API URL set either inline or in a file", err.Error())
1324+
if err.Error() != "no Slack API URL nor App token set either inline or in a file" {
1325+
t.Errorf("Expected: %s\nGot: %s", "no Slack API URL nor App token set either inline or in a file", err.Error())
12221326
}
12231327
}
12241328

config/notifiers.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -502,8 +502,11 @@ type SlackConfig struct {
502502

503503
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`
504504

505-
APIURL *SecretURL `yaml:"api_url,omitempty" json:"api_url,omitempty"`
506-
APIURLFile string `yaml:"api_url_file,omitempty" json:"api_url_file,omitempty"`
505+
APIURL *SecretURL `yaml:"api_url,omitempty" json:"api_url,omitempty"`
506+
APIURLFile string `yaml:"api_url_file,omitempty" json:"api_url_file,omitempty"`
507+
AppToken Secret `yaml:"app_token,omitempty" json:"app_token,omitempty"`
508+
AppTokenFile string `yaml:"app_token_file,omitempty" json:"app_token_file,omitempty"`
509+
AppURL *URL `yaml:"app_url,omitempty" json:"app_url,omitempty"`
507510

508511
// Slack channel override, (like #other-channel or @username).
509512
Channel string `yaml:"channel,omitempty" json:"channel,omitempty"`
@@ -542,6 +545,12 @@ func (c *SlackConfig) UnmarshalYAML(unmarshal func(any) error) error {
542545
if c.APIURL != nil && len(c.APIURLFile) > 0 {
543546
return errors.New("at most one of api_url & api_url_file must be configured")
544547
}
548+
if c.AppToken != "" && len(c.AppTokenFile) > 0 {
549+
return errors.New("at most one of app_token & app_token_file must be configured")
550+
}
551+
if (c.APIURL != nil || len(c.APIURLFile) > 0) && (c.AppToken != "" || len(c.AppTokenFile) > 0) {
552+
return errors.New("at most one of api_url/api_url_file & app_token/app_token_file must be configured")
553+
}
545554

546555
return nil
547556
}

config/notifiers_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,53 @@ mrkdwn_in:
606606
}
607607
}
608608

609+
func TestSlackAuthMethodConfigValidation(t *testing.T) {
610+
tests := []struct {
611+
in string
612+
expectedErr string
613+
}{
614+
{
615+
in: `
616+
api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'
617+
api_url_file: /slack_url
618+
`,
619+
expectedErr: "at most one of api_url & api_url_file must be configured",
620+
},
621+
{
622+
in: `
623+
app_token: 'xoxb-1234-abcdefgh'
624+
app_token_file: /slack_app_token
625+
`,
626+
expectedErr: "at most one of app_token & app_token_file must be configured",
627+
},
628+
{
629+
in: `
630+
app_token: 'xoxb-1234-abcdefgh'
631+
api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'
632+
`,
633+
expectedErr: "at most one of api_url/api_url_file & app_token/app_token_file must be configured",
634+
},
635+
}
636+
637+
for _, rt := range tests {
638+
var cfg SlackConfig
639+
err := yaml.UnmarshalStrict([]byte(rt.in), &cfg)
640+
641+
// Check if an error occurred when it was NOT expected to.
642+
if rt.expectedErr == "" && err != nil {
643+
t.Fatalf("\nerror returned when none expected, error:\n%v", err)
644+
}
645+
// Check that an error occurred if one was expected to.
646+
if rt.expectedErr != "" && err == nil {
647+
t.Fatalf("\nno error returned, expected:\n%v", rt.expectedErr)
648+
}
649+
// Check that the error that occurred was what was expected.
650+
if err != nil && err.Error() != rt.expectedErr {
651+
t.Errorf("\nexpected:\n%v\ngot:\n%v", rt.expectedErr, err.Error())
652+
}
653+
}
654+
}
655+
609656
func TestSlackFieldConfigValidation(t *testing.T) {
610657
tests := []struct {
611658
in string
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
global:
2+
slack_app_token: 'xoxb-1234-abcdefgh'
3+
slack_app_token_file: '/global_file'
4+
route:
5+
receiver: 'slack-notifications'
6+
group_by: [alertname, datacenter, app]
7+
receivers:
8+
- name: 'slack-notifications'
9+
slack_configs:
10+
- channel: '#alerts1'
11+
text: 'test'
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
global:
2+
slack_app_token: 'xoxb-1234-abcdefgh'
3+
slack_api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'
4+
route:
5+
receiver: 'slack-notifications'
6+
group_by: [alertname, datacenter, app]
7+
receivers:
8+
- name: 'slack-notifications'
9+
slack_configs:
10+
- channel: '#alerts1'
11+
text: 'test'
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
global:
2+
slack_app_token: 'xoxb-1234-abcdefgh'
3+
# old workaround to use bot tokens
4+
slack_api_url: 'https://slack.com/api/chat.postMessage'
5+
route:
6+
receiver: 'slack-notifications'
7+
group_by: [alertname, datacenter, app]
8+
receivers:
9+
- name: 'slack-notifications'
10+
slack_configs:
11+
# use global
12+
- channel: '#alerts1'
13+
text: 'test'
14+
# use override
15+
- channel: '#alerts2'
16+
text: 'test'
17+
app_token: 'xoxb-1234-xxxxxx'
18+
# use custom app url
19+
- channel: '#alerts3'
20+
text: 'test'
21+
app_url: http://api.fakeslack.example/
22+
# use workaround to configure bot token
23+
- channel: '#alerts4'
24+
text: 'test'
25+
http_config:
26+
authorization:
27+
credentials: 'xoxb-my-bot-token'
28+
- api_url: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX'
29+
send_resolved: true

0 commit comments

Comments
 (0)