diff --git a/conf/defaults.ini b/conf/defaults.ini index 492525e6b5f..bb415721391 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -259,7 +259,7 @@ external_manage_info = viewers_can_edit = false # Editors can administrate dashboard, folders and teams they create -editors_can_own = false +editors_can_admin = false [auth] # Login cookie name diff --git a/conf/sample.ini b/conf/sample.ini index fd414c2af47..321c1120693 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -239,7 +239,7 @@ log_queries = ;viewers_can_edit = false # Editors can administrate dashboard, folders and teams they create -;editors_can_own = false +;editors_can_admin = false [auth] # Login cookie name diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index d94bacc5779..a3dc0c13cf3 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -354,6 +354,11 @@ options are `Admin` and `Editor`. e.g. : Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard. Defaults to `false`. +### editors_can_admin + +Editors can administrate dashboards, folders and teams they create. +Defaults to `false`. + ### login_hint Text used as placeholder text on login page for login/username input. diff --git a/docs/sources/permissions/organization_roles.md b/docs/sources/permissions/organization_roles.md index 626d79fad87..257982afdc4 100644 --- a/docs/sources/permissions/organization_roles.md +++ b/docs/sources/permissions/organization_roles.md @@ -28,6 +28,9 @@ Can do everything scoped to the organization. For example: - Can create and modify dashboards & alert rules. This can be disabled on specific folders and dashboards. - **Cannot** create or edit data sources nor invite new users. +This role can be tweaked via Grafana server setting [editors_can_admin]({{< relref "installation/configuration.md#editors_can_admin" >}}). If you set this to true users +with **Editor** can also administrate dashboards, folders and teams they create. Useful for enabling self organizing teams. + ## Viewer Role - View any dashboard. This can be disabled on specific folders and dashboards. diff --git a/packages/grafana-ui/src/components/DeleteButton/DeleteButton.tsx b/packages/grafana-ui/src/components/DeleteButton/DeleteButton.tsx index df65d156ab3..d262c821968 100644 --- a/packages/grafana-ui/src/components/DeleteButton/DeleteButton.tsx +++ b/packages/grafana-ui/src/components/DeleteButton/DeleteButton.tsx @@ -2,6 +2,7 @@ import React, { PureComponent, SyntheticEvent } from 'react'; interface Props { onConfirm(): void; + disabled?: boolean; } interface State { @@ -33,25 +34,22 @@ export class DeleteButton extends PureComponent { }; render() { - const { onConfirm } = this.props; - let showConfirm; - let showDeleteButton; - - if (this.state.showConfirm) { - showConfirm = 'show'; - showDeleteButton = 'hide'; - } else { - showConfirm = 'hide'; - showDeleteButton = 'show'; - } + const { onConfirm, disabled } = this.props; + const showConfirmClass = this.state.showConfirm ? 'show' : 'hide'; + const showDeleteButtonClass = this.state.showConfirm ? 'hide' : 'show'; + const disabledClass = disabled ? 'disabled btn-inverse' : ''; + const onClick = disabled ? () => {} : this.onClickDelete; return ( - + - + Cancel diff --git a/pkg/api/api.go b/pkg/api/api.go index f3dc35b6b06..86bc83f558b 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -14,6 +14,7 @@ func (hs *HTTPServer) registerRoutes() { reqGrafanaAdmin := middleware.ReqGrafanaAdmin reqEditorRole := middleware.ReqEditorRole reqOrgAdmin := middleware.ReqOrgAdmin + reqCanAccessTeams := middleware.AdminOrFeatureEnabled(hs.Cfg.EditorsCanAdmin) redirectFromLegacyDashboardURL := middleware.RedirectFromLegacyDashboardURL() redirectFromLegacyDashboardSoloURL := middleware.RedirectFromLegacyDashboardSoloURL() quota := middleware.Quota(hs.QuotaService) @@ -41,8 +42,8 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/org/users", reqOrgAdmin, hs.Index) r.Get("/org/users/new", reqOrgAdmin, hs.Index) r.Get("/org/users/invite", reqOrgAdmin, hs.Index) - r.Get("/org/teams", reqOrgAdmin, hs.Index) - r.Get("/org/teams/*", reqOrgAdmin, hs.Index) + r.Get("/org/teams", reqCanAccessTeams, hs.Index) + r.Get("/org/teams/*", reqCanAccessTeams, hs.Index) r.Get("/org/apikeys/", reqOrgAdmin, hs.Index) r.Get("/dashboard/import/", reqSignedIn, hs.Index) r.Get("/configuration", reqGrafanaAdmin, hs.Index) @@ -153,20 +154,21 @@ func (hs *HTTPServer) registerRoutes() { // team (admin permission required) apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) { - teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(CreateTeam)) - teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(UpdateTeam)) - teamsRoute.Delete("/:teamId", Wrap(DeleteTeamByID)) + teamsRoute.Post("/", bind(m.CreateTeamCommand{}), Wrap(hs.CreateTeam)) + teamsRoute.Put("/:teamId", bind(m.UpdateTeamCommand{}), Wrap(hs.UpdateTeam)) + teamsRoute.Delete("/:teamId", Wrap(hs.DeleteTeamByID)) teamsRoute.Get("/:teamId/members", Wrap(GetTeamMembers)) - teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(AddTeamMember)) - teamsRoute.Delete("/:teamId/members/:userId", Wrap(RemoveTeamMember)) - teamsRoute.Get("/:teamId/preferences", Wrap(GetTeamPreferences)) - teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateTeamPreferences)) - }, reqOrgAdmin) + teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(hs.AddTeamMember)) + teamsRoute.Put("/:teamId/members/:userId", bind(m.UpdateTeamMemberCommand{}), Wrap(hs.UpdateTeamMember)) + teamsRoute.Delete("/:teamId/members/:userId", Wrap(hs.RemoveTeamMember)) + teamsRoute.Get("/:teamId/preferences", Wrap(hs.GetTeamPreferences)) + teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(hs.UpdateTeamPreferences)) + }, reqCanAccessTeams) // team without requirement of user to be org admin apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) { teamsRoute.Get("/:teamId", Wrap(GetTeamByID)) - teamsRoute.Get("/search", Wrap(SearchTeams)) + teamsRoute.Get("/search", Wrap(hs.SearchTeams)) }) // org information available to all users. @@ -265,7 +267,7 @@ func (hs *HTTPServer) registerRoutes() { apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) { folderRoute.Get("/", Wrap(GetFolders)) folderRoute.Get("/id/:id", Wrap(GetFolderByID)) - folderRoute.Post("/", bind(m.CreateFolderCommand{}), Wrap(CreateFolder)) + folderRoute.Post("/", bind(m.CreateFolderCommand{}), Wrap(hs.CreateFolder)) folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) { folderUidRoute.Get("/", Wrap(GetFolderByUID)) diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 07c4f75778d..c47e8f31ccc 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -213,7 +213,8 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) dash := cmd.GetDashboardModel() - if dash.Id == 0 && dash.Uid == "" { + newDashboard := dash.Id == 0 && dash.Uid == "" + if newDashboard { limitReached, err := hs.QuotaService.QuotaReached(c, "dashboard") if err != nil { return Error(500, "failed to get quota", err) @@ -276,6 +277,15 @@ func (hs *HTTPServer) PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) return Error(500, "Failed to save dashboard", err) } + if hs.Cfg.EditorsCanAdmin && newDashboard { + inFolder := cmd.FolderId > 0 + err := dashboards.MakeUserAdmin(hs.Bus, cmd.OrgId, cmd.UserId, dashboard.Id, !inFolder) + if err != nil { + hs.log.Error("Could not make user admin", "dashboard", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err) + return Error(500, "Failed to make user admin of dashboard", err) + } + } + c.TimeRequest(metrics.M_Api_Dashboard_Save) return JSON(200, util.DynMap{ "status": "success", diff --git a/pkg/api/dashboard_test.go b/pkg/api/dashboard_test.go index 923bf57ce8a..ea69c049115 100644 --- a/pkg/api/dashboard_test.go +++ b/pkg/api/dashboard_test.go @@ -974,6 +974,7 @@ func postDashboardScenario(desc string, url string, routePattern string, mock *d hs := HTTPServer{ Bus: bus.GetBus(), + Cfg: setting.NewCfg(), } sc := setupScenarioContext(url) @@ -1024,6 +1025,7 @@ func restoreDashboardVersionScenario(desc string, url string, routePattern strin defer bus.ClearBusHandlers() hs := HTTPServer{ + Cfg: setting.NewCfg(), Bus: bus.GetBus(), } diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 0e08343b556..0a9a2671071 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -54,13 +54,20 @@ func GetFolderByID(c *m.ReqContext) Response { return JSON(200, toFolderDto(g, folder)) } -func CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) Response { +func (hs *HTTPServer) CreateFolder(c *m.ReqContext, cmd m.CreateFolderCommand) Response { s := dashboards.NewFolderService(c.OrgId, c.SignedInUser) err := s.CreateFolder(&cmd) if err != nil { return toFolderError(err) } + if hs.Cfg.EditorsCanAdmin { + if err := dashboards.MakeUserAdmin(hs.Bus, c.OrgId, c.SignedInUser.UserId, cmd.Result.Id, true); err != nil { + hs.log.Error("Could not make user admin", "folder", cmd.Result.Title, "user", c.SignedInUser.UserId, "error", err) + return Error(500, "Failed to make user admin of folder", err) + } + } + g := guardian.New(cmd.Result.Id, c.OrgId, c.SignedInUser) return JSON(200, toFolderDto(g, cmd.Result)) } diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index 880de338c8f..5e7184ae0c9 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" ) @@ -141,12 +142,17 @@ func createFolderScenario(desc string, url string, routePattern string, mock *fa Convey(desc+" "+url, func() { defer bus.ClearBusHandlers() + hs := HTTPServer{ + Bus: bus.GetBus(), + Cfg: setting.NewCfg(), + } + sc := setupScenarioContext(url) sc.defaultHandler = Wrap(func(c *m.ReqContext) Response { sc.context = c sc.context.SignedInUser = &m.SignedInUser{OrgId: TestOrgID, UserId: TestUserID} - return CreateFolder(c, cmd) + return hs.CreateFolder(c, cmd) }) origNewFolderService := dashboards.NewFolderService diff --git a/pkg/api/frontendsettings.go b/pkg/api/frontendsettings.go index 67a511b8b4d..cd61c2f3ebc 100644 --- a/pkg/api/frontendsettings.go +++ b/pkg/api/frontendsettings.go @@ -167,7 +167,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf "externalUserMngLinkUrl": setting.ExternalUserMngLinkUrl, "externalUserMngLinkName": setting.ExternalUserMngLinkName, "viewersCanEdit": setting.ViewersCanEdit, - "editorsCanOwn": hs.Cfg.EditorsCanOwn, + "editorsCanAdmin": hs.Cfg.EditorsCanAdmin, "disableSanitizeHtml": hs.Cfg.DisableSanitizeHtml, "buildInfo": map[string]interface{}{ "version": setting.BuildVersion, diff --git a/pkg/api/index.go b/pkg/api/index.go index 904a885b171..e7555e14621 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -327,6 +327,27 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er }) } + if (c.OrgRole == m.ROLE_EDITOR || c.OrgRole == m.ROLE_VIEWER) && hs.Cfg.EditorsCanAdmin { + cfgNode := &dtos.NavLink{ + Id: "cfg", + Text: "Configuration", + SubTitle: "Organization: " + c.OrgName, + Icon: "gicon gicon-cog", + Url: setting.AppSubUrl + "/org/teams", + Children: []*dtos.NavLink{ + { + Text: "Teams", + Id: "teams", + Description: "Manage org groups", + Icon: "gicon gicon-team", + Url: setting.AppSubUrl + "/org/teams", + }, + }, + } + + data.NavTree = append(data.NavTree, cfgNode) + } + data.NavTree = append(data.NavTree, &dtos.NavLink{ Text: "Help", SubTitle: fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, setting.BuildVersion, setting.BuildCommit), diff --git a/pkg/api/team.go b/pkg/api/team.go index 32265e5d018..ecfd8028c1b 100644 --- a/pkg/api/team.go +++ b/pkg/api/team.go @@ -4,19 +4,38 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/teamguardian" "github.com/grafana/grafana/pkg/util" ) // POST /api/teams -func CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response { +func (hs *HTTPServer) CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response { cmd.OrgId = c.OrgId - if err := bus.Dispatch(&cmd); err != nil { + + if c.OrgRole == m.ROLE_VIEWER { + return Error(403, "Not allowed to create team.", nil) + } + + if err := hs.Bus.Dispatch(&cmd); err != nil { if err == m.ErrTeamNameTaken { return Error(409, "Team name taken", err) } return Error(500, "Failed to create Team", err) } + if c.OrgRole == m.ROLE_EDITOR && hs.Cfg.EditorsCanAdmin { + addMemberCmd := m.AddTeamMemberCommand{ + UserId: c.SignedInUser.UserId, + OrgId: cmd.OrgId, + TeamId: cmd.Result.Id, + Permission: m.PERMISSION_ADMIN, + } + + if err := hs.Bus.Dispatch(&addMemberCmd); err != nil { + c.Logger.Error("Could not add creator to team.", "error", err) + } + } + return JSON(200, &util.DynMap{ "teamId": cmd.Result.Id, "message": "Team created", @@ -24,10 +43,15 @@ func CreateTeam(c *m.ReqContext, cmd m.CreateTeamCommand) Response { } // PUT /api/teams/:teamId -func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response { +func (hs *HTTPServer) UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response { cmd.OrgId = c.OrgId cmd.Id = c.ParamsInt64(":teamId") - if err := bus.Dispatch(&cmd); err != nil { + + if err := teamguardian.CanAdmin(hs.Bus, cmd.OrgId, cmd.Id, c.SignedInUser); err != nil { + return Error(403, "Not allowed to update team", err) + } + + if err := hs.Bus.Dispatch(&cmd); err != nil { if err == m.ErrTeamNameTaken { return Error(400, "Team name taken", err) } @@ -38,18 +62,26 @@ func UpdateTeam(c *m.ReqContext, cmd m.UpdateTeamCommand) Response { } // DELETE /api/teams/:teamId -func DeleteTeamByID(c *m.ReqContext) Response { - if err := bus.Dispatch(&m.DeleteTeamCommand{OrgId: c.OrgId, Id: c.ParamsInt64(":teamId")}); err != nil { +func (hs *HTTPServer) DeleteTeamByID(c *m.ReqContext) Response { + orgId := c.OrgId + teamId := c.ParamsInt64(":teamId") + user := c.SignedInUser + + if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, user); err != nil { + return Error(403, "Not allowed to delete team", err) + } + + if err := hs.Bus.Dispatch(&m.DeleteTeamCommand{OrgId: orgId, Id: teamId}); err != nil { if err == m.ErrTeamNotFound { return Error(404, "Failed to delete Team. ID not found", nil) } - return Error(500, "Failed to update Team", err) + return Error(500, "Failed to delete Team", err) } return Success("Team deleted") } // GET /api/teams/search -func SearchTeams(c *m.ReqContext) Response { +func (hs *HTTPServer) SearchTeams(c *m.ReqContext) Response { perPage := c.QueryInt("perpage") if perPage <= 0 { perPage = 1000 @@ -59,12 +91,18 @@ func SearchTeams(c *m.ReqContext) Response { page = 1 } + var userIdFilter int64 + if hs.Cfg.EditorsCanAdmin && c.OrgRole != m.ROLE_ADMIN { + userIdFilter = c.SignedInUser.UserId + } + query := m.SearchTeamsQuery{ - OrgId: c.OrgId, - Query: c.Query("query"), - Name: c.Query("name"), - Page: page, - Limit: perPage, + OrgId: c.OrgId, + Query: c.Query("query"), + Name: c.Query("name"), + UserIdFilter: userIdFilter, + Page: page, + Limit: perPage, } if err := bus.Dispatch(&query); err != nil { @@ -98,11 +136,25 @@ func GetTeamByID(c *m.ReqContext) Response { } // GET /api/teams/:teamId/preferences -func GetTeamPreferences(c *m.ReqContext) Response { - return getPreferencesFor(c.OrgId, 0, c.ParamsInt64(":teamId")) +func (hs *HTTPServer) GetTeamPreferences(c *m.ReqContext) Response { + teamId := c.ParamsInt64(":teamId") + orgId := c.OrgId + + if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil { + return Error(403, "Not allowed to view team preferences.", err) + } + + return getPreferencesFor(orgId, 0, teamId) } // PUT /api/teams/:teamId/preferences -func UpdateTeamPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response { - return updatePreferencesFor(c.OrgId, 0, c.ParamsInt64(":teamId"), &dtoCmd) +func (hs *HTTPServer) UpdateTeamPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response { + teamId := c.ParamsInt64(":teamId") + orgId := c.OrgId + + if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil { + return Error(403, "Not allowed to update team preferences.", err) + } + + return updatePreferencesFor(orgId, 0, teamId, &dtoCmd) } diff --git a/pkg/api/team_members.go b/pkg/api/team_members.go index 5b5970de6ad..54a4d8220e5 100644 --- a/pkg/api/team_members.go +++ b/pkg/api/team_members.go @@ -4,6 +4,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/bus" m "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/teamguardian" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" ) @@ -29,11 +30,15 @@ func GetTeamMembers(c *m.ReqContext) Response { } // POST /api/teams/:teamId/members -func AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response { - cmd.TeamId = c.ParamsInt64(":teamId") +func (hs *HTTPServer) AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response { cmd.OrgId = c.OrgId + cmd.TeamId = c.ParamsInt64(":teamId") - if err := bus.Dispatch(&cmd); err != nil { + if err := teamguardian.CanAdmin(hs.Bus, cmd.OrgId, cmd.TeamId, c.SignedInUser); err != nil { + return Error(403, "Not allowed to add team member", err) + } + + if err := hs.Bus.Dispatch(&cmd); err != nil { if err == m.ErrTeamNotFound { return Error(404, "Team not found", nil) } @@ -50,9 +55,48 @@ func AddTeamMember(c *m.ReqContext, cmd m.AddTeamMemberCommand) Response { }) } +// PUT /:teamId/members/:userId +func (hs *HTTPServer) UpdateTeamMember(c *m.ReqContext, cmd m.UpdateTeamMemberCommand) Response { + teamId := c.ParamsInt64(":teamId") + orgId := c.OrgId + + if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil { + return Error(403, "Not allowed to update team member", err) + } + + if c.OrgRole != m.ROLE_ADMIN { + cmd.ProtectLastAdmin = true + } + + cmd.TeamId = teamId + cmd.UserId = c.ParamsInt64(":userId") + cmd.OrgId = orgId + + if err := hs.Bus.Dispatch(&cmd); err != nil { + if err == m.ErrTeamMemberNotFound { + return Error(404, "Team member not found.", nil) + } + return Error(500, "Failed to update team member.", err) + } + return Success("Team member updated") +} + // DELETE /api/teams/:teamId/members/:userId -func RemoveTeamMember(c *m.ReqContext) Response { - if err := bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: c.OrgId, TeamId: c.ParamsInt64(":teamId"), UserId: c.ParamsInt64(":userId")}); err != nil { +func (hs *HTTPServer) RemoveTeamMember(c *m.ReqContext) Response { + orgId := c.OrgId + teamId := c.ParamsInt64(":teamId") + userId := c.ParamsInt64(":userId") + + if err := teamguardian.CanAdmin(hs.Bus, orgId, teamId, c.SignedInUser); err != nil { + return Error(403, "Not allowed to remove team member", err) + } + + protectLastAdmin := false + if c.OrgRole != m.ROLE_ADMIN { + protectLastAdmin = true + } + + if err := hs.Bus.Dispatch(&m.RemoveTeamMemberCommand{OrgId: orgId, TeamId: teamId, UserId: userId, ProtectLastAdmin: protectLastAdmin}); err != nil { if err == m.ErrTeamNotFound { return Error(404, "Team not found", nil) } diff --git a/pkg/api/team_test.go b/pkg/api/team_test.go index a1984288870..cab59cc5f98 100644 --- a/pkg/api/team_test.go +++ b/pkg/api/team_test.go @@ -3,6 +3,8 @@ package api import ( "testing" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" @@ -20,6 +22,10 @@ func TestTeamApiEndpoint(t *testing.T) { TotalCount: 2, } + hs := &HTTPServer{ + Cfg: setting.NewCfg(), + } + Convey("When searching with no parameters", func() { loggedInUserScenario("When calling GET on", "/api/teams/search", func(sc *scenarioContext) { var sentLimit int @@ -33,7 +39,7 @@ func TestTeamApiEndpoint(t *testing.T) { return nil }) - sc.handlerFunc = SearchTeams + sc.handlerFunc = hs.SearchTeams sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec() So(sentLimit, ShouldEqual, 1000) @@ -60,7 +66,7 @@ func TestTeamApiEndpoint(t *testing.T) { return nil }) - sc.handlerFunc = SearchTeams + sc.handlerFunc = hs.SearchTeams sc.fakeReqWithParams("GET", sc.url, map[string]string{"perpage": "10", "page": "2"}).exec() So(sentLimit, ShouldEqual, 10) diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go index e06409211eb..c00241ea34c 100644 --- a/pkg/middleware/auth.go +++ b/pkg/middleware/auth.go @@ -86,3 +86,20 @@ func Auth(options *AuthOptions) macaron.Handler { } } } + +// AdminOrFeatureEnabled creates a middleware that allows access +// if the signed in user is either an Org Admin or if the +// feature flag is enabled. +// Intended for when feature flags open up access to APIs that +// are otherwise only available to admins. +func AdminOrFeatureEnabled(enabled bool) macaron.Handler { + return func(c *m.ReqContext) { + if c.OrgRole == m.ROLE_ADMIN { + return + } + + if !enabled { + accessForbidden(c) + } + } +} diff --git a/pkg/models/team.go b/pkg/models/team.go index 61285db3a5f..bc8cbba8100 100644 --- a/pkg/models/team.go +++ b/pkg/models/team.go @@ -7,9 +7,12 @@ import ( // Typed errors var ( - ErrTeamNotFound = errors.New("Team not found") - ErrTeamNameTaken = errors.New("Team name is taken") - ErrTeamMemberNotFound = errors.New("Team member not found") + ErrTeamNotFound = errors.New("Team not found") + ErrTeamNameTaken = errors.New("Team name is taken") + ErrTeamMemberNotFound = errors.New("Team member not found") + ErrLastTeamAdmin = errors.New("Not allowed to remove last admin") + ErrNotAllowedToUpdateTeam = errors.New("User not allowed to update team") + ErrNotAllowedToUpdateTeamInDifferentOrg = errors.New("User not allowed to update team in another org") ) // Team model @@ -59,22 +62,24 @@ type GetTeamsByUserQuery struct { } type SearchTeamsQuery struct { - Query string - Name string - Limit int - Page int - OrgId int64 + Query string + Name string + Limit int + Page int + OrgId int64 + UserIdFilter int64 Result SearchTeamQueryResult } type TeamDTO struct { - Id int64 `json:"id"` - OrgId int64 `json:"orgId"` - Name string `json:"name"` - Email string `json:"email"` - AvatarUrl string `json:"avatarUrl"` - MemberCount int64 `json:"memberCount"` + Id int64 `json:"id"` + OrgId int64 `json:"orgId"` + Name string `json:"name"` + Email string `json:"email"` + AvatarUrl string `json:"avatarUrl"` + MemberCount int64 `json:"memberCount"` + Permission PermissionType `json:"permission"` } type SearchTeamQueryResult struct { diff --git a/pkg/models/team_member.go b/pkg/models/team_member.go index dd64787f465..0b80aef8f44 100644 --- a/pkg/models/team_member.go +++ b/pkg/models/team_member.go @@ -12,11 +12,12 @@ var ( // TeamMember model type TeamMember struct { - Id int64 - OrgId int64 - TeamId int64 - UserId int64 - External bool + Id int64 + OrgId int64 + TeamId int64 + UserId int64 + External bool // Signals that the membership has been created by an external systems, such as LDAP + Permission PermissionType Created time.Time Updated time.Time @@ -26,16 +27,26 @@ type TeamMember struct { // COMMANDS type AddTeamMemberCommand struct { - UserId int64 `json:"userId" binding:"Required"` - OrgId int64 `json:"-"` - TeamId int64 `json:"-"` - External bool `json:"-"` + UserId int64 `json:"userId" binding:"Required"` + OrgId int64 `json:"-"` + TeamId int64 `json:"-"` + External bool `json:"-"` + Permission PermissionType `json:"-"` +} + +type UpdateTeamMemberCommand struct { + UserId int64 `json:"-"` + OrgId int64 `json:"-"` + TeamId int64 `json:"-"` + Permission PermissionType `json:"permission"` + ProtectLastAdmin bool `json:"-"` } type RemoveTeamMemberCommand struct { - OrgId int64 `json:"-"` - UserId int64 - TeamId int64 + OrgId int64 `json:"-"` + UserId int64 + TeamId int64 + ProtectLastAdmin bool `json:"-"` } // ---------------------- @@ -53,12 +64,13 @@ type GetTeamMembersQuery struct { // Projections and DTOs type TeamMemberDTO struct { - OrgId int64 `json:"orgId"` - TeamId int64 `json:"teamId"` - UserId int64 `json:"userId"` - External bool `json:"-"` - Email string `json:"email"` - Login string `json:"login"` - AvatarUrl string `json:"avatarUrl"` - Labels []string `json:"labels"` + OrgId int64 `json:"orgId"` + TeamId int64 `json:"teamId"` + UserId int64 `json:"userId"` + External bool `json:"-"` + Email string `json:"email"` + Login string `json:"login"` + AvatarUrl string `json:"avatarUrl"` + Labels []string `json:"labels"` + Permission PermissionType `json:"permission"` } diff --git a/pkg/services/dashboards/acl_service.go b/pkg/services/dashboards/acl_service.go new file mode 100644 index 00000000000..864fbb80a6b --- /dev/null +++ b/pkg/services/dashboards/acl_service.go @@ -0,0 +1,55 @@ +package dashboards + +import ( + "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/models" + "time" +) + +func MakeUserAdmin(bus bus.Bus, orgId int64, userId int64, dashboardId int64, setViewAndEditPermissions bool) error { + rtEditor := models.ROLE_EDITOR + rtViewer := models.ROLE_VIEWER + + items := []*models.DashboardAcl{ + { + OrgId: orgId, + DashboardId: dashboardId, + UserId: userId, + Permission: models.PERMISSION_ADMIN, + Created: time.Now(), + Updated: time.Now(), + }, + } + + if setViewAndEditPermissions { + items = append(items, + &models.DashboardAcl{ + OrgId: orgId, + DashboardId: dashboardId, + Role: &rtEditor, + Permission: models.PERMISSION_EDIT, + Created: time.Now(), + Updated: time.Now(), + }, + &models.DashboardAcl{ + OrgId: orgId, + DashboardId: dashboardId, + Role: &rtViewer, + Permission: models.PERMISSION_VIEW, + Created: time.Now(), + Updated: time.Now(), + }, + ) + } + + aclCmd := &models.UpdateDashboardAclCommand{ + DashboardId: dashboardId, + Items: items, + } + + if err := bus.Dispatch(aclCmd); err != nil { + return err + } + + return nil +} diff --git a/pkg/services/sqlstore/migrations/team_mig.go b/pkg/services/sqlstore/migrations/team_mig.go index 34c46ad13cf..981a4865caa 100644 --- a/pkg/services/sqlstore/migrations/team_mig.go +++ b/pkg/services/sqlstore/migrations/team_mig.go @@ -54,4 +54,8 @@ func addTeamMigrations(mg *Migrator) { mg.AddMigration("Add column external to team_member table", NewAddColumnMigration(teamMemberV1, &Column{ Name: "external", Type: DB_Bool, Nullable: true, })) + + mg.AddMigration("Add column permission to team_member table", NewAddColumnMigration(teamMemberV1, &Column{ + Name: "permission", Type: DB_SmallInt, Nullable: true, + })) } diff --git a/pkg/services/sqlstore/team.go b/pkg/services/sqlstore/team.go index 83593e6f2d7..03fd2df78fc 100644 --- a/pkg/services/sqlstore/team.go +++ b/pkg/services/sqlstore/team.go @@ -18,17 +18,30 @@ func init() { bus.AddHandler("sql", GetTeamsByUser) bus.AddHandler("sql", AddTeamMember) + bus.AddHandler("sql", UpdateTeamMember) bus.AddHandler("sql", RemoveTeamMember) bus.AddHandler("sql", GetTeamMembers) } +func getTeamSearchSqlBase() string { + return `SELECT + team.id as id, + team.org_id, + team.name as name, + team.email as email, + (SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count, + team_member.permission + FROM team as team + INNER JOIN team_member on team.id = team_member.team_id AND team_member.user_id = ? ` +} + func getTeamSelectSqlBase() string { return `SELECT team.id as id, team.org_id, team.name as name, team.email as email, - (SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count + (SELECT COUNT(*) from team_member where team_member.team_id = team.id) as member_count FROM team as team ` } @@ -91,10 +104,8 @@ func UpdateTeam(cmd *m.UpdateTeamCommand) error { // DeleteTeam will delete a team, its member and any permissions connected to the team func DeleteTeam(cmd *m.DeleteTeamCommand) error { return inTransaction(func(sess *DBSession) error { - if teamExists, err := teamExists(cmd.OrgId, cmd.Id, sess); err != nil { + if _, err := teamExists(cmd.OrgId, cmd.Id, sess); err != nil { return err - } else if !teamExists { - return m.ErrTeamNotFound } deletes := []string{ @@ -117,7 +128,7 @@ func teamExists(orgId int64, teamId int64, sess *DBSession) (bool, error) { if res, err := sess.Query("SELECT 1 from team WHERE org_id=? and id=?", orgId, teamId); err != nil { return false, err } else if len(res) != 1 { - return false, nil + return false, m.ErrTeamNotFound } return true, nil @@ -147,7 +158,12 @@ func SearchTeams(query *m.SearchTeamsQuery) error { var sql bytes.Buffer params := make([]interface{}, 0) - sql.WriteString(getTeamSelectSqlBase()) + if query.UserIdFilter > 0 { + sql.WriteString(getTeamSearchSqlBase()) + params = append(params, query.UserIdFilter) + } else { + sql.WriteString(getTeamSelectSqlBase()) + } sql.WriteString(` WHERE team.org_id = ?`) params = append(params, query.OrgId) @@ -233,19 +249,18 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error { return m.ErrTeamMemberAlreadyAdded } - if teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil { + if _, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil { return err - } else if !teamExists { - return m.ErrTeamNotFound } entity := m.TeamMember{ - OrgId: cmd.OrgId, - TeamId: cmd.TeamId, - UserId: cmd.UserId, - External: cmd.External, - Created: time.Now(), - Updated: time.Now(), + OrgId: cmd.OrgId, + TeamId: cmd.TeamId, + UserId: cmd.UserId, + External: cmd.External, + Created: time.Now(), + Updated: time.Now(), + Permission: cmd.Permission, } _, err := sess.Insert(&entity) @@ -253,13 +268,59 @@ func AddTeamMember(cmd *m.AddTeamMemberCommand) error { }) } +func getTeamMember(sess *DBSession, orgId int64, teamId int64, userId int64) (m.TeamMember, error) { + rawSql := `SELECT * FROM team_member WHERE org_id=? and team_id=? and user_id=?` + var member m.TeamMember + exists, err := sess.SQL(rawSql, orgId, teamId, userId).Get(&member) + + if err != nil { + return member, err + } + if !exists { + return member, m.ErrTeamMemberNotFound + } + + return member, nil +} + +// UpdateTeamMember updates a team member +func UpdateTeamMember(cmd *m.UpdateTeamMemberCommand) error { + return inTransaction(func(sess *DBSession) error { + member, err := getTeamMember(sess, cmd.OrgId, cmd.TeamId, cmd.UserId) + if err != nil { + return err + } + + if cmd.ProtectLastAdmin { + _, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId) + if err != nil { + return err + } + } + + if cmd.Permission != m.PERMISSION_ADMIN { // make sure we don't get invalid permission levels in store + cmd.Permission = 0 + } + + member.Permission = cmd.Permission + _, err = sess.Cols("permission").Where("org_id=? and team_id=? and user_id=?", cmd.OrgId, cmd.TeamId, cmd.UserId).Update(member) + + return err + }) +} + // RemoveTeamMember removes a member from a team func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error { return inTransaction(func(sess *DBSession) error { - if teamExists, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil { + if _, err := teamExists(cmd.OrgId, cmd.TeamId, sess); err != nil { return err - } else if !teamExists { - return m.ErrTeamNotFound + } + + if cmd.ProtectLastAdmin { + _, err := isLastAdmin(sess, cmd.OrgId, cmd.TeamId, cmd.UserId) + if err != nil { + return err + } } var rawSql = "DELETE FROM team_member WHERE org_id=? and team_id=? and user_id=?" @@ -276,6 +337,29 @@ func RemoveTeamMember(cmd *m.RemoveTeamMemberCommand) error { }) } +func isLastAdmin(sess *DBSession, orgId int64, teamId int64, userId int64) (bool, error) { + rawSql := "SELECT user_id FROM team_member WHERE org_id=? and team_id=? and permission=?" + userIds := []*int64{} + err := sess.SQL(rawSql, orgId, teamId, m.PERMISSION_ADMIN).Find(&userIds) + if err != nil { + return false, err + } + + isAdmin := false + for _, adminId := range userIds { + if userId == *adminId { + isAdmin = true + break + } + } + + if isAdmin && len(userIds) == 1 { + return true, m.ErrLastTeamAdmin + } + + return false, err +} + // GetTeamMembers return a list of members for the specified team func GetTeamMembers(query *m.GetTeamMembersQuery) error { query.Result = make([]*m.TeamMemberDTO, 0) @@ -293,7 +377,7 @@ func GetTeamMembers(query *m.GetTeamMembersQuery) error { if query.External { sess.Where("team_member.external=?", dialect.BooleanStr(true)) } - sess.Cols("team_member.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login", "team_member.external") + sess.Cols("team_member.org_id", "team_member.team_id", "team_member.user_id", "user.email", "user.login", "team_member.external", "team_member.permission") sess.Asc("user.login", "user.email") err := sess.Find(&query.Result) diff --git a/pkg/services/sqlstore/team_test.go b/pkg/services/sqlstore/team_test.go index 8f243617262..7ac78733af7 100644 --- a/pkg/services/sqlstore/team_test.go +++ b/pkg/services/sqlstore/team_test.go @@ -75,6 +75,72 @@ func TestTeamCommandsAndQueries(t *testing.T) { So(q2.Result[0].External, ShouldEqual, true) }) + Convey("Should be able to update users in a team", func() { + userId := userIds[0] + team := group1.Result + addMemberCmd := m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: team.Id, UserId: userId} + err = AddTeamMember(&addMemberCmd) + So(err, ShouldBeNil) + + qBeforeUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id} + err = GetTeamMembers(qBeforeUpdate) + So(err, ShouldBeNil) + So(qBeforeUpdate.Result[0].Permission, ShouldEqual, 0) + + err = UpdateTeamMember(&m.UpdateTeamMemberCommand{ + UserId: userId, + OrgId: testOrgId, + TeamId: team.Id, + Permission: m.PERMISSION_ADMIN, + }) + + So(err, ShouldBeNil) + + qAfterUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id} + err = GetTeamMembers(qAfterUpdate) + So(err, ShouldBeNil) + So(qAfterUpdate.Result[0].Permission, ShouldEqual, m.PERMISSION_ADMIN) + }) + + Convey("Should default to member permission level when updating a user with invalid permission level", func() { + userID := userIds[0] + team := group1.Result + addMemberCmd := m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: team.Id, UserId: userID} + err = AddTeamMember(&addMemberCmd) + So(err, ShouldBeNil) + + qBeforeUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id} + err = GetTeamMembers(qBeforeUpdate) + So(err, ShouldBeNil) + So(qBeforeUpdate.Result[0].Permission, ShouldEqual, 0) + + invalidPermissionLevel := m.PERMISSION_EDIT + err = UpdateTeamMember(&m.UpdateTeamMemberCommand{ + UserId: userID, + OrgId: testOrgId, + TeamId: team.Id, + Permission: invalidPermissionLevel, + }) + + So(err, ShouldBeNil) + + qAfterUpdate := &m.GetTeamMembersQuery{OrgId: testOrgId, TeamId: team.Id} + err = GetTeamMembers(qAfterUpdate) + So(err, ShouldBeNil) + So(qAfterUpdate.Result[0].Permission, ShouldEqual, 0) + }) + + Convey("Shouldn't be able to update a user not in the team.", func() { + err = UpdateTeamMember(&m.UpdateTeamMemberCommand{ + UserId: 1, + OrgId: testOrgId, + TeamId: group1.Result.Id, + Permission: m.PERMISSION_ADMIN, + }) + + So(err, ShouldEqual, m.ErrTeamMemberNotFound) + }) + Convey("Should be able to search for teams", func() { query := &m.SearchTeamsQuery{OrgId: testOrgId, Query: "group", Page: 1} err = SearchTeams(query) @@ -114,6 +180,33 @@ func TestTeamCommandsAndQueries(t *testing.T) { So(len(q2.Result), ShouldEqual, 0) }) + Convey("When ProtectLastAdmin is set to true", func() { + err = AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: m.PERMISSION_ADMIN}) + So(err, ShouldBeNil) + + Convey("A user should not be able to remove the last admin", func() { + err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], ProtectLastAdmin: true}) + So(err, ShouldEqual, m.ErrLastTeamAdmin) + }) + + Convey("A user should be able to remove an admin if there are other admins", func() { + AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[1], Permission: m.PERMISSION_ADMIN}) + err = RemoveTeamMember(&m.RemoveTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], ProtectLastAdmin: true}) + So(err, ShouldEqual, nil) + }) + + Convey("A user should not be able to remove the admin permission for the last admin", func() { + err = UpdateTeamMember(&m.UpdateTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: 0, ProtectLastAdmin: true}) + So(err, ShouldEqual, m.ErrLastTeamAdmin) + }) + + Convey("A user should be able to remove the admin permission if there are other admins", func() { + AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[1], Permission: m.PERMISSION_ADMIN}) + err = UpdateTeamMember(&m.UpdateTeamMemberCommand{OrgId: testOrgId, TeamId: group1.Result.Id, UserId: userIds[0], Permission: 0, ProtectLastAdmin: true}) + So(err, ShouldEqual, nil) + }) + }) + Convey("Should be able to remove a group with users and permissions", func() { groupId := group2.Result.Id err := AddTeamMember(&m.AddTeamMemberCommand{OrgId: testOrgId, TeamId: groupId, UserId: userIds[1]}) diff --git a/pkg/services/teamguardian/team.go b/pkg/services/teamguardian/team.go new file mode 100644 index 00000000000..70053d12da1 --- /dev/null +++ b/pkg/services/teamguardian/team.go @@ -0,0 +1,34 @@ +package teamguardian + +import ( + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" +) + +func CanAdmin(bus bus.Bus, orgId int64, teamId int64, user *m.SignedInUser) error { + if user.OrgRole == m.ROLE_ADMIN { + return nil + } + + if user.OrgId != orgId { + return m.ErrNotAllowedToUpdateTeamInDifferentOrg + } + + cmd := m.GetTeamMembersQuery{ + OrgId: orgId, + TeamId: teamId, + UserId: user.UserId, + } + + if err := bus.Dispatch(&cmd); err != nil { + return err + } + + for _, member := range cmd.Result { + if member.UserId == user.UserId && member.Permission == m.PERMISSION_ADMIN { + return nil + } + } + + return m.ErrNotAllowedToUpdateTeam +} diff --git a/pkg/services/teamguardian/teams_test.go b/pkg/services/teamguardian/teams_test.go new file mode 100644 index 00000000000..8af69569620 --- /dev/null +++ b/pkg/services/teamguardian/teams_test.go @@ -0,0 +1,87 @@ +package teamguardian + +import ( + "github.com/grafana/grafana/pkg/bus" + m "github.com/grafana/grafana/pkg/models" + . "github.com/smartystreets/goconvey/convey" + "testing" +) + +func TestUpdateTeam(t *testing.T) { + Convey("Updating a team", t, func() { + bus.ClearBusHandlers() + + admin := m.SignedInUser{ + UserId: 1, + OrgId: 1, + OrgRole: m.ROLE_ADMIN, + } + editor := m.SignedInUser{ + UserId: 2, + OrgId: 1, + OrgRole: m.ROLE_EDITOR, + } + testTeam := m.Team{ + Id: 1, + OrgId: 1, + } + + Convey("Given an editor and a team he isn't a member of", func() { + Convey("Should not be able to update the team", func() { + bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error { + cmd.Result = []*m.TeamMemberDTO{} + return nil + }) + + err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &editor) + So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeam) + }) + }) + + Convey("Given an editor and a team he is an admin in", func() { + Convey("Should be able to update the team", func() { + bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error { + cmd.Result = []*m.TeamMemberDTO{{ + OrgId: testTeam.OrgId, + TeamId: testTeam.Id, + UserId: editor.UserId, + Permission: m.PERMISSION_ADMIN, + }} + return nil + }) + + err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &editor) + So(err, ShouldBeNil) + }) + }) + + Convey("Given an editor and a team in another org", func() { + testTeamOtherOrg := m.Team{ + Id: 1, + OrgId: 2, + } + + Convey("Shouldn't be able to update the team", func() { + bus.AddHandler("test", func(cmd *m.GetTeamMembersQuery) error { + cmd.Result = []*m.TeamMemberDTO{{ + OrgId: testTeamOtherOrg.OrgId, + TeamId: testTeamOtherOrg.Id, + UserId: editor.UserId, + Permission: m.PERMISSION_ADMIN, + }} + return nil + }) + + err := CanAdmin(bus.GetBus(), testTeamOtherOrg.OrgId, testTeamOtherOrg.Id, &editor) + So(err, ShouldEqual, m.ErrNotAllowedToUpdateTeamInDifferentOrg) + }) + }) + + Convey("Given an org admin and a team", func() { + Convey("Should be able to update the team", func() { + err := CanAdmin(bus.GetBus(), testTeam.OrgId, testTeam.Id, &admin) + So(err, ShouldBeNil) + }) + }) + }) +} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index bc57291b5f9..8c6d8c54f11 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -239,14 +239,13 @@ type Cfg struct { LoginMaxLifetimeDays int TokenRotationIntervalMinutes int - // User - EditorsCanOwn bool - // Dataproxy SendUserHeader bool // DistributedCache RemoteCacheOptions *RemoteCacheOptions + + EditorsCanAdmin bool } type CommandLineArgs struct { @@ -670,7 +669,7 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error { ExternalUserMngLinkName = users.Key("external_manage_link_name").String() ExternalUserMngInfo = users.Key("external_manage_info").String() ViewersCanEdit = users.Key("viewers_can_edit").MustBool(false) - cfg.EditorsCanOwn = users.Key("editors_can_own").MustBool(false) + cfg.EditorsCanAdmin = users.Key("editors_can_admin").MustBool(false) // auth auth := iniFile.Section("auth") diff --git a/public/app/core/components/WithFeatureToggle.tsx b/public/app/core/components/WithFeatureToggle.tsx new file mode 100644 index 00000000000..250697e0029 --- /dev/null +++ b/public/app/core/components/WithFeatureToggle.tsx @@ -0,0 +1,13 @@ +import React, { FunctionComponent } from 'react'; + +export interface Props { + featureToggle: boolean; +} + +export const WithFeatureToggle: FunctionComponent = ({ featureToggle, children }) => { + if (featureToggle === true) { + return <>{children}; + } + + return null; +}; diff --git a/public/app/core/components/sidemenu/BottomNavLinks.test.tsx b/public/app/core/components/sidemenu/BottomNavLinks.test.tsx index b52e5311dc5..5ba4503da13 100644 --- a/public/app/core/components/sidemenu/BottomNavLinks.test.tsx +++ b/public/app/core/components/sidemenu/BottomNavLinks.test.tsx @@ -12,6 +12,7 @@ const setup = (propOverrides?: object) => { { link: {}, user: { + id: 1, isGrafanaAdmin: false, isSignedIn: false, orgCount: 2, diff --git a/public/app/core/config.ts b/public/app/core/config.ts index 9789888e60f..fe9005973b8 100644 --- a/public/app/core/config.ts +++ b/public/app/core/config.ts @@ -37,7 +37,7 @@ export class Settings { passwordHint: any; loginError: any; viewersCanEdit: boolean; - editorsCanOwn: boolean; + editorsCanAdmin: boolean; disableSanitizeHtml: boolean; theme: GrafanaTheme; @@ -59,7 +59,7 @@ export class Settings { isEnterprise: false, }, viewersCanEdit: false, - editorsCanOwn: false, + editorsCanAdmin: false, disableSanitizeHtml: false, }; diff --git a/public/app/core/services/context_srv.ts b/public/app/core/services/context_srv.ts index e3b10f129d1..214c7efb782 100644 --- a/public/app/core/services/context_srv.ts +++ b/public/app/core/services/context_srv.ts @@ -3,6 +3,7 @@ import _ from 'lodash'; import coreModule from 'app/core/core_module'; export class User { + id: number; isGrafanaAdmin: any; isSignedIn: any; orgRole: any; diff --git a/public/app/features/teams/TeamList.test.tsx b/public/app/features/teams/TeamList.test.tsx index 369771bb340..da5afb58796 100644 --- a/public/app/features/teams/TeamList.test.tsx +++ b/public/app/features/teams/TeamList.test.tsx @@ -1,8 +1,9 @@ import React from 'react'; import { shallow } from 'enzyme'; import { Props, TeamList } from './TeamList'; -import { NavModel, Team } from '../../types'; +import { NavModel, Team, OrgRole } from '../../types'; import { getMockTeam, getMultipleMockTeams } from './__mocks__/teamMocks'; +import { User } from 'app/core/services/context_srv'; const setup = (propOverrides?: object) => { const props: Props = { @@ -21,6 +22,11 @@ const setup = (propOverrides?: object) => { searchQuery: '', teamsCount: 0, hasFetched: false, + editorsCanAdmin: false, + signedInUser: { + id: 1, + orgRole: OrgRole.Viewer, + } as User, }; Object.assign(props, propOverrides); @@ -49,6 +55,42 @@ describe('Render', () => { expect(wrapper).toMatchSnapshot(); }); + + describe('when feature toggle editorsCanAdmin is turned on', () => { + describe('and signedin user is not viewer', () => { + it('should enable the new team button', () => { + const { wrapper } = setup({ + teams: getMultipleMockTeams(1), + teamsCount: 1, + hasFetched: true, + editorsCanAdmin: true, + signedInUser: { + id: 1, + orgRole: OrgRole.Editor, + } as User, + }); + + expect(wrapper).toMatchSnapshot(); + }); + }); + + describe('and signedin user is a viewer', () => { + it('should disable the new team button', () => { + const { wrapper } = setup({ + teams: getMultipleMockTeams(1), + teamsCount: 1, + hasFetched: true, + editorsCanAdmin: true, + signedInUser: { + id: 1, + orgRole: OrgRole.Viewer, + } as User, + }); + + expect(wrapper).toMatchSnapshot(); + }); + }); + }); }); describe('Life cycle', () => { diff --git a/public/app/features/teams/TeamList.tsx b/public/app/features/teams/TeamList.tsx index 60921a3378b..5d3ef005c9e 100644 --- a/public/app/features/teams/TeamList.tsx +++ b/public/app/features/teams/TeamList.tsx @@ -4,11 +4,13 @@ import { hot } from 'react-hot-loader'; import Page from 'app/core/components/Page/Page'; import { DeleteButton } from '@grafana/ui'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; -import { NavModel, Team } from 'app/types'; +import { NavModel, Team, OrgRole } from 'app/types'; import { loadTeams, deleteTeam, setSearchQuery } from './state/actions'; -import { getSearchQuery, getTeams, getTeamsCount } from './state/selectors'; +import { getSearchQuery, getTeams, getTeamsCount, isPermissionTeamAdmin } from './state/selectors'; import { getNavModel } from 'app/core/selectors/navModel'; import { FilterInput } from 'app/core/components/FilterInput/FilterInput'; +import { config } from 'app/core/config'; +import { contextSrv, User } from 'app/core/services/context_srv'; export interface Props { navModel: NavModel; @@ -19,6 +21,8 @@ export interface Props { loadTeams: typeof loadTeams; deleteTeam: typeof deleteTeam; setSearchQuery: typeof setSearchQuery; + editorsCanAdmin?: boolean; + signedInUser?: User; } export class TeamList extends PureComponent { @@ -39,7 +43,10 @@ export class TeamList extends PureComponent { }; renderTeam(team: Team) { + const { editorsCanAdmin, signedInUser } = this.props; + const permission = team.permission; const teamUrl = `org/teams/edit/${team.id}`; + const canDelete = isPermissionTeamAdmin({ permission, editorsCanAdmin, signedInUser }); return ( @@ -58,7 +65,7 @@ export class TeamList extends PureComponent { {team.memberCount} - this.deleteTeam(team)} /> + this.deleteTeam(team)} disabled={!canDelete} /> ); @@ -84,7 +91,10 @@ export class TeamList extends PureComponent { } renderTeamList() { - const { teams, searchQuery } = this.props; + const { teams, searchQuery, editorsCanAdmin, signedInUser } = this.props; + const isCanAdminAndViewer = editorsCanAdmin && signedInUser.orgRole === OrgRole.Viewer; + const disabledClass = isCanAdminAndViewer ? ' disabled' : ''; + const newTeamHref = isCanAdminAndViewer ? '#' : 'org/teams/new'; return ( <> @@ -101,7 +111,7 @@ export class TeamList extends PureComponent { @@ -152,6 +162,8 @@ function mapStateToProps(state) { searchQuery: getSearchQuery(state.teams), teamsCount: getTeamsCount(state.teams), hasFetched: state.teams.hasFetched, + editorsCanAdmin: config.editorsCanAdmin, // this makes the feature toggle mockable/controllable from tests, + signedInUser: contextSrv.user, // this makes the feature toggle mockable/controllable from tests, }; } diff --git a/public/app/features/teams/TeamMemberRow.test.tsx b/public/app/features/teams/TeamMemberRow.test.tsx new file mode 100644 index 00000000000..0607825bff3 --- /dev/null +++ b/public/app/features/teams/TeamMemberRow.test.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { TeamMember, TeamPermissionLevel } from '../../types'; +import { getMockTeamMember } from './__mocks__/teamMocks'; +import { TeamMemberRow, Props } from './TeamMemberRow'; +import { SelectOptionItem } from '@grafana/ui'; + +const setup = (propOverrides?: object) => { + const props: Props = { + member: getMockTeamMember(), + syncEnabled: false, + editorsCanAdmin: false, + signedInUserIsTeamAdmin: false, + updateTeamMember: jest.fn(), + removeTeamMember: jest.fn(), + }; + + Object.assign(props, propOverrides); + + const wrapper = shallow(); + const instance = wrapper.instance() as TeamMemberRow; + + return { + wrapper, + instance, + }; +}; + +describe('Render', () => { + it('should render team members when sync enabled', () => { + const member = getMockTeamMember(); + member.labels = ['LDAP']; + const { wrapper } = setup({ member, syncEnabled: true }); + + expect(wrapper).toMatchSnapshot(); + }); + + describe('when feature toggle editorsCanAdmin is turned on', () => { + it('should render permissions select if user is team admin', () => { + const { wrapper } = setup({ editorsCanAdmin: true, signedInUserIsTeamAdmin: true }); + + expect(wrapper).toMatchSnapshot(); + }); + + it('should render span and disable buttons if user is team member', () => { + const { wrapper } = setup({ editorsCanAdmin: true, signedInUserIsTeamAdmin: false }); + + expect(wrapper).toMatchSnapshot(); + }); + }); + + describe('when feature toggle editorsCanAdmin is turned off', () => { + it('should not render permissions', () => { + const { wrapper } = setup({ editorsCanAdmin: false, signedInUserIsTeamAdmin: true }); + + expect(wrapper).toMatchSnapshot(); + }); + }); +}); + +describe('Functions', () => { + describe('on remove member', () => { + const member = getMockTeamMember(); + const { instance } = setup({ member }); + + instance.onRemoveMember(member); + + expect(instance.props.removeTeamMember).toHaveBeenCalledWith(1); + }); + + describe('on update permision for user in team', () => { + const member: TeamMember = { + userId: 3, + teamId: 2, + avatarUrl: '', + email: 'user@user.org', + labels: [], + login: 'member', + permission: TeamPermissionLevel.Member, + }; + const { instance } = setup({ member }); + const permission = TeamPermissionLevel.Admin; + const item: SelectOptionItem = { value: permission }; + const expectedTeamMemeber = { ...member, permission }; + + instance.onPermissionChange(item, member); + + expect(instance.props.updateTeamMember).toHaveBeenCalledWith(expectedTeamMemeber); + }); +}); diff --git a/public/app/features/teams/TeamMemberRow.tsx b/public/app/features/teams/TeamMemberRow.tsx new file mode 100644 index 00000000000..0111d1efd8e --- /dev/null +++ b/public/app/features/teams/TeamMemberRow.tsx @@ -0,0 +1,106 @@ +import React, { PureComponent } from 'react'; +import { connect } from 'react-redux'; +import { DeleteButton, Select, SelectOptionItem } from '@grafana/ui'; + +import { TeamMember, teamsPermissionLevels } from 'app/types'; +import { WithFeatureToggle } from 'app/core/components/WithFeatureToggle'; +import { updateTeamMember, removeTeamMember } from './state/actions'; +import { TagBadge } from 'app/core/components/TagFilter/TagBadge'; + +export interface Props { + member: TeamMember; + syncEnabled: boolean; + editorsCanAdmin: boolean; + signedInUserIsTeamAdmin: boolean; + removeTeamMember?: typeof removeTeamMember; + updateTeamMember?: typeof updateTeamMember; +} + +export class TeamMemberRow extends PureComponent { + constructor(props: Props) { + super(props); + this.renderLabels = this.renderLabels.bind(this); + this.renderPermissions = this.renderPermissions.bind(this); + } + + onRemoveMember(member: TeamMember) { + this.props.removeTeamMember(member.userId); + } + + onPermissionChange = (item: SelectOptionItem, member: TeamMember) => { + const permission = item.value; + const updatedTeamMember = { ...member, permission }; + + this.props.updateTeamMember(updatedTeamMember); + }; + + renderPermissions(member: TeamMember) { + const { editorsCanAdmin, signedInUserIsTeamAdmin } = this.props; + const value = teamsPermissionLevels.find(dp => dp.value === member.permission); + + return ( + + +
+ {signedInUserIsTeamAdmin && ( + +
+ + + + + + +`; + +exports[`Render when feature toggle editorsCanAdmin is turned on should render permissions select if user is team admin 1`] = ` + + + + + + testUser + + + test@test.com + + + +
+