Skip to content

Commit aa29ea8

Browse files
feat: update slack notification by adding block functionality to template (#370)
* Enhance slack notifications to be able to use markdown formatting by adding message block functionality. Co-authored-by: sushmith <[email protected]>
1 parent 4437d32 commit aa29ea8

12 files changed

+334
-33
lines changed

core/appeal/service.go

+7
Original file line numberDiff line numberDiff line change
@@ -797,6 +797,13 @@ func getApprovalNotifications(appeal *domain.Appeal) []domain.Notification {
797797
"requestor": appeal.CreatedBy,
798798
"appeal_id": appeal.ID,
799799
"account_id": appeal.AccountID,
800+
"account_type": appeal.AccountType,
801+
"provider_type": appeal.Resource.ProviderType,
802+
"resource_type": appeal.Resource.Type,
803+
"created_at": appeal.CreatedAt,
804+
"approval_step": approval.Name,
805+
"actor": approver,
806+
"details": appeal.Details,
800807
},
801808
},
802809
})

docs/docs/guides/notification.md

+124
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# Notification configuration
2+
3+
Templates of slack notifications sent through guardian can be configured.
4+
It is a Json string having list of blocks(Json). Developers can configure list of blocks according to the notification UI needed.
5+
It can be list of Texts, Sections, Buttons, inputs etc (Ref:https://api.slack.com/reference/block-kit/block-elements)
6+
7+
8+
## Examples:
9+
10+
### Only Text:
11+
12+
```json
13+
[{
14+
"type": "section",
15+
"text": {
16+
"type": "mrkdwn",
17+
"text": "You have an appeal created by {{.requestor}} requesting access to {{.resource_name}} with role {{.role}}. Appeal ID: {{.appeal_id}}"
18+
}
19+
}]
20+
```
21+
22+
23+
### Others (Sample approval notification):
24+
25+
```json
26+
[
27+
{
28+
"type": "section",
29+
"text": {
30+
"type": "mrkdwn",
31+
"text": "You have an appeal created by {{.requestor}} requesting access to {{.resource_name}} with role {{.role}}. Appeal ID: {{.appeal_id}}"
32+
}
33+
},
34+
{
35+
"type": "section",
36+
"fields": [
37+
{
38+
"type": "mrkdwn",
39+
"text": "*Provider*\\n{{.provider_type}}"
40+
},
41+
{
42+
"type": "mrkdwn",
43+
"text": "*Resource Type:*\\n{{.resource_type}}"
44+
}
45+
]
46+
},
47+
{
48+
"type": "section",
49+
"fields": [
50+
{
51+
"type": "mrkdwn",
52+
"text": "*Resource:*\\n{{.resource_name}}"
53+
},
54+
{
55+
"type": "mrkdwn",
56+
"text": "*Account Id:*\\n{{.account_id}}"
57+
}
58+
]
59+
},
60+
{
61+
"type": "section",
62+
"fields": [
63+
{
64+
"type": "mrkdwn",
65+
"text": "*Role:*\\n{{.role}}"
66+
},
67+
{
68+
"type": "mrkdwn",
69+
"text": "*When:*\\n{{.created_at}}"
70+
}
71+
]
72+
},
73+
{
74+
"type": "section",
75+
"text": {
76+
"type": "mrkdwn",
77+
"text": "*Console link:*\nhttps://console.io/requests/{{.appeal_id}}"
78+
}
79+
},
80+
{
81+
"type": "input",
82+
"element": {
83+
"type": "plain_text_input",
84+
"placeholder": {
85+
"type": "plain_text",
86+
"text": "Approve/Reject reason? (optional)"
87+
},
88+
"action_id": "reason"
89+
},
90+
"label": {
91+
"type": "plain_text",
92+
"text": "Reason"
93+
}
94+
},
95+
{
96+
"type": "actions",
97+
"elements": [
98+
{
99+
"text": {
100+
"type": "plain_text",
101+
"emoji": true,
102+
"text": "Approve"
103+
},
104+
"type": "button",
105+
"value": "approved",
106+
"style": "primary",
107+
"url": "https://console.io/appeal_action?action=approve&appeal_id={{.appeal_id}}&approval_step={{.approval_step}}&actor={{.actor}}"
108+
},
109+
{
110+
"text": {
111+
"type": "plain_text",
112+
"emoji": true,
113+
"text": "Reject"
114+
},
115+
"type": "button",
116+
"value": "rejected",
117+
"style": "primary",
118+
"url": "https://console.io/appeal_action?action=reject&appeal_id={{.appeal_id}}&approval_step={{.approval_step}}&actor={{.actor}}"
119+
}
120+
]
121+
}
122+
]
123+
```
124+

domain/notifier.go

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
package domain
22

33
type NotificationMessages struct {
4-
ExpirationReminder string `mapstructure:"expiration_reminder" default:"Your access {{.account_id}} to {{.resource_name}} with role {{.role}} will expire at {{.expiration_date}}. Extend the access if it's still needed"`
5-
AppealApproved string `mapstructure:"appeal_approved" default:"Your appeal to {{.resource_name}} with role {{.role}} has been approved"`
6-
AppealRejected string `mapstructure:"appeal_rejected" default:"Your appeal to {{.resource_name}} with role {{.role}} has been rejected"`
7-
AccessRevoked string `mapstructure:"access_revoked" default:"Your access to {{.resource_name}}} with role {{.role}} has been revoked"`
8-
ApproverNotification string `mapstructure:"approver_notification" default:"You have an appeal created by {{.requestor}} requesting access to {{.resource_name}} with role {{.role}}. Appeal ID: {{.appeal_id}}"`
9-
OthersAppealApproved string `mapstructure:"others_appeal_approved" default:"Your appeal to {{.resource_name}} with role {{.role}} created by {{.requestor}} has been approved"`
4+
ExpirationReminder string `mapstructure:"expiration_reminder"`
5+
AppealApproved string `mapstructure:"appeal_approved"`
6+
AppealRejected string `mapstructure:"appeal_rejected"`
7+
AccessRevoked string `mapstructure:"access_revoked"`
8+
ApproverNotification string `mapstructure:"approver_notification"`
9+
OthersAppealApproved string `mapstructure:"others_appeal_approved"`
1010
}
1111

1212
const (

plugins/notifiers/slack/client.go

+60-27
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@ package slack
22

33
import (
44
"bytes"
5+
"embed"
56
"encoding/json"
67
"errors"
8+
"fmt"
79
"html/template"
810
"net/http"
911
"net/url"
1012
"strings"
1113
"time"
1214

15+
"github.com/odpf/guardian/utils"
16+
1317
"github.com/odpf/guardian/domain"
1418
)
1519

@@ -33,17 +37,28 @@ type userResponse struct {
3337
type notifier struct {
3438
accessToken string
3539

36-
slackIDCache map[string]string
37-
Messages domain.NotificationMessages
40+
slackIDCache map[string]string
41+
Messages domain.NotificationMessages
42+
httpClient utils.HTTPClient
43+
defaultMessageFiles embed.FS
3844
}
3945

4046
type Config struct {
4147
AccessToken string `mapstructure:"access_token"`
4248
Messages domain.NotificationMessages
4349
}
4450

51+
//go:embed templates/*
52+
var defaultTemplates embed.FS
53+
4554
func New(config *Config) *notifier {
46-
return &notifier{config.AccessToken, map[string]string{}, config.Messages}
55+
return &notifier{
56+
accessToken: config.AccessToken,
57+
slackIDCache: map[string]string{},
58+
Messages: config.Messages,
59+
httpClient: &http.Client{Timeout: 10 * time.Second},
60+
defaultMessageFiles: defaultTemplates,
61+
}
4762
}
4863

4964
func (n *notifier) Notify(items []domain.Notification) []error {
@@ -54,7 +69,7 @@ func (n *notifier) Notify(items []domain.Notification) []error {
5469
errs = append(errs, err)
5570
}
5671

57-
msg, err := parseMessage(item.Message, n.Messages)
72+
msg, err := parseMessage(item.Message, n.Messages, n.defaultMessageFiles)
5873
if err != nil {
5974
errs = append(errs, err)
6075
}
@@ -67,11 +82,16 @@ func (n *notifier) Notify(items []domain.Notification) []error {
6782
return errs
6883
}
6984

70-
func (n *notifier) sendMessage(channel, text string) error {
85+
func (n *notifier) sendMessage(channel, messageBlock string) error {
7186
url := slackHost + "/api/chat.postMessage"
72-
data, err := json.Marshal(map[string]string{
87+
var messageblockList []interface{}
88+
89+
if err := json.Unmarshal([]byte(messageBlock), &messageblockList); err != nil {
90+
return fmt.Errorf("error in parsing message block %s", err)
91+
}
92+
data, err := json.Marshal(map[string]interface{}{
7393
"channel": channel,
74-
"text": text,
94+
"blocks": messageblockList,
7595
})
7696
if err != nil {
7797
return err
@@ -117,8 +137,7 @@ func (n *notifier) findSlackIDByEmail(email string) (string, error) {
117137
}
118138

119139
func (n *notifier) sendRequest(req *http.Request) (*userResponse, error) {
120-
Client := &http.Client{Timeout: 10 * time.Second}
121-
resp, err := Client.Do(req)
140+
resp, err := n.httpClient.Do(req)
122141
if err != nil {
123142
return nil, err
124143
}
@@ -136,24 +155,38 @@ func (n *notifier) sendRequest(req *http.Request) (*userResponse, error) {
136155
return &result, nil
137156
}
138157

139-
func parseMessage(message domain.NotificationMessage, templates domain.NotificationMessages) (string, error) {
140-
var text string
141-
switch message.Type {
142-
case domain.NotificationTypeAccessRevoked:
143-
text = templates.AccessRevoked
144-
case domain.NotificationTypeAppealApproved:
145-
text = templates.AppealApproved
146-
case domain.NotificationTypeAppealRejected:
147-
text = templates.AppealRejected
148-
case domain.NotificationTypeApproverNotification:
149-
text = templates.ApproverNotification
150-
case domain.NotificationTypeExpirationReminder:
151-
text = templates.ExpirationReminder
152-
case domain.NotificationTypeOnBehalfAppealApproved:
153-
text = templates.OthersAppealApproved
154-
}
155-
156-
t, err := template.New("notification_messages").Parse(text)
158+
func getDefaultTemplate(messageType string, defaultTemplateFiles embed.FS) (string, error) {
159+
content, err := defaultTemplateFiles.ReadFile(fmt.Sprintf("templates/%s.json", messageType))
160+
if err != nil {
161+
return "", fmt.Errorf("error finding default template for message type %s - %s", messageType, err)
162+
}
163+
return string(content), nil
164+
}
165+
166+
func parseMessage(message domain.NotificationMessage, templates domain.NotificationMessages, defaultTemplateFiles embed.FS) (string, error) {
167+
messageTypeTemplateMap := map[string]string{
168+
domain.NotificationTypeAccessRevoked: templates.AccessRevoked,
169+
domain.NotificationTypeAppealApproved: templates.AppealApproved,
170+
domain.NotificationTypeAppealRejected: templates.AppealRejected,
171+
domain.NotificationTypeApproverNotification: templates.ApproverNotification,
172+
domain.NotificationTypeExpirationReminder: templates.ExpirationReminder,
173+
domain.NotificationTypeOnBehalfAppealApproved: templates.OthersAppealApproved,
174+
}
175+
176+
messageBlock, ok := messageTypeTemplateMap[message.Type]
177+
if !ok {
178+
return "", fmt.Errorf("template not found for message type %s", message.Type)
179+
}
180+
181+
if messageBlock == "" {
182+
defaultMsgBlock, err := getDefaultTemplate(message.Type, defaultTemplateFiles)
183+
if err != nil {
184+
return "", err
185+
}
186+
messageBlock = defaultMsgBlock
187+
}
188+
189+
t, err := template.New("notification_messages").Parse(messageBlock)
157190
if err != nil {
158191
return "", err
159192
}
+77
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package slack
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"io/ioutil"
7+
"net/http"
8+
"testing"
9+
10+
"github.com/odpf/guardian/domain"
11+
"github.com/odpf/guardian/mocks"
12+
"github.com/stretchr/testify/mock"
13+
"github.com/stretchr/testify/suite"
14+
)
15+
16+
type ClientTestSuite struct {
17+
suite.Suite
18+
mockHttpClient *mocks.HTTPClient
19+
accessToken string
20+
messages domain.NotificationMessages
21+
slackIDCache map[string]string
22+
notifier notifier
23+
}
24+
25+
func (s *ClientTestSuite) setup() {
26+
s.mockHttpClient = new(mocks.HTTPClient)
27+
s.accessToken = "XXXXX-TOKEN-XXXXX"
28+
s.messages = domain.NotificationMessages{
29+
AppealRejected: "[{\"type\":\"section\",\"text\":{\"type\":\"mrkdwn\",\"text\":\"Your appeal to {{.resource_name}} with role {{.role}} has been rejected\"}}]",
30+
}
31+
s.slackIDCache = map[string]string{}
32+
s.notifier = notifier{
33+
accessToken: s.accessToken,
34+
slackIDCache: s.slackIDCache,
35+
Messages: s.messages,
36+
httpClient: s.mockHttpClient,
37+
defaultMessageFiles: defaultTemplates,
38+
}
39+
}
40+
41+
func TestClient(t *testing.T) {
42+
suite.Run(t, new(ClientTestSuite))
43+
}
44+
45+
func (s *ClientTestSuite) TestNotify() {
46+
s.Run("should return error if slack id not found", func() {
47+
s.setup()
48+
49+
slackAPIResponse := `{"ok":false,"error":"users_not_found"}`
50+
resp := &http.Response{StatusCode: 200, Body: ioutil.NopCloser(bytes.NewReader([]byte(slackAPIResponse)))}
51+
s.mockHttpClient.On("Do", mock.Anything).Return(resp, nil)
52+
expectedErrs := []error{errors.New("users_not_found"), errors.New("EOF")}
53+
54+
actualErrs := s.notifier.Notify([]domain.Notification{
55+
{
56+
57+
Message: domain.NotificationMessage{
58+
Type: domain.NotificationTypeAppealRejected,
59+
Variables: map[string]interface{}{
60+
"ResourceName": "test-resource",
61+
},
62+
},
63+
},
64+
})
65+
66+
s.Equal(expectedErrs, actualErrs)
67+
})
68+
69+
s.Run("should get default message template from file if not found in config", func() {
70+
s.setup()
71+
expectedContent, err := ioutil.ReadFile("templates/AppealApproved.json")
72+
content, err := getDefaultTemplate("AppealApproved", s.notifier.defaultMessageFiles)
73+
74+
s.Equal(string(expectedContent), content)
75+
s.Equal(err, nil)
76+
})
77+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[
2+
{
3+
"type":"section",
4+
"text":{
5+
"type":"mrkdwn",
6+
"text":"Your access to *{{.resource_name}}}* with role *{{.role}}* has been revoked"
7+
}
8+
}
9+
]

0 commit comments

Comments
 (0)