diff --git a/api/authorization.go b/api/authorization.go index 234bece71..25eb1c33f 100644 --- a/api/authorization.go +++ b/api/authorization.go @@ -1,10 +1,13 @@ package api +import "github.com/moira-alert/moira/datatypes" + // Authorization contains authorization configuration. type Authorization struct { - AdminList map[string]struct{} - Enabled bool - AllowedContactTypes map[string]struct{} + AdminList map[string]struct{} + Enabled bool + AllowedContactTypes map[string]struct{} + AllowedEmergencyContactTypes map[datatypes.HeartbeatType]struct{} } // IsEnabled returns true if auth is enabled and false otherwise. diff --git a/api/config.go b/api/config.go index 29055ae71..6e8a2f326 100644 --- a/api/config.go +++ b/api/config.go @@ -5,6 +5,7 @@ import ( "time" "github.com/moira-alert/moira" + "github.com/moira-alert/moira/datatypes" ) // WebContact is container for web ui contact validation. @@ -43,12 +44,13 @@ type Config struct { // WebConfig is container for web ui configuration parameters. type WebConfig struct { - SupportEmail string `json:"supportEmail,omitempty" example:"opensource@skbkontur.com"` - RemoteAllowed bool `json:"remoteAllowed" example:"true"` - MetricSourceClusters []MetricSourceCluster `json:"metric_source_clusters"` - Contacts []WebContact `json:"contacts"` - FeatureFlags FeatureFlags `json:"featureFlags"` - Sentry Sentry `json:"sentry"` + SupportEmail string `json:"supportEmail,omitempty" example:"opensource@skbkontur.com"` + RemoteAllowed bool `json:"remoteAllowed" example:"true"` + MetricSourceClusters []MetricSourceCluster `json:"metric_source_clusters"` + Contacts []WebContact `json:"contacts"` + EmergencyContactTypes []datatypes.HeartbeatType `json:"emergency_contact_types"` + FeatureFlags FeatureFlags `json:"featureFlags"` + Sentry Sentry `json:"sentry"` } // MetricSourceCluster contains data about supported metric source cluster. diff --git a/api/dto/emergency_contact.go b/api/dto/emergency_contact.go index 9795888d2..d81864f1b 100644 --- a/api/dto/emergency_contact.go +++ b/api/dto/emergency_contact.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" + "github.com/moira-alert/moira/api/middleware" "github.com/moira-alert/moira/datatypes" ) @@ -28,10 +29,18 @@ func (emergencyContact *EmergencyContact) Bind(r *http.Request) error { return ErrEmptyHeartbeatTypes } + auth := middleware.GetAuth(r) + userLogin := middleware.GetLogin(r) + isAdmin := auth.IsAdmin(userLogin) + for _, emergencyType := range emergencyContact.HeartbeatTypes { if !emergencyType.IsValid() { return fmt.Errorf("'%s' heartbeat type doesn't exist", emergencyType) } + + if _, ok := auth.AllowedEmergencyContactTypes[emergencyType]; !ok && !isAdmin { + return fmt.Errorf("'%s' heartbeat type is not allowed", emergencyType) + } } return nil diff --git a/api/dto/emergency_contact_test.go b/api/dto/emergency_contact_test.go index 8d0725fae..e2267b10a 100644 --- a/api/dto/emergency_contact_test.go +++ b/api/dto/emergency_contact_test.go @@ -1,8 +1,13 @@ package dto import ( + "fmt" + "net/http" + "net/http/httptest" "testing" + "github.com/moira-alert/moira/api" + "github.com/moira-alert/moira/api/middleware" "github.com/moira-alert/moira/datatypes" . "github.com/smartystreets/goconvey/convey" ) @@ -16,6 +21,96 @@ var ( } ) +func TestEmergencyContactBind(t *testing.T) { + auth := &api.Authorization{ + Enabled: true, + AllowedEmergencyContactTypes: map[datatypes.HeartbeatType]struct{}{ + datatypes.HeartbeatNotifier: {}, + datatypes.HeartbeatDatabase: {}, + }, + } + + userLogin := "test" + testLoginKey := "login" + testAuthKey := "auth" + testContactID := "test-contact-id" + + Convey("Test Bind", t, func() { + Convey("With empty emergency types", func() { + testRequest := httptest.NewRequest(http.MethodPut, "/contact", http.NoBody) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testLoginKey, userLogin)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testAuthKey, auth)) + + emergencyContact := &EmergencyContact{ + ContactID: testContactID, + } + + err := emergencyContact.Bind(testRequest) + So(err, ShouldEqual, ErrEmptyHeartbeatTypes) + }) + + Convey("With invalid heartbeat type", func() { + testRequest := httptest.NewRequest(http.MethodPut, "/contact", http.NoBody) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testLoginKey, userLogin)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testAuthKey, auth)) + + emergencyContact := &EmergencyContact{ + ContactID: testContactID, + HeartbeatTypes: []datatypes.HeartbeatType{"invalid-heartbeat-type"}, + } + + err := emergencyContact.Bind(testRequest) + So(err, ShouldResemble, fmt.Errorf("'invalid-heartbeat-type' heartbeat type doesn't exist")) + }) + + Convey("With not allowed heartbeat type", func() { + testRequest := httptest.NewRequest(http.MethodPut, "/contact", http.NoBody) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testLoginKey, userLogin)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testAuthKey, auth)) + + emergencyContact := &EmergencyContact{ + ContactID: testContactID, + HeartbeatTypes: []datatypes.HeartbeatType{datatypes.HeartbeatFilter}, + } + + err := emergencyContact.Bind(testRequest) + So(err, ShouldResemble, fmt.Errorf("'%s' heartbeat type is not allowed", datatypes.HeartbeatFilter)) + }) + + Convey("With allowed heartbeat types", func() { + testRequest := httptest.NewRequest(http.MethodPut, "/contact", http.NoBody) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testLoginKey, userLogin)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testAuthKey, auth)) + + emergencyContact := &EmergencyContact{ + ContactID: testContactID, + HeartbeatTypes: []datatypes.HeartbeatType{datatypes.HeartbeatDatabase, datatypes.HeartbeatNotifier}, + } + + err := emergencyContact.Bind(testRequest) + So(err, ShouldBeNil) + }) + + Convey("With admin who's allowed everything", func() { + testRequest := httptest.NewRequest(http.MethodPut, "/contact", http.NoBody) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testLoginKey, userLogin)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testAuthKey, auth)) + + auth.AdminList = map[string]struct{}{ + userLogin: {}, + } + + emergencyContact := &EmergencyContact{ + ContactID: testContactID, + HeartbeatTypes: []datatypes.HeartbeatType{datatypes.HeartbeatFilter, datatypes.HeartbeatNotifier}, + } + + err := emergencyContact.Bind(testRequest) + So(err, ShouldBeNil) + }) + }) +} + func TestFromEmergencyContacts(t *testing.T) { Convey("Test FromEmergencyContacts", t, func() { Convey("With nil emergency contacts", func() { diff --git a/api/handler/emergency_contact_test.go b/api/handler/emergency_contact_test.go index ac4bd67b4..c83f69c0f 100644 --- a/api/handler/emergency_contact_test.go +++ b/api/handler/emergency_contact_test.go @@ -32,7 +32,7 @@ var ( } testEmergencyContact2 = datatypes.EmergencyContact{ ContactID: testContactID2, - HeartbeatTypes: []datatypes.HeartbeatType{datatypes.HeartbeatTypeNotSet}, + HeartbeatTypes: []datatypes.HeartbeatType{datatypes.HeartbeatFilter}, } login = "testLogin" @@ -209,8 +209,14 @@ func TestCreateEmergencyContact(t *testing.T) { auth := &api.Authorization{ Enabled: true, AdminList: map[string]struct{}{login: {}}, + AllowedEmergencyContactTypes: map[datatypes.HeartbeatType]struct{}{ + datatypes.HeartbeatFilter: {}, + datatypes.HeartbeatNotifier: {}, + }, } + notAdmin := "not-admin" + Convey("Successfully create emergency contact", func() { emergencyContactDTO := dto.EmergencyContact(testEmergencyContact) @@ -356,6 +362,85 @@ func TestCreateEmergencyContact(t *testing.T) { So(response.StatusCode, ShouldEqual, http.StatusBadRequest) }) + Convey("Try to create emergency contact with not allowed heartbeat type", func() { + emergencyContact := datatypes.EmergencyContact{ + ContactID: testContactID, + HeartbeatTypes: []datatypes.HeartbeatType{ + datatypes.HeartbeatLocalChecker, + }, + } + emergencyContactDTO := dto.EmergencyContact(emergencyContact) + + jsonEmergencyContact, err := json.Marshal(emergencyContactDTO) + So(err, ShouldBeNil) + + database = mockDb + + expectedErr := &api.ErrorResponse{ + StatusText: "Invalid request", + ErrorText: "'heartbeat_local_checker' heartbeat type is not allowed", + } + + testRequest := httptest.NewRequest(http.MethodPost, "/emergency-contact", bytes.NewBuffer(jsonEmergencyContact)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testLoginKey, notAdmin)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testAuthKey, auth)) + testRequest.Header.Add("content-type", "application/json") + + createEmergencyContact(responseWriter, testRequest) + + response := responseWriter.Result() + defer response.Body.Close() + contentBytes, err := io.ReadAll(response.Body) + So(err, ShouldBeNil) + contents := string(contentBytes) + actual := &api.ErrorResponse{} + err = json.Unmarshal([]byte(contents), actual) + So(err, ShouldBeNil) + + So(actual, ShouldResemble, expectedErr) + So(response.StatusCode, ShouldEqual, http.StatusBadRequest) + }) + + Convey("Try to create emergency contact with not allowed but with admin login", func() { + emergencyContact := datatypes.EmergencyContact{ + ContactID: testContactID, + HeartbeatTypes: []datatypes.HeartbeatType{ + datatypes.HeartbeatLocalChecker, + }, + } + emergencyContactDTO := dto.EmergencyContact(emergencyContact) + + jsonEmergencyContact, err := json.Marshal(emergencyContactDTO) + So(err, ShouldBeNil) + + mockDb.EXPECT().GetContact(testContactID).Return(testContact, nil) + mockDb.EXPECT().SaveEmergencyContact(emergencyContact).Return(nil) + database = mockDb + + expectedResponse := &dto.SaveEmergencyContactResponse{ + ContactID: testContactID, + } + + testRequest := httptest.NewRequest(http.MethodPost, "/emergency-contact", bytes.NewBuffer(jsonEmergencyContact)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testLoginKey, login)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testAuthKey, auth)) + testRequest.Header.Add("content-type", "application/json") + + createEmergencyContact(responseWriter, testRequest) + + response := responseWriter.Result() + defer response.Body.Close() + contentBytes, err := io.ReadAll(response.Body) + So(err, ShouldBeNil) + contents := string(contentBytes) + actual := &dto.SaveEmergencyContactResponse{} + err = json.Unmarshal([]byte(contents), actual) + So(err, ShouldBeNil) + + So(actual, ShouldResemble, expectedResponse) + So(response.StatusCode, ShouldEqual, http.StatusOK) + }) + Convey("Internal server error with get contact database error", func() { emergencyContactDTO := dto.EmergencyContact(testEmergencyContact) @@ -439,6 +524,17 @@ func TestUpdateEmergencyContact(t *testing.T) { responseWriter := httptest.NewRecorder() mockDb := mock_moira_alert.NewMockDatabase(mockCtrl) + auth := &api.Authorization{ + Enabled: true, + AdminList: map[string]struct{}{login: {}}, + AllowedEmergencyContactTypes: map[datatypes.HeartbeatType]struct{}{ + datatypes.HeartbeatFilter: {}, + datatypes.HeartbeatNotifier: {}, + }, + } + + notAdmin := "not-admin" + Convey("Successfully update emergency contact", func() { emergencyContactDTO := dto.EmergencyContact(testEmergencyContact) @@ -454,6 +550,8 @@ func TestUpdateEmergencyContact(t *testing.T) { testRequest := httptest.NewRequest(http.MethodPut, "/emergency-contact/"+testContactID, bytes.NewBuffer(jsonEmergencyContact)) testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testContactIDKey, testContactID)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testLoginKey, login)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testAuthKey, auth)) testRequest.Header.Add("content-type", "application/json") updateEmergencyContact(responseWriter, testRequest) @@ -489,6 +587,8 @@ func TestUpdateEmergencyContact(t *testing.T) { testRequest := httptest.NewRequest(http.MethodPut, "/emergency-contact/"+testContactID, bytes.NewBuffer(jsonEmergencyContact)) testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testContactIDKey, testContactID)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testLoginKey, login)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testAuthKey, auth)) testRequest.Header.Add("content-type", "application/json") updateEmergencyContact(responseWriter, testRequest) @@ -523,6 +623,8 @@ func TestUpdateEmergencyContact(t *testing.T) { testRequest := httptest.NewRequest(http.MethodPut, "/emergency-contact/"+testContactID, bytes.NewBuffer(jsonEmergencyContact)) testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testContactIDKey, testContactID)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testLoginKey, login)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testAuthKey, auth)) testRequest.Header.Add("content-type", "application/json") updateEmergencyContact(responseWriter, testRequest) @@ -560,6 +662,47 @@ func TestUpdateEmergencyContact(t *testing.T) { testRequest := httptest.NewRequest(http.MethodPut, "/emergency-contact/"+testContactID, bytes.NewBuffer(jsonEmergencyContact)) testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testContactIDKey, testContactID)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testLoginKey, login)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testAuthKey, auth)) + testRequest.Header.Add("content-type", "application/json") + + updateEmergencyContact(responseWriter, testRequest) + + response := responseWriter.Result() + defer response.Body.Close() + contentBytes, err := io.ReadAll(response.Body) + So(err, ShouldBeNil) + contents := string(contentBytes) + actual := &api.ErrorResponse{} + err = json.Unmarshal([]byte(contents), actual) + So(err, ShouldBeNil) + + So(actual, ShouldResemble, expectedErr) + So(response.StatusCode, ShouldEqual, http.StatusBadRequest) + }) + + Convey("Invalid Request with not allowed heartbeat type in dto", func() { + emergencyContact := datatypes.EmergencyContact{ + ContactID: testContactID, + HeartbeatTypes: []datatypes.HeartbeatType{ + datatypes.HeartbeatLocalChecker, + }, + } + emergencyContactDTO := dto.EmergencyContact(emergencyContact) + + expectedErr := &api.ErrorResponse{ + StatusText: "Invalid request", + ErrorText: "'heartbeat_local_checker' heartbeat type is not allowed", + } + jsonEmergencyContact, err := json.Marshal(emergencyContactDTO) + So(err, ShouldBeNil) + + database = mockDb + + testRequest := httptest.NewRequest(http.MethodPut, "/emergency-contact/"+testContactID, bytes.NewBuffer(jsonEmergencyContact)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testContactIDKey, testContactID)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testLoginKey, notAdmin)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testAuthKey, auth)) testRequest.Header.Add("content-type", "application/json") updateEmergencyContact(responseWriter, testRequest) @@ -577,6 +720,46 @@ func TestUpdateEmergencyContact(t *testing.T) { So(response.StatusCode, ShouldEqual, http.StatusBadRequest) }) + Convey("Try to update emergency contact with not allowed but with admin login", func() { + emergencyContact := datatypes.EmergencyContact{ + ContactID: testContactID, + HeartbeatTypes: []datatypes.HeartbeatType{ + datatypes.HeartbeatLocalChecker, + }, + } + emergencyContactDTO := dto.EmergencyContact(emergencyContact) + + expectedResponse := &dto.SaveEmergencyContactResponse{ + ContactID: testContactID, + } + + jsonEmergencyContact, err := json.Marshal(emergencyContactDTO) + So(err, ShouldBeNil) + + mockDb.EXPECT().SaveEmergencyContact(emergencyContact).Return(nil) + database = mockDb + + testRequest := httptest.NewRequest(http.MethodPut, "/emergency-contact/"+testContactID, bytes.NewBuffer(jsonEmergencyContact)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testContactIDKey, testContactID)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testLoginKey, login)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testAuthKey, auth)) + testRequest.Header.Add("content-type", "application/json") + + updateEmergencyContact(responseWriter, testRequest) + + response := responseWriter.Result() + defer response.Body.Close() + contentBytes, err := io.ReadAll(response.Body) + So(err, ShouldBeNil) + contents := string(contentBytes) + actual := &dto.SaveEmergencyContactResponse{} + err = json.Unmarshal([]byte(contents), actual) + So(err, ShouldBeNil) + + So(actual, ShouldResemble, expectedResponse) + So(response.StatusCode, ShouldEqual, http.StatusOK) + }) + Convey("Internal Server Error with database error", func() { emergencyContactDTO := dto.EmergencyContact(testEmergencyContact) @@ -593,6 +776,8 @@ func TestUpdateEmergencyContact(t *testing.T) { testRequest := httptest.NewRequest(http.MethodPut, "/emergency-contact/"+testContactID, bytes.NewBuffer(jsonEmergencyContact)) testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testContactIDKey, testContactID)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testLoginKey, login)) + testRequest = testRequest.WithContext(middleware.SetContextValueForTest(testRequest.Context(), testAuthKey, auth)) testRequest.Header.Add("content-type", "application/json") updateEmergencyContact(responseWriter, testRequest) diff --git a/cmd/api/config.go b/cmd/api/config.go index b63770873..a1b73cc9d 100644 --- a/cmd/api/config.go +++ b/cmd/api/config.go @@ -10,6 +10,7 @@ import ( "github.com/moira-alert/moira/api" "github.com/moira-alert/moira/cmd" + "github.com/moira-alert/moira/datatypes" ) type config struct { @@ -99,6 +100,8 @@ type webConfig struct { RemoteAllowed bool // List of enabled contacts template. ContactsTemplate []webContact `yaml:"contacts_template"` + // List of allowed emergency contact types. + EmergencyContactTypes []datatypes.HeartbeatType `yaml:"emergency_contact_types"` // Struct to manage feature flags. FeatureFlags featureFlags `yaml:"feature_flags"` // Returns the sentry configuration for the frontend. @@ -150,15 +153,20 @@ func (auth *authorization) toApiConfig(webConfig *webConfig) api.Authorization { } allowedContactTypes := make(map[string]struct{}, len(webConfig.ContactsTemplate)) - for _, contactTemplate := range webConfig.ContactsTemplate { allowedContactTypes[contactTemplate.ContactType] = struct{}{} } + allowedEmergencyContactTypes := make(map[datatypes.HeartbeatType]struct{}, len(webConfig.EmergencyContactTypes)) + for _, emergencyContactType := range webConfig.EmergencyContactTypes { + allowedEmergencyContactTypes[emergencyContactType] = struct{}{} + } + return api.Authorization{ - Enabled: auth.Enabled, - AdminList: adminList, - AllowedContactTypes: allowedContactTypes, + Enabled: auth.Enabled, + AdminList: adminList, + AllowedContactTypes: allowedContactTypes, + AllowedEmergencyContactTypes: allowedEmergencyContactTypes, } } @@ -218,12 +226,13 @@ func (config *webConfig) getSettings(isRemoteEnabled bool, remotes cmd.RemotesCo } return &api.WebConfig{ - SupportEmail: config.SupportEmail, - RemoteAllowed: isRemoteEnabled, - MetricSourceClusters: clusters, - Contacts: webContacts, - FeatureFlags: config.getFeatureFlags(), - Sentry: config.Sentry.getSettings(), + SupportEmail: config.SupportEmail, + RemoteAllowed: isRemoteEnabled, + MetricSourceClusters: clusters, + Contacts: webContacts, + EmergencyContactTypes: config.EmergencyContactTypes, + FeatureFlags: config.getFeatureFlags(), + Sentry: config.Sentry.getSettings(), } } diff --git a/cmd/api/config_test.go b/cmd/api/config_test.go index 4d7f86a2b..96a09c063 100644 --- a/cmd/api/config_test.go +++ b/cmd/api/config_test.go @@ -6,6 +6,7 @@ import ( "github.com/moira-alert/moira" "github.com/moira-alert/moira/cmd" + "github.com/moira-alert/moira/datatypes" "github.com/moira-alert/moira/api" @@ -25,6 +26,7 @@ func Test_apiConfig_getSettings(t *testing.T) { ContactType: "test", }, }, + EmergencyContactTypes: []datatypes.HeartbeatType{datatypes.HeartbeatDatabase, datatypes.HeartbeatFilter}, } apiConf := apiConfig{ @@ -42,6 +44,10 @@ func Test_apiConfig_getSettings(t *testing.T) { AllowedContactTypes: map[string]struct{}{ "test": {}, }, + AllowedEmergencyContactTypes: map[datatypes.HeartbeatType]struct{}{ + datatypes.HeartbeatDatabase: {}, + datatypes.HeartbeatFilter: {}, + }, }, } @@ -173,6 +179,7 @@ func Test_webConfig_getSettings(t *testing.T) { Help: "help", }, }, + EmergencyContactTypes: []datatypes.HeartbeatType{datatypes.HeartbeatDatabase, datatypes.HeartbeatFilter}, FeatureFlags: featureFlags{ IsPlottingDefaultOn: true, IsPlottingAvailable: true, @@ -198,6 +205,7 @@ func Test_webConfig_getSettings(t *testing.T) { Help: "help", }, }, + EmergencyContactTypes: []datatypes.HeartbeatType{datatypes.HeartbeatDatabase, datatypes.HeartbeatFilter}, FeatureFlags: api.FeatureFlags{ IsPlottingDefaultOn: true, IsPlottingAvailable: true,