From 09f40688497f3433d96dbb2dcc2b46d8b5d0b6ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Thu, 22 Sep 2022 22:04:48 +0200 Subject: [PATCH] NavTree: Refactor out the navtree building from api/index.go and into it's own service (#55552) --- pkg/api/accesscontrol.go | 85 +-- pkg/api/api.go | 42 +- pkg/api/dtos/index.go | 53 +- pkg/api/http_server.go | 6 +- pkg/api/index.go | 739 +------------------ pkg/api/login_test.go | 3 +- pkg/api/navlinks/navlinks.go | 21 - pkg/api/org_test.go | 36 +- pkg/api/preferences_test.go | 8 +- pkg/api/quota_test.go | 12 +- pkg/middleware/middleware_test.go | 3 +- pkg/server/wire.go | 2 + pkg/services/accesscontrol/models.go | 59 ++ pkg/services/licensing/oss.go | 3 +- pkg/services/navtree/models.go | 69 ++ pkg/services/navtree/navtree.go | 10 + pkg/services/navtree/navtreeimpl/admin.go | 116 +++ pkg/services/navtree/navtreeimpl/applinks.go | 113 +++ pkg/services/navtree/navtreeimpl/navtree.go | 598 +++++++++++++++ pkg/services/serviceaccounts/models.go | 6 + 20 files changed, 1045 insertions(+), 939 deletions(-) delete mode 100644 pkg/api/navlinks/navlinks.go create mode 100644 pkg/services/navtree/models.go create mode 100644 pkg/services/navtree/navtree.go create mode 100644 pkg/services/navtree/navtreeimpl/admin.go create mode 100644 pkg/services/navtree/navtreeimpl/applinks.go create mode 100644 pkg/services/navtree/navtreeimpl/navtree.go diff --git a/pkg/api/accesscontrol.go b/pkg/api/accesscontrol.go index c8b55f4de05..3fd170c6b70 100644 --- a/pkg/api/accesscontrol.go +++ b/pkg/api/accesscontrol.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/org" - "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/grafanads" ) @@ -17,15 +16,6 @@ import ( // API related actions const ( ActionProvisioningReload = "provisioning:reload" - - ActionOrgsRead = "orgs:read" - ActionOrgsPreferencesRead = "orgs.preferences:read" - ActionOrgsQuotasRead = "orgs.quotas:read" - ActionOrgsWrite = "orgs:write" - ActionOrgsPreferencesWrite = "orgs.preferences:write" - ActionOrgsQuotasWrite = "orgs.quotas:write" - ActionOrgsDelete = "orgs:delete" - ActionOrgsCreate = "orgs:create" ) // API related scopes @@ -209,8 +199,8 @@ func (hs *HTTPServer) declareFixedRoles() error { Description: "Read an organization, such as its ID, name, address, or quotas.", Group: "Organizations", Permissions: []ac.Permission{ - {Action: ActionOrgsRead}, - {Action: ActionOrgsQuotasRead}, + {Action: ac.ActionOrgsRead}, + {Action: ac.ActionOrgsQuotasRead}, }, }, Grants: []string{string(org.RoleViewer), ac.RoleGrafanaAdmin}, @@ -223,9 +213,9 @@ func (hs *HTTPServer) declareFixedRoles() error { Description: "Read an organization, its quotas, or its preferences. Update organization properties, or its preferences.", Group: "Organizations", Permissions: ac.ConcatPermissions(orgReaderRole.Role.Permissions, []ac.Permission{ - {Action: ActionOrgsPreferencesRead}, - {Action: ActionOrgsWrite}, - {Action: ActionOrgsPreferencesWrite}, + {Action: ac.ActionOrgsPreferencesRead}, + {Action: ac.ActionOrgsWrite}, + {Action: ac.ActionOrgsPreferencesWrite}, }), }, Grants: []string{string(org.RoleAdmin)}, @@ -238,10 +228,10 @@ func (hs *HTTPServer) declareFixedRoles() error { Description: "Create, read, write, or delete an organization. Read or write an organization's quotas. Needs to be assigned globally.", Group: "Organizations", Permissions: ac.ConcatPermissions(orgReaderRole.Role.Permissions, []ac.Permission{ - {Action: ActionOrgsCreate}, - {Action: ActionOrgsWrite}, - {Action: ActionOrgsDelete}, - {Action: ActionOrgsQuotasWrite}, + {Action: ac.ActionOrgsCreate}, + {Action: ac.ActionOrgsWrite}, + {Action: ac.ActionOrgsDelete}, + {Action: ac.ActionOrgsQuotasWrite}, }), }, Grants: []string{string(ac.RoleGrafanaAdmin)}, @@ -443,63 +433,6 @@ func (hs *HTTPServer) declareFixedRoles() error { ) } -// Evaluators -// here is the list of complex evaluators we use in this package - -// orgPreferencesAccessEvaluator is used to protect the "Configure > Preferences" page access -var orgPreferencesAccessEvaluator = ac.EvalAny( - ac.EvalAll( - ac.EvalPermission(ActionOrgsRead), - ac.EvalPermission(ActionOrgsWrite), - ), - ac.EvalAll( - ac.EvalPermission(ActionOrgsPreferencesRead), - ac.EvalPermission(ActionOrgsPreferencesWrite), - ), -) - -// orgsAccessEvaluator is used to protect the "Server Admin > Orgs" page access -// (you need to have read access to update or delete orgs; read is the minimum) -var orgsAccessEvaluator = ac.EvalPermission(ActionOrgsRead) - -// orgsCreateAccessEvaluator is used to protect the "Server Admin > Orgs > New Org" page access -var orgsCreateAccessEvaluator = ac.EvalAll( - ac.EvalPermission(ActionOrgsRead), - ac.EvalPermission(ActionOrgsCreate), -) - -// teamsAccessEvaluator is used to protect the "Configuration > Teams" page access -// grants access to a user when they can either create teams or can read and update a team -var teamsAccessEvaluator = ac.EvalAny( - ac.EvalPermission(ac.ActionTeamsCreate), - ac.EvalAll( - ac.EvalPermission(ac.ActionTeamsRead), - ac.EvalAny( - ac.EvalPermission(ac.ActionTeamsWrite), - ac.EvalPermission(ac.ActionTeamsPermissionsWrite), - ), - ), -) - -// teamsEditAccessEvaluator is used to protect the "Configuration > Teams > edit" page access -var teamsEditAccessEvaluator = ac.EvalAll( - ac.EvalPermission(ac.ActionTeamsRead), - ac.EvalAny( - ac.EvalPermission(ac.ActionTeamsCreate), - ac.EvalPermission(ac.ActionTeamsWrite), - ac.EvalPermission(ac.ActionTeamsPermissionsWrite), - ), -) - -// apiKeyAccessEvaluator is used to protect the "Configuration > API keys" page access -var apiKeyAccessEvaluator = ac.EvalPermission(ac.ActionAPIKeyRead) - -// serviceAccountAccessEvaluator is used to protect the "Configuration > Service accounts" page access -var serviceAccountAccessEvaluator = ac.EvalAny( - ac.EvalPermission(serviceaccounts.ActionRead), - ac.EvalPermission(serviceaccounts.ActionCreate), -) - // Metadata helpers // getAccessControlMetadata returns the accesscontrol metadata associated with a given resource func (hs *HTTPServer) getAccessControlMetadata(c *models.ReqContext, diff --git a/pkg/api/api.go b/pkg/api/api.go index b4acf5a1224..6a89321e1d2 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -81,8 +81,8 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/profile/password", reqSignedInNoAnonymous, hs.Index) r.Get("/.well-known/change-password", redirectToChangePassword) r.Get("/profile/switch-org/:id", reqSignedInNoAnonymous, hs.ChangeActiveOrgAndRedirectToHome) - r.Get("/org/", authorize(reqOrgAdmin, orgPreferencesAccessEvaluator), hs.Index) - r.Get("/org/new", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, orgsCreateAccessEvaluator), hs.Index) + r.Get("/org/", authorize(reqOrgAdmin, ac.OrgPreferencesAccessEvaluator), hs.Index) + r.Get("/org/new", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, ac.OrgsCreateAccessEvaluator), hs.Index) r.Get("/datasources/", authorize(reqOrgAdmin, datasources.ConfigurationPageAccess), hs.Index) r.Get("/datasources/new", authorize(reqOrgAdmin, datasources.NewPageAccess), hs.Index) r.Get("/datasources/edit/*", authorize(reqOrgAdmin, datasources.EditPageAccess), hs.Index) @@ -91,7 +91,7 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/org/users/new", reqOrgAdmin, hs.Index) r.Get("/org/users/invite", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), hs.Index) r.Get("/org/teams", authorize(reqCanAccessTeams, ac.EvalPermission(ac.ActionTeamsRead)), hs.Index) - r.Get("/org/teams/edit/*", authorize(reqCanAccessTeams, teamsEditAccessEvaluator), hs.Index) + r.Get("/org/teams/edit/*", authorize(reqCanAccessTeams, ac.TeamsEditAccessEvaluator), hs.Index) r.Get("/org/teams/new", authorize(reqCanAccessTeams, ac.EvalPermission(ac.ActionTeamsCreate)), hs.Index) r.Get("/org/serviceaccounts", authorize(reqOrgAdmin, ac.EvalPermission(serviceaccounts.ActionRead)), hs.Index) r.Get("/org/serviceaccounts/:serviceAccountId", authorize(reqOrgAdmin, ac.EvalPermission(serviceaccounts.ActionRead)), hs.Index) @@ -103,8 +103,8 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/admin/users", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)), hs.Index) r.Get("/admin/users/create", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersCreate)), hs.Index) r.Get("/admin/users/edit/:id", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead)), hs.Index) - r.Get("/admin/orgs", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, orgsAccessEvaluator), hs.Index) - r.Get("/admin/orgs/edit/:id", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, orgsAccessEvaluator), hs.Index) + r.Get("/admin/orgs", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, ac.OrgsAccessEvaluator), hs.Index) + r.Get("/admin/orgs/edit/:id", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, ac.OrgsAccessEvaluator), hs.Index) r.Get("/admin/stats", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)), hs.Index) r.Get("/admin/storage/*", reqGrafanaAdmin, hs.Index) r.Get("/admin/ldap", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)), hs.Index) @@ -251,8 +251,8 @@ func (hs *HTTPServer) registerRoutes() { // org information available to all users. apiRoute.Group("/org", func(orgRoute routing.RouteRegister) { - orgRoute.Get("/", authorize(reqSignedIn, ac.EvalPermission(ActionOrgsRead)), routing.Wrap(hs.GetCurrentOrg)) - orgRoute.Get("/quotas", authorize(reqSignedIn, ac.EvalPermission(ActionOrgsQuotasRead)), routing.Wrap(hs.GetCurrentOrgQuotas)) + orgRoute.Get("/", authorize(reqSignedIn, ac.EvalPermission(ac.ActionOrgsRead)), routing.Wrap(hs.GetCurrentOrg)) + orgRoute.Get("/quotas", authorize(reqSignedIn, ac.EvalPermission(ac.ActionOrgsQuotasRead)), routing.Wrap(hs.GetCurrentOrgQuotas)) }) if hs.Features.IsEnabled(featuremgmt.FlagStorage) { @@ -262,8 +262,8 @@ func (hs *HTTPServer) registerRoutes() { // current org apiRoute.Group("/org", func(orgRoute routing.RouteRegister) { userIDScope := ac.Scope("users", "id", ac.Parameter(":userId")) - orgRoute.Put("/", authorize(reqOrgAdmin, ac.EvalPermission(ActionOrgsWrite)), routing.Wrap(hs.UpdateCurrentOrg)) - orgRoute.Put("/address", authorize(reqOrgAdmin, ac.EvalPermission(ActionOrgsWrite)), routing.Wrap(hs.UpdateCurrentOrgAddress)) + orgRoute.Put("/", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgsWrite)), routing.Wrap(hs.UpdateCurrentOrg)) + orgRoute.Put("/address", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgsWrite)), routing.Wrap(hs.UpdateCurrentOrgAddress)) orgRoute.Get("/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)), routing.Wrap(hs.GetOrgUsersForCurrentOrg)) orgRoute.Get("/users/search", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)), routing.Wrap(hs.SearchOrgUsersWithPaging)) orgRoute.Post("/users", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd, ac.ScopeUsersAll)), quota("user"), routing.Wrap(hs.AddOrgUserToCurrentOrg)) @@ -276,9 +276,9 @@ func (hs *HTTPServer) registerRoutes() { orgRoute.Patch("/invites/:code/revoke", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersAdd)), routing.Wrap(hs.RevokeInvite)) // prefs - orgRoute.Get("/preferences", authorize(reqOrgAdmin, ac.EvalPermission(ActionOrgsPreferencesRead)), routing.Wrap(hs.GetOrgPreferences)) - orgRoute.Put("/preferences", authorize(reqOrgAdmin, ac.EvalPermission(ActionOrgsPreferencesWrite)), routing.Wrap(hs.UpdateOrgPreferences)) - orgRoute.Patch("/preferences", authorize(reqOrgAdmin, ac.EvalPermission(ActionOrgsPreferencesWrite)), routing.Wrap(hs.PatchOrgPreferences)) + orgRoute.Get("/preferences", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgsPreferencesRead)), routing.Wrap(hs.GetOrgPreferences)) + orgRoute.Put("/preferences", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgsPreferencesWrite)), routing.Wrap(hs.UpdateOrgPreferences)) + orgRoute.Patch("/preferences", authorize(reqOrgAdmin, ac.EvalPermission(ac.ActionOrgsPreferencesWrite)), routing.Wrap(hs.PatchOrgPreferences)) }) // current org without requirement of user to be org admin @@ -299,28 +299,28 @@ func (hs *HTTPServer) registerRoutes() { }) // create new org - apiRoute.Post("/orgs", authorizeInOrg(reqSignedIn, ac.UseGlobalOrg, ac.EvalPermission(ActionOrgsCreate)), quota("org"), routing.Wrap(hs.CreateOrg)) + apiRoute.Post("/orgs", authorizeInOrg(reqSignedIn, ac.UseGlobalOrg, ac.EvalPermission(ac.ActionOrgsCreate)), quota("org"), routing.Wrap(hs.CreateOrg)) // search all orgs - apiRoute.Get("/orgs", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, ac.EvalPermission(ActionOrgsRead)), routing.Wrap(hs.SearchOrgs)) + apiRoute.Get("/orgs", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, ac.EvalPermission(ac.ActionOrgsRead)), routing.Wrap(hs.SearchOrgs)) // orgs (admin routes) apiRoute.Group("/orgs/:orgId", func(orgsRoute routing.RouteRegister) { userIDScope := ac.Scope("users", "id", ac.Parameter(":userId")) - orgsRoute.Get("/", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ActionOrgsRead)), routing.Wrap(hs.GetOrgByID)) - orgsRoute.Put("/", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ActionOrgsWrite)), routing.Wrap(hs.UpdateOrg)) - orgsRoute.Put("/address", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ActionOrgsWrite)), routing.Wrap(hs.UpdateOrgAddress)) - orgsRoute.Delete("/", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ActionOrgsDelete)), routing.Wrap(hs.DeleteOrgByID)) + orgsRoute.Get("/", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgsRead)), routing.Wrap(hs.GetOrgByID)) + orgsRoute.Put("/", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgsWrite)), routing.Wrap(hs.UpdateOrg)) + orgsRoute.Put("/address", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgsWrite)), routing.Wrap(hs.UpdateOrgAddress)) + orgsRoute.Delete("/", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgsDelete)), routing.Wrap(hs.DeleteOrgByID)) orgsRoute.Get("/users", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersRead)), routing.Wrap(hs.GetOrgUsers)) orgsRoute.Post("/users", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersAdd, ac.ScopeUsersAll)), routing.Wrap(hs.AddOrgUser)) orgsRoute.Patch("/users/:userId", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersWrite, userIDScope)), routing.Wrap(hs.UpdateOrgUser)) orgsRoute.Delete("/users/:userId", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersRemove, userIDScope)), routing.Wrap(hs.RemoveOrgUser)) - orgsRoute.Get("/quotas", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ActionOrgsQuotasRead)), routing.Wrap(hs.GetOrgQuotas)) - orgsRoute.Put("/quotas/:target", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ActionOrgsQuotasWrite)), routing.Wrap(hs.UpdateOrgQuota)) + orgsRoute.Get("/quotas", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgsQuotasRead)), routing.Wrap(hs.GetOrgQuotas)) + orgsRoute.Put("/quotas/:target", authorizeInOrg(reqGrafanaAdmin, ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgsQuotasWrite)), routing.Wrap(hs.UpdateOrgQuota)) }) // orgs (admin routes) - apiRoute.Get("/orgs/name/:name/", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, ac.EvalPermission(ActionOrgsRead)), routing.Wrap(hs.GetOrgByName)) + apiRoute.Get("/orgs/name/:name/", authorizeInOrg(reqGrafanaAdmin, ac.UseGlobalOrg, ac.EvalPermission(ac.ActionOrgsRead)), routing.Wrap(hs.GetOrgByName)) // auth api keys apiRoute.Group("/auth/keys", func(keysRoute routing.RouteRegister) { diff --git a/pkg/api/dtos/index.go b/pkg/api/dtos/index.go index 9ae6797231c..980f0c4bb78 100644 --- a/pkg/api/dtos/index.go +++ b/pkg/api/dtos/index.go @@ -1,6 +1,7 @@ package dtos import ( + "github.com/grafana/grafana/pkg/services/navtree" "github.com/grafana/grafana/pkg/setting" "html/template" @@ -14,7 +15,7 @@ type IndexViewData struct { GoogleAnalyticsId string GoogleAnalytics4Id string GoogleTagManagerId string - NavTree []*NavLink + NavTree []*navtree.NavLink BuildVersion string BuildCommit string Theme string @@ -31,53 +32,3 @@ type IndexViewData struct { // Nonce is a cryptographic identifier for use with Content Security Policy. Nonce string } - -const ( - // These weights may be used by an extension to reliably place - // itself in relation to a particular item in the menu. The weights - // are negative to ensure that the default items are placed above - // any items with default weight. - - WeightSavedItems = (iota - 20) * 100 - WeightCreate - WeightDashboard - WeightExplore - WeightAlerting - WeightDataConnections - WeightPlugin - WeightConfig - WeightAdmin - WeightProfile - WeightHelp -) - -const ( - NavSectionCore string = "core" - NavSectionPlugin string = "plugin" - NavSectionConfig string = "config" -) - -type NavLink struct { - Id string `json:"id,omitempty"` - Text string `json:"text"` - Description string `json:"description,omitempty"` - Section string `json:"section,omitempty"` - SubTitle string `json:"subTitle,omitempty"` - Icon string `json:"icon,omitempty"` // Available icons can be browsed in Storybook: https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview - Img string `json:"img,omitempty"` - Url string `json:"url,omitempty"` - Target string `json:"target,omitempty"` - SortWeight int64 `json:"sortWeight,omitempty"` - Divider bool `json:"divider,omitempty"` - HideFromMenu bool `json:"hideFromMenu,omitempty"` - HideFromTabs bool `json:"hideFromTabs,omitempty"` - ShowIconInNavbar bool `json:"showIconInNavbar,omitempty"` - RoundIcon bool `json:"roundIcon,omitempty"` - Children []*NavLink `json:"children,omitempty"` - HighlightText string `json:"highlightText,omitempty"` - HighlightID string `json:"highlightId,omitempty"` - EmptyMessageId string `json:"emptyMessageId,omitempty"` -} - -// NavIDCfg is the id for org configuration navigation node -const NavIDCfg = "cfg" diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 8350e0db3e4..62360dab277 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -61,6 +61,7 @@ import ( "github.com/grafana/grafana/pkg/services/live/pushhttp" "github.com/grafana/grafana/pkg/services/login" loginAttempt "github.com/grafana/grafana/pkg/services/loginattempt" + "github.com/grafana/grafana/pkg/services/navtree" "github.com/grafana/grafana/pkg/services/ngalert" "github.com/grafana/grafana/pkg/services/notifications" "github.com/grafana/grafana/pkg/services/org" @@ -111,6 +112,7 @@ type HTTPServer struct { Features *featuremgmt.FeatureManager SettingsProvider setting.Provider HooksService *hooks.HooksService + navTreeService navtree.Service CacheService *localcache.CacheService DataSourceCache datasources.CacheService AuthTokenService models.UserTokenService @@ -234,7 +236,8 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi secretsPluginMigrator spm.SecretMigrationProvider, secretsStore secretsKV.SecretsKVStore, publicDashboardsApi *publicdashboardsApi.Api, userService user.Service, tempUserService tempUser.Service, loginAttemptService loginAttempt.Service, orgService org.Service, teamService team.Service, - accesscontrolService accesscontrol.Service, dashboardThumbsService dashboardThumbs.Service, annotationRepo annotations.Repository, tagService tag.Service, + accesscontrolService accesscontrol.Service, dashboardThumbsService dashboardThumbs.Service, navTreeService navtree.Service, + annotationRepo annotations.Repository, tagService tag.Service, ) (*HTTPServer, error) { web.Env = cfg.Env m := web.New() @@ -330,6 +333,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi loginAttemptService: loginAttemptService, orgService: orgService, teamService: teamService, + navTreeService: navTreeService, accesscontrolService: accesscontrolService, annotationsRepo: annotationRepo, tagService: tagService, diff --git a/pkg/api/index.go b/pkg/api/index.go index 98bf217821e..47e3a764747 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -3,22 +3,15 @@ package api import ( "fmt" "net/http" - "path" "sort" "strings" "github.com/grafana/grafana/pkg/api/dtos" - "github.com/grafana/grafana/pkg/api/navlinks" "github.com/grafana/grafana/pkg/models" - "github.com/grafana/grafana/pkg/plugins" ac "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/correlations" "github.com/grafana/grafana/pkg/services/dashboards" - "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/org" pref "github.com/grafana/grafana/pkg/services/preference" - "github.com/grafana/grafana/pkg/services/star" "github.com/grafana/grafana/pkg/setting" ) @@ -28,736 +21,6 @@ const ( darkName = "dark" ) -func (hs *HTTPServer) getProfileNode(c *models.ReqContext) *dtos.NavLink { - // Only set login if it's different from the name - var login string - if c.SignedInUser.Login != c.SignedInUser.NameOrFallback() { - login = c.SignedInUser.Login - } - gravatarURL := dtos.GetGravatarUrl(c.Email) - - children := []*dtos.NavLink{ - { - Text: "Preferences", Id: "profile/settings", Url: hs.Cfg.AppSubURL + "/profile", Icon: "sliders-v-alt", - }, - } - - children = append(children, &dtos.NavLink{ - Text: "Notification history", Id: "profile/notifications", Url: hs.Cfg.AppSubURL + "/profile/notifications", Icon: "bell", - }) - - if setting.AddChangePasswordLink() { - children = append(children, &dtos.NavLink{ - Text: "Change password", Id: "profile/password", Url: hs.Cfg.AppSubURL + "/profile/password", - Icon: "lock", - }) - } - - if !setting.DisableSignoutMenu { - // add sign out first - children = append(children, &dtos.NavLink{ - Text: "Sign out", - Id: "sign-out", - Url: hs.Cfg.AppSubURL + "/logout", - Icon: "arrow-from-right", - Target: "_self", - HideFromTabs: true, - }) - } - - return &dtos.NavLink{ - Text: c.SignedInUser.NameOrFallback(), - SubTitle: login, - Id: "profile", - Img: gravatarURL, - Url: hs.Cfg.AppSubURL + "/profile", - Section: dtos.NavSectionConfig, - SortWeight: dtos.WeightProfile, - Children: children, - RoundIcon: true, - } -} - -func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error) { - hasAccess := ac.HasAccess(hs.AccessControl, c) - enabledPlugins, err := hs.enabledPlugins(c.Req.Context(), c.OrgID) - if err != nil { - return nil, err - } - - appLinks := []*dtos.NavLink{} - for _, plugin := range enabledPlugins[plugins.App] { - if !plugin.Pinned { - continue - } - - if !hasAccess(ac.ReqSignedIn, - ac.EvalPermission(plugins.ActionAppAccess, plugins.ScopeProvider.GetResourceScope(plugin.ID))) { - continue - } - - appLink := &dtos.NavLink{ - Text: plugin.Name, - Id: "plugin-page-" + plugin.ID, - Img: plugin.Info.Logos.Small, - Section: dtos.NavSectionPlugin, - SortWeight: dtos.WeightPlugin, - } - - if hs.Features.IsEnabled(featuremgmt.FlagTopnav) { - appLink.Url = hs.Cfg.AppSubURL + "/a/" + plugin.ID - } else { - appLink.Url = path.Join(hs.Cfg.AppSubURL, plugin.DefaultNavURL) - } - - for _, include := range plugin.Includes { - if !c.HasUserRole(include.Role) { - continue - } - - if include.Type == "page" && include.AddToNav { - var link *dtos.NavLink - if len(include.Path) > 0 { - link = &dtos.NavLink{ - Url: hs.Cfg.AppSubURL + include.Path, - Text: include.Name, - } - if include.DefaultNav && !hs.Features.IsEnabled(featuremgmt.FlagTopnav) { - appLink.Url = link.Url // Overwrite the hardcoded page logic - } - } else { - link = &dtos.NavLink{ - Url: hs.Cfg.AppSubURL + "/plugins/" + plugin.ID + "/page/" + include.Slug, - Text: include.Name, - } - } - link.Icon = include.Icon - appLink.Children = append(appLink.Children, link) - } - - if include.Type == "dashboard" && include.AddToNav { - dboardURL := include.DashboardURLPath() - if dboardURL != "" { - link := &dtos.NavLink{ - Url: path.Join(hs.Cfg.AppSubURL, dboardURL), - Text: include.Name, - } - appLink.Children = append(appLink.Children, link) - } - } - } - - if len(appLink.Children) > 0 { - // If we only have one child and it's the app default nav then remove it from children - if len(appLink.Children) == 1 && appLink.Children[0].Url == appLink.Url { - appLink.Children = []*dtos.NavLink{} - } - appLinks = append(appLinks, appLink) - } - } - - if len(appLinks) > 0 { - sort.SliceStable(appLinks, func(i, j int) bool { - return appLinks[i].Text < appLinks[j].Text - }) - } - return appLinks, nil -} - -func enableServiceAccount(hs *HTTPServer, c *models.ReqContext) bool { - hasAccess := ac.HasAccess(hs.AccessControl, c) - return hasAccess(ac.ReqOrgAdmin, serviceAccountAccessEvaluator) -} - -func (hs *HTTPServer) ReqCanAdminTeams(c *models.ReqContext) bool { - return c.OrgRole == org.RoleAdmin || (hs.Cfg.EditorsCanAdmin && c.OrgRole == org.RoleEditor) -} - -//nolint:gocyclo -func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool, prefs *pref.Preference) ([]*dtos.NavLink, error) { - hasAccess := ac.HasAccess(hs.AccessControl, c) - var navTree []*dtos.NavLink - - if hasAccess(ac.ReqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsRead)) { - starredItemsLinks, err := hs.buildStarredItemsNavLinks(c, prefs) - if err != nil { - return nil, err - } - - navTree = append(navTree, &dtos.NavLink{ - Text: "Starred", - Id: "starred", - Icon: "star", - SortWeight: dtos.WeightSavedItems, - Section: dtos.NavSectionCore, - Children: starredItemsLinks, - EmptyMessageId: "starred-empty", - }) - - dashboardChildLinks := hs.buildDashboardNavLinks(c, hasEditPerm) - - dashboardsUrl := "/dashboards" - - dashboardLink := &dtos.NavLink{ - Text: "Dashboards", - Id: "dashboards", - SubTitle: "Manage dashboards and folders", - Icon: "apps", - Url: hs.Cfg.AppSubURL + dashboardsUrl, - SortWeight: dtos.WeightDashboard, - Section: dtos.NavSectionCore, - Children: dashboardChildLinks, - } - - if hs.Features.IsEnabled(featuremgmt.FlagTopnav) { - dashboardLink.Id = "dashboards/browse" - } - - navTree = append(navTree, dashboardLink) - } - - canExplore := func(context *models.ReqContext) bool { - return c.OrgRole == org.RoleAdmin || c.OrgRole == org.RoleEditor || setting.ViewersCanEdit - } - - if setting.ExploreEnabled && hasAccess(canExplore, ac.EvalPermission(ac.ActionDatasourcesExplore)) { - navTree = append(navTree, &dtos.NavLink{ - Text: "Explore", - Id: "explore", - SubTitle: "Explore your data", - Icon: "compass", - SortWeight: dtos.WeightExplore, - Section: dtos.NavSectionCore, - Url: hs.Cfg.AppSubURL + "/explore", - }) - } - - navTree = hs.addProfile(navTree, c) - - _, uaIsDisabledForOrg := hs.Cfg.UnifiedAlerting.DisabledOrgs[c.OrgID] - uaVisibleForOrg := hs.Cfg.UnifiedAlerting.IsEnabled() && !uaIsDisabledForOrg - - if setting.AlertingEnabled != nil && *setting.AlertingEnabled { - navTree = append(navTree, hs.buildLegacyAlertNavLinks(c)...) - } else if uaVisibleForOrg { - navTree = append(navTree, hs.buildAlertNavLinks(c)...) - } - - if hs.Features.IsEnabled(featuremgmt.FlagDataConnectionsConsole) { - navTree = append(navTree, hs.buildDataConnectionsNavLink(c)) - } - - appLinks, err := hs.getAppLinks(c) - if err != nil { - return nil, err - } - - // When topnav is enabled we can test new information architecture where plugins live in Apps category - if hs.Features.IsEnabled(featuremgmt.FlagTopnav) { - navTree = append(navTree, &dtos.NavLink{ - Text: "Apps", - Icon: "apps", - Description: "App plugins", - Id: "apps", - Children: appLinks, - Section: dtos.NavSectionCore, - Url: hs.Cfg.AppSubURL + "/apps", - }) - } else { - navTree = append(navTree, appLinks...) - } - - configNodes, err := hs.setupConfigNodes(c) - if err != nil { - return navTree, err - } - - if hs.Features.IsEnabled(featuremgmt.FlagLivePipeline) { - liveNavLinks := []*dtos.NavLink{} - - liveNavLinks = append(liveNavLinks, &dtos.NavLink{ - Text: "Status", Id: "live-status", Url: hs.Cfg.AppSubURL + "/live", Icon: "exchange-alt", - }) - liveNavLinks = append(liveNavLinks, &dtos.NavLink{ - Text: "Pipeline", Id: "live-pipeline", Url: hs.Cfg.AppSubURL + "/live/pipeline", Icon: "arrow-to-right", - }) - liveNavLinks = append(liveNavLinks, &dtos.NavLink{ - Text: "Cloud", Id: "live-cloud", Url: hs.Cfg.AppSubURL + "/live/cloud", Icon: "cloud-upload", - }) - navTree = append(navTree, &dtos.NavLink{ - Id: "live", - Text: "Live", - SubTitle: "Event streaming", - Icon: "exchange-alt", - Url: hs.Cfg.AppSubURL + "/live", - Children: liveNavLinks, - Section: dtos.NavSectionConfig, - HideFromTabs: true, - }) - } - - var configNode *dtos.NavLink - var serverAdminNode *dtos.NavLink - - if len(configNodes) > 0 { - configNode = &dtos.NavLink{ - Id: dtos.NavIDCfg, - Text: "Configuration", - SubTitle: "Organization: " + c.OrgName, - Icon: "cog", - Url: configNodes[0].Url, - Section: dtos.NavSectionConfig, - SortWeight: dtos.WeightConfig, - Children: configNodes, - } - if hs.Features.IsEnabled(featuremgmt.FlagTopnav) { - configNode.Url = "/admin" - } else { - configNode.Url = configNodes[0].Url - } - navTree = append(navTree, configNode) - } - - adminNavLinks := hs.buildAdminNavLinks(c) - - if len(adminNavLinks) > 0 { - serverAdminNode = navlinks.GetServerAdminNode(adminNavLinks) - navTree = append(navTree, serverAdminNode) - } - - if hs.Features.IsEnabled(featuremgmt.FlagTopnav) { - // Move server admin into Configuration and rename to administration - if configNode != nil && serverAdminNode != nil { - configNode.Text = "Administration" - serverAdminNode.Url = "/admin/server" - serverAdminNode.HideFromTabs = false - configNode.Children = append(configNode.Children, serverAdminNode) - adminNodeIndex := len(navTree) - 1 - navTree = navTree[:adminNodeIndex] - } - } - - navTree = hs.addHelpLinks(navTree, c) - - return navTree, nil -} - -func (hs *HTTPServer) setupConfigNodes(c *models.ReqContext) ([]*dtos.NavLink, error) { - var configNodes []*dtos.NavLink - - hasAccess := ac.HasAccess(hs.AccessControl, c) - if hasAccess(ac.ReqOrgAdmin, datasources.ConfigurationPageAccess) { - configNodes = append(configNodes, &dtos.NavLink{ - Text: "Data sources", - Icon: "database", - Description: "Add and configure data sources", - Id: "datasources", - Url: hs.Cfg.AppSubURL + "/datasources", - }) - } - - if hs.Features.IsEnabled(featuremgmt.FlagCorrelations) && hasAccess(ac.ReqOrgAdmin, correlations.ConfigurationPageAccess) { - configNodes = append(configNodes, &dtos.NavLink{ - Text: "Correlations", - Icon: "gf-glue", - Description: "Add and configure correlations", - Id: "correlations", - Url: hs.Cfg.AppSubURL + "/datasources/correlations", - }) - } - - if hasAccess(ac.ReqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)) { - configNodes = append(configNodes, &dtos.NavLink{ - Text: "Users", - Id: "users", - Description: "Manage org members", - Icon: "user", - Url: hs.Cfg.AppSubURL + "/org/users", - }) - } - - if hasAccess(hs.ReqCanAdminTeams, teamsAccessEvaluator) { - configNodes = append(configNodes, &dtos.NavLink{ - Text: "Teams", - Id: "teams", - Description: "Manage org groups", - Icon: "users-alt", - Url: hs.Cfg.AppSubURL + "/org/teams", - }) - } - - // FIXME: while we don't have a permissions for listing plugins the legacy check has to stay as a default - if plugins.ReqCanAdminPlugins(hs.Cfg)(c) || hasAccess(plugins.ReqCanAdminPlugins(hs.Cfg), plugins.AdminAccessEvaluator) { - configNodes = append(configNodes, &dtos.NavLink{ - Text: "Plugins", - Id: "plugins", - Description: "View and configure plugins", - Icon: "plug", - Url: hs.Cfg.AppSubURL + "/plugins", - }) - } - - if hasAccess(ac.ReqOrgAdmin, orgPreferencesAccessEvaluator) { - configNodes = append(configNodes, &dtos.NavLink{ - Text: "Preferences", - Id: "org-settings", - Description: "Organization preferences", - Icon: "sliders-v-alt", - Url: hs.Cfg.AppSubURL + "/org", - }) - } - - hideApiKeys, _, _ := hs.kvStore.Get(c.Req.Context(), c.OrgID, "serviceaccounts", "hideApiKeys") - apiKeys, err := hs.apiKeyService.GetAllAPIKeys(c.Req.Context(), c.OrgID) - if err != nil { - return nil, err - } - apiKeysHidden := hideApiKeys == "1" && len(apiKeys) == 0 - if hasAccess(ac.ReqOrgAdmin, apiKeyAccessEvaluator) && !apiKeysHidden { - configNodes = append(configNodes, &dtos.NavLink{ - Text: "API keys", - Id: "apikeys", - Description: "Create & manage API keys", - Icon: "key-skeleton-alt", - Url: hs.Cfg.AppSubURL + "/org/apikeys", - }) - } - - if enableServiceAccount(hs, c) { - configNodes = append(configNodes, &dtos.NavLink{ - Text: "Service accounts", - Id: "serviceaccounts", - Description: "Manage service accounts", - Icon: "gf-service-account", - Url: hs.Cfg.AppSubURL + "/org/serviceaccounts", - }) - } - return configNodes, nil -} - -func (hs *HTTPServer) addProfile(navTree []*dtos.NavLink, c *models.ReqContext) []*dtos.NavLink { - if setting.ProfileEnabled && c.IsSignedIn { - navTree = append(navTree, hs.getProfileNode(c)) - } - return navTree -} - -func (hs *HTTPServer) addHelpLinks(navTree []*dtos.NavLink, c *models.ReqContext) []*dtos.NavLink { - if setting.HelpEnabled { - helpVersion := fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, setting.BuildVersion, setting.BuildCommit) - if hs.Cfg.AnonymousHideVersion && !c.IsSignedIn { - helpVersion = setting.ApplicationName - } - - navTree = append(navTree, &dtos.NavLink{ - Text: "Help", - SubTitle: helpVersion, - Id: "help", - Url: "#", - Icon: "question-circle", - SortWeight: dtos.WeightHelp, - Section: dtos.NavSectionConfig, - Children: []*dtos.NavLink{}, - }) - } - return navTree -} - -func (hs *HTTPServer) buildStarredItemsNavLinks(c *models.ReqContext, prefs *pref.Preference) ([]*dtos.NavLink, error) { - starredItemsChildNavs := []*dtos.NavLink{} - - query := star.GetUserStarsQuery{ - UserID: c.SignedInUser.UserID, - } - - starredDashboardResult, err := hs.starService.GetByUser(c.Req.Context(), &query) - if err != nil { - return nil, err - } - - starredDashboards := []*models.Dashboard{} - starredDashboardsCounter := 0 - for dashboardId := range starredDashboardResult.UserStars { - // Set a loose limit to the first 50 starred dashboards found - if starredDashboardsCounter > 50 { - break - } - starredDashboardsCounter++ - query := &models.GetDashboardQuery{ - Id: dashboardId, - OrgId: c.OrgID, - } - err := hs.DashboardService.GetDashboard(c.Req.Context(), query) - if err == nil { - starredDashboards = append(starredDashboards, query.Result) - } - } - - if len(starredDashboards) > 0 { - sort.Slice(starredDashboards, func(i, j int) bool { - return starredDashboards[i].Title < starredDashboards[j].Title - }) - for _, starredItem := range starredDashboards { - starredItemsChildNavs = append(starredItemsChildNavs, &dtos.NavLink{ - Id: starredItem.Uid, - Text: starredItem.Title, - Url: starredItem.GetUrl(), - }) - } - } - - return starredItemsChildNavs, nil -} - -func (hs *HTTPServer) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm bool) []*dtos.NavLink { - hasAccess := ac.HasAccess(hs.AccessControl, c) - hasEditPermInAnyFolder := func(c *models.ReqContext) bool { - return hasEditPerm - } - - dashboardChildNavs := []*dtos.NavLink{} - if !hs.Features.IsEnabled(featuremgmt.FlagTopnav) { - dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ - Text: "Browse", Id: "dashboards/browse", Url: hs.Cfg.AppSubURL + "/dashboards", Icon: "sitemap", - }) - } - - dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ - Text: "Playlists", Id: "dashboards/playlists", Url: hs.Cfg.AppSubURL + "/playlists", Icon: "presentation-play", - }) - - if c.IsSignedIn { - dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ - Text: "Snapshots", - Id: "dashboards/snapshots", - Url: hs.Cfg.AppSubURL + "/dashboard/snapshots", - Icon: "camera", - }) - - dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ - Text: "Library panels", - Id: "dashboards/library-panels", - Url: hs.Cfg.AppSubURL + "/library-panels", - Icon: "library-panel", - }) - } - - if hs.Features.IsEnabled(featuremgmt.FlagScenes) { - dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ - Text: "Scenes", - Id: "scenes", - Url: hs.Cfg.AppSubURL + "/scenes", - Icon: "apps", - }) - } - - if hasEditPerm { - dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ - Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true, - }) - - if hasAccess(hasEditPermInAnyFolder, ac.EvalPermission(dashboards.ActionDashboardsCreate)) { - dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ - Text: "New dashboard", Icon: "plus", Url: hs.Cfg.AppSubURL + "/dashboard/new", HideFromTabs: true, Id: "dashboards/new", ShowIconInNavbar: true, - }) - } - - if hasAccess(ac.ReqOrgAdminOrEditor, ac.EvalPermission(dashboards.ActionFoldersCreate)) { - dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ - Text: "New folder", SubTitle: "Create a new folder to organize your dashboards", Id: "dashboards/folder/new", - Icon: "plus", Url: hs.Cfg.AppSubURL + "/dashboards/folder/new", HideFromTabs: true, ShowIconInNavbar: true, - }) - } - - if hasAccess(hasEditPermInAnyFolder, ac.EvalPermission(dashboards.ActionDashboardsCreate)) { - dashboardChildNavs = append(dashboardChildNavs, &dtos.NavLink{ - Text: "Import", SubTitle: "Import dashboard from file or Grafana.com", Id: "dashboards/import", Icon: "plus", - Url: hs.Cfg.AppSubURL + "/dashboard/import", HideFromTabs: true, ShowIconInNavbar: true, - }) - } - } - return dashboardChildNavs -} - -func (hs *HTTPServer) buildLegacyAlertNavLinks(c *models.ReqContext) []*dtos.NavLink { - var alertChildNavs []*dtos.NavLink - alertChildNavs = append(alertChildNavs, &dtos.NavLink{ - Text: "Alert rules", Id: "alert-list", Url: hs.Cfg.AppSubURL + "/alerting/list", Icon: "list-ul", - }) - - if c.HasRole(org.RoleEditor) { - alertChildNavs = append(alertChildNavs, &dtos.NavLink{ - Text: "Notification channels", Id: "channels", Url: hs.Cfg.AppSubURL + "/alerting/notifications", - Icon: "comment-alt-share", - }) - } - - var alertNav = dtos.NavLink{ - Text: "Alerting", - SubTitle: "Alert rules and notifications", - Id: "alerting-legacy", - Icon: "bell", - Children: alertChildNavs, - Section: dtos.NavSectionCore, - SortWeight: dtos.WeightAlerting, - } - if hs.Features.IsEnabled(featuremgmt.FlagTopnav) { - alertNav.Url = hs.Cfg.AppSubURL + "/alerting" - } else { - alertNav.Url = hs.Cfg.AppSubURL + "/alerting/list" - } - return []*dtos.NavLink{&alertNav} -} - -func (hs *HTTPServer) buildAlertNavLinks(c *models.ReqContext) []*dtos.NavLink { - hasAccess := ac.HasAccess(hs.AccessControl, c) - var alertChildNavs []*dtos.NavLink - - if hasAccess(ac.ReqViewer, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) { - alertChildNavs = append(alertChildNavs, &dtos.NavLink{ - Text: "Alert rules", Id: "alert-list", Url: hs.Cfg.AppSubURL + "/alerting/list", Icon: "list-ul", - }) - } - - if hasAccess(ac.ReqOrgAdminOrEditor, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingNotificationsRead), ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead))) { - alertChildNavs = append(alertChildNavs, &dtos.NavLink{ - Text: "Contact points", Id: "receivers", Url: hs.Cfg.AppSubURL + "/alerting/notifications", - Icon: "comment-alt-share", SubTitle: "Manage the settings of your contact points", - }) - alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Notification policies", Id: "am-routes", Url: hs.Cfg.AppSubURL + "/alerting/routes", Icon: "sitemap"}) - } - - if hasAccess(ac.ReqViewer, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingInstanceRead), ac.EvalPermission(ac.ActionAlertingInstancesExternalRead))) { - alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Silences", Id: "silences", Url: hs.Cfg.AppSubURL + "/alerting/silences", Icon: "bell-slash"}) - alertChildNavs = append(alertChildNavs, &dtos.NavLink{Text: "Alert groups", Id: "groups", Url: hs.Cfg.AppSubURL + "/alerting/groups", Icon: "layer-group"}) - } - - if c.OrgRole == org.RoleAdmin { - alertChildNavs = append(alertChildNavs, &dtos.NavLink{ - Text: "Admin", Id: "alerting-admin", Url: hs.Cfg.AppSubURL + "/alerting/admin", - Icon: "cog", - }) - } - - if hasAccess(hs.editorInAnyFolder, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) { - alertChildNavs = append(alertChildNavs, &dtos.NavLink{ - Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true, - }) - - alertChildNavs = append(alertChildNavs, &dtos.NavLink{ - Text: "New alert rule", SubTitle: "Create an alert rule", Id: "alert", - Icon: "plus", Url: hs.Cfg.AppSubURL + "/alerting/new", HideFromTabs: true, ShowIconInNavbar: true, - }) - } - - if len(alertChildNavs) > 0 { - var alertNav = dtos.NavLink{ - Text: "Alerting", - SubTitle: "Alert rules and notifications", - Id: "alerting", - Icon: "bell", - Children: alertChildNavs, - Section: dtos.NavSectionCore, - SortWeight: dtos.WeightAlerting, - } - - if hs.Features.IsEnabled(featuremgmt.FlagTopnav) { - alertNav.Url = hs.Cfg.AppSubURL + "/alerting" - } else { - alertNav.Url = hs.Cfg.AppSubURL + "/alerting/list" - } - - return []*dtos.NavLink{&alertNav} - } - return nil -} - -func (hs *HTTPServer) buildDataConnectionsNavLink(c *models.ReqContext) *dtos.NavLink { - var children []*dtos.NavLink - var navLink *dtos.NavLink - - baseId := "data-connections" - baseUrl := hs.Cfg.AppSubURL + "/" + baseId - - children = append(children, &dtos.NavLink{ - Id: baseId + "-datasources", - Text: "Data sources", - Icon: "database", - Description: "Add and configure data sources", - Url: baseUrl + "/datasources", - }) - - children = append(children, &dtos.NavLink{ - Id: baseId + "-plugins", - Text: "Plugins", - Icon: "plug", - Description: "Manage plugins", - Url: baseUrl + "/plugins", - }) - - children = append(children, &dtos.NavLink{ - Id: baseId + "-cloud-integrations", - Text: "Cloud integrations", - Icon: "bolt", - Description: "Manage your cloud integrations", - Url: baseUrl + "/cloud-integrations", - }) - - navLink = &dtos.NavLink{ - Text: "Data Connections", - Icon: "link", - Id: baseId, - Url: baseUrl, - Children: children, - Section: dtos.NavSectionCore, - SortWeight: dtos.WeightDataConnections, - } - - return navLink -} - -func (hs *HTTPServer) buildAdminNavLinks(c *models.ReqContext) []*dtos.NavLink { - hasAccess := ac.HasAccess(hs.AccessControl, c) - hasGlobalAccess := ac.HasGlobalAccess(hs.AccessControl, hs.accesscontrolService, c) - adminNavLinks := []*dtos.NavLink{} - - if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)) { - adminNavLinks = append(adminNavLinks, &dtos.NavLink{ - Text: "Users", Id: "global-users", Url: hs.Cfg.AppSubURL + "/admin/users", Icon: "user", - }) - } - - if hasGlobalAccess(ac.ReqGrafanaAdmin, orgsAccessEvaluator) { - adminNavLinks = append(adminNavLinks, &dtos.NavLink{ - Text: "Orgs", Id: "global-orgs", Url: hs.Cfg.AppSubURL + "/admin/orgs", Icon: "building", - }) - } - - if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)) { - adminNavLinks = append(adminNavLinks, &dtos.NavLink{ - Text: "Settings", Id: "server-settings", Url: hs.Cfg.AppSubURL + "/admin/settings", Icon: "sliders-v-alt", - }) - } - - if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)) && hs.Features.IsEnabled(featuremgmt.FlagStorage) { - adminNavLinks = append(adminNavLinks, &dtos.NavLink{ - Text: "Storage", - Id: "storage", - Description: "Manage file storage", - Icon: "cube", - Url: hs.Cfg.AppSubURL + "/admin/storage", - }) - } - - if hs.Cfg.LDAPEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)) { - adminNavLinks = append(adminNavLinks, &dtos.NavLink{ - Text: "LDAP", Id: "ldap", Url: hs.Cfg.AppSubURL + "/admin/ldap", Icon: "book", - }) - } - - return adminNavLinks -} - func (hs *HTTPServer) editorInAnyFolder(c *models.ReqContext) bool { hasEditPermissionInFoldersQuery := models.HasEditPermissionInFoldersQuery{SignedInUser: c.SignedInUser} if err := hs.DashboardService.HasEditPermissionInFolders(c.Req.Context(), &hasEditPermissionInFoldersQuery); err != nil { @@ -806,7 +69,7 @@ func (hs *HTTPServer) setIndexViewData(c *models.ReqContext) (*dtos.IndexViewDat settings["appSubUrl"] = "" } - navTree, err := hs.getNavTree(c, hasEditPerm, prefs) + navTree, err := hs.navTreeService.GetNavTree(c, hasEditPerm, prefs) if err != nil { return nil, err } diff --git a/pkg/api/login_test.go b/pkg/api/login_test.go index e5dad03da54..8009b1a1c84 100644 --- a/pkg/api/login_test.go +++ b/pkg/api/login_test.go @@ -13,6 +13,7 @@ import ( "testing" loginservice "github.com/grafana/grafana/pkg/services/login" + "github.com/grafana/grafana/pkg/services/navtree" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -43,7 +44,7 @@ func fakeSetIndexViewData(t *testing.T) { data := &dtos.IndexViewData{ User: &dtos.CurrentUser{}, Settings: map[string]interface{}{}, - NavTree: []*dtos.NavLink{}, + NavTree: []*navtree.NavLink{}, } return data, nil } diff --git a/pkg/api/navlinks/navlinks.go b/pkg/api/navlinks/navlinks.go deleted file mode 100644 index 38f0668ac1c..00000000000 --- a/pkg/api/navlinks/navlinks.go +++ /dev/null @@ -1,21 +0,0 @@ -package navlinks - -import "github.com/grafana/grafana/pkg/api/dtos" - -func GetServerAdminNode(children []*dtos.NavLink) *dtos.NavLink { - url := "" - if len(children) > 0 { - url = children[0].Url - } - return &dtos.NavLink{ - Text: "Server admin", - SubTitle: "Manage all users and orgs", - HideFromTabs: true, - Id: "admin", - Icon: "shield", - Url: url, - SortWeight: dtos.WeightAdmin, - Section: dtos.NavSectionConfig, - Children: children, - } -} diff --git a/pkg/api/org_test.go b/pkg/api/org_test.go index 91259753faa..dfc2aeadc91 100644 --- a/pkg/api/org_test.go +++ b/pkg/api/org_test.go @@ -70,12 +70,12 @@ func TestAPIEndpoint_GetCurrentOrg_AccessControl(t *testing.T) { require.NoError(t, err) t.Run("AccessControl allows viewing CurrentOrg with correct permissions", func(t *testing.T) { - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsRead}}, sc.initCtx.OrgID) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsRead}}, sc.initCtx.OrgID) response := callAPI(sc.server, http.MethodGet, getCurrentOrgURL, nil, t) assert.Equal(t, http.StatusOK, response.Code) }) t.Run("AccessControl prevents viewing CurrentOrg with correct permissions in another org", func(t *testing.T) { - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsRead}}, 2) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsRead}}, 2) response := callAPI(sc.server, http.MethodGet, getCurrentOrgURL, nil, t) assert.Equal(t, http.StatusForbidden, response.Code) }) @@ -121,13 +121,13 @@ func TestAPIEndpoint_PutCurrentOrg_AccessControl(t *testing.T) { input := strings.NewReader(testUpdateOrgNameForm) t.Run("AccessControl allows updating current org with correct permissions", func(t *testing.T) { - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsWrite}}, sc.initCtx.OrgID) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsWrite}}, sc.initCtx.OrgID) response := callAPI(sc.server, http.MethodPut, putCurrentOrgURL, input, t) assert.Equal(t, http.StatusOK, response.Code) }) t.Run("AccessControl prevents updating current org with correct permissions in another org", func(t *testing.T) { - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsWrite}}, 2) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsWrite}}, 2) response := callAPI(sc.server, http.MethodPut, putCurrentOrgURL, input, t) assert.Equal(t, http.StatusForbidden, response.Code) }) @@ -171,14 +171,14 @@ func TestAPIEndpoint_PutCurrentOrgAddress_AccessControl(t *testing.T) { input := strings.NewReader(testUpdateOrgAddressForm) t.Run("AccessControl allows updating current org address with correct permissions", func(t *testing.T) { - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsWrite}}, sc.initCtx.OrgID) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsWrite}}, sc.initCtx.OrgID) response := callAPI(sc.server, http.MethodPut, putCurrentOrgAddressURL, input, t) assert.Equal(t, http.StatusOK, response.Code) }) input = strings.NewReader(testUpdateOrgAddressForm) t.Run("AccessControl prevents updating current org address with correct permissions in another org", func(t *testing.T) { - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsWrite}}, 2) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsWrite}}, 2) response := callAPI(sc.server, http.MethodPut, putCurrentOrgAddressURL, input, t) assert.Equal(t, http.StatusForbidden, response.Code) }) @@ -243,7 +243,7 @@ func TestAPIEndpoint_CreateOrgs_AccessControl(t *testing.T) { input := strings.NewReader(fmt.Sprintf(testCreateOrgCmd, 2)) t.Run("AccessControl allows creating Orgs with correct permissions", func(t *testing.T) { setInitCtxSignedInViewer(sc.initCtx) - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsCreate}}, accesscontrol.GlobalOrgID) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsCreate}}, accesscontrol.GlobalOrgID) response := callAPI(sc.server, http.MethodPost, createOrgsURL, input, t) assert.Equal(t, http.StatusOK, response.Code) }) @@ -289,13 +289,13 @@ func TestAPIEndpoint_DeleteOrgs_AccessControl(t *testing.T) { }) t.Run("AccessControl prevents deleting Orgs with correct permissions in another org", func(t *testing.T) { setInitCtxSignedInViewer(sc.initCtx) - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsDelete}}, 1) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsDelete}}, 1) response := callAPI(sc.server, http.MethodDelete, fmt.Sprintf(deleteOrgsURL, 2), nil, t) assert.Equal(t, http.StatusForbidden, response.Code) }) t.Run("AccessControl allows deleting Orgs with correct permissions", func(t *testing.T) { setInitCtxSignedInViewer(sc.initCtx) - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsDelete}}, 2) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsDelete}}, 2) response := callAPI(sc.server, http.MethodDelete, fmt.Sprintf(deleteOrgsURL, 2), nil, t) assert.Equal(t, http.StatusOK, response.Code) }) @@ -324,13 +324,13 @@ func TestAPIEndpoint_SearchOrgs_AccessControl(t *testing.T) { t.Run("AccessControl allows listing Orgs with correct permissions", func(t *testing.T) { setInitCtxSignedInViewer(sc.initCtx) - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsRead}}, accesscontrol.GlobalOrgID) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsRead}}, accesscontrol.GlobalOrgID) response := callAPI(sc.server, http.MethodGet, searchOrgsURL, nil, t) assert.Equal(t, http.StatusOK, response.Code) }) t.Run("AccessControl prevents listing Orgs with correct permissions not granted globally", func(t *testing.T) { setInitCtxSignedInViewer(sc.initCtx) - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsRead}}, 1) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsRead}}, 1) response := callAPI(sc.server, http.MethodGet, searchOrgsURL, nil, t) assert.Equal(t, http.StatusForbidden, response.Code) }) @@ -371,13 +371,13 @@ func TestAPIEndpoint_GetOrg_AccessControl(t *testing.T) { t.Run("AccessControl allows viewing another org with correct permissions", func(t *testing.T) { setInitCtxSignedInViewer(sc.initCtx) - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsRead}}, 2) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsRead}}, 2) response := callAPI(sc.server, http.MethodGet, fmt.Sprintf(getOrgsURL, 2), nil, t) assert.Equal(t, http.StatusOK, response.Code) }) t.Run("AccessControl prevents viewing another org with correct permissions in another org", func(t *testing.T) { setInitCtxSignedInViewer(sc.initCtx) - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsRead}}, 1) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsRead}}, 1) response := callAPI(sc.server, http.MethodGet, fmt.Sprintf(getOrgsURL, 2), nil, t) assert.Equal(t, http.StatusForbidden, response.Code) }) @@ -418,7 +418,7 @@ func TestAPIEndpoint_GetOrgByName_AccessControl(t *testing.T) { t.Run("AccessControl allows viewing another org with correct permissions", func(t *testing.T) { setInitCtxSignedInViewer(sc.initCtx) - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsRead}}, accesscontrol.GlobalOrgID) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsRead}}, accesscontrol.GlobalOrgID) response := callAPI(sc.server, http.MethodGet, fmt.Sprintf(getOrgsByNameURL, "TestOrg2"), nil, t) assert.Equal(t, http.StatusOK, response.Code) }) @@ -462,14 +462,14 @@ func TestAPIEndpoint_PutOrg_AccessControl(t *testing.T) { input := strings.NewReader(testUpdateOrgNameForm) t.Run("AccessControl allows updating another org with correct permissions", func(t *testing.T) { setInitCtxSignedInViewer(sc.initCtx) - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsWrite}}, 2) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsWrite}}, 2) response := callAPI(sc.server, http.MethodPut, fmt.Sprintf(putOrgsURL, 2), input, t) assert.Equal(t, http.StatusOK, response.Code) }) t.Run("AccessControl prevents updating another org with correct permissions in another org", func(t *testing.T) { setInitCtxSignedInViewer(sc.initCtx) - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsWrite}}, 1) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsWrite}}, 1) response := callAPI(sc.server, http.MethodPut, fmt.Sprintf(putOrgsURL, 2), input, t) assert.Equal(t, http.StatusForbidden, response.Code) }) @@ -514,7 +514,7 @@ func TestAPIEndpoint_PutOrgAddress_AccessControl(t *testing.T) { input := strings.NewReader(testUpdateOrgAddressForm) t.Run("AccessControl allows updating another org address with correct permissions", func(t *testing.T) { setInitCtxSignedInViewer(sc.initCtx) - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsWrite}}, 2) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsWrite}}, 2) response := callAPI(sc.server, http.MethodPut, fmt.Sprintf(putOrgsAddressURL, 2), input, t) assert.Equal(t, http.StatusOK, response.Code) }) @@ -522,7 +522,7 @@ func TestAPIEndpoint_PutOrgAddress_AccessControl(t *testing.T) { input = strings.NewReader(testUpdateOrgAddressForm) t.Run("AccessControl prevents updating another org address with correct permissions in the current org", func(t *testing.T) { setInitCtxSignedInViewer(sc.initCtx) - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsWrite}}, 1) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsWrite}}, 1) response := callAPI(sc.server, http.MethodPut, fmt.Sprintf(putOrgsAddressURL, 2), input, t) assert.Equal(t, http.StatusForbidden, response.Code) }) diff --git a/pkg/api/preferences_test.go b/pkg/api/preferences_test.go index b0c5c6fd313..3e11d2a13a6 100644 --- a/pkg/api/preferences_test.go +++ b/pkg/api/preferences_test.go @@ -83,12 +83,12 @@ func TestAPIEndpoint_GetCurrentOrgPreferences_AccessControl(t *testing.T) { require.NoError(t, err) t.Run("AccessControl allows getting org preferences with correct permissions", func(t *testing.T) { - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsPreferencesRead}}, sc.initCtx.OrgID) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsPreferencesRead}}, sc.initCtx.OrgID) response := callAPI(sc.server, http.MethodGet, getOrgPreferencesURL, nil, t) assert.Equal(t, http.StatusOK, response.Code) }) t.Run("AccessControl prevents getting org preferences with correct permissions in another org", func(t *testing.T) { - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsPreferencesRead}}, 2) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsPreferencesRead}}, 2) response := callAPI(sc.server, http.MethodGet, getOrgPreferencesURL, nil, t) assert.Equal(t, http.StatusForbidden, response.Code) }) @@ -131,14 +131,14 @@ func TestAPIEndpoint_PutCurrentOrgPreferences_AccessControl(t *testing.T) { input := strings.NewReader(testUpdateOrgPreferencesCmd) t.Run("AccessControl allows updating org preferences with correct permissions", func(t *testing.T) { - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsPreferencesWrite}}, sc.initCtx.OrgID) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsPreferencesWrite}}, sc.initCtx.OrgID) response := callAPI(sc.server, http.MethodPut, putOrgPreferencesURL, input, t) assert.Equal(t, http.StatusOK, response.Code) }) input = strings.NewReader(testUpdateOrgPreferencesCmd) t.Run("AccessControl prevents updating org preferences with correct permissions in another org", func(t *testing.T) { - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsPreferencesWrite}}, 2) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsPreferencesWrite}}, 2) response := callAPI(sc.server, http.MethodPut, putOrgPreferencesURL, input, t) assert.Equal(t, http.StatusForbidden, response.Code) }) diff --git a/pkg/api/quota_test.go b/pkg/api/quota_test.go index 292cb22a1f8..51a6806a35f 100644 --- a/pkg/api/quota_test.go +++ b/pkg/api/quota_test.go @@ -68,12 +68,12 @@ func TestAPIEndpoint_GetCurrentOrgQuotas_AccessControl(t *testing.T) { setupDBAndSettingsForAccessControlQuotaTests(t, sc) t.Run("AccessControl allows viewing CurrentOrgQuotas with correct permissions", func(t *testing.T) { - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsQuotasRead}}, sc.initCtx.OrgID) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsQuotasRead}}, sc.initCtx.OrgID) response := callAPI(sc.server, http.MethodGet, getCurrentOrgQuotasURL, nil, t) assert.Equal(t, http.StatusOK, response.Code) }) t.Run("AccessControl prevents viewing CurrentOrgQuotas with correct permissions in another org", func(t *testing.T) { - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsQuotasRead}}, 2) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsQuotasRead}}, 2) response := callAPI(sc.server, http.MethodGet, getCurrentOrgQuotasURL, nil, t) assert.Equal(t, http.StatusForbidden, response.Code) }) @@ -110,13 +110,13 @@ func TestAPIEndpoint_GetOrgQuotas_AccessControl(t *testing.T) { t.Run("AccessControl allows viewing another org quotas with correct permissions", func(t *testing.T) { setInitCtxSignedInViewer(sc.initCtx) - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsQuotasRead}}, 2) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsQuotasRead}}, 2) response := callAPI(sc.server, http.MethodGet, fmt.Sprintf(getOrgsQuotasURL, 2), nil, t) assert.Equal(t, http.StatusOK, response.Code) }) t.Run("AccessControl prevents viewing another org quotas with correct permissions in another org", func(t *testing.T) { setInitCtxSignedInViewer(sc.initCtx) - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsQuotasRead}}, 1) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsQuotasRead}}, 1) response := callAPI(sc.server, http.MethodGet, fmt.Sprintf(getOrgsQuotasURL, 2), nil, t) assert.Equal(t, http.StatusForbidden, response.Code) }) @@ -157,7 +157,7 @@ func TestAPIEndpoint_PutOrgQuotas_AccessControl(t *testing.T) { input := strings.NewReader(testUpdateOrgQuotaCmd) t.Run("AccessControl allows updating another org quotas with correct permissions", func(t *testing.T) { setInitCtxSignedInViewer(sc.initCtx) - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsQuotasWrite}}, 2) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsQuotasWrite}}, 2) response := callAPI(sc.server, http.MethodPut, fmt.Sprintf(putOrgsQuotasURL, 2, "org_user"), input, t) assert.Equal(t, http.StatusOK, response.Code) }) @@ -165,7 +165,7 @@ func TestAPIEndpoint_PutOrgQuotas_AccessControl(t *testing.T) { input = strings.NewReader(testUpdateOrgQuotaCmd) t.Run("AccessControl prevents updating another org quotas with correct permissions in another org", func(t *testing.T) { setInitCtxSignedInViewer(sc.initCtx) - setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: ActionOrgsQuotasWrite}}, 1) + setAccessControlPermissions(sc.acmock, []accesscontrol.Permission{{Action: accesscontrol.ActionOrgsQuotasWrite}}, 1) response := callAPI(sc.server, http.MethodPut, fmt.Sprintf(putOrgsQuotasURL, 2, "org_user"), input, t) assert.Equal(t, http.StatusForbidden, response.Code) }) diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 2cd381398e1..d264a65f80a 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -27,6 +27,7 @@ import ( "github.com/grafana/grafana/pkg/services/contexthandler/authproxy" "github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/login/logintest" + "github.com/grafana/grafana/pkg/services/navtree" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/rendering" "github.com/grafana/grafana/pkg/services/sqlstore/mockstore" @@ -118,7 +119,7 @@ func TestMiddlewareContext(t *testing.T) { data := &dtos.IndexViewData{ User: &dtos.CurrentUser{}, Settings: map[string]interface{}{}, - NavTree: []*dtos.NavLink{}, + NavTree: []*navtree.NavLink{}, } t.Log("Calling HTML", "data", data) c.HTML(http.StatusOK, "index-template", data) diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 15b50995d6f..eca63f6e147 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -86,6 +86,7 @@ import ( authinfodatabase "github.com/grafana/grafana/pkg/services/login/authinfoservice/database" "github.com/grafana/grafana/pkg/services/login/loginservice" "github.com/grafana/grafana/pkg/services/loginattempt/loginattemptimpl" + "github.com/grafana/grafana/pkg/services/navtree/navtreeimpl" "github.com/grafana/grafana/pkg/services/ngalert" ngimage "github.com/grafana/grafana/pkg/services/ngalert/image" ngmetrics "github.com/grafana/grafana/pkg/services/ngalert/metrics" @@ -351,6 +352,7 @@ var wireBasicSet = wire.NewSet( wire.Bind(new(secretsMigrations.SecretMigrationProvider), new(*secretsMigrations.SecretMigrationProviderImpl)), userauthimpl.ProvideService, acimpl.ProvideAccessControl, + navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index b2e12129ffa..40251ab628d 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -306,6 +306,15 @@ const ( ActionUsersQuotasUpdate = "users.quotas:write" // Org actions + ActionOrgsRead = "orgs:read" + ActionOrgsPreferencesRead = "orgs.preferences:read" + ActionOrgsQuotasRead = "orgs.quotas:read" + ActionOrgsWrite = "orgs:write" + ActionOrgsPreferencesWrite = "orgs.preferences:write" + ActionOrgsQuotasWrite = "orgs.quotas:write" + ActionOrgsDelete = "orgs:delete" + ActionOrgsCreate = "orgs:create" + ActionOrgUsersRead = "org.users:read" ActionOrgUsersAdd = "org.users:add" ActionOrgUsersRemove = "org.users:remove" @@ -418,3 +427,53 @@ func BuiltInRolesWithParents(builtInRoles []string) map[string]struct{} { return res } + +// Evaluators + +// TeamsAccessEvaluator is used to protect the "Configuration > Teams" page access +// grants access to a user when they can either create teams or can read and update a team +var TeamsAccessEvaluator = EvalAny( + EvalPermission(ActionTeamsCreate), + EvalAll( + EvalPermission(ActionTeamsRead), + EvalAny( + EvalPermission(ActionTeamsWrite), + EvalPermission(ActionTeamsPermissionsWrite), + ), + ), +) + +// TeamsEditAccessEvaluator is used to protect the "Configuration > Teams > edit" page access +var TeamsEditAccessEvaluator = EvalAll( + EvalPermission(ActionTeamsRead), + EvalAny( + EvalPermission(ActionTeamsCreate), + EvalPermission(ActionTeamsWrite), + EvalPermission(ActionTeamsPermissionsWrite), + ), +) + +// OrgPreferencesAccessEvaluator is used to protect the "Configure > Preferences" page access +var OrgPreferencesAccessEvaluator = EvalAny( + EvalAll( + EvalPermission(ActionOrgsRead), + EvalPermission(ActionOrgsWrite), + ), + EvalAll( + EvalPermission(ActionOrgsPreferencesRead), + EvalPermission(ActionOrgsPreferencesWrite), + ), +) + +// OrgsAccessEvaluator is used to protect the "Server Admin > Orgs" page access +// (you need to have read access to update or delete orgs; read is the minimum) +var OrgsAccessEvaluator = EvalPermission(ActionOrgsRead) + +// OrgsCreateAccessEvaluator is used to protect the "Server Admin > Orgs > New Org" page access +var OrgsCreateAccessEvaluator = EvalAll( + EvalPermission(ActionOrgsRead), + EvalPermission(ActionOrgsCreate), +) + +// ApiKeyAccessEvaluator is used to protect the "Configuration > API keys" page access +var ApiKeyAccessEvaluator = EvalPermission(ActionAPIKeyRead) diff --git a/pkg/services/licensing/oss.go b/pkg/services/licensing/oss.go index b26f99a4515..9a835084eb8 100644 --- a/pkg/services/licensing/oss.go +++ b/pkg/services/licensing/oss.go @@ -4,6 +4,7 @@ import ( "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/hooks" + "github.com/grafana/grafana/pkg/services/navtree" "github.com/grafana/grafana/pkg/setting" ) @@ -56,7 +57,7 @@ func ProvideService(cfg *setting.Cfg, hooksService *hooks.HooksService) *OSSLice l.HooksService.AddIndexDataHook(func(indexData *dtos.IndexViewData, req *models.ReqContext) { for _, node := range indexData.NavTree { if node.Id == "admin" { - node.Children = append(node.Children, &dtos.NavLink{ + node.Children = append(node.Children, &navtree.NavLink{ Text: "Stats and license", Id: "upgrading", Url: l.LicenseURL(req.IsGrafanaAdmin), diff --git a/pkg/services/navtree/models.go b/pkg/services/navtree/models.go new file mode 100644 index 00000000000..6b86780b342 --- /dev/null +++ b/pkg/services/navtree/models.go @@ -0,0 +1,69 @@ +package navtree + +const ( + // These weights may be used by an extension to reliably place + // itself in relation to a particular item in the menu. The weights + // are negative to ensure that the default items are placed above + // any items with default weight. + + WeightSavedItems = (iota - 20) * 100 + WeightCreate + WeightDashboard + WeightExplore + WeightAlerting + WeightDataConnections + WeightPlugin + WeightConfig + WeightAdmin + WeightProfile + WeightHelp +) + +const ( + NavSectionCore string = "core" + NavSectionPlugin string = "plugin" + NavSectionConfig string = "config" +) + +type NavLink struct { + Id string `json:"id,omitempty"` + Text string `json:"text"` + Description string `json:"description,omitempty"` + Section string `json:"section,omitempty"` + SubTitle string `json:"subTitle,omitempty"` + Icon string `json:"icon,omitempty"` // Available icons can be browsed in Storybook: https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview + Img string `json:"img,omitempty"` + Url string `json:"url,omitempty"` + Target string `json:"target,omitempty"` + SortWeight int64 `json:"sortWeight,omitempty"` + Divider bool `json:"divider,omitempty"` + HideFromMenu bool `json:"hideFromMenu,omitempty"` + HideFromTabs bool `json:"hideFromTabs,omitempty"` + ShowIconInNavbar bool `json:"showIconInNavbar,omitempty"` + RoundIcon bool `json:"roundIcon,omitempty"` + Children []*NavLink `json:"children,omitempty"` + HighlightText string `json:"highlightText,omitempty"` + HighlightID string `json:"highlightId,omitempty"` + EmptyMessageId string `json:"emptyMessageId,omitempty"` +} + +// NavIDCfg is the id for org configuration navigation node +const NavIDCfg = "cfg" + +func GetServerAdminNode(children []*NavLink) *NavLink { + url := "" + if len(children) > 0 { + url = children[0].Url + } + return &NavLink{ + Text: "Server admin", + SubTitle: "Manage all users and orgs", + HideFromTabs: true, + Id: "admin", + Icon: "shield", + Url: url, + SortWeight: WeightAdmin, + Section: NavSectionConfig, + Children: children, + } +} diff --git a/pkg/services/navtree/navtree.go b/pkg/services/navtree/navtree.go new file mode 100644 index 00000000000..5f77a3a51fd --- /dev/null +++ b/pkg/services/navtree/navtree.go @@ -0,0 +1,10 @@ +package navtree + +import ( + "github.com/grafana/grafana/pkg/models" + pref "github.com/grafana/grafana/pkg/services/preference" +) + +type Service interface { + GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *pref.Preference) ([]*NavLink, error) +} diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go new file mode 100644 index 00000000000..612e8632b51 --- /dev/null +++ b/pkg/services/navtree/navtreeimpl/admin.go @@ -0,0 +1,116 @@ +package navtreeimpl + +import ( + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/correlations" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/navtree" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/serviceaccounts" +) + +func (s *ServiceImpl) setupConfigNodes(c *models.ReqContext) ([]*navtree.NavLink, error) { + var configNodes []*navtree.NavLink + + hasAccess := ac.HasAccess(s.accessControl, c) + if hasAccess(ac.ReqOrgAdmin, datasources.ConfigurationPageAccess) { + configNodes = append(configNodes, &navtree.NavLink{ + Text: "Data sources", + Icon: "database", + Description: "Add and configure data sources", + Id: "datasources", + Url: s.cfg.AppSubURL + "/datasources", + }) + } + + if s.features.IsEnabled(featuremgmt.FlagCorrelations) && hasAccess(ac.ReqOrgAdmin, correlations.ConfigurationPageAccess) { + configNodes = append(configNodes, &navtree.NavLink{ + Text: "Correlations", + Icon: "gf-glue", + Description: "Add and configure correlations", + Id: "correlations", + Url: s.cfg.AppSubURL + "/datasources/correlations", + }) + } + + if hasAccess(ac.ReqOrgAdmin, ac.EvalPermission(ac.ActionOrgUsersRead)) { + configNodes = append(configNodes, &navtree.NavLink{ + Text: "Users", + Id: "users", + Description: "Manage org members", + Icon: "user", + Url: s.cfg.AppSubURL + "/org/users", + }) + } + + if hasAccess(s.ReqCanAdminTeams, ac.TeamsAccessEvaluator) { + configNodes = append(configNodes, &navtree.NavLink{ + Text: "Teams", + Id: "teams", + Description: "Manage org groups", + Icon: "users-alt", + Url: s.cfg.AppSubURL + "/org/teams", + }) + } + + // FIXME: while we don't have a permissions for listing plugins the legacy check has to stay as a default + if plugins.ReqCanAdminPlugins(s.cfg)(c) || hasAccess(plugins.ReqCanAdminPlugins(s.cfg), plugins.AdminAccessEvaluator) { + configNodes = append(configNodes, &navtree.NavLink{ + Text: "Plugins", + Id: "plugins", + Description: "View and configure plugins", + Icon: "plug", + Url: s.cfg.AppSubURL + "/plugins", + }) + } + + if hasAccess(ac.ReqOrgAdmin, ac.OrgPreferencesAccessEvaluator) { + configNodes = append(configNodes, &navtree.NavLink{ + Text: "Preferences", + Id: "org-settings", + Description: "Organization preferences", + Icon: "sliders-v-alt", + Url: s.cfg.AppSubURL + "/org", + }) + } + + hideApiKeys, _, _ := s.kvStore.Get(c.Req.Context(), c.OrgID, "serviceaccounts", "hideApiKeys") + apiKeys, err := s.apiKeyService.GetAllAPIKeys(c.Req.Context(), c.OrgID) + if err != nil { + return nil, err + } + + apiKeysHidden := hideApiKeys == "1" && len(apiKeys) == 0 + if hasAccess(ac.ReqOrgAdmin, ac.ApiKeyAccessEvaluator) && !apiKeysHidden { + configNodes = append(configNodes, &navtree.NavLink{ + Text: "API keys", + Id: "apikeys", + Description: "Create & manage API keys", + Icon: "key-skeleton-alt", + Url: s.cfg.AppSubURL + "/org/apikeys", + }) + } + + if enableServiceAccount(s, c) { + configNodes = append(configNodes, &navtree.NavLink{ + Text: "Service accounts", + Id: "serviceaccounts", + Description: "Manage service accounts", + Icon: "gf-service-account", + Url: s.cfg.AppSubURL + "/org/serviceaccounts", + }) + } + return configNodes, nil +} + +func (s *ServiceImpl) ReqCanAdminTeams(c *models.ReqContext) bool { + return c.OrgRole == org.RoleAdmin || (s.cfg.EditorsCanAdmin && c.OrgRole == org.RoleEditor) +} + +func enableServiceAccount(s *ServiceImpl, c *models.ReqContext) bool { + hasAccess := ac.HasAccess(s.accessControl, c) + return hasAccess(ac.ReqOrgAdmin, serviceaccounts.AccessEvaluator) +} diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go new file mode 100644 index 00000000000..57e4bb47776 --- /dev/null +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -0,0 +1,113 @@ +package navtreeimpl + +import ( + "path" + "sort" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/navtree" + "github.com/grafana/grafana/pkg/services/pluginsettings" +) + +func (s *ServiceImpl) getAppLinks(c *models.ReqContext) ([]*navtree.NavLink, error) { + hasAccess := ac.HasAccess(s.accessControl, c) + appLinks := []*navtree.NavLink{} + + pss, err := s.pluginSettings.GetPluginSettings(c.Req.Context(), &pluginsettings.GetArgs{OrgID: c.OrgID}) + if err != nil { + return nil, err + } + + isPluginEnabled := func(plugin plugins.PluginDTO) bool { + if plugin.AutoEnabled { + return true + } + for _, ps := range pss { + if ps.PluginID == plugin.ID { + return ps.Enabled + } + } + return false + } + + for _, plugin := range s.pluginStore.Plugins(c.Req.Context(), plugins.App) { + if !isPluginEnabled(plugin) { + continue + } + + if !hasAccess(ac.ReqSignedIn, + ac.EvalPermission(plugins.ActionAppAccess, plugins.ScopeProvider.GetResourceScope(plugin.ID))) { + continue + } + + appLink := &navtree.NavLink{ + Text: plugin.Name, + Id: "plugin-page-" + plugin.ID, + Img: plugin.Info.Logos.Small, + Section: navtree.NavSectionPlugin, + SortWeight: navtree.WeightPlugin, + } + + if s.features.IsEnabled(featuremgmt.FlagTopnav) { + appLink.Url = s.cfg.AppSubURL + "/a/" + plugin.ID + } else { + appLink.Url = path.Join(s.cfg.AppSubURL, plugin.DefaultNavURL) + } + + for _, include := range plugin.Includes { + if !c.HasUserRole(include.Role) { + continue + } + + if include.Type == "page" && include.AddToNav { + var link *navtree.NavLink + if len(include.Path) > 0 { + link = &navtree.NavLink{ + Url: s.cfg.AppSubURL + include.Path, + Text: include.Name, + } + if include.DefaultNav && !s.features.IsEnabled(featuremgmt.FlagTopnav) { + appLink.Url = link.Url // Overwrite the hardcoded page logic + } + } else { + link = &navtree.NavLink{ + Url: s.cfg.AppSubURL + "/plugins/" + plugin.ID + "/page/" + include.Slug, + Text: include.Name, + } + } + link.Icon = include.Icon + appLink.Children = append(appLink.Children, link) + } + + if include.Type == "dashboard" && include.AddToNav { + dboardURL := include.DashboardURLPath() + if dboardURL != "" { + link := &navtree.NavLink{ + Url: path.Join(s.cfg.AppSubURL, dboardURL), + Text: include.Name, + } + appLink.Children = append(appLink.Children, link) + } + } + } + + if len(appLink.Children) > 0 { + // If we only have one child and it's the app default nav then remove it from children + if len(appLink.Children) == 1 && appLink.Children[0].Url == appLink.Url { + appLink.Children = []*navtree.NavLink{} + } + appLinks = append(appLinks, appLink) + } + } + + if len(appLinks) > 0 { + sort.SliceStable(appLinks, func(i, j int) bool { + return appLinks[i].Text < appLinks[j].Text + }) + } + + return appLinks, nil +} diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go new file mode 100644 index 00000000000..433bcb1cd52 --- /dev/null +++ b/pkg/services/navtree/navtreeimpl/navtree.go @@ -0,0 +1,598 @@ +package navtreeimpl + +import ( + "fmt" + "sort" + + "github.com/grafana/grafana/pkg/api/dtos" + "github.com/grafana/grafana/pkg/infra/kvstore" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/plugins" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/apikey" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/navtree" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/pluginsettings" + pref "github.com/grafana/grafana/pkg/services/preference" + "github.com/grafana/grafana/pkg/services/star" + "github.com/grafana/grafana/pkg/setting" +) + +type ServiceImpl struct { + cfg *setting.Cfg + log log.Logger + accessControl ac.AccessControl + pluginStore plugins.Store + pluginSettings pluginsettings.Service + starService star.Service + features *featuremgmt.FeatureManager + dashboardService dashboards.DashboardService + accesscontrolService ac.Service + kvStore kvstore.KVStore + apiKeyService apikey.Service +} + +func ProvideService(cfg *setting.Cfg, accessControl ac.AccessControl, pluginStore plugins.Store, pluginSettings pluginsettings.Service, starService star.Service, features *featuremgmt.FeatureManager, dashboardService dashboards.DashboardService, accesscontrolService ac.Service, kvStore kvstore.KVStore, apiKeyService apikey.Service) navtree.Service { + return &ServiceImpl{ + cfg: cfg, + log: log.New("navtree service"), + accessControl: accessControl, + pluginStore: pluginStore, + pluginSettings: pluginSettings, + starService: starService, + features: features, + dashboardService: dashboardService, + accesscontrolService: accesscontrolService, + kvStore: kvStore, + apiKeyService: apiKeyService, + } +} + +//nolint:gocyclo +func (s *ServiceImpl) GetNavTree(c *models.ReqContext, hasEditPerm bool, prefs *pref.Preference) ([]*navtree.NavLink, error) { + hasAccess := ac.HasAccess(s.accessControl, c) + var navTree []*navtree.NavLink + + if hasAccess(ac.ReqSignedIn, ac.EvalPermission(dashboards.ActionDashboardsRead)) { + starredItemsLinks, err := s.buildStarredItemsNavLinks(c, prefs) + if err != nil { + return nil, err + } + + navTree = append(navTree, &navtree.NavLink{ + Text: "Starred", + Id: "starred", + Icon: "star", + SortWeight: navtree.WeightSavedItems, + Section: navtree.NavSectionCore, + Children: starredItemsLinks, + EmptyMessageId: "starred-empty", + }) + + dashboardChildLinks := s.buildDashboardNavLinks(c, hasEditPerm) + + dashboardsUrl := "/dashboards" + + dashboardLink := &navtree.NavLink{ + Text: "Dashboards", + Id: "dashboards", + SubTitle: "Manage dashboards and folders", + Icon: "apps", + Url: s.cfg.AppSubURL + dashboardsUrl, + SortWeight: navtree.WeightDashboard, + Section: navtree.NavSectionCore, + Children: dashboardChildLinks, + } + + if s.features.IsEnabled(featuremgmt.FlagTopnav) { + dashboardLink.Id = "dashboards/browse" + } + + navTree = append(navTree, dashboardLink) + } + + canExplore := func(context *models.ReqContext) bool { + return c.OrgRole == org.RoleAdmin || c.OrgRole == org.RoleEditor || setting.ViewersCanEdit + } + + if setting.ExploreEnabled && hasAccess(canExplore, ac.EvalPermission(ac.ActionDatasourcesExplore)) { + navTree = append(navTree, &navtree.NavLink{ + Text: "Explore", + Id: "explore", + SubTitle: "Explore your data", + Icon: "compass", + SortWeight: navtree.WeightExplore, + Section: navtree.NavSectionCore, + Url: s.cfg.AppSubURL + "/explore", + }) + } + + navTree = s.addProfile(navTree, c) + + _, uaIsDisabledForOrg := s.cfg.UnifiedAlerting.DisabledOrgs[c.OrgID] + uaVisibleForOrg := s.cfg.UnifiedAlerting.IsEnabled() && !uaIsDisabledForOrg + + if setting.AlertingEnabled != nil && *setting.AlertingEnabled { + navTree = append(navTree, s.buildLegacyAlertNavLinks(c)...) + } else if uaVisibleForOrg { + navTree = append(navTree, s.buildAlertNavLinks(c, hasEditPerm)...) + } + + if s.features.IsEnabled(featuremgmt.FlagDataConnectionsConsole) { + navTree = append(navTree, s.buildDataConnectionsNavLink(c)) + } + + appLinks, err := s.getAppLinks(c) + if err != nil { + return nil, err + } + + // When topnav is enabled we can test new information architecture where plugins live in Apps category + if s.features.IsEnabled(featuremgmt.FlagTopnav) { + navTree = append(navTree, &navtree.NavLink{ + Text: "Apps", + Icon: "apps", + Description: "App plugins", + Id: "apps", + Children: appLinks, + Section: navtree.NavSectionCore, + Url: s.cfg.AppSubURL + "/apps", + }) + } else { + navTree = append(navTree, appLinks...) + } + + configNodes, err := s.setupConfigNodes(c) + if err != nil { + return navTree, err + } + + if s.features.IsEnabled(featuremgmt.FlagLivePipeline) { + liveNavLinks := []*navtree.NavLink{} + + liveNavLinks = append(liveNavLinks, &navtree.NavLink{ + Text: "Status", Id: "live-status", Url: s.cfg.AppSubURL + "/live", Icon: "exchange-alt", + }) + liveNavLinks = append(liveNavLinks, &navtree.NavLink{ + Text: "Pipeline", Id: "live-pipeline", Url: s.cfg.AppSubURL + "/live/pipeline", Icon: "arrow-to-right", + }) + liveNavLinks = append(liveNavLinks, &navtree.NavLink{ + Text: "Cloud", Id: "live-cloud", Url: s.cfg.AppSubURL + "/live/cloud", Icon: "cloud-upload", + }) + navTree = append(navTree, &navtree.NavLink{ + Id: "live", + Text: "Live", + SubTitle: "Event streaming", + Icon: "exchange-alt", + Url: s.cfg.AppSubURL + "/live", + Children: liveNavLinks, + Section: navtree.NavSectionConfig, + HideFromTabs: true, + }) + } + + var configNode *navtree.NavLink + var serverAdminNode *navtree.NavLink + + if len(configNodes) > 0 { + configNode = &navtree.NavLink{ + Id: navtree.NavIDCfg, + Text: "Configuration", + SubTitle: "Organization: " + c.OrgName, + Icon: "cog", + Url: configNodes[0].Url, + Section: navtree.NavSectionConfig, + SortWeight: navtree.WeightConfig, + Children: configNodes, + } + if s.features.IsEnabled(featuremgmt.FlagTopnav) { + configNode.Url = "/admin" + } else { + configNode.Url = configNodes[0].Url + } + navTree = append(navTree, configNode) + } + + adminNavLinks := s.buildAdminNavLinks(c) + + if len(adminNavLinks) > 0 { + serverAdminNode = navtree.GetServerAdminNode(adminNavLinks) + navTree = append(navTree, serverAdminNode) + } + + if s.features.IsEnabled(featuremgmt.FlagTopnav) { + // Move server admin into Configuration and rename to administration + if configNode != nil && serverAdminNode != nil { + configNode.Text = "Administration" + serverAdminNode.Url = "/admin/server" + serverAdminNode.HideFromTabs = false + configNode.Children = append(configNode.Children, serverAdminNode) + adminNodeIndex := len(navTree) - 1 + navTree = navTree[:adminNodeIndex] + } + } + + navTree = s.addHelpLinks(navTree, c) + + return navTree, nil +} + +func (s *ServiceImpl) addHelpLinks(navTree []*navtree.NavLink, c *models.ReqContext) []*navtree.NavLink { + if setting.HelpEnabled { + helpVersion := fmt.Sprintf(`%s v%s (%s)`, setting.ApplicationName, setting.BuildVersion, setting.BuildCommit) + if s.cfg.AnonymousHideVersion && !c.IsSignedIn { + helpVersion = setting.ApplicationName + } + + navTree = append(navTree, &navtree.NavLink{ + Text: "Help", + SubTitle: helpVersion, + Id: "help", + Url: "#", + Icon: "question-circle", + SortWeight: navtree.WeightHelp, + Section: navtree.NavSectionConfig, + Children: []*navtree.NavLink{}, + }) + } + return navTree +} + +func (s *ServiceImpl) addProfile(navTree []*navtree.NavLink, c *models.ReqContext) []*navtree.NavLink { + if setting.ProfileEnabled && c.IsSignedIn { + navTree = append(navTree, s.getProfileNode(c)) + } + return navTree +} + +func (s *ServiceImpl) getProfileNode(c *models.ReqContext) *navtree.NavLink { + // Only set login if it's different from the name + var login string + if c.SignedInUser.Login != c.SignedInUser.NameOrFallback() { + login = c.SignedInUser.Login + } + gravatarURL := dtos.GetGravatarUrl(c.Email) + + children := []*navtree.NavLink{ + { + Text: "Preferences", Id: "profile/settings", Url: s.cfg.AppSubURL + "/profile", Icon: "sliders-v-alt", + }, + } + + children = append(children, &navtree.NavLink{ + Text: "Notification history", Id: "profile/notifications", Url: s.cfg.AppSubURL + "/profile/notifications", Icon: "bell", + }) + + if setting.AddChangePasswordLink() { + children = append(children, &navtree.NavLink{ + Text: "Change password", Id: "profile/password", Url: s.cfg.AppSubURL + "/profile/password", + Icon: "lock", + }) + } + + if !setting.DisableSignoutMenu { + // add sign out first + children = append(children, &navtree.NavLink{ + Text: "Sign out", + Id: "sign-out", + Url: s.cfg.AppSubURL + "/logout", + Icon: "arrow-from-right", + Target: "_self", + HideFromTabs: true, + }) + } + + return &navtree.NavLink{ + Text: c.SignedInUser.NameOrFallback(), + SubTitle: login, + Id: "profile", + Img: gravatarURL, + Url: s.cfg.AppSubURL + "/profile", + Section: navtree.NavSectionConfig, + SortWeight: navtree.WeightProfile, + Children: children, + RoundIcon: true, + } +} + +func (s *ServiceImpl) buildStarredItemsNavLinks(c *models.ReqContext, prefs *pref.Preference) ([]*navtree.NavLink, error) { + starredItemsChildNavs := []*navtree.NavLink{} + + query := star.GetUserStarsQuery{ + UserID: c.SignedInUser.UserID, + } + + starredDashboardResult, err := s.starService.GetByUser(c.Req.Context(), &query) + if err != nil { + return nil, err + } + + starredDashboards := []*models.Dashboard{} + starredDashboardsCounter := 0 + for dashboardId := range starredDashboardResult.UserStars { + // Set a loose limit to the first 50 starred dashboards found + if starredDashboardsCounter > 50 { + break + } + starredDashboardsCounter++ + query := &models.GetDashboardQuery{ + Id: dashboardId, + OrgId: c.OrgID, + } + err := s.dashboardService.GetDashboard(c.Req.Context(), query) + if err == nil { + starredDashboards = append(starredDashboards, query.Result) + } + } + + if len(starredDashboards) > 0 { + sort.Slice(starredDashboards, func(i, j int) bool { + return starredDashboards[i].Title < starredDashboards[j].Title + }) + for _, starredItem := range starredDashboards { + starredItemsChildNavs = append(starredItemsChildNavs, &navtree.NavLink{ + Id: starredItem.Uid, + Text: starredItem.Title, + Url: starredItem.GetUrl(), + }) + } + } + + return starredItemsChildNavs, nil +} + +func (s *ServiceImpl) buildDashboardNavLinks(c *models.ReqContext, hasEditPerm bool) []*navtree.NavLink { + hasAccess := ac.HasAccess(s.accessControl, c) + hasEditPermInAnyFolder := func(c *models.ReqContext) bool { + return hasEditPerm + } + + dashboardChildNavs := []*navtree.NavLink{} + if !s.features.IsEnabled(featuremgmt.FlagTopnav) { + dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ + Text: "Browse", Id: "dashboards/browse", Url: s.cfg.AppSubURL + "/dashboards", Icon: "sitemap", + }) + } + dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ + Text: "Playlists", Id: "dashboards/playlists", Url: s.cfg.AppSubURL + "/playlists", Icon: "presentation-play", + }) + + if c.IsSignedIn { + dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ + Text: "Snapshots", + Id: "dashboards/snapshots", + Url: s.cfg.AppSubURL + "/dashboard/snapshots", + Icon: "camera", + }) + + dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ + Text: "Library panels", + Id: "dashboards/library-panels", + Url: s.cfg.AppSubURL + "/library-panels", + Icon: "library-panel", + }) + } + + if s.features.IsEnabled(featuremgmt.FlagScenes) { + dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ + Text: "Scenes", + Id: "scenes", + Url: s.cfg.AppSubURL + "/scenes", + Icon: "apps", + }) + } + + if hasEditPerm { + dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ + Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true, + }) + + if hasAccess(hasEditPermInAnyFolder, ac.EvalPermission(dashboards.ActionDashboardsCreate)) { + dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ + Text: "New dashboard", Icon: "plus", Url: s.cfg.AppSubURL + "/dashboard/new", HideFromTabs: true, Id: "dashboards/new", ShowIconInNavbar: true, + }) + } + + if hasAccess(ac.ReqOrgAdminOrEditor, ac.EvalPermission(dashboards.ActionFoldersCreate)) { + dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ + Text: "New folder", SubTitle: "Create a new folder to organize your dashboards", Id: "dashboards/folder/new", + Icon: "plus", Url: s.cfg.AppSubURL + "/dashboards/folder/new", HideFromTabs: true, ShowIconInNavbar: true, + }) + } + + if hasAccess(hasEditPermInAnyFolder, ac.EvalPermission(dashboards.ActionDashboardsCreate)) { + dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{ + Text: "Import", SubTitle: "Import dashboard from file or Grafana.com", Id: "dashboards/import", Icon: "plus", + Url: s.cfg.AppSubURL + "/dashboard/import", HideFromTabs: true, ShowIconInNavbar: true, + }) + } + } + return dashboardChildNavs +} + +func (s *ServiceImpl) buildLegacyAlertNavLinks(c *models.ReqContext) []*navtree.NavLink { + var alertChildNavs []*navtree.NavLink + alertChildNavs = append(alertChildNavs, &navtree.NavLink{ + Text: "Alert rules", Id: "alert-list", Url: s.cfg.AppSubURL + "/alerting/list", Icon: "list-ul", + }) + + if c.HasRole(org.RoleEditor) { + alertChildNavs = append(alertChildNavs, &navtree.NavLink{ + Text: "Notification channels", Id: "channels", Url: s.cfg.AppSubURL + "/alerting/notifications", + Icon: "comment-alt-share", + }) + } + + var alertNav = navtree.NavLink{ + Text: "Alerting", + SubTitle: "Alert rules and notifications", + Id: "alerting-legacy", + Icon: "bell", + Children: alertChildNavs, + Section: navtree.NavSectionCore, + SortWeight: navtree.WeightAlerting, + } + + if s.features.IsEnabled(featuremgmt.FlagTopnav) { + alertNav.Url = s.cfg.AppSubURL + "/alerting" + } else { + alertNav.Url = s.cfg.AppSubURL + "/alerting/list" + } + + return []*navtree.NavLink{&alertNav} +} + +func (s *ServiceImpl) buildAlertNavLinks(c *models.ReqContext, hasEditPerm bool) []*navtree.NavLink { + hasAccess := ac.HasAccess(s.accessControl, c) + var alertChildNavs []*navtree.NavLink + + if hasAccess(ac.ReqViewer, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleRead), ac.EvalPermission(ac.ActionAlertingRuleExternalRead))) { + alertChildNavs = append(alertChildNavs, &navtree.NavLink{ + Text: "Alert rules", Id: "alert-list", Url: s.cfg.AppSubURL + "/alerting/list", Icon: "list-ul", + }) + } + + if hasAccess(ac.ReqOrgAdminOrEditor, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingNotificationsRead), ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead))) { + alertChildNavs = append(alertChildNavs, &navtree.NavLink{ + Text: "Contact points", Id: "receivers", Url: s.cfg.AppSubURL + "/alerting/notifications", + Icon: "comment-alt-share", SubTitle: "Manage the settings of your contact points", + }) + alertChildNavs = append(alertChildNavs, &navtree.NavLink{Text: "Notification policies", Id: "am-routes", Url: s.cfg.AppSubURL + "/alerting/routes", Icon: "sitemap"}) + } + + if hasAccess(ac.ReqViewer, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingInstanceRead), ac.EvalPermission(ac.ActionAlertingInstancesExternalRead))) { + alertChildNavs = append(alertChildNavs, &navtree.NavLink{Text: "Silences", Id: "silences", Url: s.cfg.AppSubURL + "/alerting/silences", Icon: "bell-slash"}) + alertChildNavs = append(alertChildNavs, &navtree.NavLink{Text: "Alert groups", Id: "groups", Url: s.cfg.AppSubURL + "/alerting/groups", Icon: "layer-group"}) + } + + if c.OrgRole == org.RoleAdmin { + alertChildNavs = append(alertChildNavs, &navtree.NavLink{ + Text: "Admin", Id: "alerting-admin", Url: s.cfg.AppSubURL + "/alerting/admin", + Icon: "cog", + }) + } + + fallbackHasEditPerm := func(*models.ReqContext) bool { return hasEditPerm } + + if hasAccess(fallbackHasEditPerm, ac.EvalAny(ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingRuleExternalWrite))) { + alertChildNavs = append(alertChildNavs, &navtree.NavLink{ + Text: "Divider", Divider: true, Id: "divider", HideFromTabs: true, + }) + + alertChildNavs = append(alertChildNavs, &navtree.NavLink{ + Text: "New alert rule", SubTitle: "Create an alert rule", Id: "alert", + Icon: "plus", Url: s.cfg.AppSubURL + "/alerting/new", HideFromTabs: true, ShowIconInNavbar: true, + }) + } + + if len(alertChildNavs) > 0 { + var alertNav = navtree.NavLink{ + Text: "Alerting", + SubTitle: "Alert rules and notifications", + Id: "alerting", + Icon: "bell", + Children: alertChildNavs, + Section: navtree.NavSectionCore, + SortWeight: navtree.WeightAlerting, + } + + if s.features.IsEnabled(featuremgmt.FlagTopnav) { + alertNav.Url = s.cfg.AppSubURL + "/alerting" + } else { + alertNav.Url = s.cfg.AppSubURL + "/alerting/list" + } + + return []*navtree.NavLink{&alertNav} + } + return nil +} + +func (s *ServiceImpl) buildDataConnectionsNavLink(c *models.ReqContext) *navtree.NavLink { + var children []*navtree.NavLink + var navLink *navtree.NavLink + + baseId := "data-connections" + baseUrl := s.cfg.AppSubURL + "/" + baseId + + children = append(children, &navtree.NavLink{ + Id: baseId + "-datasources", + Text: "Data sources", + Icon: "database", + Description: "Add and configure data sources", + Url: baseUrl + "/datasources", + }) + + children = append(children, &navtree.NavLink{ + Id: baseId + "-plugins", + Text: "Plugins", + Icon: "plug", + Description: "Manage plugins", + Url: baseUrl + "/plugins", + }) + + children = append(children, &navtree.NavLink{ + Id: baseId + "-cloud-integrations", + Text: "Cloud integrations", + Icon: "bolt", + Description: "Manage your cloud integrations", + Url: baseUrl + "/cloud-integrations", + }) + + navLink = &navtree.NavLink{ + Text: "Data Connections", + Icon: "link", + Id: baseId, + Url: baseUrl, + Children: children, + Section: navtree.NavSectionCore, + SortWeight: navtree.WeightDataConnections, + } + + return navLink +} + +func (s *ServiceImpl) buildAdminNavLinks(c *models.ReqContext) []*navtree.NavLink { + hasAccess := ac.HasAccess(s.accessControl, c) + hasGlobalAccess := ac.HasGlobalAccess(s.accessControl, s.accesscontrolService, c) + orgsAccessEvaluator := ac.EvalPermission(ac.ActionOrgsRead) + adminNavLinks := []*navtree.NavLink{} + + if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)) { + adminNavLinks = append(adminNavLinks, &navtree.NavLink{ + Text: "Users", Id: "global-users", Url: s.cfg.AppSubURL + "/admin/users", Icon: "user", + }) + } + + if hasGlobalAccess(ac.ReqGrafanaAdmin, orgsAccessEvaluator) { + adminNavLinks = append(adminNavLinks, &navtree.NavLink{ + Text: "Orgs", Id: "global-orgs", Url: s.cfg.AppSubURL + "/admin/orgs", Icon: "building", + }) + } + + if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)) { + adminNavLinks = append(adminNavLinks, &navtree.NavLink{ + Text: "Settings", Id: "server-settings", Url: s.cfg.AppSubURL + "/admin/settings", Icon: "sliders-v-alt", + }) + } + + if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionSettingsRead)) && s.features.IsEnabled(featuremgmt.FlagStorage) { + adminNavLinks = append(adminNavLinks, &navtree.NavLink{ + Text: "Storage", + Id: "storage", + Description: "Manage file storage", + Icon: "cube", + Url: s.cfg.AppSubURL + "/admin/storage", + }) + } + + if s.cfg.LDAPEnabled && hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)) { + adminNavLinks = append(adminNavLinks, &navtree.NavLink{ + Text: "LDAP", Id: "ldap", Url: s.cfg.AppSubURL + "/admin/ldap", Icon: "book", + }) + } + + return adminNavLinks +} diff --git a/pkg/services/serviceaccounts/models.go b/pkg/services/serviceaccounts/models.go index 0916a1d45ac..2b1bc8a9c66 100644 --- a/pkg/services/serviceaccounts/models.go +++ b/pkg/services/serviceaccounts/models.go @@ -130,3 +130,9 @@ type Stats struct { ServiceAccounts int64 `xorm:"serviceaccounts"` Tokens int64 `xorm:"serviceaccount_tokens"` } + +// AccessEvaluator is used to protect the "Configuration > Service accounts" page access +var AccessEvaluator = accesscontrol.EvalAny( + accesscontrol.EvalPermission(ActionRead), + accesscontrol.EvalPermission(ActionCreate), +)