From da726782680cccf74501e51ab68e0b1c9c2f4b89 Mon Sep 17 00:00:00 2001 From: Tetrergeru Date: Wed, 13 Mar 2024 14:35:13 +0500 Subject: [PATCH] Add admin permissions via list of admins in api (#996) --- api/config.go | 29 +++++++++-- api/controller/contact.go | 10 +++- api/controller/contact_test.go | 50 +++++++++++++++--- api/controller/subscription.go | 10 +++- api/controller/subscription_test.go | 50 +++++++++++++++--- api/controller/team.go | 10 +++- api/controller/team_test.go | 11 ++-- api/handler/contact.go | 3 +- api/handler/handler.go | 1 + api/handler/health.go | 16 +++++- api/handler/health_test.go | 79 +++++++++++++++++++++++++++++ api/handler/notification.go | 20 +++++++- api/handler/pattern.go | 4 ++ api/handler/subscription.go | 3 +- api/handler/team.go | 5 +- api/middleware/authorization.go | 25 +++++++++ api/middleware/context.go | 10 ++++ api/middleware/middleware.go | 7 +++ cmd/api/config.go | 29 +++++++++-- cmd/api/config_test.go | 3 ++ 20 files changed, 338 insertions(+), 37 deletions(-) create mode 100644 api/handler/health_test.go create mode 100644 api/middleware/authorization.go diff --git a/api/config.go b/api/config.go index d11484916..a45f70ab1 100644 --- a/api/config.go +++ b/api/config.go @@ -32,10 +32,31 @@ type Sentry struct { // Config for api configuration variables. type Config struct { - EnableCORS bool - Listen string - MetricsTTL map[moira.ClusterKey]time.Duration - Flags FeatureFlags + EnableCORS bool + Listen string + MetricsTTL map[moira.ClusterKey]time.Duration + Flags FeatureFlags + Authorization Authorization +} + +// Authorization contains authorization configuration. +type Authorization struct { + AdminList map[string]struct{} + Enabled bool +} + +// IsEnabled returns true if auth is enabled and false otherwise. +func (auth *Authorization) IsEnabled() bool { + return auth.Enabled +} + +// IsAdmin checks whether given user is considered an administrator. +func (auth *Authorization) IsAdmin(login string) bool { + if !auth.IsEnabled() { + return false + } + _, ok := auth.AdminList[login] + return ok } // WebConfig is container for web ui configuration parameters. diff --git a/api/controller/contact.go b/api/controller/contact.go index fb5170538..4b8196d67 100644 --- a/api/controller/contact.go +++ b/api/controller/contact.go @@ -177,7 +177,12 @@ func SendTestContactNotification(dataBase moira.Database, contactID string) *api } // CheckUserPermissionsForContact checks contact for existence and permissions for given user. -func CheckUserPermissionsForContact(dataBase moira.Database, contactID string, userLogin string) (moira.ContactData, *api.ErrorResponse) { +func CheckUserPermissionsForContact( + dataBase moira.Database, + contactID string, + userLogin string, + auth *api.Authorization, +) (moira.ContactData, *api.ErrorResponse) { contactData, err := dataBase.GetContact(contactID) if err != nil { if errors.Is(err, database.ErrNil) { @@ -185,6 +190,9 @@ func CheckUserPermissionsForContact(dataBase moira.Database, contactID string, u } return moira.ContactData{}, api.ErrorInternalServer(err) } + if auth.IsAdmin(userLogin) { + return contactData, nil + } if contactData.Team != "" { teamContainsUser, err := dataBase.IsTeamContainUser(contactData.Team, userLogin) if err != nil { diff --git a/api/controller/contact_test.go b/api/controller/contact_test.go index ec749ebac..a31b1214f 100644 --- a/api/controller/contact_test.go +++ b/api/controller/contact_test.go @@ -497,17 +497,18 @@ func TestCheckUserPermissionsForContact(t *testing.T) { userLogin := uuid.Must(uuid.NewV4()).String() teamID := uuid.Must(uuid.NewV4()).String() id := uuid.Must(uuid.NewV4()).String() + auth := &api.Authorization{} Convey("No contact", t, func() { dataBase.EXPECT().GetContact(id).Return(moira.ContactData{}, database.ErrNil) - expectedContact, expected := CheckUserPermissionsForContact(dataBase, id, userLogin) + expectedContact, expected := CheckUserPermissionsForContact(dataBase, id, userLogin, auth) So(expected, ShouldResemble, api.ErrorNotFound(fmt.Sprintf("contact with ID '%s' does not exists", id))) So(expectedContact, ShouldResemble, moira.ContactData{}) }) Convey("Different user", t, func() { dataBase.EXPECT().GetContact(id).Return(moira.ContactData{User: "diffUser"}, nil) - expectedContact, expected := CheckUserPermissionsForContact(dataBase, id, userLogin) + expectedContact, expected := CheckUserPermissionsForContact(dataBase, id, userLogin, auth) So(expected, ShouldResemble, api.ErrorForbidden("you are not permitted")) So(expectedContact, ShouldResemble, moira.ContactData{}) }) @@ -515,7 +516,7 @@ func TestCheckUserPermissionsForContact(t *testing.T) { Convey("Has contact", t, func() { actualContact := moira.ContactData{ID: id, User: userLogin} dataBase.EXPECT().GetContact(id).Return(actualContact, nil) - expectedContact, expected := CheckUserPermissionsForContact(dataBase, id, userLogin) + expectedContact, expected := CheckUserPermissionsForContact(dataBase, id, userLogin, auth) So(expected, ShouldBeNil) So(expectedContact, ShouldResemble, actualContact) }) @@ -523,7 +524,7 @@ func TestCheckUserPermissionsForContact(t *testing.T) { Convey("Error get contact", t, func() { err := fmt.Errorf("oooops! Can not read contact") dataBase.EXPECT().GetContact(id).Return(moira.ContactData{User: userLogin}, err) - expectedContact, expected := CheckUserPermissionsForContact(dataBase, id, userLogin) + expectedContact, expected := CheckUserPermissionsForContact(dataBase, id, userLogin, auth) So(expected, ShouldResemble, api.ErrorInternalServer(err)) So(expectedContact, ShouldResemble, moira.ContactData{}) }) @@ -533,14 +534,14 @@ func TestCheckUserPermissionsForContact(t *testing.T) { expectedSub := moira.ContactData{ID: id, Team: teamID} dataBase.EXPECT().GetContact(id).Return(expectedSub, nil) dataBase.EXPECT().IsTeamContainUser(teamID, userLogin).Return(true, nil) - actual, err := CheckUserPermissionsForContact(dataBase, id, userLogin) + actual, err := CheckUserPermissionsForContact(dataBase, id, userLogin, auth) So(err, ShouldBeNil) So(actual, ShouldResemble, expectedSub) }) Convey("User is not in team", func() { dataBase.EXPECT().GetContact(id).Return(moira.ContactData{ID: id, Team: teamID}, nil) dataBase.EXPECT().IsTeamContainUser(teamID, userLogin).Return(false, nil) - actual, err := CheckUserPermissionsForContact(dataBase, id, userLogin) + actual, err := CheckUserPermissionsForContact(dataBase, id, userLogin, auth) So(err, ShouldResemble, api.ErrorForbidden("you are not permitted")) So(actual, ShouldResemble, moira.ContactData{}) }) @@ -548,13 +549,48 @@ func TestCheckUserPermissionsForContact(t *testing.T) { errReturned := errors.New("test error") dataBase.EXPECT().GetContact(id).Return(moira.ContactData{ID: id, Team: teamID}, nil) dataBase.EXPECT().IsTeamContainUser(teamID, userLogin).Return(false, errReturned) - actual, err := CheckUserPermissionsForContact(dataBase, id, userLogin) + actual, err := CheckUserPermissionsForContact(dataBase, id, userLogin, auth) So(err, ShouldResemble, api.ErrorInternalServer(errReturned)) So(actual, ShouldResemble, moira.ContactData{}) }) }) } +func TestCheckAdminPermissionsForContact(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + dataBase := mock_moira_alert.NewMockDatabase(mockCtrl) + teamID := uuid.Must(uuid.NewV4()).String() + id := uuid.Must(uuid.NewV4()).String() + adminLogin := "admin_login" + auth := &api.Authorization{Enabled: true, AdminList: map[string]struct{}{adminLogin: {}}} + + Convey("Same user", t, func() { + expectedContact := moira.ContactData{ID: id, User: adminLogin} + dataBase.EXPECT().GetContact(id).Return(expectedContact, nil) + actualContact, errorResponse := CheckUserPermissionsForContact(dataBase, id, adminLogin, auth) + So(errorResponse, ShouldBeNil) + So(actualContact, ShouldResemble, expectedContact) + }) + + Convey("Different user", t, func() { + expectedContact := moira.ContactData{ID: id, User: "diffUser"} + dataBase.EXPECT().GetContact(id).Return(expectedContact, nil) + actualContact, errorResponse := CheckUserPermissionsForContact(dataBase, id, adminLogin, auth) + So(errorResponse, ShouldBeNil) + So(actualContact, ShouldResemble, expectedContact) + }) + + Convey("Team contact", t, func() { + expectedContact := moira.ContactData{ID: id, Team: teamID} + dataBase.EXPECT().GetContact(id).Return(expectedContact, nil) + actualContact, errorResponse := CheckUserPermissionsForContact(dataBase, id, adminLogin, auth) + So(errorResponse, ShouldBeNil) + So(actualContact, ShouldResemble, expectedContact) + }) +} + func Test_isContactExists(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() diff --git a/api/controller/subscription.go b/api/controller/subscription.go index 41d47bf8f..96e64dd20 100644 --- a/api/controller/subscription.go +++ b/api/controller/subscription.go @@ -105,7 +105,12 @@ func SendTestNotification(database moira.Database, subscriptionID string) *api.E } // CheckUserPermissionsForSubscription checks subscription for existence and permissions for given user. -func CheckUserPermissionsForSubscription(dataBase moira.Database, subscriptionID string, userLogin string) (moira.SubscriptionData, *api.ErrorResponse) { +func CheckUserPermissionsForSubscription( + dataBase moira.Database, + subscriptionID string, + userLogin string, + auth *api.Authorization, +) (moira.SubscriptionData, *api.ErrorResponse) { subscription, err := dataBase.GetSubscription(subscriptionID) if err != nil { if errors.Is(err, database.ErrNil) { @@ -113,6 +118,9 @@ func CheckUserPermissionsForSubscription(dataBase moira.Database, subscriptionID } return moira.SubscriptionData{}, api.ErrorInternalServer(err) } + if auth.IsAdmin(userLogin) { + return subscription, nil + } if subscription.TeamID != "" { teamContainsUser, err := dataBase.IsTeamContainUser(subscription.TeamID, userLogin) if err != nil { diff --git a/api/controller/subscription_test.go b/api/controller/subscription_test.go index f56e8b519..0d275c40a 100644 --- a/api/controller/subscription_test.go +++ b/api/controller/subscription_test.go @@ -285,10 +285,11 @@ func TestCheckUserPermissionsForSubscription(t *testing.T) { userLogin := uuid.Must(uuid.NewV4()).String() teamID := uuid.Must(uuid.NewV4()).String() id := uuid.Must(uuid.NewV4()).String() + auth := &api.Authorization{} Convey("No subscription", t, func() { dataBase.EXPECT().GetSubscription(id).Return(moira.SubscriptionData{}, database.ErrNil) - expectedSub, expected := CheckUserPermissionsForSubscription(dataBase, id, userLogin) + expectedSub, expected := CheckUserPermissionsForSubscription(dataBase, id, userLogin, auth) So(expected, ShouldResemble, api.ErrorNotFound(fmt.Sprintf("subscription with ID '%s' does not exists", id))) So(expectedSub, ShouldResemble, moira.SubscriptionData{}) }) @@ -296,7 +297,7 @@ func TestCheckUserPermissionsForSubscription(t *testing.T) { Convey("Different user", t, func() { actualSub := moira.SubscriptionData{User: "diffUser"} dataBase.EXPECT().GetSubscription(id).Return(actualSub, nil) - expectedSub, expected := CheckUserPermissionsForSubscription(dataBase, id, userLogin) + expectedSub, expected := CheckUserPermissionsForSubscription(dataBase, id, userLogin, auth) So(expected, ShouldResemble, api.ErrorForbidden("you are not permitted")) So(expectedSub, ShouldResemble, moira.SubscriptionData{}) }) @@ -304,7 +305,7 @@ func TestCheckUserPermissionsForSubscription(t *testing.T) { Convey("Has subscription", t, func() { actualSub := moira.SubscriptionData{ID: id, User: userLogin} dataBase.EXPECT().GetSubscription(id).Return(actualSub, nil) - expectedSub, expected := CheckUserPermissionsForSubscription(dataBase, id, userLogin) + expectedSub, expected := CheckUserPermissionsForSubscription(dataBase, id, userLogin, auth) So(expected, ShouldBeNil) So(expectedSub, ShouldResemble, actualSub) }) @@ -312,7 +313,7 @@ func TestCheckUserPermissionsForSubscription(t *testing.T) { Convey("Error get contact", t, func() { err := fmt.Errorf("oooops! Can not read contact") dataBase.EXPECT().GetSubscription(id).Return(moira.SubscriptionData{}, err) - expectedSub, expected := CheckUserPermissionsForSubscription(dataBase, id, userLogin) + expectedSub, expected := CheckUserPermissionsForSubscription(dataBase, id, userLogin, auth) So(expected, ShouldResemble, api.ErrorInternalServer(err)) So(expectedSub, ShouldResemble, moira.SubscriptionData{}) }) @@ -322,14 +323,14 @@ func TestCheckUserPermissionsForSubscription(t *testing.T) { expectedSub := moira.SubscriptionData{ID: id, TeamID: teamID} dataBase.EXPECT().GetSubscription(id).Return(expectedSub, nil) dataBase.EXPECT().IsTeamContainUser(teamID, userLogin).Return(true, nil) - actual, err := CheckUserPermissionsForSubscription(dataBase, id, userLogin) + actual, err := CheckUserPermissionsForSubscription(dataBase, id, userLogin, auth) So(err, ShouldBeNil) So(actual, ShouldResemble, expectedSub) }) Convey("User is not in team", func() { dataBase.EXPECT().GetSubscription(id).Return(moira.SubscriptionData{ID: id, TeamID: teamID}, nil) dataBase.EXPECT().IsTeamContainUser(teamID, userLogin).Return(false, nil) - actual, err := CheckUserPermissionsForSubscription(dataBase, id, userLogin) + actual, err := CheckUserPermissionsForSubscription(dataBase, id, userLogin, auth) So(err, ShouldResemble, api.ErrorForbidden("you are not permitted")) So(actual, ShouldResemble, moira.SubscriptionData{}) }) @@ -337,13 +338,48 @@ func TestCheckUserPermissionsForSubscription(t *testing.T) { errReturned := errors.New("test error") dataBase.EXPECT().GetSubscription(id).Return(moira.SubscriptionData{ID: id, TeamID: teamID}, nil) dataBase.EXPECT().IsTeamContainUser(teamID, userLogin).Return(false, errReturned) - actual, err := CheckUserPermissionsForSubscription(dataBase, id, userLogin) + actual, err := CheckUserPermissionsForSubscription(dataBase, id, userLogin, auth) So(err, ShouldResemble, api.ErrorInternalServer(errReturned)) So(actual, ShouldResemble, moira.SubscriptionData{}) }) }) } +func TestCheckAdminPermissionsForSubscription(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + dataBase := mock_moira_alert.NewMockDatabase(mockCtrl) + teamID := uuid.Must(uuid.NewV4()).String() + id := uuid.Must(uuid.NewV4()).String() + adminLogin := "admin_login" + auth := &api.Authorization{Enabled: true, AdminList: map[string]struct{}{adminLogin: {}}} + + Convey("Same user", t, func() { + expectedSub := moira.SubscriptionData{ID: id, User: adminLogin} + dataBase.EXPECT().GetSubscription(id).Return(expectedSub, nil) + actualContact, errorResponse := CheckUserPermissionsForSubscription(dataBase, id, adminLogin, auth) + So(errorResponse, ShouldBeNil) + So(actualContact, ShouldResemble, expectedSub) + }) + + Convey("Different user", t, func() { + expectedSub := moira.SubscriptionData{ID: id, User: "diffUser"} + dataBase.EXPECT().GetSubscription(id).Return(expectedSub, nil) + actualContact, errorResponse := CheckUserPermissionsForSubscription(dataBase, id, adminLogin, auth) + So(errorResponse, ShouldBeNil) + So(actualContact, ShouldResemble, expectedSub) + }) + + Convey("Team contact", t, func() { + expectedSub := moira.SubscriptionData{ID: id, TeamID: teamID} + dataBase.EXPECT().GetSubscription(id).Return(expectedSub, nil) + actualContact, errorResponse := CheckUserPermissionsForSubscription(dataBase, id, adminLogin, auth) + So(errorResponse, ShouldBeNil) + So(actualContact, ShouldResemble, expectedSub) + }) +} + func Test_isSubscriptionExists(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() diff --git a/api/controller/team.go b/api/controller/team.go index f22558524..37c23b18e 100644 --- a/api/controller/team.go +++ b/api/controller/team.go @@ -400,7 +400,15 @@ func removeUserTeam(teams []string, teamID string) ([]string, error) { return []string{}, fmt.Errorf("cannot find team in user teams: %s", teamID) } -func CheckUserPermissionsForTeam(dataBase moira.Database, teamID, userID string) *api.ErrorResponse { +func CheckUserPermissionsForTeam( + dataBase moira.Database, + teamID, userID string, + auth *api.Authorization, +) *api.ErrorResponse { + if auth.IsAdmin(userID) { + return nil + } + _, err := dataBase.GetTeam(teamID) if err != nil { if errors.Is(err, database.ErrNil) { diff --git a/api/controller/team_test.go b/api/controller/team_test.go index d74b7c870..7d32078a1 100644 --- a/api/controller/team_test.go +++ b/api/controller/team_test.go @@ -591,6 +591,7 @@ func TestSetTeamUsers(t *testing.T) { func TestCheckUserPermissionsForTeam(t *testing.T) { const teamID = "testTeam" const userID = "userID" + auth := &api.Authorization{} Convey("CheckUserPermissionsForTeam", t, func() { mockCtrl := gomock.NewController(t) @@ -600,31 +601,31 @@ func TestCheckUserPermissionsForTeam(t *testing.T) { Convey("user in team", func() { dataBase.EXPECT().GetTeam(teamID).Return(moira.Team{}, nil) dataBase.EXPECT().IsTeamContainUser(teamID, userID).Return(true, nil) - err := CheckUserPermissionsForTeam(dataBase, teamID, userID) + err := CheckUserPermissionsForTeam(dataBase, teamID, userID, auth) So(err, ShouldBeNil) }) Convey("user is not in team", func() { dataBase.EXPECT().GetTeam(teamID).Return(moira.Team{}, nil) dataBase.EXPECT().IsTeamContainUser(teamID, userID).Return(false, nil) - err := CheckUserPermissionsForTeam(dataBase, teamID, userID) + err := CheckUserPermissionsForTeam(dataBase, teamID, userID, auth) So(err, ShouldResemble, api.ErrorForbidden("you are not permitted to manipulate with this team")) }) Convey("error while checking user", func() { returnErr := errors.New("returning error") dataBase.EXPECT().GetTeam(teamID).Return(moira.Team{}, nil) dataBase.EXPECT().IsTeamContainUser(teamID, userID).Return(false, returnErr) - err := CheckUserPermissionsForTeam(dataBase, teamID, userID) + err := CheckUserPermissionsForTeam(dataBase, teamID, userID, auth) So(err, ShouldResemble, api.ErrorInternalServer(returnErr)) }) Convey("error while getting team", func() { returnErr := errors.New("returning error") dataBase.EXPECT().GetTeam(teamID).Return(moira.Team{}, returnErr) - err := CheckUserPermissionsForTeam(dataBase, teamID, userID) + err := CheckUserPermissionsForTeam(dataBase, teamID, userID, auth) So(err, ShouldResemble, api.ErrorInternalServer(returnErr)) }) Convey("team is not exist", func() { dataBase.EXPECT().GetTeam(teamID).Return(moira.Team{}, database.ErrNil) - err := CheckUserPermissionsForTeam(dataBase, teamID, userID) + err := CheckUserPermissionsForTeam(dataBase, teamID, userID, auth) So(err, ShouldResemble, api.ErrorNotFound("team with ID 'testTeam' does not exists")) }) }) diff --git a/api/handler/contact.go b/api/handler/contact.go index de40e75cf..21a508d7d 100644 --- a/api/handler/contact.go +++ b/api/handler/contact.go @@ -116,7 +116,8 @@ func contactFilter(next http.Handler) http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { contactID := middleware.GetContactID(request) userLogin := middleware.GetLogin(request) - contactData, err := controller.CheckUserPermissionsForContact(database, contactID, userLogin) + auth := middleware.GetAuth(request) + contactData, err := controller.CheckUserPermissionsForContact(database, contactID, userLogin, auth) if err != nil { render.Render(writer, request, err) //nolint return diff --git a/api/handler/handler.go b/api/handler/handler.go index 898a1ba62..78c03f1e8 100644 --- a/api/handler/handler.go +++ b/api/handler/handler.go @@ -92,6 +92,7 @@ func NewHandler( // @tag.description APIs for interacting with Moira users router.Route("/api", func(router chi.Router) { router.Use(moiramiddle.DatabaseContext(database)) + router.Use(moiramiddle.AuthorizationContext(&apiConfig.Authorization)) router.Route("/health", health) router.Route("/", func(router chi.Router) { router.Use(moiramiddle.ReadOnlyMiddleware(apiConfig)) diff --git a/api/handler/health.go b/api/handler/health.go index e8affaa7f..16bbd01f7 100644 --- a/api/handler/health.go +++ b/api/handler/health.go @@ -8,11 +8,14 @@ import ( "github.com/moira-alert/moira/api" "github.com/moira-alert/moira/api/controller" "github.com/moira-alert/moira/api/dto" + "github.com/moira-alert/moira/api/middleware" ) func health(router chi.Router) { router.Get("/notifier", getNotifierState) - router.Put("/notifier", setNotifierState) + + router.With(middleware.AdminOnlyMiddleware()). + Put("/notifier", setNotifierState) } // nolint: gofmt,goimports @@ -38,6 +41,17 @@ func getNotifierState(writer http.ResponseWriter, request *http.Request) { } } +// nolint: gofmt,goimports +// +// @summary Set notifier state +// @id set-notifier-state +// @tags health +// @produce json +// @success 200 {object} dto.NotifierState "Notifier state retrieved" +// @failure 403 {object} api.ErrorForbiddenExample "Forbidden" +// @failure 422 {object} api.ErrorRenderExample "Render error" +// @failure 500 {object} api.ErrorInternalServerExample "Internal server error" +// @router /health/notifier [put] func setNotifierState(writer http.ResponseWriter, request *http.Request) { state := &dto.NotifierState{} if err := render.Bind(request, state); err != nil { diff --git a/api/handler/health_test.go b/api/handler/health_test.go new file mode 100644 index 000000000..26c9deab7 --- /dev/null +++ b/api/handler/health_test.go @@ -0,0 +1,79 @@ +package handler + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/golang/mock/gomock" + "github.com/moira-alert/moira/api" + "github.com/moira-alert/moira/api/dto" + "github.com/moira-alert/moira/logging/zerolog_adapter" + mock_moira_alert "github.com/moira-alert/moira/mock/moira-alert" + . "github.com/smartystreets/goconvey/convey" +) + +func TestSetHealthWithAuth(t *testing.T) { + Convey("Authorization enabled", t, func() { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + responseWriter := httptest.NewRecorder() + mockDb := mock_moira_alert.NewMockDatabase(mockCtrl) + database = mockDb + + logger, _ := zerolog_adapter.GetLogger("Test") + + adminLogin := "admin_login" + config := &api.Config{Authorization: api.Authorization{ + Enabled: true, + AdminList: map[string]struct{}{adminLogin: {}}, + }} + webConfig := &api.WebConfig{ + SupportEmail: "test", + Contacts: []api.WebContact{}, + } + handler := NewHandler(mockDb, logger, nil, config, nil, webConfig) + + Convey("Admin tries to set notifier state", func() { + mockDb.EXPECT().SetNotifierState("OK").Return(nil).Times(1) + + state := &dto.NotifierState{ + State: "OK", + } + + stateBytes, err := json.Marshal(state) + So(err, ShouldBeNil) + + testRequest := httptest.NewRequest(http.MethodPut, "/api/health/notifier", bytes.NewReader(stateBytes)) + testRequest.Header.Set("x-webauth-user", adminLogin) + + handler.ServeHTTP(responseWriter, testRequest) + + response := responseWriter.Result() + defer response.Body.Close() + So(response.StatusCode, ShouldEqual, http.StatusOK) + }) + + Convey("Non-admin tries to set notifier state", func() { + state := &dto.NotifierState{ + State: "OK", + } + + stateBytes, err := json.Marshal(state) + So(err, ShouldBeNil) + + testRequest := httptest.NewRequest(http.MethodPut, "/api/health/notifier", bytes.NewReader(stateBytes)) + testRequest.Header.Set("x-webauth-user", "non-admin") + + handler.ServeHTTP(responseWriter, testRequest) + + response := responseWriter.Result() + defer response.Body.Close() + + So(response.StatusCode, ShouldEqual, http.StatusForbidden) + }) + }) +} diff --git a/api/handler/notification.go b/api/handler/notification.go index 92d879f00..996a92352 100644 --- a/api/handler/notification.go +++ b/api/handler/notification.go @@ -10,12 +10,17 @@ import ( "github.com/go-chi/render" "github.com/moira-alert/moira/api" "github.com/moira-alert/moira/api/controller" + "github.com/moira-alert/moira/api/middleware" ) func notification(router chi.Router) { router.Get("/", getNotification) - router.Delete("/", deleteNotification) - router.Delete("/all", deleteAllNotifications) + + router.Route("/", func(r chi.Router) { + r.Use(middleware.AdminOnlyMiddleware()) + r.Delete("/", deleteNotification) + r.Delete("/all", deleteAllNotifications) + }) } // nolint: gofmt,goimports @@ -68,6 +73,7 @@ func getNotification(writer http.ResponseWriter, request *http.Request) { // @produce json // @success 200 {object} dto.NotificationDeleteResponse "Notification have been deleted" // @failure 400 {object} api.ErrorInvalidRequestExample "Bad request from client" +// @failure 403 {object} api.ErrorForbiddenExample "Forbidden" // @failure 422 {object} api.ErrorRenderExample "Render error" // @failure 500 {object} api.ErrorInternalServerExample "Internal server error" // @router /notification [delete] @@ -95,6 +101,16 @@ func deleteNotification(writer http.ResponseWriter, request *http.Request) { } } +// nolint: gofmt,goimports +// +// @summary Delete all notifications +// @id delete-all-notifications +// @tags notification +// @produce json +// @success 200 {object} dto.NotificationsList "Notification have been deleted" +// @failure 403 {object} api.ErrorForbiddenExample "Forbidden" +// @failure 500 {object} api.ErrorInternalServerExample "Internal server error" +// @router /notification [delete] func deleteAllNotifications(writer http.ResponseWriter, request *http.Request) { if errorResponse := controller.DeleteAllNotifications(database); errorResponse != nil { render.Render(writer, request, errorResponse) //nolint diff --git a/api/handler/pattern.go b/api/handler/pattern.go index 148c6b98f..149ccfe0d 100644 --- a/api/handler/pattern.go +++ b/api/handler/pattern.go @@ -12,6 +12,8 @@ import ( ) func pattern(router chi.Router) { + router.Use(middleware.AdminOnlyMiddleware()) + router.Get("/", getAllPatterns) router.Delete("/{pattern}", deletePattern) } @@ -23,6 +25,7 @@ func pattern(router chi.Router) { // @tags pattern // @produce json // @success 200 {object} dto.PatternList "Patterns fetched successfully" +// @failure 403 {object} api.ErrorForbiddenExample "Forbidden" // @Failure 422 {object} api.ErrorRenderExample "Render error" // @Failure 500 {object} api.ErrorInternalServerExample "Internal server error" // @router /pattern [get] @@ -46,6 +49,7 @@ func getAllPatterns(writer http.ResponseWriter, request *http.Request) { // @produce json // @param pattern path string true "Trigger pattern to operate on" default(DevOps.my_server.hdd.freespace_mbytes) // @success 200 "Pattern deleted successfully" +// @failure 403 {object} api.ErrorForbiddenExample "Forbidden" // @failure 400 {object} api.ErrorInvalidRequestExample "Bad request from client" // @failure 500 {object} api.ErrorInternalServerExample "Internal server error" // @router /pattern/{pattern} [delete] diff --git a/api/handler/subscription.go b/api/handler/subscription.go index b21ebda46..b86e2a267 100644 --- a/api/handler/subscription.go +++ b/api/handler/subscription.go @@ -91,7 +91,8 @@ func subscriptionFilter(next http.Handler) http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { contactID := middleware.GetSubscriptionID(request) userLogin := middleware.GetLogin(request) - subscriptionData, err := controller.CheckUserPermissionsForSubscription(database, contactID, userLogin) + auth := middleware.GetAuth(request) + subscriptionData, err := controller.CheckUserPermissionsForSubscription(database, contactID, userLogin, auth) if err != nil { render.Render(writer, request, err) //nolint return diff --git a/api/handler/team.go b/api/handler/team.go index 398523845..c15a3bbb3 100644 --- a/api/handler/team.go +++ b/api/handler/team.go @@ -35,9 +35,10 @@ func teams(router chi.Router) { // usersFilterForTeams is middleware that checks that user exists in this. func usersFilterForTeams(next http.Handler) http.Handler { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - userID := middleware.GetLogin(request) + userLogin := middleware.GetLogin(request) teamID := middleware.GetTeamID(request) - err := controller.CheckUserPermissionsForTeam(database, teamID, userID) + auth := middleware.GetAuth(request) + err := controller.CheckUserPermissionsForTeam(database, teamID, userLogin, auth) if err != nil { render.Render(writer, request, err) //nolint return diff --git a/api/middleware/authorization.go b/api/middleware/authorization.go new file mode 100644 index 000000000..ac282741a --- /dev/null +++ b/api/middleware/authorization.go @@ -0,0 +1,25 @@ +package middleware + +import ( + "net/http" + + "github.com/go-chi/render" + "github.com/moira-alert/moira/api" +) + +// AdminOnlyMiddleware returns 403 if request for made by non-admin user. +func AdminOnlyMiddleware() func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + auth := GetAuth(r) + userLogin := GetLogin(r) + + if auth.IsEnabled() && !auth.IsAdmin(userLogin) { + render.Render(w, r, api.ErrorForbidden("Only administrators can use this")) //nolint:errcheck + return + } + next.ServeHTTP(w, r) + } + return http.HandlerFunc(fn) + } +} diff --git a/api/middleware/context.go b/api/middleware/context.go index c81391077..5dbc88e34 100644 --- a/api/middleware/context.go +++ b/api/middleware/context.go @@ -267,3 +267,13 @@ func TeamUserIDContext(next http.Handler) http.Handler { next.ServeHTTP(writer, request.WithContext(ctx)) }) } + +// AuthorizationContext sets given authorization configuration to request context. +func AuthorizationContext(auth *api.Authorization) func(next http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + ctx := context.WithValue(request.Context(), authKey, auth) + next.ServeHTTP(writer, request.WithContext(ctx)) + }) + } +} diff --git a/api/middleware/middleware.go b/api/middleware/middleware.go index 51ab6b374..a520f01b4 100644 --- a/api/middleware/middleware.go +++ b/api/middleware/middleware.go @@ -6,6 +6,7 @@ import ( "time" "github.com/moira-alert/moira" + "github.com/moira-alert/moira/api" metricSource "github.com/moira-alert/moira/metric_source" ) @@ -37,6 +38,7 @@ var ( targetNameKey ContextKey = "target" teamIDKey ContextKey = "teamID" teamUserIDKey ContextKey = "teamUserIDKey" + authKey ContextKey = "auth" anonymousUser = "anonymous" ) @@ -155,3 +157,8 @@ func GetTeamUserID(request *http.Request) string { func SetContextValueForTest(ctx context.Context, key string, value interface{}) context.Context { return context.WithValue(ctx, ContextKey(key), value) } + +// GetAuth gets authorization configuration. +func GetAuth(request *http.Request) *api.Authorization { + return request.Context().Value(authKey).(*api.Authorization) +} diff --git a/cmd/api/config.go b/cmd/api/config.go index 1fc82e7a8..33f8b08ee 100644 --- a/cmd/api/config.go +++ b/cmd/api/config.go @@ -46,6 +46,15 @@ type apiConfig struct { Listen string `yaml:"listen"` // If true, CORS for cross-domain requests will be enabled. This option can be used only for debugging purposes. EnableCORS bool `yaml:"enable_cors"` + // Authorization contains authorization configuration. + Authorization authorization `yaml:"authorization"` +} + +type authorization struct { + // True if should limit non-admins and give admins additional privileges. + Enabled bool `yaml:"enabled"` + // List of logins of users who are considered to be admins. + AdminList []string `yaml:"admin_list"` } type sentryConfig struct { @@ -99,10 +108,22 @@ func (config *apiConfig) getSettings( flags api.FeatureFlags, ) *api.Config { return &api.Config{ - EnableCORS: config.EnableCORS, - Listen: config.Listen, - MetricsTTL: metricsTTL, - Flags: flags, + EnableCORS: config.EnableCORS, + Listen: config.Listen, + MetricsTTL: metricsTTL, + Flags: flags, + Authorization: config.Authorization.toApiConfig(), + } +} + +func (auth *authorization) toApiConfig() api.Authorization { + adminList := make(map[string]struct{}, len(auth.AdminList)) + for _, admin := range auth.AdminList { + adminList[admin] = struct{}{} + } + return api.Authorization{ + Enabled: auth.Enabled, + AdminList: adminList, } } diff --git a/cmd/api/config_test.go b/cmd/api/config_test.go index 4516910a5..2a8601d42 100644 --- a/cmd/api/config_test.go +++ b/cmd/api/config_test.go @@ -29,6 +29,9 @@ func Test_apiConfig_getSettings(t *testing.T) { Listen: "0000", MetricsTTL: metricTTLs, Flags: api.FeatureFlags{IsReadonlyEnabled: true}, + Authorization: api.Authorization{ + AdminList: make(map[string]struct{}), + }, } result := apiConf.getSettings(metricTTLs, api.FeatureFlags{IsReadonlyEnabled: true})