diff --git a/Makefile b/Makefile index 1f2435f8..4352d184 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,10 @@ clean: tidy test: go test ./... -race -coverprofile=coverage.out +test-short: + @echo "Running short tests by disabling store tests..." + go test ./... -race -short -coverprofile=coverage.out + coverage: test @echo "Generating coverage report..." @go tool cover -html=coverage.out diff --git a/internal/server/config.yaml b/internal/server/config.yaml index f874c13b..3d7fc96e 100644 --- a/internal/server/config.yaml +++ b/internal/server/config.yaml @@ -24,6 +24,10 @@ DB: NOTIFIER: PROVIDER: slack ACCESS_TOKEN: + WORKSPACES: + - WORKSPACE: goto + ACCESS_TOKEN: + CRITERIA: "email contains '@goto'" JOBS: FETCH_RESOURCES: ENABLED: true diff --git a/pkg/evaluator/expression_test.go b/pkg/evaluator/expression_test.go index c98daf6f..ee6c1285 100644 --- a/pkg/evaluator/expression_test.go +++ b/pkg/evaluator/expression_test.go @@ -100,6 +100,71 @@ func TestEvaluate(t *testing.T) { }, expectedResult: true, }, + { + // a complex test case to evaluate the expression where "the appeal role must either not contain viewer or (the resource type is gcloud_iam and the duration is 0h) + expression: "!($appeal.role contains 'viewer') || $appeal.resource.provider_type == 'gcloud_iam' && $appeal.options.duration in ['0h','']", + params: map[string]interface{}{ + "appeal": map[string]interface{}{ + "role": "Editor", + "resource": map[string]interface{}{ + "provider_type": "gcloud_iam", + }, + "options": map[string]interface{}{ + "duration": "0h", + }, + }, + }, + expectedResult: true, + }, + { + // a complex test case to evaluate the expression where "the appeal role must either not contain viewer or (the resource type is gcloud_iam and the duration is 0h and the role must contain viewer) + expression: "!($appeal.role contains 'viewer') || $appeal.resource.provider_type == 'gcloud_iam' && $appeal.options.duration in ['0h',''] && $appeal.role contains 'viewer'", + params: map[string]interface{}{ + "appeal": map[string]interface{}{ + "role": "viewer", + "resource": map[string]interface{}{ + "provider_type": "gcloud_iam", + }, + "options": map[string]interface{}{ + "duration": "0h", + }, + }, + }, + expectedResult: true, + }, + { + // a complex test case to evaluate the expression where "the appeal role must either not contain viewer or (the resource type is gcloud_iam and the duration is 0h and the role must contain viewer + expression: "!($appeal.role contains 'viewer') || $appeal.resource.provider_type == 'gcloud_iam' && $appeal.options.duration in ['0h',''] && $appeal.role contains 'viewer'", + params: map[string]interface{}{ + "appeal": map[string]interface{}{ + "role": "viewer", + "resource": map[string]interface{}{ + "provider_type": "gcloud_iam", + }, + "options": map[string]interface{}{ + "duration": "2160h", + }, + }, + }, + expectedResult: false, + }, + { + // a complex test case to evaluate the expression where "the appeal role must either not contain admin_user or (the resource type is gcloud_iam and the duration is 0h and the role must contain admin_user) + expression: "!($appeal.role contains 'admin_user') || $appeal.resource.provider_type == 'gcloud_iam' && $appeal.options.duration in ['0h',''] && $appeal.role contains 'admin_user'", + params: map[string]interface{}{ + "appeal": map[string]interface{}{ + "role": "viewer", + "resource": map[string]interface{}{ + "provider_type": "gcloud_iam", + }, + "options": map[string]interface{}{ + "duration": "2160h", + }, + }, + }, + expectedResult: true, + }, + { expression: "len(Split($user.email_id, '@')[0]) > 2", params: map[string]interface{}{ diff --git a/plugins/notifiers/client.go b/plugins/notifiers/client.go index 363cf1a7..683c0b02 100644 --- a/plugins/notifiers/client.go +++ b/plugins/notifiers/client.go @@ -21,7 +21,8 @@ type Config struct { Provider string `mapstructure:"provider" validate:"omitempty,oneof=slack"` // slack - AccessToken string `mapstructure:"access_token" validate:"required_if=Provider slack"` + AccessToken string `mapstructure:"access_token" validate:"required_without=Workspaces"` + Workspaces []slack.SlackWorkspace `mapstructure:"workspaces" validate:"required_without=AccessToken,dive"` // custom messages Messages domain.NotificationMessages @@ -29,13 +30,49 @@ type Config struct { func NewClient(config *Config) (Client, error) { if config.Provider == ProviderTypeSlack { - slackConfig := &slack.Config{ - AccessToken: config.AccessToken, - Messages: config.Messages, + + slackConfig, err := NewSlackConfig(config) + if err != nil { + return nil, err } + httpClient := &http.Client{Timeout: 10 * time.Second} return slack.NewNotifier(slackConfig, httpClient), nil } return nil, errors.New("invalid notifier provider type") } + +func NewSlackConfig(config *Config) (*slack.Config, error) { + + // validation + if config.AccessToken == "" && len(config.Workspaces) == 0 { + return nil, errors.New("slack access token or workspaces must be provided") + } + if config.AccessToken != "" && len(config.Workspaces) != 0 { + return nil, errors.New("slack access token and workspaces cannot be provided at the same time") + } + + var slackConfig *slack.Config + if config.AccessToken != "" { + workspaces := []slack.SlackWorkspace{ + { + WorkspaceName: "default", + AccessToken: config.AccessToken, + Criteria: "1==1", + }, + } + slackConfig = &slack.Config{ + Workspaces: workspaces, + Messages: config.Messages, + } + return slackConfig, nil + } + + slackConfig = &slack.Config{ + Workspaces: config.Workspaces, + Messages: config.Messages, + } + + return slackConfig, nil +} diff --git a/plugins/notifiers/client_test.go b/plugins/notifiers/client_test.go new file mode 100644 index 00000000..c30cf12b --- /dev/null +++ b/plugins/notifiers/client_test.go @@ -0,0 +1,111 @@ +package notifiers + +import ( + "github.com/goto/guardian/plugins/notifiers/slack" + "reflect" + "testing" +) + +func TestNewSlackConfig(t *testing.T) { + type args struct { + config *Config + } + tests := []struct { + name string + args args + want *slack.Config + wantErr bool + }{ + { + name: "should return error when no access token or workspaces are provided", + args: args{ + config: &Config{ + Provider: ProviderTypeSlack, + }, + }, + want: nil, + wantErr: true, + }, { + name: "should return error when both access token and workspaces are provided", + args: args{ + config: &Config{ + Provider: ProviderTypeSlack, + AccessToken: "foo", + Workspaces: []slack.SlackWorkspace{ + { + WorkspaceName: "default", + AccessToken: "bar", + Criteria: "1==1", + }, + }, + }, + }, + want: nil, + wantErr: true, + }, { + name: "should return slack config when access token is provided", + args: args{ + config: &Config{ + Provider: ProviderTypeSlack, + AccessToken: "foo", + }, + }, + want: &slack.Config{ + Workspaces: []slack.SlackWorkspace{ + { + WorkspaceName: "default", + AccessToken: "foo", + Criteria: "1==1", + }, + }, + }, + wantErr: false, + }, { + name: "should return slack config when workspaces are provided", + args: args{ + config: &Config{ + Provider: ProviderTypeSlack, + Workspaces: []slack.SlackWorkspace{ + { + WorkspaceName: "A", + AccessToken: "foo", + Criteria: "$email contains '@abc'", + }, + { + WorkspaceName: "B", + AccessToken: "bar", + Criteria: "$email contains '@xyz'", + }, + }, + }, + }, + want: &slack.Config{ + Workspaces: []slack.SlackWorkspace{ + { + WorkspaceName: "A", + AccessToken: "foo", + Criteria: "$email contains '@abc'", + }, + { + WorkspaceName: "B", + AccessToken: "bar", + Criteria: "$email contains '@xyz'", + }, + }, + }, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewSlackConfig(tt.args.config) + if (err != nil) != tt.wantErr { + t.Errorf("NewSlackConfig() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("NewSlackConfig() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/plugins/notifiers/slack/client.go b/plugins/notifiers/slack/client.go index 2bbb7f21..28adf431 100644 --- a/plugins/notifiers/slack/client.go +++ b/plugins/notifiers/slack/client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/goto/guardian/pkg/evaluator" "html/template" "net/http" "net/url" @@ -33,18 +34,29 @@ type userResponse struct { Error string `json:"error"` } +type SlackWorkspace struct { + WorkspaceName string `mapstructure:"workspace" validate:"required"` + AccessToken string `mapstructure:"access_token" validate:"required"` + Criteria string `mapstructure:"criteria" validate:"required"` +} + type Notifier struct { - accessToken string + workspaces []SlackWorkspace - slackIDCache map[string]string + slackIDCache map[string]*slackIDCacheItem Messages domain.NotificationMessages httpClient utils.HTTPClient defaultMessageFiles embed.FS } +type slackIDCacheItem struct { + SlackID string + Workspace *SlackWorkspace +} + type Config struct { - AccessToken string `mapstructure:"access_token"` - Messages domain.NotificationMessages + Workspaces []SlackWorkspace `mapstructure:"workspaces"` + Messages domain.NotificationMessages } //go:embed templates/* @@ -52,8 +64,8 @@ var defaultTemplates embed.FS func NewNotifier(config *Config, httpClient utils.HTTPClient) *Notifier { return &Notifier{ - accessToken: config.AccessToken, - slackIDCache: map[string]string{}, + workspaces: config.Workspaces, + slackIDCache: map[string]*slackIDCacheItem{}, Messages: config.Messages, httpClient: httpClient, defaultMessageFiles: defaultTemplates, @@ -63,26 +75,49 @@ func NewNotifier(config *Config, httpClient utils.HTTPClient) *Notifier { func (n *Notifier) Notify(items []domain.Notification) []error { errs := make([]error, 0) for _, item := range items { + var ws *SlackWorkspace + var slackID string labelSlice := utils.MapToSlice(item.Labels) - slackID, err := n.findSlackIDByEmail(item.User) - if err != nil { - errs = append(errs, fmt.Errorf("%v | %w", labelSlice, err)) + + // check cache + if n.slackIDCache[item.User] != nil { + slackID = n.slackIDCache[item.User].SlackID + ws = n.slackIDCache[item.User].Workspace + } else { + ws, err := n.GetSlackWorkspaceForUser(item.User) + if err != nil { + errs = append(errs, fmt.Errorf("%v | %w", labelSlice, err)) + continue + } + slackID, err = n.findSlackIDByEmail(item.User, *ws) + if err != nil { + errs = append(errs, fmt.Errorf("%v | %w", labelSlice, err)) + continue + } + + // cache + n.slackIDCache[item.User] = &slackIDCacheItem{ + SlackID: slackID, + Workspace: ws, + } } msg, err := ParseMessage(item.Message, n.Messages, n.defaultMessageFiles) if err != nil { errs = append(errs, fmt.Errorf("%v | error parsing message : %w", labelSlice, err)) + continue } - if err := n.sendMessage(slackID, msg); err != nil { - errs = append(errs, fmt.Errorf("%v | error sending message to user:%s | %w", labelSlice, item.User, err)) + if err := n.sendMessage(*ws, slackID, msg); err != nil { + errs = append(errs, fmt.Errorf("%v | error sending message to user:%s in workspace:%s | %w", labelSlice, item.User, ws.WorkspaceName, err)) + continue } } return errs } -func (n *Notifier) sendMessage(channel, messageBlock string) error { +func (n *Notifier) sendMessage(workspace SlackWorkspace, channel, messageBlock string) error { url := slackHost + "/api/chat.postMessage" var messageblockList []interface{} @@ -101,18 +136,40 @@ func (n *Notifier) sendMessage(channel, messageBlock string) error { if err != nil { return err } - req.Header.Add("Authorization", "Bearer "+n.accessToken) + req.Header.Add("Authorization", "Bearer "+workspace.AccessToken) req.Header.Add("Content-Type", "application/json") _, err = n.sendRequest(req) return err } -func (n *Notifier) findSlackIDByEmail(email string) (string, error) { - if n.slackIDCache[email] != "" { - return n.slackIDCache[email], nil +func (n *Notifier) GetSlackWorkspaceForUser(email string) (*SlackWorkspace, error) { + var ws *SlackWorkspace + for _, workspace := range n.workspaces { + v, err := evaluator.Expression(workspace.Criteria).EvaluateWithVars(map[string]interface{}{ + "email": email, + }) + if err != nil { + return ws, fmt.Errorf("error evaluating notifier expression: %w", err) + } + + // if the expression evaluates to true, return the workspace + if match, ok := v.(bool); !ok { + return ws, errors.New("notifier expression did not evaluate to a boolean") + } else if match { + ws = &workspace + break + } } + if ws == nil { + return ws, errors.New(fmt.Sprintf("no slack workspace found for user: %s", email)) + } + + return ws, nil +} + +func (n *Notifier) findSlackIDByEmail(email string, ws SlackWorkspace) (string, error) { slackURL := slackHost + "/api/users.lookupByEmail" form := url.Values{} form.Add("email", email) @@ -121,18 +178,17 @@ func (n *Notifier) findSlackIDByEmail(email string) (string, error) { if err != nil { return "", err } - req.Header.Add("Authorization", "Bearer "+n.accessToken) + req.Header.Add("Authorization", "Bearer "+ws.AccessToken) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") result, err := n.sendRequest(req) if err != nil { - return "", fmt.Errorf("error finding slack id for email %s - %s", email, err) + return "", fmt.Errorf("error finding slack id for email %s in workspace: %s - %s", email, ws.WorkspaceName, err) } if result.User == nil { - return "", errors.New(fmt.Sprintf("user not found: %s", email)) + return "", errors.New(fmt.Sprintf("user not found: %s in workspace: %s", email, ws.WorkspaceName)) } - n.slackIDCache[email] = result.User.ID return result.User.ID, nil } diff --git a/plugins/notifiers/slack/client_test.go b/plugins/notifiers/slack/client_test.go index 26aa74ae..bf219743 100644 --- a/plugins/notifiers/slack/client_test.go +++ b/plugins/notifiers/slack/client_test.go @@ -26,14 +26,26 @@ type ClientTestSuite struct { func (s *ClientTestSuite) setup() { s.mockHttpClient = new(mocks.HTTPClient) + workspaces := []slack.SlackWorkspace{ + { + WorkspaceName: "ws-1", + AccessToken: "XXXXX-TOKEN-1-XXXXX", + Criteria: "$email contains '@abc'", + }, + { + WorkspaceName: "ws-2", + AccessToken: "XXXXX-TOKEN-2-XXXXX", + Criteria: "$email contains '@xyz'", + }, + } 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\"}}]", } conf := &slack.Config{ - AccessToken: s.accessToken, - Messages: s.messages, + Workspaces: workspaces, + Messages: s.messages, } s.notifier = slack.NewNotifier(conf, s.mockHttpClient) @@ -47,8 +59,7 @@ func (s *ClientTestSuite) TestNotify() { resp := &http.Response{StatusCode: 200, Body: ioutil.NopCloser(bytes.NewReader([]byte(slackAPIResponse)))} s.mockHttpClient.On("Do", mock.Anything).Return(resp, nil) expectedErrs := []error{ - fmt.Errorf("[appeal_id=test-appeal-id] | %w", errors.New("error finding slack id for email test-user@abc.com - users_not_found")), - fmt.Errorf("[appeal_id=test-appeal-id] | error sending message to user:test-user@abc.com | %w", errors.New("EOF")), + fmt.Errorf("[appeal_id=test-appeal-id] | %w", errors.New("error finding slack id for email test-user@abc.com in workspace: ws-1 - users_not_found")), } notifications := []domain.Notification{ @@ -114,6 +125,41 @@ func (s *ClientTestSuite) TestParseMessage() { }) } +func (s *ClientTestSuite) TestGetSlackWorkspaceForUser() { + s.setup() + s.Run("should return slack workspace 1 for user", func() { + userEmail := "example-user@abc.com" + expectedWs := &slack.SlackWorkspace{ + WorkspaceName: "ws-1", + AccessToken: "XXXXX-TOKEN-1-XXXXX", + Criteria: "$email contains '@abc'", + } + actualWs, err := s.notifier.GetSlackWorkspaceForUser(userEmail) + s.Nil(err) + s.Equal(expectedWs, actualWs) + }) + + s.Run("should return slack workspace 2 for user", func() { + userEmail := "example-user@xyz.com" + expectedWs := &slack.SlackWorkspace{ + WorkspaceName: "ws-2", + AccessToken: "XXXXX-TOKEN-2-XXXXX", + Criteria: "$email contains '@xyz'", + } + actualWs, err := s.notifier.GetSlackWorkspaceForUser(userEmail) + s.Nil(err) + s.Equal(expectedWs, actualWs) + }) + + s.Run("should return error if slack workspace not found for user", func() { + userEmail := "example-user@def.com" + expectedErr := fmt.Errorf("no slack workspace found for user: %s", userEmail) + _, actualErr := s.notifier.GetSlackWorkspaceForUser(userEmail) + s.Equal(expectedErr, actualErr) + }) + +} + func TestClient(t *testing.T) { suite.Run(t, new(ClientTestSuite)) }