Skip to content

Commit

Permalink
feat: update slack notification by adding block functionality to temp…
Browse files Browse the repository at this point in the history
…late (#370)

* Enhance slack notifications to be able to use markdown formatting by adding message block functionality.

Co-authored-by: sushmith <[email protected]>
  • Loading branch information
utsav14nov and bsushmith authored Mar 6, 2023
1 parent 4437d32 commit aa29ea8
Show file tree
Hide file tree
Showing 12 changed files with 334 additions and 33 deletions.
7 changes: 7 additions & 0 deletions core/appeal/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,13 @@ func getApprovalNotifications(appeal *domain.Appeal) []domain.Notification {
"requestor": appeal.CreatedBy,
"appeal_id": appeal.ID,
"account_id": appeal.AccountID,
"account_type": appeal.AccountType,
"provider_type": appeal.Resource.ProviderType,
"resource_type": appeal.Resource.Type,
"created_at": appeal.CreatedAt,
"approval_step": approval.Name,
"actor": approver,
"details": appeal.Details,
},
},
})
Expand Down
124 changes: 124 additions & 0 deletions docs/docs/guides/notification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Notification configuration

Templates of slack notifications sent through guardian can be configured.
It is a Json string having list of blocks(Json). Developers can configure list of blocks according to the notification UI needed.
It can be list of Texts, Sections, Buttons, inputs etc (Ref:https://api.slack.com/reference/block-kit/block-elements)


## Examples:

### Only Text:

```json
[{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "You have an appeal created by {{.requestor}} requesting access to {{.resource_name}} with role {{.role}}. Appeal ID: {{.appeal_id}}"
}
}]
```


### Others (Sample approval notification):

```json
[
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "You have an appeal created by {{.requestor}} requesting access to {{.resource_name}} with role {{.role}}. Appeal ID: {{.appeal_id}}"
}
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Provider*\\n{{.provider_type}}"
},
{
"type": "mrkdwn",
"text": "*Resource Type:*\\n{{.resource_type}}"
}
]
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Resource:*\\n{{.resource_name}}"
},
{
"type": "mrkdwn",
"text": "*Account Id:*\\n{{.account_id}}"
}
]
},
{
"type": "section",
"fields": [
{
"type": "mrkdwn",
"text": "*Role:*\\n{{.role}}"
},
{
"type": "mrkdwn",
"text": "*When:*\\n{{.created_at}}"
}
]
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Console link:*\nhttps://console.io/requests/{{.appeal_id}}"
}
},
{
"type": "input",
"element": {
"type": "plain_text_input",
"placeholder": {
"type": "plain_text",
"text": "Approve/Reject reason? (optional)"
},
"action_id": "reason"
},
"label": {
"type": "plain_text",
"text": "Reason"
}
},
{
"type": "actions",
"elements": [
{
"text": {
"type": "plain_text",
"emoji": true,
"text": "Approve"
},
"type": "button",
"value": "approved",
"style": "primary",
"url": "https://console.io/appeal_action?action=approve&appeal_id={{.appeal_id}}&approval_step={{.approval_step}}&actor={{.actor}}"
},
{
"text": {
"type": "plain_text",
"emoji": true,
"text": "Reject"
},
"type": "button",
"value": "rejected",
"style": "primary",
"url": "https://console.io/appeal_action?action=reject&appeal_id={{.appeal_id}}&approval_step={{.approval_step}}&actor={{.actor}}"
}
]
}
]
```

12 changes: 6 additions & 6 deletions domain/notifier.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package domain

type NotificationMessages struct {
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"`
AppealApproved string `mapstructure:"appeal_approved" default:"Your appeal to {{.resource_name}} with role {{.role}} has been approved"`
AppealRejected string `mapstructure:"appeal_rejected" default:"Your appeal to {{.resource_name}} with role {{.role}} has been rejected"`
AccessRevoked string `mapstructure:"access_revoked" default:"Your access to {{.resource_name}}} with role {{.role}} has been revoked"`
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}}"`
OthersAppealApproved string `mapstructure:"others_appeal_approved" default:"Your appeal to {{.resource_name}} with role {{.role}} created by {{.requestor}} has been approved"`
ExpirationReminder string `mapstructure:"expiration_reminder"`
AppealApproved string `mapstructure:"appeal_approved"`
AppealRejected string `mapstructure:"appeal_rejected"`
AccessRevoked string `mapstructure:"access_revoked"`
ApproverNotification string `mapstructure:"approver_notification"`
OthersAppealApproved string `mapstructure:"others_appeal_approved"`
}

const (
Expand Down
87 changes: 60 additions & 27 deletions plugins/notifiers/slack/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@ package slack

import (
"bytes"
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"strings"
"time"

"github.com/odpf/guardian/utils"

"github.com/odpf/guardian/domain"
)

Expand All @@ -33,17 +37,28 @@ type userResponse struct {
type notifier struct {
accessToken string

slackIDCache map[string]string
Messages domain.NotificationMessages
slackIDCache map[string]string
Messages domain.NotificationMessages
httpClient utils.HTTPClient
defaultMessageFiles embed.FS
}

type Config struct {
AccessToken string `mapstructure:"access_token"`
Messages domain.NotificationMessages
}

//go:embed templates/*
var defaultTemplates embed.FS

func New(config *Config) *notifier {
return &notifier{config.AccessToken, map[string]string{}, config.Messages}
return &notifier{
accessToken: config.AccessToken,
slackIDCache: map[string]string{},
Messages: config.Messages,
httpClient: &http.Client{Timeout: 10 * time.Second},
defaultMessageFiles: defaultTemplates,
}
}

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

msg, err := parseMessage(item.Message, n.Messages)
msg, err := parseMessage(item.Message, n.Messages, n.defaultMessageFiles)
if err != nil {
errs = append(errs, err)
}
Expand All @@ -67,11 +82,16 @@ func (n *notifier) Notify(items []domain.Notification) []error {
return errs
}

func (n *notifier) sendMessage(channel, text string) error {
func (n *notifier) sendMessage(channel, messageBlock string) error {
url := slackHost + "/api/chat.postMessage"
data, err := json.Marshal(map[string]string{
var messageblockList []interface{}

if err := json.Unmarshal([]byte(messageBlock), &messageblockList); err != nil {
return fmt.Errorf("error in parsing message block %s", err)
}
data, err := json.Marshal(map[string]interface{}{
"channel": channel,
"text": text,
"blocks": messageblockList,
})
if err != nil {
return err
Expand Down Expand Up @@ -117,8 +137,7 @@ func (n *notifier) findSlackIDByEmail(email string) (string, error) {
}

func (n *notifier) sendRequest(req *http.Request) (*userResponse, error) {
Client := &http.Client{Timeout: 10 * time.Second}
resp, err := Client.Do(req)
resp, err := n.httpClient.Do(req)
if err != nil {
return nil, err
}
Expand All @@ -136,24 +155,38 @@ func (n *notifier) sendRequest(req *http.Request) (*userResponse, error) {
return &result, nil
}

func parseMessage(message domain.NotificationMessage, templates domain.NotificationMessages) (string, error) {
var text string
switch message.Type {
case domain.NotificationTypeAccessRevoked:
text = templates.AccessRevoked
case domain.NotificationTypeAppealApproved:
text = templates.AppealApproved
case domain.NotificationTypeAppealRejected:
text = templates.AppealRejected
case domain.NotificationTypeApproverNotification:
text = templates.ApproverNotification
case domain.NotificationTypeExpirationReminder:
text = templates.ExpirationReminder
case domain.NotificationTypeOnBehalfAppealApproved:
text = templates.OthersAppealApproved
}

t, err := template.New("notification_messages").Parse(text)
func getDefaultTemplate(messageType string, defaultTemplateFiles embed.FS) (string, error) {
content, err := defaultTemplateFiles.ReadFile(fmt.Sprintf("templates/%s.json", messageType))
if err != nil {
return "", fmt.Errorf("error finding default template for message type %s - %s", messageType, err)
}
return string(content), nil
}

func parseMessage(message domain.NotificationMessage, templates domain.NotificationMessages, defaultTemplateFiles embed.FS) (string, error) {
messageTypeTemplateMap := map[string]string{
domain.NotificationTypeAccessRevoked: templates.AccessRevoked,
domain.NotificationTypeAppealApproved: templates.AppealApproved,
domain.NotificationTypeAppealRejected: templates.AppealRejected,
domain.NotificationTypeApproverNotification: templates.ApproverNotification,
domain.NotificationTypeExpirationReminder: templates.ExpirationReminder,
domain.NotificationTypeOnBehalfAppealApproved: templates.OthersAppealApproved,
}

messageBlock, ok := messageTypeTemplateMap[message.Type]
if !ok {
return "", fmt.Errorf("template not found for message type %s", message.Type)
}

if messageBlock == "" {
defaultMsgBlock, err := getDefaultTemplate(message.Type, defaultTemplateFiles)
if err != nil {
return "", err
}
messageBlock = defaultMsgBlock
}

t, err := template.New("notification_messages").Parse(messageBlock)
if err != nil {
return "", err
}
Expand Down
77 changes: 77 additions & 0 deletions plugins/notifiers/slack/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package slack

import (
"bytes"
"errors"
"io/ioutil"
"net/http"
"testing"

"github.com/odpf/guardian/domain"
"github.com/odpf/guardian/mocks"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
)

type ClientTestSuite struct {
suite.Suite
mockHttpClient *mocks.HTTPClient
accessToken string
messages domain.NotificationMessages
slackIDCache map[string]string
notifier notifier
}

func (s *ClientTestSuite) setup() {
s.mockHttpClient = new(mocks.HTTPClient)
s.accessToken = "XXXXX-TOKEN-XXXXX"
s.messages = domain.NotificationMessages{
AppealRejected: "[{\"type\":\"section\",\"text\":{\"type\":\"mrkdwn\",\"text\":\"Your appeal to {{.resource_name}} with role {{.role}} has been rejected\"}}]",
}
s.slackIDCache = map[string]string{}
s.notifier = notifier{
accessToken: s.accessToken,
slackIDCache: s.slackIDCache,
Messages: s.messages,
httpClient: s.mockHttpClient,
defaultMessageFiles: defaultTemplates,
}
}

func TestClient(t *testing.T) {
suite.Run(t, new(ClientTestSuite))
}

func (s *ClientTestSuite) TestNotify() {
s.Run("should return error if slack id not found", func() {
s.setup()

slackAPIResponse := `{"ok":false,"error":"users_not_found"}`
resp := &http.Response{StatusCode: 200, Body: ioutil.NopCloser(bytes.NewReader([]byte(slackAPIResponse)))}
s.mockHttpClient.On("Do", mock.Anything).Return(resp, nil)
expectedErrs := []error{errors.New("users_not_found"), errors.New("EOF")}

actualErrs := s.notifier.Notify([]domain.Notification{
{
User: "[email protected]",
Message: domain.NotificationMessage{
Type: domain.NotificationTypeAppealRejected,
Variables: map[string]interface{}{
"ResourceName": "test-resource",
},
},
},
})

s.Equal(expectedErrs, actualErrs)
})

s.Run("should get default message template from file if not found in config", func() {
s.setup()
expectedContent, err := ioutil.ReadFile("templates/AppealApproved.json")
content, err := getDefaultTemplate("AppealApproved", s.notifier.defaultMessageFiles)

s.Equal(string(expectedContent), content)
s.Equal(err, nil)
})
}
9 changes: 9 additions & 0 deletions plugins/notifiers/slack/templates/AccessRevoked.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[
{
"type":"section",
"text":{
"type":"mrkdwn",
"text":"Your access to *{{.resource_name}}}* with role *{{.role}}* has been revoked"
}
}
]
Loading

0 comments on commit aa29ea8

Please sign in to comment.