+
{isPinned && }
{contents}
,
@@ -290,5 +305,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
position: 'absolute',
background: theme.colors.background.secondary,
boxShadow: `0 4px 8px ${theme.colors.background.primary}`,
+ userSelect: 'text',
}),
});
diff --git a/packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx b/packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx
index 415d000d303..02f539dd3ee 100644
--- a/packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx
+++ b/packages/grafana-ui/src/components/uPlot/plugins/ZoomPlugin.tsx
@@ -11,6 +11,8 @@ interface ZoomPluginProps {
// min px width that triggers zoom
const MIN_ZOOM_DIST = 5;
+const maybeZoomAction = (e?: MouseEvent | null) => e != null && !e.ctrlKey && !e.metaKey;
+
/**
* @alpha
*/
@@ -21,9 +23,13 @@ export const ZoomPlugin = ({ onZoom, config, withZoomY = false }: ZoomPluginProp
if (withZoomY) {
config.addHook('init', (u) => {
- u.root!.addEventListener(
+ u.over!.addEventListener(
'mousedown',
(e) => {
+ if (!maybeZoomAction(e)) {
+ return;
+ }
+
if (e.button === 0 && e.shiftKey) {
yDrag = true;
@@ -45,6 +51,10 @@ export const ZoomPlugin = ({ onZoom, config, withZoomY = false }: ZoomPluginProp
}
config.addHook('setSelect', (u) => {
+ if (!maybeZoomAction(u.cursor!.event)) {
+ return;
+ }
+
if (withZoomY && yDrag) {
if (u.select.height >= MIN_ZOOM_DIST) {
for (let key in u.scales!) {
@@ -76,6 +86,10 @@ export const ZoomPlugin = ({ onZoom, config, withZoomY = false }: ZoomPluginProp
config.setCursor({
bind: {
dblclick: (u) => () => {
+ if (!maybeZoomAction(u.cursor!.event)) {
+ return null;
+ }
+
if (withZoomY && yZoomed) {
for (let key in u.scales!) {
if (key !== 'x') {
diff --git a/packages/grafana-ui/src/utils/logger.ts b/packages/grafana-ui/src/utils/logger.ts
index 157a517de25..e52bb4ba8b9 100644
--- a/packages/grafana-ui/src/utils/logger.ts
+++ b/packages/grafana-ui/src/utils/logger.ts
@@ -19,17 +19,22 @@ export interface Logger {
/** @internal */
export const createLogger = (name: string): Logger => {
- let LOGGIN_ENABLED = false;
+ let loggingEnabled = false;
+
+ if (typeof window !== 'undefined') {
+ loggingEnabled = window.localStorage.getItem('grafana.debug') === 'true';
+ }
+
return {
logger: (id: string, throttle = false, ...t: any[]) => {
- if (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test' || !LOGGIN_ENABLED) {
+ if (process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test' || !loggingEnabled) {
return;
}
const fn = throttle ? throttledLog : console.log;
- fn(`[${name}: ${id}]: `, ...t);
+ fn(`[${name}: ${id}]:`, ...t);
},
- enable: () => (LOGGIN_ENABLED = true),
- disable: () => (LOGGIN_ENABLED = false),
- isEnabled: () => LOGGIN_ENABLED,
+ enable: () => (loggingEnabled = true),
+ disable: () => (loggingEnabled = false),
+ isEnabled: () => loggingEnabled,
};
};
diff --git a/pkg/api/accesscontrol.go b/pkg/api/accesscontrol.go
index c2cbec08a44..e3d5265229a 100644
--- a/pkg/api/accesscontrol.go
+++ b/pkg/api/accesscontrol.go
@@ -7,6 +7,8 @@ import (
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"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/libraryelements"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
"github.com/grafana/grafana/pkg/tsdb/grafanads"
@@ -408,6 +410,76 @@ func (hs *HTTPServer) declareFixedRoles() error {
Grants: []string{"Admin"},
}
+ libraryPanelsCreatorRole := ac.RoleRegistration{
+ Role: ac.RoleDTO{
+ Name: "fixed:library.panels:creator",
+ DisplayName: "Library panel creator",
+ Description: "Create library panel in general folder.",
+ Group: "Library panels",
+ Permissions: []ac.Permission{
+ {Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.GeneralFolderUID)},
+ {Action: libraryelements.ActionLibraryPanelsCreate, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.GeneralFolderUID)},
+ },
+ },
+ Grants: []string{"Editor"},
+ }
+
+ libraryPanelsReaderRole := ac.RoleRegistration{
+ Role: ac.RoleDTO{
+ Name: "fixed:library.panels:reader",
+ DisplayName: "Library panel reader",
+ Description: "Read all library panels.",
+ Group: "Library panels",
+ Permissions: []ac.Permission{
+ {Action: libraryelements.ActionLibraryPanelsRead, Scope: libraryelements.ScopeLibraryPanelsAll},
+ },
+ },
+ Grants: []string{"Admin"},
+ }
+
+ libraryPanelsGeneralReaderRole := ac.RoleRegistration{
+ Role: ac.RoleDTO{
+ Name: "fixed:library.panels:general.reader",
+ DisplayName: "Library panel general reader",
+ Description: "Read all library panels in general folder.",
+ Group: "Library panels",
+ Permissions: []ac.Permission{
+ {Action: libraryelements.ActionLibraryPanelsRead, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.GeneralFolderUID)},
+ },
+ },
+ Grants: []string{"Viewer"},
+ }
+
+ libraryPanelsWriterRole := ac.RoleRegistration{
+ Role: ac.RoleDTO{
+ Name: "fixed:library.panels:writer",
+ DisplayName: "Library panel writer",
+ Group: "Library panels",
+ Description: "Create, read, write or delete all library panels and their permissions.",
+ Permissions: ac.ConcatPermissions(libraryPanelsReaderRole.Role.Permissions, []ac.Permission{
+ {Action: libraryelements.ActionLibraryPanelsWrite, Scope: libraryelements.ScopeLibraryPanelsAll},
+ {Action: libraryelements.ActionLibraryPanelsDelete, Scope: libraryelements.ScopeLibraryPanelsAll},
+ {Action: libraryelements.ActionLibraryPanelsCreate, Scope: libraryelements.ScopeLibraryPanelsAll},
+ }),
+ },
+ Grants: []string{"Admin"},
+ }
+
+ libraryPanelsGeneralWriterRole := ac.RoleRegistration{
+ Role: ac.RoleDTO{
+ Name: "fixed:library.panels:general.writer",
+ DisplayName: "Library panel general writer",
+ Group: "Library panels",
+ Description: "Create, read, write or delete all library panels and their permissions in the general folder.",
+ Permissions: ac.ConcatPermissions(libraryPanelsGeneralReaderRole.Role.Permissions, []ac.Permission{
+ {Action: libraryelements.ActionLibraryPanelsWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.GeneralFolderUID)},
+ {Action: libraryelements.ActionLibraryPanelsDelete, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.GeneralFolderUID)},
+ {Action: libraryelements.ActionLibraryPanelsCreate, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID(ac.GeneralFolderUID)},
+ }),
+ },
+ Grants: []string{"Editor"},
+ }
+
publicDashboardsWriterRole := ac.RoleRegistration{
Role: ac.RoleDTO{
Name: "fixed:dashboards.public:writer",
@@ -447,15 +519,18 @@ func (hs *HTTPServer) declareFixedRoles() error {
Grants: []string{"Admin"},
}
- return hs.accesscontrolService.DeclareFixedRoles(
- provisioningWriterRole, datasourcesReaderRole, builtInDatasourceReader, datasourcesWriterRole,
+ roles := []ac.RoleRegistration{provisioningWriterRole, datasourcesReaderRole, builtInDatasourceReader, datasourcesWriterRole,
datasourcesIdReaderRole, orgReaderRole, orgWriterRole,
orgMaintainerRole, teamsCreatorRole, teamsWriterRole, datasourcesExplorerRole,
annotationsReaderRole, dashboardAnnotationsWriterRole, annotationsWriterRole,
dashboardsCreatorRole, dashboardsReaderRole, dashboardsWriterRole,
foldersCreatorRole, foldersReaderRole, foldersWriterRole, apikeyReaderRole, apikeyWriterRole,
- publicDashboardsWriterRole, featuremgmtReaderRole, featuremgmtWriterRole,
- )
+ publicDashboardsWriterRole, featuremgmtReaderRole, featuremgmtWriterRole}
+ if hs.Features.IsEnabled(featuremgmt.FlagLibraryPanelRBAC) {
+ roles = append(roles, libraryPanelsCreatorRole, libraryPanelsReaderRole, libraryPanelsWriterRole, libraryPanelsGeneralReaderRole, libraryPanelsGeneralWriterRole)
+ }
+
+ return hs.accesscontrolService.DeclareFixedRoles(roles...)
}
// Metadata helpers
diff --git a/pkg/api/admin_users.go b/pkg/api/admin_users.go
index 9030ba71876..6539fb3de0b 100644
--- a/pkg/api/admin_users.go
+++ b/pkg/api/admin_users.go
@@ -57,13 +57,6 @@ func (hs *HTTPServer) AdminCreateUser(c *contextmodel.ReqContext) response.Respo
OrgID: form.OrgId,
}
- if len(cmd.Login) == 0 {
- cmd.Login = cmd.Email
- if len(cmd.Login) == 0 {
- return response.Error(400, "Validation error, need specify either username or email", nil)
- }
- }
-
if len(cmd.Password) < 4 {
return response.Error(400, "Password is missing or too short", nil)
}
@@ -78,7 +71,7 @@ func (hs *HTTPServer) AdminCreateUser(c *contextmodel.ReqContext) response.Respo
return response.Error(http.StatusPreconditionFailed, fmt.Sprintf("User with email '%s' or username '%s' already exists", form.Email, form.Login), err)
}
- return response.Error(http.StatusInternalServerError, "failed to create user", err)
+ return response.ErrOrFallback(http.StatusInternalServerError, "failed to create user", err)
}
metrics.MApiAdminUserCreate.Inc()
diff --git a/pkg/api/api.go b/pkg/api/api.go
index cad49e0c4c6..9ff29328060 100644
--- a/pkg/api/api.go
+++ b/pkg/api/api.go
@@ -124,18 +124,19 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/live/pipeline", reqGrafanaAdmin, hs.Index)
r.Get("/live/cloud", reqGrafanaAdmin, hs.Index)
- r.Get("/plugins", middleware.CanAdminPlugins(hs.Cfg), hs.Index)
- r.Get("/plugins/:id/", middleware.CanAdminPlugins(hs.Cfg), hs.Index)
- r.Get("/plugins/:id/edit", middleware.CanAdminPlugins(hs.Cfg), hs.Index) // deprecated
- r.Get("/plugins/:id/page/:page", middleware.CanAdminPlugins(hs.Cfg), hs.Index)
+ r.Get("/plugins", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index)
+ r.Get("/plugins/:id/", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index)
+ r.Get("/plugins/:id/edit", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index) // deprecated
+ r.Get("/plugins/:id/page/:page", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index)
r.Get("/connections/datasources", authorize(datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/datasources/new", authorize(datasources.NewPageAccess), hs.Index)
r.Get("/connections/datasources/edit/*", authorize(datasources.EditPageAccess), hs.Index)
r.Get("/connections", authorize(datasources.ConfigurationPageAccess), hs.Index)
r.Get("/connections/add-new-connection", authorize(datasources.ConfigurationPageAccess), hs.Index)
- r.Get("/connections/datasources/:id", middleware.CanAdminPlugins(hs.Cfg), hs.Index)
- r.Get("/connections/datasources/:id/page/:page", middleware.CanAdminPlugins(hs.Cfg), hs.Index)
+ // Plugin details pages
+ r.Get("/connections/datasources/:id", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index)
+ r.Get("/connections/datasources/:id/page/:page", middleware.CanAdminPlugins(hs.Cfg, hs.AccessControl), hs.Index)
// App Root Page
appPluginIDScope := pluginaccesscontrol.ScopeProvider.GetResourceScope(ac.Parameter(":id"))
@@ -270,25 +271,6 @@ func (hs *HTTPServer) registerRoutes() {
usersRoute.Post("/:id/using/:orgId", authorize(ac.EvalPermission(ac.ActionUsersWrite, userIDScope)), routing.Wrap(hs.UpdateUserActiveOrg))
}, requestmeta.SetOwner(requestmeta.TeamAuth))
- // team (admin permission required)
- apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
- teamsRoute.Post("/", authorize(ac.EvalPermission(ac.ActionTeamsCreate)), routing.Wrap(hs.CreateTeam))
- teamsRoute.Put("/:teamId", authorize(ac.EvalPermission(ac.ActionTeamsWrite, ac.ScopeTeamsID)), routing.Wrap(hs.UpdateTeam))
- teamsRoute.Delete("/:teamId", authorize(ac.EvalPermission(ac.ActionTeamsDelete, ac.ScopeTeamsID)), routing.Wrap(hs.DeleteTeamByID))
- teamsRoute.Get("/:teamId/members", authorize(ac.EvalPermission(ac.ActionTeamsPermissionsRead, ac.ScopeTeamsID)), routing.Wrap(hs.GetTeamMembers))
- teamsRoute.Post("/:teamId/members", authorize(ac.EvalPermission(ac.ActionTeamsPermissionsWrite, ac.ScopeTeamsID)), routing.Wrap(hs.AddTeamMember))
- teamsRoute.Put("/:teamId/members/:userId", authorize(ac.EvalPermission(ac.ActionTeamsPermissionsWrite, ac.ScopeTeamsID)), routing.Wrap(hs.UpdateTeamMember))
- teamsRoute.Delete("/:teamId/members/:userId", authorize(ac.EvalPermission(ac.ActionTeamsPermissionsWrite, ac.ScopeTeamsID)), routing.Wrap(hs.RemoveTeamMember))
- teamsRoute.Get("/:teamId/preferences", authorize(ac.EvalPermission(ac.ActionTeamsRead, ac.ScopeTeamsID)), routing.Wrap(hs.GetTeamPreferences))
- teamsRoute.Put("/:teamId/preferences", authorize(ac.EvalPermission(ac.ActionTeamsWrite, ac.ScopeTeamsID)), routing.Wrap(hs.UpdateTeamPreferences))
- }, requestmeta.SetOwner(requestmeta.TeamAuth))
-
- // team without requirement of user to be org admin
- apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
- teamsRoute.Get("/:teamId", authorize(ac.EvalPermission(ac.ActionTeamsRead, ac.ScopeTeamsID)), routing.Wrap(hs.GetTeamByID))
- teamsRoute.Get("/search", authorize(ac.EvalPermission(ac.ActionTeamsRead)), routing.Wrap(hs.SearchTeams))
- }, requestmeta.SetOwner(requestmeta.TeamAuth))
-
// org information available to all users.
apiRoute.Group("/org", func(orgRoute routing.RouteRegister) {
orgRoute.Get("/", authorize(ac.EvalPermission(ac.ActionOrgsRead)), routing.Wrap(hs.GetCurrentOrg))
diff --git a/pkg/api/common_test.go b/pkg/api/common_test.go
index d7227f4127d..9f40753df17 100644
--- a/pkg/api/common_test.go
+++ b/pkg/api/common_test.go
@@ -214,7 +214,6 @@ func setupScenarioContext(t *testing.T, url string) *scenarioContext {
return sc
}
-// FIXME: This user should not be anonymous
func authedUserWithPermissions(userID, orgID int64, permissions []accesscontrol.Permission) *user.SignedInUser {
return &user.SignedInUser{UserID: userID, OrgID: orgID, OrgRole: org.RoleViewer, Permissions: map[int64]map[string][]string{orgID: accesscontrol.GroupScopesByAction(permissions)}}
}
diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go
index 63e95b1afd7..4f3a9c04ef5 100644
--- a/pkg/api/http_server.go
+++ b/pkg/api/http_server.go
@@ -170,7 +170,6 @@ type HTTPServer struct {
queryDataService query.Service
serviceAccountsService serviceaccounts.Service
authInfoService login.AuthInfoService
- teamPermissionsService accesscontrol.TeamPermissionsService
NotificationService *notifications.NotificationService
DashboardService dashboards.DashboardService
dashboardProvisioningService dashboards.DashboardProvisioningService
@@ -236,7 +235,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
dsGuardian guardian.DatasourceGuardianProvider, alertNotificationService *alerting.AlertNotificationService,
dashboardsnapshotsService dashboardsnapshots.Service, pluginSettings pluginSettings.Service,
avatarCacheServer *avatar.AvatarCacheServer, preferenceService pref.Service,
- teamsPermissionsService accesscontrol.TeamPermissionsService, folderPermissionsService accesscontrol.FolderPermissionsService,
+ folderPermissionsService accesscontrol.FolderPermissionsService,
dashboardPermissionsService accesscontrol.DashboardPermissionsService, dashboardVersionService dashver.Service,
starService star.Service, csrfService csrf.Service, basekinds *corekind.Base,
playlistService playlist.Service, apiKeyService apikey.Service, kvStore kvstore.KVStore,
@@ -319,7 +318,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
dashboardProvisioningService: dashboardProvisioningService,
folderService: folderService,
dsGuardian: dsGuardian,
- teamPermissionsService: teamsPermissionsService,
AlertNotificationService: alertNotificationService,
dashboardsnapshotsService: dashboardsnapshotsService,
PluginSettings: pluginSettings,
diff --git a/pkg/api/index.go b/pkg/api/index.go
index 966f6af846d..db692c272f6 100644
--- a/pkg/api/index.go
+++ b/pkg/api/index.go
@@ -166,7 +166,7 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV
hs.HooksService.RunIndexDataHooks(&data, c)
- data.NavTree.ApplyAdminIA()
+ data.NavTree.ApplyAdminIA(hs.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagNavAdminSubsections))
data.NavTree.Sort()
return &data, nil
diff --git a/pkg/api/preferences.go b/pkg/api/preferences.go
index e6c71d8b775..7cbbcfd1912 100644
--- a/pkg/api/preferences.go
+++ b/pkg/api/preferences.go
@@ -11,6 +11,7 @@ import (
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
pref "github.com/grafana/grafana/pkg/services/preference"
+ "github.com/grafana/grafana/pkg/services/preference/prefapi"
"github.com/grafana/grafana/pkg/web"
)
@@ -68,56 +69,7 @@ func (hs *HTTPServer) GetUserPreferences(c *contextmodel.ReqContext) response.Re
return response.Error(http.StatusInternalServerError, "Failed to get user preferences", errID)
}
- return hs.getPreferencesFor(c.Req.Context(), c.SignedInUser.GetOrgID(), userID, 0)
-}
-
-func (hs *HTTPServer) getPreferencesFor(ctx context.Context, orgID, userID, teamID int64) response.Response {
- prefsQuery := pref.GetPreferenceQuery{UserID: userID, OrgID: orgID, TeamID: teamID}
-
- preference, err := hs.preferenceService.Get(ctx, &prefsQuery)
- if err != nil {
- return response.Error(http.StatusInternalServerError, "Failed to get preferences", err)
- }
-
- var dashboardUID string
-
- // when homedashboardID is 0, that means it is the default home dashboard, no UID would be returned in the response
- if preference.HomeDashboardID != 0 {
- query := dashboards.GetDashboardQuery{ID: preference.HomeDashboardID, OrgID: orgID}
- queryResult, err := hs.DashboardService.GetDashboard(ctx, &query)
- if err == nil {
- dashboardUID = queryResult.UID
- }
- }
-
- dto := preferences.Spec{}
-
- if preference.WeekStart != nil && *preference.WeekStart != "" {
- dto.WeekStart = preference.WeekStart
- }
- if preference.Theme != "" {
- dto.Theme = &preference.Theme
- }
- if dashboardUID != "" {
- dto.HomeDashboardUID = &dashboardUID
- }
- if preference.Timezone != "" {
- dto.Timezone = &preference.Timezone
- }
-
- if preference.JSONData != nil {
- if preference.JSONData.Language != "" {
- dto.Language = &preference.JSONData.Language
- }
-
- if preference.JSONData.QueryHistory.HomeTab != "" {
- dto.QueryHistory = &preferences.QueryHistoryPreference{
- HomeTab: &preference.JSONData.QueryHistory.HomeTab,
- }
- }
- }
-
- return response.JSON(http.StatusOK, &dto)
+ return prefapi.GetPreferencesFor(c.Req.Context(), hs.DashboardService, hs.preferenceService, c.SignedInUser.GetOrgID(), userID, 0)
}
// swagger:route PUT /user/preferences user_preferences updateUserPreferences
@@ -142,48 +94,8 @@ func (hs *HTTPServer) UpdateUserPreferences(c *contextmodel.ReqContext) response
return response.Error(http.StatusInternalServerError, "Failed to update user preferences", errID)
}
- return hs.updatePreferencesFor(c.Req.Context(), c.SignedInUser.GetOrgID(), userID, 0, &dtoCmd)
-}
-
-func (hs *HTTPServer) updatePreferencesFor(ctx context.Context, orgID, userID, teamId int64, dtoCmd *dtos.UpdatePrefsCmd) response.Response {
- if dtoCmd.Theme != "" && !pref.IsValidThemeID(dtoCmd.Theme) {
- return response.Error(http.StatusBadRequest, "Invalid theme", nil)
- }
-
- dashboardID := dtoCmd.HomeDashboardID
- if dtoCmd.HomeDashboardUID != nil {
- query := dashboards.GetDashboardQuery{UID: *dtoCmd.HomeDashboardUID, OrgID: orgID}
- if query.UID == "" {
- // clear the value
- dashboardID = 0
- } else {
- queryResult, err := hs.DashboardService.GetDashboard(ctx, &query)
- if err != nil {
- return response.Error(http.StatusNotFound, "Dashboard not found", err)
- }
- dashboardID = queryResult.ID
- }
- }
- dtoCmd.HomeDashboardID = dashboardID
-
- saveCmd := pref.SavePreferenceCommand{
- UserID: userID,
- OrgID: orgID,
- TeamID: teamId,
- Theme: dtoCmd.Theme,
- Language: dtoCmd.Language,
- Timezone: dtoCmd.Timezone,
- WeekStart: dtoCmd.WeekStart,
- HomeDashboardID: dtoCmd.HomeDashboardID,
- QueryHistory: dtoCmd.QueryHistory,
- CookiePreferences: dtoCmd.Cookies,
- }
-
- if err := hs.preferenceService.Save(ctx, &saveCmd); err != nil {
- return response.ErrOrFallback(http.StatusInternalServerError, "Failed to save preferences", err)
- }
-
- return response.Success("Preferences updated")
+ return prefapi.UpdatePreferencesFor(c.Req.Context(), hs.DashboardService,
+ hs.preferenceService, c.SignedInUser.GetOrgID(), userID, 0, &dtoCmd)
}
// swagger:route PATCH /user/preferences user_preferences patchUserPreferences
@@ -262,7 +174,7 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te
// 403: forbiddenError
// 500: internalServerError
func (hs *HTTPServer) GetOrgPreferences(c *contextmodel.ReqContext) response.Response {
- return hs.getPreferencesFor(c.Req.Context(), c.SignedInUser.GetOrgID(), 0, 0)
+ return prefapi.GetPreferencesFor(c.Req.Context(), hs.DashboardService, hs.preferenceService, c.SignedInUser.GetOrgID(), 0, 0)
}
// swagger:route PUT /org/preferences org_preferences updateOrgPreferences
@@ -281,7 +193,7 @@ func (hs *HTTPServer) UpdateOrgPreferences(c *contextmodel.ReqContext) response.
return response.Error(http.StatusBadRequest, "bad request data", err)
}
- return hs.updatePreferencesFor(c.Req.Context(), c.SignedInUser.GetOrgID(), 0, 0, &dtoCmd)
+ return prefapi.UpdatePreferencesFor(c.Req.Context(), hs.DashboardService, hs.preferenceService, c.SignedInUser.GetOrgID(), 0, 0, &dtoCmd)
}
// swagger:route PATCH /org/preferences org_preferences patchOrgPreferences
diff --git a/pkg/api/user.go b/pkg/api/user.go
index 0437baa5fbf..32b7db52597 100644
--- a/pkg/api/user.go
+++ b/pkg/api/user.go
@@ -221,18 +221,11 @@ func (hs *HTTPServer) handleUpdateUser(ctx context.Context, cmd user.UpdateUserC
return response.Error(http.StatusForbidden, "User info cannot be updated for external Users", nil)
}
- if len(cmd.Login) == 0 {
- cmd.Login = cmd.Email
- if len(cmd.Login) == 0 {
- return response.Error(http.StatusBadRequest, "Validation error, need to specify either username or email", nil)
- }
- }
-
if err := hs.userService.Update(ctx, &cmd); err != nil {
if errors.Is(err, user.ErrCaseInsensitive) {
return response.Error(http.StatusConflict, "Update would result in user login conflict", err)
}
- return response.Error(http.StatusInternalServerError, "Failed to update user", err)
+ return response.ErrOrFallback(http.StatusInternalServerError, "Failed to update user", err)
}
return response.Success("User updated")
diff --git a/pkg/expr/graph.go b/pkg/expr/graph.go
index d45bae86b2a..86668d27579 100644
--- a/pkg/expr/graph.go
+++ b/pkg/expr/graph.go
@@ -225,7 +225,7 @@ func (s *Service) buildGraph(req *Request) (*simple.DirectedGraph, error) {
case TypeDatasourceNode:
node, err = s.buildDSNode(dp, rn, req)
case TypeCMDNode:
- node, err = buildCMDNode(dp, rn)
+ node, err = buildCMDNode(rn, s.features)
case TypeMLNode:
if s.features.IsEnabled(featuremgmt.FlagMlExpressions) {
node, err = s.buildMLNode(dp, rn, req)
diff --git a/pkg/expr/hysteresis.go b/pkg/expr/hysteresis.go
new file mode 100644
index 00000000000..9526f835715
--- /dev/null
+++ b/pkg/expr/hysteresis.go
@@ -0,0 +1,122 @@
+package expr
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/grafana/grafana-plugin-sdk-go/data"
+
+ "github.com/grafana/grafana/pkg/expr/mathexp"
+ "github.com/grafana/grafana/pkg/infra/tracing"
+)
+
+type Fingerprints map[data.Fingerprint]struct{}
+
+// HysteresisCommand is a special case of ThresholdCommand that encapsulates two thresholds that are applied depending on the results of the previous evaluations:
+// - first threshold - "loading", is used when the metric is determined as not loaded, i.e. it does not exist in the data provided by the reader.
+// - second threshold - "unloading", is used when the metric is determined as loaded.
+// To determine whether a metric is loaded, the command uses LoadedDimensions that is supposed to contain data.Fingerprint of
+// the metrics that were loaded during the previous evaluation.
+// The result of the execution of the command is the same as ThresholdCommand: 0 or 1 for each metric.
+type HysteresisCommand struct {
+ RefID string
+ ReferenceVar string
+ LoadingThresholdFunc ThresholdCommand
+ UnloadingThresholdFunc ThresholdCommand
+ LoadedDimensions Fingerprints
+}
+
+func (h *HysteresisCommand) NeedsVars() []string {
+ return []string{h.ReferenceVar}
+}
+
+func (h *HysteresisCommand) Execute(ctx context.Context, now time.Time, vars mathexp.Vars, tracer tracing.Tracer) (mathexp.Results, error) {
+ results := vars[h.ReferenceVar]
+
+ // shortcut for NoData
+ if results.IsNoData() {
+ return mathexp.Results{Values: mathexp.Values{mathexp.NewNoData()}}, nil
+ }
+ if h.LoadedDimensions == nil || len(h.LoadedDimensions) == 0 {
+ return h.LoadingThresholdFunc.Execute(ctx, now, vars, tracer)
+ }
+ var loadedVals, unloadedVals mathexp.Values
+ for _, value := range results.Values {
+ _, ok := h.LoadedDimensions[value.GetLabels().Fingerprint()]
+ if ok {
+ loadedVals = append(loadedVals, value)
+ } else {
+ unloadedVals = append(unloadedVals, value)
+ }
+ }
+
+ if len(loadedVals) == 0 { // if all values are unloaded
+ return h.LoadingThresholdFunc.Execute(ctx, now, vars, tracer)
+ }
+ if len(unloadedVals) == 0 { // if all values are loaded
+ return h.UnloadingThresholdFunc.Execute(ctx, now, vars, tracer)
+ }
+
+ defer func() {
+ // return back the old values
+ vars[h.ReferenceVar] = results
+ }()
+
+ vars[h.ReferenceVar] = mathexp.Results{Values: unloadedVals}
+ loadingResults, err := h.LoadingThresholdFunc.Execute(ctx, now, vars, tracer)
+ if err != nil {
+ return mathexp.Results{}, fmt.Errorf("failed to execute loading threshold: %w", err)
+ }
+ vars[h.ReferenceVar] = mathexp.Results{Values: loadedVals}
+ unloadingResults, err := h.UnloadingThresholdFunc.Execute(ctx, now, vars, tracer)
+ if err != nil {
+ return mathexp.Results{}, fmt.Errorf("failed to execute unloading threshold: %w", err)
+ }
+
+ return mathexp.Results{Values: append(loadingResults.Values, unloadingResults.Values...)}, nil
+}
+
+func NewHysteresisCommand(refID string, referenceVar string, loadCondition ThresholdCommand, unloadCondition ThresholdCommand, l Fingerprints) (*HysteresisCommand, error) {
+ return &HysteresisCommand{
+ RefID: refID,
+ LoadingThresholdFunc: loadCondition,
+ UnloadingThresholdFunc: unloadCondition,
+ ReferenceVar: referenceVar,
+ LoadedDimensions: l,
+ }, nil
+}
+
+// FingerprintsFromFrame converts data.Frame to Fingerprints.
+// The input data frame must have a single field of uint64 type.
+// Returns error if the input data frame has invalid format
+func FingerprintsFromFrame(frame *data.Frame) (Fingerprints, error) {
+ frameType, frameVersion := frame.TypeInfo("")
+ if frameType != "fingerprints" {
+ return nil, fmt.Errorf("invalid format of loaded dimensions frame: expected frame type 'fingerprints'")
+ }
+ if frameVersion.Greater(data.FrameTypeVersion{1, 0}) {
+ return nil, fmt.Errorf("invalid format of loaded dimensions frame: expected frame type 'fingerprints' of version 1.0 or lower")
+ }
+ if len(frame.Fields) != 1 {
+ return nil, fmt.Errorf("invalid format of loaded dimensions frame: expected a single field but got %d", len(frame.Fields))
+ }
+ fld := frame.Fields[0]
+ if fld.Type() != data.FieldTypeUint64 {
+ return nil, fmt.Errorf("invalid format of loaded dimensions frame: the field type must be uint64 but got %s", fld.Type().String())
+ }
+ result := make(Fingerprints, fld.Len())
+ for i := 0; i < fld.Len(); i++ {
+ val, ok := fld.ConcreteAt(i)
+ if !ok {
+ continue
+ }
+ switch v := val.(type) {
+ case uint64:
+ result[data.Fingerprint(v)] = struct{}{}
+ default:
+ return nil, fmt.Errorf("cannot read the value at index [%d], expected uint64 but got '%T'", i, val)
+ }
+ }
+ return result, nil
+}
diff --git a/pkg/expr/hysteresis_test.go b/pkg/expr/hysteresis_test.go
new file mode 100644
index 00000000000..957e045174c
--- /dev/null
+++ b/pkg/expr/hysteresis_test.go
@@ -0,0 +1,188 @@
+package expr
+
+import (
+ "context"
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/grafana/grafana-plugin-sdk-go/data"
+ "github.com/stretchr/testify/require"
+
+ "github.com/grafana/grafana/pkg/expr/mathexp"
+ "github.com/grafana/grafana/pkg/infra/tracing"
+)
+
+func TestHysteresisExecute(t *testing.T) {
+ number := func(label string, value float64) mathexp.Number {
+ n := mathexp.NewNumber("A", data.Labels{"label": label})
+ n.SetValue(&value)
+ return n
+ }
+ fingerprint := func(label string) data.Fingerprint {
+ return data.Labels{"label": label}.Fingerprint()
+ }
+
+ tracer := tracing.InitializeTracerForTest()
+
+ var loadThreshold = 100.0
+ var unloadThreshold = 30.0
+
+ testCases := []struct {
+ name string
+ loadedDimensions Fingerprints
+ input mathexp.Values
+ expected mathexp.Values
+ expectedError error
+ }{
+ {
+ name: "return NoData when no data",
+ loadedDimensions: Fingerprints{0: struct{}{}},
+ input: mathexp.Values{mathexp.NewNoData()},
+ expected: mathexp.Values{mathexp.NewNoData()},
+ },
+ {
+ name: "use only loaded condition if no loaded metrics",
+ loadedDimensions: Fingerprints{},
+ input: mathexp.Values{
+ number("value1", loadThreshold+1),
+ number("value2", loadThreshold),
+ number("value3", loadThreshold-1),
+ number("value4", unloadThreshold+1),
+ number("value5", unloadThreshold),
+ number("value6", unloadThreshold-1),
+ },
+ expected: mathexp.Values{
+ number("value1", 1),
+ number("value2", 0),
+ number("value3", 0),
+ number("value4", 0),
+ number("value5", 0),
+ number("value6", 0),
+ },
+ },
+ {
+ name: "evaluate loaded metrics against unloaded threshold",
+ loadedDimensions: Fingerprints{
+ fingerprint("value4"): {},
+ fingerprint("value5"): {},
+ fingerprint("value6"): {},
+ },
+ input: mathexp.Values{
+ number("value1", loadThreshold+1),
+ number("value2", loadThreshold),
+ number("value3", loadThreshold-1),
+ number("value4", unloadThreshold+1),
+ number("value5", unloadThreshold),
+ number("value6", unloadThreshold-1),
+ },
+ expected: mathexp.Values{
+ number("value1", 1),
+ number("value2", 0),
+ number("value3", 0),
+ number("value4", 1),
+ number("value5", 0),
+ number("value6", 0),
+ },
+ },
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ cmd := &HysteresisCommand{
+ RefID: "B",
+ ReferenceVar: "A",
+ LoadingThresholdFunc: ThresholdCommand{
+ ReferenceVar: "A",
+ RefID: "B",
+ ThresholdFunc: ThresholdIsAbove,
+ Conditions: []float64{loadThreshold},
+ },
+ UnloadingThresholdFunc: ThresholdCommand{
+ ReferenceVar: "A",
+ RefID: "B",
+ ThresholdFunc: ThresholdIsAbove,
+ Conditions: []float64{unloadThreshold},
+ },
+ LoadedDimensions: tc.loadedDimensions,
+ }
+
+ result, err := cmd.Execute(context.Background(), time.Now(), mathexp.Vars{
+ "A": mathexp.Results{Values: tc.input},
+ }, tracer)
+ if tc.expectedError != nil {
+ require.ErrorIs(t, err, tc.expectedError)
+ return
+ }
+ require.NoError(t, err)
+ require.EqualValues(t, result.Values, tc.expected)
+ })
+ }
+}
+
+func TestLoadedDimensionsFromFrame(t *testing.T) {
+ correctType := &data.FrameMeta{Type: "fingerprints", TypeVersion: data.FrameTypeVersion{1, 0}}
+ testCases := []struct {
+ name string
+ frame *data.Frame
+ expected Fingerprints
+ expectedError bool
+ }{
+ {
+ name: "should fail if frame has wrong type",
+ frame: data.NewFrame("test").SetMeta(&data.FrameMeta{Type: "test"}),
+ expectedError: true,
+ },
+ {
+ name: "should fail if frame has unsupported version",
+ frame: data.NewFrame("test").SetMeta(&data.FrameMeta{Type: "fingerprints", TypeVersion: data.FrameTypeVersion{1, 1}}),
+ expectedError: true,
+ },
+ {
+ name: "should fail if frame has no fields",
+ frame: data.NewFrame("test").SetMeta(correctType),
+ expectedError: true,
+ },
+ {
+ name: "should fail if frame has many fields",
+ frame: data.NewFrame("test",
+ data.NewField("fingerprints", nil, []uint64{}),
+ data.NewField("test", nil, []string{}),
+ ).SetMeta(correctType),
+ expectedError: true,
+ },
+ {
+ name: "should fail if frame has field of a wrong type",
+ frame: data.NewFrame("test",
+ data.NewField("fingerprints", nil, []int64{}),
+ ).SetMeta(correctType),
+ expectedError: true,
+ },
+ {
+ name: "should fail if frame has nullable uint64 field",
+ frame: data.NewFrame("test",
+ data.NewField("fingerprints", nil, []*uint64{}),
+ ).SetMeta(correctType),
+ expectedError: true,
+ },
+ {
+ name: "should create LoadedMetrics",
+ frame: data.NewFrame("test",
+ data.NewField("fingerprints", nil, []uint64{1, 2, 3, 4, 5}),
+ ).SetMeta(correctType),
+ expected: Fingerprints{1: {}, 2: {}, 3: {}, 4: {}, 5: {}},
+ },
+ }
+
+ for _, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ result, err := FingerprintsFromFrame(testCase.frame)
+ if testCase.expectedError {
+ require.Error(t, err)
+ } else {
+ require.EqualValues(t, testCase.expected, result)
+ b, _ := json.Marshal(testCase.frame)
+ t.Log(string(b))
+ }
+ })
+ }
+}
diff --git a/pkg/expr/mathexp/types.go b/pkg/expr/mathexp/types.go
index 38a975caed8..ca5069650b7 100644
--- a/pkg/expr/mathexp/types.go
+++ b/pkg/expr/mathexp/types.go
@@ -13,6 +13,11 @@ type Results struct {
Error error
}
+// IsNoData checks whether the result contains NoData value
+func (r Results) IsNoData() bool {
+ return len(r.Values) == 0 || len(r.Values) == 1 && r.Values[0].Type() == parse.TypeNoData
+}
+
// Values is a slice of Value interfaces
type Values []Value
diff --git a/pkg/expr/nodes.go b/pkg/expr/nodes.go
index 9991c8ea8d5..bb98c995f3b 100644
--- a/pkg/expr/nodes.go
+++ b/pkg/expr/nodes.go
@@ -96,7 +96,7 @@ func (gn *CMDNode) Execute(ctx context.Context, now time.Time, vars mathexp.Vars
return gn.Command.Execute(ctx, now, vars, s.tracer)
}
-func buildCMDNode(dp *simple.DirectedGraph, rn *rawNode) (*CMDNode, error) {
+func buildCMDNode(rn *rawNode, toggles featuremgmt.FeatureToggles) (*CMDNode, error) {
commandType, err := rn.GetCommandType()
if err != nil {
return nil, fmt.Errorf("invalid command type in expression '%v': %w", rn.RefID, err)
@@ -120,7 +120,7 @@ func buildCMDNode(dp *simple.DirectedGraph, rn *rawNode) (*CMDNode, error) {
case TypeClassicConditions:
node.Command, err = classic.UnmarshalConditionsCmd(rn.Query, rn.RefID)
case TypeThreshold:
- node.Command, err = UnmarshalThresholdCommand(rn)
+ node.Command, err = UnmarshalThresholdCommand(rn, toggles)
default:
return nil, fmt.Errorf("expression command type '%v' in expression '%v' not implemented", commandType, rn.RefID)
}
diff --git a/pkg/expr/threshold.go b/pkg/expr/threshold.go
index 588cd65e30f..f49eea59b5f 100644
--- a/pkg/expr/threshold.go
+++ b/pkg/expr/threshold.go
@@ -7,8 +7,11 @@ import (
"strings"
"time"
+ "github.com/grafana/grafana-plugin-sdk-go/data"
+
"github.com/grafana/grafana/pkg/expr/mathexp"
"github.com/grafana/grafana/pkg/infra/tracing"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
)
type ThresholdCommand struct {
@@ -16,6 +19,7 @@ type ThresholdCommand struct {
RefID string
ThresholdFunc string
Conditions []float64
+ Invert bool
}
const (
@@ -39,6 +43,8 @@ func NewThresholdCommand(refID, referenceVar, thresholdFunc string, conditions [
if len(conditions) < 1 {
return nil, fmt.Errorf("incorrect number of arguments: got %d but need 1", len(conditions))
}
+ default:
+ return nil, fmt.Errorf("expected threshold function to be one of [%s], got %s", strings.Join(supportedThresholdFuncs, ", "), thresholdFunc)
}
return &ThresholdCommand{
@@ -49,50 +55,48 @@ func NewThresholdCommand(refID, referenceVar, thresholdFunc string, conditions [
}, nil
}
-type ThresholdConditionJSON struct {
- Evaluator ConditionEvalJSON `json:"evaluator"`
-}
-
type ConditionEvalJSON struct {
Params []float64 `json:"params"`
Type string `json:"type"` // e.g. "gt"
}
// UnmarshalResampleCommand creates a ResampleCMD from Grafana's frontend query.
-func UnmarshalThresholdCommand(rn *rawNode) (*ThresholdCommand, error) {
- rawQuery := rn.Query
-
- rawExpression, ok := rawQuery["expression"]
- if !ok {
+func UnmarshalThresholdCommand(rn *rawNode, features featuremgmt.FeatureToggles) (Command, error) {
+ cmdConfig := ThresholdCommandConfig{}
+ if err := json.Unmarshal(rn.QueryRaw, &cmdConfig); err != nil {
+ return nil, fmt.Errorf("failed to parse the threshold command: %w", err)
+ }
+ if cmdConfig.Expression == "" {
return nil, fmt.Errorf("no variable specified to reference for refId %v", rn.RefID)
}
- referenceVar, ok := rawExpression.(string)
- if !ok {
- return nil, fmt.Errorf("expected threshold variable to be a string, got %T for refId %v", rawExpression, rn.RefID)
- }
-
- jsonFromM, err := json.Marshal(rawQuery["conditions"])
- if err != nil {
- return nil, fmt.Errorf("failed to remarshal threshold expression body: %w", err)
- }
- var conditions []ThresholdConditionJSON
- if err = json.Unmarshal(jsonFromM, &conditions); err != nil {
- return nil, fmt.Errorf("failed to unmarshal remarshaled threshold expression body: %w", err)
- }
-
- for _, condition := range conditions {
- if !IsSupportedThresholdFunc(condition.Evaluator.Type) {
- return nil, fmt.Errorf("expected threshold function to be one of %s, got %s", strings.Join(supportedThresholdFuncs, ", "), condition.Evaluator.Type)
- }
- }
+ referenceVar := cmdConfig.Expression
// we only support one condition for now, we might want to turn this in to "OR" expressions later
- if len(conditions) != 1 {
+ if len(cmdConfig.Conditions) != 1 {
return nil, fmt.Errorf("threshold expression requires exactly one condition")
}
- firstCondition := conditions[0]
+ firstCondition := cmdConfig.Conditions[0]
- return NewThresholdCommand(rn.RefID, referenceVar, firstCondition.Evaluator.Type, firstCondition.Evaluator.Params)
+ threshold, err := NewThresholdCommand(rn.RefID, referenceVar, firstCondition.Evaluator.Type, firstCondition.Evaluator.Params)
+ if err != nil {
+ return nil, fmt.Errorf("invalid condition: %w", err)
+ }
+ if firstCondition.UnloadEvaluator != nil && features.IsEnabled(featuremgmt.FlagRecoveryThreshold) {
+ unloading, err := NewThresholdCommand(rn.RefID, referenceVar, firstCondition.UnloadEvaluator.Type, firstCondition.UnloadEvaluator.Params)
+ unloading.Invert = true
+ if err != nil {
+ return nil, fmt.Errorf("invalid unloadCondition: %w", err)
+ }
+ var d Fingerprints
+ if firstCondition.LoadedDimensions != nil {
+ d, err = FingerprintsFromFrame(firstCondition.LoadedDimensions)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse loaded dimensions: %w", err)
+ }
+ }
+ return NewHysteresisCommand(rn.RefID, referenceVar, *threshold, *unloading, d)
+ }
+ return threshold, nil
}
// NeedsVars returns the variable names (refIds) that are dependencies
@@ -102,7 +106,7 @@ func (tc *ThresholdCommand) NeedsVars() []string {
}
func (tc *ThresholdCommand) Execute(ctx context.Context, now time.Time, vars mathexp.Vars, tracer tracing.Tracer) (mathexp.Results, error) {
- mathExpression, err := createMathExpression(tc.ReferenceVar, tc.ThresholdFunc, tc.Conditions)
+ mathExpression, err := createMathExpression(tc.ReferenceVar, tc.ThresholdFunc, tc.Conditions, tc.Invert)
if err != nil {
return mathexp.Results{}, err
}
@@ -116,19 +120,25 @@ func (tc *ThresholdCommand) Execute(ctx context.Context, now time.Time, vars mat
}
// createMathExpression converts all the info we have about a "threshold" expression in to a Math expression
-func createMathExpression(referenceVar string, thresholdFunc string, args []float64) (string, error) {
+func createMathExpression(referenceVar string, thresholdFunc string, args []float64, invert bool) (string, error) {
+ var exp string
switch thresholdFunc {
case ThresholdIsAbove:
- return fmt.Sprintf("${%s} > %f", referenceVar, args[0]), nil
+ exp = fmt.Sprintf("${%s} > %f", referenceVar, args[0])
case ThresholdIsBelow:
- return fmt.Sprintf("${%s} < %f", referenceVar, args[0]), nil
+ exp = fmt.Sprintf("${%s} < %f", referenceVar, args[0])
case ThresholdIsWithinRange:
- return fmt.Sprintf("${%s} > %f && ${%s} < %f", referenceVar, args[0], referenceVar, args[1]), nil
+ exp = fmt.Sprintf("${%s} > %f && ${%s} < %f", referenceVar, args[0], referenceVar, args[1])
case ThresholdIsOutsideRange:
- return fmt.Sprintf("${%s} < %f || ${%s} > %f", referenceVar, args[0], referenceVar, args[1]), nil
+ exp = fmt.Sprintf("${%s} < %f || ${%s} > %f", referenceVar, args[0], referenceVar, args[1])
default:
return "", fmt.Errorf("failed to evaluate threshold expression: no such threshold function %s", thresholdFunc)
}
+
+ if invert {
+ return fmt.Sprintf("!(%s)", exp), nil
+ }
+ return exp, nil
}
func IsSupportedThresholdFunc(name string) bool {
@@ -142,3 +152,14 @@ func IsSupportedThresholdFunc(name string) bool {
return isSupported
}
+
+type ThresholdCommandConfig struct {
+ Expression string `json:"expression"`
+ Conditions []ThresholdConditionJSON `json:"conditions"`
+}
+
+type ThresholdConditionJSON struct {
+ Evaluator ConditionEvalJSON `json:"evaluator"`
+ UnloadEvaluator *ConditionEvalJSON `json:"unloadEvaluator"`
+ LoadedDimensions *data.Frame `json:"loadedDimensions"`
+}
diff --git a/pkg/expr/threshold_test.go b/pkg/expr/threshold_test.go
index 6a99d60f5a2..5368ae33486 100644
--- a/pkg/expr/threshold_test.go
+++ b/pkg/expr/threshold_test.go
@@ -2,9 +2,13 @@ package expr
import (
"encoding/json"
+ "fmt"
+ "sort"
"testing"
"github.com/stretchr/testify/require"
+
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
)
func TestNewThresholdCommand(t *testing.T) {
@@ -82,6 +86,7 @@ func TestUnmarshalThresholdCommand(t *testing.T) {
query string
shouldError bool
expectedError string
+ assert func(*testing.T, Command)
}
cases := []testCase{
@@ -97,7 +102,13 @@ func TestUnmarshalThresholdCommand(t *testing.T) {
}
}]
}`,
- shouldError: false,
+ assert: func(t *testing.T, command Command) {
+ require.IsType(t, &ThresholdCommand{}, command)
+ cmd := command.(*ThresholdCommand)
+ require.Equal(t, []string{"A"}, cmd.NeedsVars())
+ require.Equal(t, "gt", cmd.ThresholdFunc)
+ require.Equal(t, []float64{20.0, 80.0}, cmd.Conditions)
+ },
},
{
description: "unmarshal with missing conditions should error",
@@ -107,17 +118,7 @@ func TestUnmarshalThresholdCommand(t *testing.T) {
"conditions": []
}`,
shouldError: true,
- expectedError: "requires exactly one condition",
- },
- {
- description: "unmarshal with missing conditions should error",
- query: `{
- "expression" : "A",
- "type": "threshold",
- "conditions": []
- }`,
- shouldError: true,
- expectedError: "requires exactly one condition",
+ expectedError: "threshold expression requires exactly one condition",
},
{
description: "unmarshal with unsupported threshold function",
@@ -141,37 +142,86 @@ func TestUnmarshalThresholdCommand(t *testing.T) {
"type": "threshold",
"conditions": []
}`,
- shouldError: true,
- expectedError: "expected threshold variable to be a string",
+ shouldError: true,
+ },
+ {
+ description: "unmarshal as hysteresis command if two evaluators",
+ query: `{
+ "expression": "B",
+ "conditions": [
+ {
+ "evaluator": {
+ "params": [
+ 100
+ ],
+ "type": "gt"
+ },
+ "unloadEvaluator": {
+ "params": [
+ 31
+ ],
+ "type": "lt"
+ },
+ "loadedDimensions": {"schema":{"name":"test","meta":{"type":"fingerprints","typeVersion":[1,0]},"fields":[{"name":"fingerprints","type":"number","typeInfo":{"frame":"uint64"}}]},"data":{"values":[[1,2,3,4,5]]}}
+ }
+ ]
+ }`,
+ assert: func(t *testing.T, c Command) {
+ require.IsType(t, &HysteresisCommand{}, c)
+ cmd := c.(*HysteresisCommand)
+ require.Equal(t, []string{"B"}, cmd.NeedsVars())
+ require.Equal(t, []string{"B"}, cmd.LoadingThresholdFunc.NeedsVars())
+ require.Equal(t, "gt", cmd.LoadingThresholdFunc.ThresholdFunc)
+ require.Equal(t, []float64{100.0}, cmd.LoadingThresholdFunc.Conditions)
+ require.Equal(t, []string{"B"}, cmd.UnloadingThresholdFunc.NeedsVars())
+ require.Equal(t, "lt", cmd.UnloadingThresholdFunc.ThresholdFunc)
+ require.Equal(t, []float64{31.0}, cmd.UnloadingThresholdFunc.Conditions)
+ require.True(t, cmd.UnloadingThresholdFunc.Invert)
+ require.NotNil(t, cmd.LoadedDimensions)
+ actual := make([]uint64, 0, len(cmd.LoadedDimensions))
+ for fingerprint := range cmd.LoadedDimensions {
+ actual = append(actual, uint64(fingerprint))
+ }
+ sort.Slice(actual, func(i, j int) bool {
+ return actual[i] < actual[j]
+ })
+
+ require.EqualValues(t, []uint64{1, 2, 3, 4, 5}, actual)
+ },
},
}
for _, tc := range cases {
- q := []byte(tc.query)
+ t.Run(tc.description, func(t *testing.T) {
+ q := []byte(tc.query)
+ var qmap = make(map[string]any)
+ require.NoError(t, json.Unmarshal(q, &qmap))
- var qmap = make(map[string]any)
- require.NoError(t, json.Unmarshal(q, &qmap))
+ cmd, err := UnmarshalThresholdCommand(&rawNode{
+ RefID: "",
+ Query: qmap,
+ QueryRaw: []byte(tc.query),
+ QueryType: "",
+ DataSource: nil,
+ }, featuremgmt.WithFeatures(featuremgmt.FlagRecoveryThreshold))
- cmd, err := UnmarshalThresholdCommand(&rawNode{
- RefID: "",
- Query: qmap,
- QueryType: "",
- DataSource: nil,
+ if tc.shouldError {
+ require.Nil(t, cmd)
+ require.NotNil(t, err)
+ require.Contains(t, err.Error(), tc.expectedError)
+ } else {
+ require.Nil(t, err)
+ require.NotNil(t, cmd)
+ if tc.assert != nil {
+ tc.assert(t, cmd)
+ }
+ }
})
-
- if tc.shouldError {
- require.Nil(t, cmd)
- require.NotNil(t, err)
- require.Contains(t, err.Error(), tc.expectedError)
- } else {
- require.Nil(t, err)
- require.NotNil(t, cmd)
- }
}
}
func TestThresholdCommandVars(t *testing.T) {
- cmd, err := NewThresholdCommand("B", "A", "is_above", []float64{})
+ cmd, err := NewThresholdCommand("B", "A", "lt", []float64{1.0})
require.Nil(t, err)
require.Equal(t, cmd.NeedsVars(), []string{"A"})
}
@@ -219,17 +269,25 @@ func TestCreateMathExpression(t *testing.T) {
for _, tc := range cases {
t.Run(tc.description, func(t *testing.T) {
- expr, err := createMathExpression(tc.ref, tc.function, tc.params)
+ expr, err := createMathExpression(tc.ref, tc.function, tc.params, false)
require.Nil(t, err)
require.NotNil(t, expr)
- require.Equal(t, expr, tc.expected)
+ require.Equal(t, tc.expected, expr)
+
+ t.Run("inverted", func(t *testing.T) {
+ expr, err := createMathExpression(tc.ref, tc.function, tc.params, true)
+ require.Nil(t, err)
+ require.NotNil(t, expr)
+
+ require.Equal(t, fmt.Sprintf("!(%s)", tc.expected), expr)
+ })
})
}
t.Run("should error if function is unsupported", func(t *testing.T) {
- expr, err := createMathExpression("A", "foo", []float64{0})
+ expr, err := createMathExpression("A", "foo", []float64{0}, false)
require.Equal(t, expr, "")
require.NotNil(t, err)
require.Contains(t, err.Error(), "no such threshold function")
diff --git a/pkg/kinds/dashboard/dashboard_spec_gen.go b/pkg/kinds/dashboard/dashboard_spec_gen.go
index 145ae289ca3..ca5b81891a3 100644
--- a/pkg/kinds/dashboard/dashboard_spec_gen.go
+++ b/pkg/kinds/dashboard/dashboard_spec_gen.go
@@ -529,7 +529,7 @@ type Panel struct {
// The data model used in Grafana, namely the data frame, is a columnar-oriented table structure that unifies both time series and table query results.
// Each column within this structure is called a field. A field can represent a single time series or table column.
// Field options allow you to change how the data is displayed in your visualizations.
- FieldConfig FieldConfigSource `json:"fieldConfig"`
+ FieldConfig *FieldConfigSource `json:"fieldConfig,omitempty"`
// Position and dimensions of a panel in the grid
GridPos *GridPos `json:"gridPos,omitempty"`
@@ -562,7 +562,7 @@ type Panel struct {
MaxPerRow *float32 `json:"maxPerRow,omitempty"`
// It depends on the panel plugin. They are specified by the Options field in panel plugin schemas.
- Options map[string]any `json:"options"`
+ Options map[string]any `json:"options,omitempty"`
// The version of the plugin that is used for this panel. This is used to find the plugin to display the panel and to migrate old panel configs.
PluginVersion *string `json:"pluginVersion,omitempty"`
@@ -602,10 +602,10 @@ type Panel struct {
// List of transformations that are applied to the panel data before rendering.
// When there are multiple transformations, Grafana applies them in the order they are listed.
// Each transformation creates a result set that then passes on to the next transformation in the processing pipeline.
- Transformations []DataTransformerConfig `json:"transformations"`
+ Transformations []DataTransformerConfig `json:"transformations,omitempty"`
// Whether to display the panel without a background.
- Transparent bool `json:"transparent"`
+ Transparent *bool `json:"transparent,omitempty"`
// The panel plugin type id. This is used to find the plugin to display the panel.
Type string `json:"type"`
@@ -734,7 +734,7 @@ type Spec struct {
Description *string `json:"description,omitempty"`
// Whether a dashboard is editable or not.
- Editable bool `json:"editable"`
+ Editable *bool `json:"editable,omitempty"`
// The month that the fiscal year starts on. 0 = January, 11 = December
FiscalYearStartMonth *int `json:"fiscalYearStartMonth,omitempty"`
@@ -745,7 +745,7 @@ type Spec struct {
// 0 for no shared crosshair or tooltip (default).
// 1 for shared crosshair.
// 2 for shared crosshair AND shared tooltip.
- GraphTooltip CursorSync `json:"graphTooltip"`
+ GraphTooltip *CursorSync `json:"graphTooltip,omitempty"`
// Unique numeric identifier for the dashboard.
// `id` is internal to a specific Grafana instance. `uid` should be used to identify a dashboard across Grafana instances.
diff --git a/pkg/kinds/general.go b/pkg/kinds/general.go
index 94022921448..74c7a7f6fb0 100644
--- a/pkg/kinds/general.go
+++ b/pkg/kinds/general.go
@@ -53,10 +53,10 @@ const annoKeyFolder = "grafana.com/folder"
const annoKeySlug = "grafana.com/slug"
// Identify where values came from
-const annoKeyOriginName = "grafana.com/origin/name"
-const annoKeyOriginPath = "grafana.com/origin/path"
-const annoKeyOriginKey = "grafana.com/origin/key"
-const annoKeyOriginTime = "grafana.com/origin/time"
+const annoKeyOriginName = "grafana.com/originName"
+const annoKeyOriginPath = "grafana.com/originPath"
+const annoKeyOriginKey = "grafana.com/originKey"
+const annoKeyOriginTime = "grafana.com/originTime"
func (m *GrafanaResourceMetadata) GetUpdatedTimestamp() *time.Time {
v, ok := m.Annotations[annoKeyUpdatedTimestamp]
diff --git a/pkg/login/social/azuread_oauth.go b/pkg/login/social/azuread_oauth.go
index e7dc8b11b63..640552e9aae 100644
--- a/pkg/login/social/azuread_oauth.go
+++ b/pkg/login/social/azuread_oauth.go
@@ -23,7 +23,6 @@ type SocialAzureAD struct {
*SocialBase
cache remotecache.CacheStorage
allowedOrganizations []string
- allowedGroups []string
forceUseGraphAPI bool
skipOrgRoleSync bool
}
@@ -99,7 +98,7 @@ func (s *SocialAzureAD) UserInfo(ctx context.Context, client *http.Client, token
return nil, fmt.Errorf("failed to extract groups: %w", err)
}
s.log.Debug("AzureAD OAuth: extracted groups", "email", email, "groups", fmt.Sprintf("%v", groups))
- if !s.IsGroupMember(groups) {
+ if !s.isGroupMember(groups) {
return nil, errMissingGroupMembership
}
@@ -182,22 +181,6 @@ func (s *SocialAzureAD) validateIDTokenSignature(ctx context.Context, client *ht
return nil, &Error{"AzureAD OAuth: signing key not found"}
}
-func (s *SocialAzureAD) IsGroupMember(groups []string) bool {
- if len(s.allowedGroups) == 0 {
- return true
- }
-
- for _, allowedGroup := range s.allowedGroups {
- for _, group := range groups {
- if group == allowedGroup {
- return true
- }
- }
- }
-
- return false
-}
-
func (claims *azureClaims) extractEmail() string {
if claims.Email == "" {
if claims.PreferredUsername != "" {
diff --git a/pkg/login/social/azuread_oauth_test.go b/pkg/login/social/azuread_oauth_test.go
index 5818293d200..d8819629c4b 100644
--- a/pkg/login/social/azuread_oauth_test.go
+++ b/pkg/login/social/azuread_oauth_test.go
@@ -530,7 +530,6 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
s := &SocialAzureAD{
SocialBase: tt.fields.SocialBase,
- allowedGroups: tt.fields.allowedGroups,
allowedOrganizations: tt.fields.allowedOrganizations,
forceUseGraphAPI: tt.fields.forceUseGraphAPI,
cache: cache,
@@ -540,6 +539,10 @@ func TestSocialAzureAD_UserInfo(t *testing.T) {
s.SocialBase = newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{}, "", false, *featuremgmt.WithFeatures())
}
+ if tt.fields.allowedGroups != nil {
+ s.allowedGroups = tt.fields.allowedGroups
+ }
+
if tt.fields.usGovURL {
s.SocialBase.Endpoint.AuthURL = usGovAuthURL
} else {
@@ -710,14 +713,15 @@ func TestSocialAzureAD_SkipOrgRole(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
s := &SocialAzureAD{
SocialBase: tt.fields.SocialBase,
- allowedGroups: tt.fields.allowedGroups,
forceUseGraphAPI: tt.fields.forceUseGraphAPI,
skipOrgRoleSync: tt.fields.skipOrgRoleSync,
cache: cache,
}
if tt.fields.SocialBase == nil {
- s.SocialBase = newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{}, "", false, *featuremgmt.WithFeatures())
+ s.SocialBase = newSocialBase("azuread", &oauth2.Config{ClientID: "client-id-example"}, &OAuthInfo{
+ AllowedGroups: tt.fields.allowedGroups,
+ }, "", false, *featuremgmt.WithFeatures())
}
s.SocialBase.Endpoint.AuthURL = authURL
diff --git a/pkg/login/social/gitlab_oauth.go b/pkg/login/social/gitlab_oauth.go
index 0bf22db646b..70d2eb23730 100644
--- a/pkg/login/social/gitlab_oauth.go
+++ b/pkg/login/social/gitlab_oauth.go
@@ -22,7 +22,6 @@ const (
type SocialGitlab struct {
*SocialBase
- allowedGroups []string
apiUrl string
skipOrgRoleSync bool
}
@@ -48,22 +47,6 @@ type userData struct {
IsGrafanaAdmin *bool `json:"-"`
}
-func (s *SocialGitlab) isGroupMember(groups []string) bool {
- if len(s.allowedGroups) == 0 {
- return true
- }
-
- for _, allowedGroup := range s.allowedGroups {
- for _, group := range groups {
- if group == allowedGroup {
- return true
- }
- }
- }
-
- return false
-}
-
func (s *SocialGitlab) getGroups(ctx context.Context, client *http.Client) []string {
groups := make([]string, 0)
nextPage := new(int)
diff --git a/pkg/login/social/google_oauth.go b/pkg/login/social/google_oauth.go
index 21beaccf449..f4957eb4096 100644
--- a/pkg/login/social/google_oauth.go
+++ b/pkg/login/social/google_oauth.go
@@ -30,6 +30,7 @@ type googleUserData struct {
Email string `json:"email"`
Name string `json:"name"`
EmailVerified bool `json:"email_verified"`
+ rawJSON []byte `json:"-"`
}
func (s *SocialGoogle) UserInfo(ctx context.Context, client *http.Client, token *oauth2.Token) (*BasicUserInfo, error) {
@@ -59,6 +60,10 @@ func (s *SocialGoogle) UserInfo(ctx context.Context, client *http.Client, token
s.log.Warn("Error retrieving groups", "error", errPage)
}
+ if !s.isGroupMember(groups) {
+ return nil, errMissingGroupMembership
+ }
+
userInfo := &BasicUserInfo{
Id: data.ID,
Name: data.Name,
@@ -69,6 +74,19 @@ func (s *SocialGoogle) UserInfo(ctx context.Context, client *http.Client, token
Groups: groups,
}
+ if !s.skipOrgRoleSync {
+ role, grafanaAdmin, errRole := s.extractRoleAndAdmin(data.rawJSON, groups)
+ if errRole != nil {
+ return nil, errRole
+ }
+
+ if s.allowAssignGrafanaAdmin {
+ userInfo.IsGrafanaAdmin = &grafanaAdmin
+ }
+
+ userInfo.Role = role
+ }
+
s.log.Debug("Resolved user info", "data", fmt.Sprintf("%+v", userInfo))
return userInfo, nil
@@ -98,6 +116,7 @@ func (s *SocialGoogle) extractFromAPI(ctx context.Context, client *http.Client)
Name: data.Name,
Email: data.Email,
EmailVerified: data.EmailVerified,
+ rawJSON: response.Body,
}, nil
}
@@ -145,6 +164,8 @@ func (s *SocialGoogle) extractFromToken(ctx context.Context, client *http.Client
return nil, fmt.Errorf("Error getting user info: %s", err)
}
+ data.rawJSON = rawJSON
+
return &data, nil
}
diff --git a/pkg/login/social/google_oauth_test.go b/pkg/login/social/google_oauth_test.go
index 783125d0f45..96aa302d490 100644
--- a/pkg/login/social/google_oauth_test.go
+++ b/pkg/login/social/google_oauth_test.go
@@ -15,6 +15,7 @@ import (
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/models/roletype"
)
func TestSocialGoogle_retrieveGroups(t *testing.T) {
@@ -239,8 +240,13 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
tokenWithoutID := &oauth2.Token{}
type fields struct {
- Scopes []string
- apiURL string
+ Scopes []string
+ apiURL string
+ allowedGroups []string
+ roleAttributePath string
+ roleAttributeStrict bool
+ allowAssignGrafanaAdmin bool
+ skipOrgRoleSync bool
}
type args struct {
client *http.Client
@@ -257,7 +263,8 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
{
name: "Success id_token",
fields: fields{
- Scopes: []string{},
+ Scopes: []string{},
+ skipOrgRoleSync: true,
},
args: args{
token: tokenWithID,
@@ -273,7 +280,8 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
{
name: "Success id_token - groups requested",
fields: fields{
- Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
+ Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
+ skipOrgRoleSync: true,
},
args: args{
token: tokenWithID,
@@ -310,7 +318,8 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
{
name: "Legacy API URL",
fields: fields{
- apiURL: legacyAPIURL,
+ apiURL: legacyAPIURL,
+ skipOrgRoleSync: true,
},
args: args{
token: tokenWithoutID,
@@ -340,7 +349,8 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
{
name: "Legacy API URL - no id provided",
fields: fields{
- apiURL: legacyAPIURL,
+ apiURL: legacyAPIURL,
+ skipOrgRoleSync: true,
},
args: args{
token: tokenWithoutID,
@@ -426,7 +436,8 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
{
name: "Success",
fields: fields{
- apiURL: "https://openidconnect.googleapis.com/v1/userinfo",
+ apiURL: "https://openidconnect.googleapis.com/v1/userinfo",
+ skipOrgRoleSync: true,
},
args: args{
token: tokenWithoutID,
@@ -478,6 +489,145 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
wantErr: true,
wantErrMsg: "email is not verified",
},
+ {
+ name: "not in allowed Groups",
+ fields: fields{
+ Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
+ allowedGroups: []string{"not-that-one"},
+ },
+ args: args{
+ token: tokenWithID,
+ client: &http.Client{
+ Transport: &roundTripperFunc{
+ fn: func(req *http.Request) (*http.Response, error) {
+ resp := httptest.NewRecorder()
+ _, _ = resp.WriteString(`{
+ "memberships": [
+ {
+ "group": "test-group",
+ "groupKey": {
+ "id": "test-group@google.com"
+ },
+ "displayName": "Test Group"
+ }
+ ],
+ "nextPageToken": ""
+ }`)
+ return resp.Result(), nil
+ },
+ },
+ },
+ },
+ wantData: &BasicUserInfo{
+ Id: "88888888888888",
+ Login: "test@example.com",
+ Email: "test@example.com",
+ Name: "Test User",
+ Groups: []string{"test-group@google.com"},
+ },
+ wantErr: true,
+ wantErrMsg: "user not a member of one of the required groups",
+ },
+ {
+ name: "Role mapping - strict",
+ fields: fields{
+ Scopes: []string{},
+ allowedGroups: []string{},
+ roleAttributePath: "this",
+ roleAttributeStrict: true,
+ },
+ args: args{
+ token: tokenWithID,
+ },
+ wantData: &BasicUserInfo{
+ Id: "88888888888888",
+ Login: "test@example.com",
+ Email: "test@example.com",
+ Name: "Test User",
+ Groups: []string{"test-group@google.com"},
+ },
+ wantErr: true,
+ wantErrMsg: "idP did not return a role attribute, but role_attribute_strict is set",
+ },
+ {
+ name: "role mapping from id_token - no allowed assign Grafana Admin",
+ fields: fields{
+ Scopes: []string{},
+ allowAssignGrafanaAdmin: false,
+ roleAttributePath: "email_verified && 'GrafanaAdmin'",
+ },
+ args: args{
+ token: tokenWithID,
+ },
+ wantData: &BasicUserInfo{
+ Id: "88888888888888",
+ Login: "test@example.com",
+ Email: "test@example.com",
+ Name: "Test User",
+ Role: roletype.RoleAdmin,
+ IsGrafanaAdmin: nil,
+ },
+ wantErr: false,
+ },
+ {
+ name: "role mapping from id_token - allowed assign Grafana Admin",
+ fields: fields{
+ Scopes: []string{},
+ allowAssignGrafanaAdmin: true,
+ roleAttributePath: "email_verified && 'GrafanaAdmin'",
+ },
+ args: args{
+ token: tokenWithID,
+ },
+ wantData: &BasicUserInfo{
+ Id: "88888888888888",
+ Login: "test@example.com",
+ Email: "test@example.com",
+ Name: "Test User",
+ Role: roletype.RoleAdmin,
+ IsGrafanaAdmin: trueBoolPtr(),
+ },
+ wantErr: false,
+ },
+ {
+ name: "mapping from groups",
+ fields: fields{
+ Scopes: []string{"https://www.googleapis.com/auth/cloud-identity.groups.readonly"},
+ roleAttributePath: "contains(groups[*], 'test-group@google.com') && 'Editor'",
+ },
+ args: args{
+ token: tokenWithID,
+ client: &http.Client{
+ Transport: &roundTripperFunc{
+ fn: func(req *http.Request) (*http.Response, error) {
+ resp := httptest.NewRecorder()
+ _, _ = resp.WriteString(`{
+ "memberships": [
+ {
+ "group": "test-group",
+ "groupKey": {
+ "id": "test-group@google.com"
+ },
+ "displayName": "Test Group"
+ }
+ ],
+ "nextPageToken": ""
+ }`)
+ return resp.Result(), nil
+ },
+ },
+ },
+ },
+ wantData: &BasicUserInfo{
+ Id: "88888888888888",
+ Login: "test@example.com",
+ Email: "test@example.com",
+ Name: "Test User",
+ Role: "Editor",
+ Groups: []string{"test-group@google.com"},
+ },
+ wantErr: false,
+ },
}
for _, tt := range tests {
@@ -485,10 +635,15 @@ func TestSocialGoogle_UserInfo(t *testing.T) {
s := &SocialGoogle{
apiUrl: tt.fields.apiURL,
SocialBase: &SocialBase{
- Config: &oauth2.Config{Scopes: tt.fields.Scopes},
- log: log.NewNopLogger(),
- allowSignup: false,
+ Config: &oauth2.Config{Scopes: tt.fields.Scopes},
+ log: log.NewNopLogger(),
+ allowSignup: false,
+ allowedGroups: tt.fields.allowedGroups,
+ roleAttributePath: tt.fields.roleAttributePath,
+ roleAttributeStrict: tt.fields.roleAttributeStrict,
+ allowAssignGrafanaAdmin: tt.fields.allowAssignGrafanaAdmin,
},
+ skipOrgRoleSync: tt.fields.skipOrgRoleSync,
}
gotData, err := s.UserInfo(context.Background(), tt.args.client, tt.args.token)
diff --git a/pkg/login/social/social.go b/pkg/login/social/social.go
index 044e4f5a40a..2d81fd8a97c 100644
--- a/pkg/login/social/social.go
+++ b/pkg/login/social/social.go
@@ -63,6 +63,7 @@ type OAuthInfo struct {
TlsClientKey string `toml:"tls_client_key"`
TokenUrl string `toml:"token_url"`
AllowedDomains []string `toml:"allowed_domains"`
+ AllowedGroups []string `toml:"allowed_groups"`
Scopes []string `toml:"scopes"`
AllowAssignGrafanaAdmin bool `toml:"allow_assign_grafana_admin"`
AllowSignup bool `toml:"allow_signup"`
@@ -120,6 +121,7 @@ func ProvideService(cfg *setting.Cfg,
UseRefreshToken: sec.Key("use_refresh_token").MustBool(false),
AllowAssignGrafanaAdmin: sec.Key("allow_assign_grafana_admin").MustBool(false),
AutoLogin: sec.Key("auto_login").MustBool(false),
+ AllowedGroups: util.SplitString(sec.Key("allowed_groups").String()),
}
// when empty_scopes parameter exists and is true, overwrite scope with empty value
@@ -178,7 +180,6 @@ func ProvideService(cfg *setting.Cfg,
ss.socialMap["gitlab"] = &SocialGitlab{
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
apiUrl: info.ApiUrl,
- allowedGroups: util.SplitString(sec.Key("allowed_groups").String()),
skipOrgRoleSync: cfg.GitLabSkipOrgRoleSync,
}
}
@@ -202,7 +203,6 @@ func ProvideService(cfg *setting.Cfg,
SocialBase: newSocialBase(name, &config, info, cfg.AutoAssignOrgRole, cfg.OAuthSkipOrgRoleUpdateSync, *features),
cache: cache,
allowedOrganizations: util.SplitString(sec.Key("allowed_organizations").String()),
- allowedGroups: util.SplitString(sec.Key("allowed_groups").String()),
forceUseGraphAPI: sec.Key("force_use_graph_api").MustBool(false),
skipOrgRoleSync: cfg.AzureADSkipOrgRoleSync,
}
@@ -305,6 +305,7 @@ type SocialBase struct {
allowSignup bool
allowAssignGrafanaAdmin bool
allowedDomains []string
+ allowedGroups []string
roleAttributePath string
roleAttributeStrict bool
@@ -356,9 +357,10 @@ func newSocialBase(name string,
allowSignup: info.AllowSignup,
allowAssignGrafanaAdmin: info.AllowAssignGrafanaAdmin,
allowedDomains: info.AllowedDomains,
- autoAssignOrgRole: autoAssignOrgRole,
+ allowedGroups: info.AllowedGroups,
roleAttributePath: info.RoleAttributePath,
roleAttributeStrict: info.RoleAttributeStrict,
+ autoAssignOrgRole: autoAssignOrgRole,
skipOrgRoleSync: skipOrgRoleSync,
features: features,
useRefreshToken: info.UseRefreshToken,
@@ -571,6 +573,22 @@ func (ss *SocialService) getUsageStats(ctx context.Context) (map[string]interfac
return m, nil
}
+func (s *SocialBase) isGroupMember(groups []string) bool {
+ if len(s.allowedGroups) == 0 {
+ return true
+ }
+
+ for _, allowedGroup := range s.allowedGroups {
+ for _, group := range groups {
+ if group == allowedGroup {
+ return true
+ }
+ }
+ }
+
+ return false
+}
+
func (s *SocialBase) retrieveRawIDToken(idToken interface{}) ([]byte, error) {
tokenString, ok := idToken.(string)
if !ok {
diff --git a/pkg/middleware/auth.go b/pkg/middleware/auth.go
index fccbcb409fe..d0bbe913713 100644
--- a/pkg/middleware/auth.go
+++ b/pkg/middleware/auth.go
@@ -9,6 +9,7 @@ import (
"strings"
"github.com/grafana/grafana/pkg/middleware/cookies"
+ ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/authn"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
@@ -86,9 +87,10 @@ func removeForceLoginParams(str string) string {
return forceLoginParamsRegexp.ReplaceAllString(str, "")
}
-func CanAdminPlugins(cfg *setting.Cfg) func(c *contextmodel.ReqContext) {
+func CanAdminPlugins(cfg *setting.Cfg, accessControl ac.AccessControl) func(c *contextmodel.ReqContext) {
return func(c *contextmodel.ReqContext) {
- if !pluginaccesscontrol.ReqCanAdminPlugins(cfg)(c) {
+ hasAccess := ac.HasAccess(accessControl, c)
+ if !pluginaccesscontrol.ReqCanAdminPlugins(cfg)(c) && !hasAccess(pluginaccesscontrol.AdminAccessEvaluator) {
accessForbidden(c)
return
}
diff --git a/pkg/plugins/log/fake.go b/pkg/plugins/log/fake.go
index 41af06d21e8..2a4f0163ac2 100644
--- a/pkg/plugins/log/fake.go
+++ b/pkg/plugins/log/fake.go
@@ -1,5 +1,7 @@
package log
+import "context"
+
var _ Logger = (*TestLogger)(nil)
type TestLogger struct {
@@ -41,6 +43,10 @@ func (f *TestLogger) Error(msg string, ctx ...any) {
f.ErrorLogs.Ctx = ctx
}
+func (f *TestLogger) FromContext(_ context.Context) Logger {
+ return NewTestLogger()
+}
+
type Logs struct {
Calls int
Message string
diff --git a/pkg/plugins/log/ifaces.go b/pkg/plugins/log/ifaces.go
index 2d3033194d3..14e603b97e4 100644
--- a/pkg/plugins/log/ifaces.go
+++ b/pkg/plugins/log/ifaces.go
@@ -1,35 +1,40 @@
package log
+import "context"
+
// Logger is the default logger
type Logger interface {
// New returns a new contextual Logger that has this logger's context plus the given context.
- New(ctx ...interface{}) Logger
+ New(ctx ...any) Logger
// Debug logs a message with debug level and key/value pairs, if any.
- Debug(msg string, ctx ...interface{})
+ Debug(msg string, ctx ...any)
// Info logs a message with info level and key/value pairs, if any.
- Info(msg string, ctx ...interface{})
+ Info(msg string, ctx ...any)
// Warn logs a message with warning level and key/value pairs, if any.
- Warn(msg string, ctx ...interface{})
+ Warn(msg string, ctx ...any)
// Error logs a message with error level and key/value pairs, if any.
- Error(msg string, ctx ...interface{})
+ Error(msg string, ctx ...any)
+
+ // FromContext returns a new contextual Logger that has this logger's context plus the given context.
+ FromContext(ctx context.Context) Logger
}
// PrettyLogger is used primarily to facilitate logging/user feedback for both
// the grafana-cli and the grafana backend when managing plugin installs
type PrettyLogger interface {
- Successf(format string, args ...interface{})
- Failuref(format string, args ...interface{})
+ Successf(format string, args ...any)
+ Failuref(format string, args ...any)
- Info(args ...interface{})
- Infof(format string, args ...interface{})
- Debug(args ...interface{})
- Debugf(format string, args ...interface{})
- Warn(args ...interface{})
- Warnf(format string, args ...interface{})
- Error(args ...interface{})
- Errorf(format string, args ...interface{})
+ Info(args ...any)
+ Infof(format string, args ...any)
+ Debug(args ...any)
+ Debugf(format string, args ...any)
+ Warn(args ...any)
+ Warnf(format string, args ...any)
+ Error(args ...any)
+ Errorf(format string, args ...any)
}
diff --git a/pkg/plugins/log/logger.go b/pkg/plugins/log/logger.go
index d0fbd855303..58ac225cf64 100644
--- a/pkg/plugins/log/logger.go
+++ b/pkg/plugins/log/logger.go
@@ -1,6 +1,8 @@
package log
import (
+ "context"
+
"github.com/grafana/grafana/pkg/infra/log"
)
@@ -42,3 +44,13 @@ func (d *grafanaInfraLogWrapper) Warn(msg string, ctx ...any) {
func (d *grafanaInfraLogWrapper) Error(msg string, ctx ...any) {
d.l.Error(msg, ctx...)
}
+
+func (d *grafanaInfraLogWrapper) FromContext(ctx context.Context) Logger {
+ concreteInfraLogger, ok := d.l.FromContext(ctx).(*log.ConcreteLogger)
+ if !ok {
+ return d.New()
+ }
+ return &grafanaInfraLogWrapper{
+ l: concreteInfraLogger,
+ }
+}
diff --git a/pkg/plugins/manager/pipeline/bootstrap/steps.go b/pkg/plugins/manager/pipeline/bootstrap/steps.go
index f2d61e58e91..9cfb92ea3cd 100644
--- a/pkg/plugins/manager/pipeline/bootstrap/steps.go
+++ b/pkg/plugins/manager/pipeline/bootstrap/steps.go
@@ -30,6 +30,7 @@ func DefaultConstructFunc(signatureCalculator plugins.SignatureCalculator, asset
func DefaultDecorateFuncs(cfg *config.Cfg) []DecorateFunc {
return []DecorateFunc{
AppDefaultNavURLDecorateFunc,
+ TemplateDecorateFunc,
AppChildDecorateFunc(cfg),
}
}
@@ -86,6 +87,22 @@ func AppDefaultNavURLDecorateFunc(_ context.Context, p *plugins.Plugin) (*plugin
return p, nil
}
+// TemplateDecorateFunc is a DecorateFunc that removes the placeholder for the version and last_update fields.
+func TemplateDecorateFunc(_ context.Context, p *plugins.Plugin) (*plugins.Plugin, error) {
+ // %VERSION% and %TODAY% are valid values, according to the plugin schema
+ // but it's meant to be replaced by the build system with the actual version and date.
+ // If not, it's the same than not having a version or a date.
+ if p.Info.Version == "%VERSION%" {
+ p.Info.Version = ""
+ }
+
+ if p.Info.Updated == "%TODAY%" {
+ p.Info.Updated = ""
+ }
+
+ return p, nil
+}
+
func setDefaultNavURL(p *plugins.Plugin) {
// slugify pages
for _, include := range p.Includes {
diff --git a/pkg/plugins/manager/pipeline/bootstrap/steps_test.go b/pkg/plugins/manager/pipeline/bootstrap/steps_test.go
index 670a667a707..795d7f8a5a9 100644
--- a/pkg/plugins/manager/pipeline/bootstrap/steps_test.go
+++ b/pkg/plugins/manager/pipeline/bootstrap/steps_test.go
@@ -1,6 +1,7 @@
package bootstrap
import (
+ "context"
"testing"
"github.com/stretchr/testify/require"
@@ -66,6 +67,34 @@ func TestSetDefaultNavURL(t *testing.T) {
})
}
+func TestTemplateDecorateFunc(t *testing.T) {
+ t.Run("Removes %VERSION%", func(t *testing.T) {
+ pluginWithoutVersion := &plugins.Plugin{
+ JSONData: plugins.JSONData{
+ Info: plugins.Info{
+ Version: "%VERSION%",
+ },
+ },
+ }
+ p, err := TemplateDecorateFunc(context.TODO(), pluginWithoutVersion)
+ require.NoError(t, err)
+ require.Equal(t, "", p.Info.Version)
+ })
+
+ t.Run("Removes %TODAY%", func(t *testing.T) {
+ pluginWithoutVersion := &plugins.Plugin{
+ JSONData: plugins.JSONData{
+ Info: plugins.Info{
+ Version: "%TODAY%",
+ },
+ },
+ }
+ p, err := TemplateDecorateFunc(context.TODO(), pluginWithoutVersion)
+ require.NoError(t, err)
+ require.Equal(t, "", p.Info.Updated)
+ })
+}
+
func Test_configureAppChildPlugin(t *testing.T) {
t.Run("When setting paths based on core plugin on Windows", func(t *testing.T) {
child := &plugins.Plugin{
diff --git a/pkg/registry/backgroundsvcs/background_services.go b/pkg/registry/backgroundsvcs/background_services.go
index 19f8cfc0d26..8e316697aa4 100644
--- a/pkg/registry/backgroundsvcs/background_services.go
+++ b/pkg/registry/backgroundsvcs/background_services.go
@@ -40,6 +40,7 @@ import (
"github.com/grafana/grafana/pkg/services/store/entity"
"github.com/grafana/grafana/pkg/services/store/sanitizer"
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlesimpl"
+ "github.com/grafana/grafana/pkg/services/team/teamapi"
"github.com/grafana/grafana/pkg/services/updatechecker"
)
@@ -62,7 +63,7 @@ func ProvideBackgroundServiceRegistry(
_ serviceaccounts.Service, _ *guardian.Provider,
_ *plugindashboardsservice.DashboardUpdater, _ *sanitizer.Provider,
_ *grpcserver.HealthService, _ entity.EntityStoreServer, _ *grpcserver.ReflectionService, _ *ldapapi.Service,
- _ *apiregistry.Service, _ auth.IDService,
+ _ *apiregistry.Service, _ auth.IDService, _ *teamapi.TeamAPI,
) *BackgroundServiceRegistry {
return NewBackgroundServiceRegistry(
httpServer,
diff --git a/pkg/server/wire.go b/pkg/server/wire.go
index 44755d416b4..a6f4567bc65 100644
--- a/pkg/server/wire.go
+++ b/pkg/server/wire.go
@@ -141,6 +141,7 @@ import (
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlesimpl"
"github.com/grafana/grafana/pkg/services/tag"
"github.com/grafana/grafana/pkg/services/tag/tagimpl"
+ "github.com/grafana/grafana/pkg/services/team/teamapi"
"github.com/grafana/grafana/pkg/services/team/teamimpl"
tempuser "github.com/grafana/grafana/pkg/services/temp_user"
"github.com/grafana/grafana/pkg/services/temp_user/tempuserimpl"
@@ -342,6 +343,7 @@ var wireBasicSet = wire.NewSet(
resolver.ProvideEntityReferenceResolver,
httpentitystore.ProvideHTTPEntityStore,
teamimpl.ProvideService,
+ teamapi.ProvideTeamAPI,
tempuserimpl.ProvideService,
loginattemptimpl.ProvideService,
wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)),
diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go
index 87a95f89a89..a5b0a6ed08b 100644
--- a/pkg/services/accesscontrol/models.go
+++ b/pkg/services/accesscontrol/models.go
@@ -321,6 +321,7 @@ func (cmd *SaveExternalServiceRoleCommand) Validate() error {
const (
GlobalOrgID = 0
FixedRolePrefix = "fixed:"
+ FixedRoleUIDPrefix = "fixed_"
ManagedRolePrefix = "managed:"
BasicRolePrefix = "basic:"
PluginRolePrefix = "plugins:"
@@ -467,6 +468,12 @@ const (
// Feature Management actions
ActionFeatureManagementRead = "featuremgmt.read"
ActionFeatureManagementWrite = "featuremgmt.write"
+
+ // Library Panel actions
+ ActionLibraryPanelsCreate = "library.panels:create"
+ ActionLibraryPanelsRead = "library.panels:read"
+ ActionLibraryPanelsWrite = "library.panels:write"
+ ActionLibraryPanelsDelete = "library.panels:delete"
)
var (
diff --git a/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go b/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go
index 3b469539b71..35c4b9cd657 100644
--- a/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go
+++ b/pkg/services/accesscontrol/ossaccesscontrol/permissions_services.go
@@ -14,6 +14,7 @@ import (
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
+ "github.com/grafana/grafana/pkg/services/libraryelements"
"github.com/grafana/grafana/pkg/services/licensing"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/serviceaccounts/retriever"
@@ -191,7 +192,7 @@ type FolderPermissionsService struct {
*resourcepermissions.Service
}
-var FolderViewActions = []string{dashboards.ActionFoldersRead, accesscontrol.ActionAlertingRuleRead}
+var FolderViewActions = []string{dashboards.ActionFoldersRead, accesscontrol.ActionAlertingRuleRead, libraryelements.ActionLibraryPanelsRead}
var FolderEditActions = append(FolderViewActions, []string{
dashboards.ActionFoldersWrite,
dashboards.ActionFoldersDelete,
@@ -199,6 +200,9 @@ var FolderEditActions = append(FolderViewActions, []string{
accesscontrol.ActionAlertingRuleCreate,
accesscontrol.ActionAlertingRuleUpdate,
accesscontrol.ActionAlertingRuleDelete,
+ libraryelements.ActionLibraryPanelsCreate,
+ libraryelements.ActionLibraryPanelsWrite,
+ libraryelements.ActionLibraryPanelsDelete,
}...)
var FolderAdminActions = append(FolderEditActions, []string{dashboards.ActionFoldersPermissionsRead, dashboards.ActionFoldersPermissionsWrite}...)
diff --git a/pkg/services/accesscontrol/roles.go b/pkg/services/accesscontrol/roles.go
index 198b4583790..6f6a8822dde 100644
--- a/pkg/services/accesscontrol/roles.go
+++ b/pkg/services/accesscontrol/roles.go
@@ -1,6 +1,9 @@
package accesscontrol
import (
+ // #nosec G505 Used only for generating a 160 bit hash, it's not used for security purposes
+ "crypto/sha1"
+ "encoding/base64"
"fmt"
"strings"
"sync"
@@ -253,6 +256,15 @@ func ConcatPermissions(permissions ...[]Permission) []Permission {
return perms
}
+// FixedRoleUID generates a UID of 34 bytes: "fixed_" + base64(sha1(roleName))
+func FixedRoleUID(roleName string) string {
+ // #nosec G505 Used only for generating a 160 bit hash, it's not used for security purposes
+ hasher := sha1.New()
+ hasher.Write([]byte(roleName))
+
+ return fmt.Sprintf("%s%s", FixedRoleUIDPrefix, base64.RawURLEncoding.EncodeToString(hasher.Sum(nil)))
+}
+
// ValidateFixedRole errors when a fixed role does not match expected pattern
func ValidateFixedRole(role RoleDTO) error {
if !strings.HasPrefix(role.Name, FixedRolePrefix) {
diff --git a/pkg/services/auth/idimpl/service.go b/pkg/services/auth/idimpl/service.go
index 19a33033a81..08b65b4df41 100644
--- a/pkg/services/auth/idimpl/service.go
+++ b/pkg/services/auth/idimpl/service.go
@@ -3,7 +3,6 @@ package idimpl
import (
"context"
"fmt"
- "strconv"
"time"
"github.com/go-jose/go-jose/v3/jwt"
@@ -67,10 +66,9 @@ func (s *Service) SignIdentity(ctx context.Context, id identity.Requester) (stri
now := time.Now()
token, err := s.signer.SignIDToken(ctx, &auth.IDClaims{
Claims: jwt.Claims{
- ID: identifier,
Issuer: s.cfg.AppURL,
- Audience: jwt.Audience{strconv.FormatInt(id.GetOrgID(), 10)},
- Subject: fmt.Sprintf("%s:%s", namespace, identifier),
+ Audience: getAudience(id.GetOrgID()),
+ Subject: getSubject(namespace, identifier),
Expiry: jwt.NewNumericDate(now.Add(tokenTTL)),
IssuedAt: jwt.NewNumericDate(now),
},
@@ -102,6 +100,14 @@ func (s *Service) hook(ctx context.Context, identity *authn.Identity, _ *authn.R
return nil
}
+func getAudience(orgID int64) jwt.Audience {
+ return jwt.Audience{fmt.Sprintf("org:%d", orgID)}
+}
+
+func getSubject(namespace, identifier string) string {
+ return fmt.Sprintf("%s:%s", namespace, identifier)
+}
+
func prefixCacheKey(key string) string {
return fmt.Sprintf("%s-%s", cachePrefix, key)
}
diff --git a/pkg/services/auth/idimpl/signer.go b/pkg/services/auth/idimpl/signer.go
index 15300c0979e..4cd8a090503 100644
--- a/pkg/services/auth/idimpl/signer.go
+++ b/pkg/services/auth/idimpl/signer.go
@@ -11,40 +11,20 @@ import (
"github.com/grafana/grafana/pkg/services/signingkeys"
)
-const idSignerKeyPrefix = "id"
+const (
+ keyPrefix = "id"
+ headerKeyID = "kid"
+)
var _ auth.IDSigner = (*LocalSigner)(nil)
func ProvideLocalSigner(keyService signingkeys.Service, features featuremgmt.FeatureToggles) (*LocalSigner, error) {
- if features.IsEnabled(featuremgmt.FlagIdForwarding) {
- id, key, err := keyService.GetOrCreatePrivateKey(context.Background(), idSignerKeyPrefix, jose.ES256)
- if err != nil {
- return nil, err
- }
-
- // FIXME: Handle key rotation
- signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: key}, &jose.SignerOptions{
- ExtraHeaders: map[jose.HeaderKey]interface{}{
- "kid": id,
- },
- })
-
- if err != nil {
- return nil, err
- }
-
- return &LocalSigner{
- features: features,
- signer: signer,
- }, nil
- }
-
- return &LocalSigner{features: features}, nil
+ return &LocalSigner{features, keyService}, nil
}
type LocalSigner struct {
- signer jose.Signer
- features featuremgmt.FeatureToggles
+ features featuremgmt.FeatureToggles
+ keyService signingkeys.Service
}
func (s *LocalSigner) SignIDToken(ctx context.Context, claims *auth.IDClaims) (string, error) {
@@ -52,7 +32,12 @@ func (s *LocalSigner) SignIDToken(ctx context.Context, claims *auth.IDClaims) (s
return "", nil
}
- builder := jwt.Signed(s.signer).Claims(claims.Claims)
+ signer, err := s.getSigner(ctx)
+ if err != nil {
+ return "", err
+ }
+
+ builder := jwt.Signed(signer).Claims(claims.Claims)
token, err := builder.CompactSerialize()
if err != nil {
@@ -61,3 +46,20 @@ func (s *LocalSigner) SignIDToken(ctx context.Context, claims *auth.IDClaims) (s
return token, nil
}
+
+func (s *LocalSigner) getSigner(ctx context.Context) (jose.Signer, error) {
+ id, key, err := s.keyService.GetOrCreatePrivateKey(ctx, keyPrefix, jose.ES256)
+ if err != nil {
+ return nil, err
+ }
+
+ signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.ES256, Key: key}, &jose.SignerOptions{
+ ExtraHeaders: map[jose.HeaderKey]any{headerKeyID: id},
+ })
+
+ if err != nil {
+ return nil, err
+ }
+
+ return signer, nil
+}
diff --git a/pkg/services/authn/authnimpl/service.go b/pkg/services/authn/authnimpl/service.go
index 2a2e165eac9..4c50c97e9ff 100644
--- a/pkg/services/authn/authnimpl/service.go
+++ b/pkg/services/authn/authnimpl/service.go
@@ -226,17 +226,12 @@ func (s *Service) authenticate(ctx context.Context, c authn.Client, r *authn.Req
r.OrgID = orgIDFromRequest(r)
identity, err := c.Authenticate(ctx, r)
if err != nil {
- log := s.log.FromContext(ctx).Warn
- if errors.Is(err, authn.ErrTokenNeedsRotation) {
- log = s.log.FromContext(ctx).Debug
- }
-
- log("Failed to authenticate request", "client", c.Name(), "error", err)
+ s.errorLogFunc(ctx, err)("Failed to authenticate request", "client", c.Name(), "error", err)
return nil, err
}
if err := s.runPostAuthHooks(ctx, identity, r); err != nil {
- s.log.FromContext(ctx).Warn("Failed to run post auth hook", "client", c.Name(), "id", identity.ID, "error", err)
+ s.errorLogFunc(ctx, err)("Failed to run post auth hook", "client", c.Name(), "id", identity.ID, "error", err)
return nil, err
}
@@ -246,7 +241,7 @@ func (s *Service) authenticate(ctx context.Context, c authn.Client, r *authn.Req
if hc, ok := c.(authn.HookClient); ok {
if err := hc.Hook(ctx, identity, r); err != nil {
- s.log.FromContext(ctx).Warn("Failed to run post client auth hook", "client", c.Name(), "id", identity.ID, "error", err)
+ s.errorLogFunc(ctx, err)("Failed to run post client auth hook", "client", c.Name(), "id", identity.ID, "error", err)
return nil, err
}
}
@@ -355,6 +350,17 @@ func (s *Service) SyncIdentity(ctx context.Context, identity *authn.Identity) er
return s.runPostAuthHooks(ctx, identity, r)
}
+func (s *Service) errorLogFunc(ctx context.Context, err error) func(msg string, ctx ...any) {
+ l := s.log.FromContext(ctx)
+
+ var grfErr errutil.Error
+ if errors.As(err, &grfErr) {
+ return grfErr.LogLevel.LogFunc(l)
+ }
+
+ return l.Warn
+}
+
func orgIDFromRequest(r *authn.Request) int64 {
if r.HTTPRequest == nil {
return 0
diff --git a/pkg/services/authn/authnimpl/sync/user_sync.go b/pkg/services/authn/authnimpl/sync/user_sync.go
index 97bc58482a8..0b0a5b09b91 100644
--- a/pkg/services/authn/authnimpl/sync/user_sync.go
+++ b/pkg/services/authn/authnimpl/sync/user_sync.go
@@ -91,7 +91,7 @@ func (s *UserSync) SyncUserHook(ctx context.Context, id *authn.Identity, _ *auth
usr, errCreate = s.createUser(ctx, id)
if errCreate != nil {
s.log.FromContext(ctx).Error("Failed to create user", "error", errCreate, "auth_module", id.AuthenticatedBy, "auth_id", id.AuthID)
- return errSyncUserInternal.Errorf("unable to create user")
+ return errSyncUserInternal.Errorf("unable to create user: %w", errCreate)
}
} else {
// update user
diff --git a/pkg/services/authn/clients/ext_jwt_test.go b/pkg/services/authn/clients/ext_jwt_test.go
index c3a6a82ab0c..7f2f3e91f7d 100644
--- a/pkg/services/authn/clients/ext_jwt_test.go
+++ b/pkg/services/authn/clients/ext_jwt_test.go
@@ -2,7 +2,6 @@ package clients
import (
"context"
- "crypto"
"crypto/rand"
"crypto/rsa"
"fmt"
@@ -516,8 +515,9 @@ func setupTestCtx(t *testing.T, cfg *setting.Cfg) *testEnv {
}
}
- signingKeysSvc := &signingkeystest.FakeSigningKeysService{ExpectedKeys: map[string]crypto.Signer{
- signingkeys.ServerPrivateKeyID: pk},
+ signingKeysSvc := &signingkeystest.FakeSigningKeysService{
+ ExpectedSinger: pk,
+ ExpectedKeyID: signingkeys.ServerPrivateKeyID,
}
userSvc := &usertest.FakeUserService{}
diff --git a/pkg/services/authn/error.go b/pkg/services/authn/error.go
index 33fbdba1320..053ceeacfab 100644
--- a/pkg/services/authn/error.go
+++ b/pkg/services/authn/error.go
@@ -3,7 +3,7 @@ package authn
import "github.com/grafana/grafana/pkg/util/errutil"
var (
- ErrTokenNeedsRotation = errutil.Unauthorized("session.token.rotate")
+ ErrTokenNeedsRotation = errutil.Unauthorized("session.token.rotate", errutil.WithLogLevel(errutil.LevelDebug))
ErrUnsupportedClient = errutil.BadRequest("auth.client.unsupported")
ErrClientNotConfigured = errutil.BadRequest("auth.client.notConfigured")
ErrUnsupportedIdentity = errutil.NotImplemented("auth.identity.unsupported")
diff --git a/pkg/services/dashboards/models_test.go b/pkg/services/dashboards/models_test.go
index 1e34a927ddd..dcbc4102956 100644
--- a/pkg/services/dashboards/models_test.go
+++ b/pkg/services/dashboards/models_test.go
@@ -123,8 +123,8 @@ func TestResourceConversion(t *testing.T) {
"annotations": {
"grafana.com/createdBy": "user:10",
"grafana.com/folder": "folder:1234",
- "grafana.com/origin/key": "plugin-xyz",
- "grafana.com/origin/name": "plugin",
+ "grafana.com/originKey": "plugin-xyz",
+ "grafana.com/originName": "plugin",
"grafana.com/slug": "test-dash",
"grafana.com/updatedBy": "user:11",
"grafana.com/updatedTimestamp": "2010-01-01T08:00:00Z"
diff --git a/pkg/services/extsvcauth/oauthserver/oasimpl/service_test.go b/pkg/services/extsvcauth/oauthserver/oasimpl/service_test.go
index 1188a6f800d..c97c5cc2c78 100644
--- a/pkg/services/extsvcauth/oauthserver/oasimpl/service_test.go
+++ b/pkg/services/extsvcauth/oauthserver/oasimpl/service_test.go
@@ -2,7 +2,6 @@ package oasimpl
import (
"context"
- "crypto"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
@@ -93,10 +92,9 @@ func setupTestEnv(t *testing.T) *TestEnv {
}
env.S.oauthProvider = newProvider(config, env.S, &signingkeystest.FakeSigningKeysService{
- ExpectedKeys: map[string]crypto.Signer{
- "default": pk,
- },
- ExpectedError: nil,
+ ExpectedSinger: pk,
+ ExpectedKeyID: "default",
+ ExpectedError: nil,
})
return env
diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go
index 37fa797b436..5810fdb840a 100644
--- a/pkg/services/featuremgmt/registry.go
+++ b/pkg/services/featuremgmt/registry.go
@@ -164,7 +164,7 @@ var (
{
Name: "dockedMegaMenu",
Description: "Enable support for a persistent (docked) navigation menu",
- Stage: FeatureStagePublicPreview,
+ Stage: FeatureStageExperimental,
FrontendOnly: true,
Owner: grafanaFrontendPlatformSquad,
},
@@ -331,14 +331,6 @@ var (
FrontendOnly: true,
Owner: appO11ySquad,
},
- {
- Name: "prometheusResourceBrowserCache",
- Description: "Displays browser caching options in Prometheus data source configuration",
- Stage: FeatureStageGeneralAvailability,
- FrontendOnly: true,
- Expression: "true", // turned on by default
- Owner: grafanaObservabilityMetricsSquad,
- },
{
Name: "influxdbBackendMigration",
Description: "Query InfluxDB InfluxQL without the proxy",
@@ -695,14 +687,6 @@ var (
Owner: grafanaObservabilityMetricsSquad,
RequiresRestart: false,
},
- {
- Name: "noBasicRole",
- Description: "Enables a new role that has no permissions by default",
- Stage: FeatureStageExperimental,
- FrontendOnly: true,
- Owner: grafanaAuthnzSquad,
- RequiresRestart: true,
- },
{
Name: "alertingNoDataErrorExecution",
Description: "Changes how Alerting state manager handles execution of NoData/Error",
@@ -754,6 +738,14 @@ var (
FrontendOnly: false,
Owner: grafanaPluginsPlatformSquad,
},
+ {
+ Name: "libraryPanelRBAC",
+ Description: "Enables RBAC support for library panels",
+ Stage: FeatureStageExperimental,
+ FrontendOnly: false,
+ Owner: grafanaDashboardsSquad,
+ RequiresRestart: true,
+ },
{
Name: "lokiRunQueriesInParallel",
Description: "Enables running Loki queries in parallel",
@@ -772,8 +764,9 @@ var (
Name: "alertingInsights",
Description: "Show the new alerting insights landing page",
FrontendOnly: true,
- Stage: FeatureStageExperimental,
+ Stage: FeatureStageGeneralAvailability,
Owner: grafanaAlertingSquad,
+ Expression: "true", // enabled by default
},
{
Name: "externalCorePlugins",
@@ -859,5 +852,20 @@ var (
FrontendOnly: false,
Owner: grafanaFrontendPlatformSquad,
},
+ {
+ Name: "recoveryThreshold",
+ Description: "Enables feature recovery threshold (aka hysteresis) for threshold server-side expression",
+ Stage: FeatureStageExperimental,
+ FrontendOnly: false,
+ Owner: grafanaAlertingSquad,
+ RequiresRestart: true,
+ },
+ {
+ Name: "awsDatasourcesNewFormStyling",
+ Description: "Applies new form styling for configuration and query editors in AWS plugins",
+ Stage: FeatureStageExperimental,
+ FrontendOnly: true,
+ Owner: awsDatasourcesSquad,
+ },
}
)
diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv
index 0a3882cb846..3c928a69c99 100644
--- a/pkg/services/featuremgmt/toggles_gen.csv
+++ b/pkg/services/featuremgmt/toggles_gen.csv
@@ -22,7 +22,7 @@ disableSecretsCompatibility,experimental,@grafana/hosted-grafana-team,false,fals
logRequestsInstrumentedAsUnknown,experimental,@grafana/hosted-grafana-team,false,false,false,false
dataConnectionsConsole,GA,@grafana/plugins-platform-backend,false,false,false,false
topnav,deprecated,@grafana/grafana-frontend-platform,false,false,false,false
-dockedMegaMenu,preview,@grafana/grafana-frontend-platform,false,false,false,true
+dockedMegaMenu,experimental,@grafana/grafana-frontend-platform,false,false,false,true
grpcServer,preview,@grafana/grafana-app-platform-squad,false,false,false,false
entityStore,experimental,@grafana/grafana-app-platform-squad,true,false,false,false
cloudWatchCrossAccountQuerying,GA,@grafana/aws-datasources,false,false,false,false
@@ -47,7 +47,6 @@ individualCookiePreferences,experimental,@grafana/backend-platform,false,false,f
gcomOnlyExternalOrgRoleSync,GA,@grafana/grafana-authnz-team,false,false,false,false
prometheusMetricEncyclopedia,GA,@grafana/observability-metrics,false,false,false,true
timeSeriesTable,experimental,@grafana/app-o11y,false,false,false,true
-prometheusResourceBrowserCache,GA,@grafana/observability-metrics,false,false,false,true
influxdbBackendMigration,preview,@grafana/observability-metrics,false,false,false,true
clientTokenRotation,experimental,@grafana/grafana-authnz-team,false,false,false,false
prometheusDataplane,GA,@grafana/observability-metrics,false,false,false,false
@@ -99,7 +98,6 @@ permissionsFilterRemoveSubquery,experimental,@grafana/backend-platform,false,fal
prometheusConfigOverhaulAuth,GA,@grafana/observability-metrics,false,false,false,false
configurableSchedulerTick,experimental,@grafana/alerting-squad,false,false,true,false
influxdbSqlSupport,experimental,@grafana/observability-metrics,false,false,false,false
-noBasicRole,experimental,@grafana/grafana-authnz-team,false,false,true,true
alertingNoDataErrorExecution,privatePreview,@grafana/alerting-squad,false,false,true,false
angularDeprecationUI,experimental,@grafana/plugins-platform-backend,false,false,false,true
dashgpt,experimental,@grafana/dashboards-squad,false,false,false,true
@@ -107,9 +105,10 @@ reportingRetries,preview,@grafana/sharing-squad,false,false,true,false
newBrowseDashboards,GA,@grafana/grafana-frontend-platform,false,false,false,true
sseGroupByDatasource,experimental,@grafana/observability-metrics,false,false,false,false
requestInstrumentationStatusSource,experimental,@grafana/plugins-platform-backend,false,false,false,false
+libraryPanelRBAC,experimental,@grafana/dashboards-squad,false,false,true,false
lokiRunQueriesInParallel,privatePreview,@grafana/observability-logs,false,false,false,false
wargamesTesting,experimental,@grafana/hosted-grafana-team,false,false,false,false
-alertingInsights,experimental,@grafana/alerting-squad,false,false,false,true
+alertingInsights,GA,@grafana/alerting-squad,false,false,false,true
externalCorePlugins,experimental,@grafana/plugins-platform-backend,false,false,false,false
pluginsAPIMetrics,experimental,@grafana/plugins-platform-backend,false,false,false,true
httpSLOLevels,experimental,@grafana/hosted-grafana-team,false,false,true,false
@@ -122,3 +121,5 @@ enableNativeHTTPHistogram,experimental,@grafana/hosted-grafana-team,false,false,
transformationsVariableSupport,experimental,@grafana/grafana-bi-squad,false,false,false,true
kubernetesPlaylists,experimental,@grafana/grafana-app-platform-squad,false,false,false,true
navAdminSubsections,experimental,@grafana/grafana-frontend-platform,false,false,false,false
+recoveryThreshold,experimental,@grafana/alerting-squad,false,false,true,false
+awsDatasourcesNewFormStyling,experimental,@grafana/aws-datasources,false,false,false,true
diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go
index 1c793fde7bc..3240b125833 100644
--- a/pkg/services/featuremgmt/toggles_gen.go
+++ b/pkg/services/featuremgmt/toggles_gen.go
@@ -199,10 +199,6 @@ const (
// Enable time series table transformer & sparkline cell type
FlagTimeSeriesTable = "timeSeriesTable"
- // FlagPrometheusResourceBrowserCache
- // Displays browser caching options in Prometheus data source configuration
- FlagPrometheusResourceBrowserCache = "prometheusResourceBrowserCache"
-
// FlagInfluxdbBackendMigration
// Query InfluxDB InfluxQL without the proxy
FlagInfluxdbBackendMigration = "influxdbBackendMigration"
@@ -407,10 +403,6 @@ const (
// Enable InfluxDB SQL query language support with new querying UI
FlagInfluxdbSqlSupport = "influxdbSqlSupport"
- // FlagNoBasicRole
- // Enables a new role that has no permissions by default
- FlagNoBasicRole = "noBasicRole"
-
// FlagAlertingNoDataErrorExecution
// Changes how Alerting state manager handles execution of NoData/Error
FlagAlertingNoDataErrorExecution = "alertingNoDataErrorExecution"
@@ -439,6 +431,10 @@ const (
// Include a status source label for request metrics and logs
FlagRequestInstrumentationStatusSource = "requestInstrumentationStatusSource"
+ // FlagLibraryPanelRBAC
+ // Enables RBAC support for library panels
+ FlagLibraryPanelRBAC = "libraryPanelRBAC"
+
// FlagLokiRunQueriesInParallel
// Enables running Loki queries in parallel
FlagLokiRunQueriesInParallel = "lokiRunQueriesInParallel"
@@ -498,4 +494,12 @@ const (
// FlagNavAdminSubsections
// Splits the administration section of the nav tree into subsections
FlagNavAdminSubsections = "navAdminSubsections"
+
+ // FlagRecoveryThreshold
+ // Enables feature recovery threshold (aka hysteresis) for threshold server-side expression
+ FlagRecoveryThreshold = "recoveryThreshold"
+
+ // FlagAwsDatasourcesNewFormStyling
+ // Applies new form styling for configuration and query editors in AWS plugins
+ FlagAwsDatasourcesNewFormStyling = "awsDatasourcesNewFormStyling"
)
diff --git a/pkg/services/folder/folderimpl/folder_test.go b/pkg/services/folder/folderimpl/folder_test.go
index f9d85b577e9..0d2e5eaa6db 100644
--- a/pkg/services/folder/folderimpl/folder_test.go
+++ b/pkg/services/folder/folderimpl/folder_test.go
@@ -406,7 +406,7 @@ func TestIntegrationNestedFolderService(t *testing.T) {
alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOn, db, serviceWithFlagOn, ac, dashSrv)
require.NoError(t, err)
- elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOn, featuresFlagOn)
+ elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOn, featuresFlagOn, ac)
lps, err := librarypanels.ProvideService(cfg, db, routeRegister, elementService, serviceWithFlagOn)
require.NoError(t, err)
@@ -481,7 +481,7 @@ func TestIntegrationNestedFolderService(t *testing.T) {
alertStore, err := ngstore.ProvideDBStore(cfg, featuresFlagOff, db, serviceWithFlagOff, ac, dashSrv)
require.NoError(t, err)
- elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOff, featuresFlagOff)
+ elementService := libraryelements.ProvideService(cfg, db, routeRegister, serviceWithFlagOff, featuresFlagOff, ac)
lps, err := librarypanels.ProvideService(cfg, db, routeRegister, elementService, serviceWithFlagOff)
require.NoError(t, err)
@@ -602,7 +602,7 @@ func TestIntegrationNestedFolderService(t *testing.T) {
CanEditValue: true,
})
- elementService := libraryelements.ProvideService(cfg, db, routeRegister, tc.service, tc.featuresFlag)
+ elementService := libraryelements.ProvideService(cfg, db, routeRegister, tc.service, tc.featuresFlag, ac)
lps, err := librarypanels.ProvideService(cfg, db, routeRegister, elementService, tc.service)
require.NoError(t, err)
diff --git a/pkg/services/libraryelements/accesscontrol.go b/pkg/services/libraryelements/accesscontrol.go
new file mode 100644
index 00000000000..4760f9cf2ae
--- /dev/null
+++ b/pkg/services/libraryelements/accesscontrol.go
@@ -0,0 +1,69 @@
+package libraryelements
+
+import (
+ "context"
+ "errors"
+ "strings"
+
+ "github.com/grafana/grafana/pkg/infra/appcontext"
+ ac "github.com/grafana/grafana/pkg/services/accesscontrol"
+ "github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/folder"
+ "github.com/grafana/grafana/pkg/services/libraryelements/model"
+)
+
+const (
+ ScopeLibraryPanelsRoot = "library.panels"
+ ScopeLibraryPanelsPrefix = "library.panels:uid:"
+
+ ActionLibraryPanelsCreate = "library.panels:create"
+ ActionLibraryPanelsRead = "library.panels:read"
+ ActionLibraryPanelsWrite = "library.panels:write"
+ ActionLibraryPanelsDelete = "library.panels:delete"
+)
+
+var (
+ ScopeLibraryPanelsProvider = ac.NewScopeProvider(ScopeLibraryPanelsRoot)
+
+ ScopeLibraryPanelsAll = ScopeLibraryPanelsProvider.GetResourceAllScope()
+)
+
+var (
+ ErrNoElementsFound = errors.New("library element not found")
+ ErrElementNameNotUnique = errors.New("several library elements with the same name were found")
+)
+
+// LibraryPanelUIDScopeResolver provides a ScopeAttributeResolver that is able to convert a scope prefixed with "library.panels:uid:"
+// into uid based scopes for a library panel and its associated folder hierarchy
+func LibraryPanelUIDScopeResolver(l *LibraryElementService, folderSvc folder.Service) (string, ac.ScopeAttributeResolver) {
+ prefix := ScopeLibraryPanelsProvider.GetResourceScopeUID("")
+ return prefix, ac.ScopeAttributeResolverFunc(func(ctx context.Context, orgID int64, scope string) ([]string, error) {
+ if !strings.HasPrefix(scope, prefix) {
+ return nil, ac.ErrInvalidScope
+ }
+
+ uid, err := ac.ParseScopeUID(scope)
+ if err != nil {
+ return nil, err
+ }
+
+ user, err := appcontext.User(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ libElDTO, err := l.getLibraryElementByUid(ctx, user, model.GetLibraryElementCommand{
+ UID: uid,
+ FolderName: dashboards.RootFolderName,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ inheritedScopes, err := dashboards.GetInheritedScopes(ctx, orgID, libElDTO.FolderUID, folderSvc)
+ if err != nil {
+ return nil, err
+ }
+ return append(inheritedScopes, dashboards.ScopeFoldersProvider.GetResourceScopeUID(libElDTO.FolderUID), ScopeLibraryPanelsProvider.GetResourceScopeUID(uid)), nil
+ })
+}
diff --git a/pkg/services/libraryelements/api.go b/pkg/services/libraryelements/api.go
index 4b09a49a773..5bb3a03612a 100644
--- a/pkg/services/libraryelements/api.go
+++ b/pkg/services/libraryelements/api.go
@@ -6,23 +6,38 @@ import (
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
- "github.com/grafana/grafana/pkg/middleware"
+ ac "github.com/grafana/grafana/pkg/services/accesscontrol"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/web"
)
func (l *LibraryElementService) registerAPIEndpoints() {
+ authorize := ac.Middleware(l.AccessControl)
+
l.RouteRegister.Group("/api/library-elements", func(entities routing.RouteRegister) {
- entities.Post("/", middleware.ReqSignedIn, routing.Wrap(l.createHandler))
- entities.Delete("/:uid", middleware.ReqSignedIn, routing.Wrap(l.deleteHandler))
- entities.Get("/", middleware.ReqSignedIn, routing.Wrap(l.getAllHandler))
- entities.Get("/:uid", middleware.ReqSignedIn, routing.Wrap(l.getHandler))
- entities.Get("/:uid/connections/", middleware.ReqSignedIn, routing.Wrap(l.getConnectionsHandler))
- entities.Get("/name/:name", middleware.ReqSignedIn, routing.Wrap(l.getByNameHandler))
- entities.Patch("/:uid", middleware.ReqSignedIn, routing.Wrap(l.patchHandler))
+ uidScope := ScopeLibraryPanelsProvider.GetResourceScopeUID(ac.Parameter(":uid"))
+
+ if l.features.IsEnabled(featuremgmt.FlagLibraryPanelRBAC) {
+ entities.Post("/", authorize(ac.EvalPermission(ActionLibraryPanelsCreate)), routing.Wrap(l.createHandler))
+ entities.Delete("/:uid", authorize(ac.EvalPermission(ActionLibraryPanelsDelete, uidScope)), routing.Wrap(l.deleteHandler))
+ entities.Get("/", authorize(ac.EvalPermission(ActionLibraryPanelsRead)), routing.Wrap(l.getAllHandler))
+ entities.Get("/:uid", authorize(ac.EvalPermission(ActionLibraryPanelsRead, uidScope)), routing.Wrap(l.getHandler))
+ entities.Get("/:uid/connections/", authorize(ac.EvalPermission(ActionLibraryPanelsRead, uidScope)), routing.Wrap(l.getConnectionsHandler))
+ entities.Get("/name/:name", routing.Wrap(l.getByNameHandler))
+ entities.Patch("/:uid", authorize(ac.EvalPermission(ActionLibraryPanelsWrite, uidScope)), routing.Wrap(l.patchHandler))
+ } else {
+ entities.Post("/", routing.Wrap(l.createHandler))
+ entities.Delete("/:uid", routing.Wrap(l.deleteHandler))
+ entities.Get("/", routing.Wrap(l.getAllHandler))
+ entities.Get("/:uid", routing.Wrap(l.getHandler))
+ entities.Get("/:uid/connections/", routing.Wrap(l.getConnectionsHandler))
+ entities.Get("/name/:name", routing.Wrap(l.getByNameHandler))
+ entities.Patch("/:uid", routing.Wrap(l.patchHandler))
+ }
})
}
@@ -56,7 +71,8 @@ func (l *LibraryElementService) createHandler(c *contextmodel.ReqContext) respon
cmd.FolderID = folder.ID
}
}
- element, err := l.CreateElement(c.Req.Context(), c.SignedInUser, cmd)
+
+ element, err := l.createLibraryElement(c.Req.Context(), c.SignedInUser, cmd)
if err != nil {
return toLibraryElementError(err, "Failed to create library element")
}
@@ -109,6 +125,7 @@ func (l *LibraryElementService) deleteHandler(c *contextmodel.ReqContext) respon
// Responses:
// 200: getLibraryElementResponse
// 401: unauthorisedError
+// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
func (l *LibraryElementService) getHandler(c *contextmodel.ReqContext) response.Response {
@@ -154,6 +171,14 @@ func (l *LibraryElementService) getAllHandler(c *contextmodel.ReqContext) respon
return toLibraryElementError(err, "Failed to get library elements")
}
+ if l.features.IsEnabled(featuremgmt.FlagLibraryPanelRBAC) {
+ filteredPanels, err := l.filterLibraryPanelsByPermission(c, elementsResult.Elements)
+ if err != nil {
+ return toLibraryElementError(err, "Failed to evaluate permissions")
+ }
+ elementsResult.Elements = filteredPanels
+ }
+
return response.JSON(http.StatusOK, model.LibraryElementSearchResponse{Result: elementsResult})
}
@@ -216,6 +241,7 @@ func (l *LibraryElementService) patchHandler(c *contextmodel.ReqContext) respons
// Responses:
// 200: getLibraryElementConnectionsResponse
// 401: unauthorisedError
+// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
func (l *LibraryElementService) getConnectionsHandler(c *contextmodel.ReqContext) response.Response {
@@ -244,7 +270,31 @@ func (l *LibraryElementService) getByNameHandler(c *contextmodel.ReqContext) res
return toLibraryElementError(err, "Failed to get library element")
}
- return response.JSON(http.StatusOK, model.LibraryElementArrayResponse{Result: elements})
+ if l.features.IsEnabled(featuremgmt.FlagLibraryPanelRBAC) {
+ filteredElements, err := l.filterLibraryPanelsByPermission(c, elements)
+ if err != nil {
+ return toLibraryElementError(err, err.Error())
+ }
+
+ return response.JSON(http.StatusOK, model.LibraryElementArrayResponse{Result: filteredElements})
+ } else {
+ return response.JSON(http.StatusOK, model.LibraryElementArrayResponse{Result: elements})
+ }
+}
+
+func (l *LibraryElementService) filterLibraryPanelsByPermission(c *contextmodel.ReqContext, elements []model.LibraryElementDTO) ([]model.LibraryElementDTO, error) {
+ filteredPanels := make([]model.LibraryElementDTO, 0)
+ for _, p := range elements {
+ allowed, err := l.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, ac.EvalPermission(ActionLibraryPanelsRead, ScopeLibraryPanelsProvider.GetResourceScopeUID(p.UID)))
+ if err != nil {
+ return nil, err
+ }
+ if allowed {
+ filteredPanels = append(filteredPanels, p)
+ }
+ }
+
+ return filteredPanels, nil
}
func toLibraryElementError(err error, message string) response.Response {
diff --git a/pkg/services/libraryelements/database.go b/pkg/services/libraryelements/database.go
index 2932bfa3e17..73df537ec8b 100644
--- a/pkg/services/libraryelements/database.go
+++ b/pkg/services/libraryelements/database.go
@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/kinds/librarypanel"
+ ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -82,7 +83,7 @@ func syncFieldsWithModel(libraryElement *model.LibraryElement) error {
return nil
}
-func getLibraryElement(dialect migrator.Dialect, session *db.Session, uid string, orgID int64) (model.LibraryElementWithMeta, error) {
+func GetLibraryElement(dialect migrator.Dialect, session *db.Session, uid string, orgID int64) (model.LibraryElementWithMeta, error) {
elements := make([]model.LibraryElementWithMeta, 0)
sql := selectLibraryElementDTOWithMeta +
", coalesce(dashboard.title, 'General') AS folder_name" +
@@ -161,8 +162,18 @@ func (l *LibraryElementService) createLibraryElement(c context.Context, signedIn
}
err = l.SQLStore.WithTransactionalDbSession(c, func(session *db.Session) error {
- if err := l.requireEditPermissionsOnFolder(c, signedInUser, cmd.FolderID); err != nil {
- return err
+ if l.features.IsEnabled(featuremgmt.FlagLibraryPanelRBAC) {
+ allowed, err := l.AccessControl.Evaluate(c, signedInUser, ac.EvalPermission(ActionLibraryPanelsCreate, dashboards.ScopeFoldersProvider.GetResourceScopeUID(*cmd.FolderUID)))
+ if !allowed {
+ return fmt.Errorf("insufficient permissions for creating library panel in folder with UID %s", *cmd.FolderUID)
+ }
+ if err != nil {
+ return err
+ }
+ } else {
+ if err := l.requireEditPermissionsOnFolder(c, signedInUser, cmd.FolderID); err != nil {
+ return err
+ }
}
if _, err := session.Insert(&element); err != nil {
if l.SQLStore.GetDialect().IsUniqueConstraintViolation(err) {
@@ -208,7 +219,7 @@ func (l *LibraryElementService) createLibraryElement(c context.Context, signedIn
func (l *LibraryElementService) deleteLibraryElement(c context.Context, signedInUser identity.Requester, uid string) (int64, error) {
var elementID int64
err := l.SQLStore.WithTransactionalDbSession(c, func(session *db.Session) error {
- element, err := getLibraryElement(l.SQLStore.GetDialect(), session, uid, signedInUser.GetOrgID())
+ element, err := GetLibraryElement(l.SQLStore.GetDialect(), session, uid, signedInUser.GetOrgID())
if err != nil {
return err
}
@@ -520,7 +531,7 @@ func (l *LibraryElementService) patchLibraryElement(c context.Context, signedInU
return model.LibraryElementDTO{}, err
}
err := l.SQLStore.WithTransactionalDbSession(c, func(session *db.Session) error {
- elementInDB, err := getLibraryElement(l.SQLStore.GetDialect(), session, uid, signedInUser.GetOrgID())
+ elementInDB, err := GetLibraryElement(l.SQLStore.GetDialect(), session, uid, signedInUser.GetOrgID())
if err != nil {
return err
}
@@ -537,7 +548,7 @@ func (l *LibraryElementService) patchLibraryElement(c context.Context, signedInU
return model.ErrLibraryElementUIDTooLong
}
- _, err := getLibraryElement(l.SQLStore.GetDialect(), session, updateUID, signedInUser.GetOrgID())
+ _, err := GetLibraryElement(l.SQLStore.GetDialect(), session, updateUID, signedInUser.GetOrgID())
if !errors.Is(err, model.ErrLibraryElementNotFound) {
return model.ErrLibraryElementAlreadyExists
}
@@ -634,7 +645,7 @@ func (l *LibraryElementService) getConnections(c context.Context, signedInUser i
}
err = l.SQLStore.WithDbSession(c, func(session *db.Session) error {
- element, err := getLibraryElement(l.SQLStore.GetDialect(), session, uid, signedInUser.GetOrgID())
+ element, err := GetLibraryElement(l.SQLStore.GetDialect(), session, uid, signedInUser.GetOrgID())
if err != nil {
return err
}
@@ -737,7 +748,7 @@ func (l *LibraryElementService) connectElementsToDashboardID(c context.Context,
return err
}
for _, elementUID := range elementUIDs {
- element, err := getLibraryElement(l.SQLStore.GetDialect(), session, elementUID, signedInUser.GetOrgID())
+ element, err := GetLibraryElement(l.SQLStore.GetDialect(), session, elementUID, signedInUser.GetOrgID())
if err != nil {
return err
}
diff --git a/pkg/services/libraryelements/libraryelements.go b/pkg/services/libraryelements/libraryelements.go
index 65f60cbc7f2..864f951ac75 100644
--- a/pkg/services/libraryelements/libraryelements.go
+++ b/pkg/services/libraryelements/libraryelements.go
@@ -7,6 +7,7 @@ import (
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth/identity"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
@@ -14,7 +15,7 @@ import (
"github.com/grafana/grafana/pkg/setting"
)
-func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.RouteRegister, folderService folder.Service, features featuremgmt.FeatureToggles) *LibraryElementService {
+func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.RouteRegister, folderService folder.Service, features featuremgmt.FeatureToggles, ac accesscontrol.AccessControl) *LibraryElementService {
l := &LibraryElementService{
Cfg: cfg,
SQLStore: sqlStore,
@@ -22,8 +23,12 @@ func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.Rout
folderService: folderService,
log: log.New("library-elements"),
features: features,
+ AccessControl: ac,
}
+
l.registerAPIEndpoints()
+ ac.RegisterScopeAttributeResolver(LibraryPanelUIDScopeResolver(l, l.folderService))
+
return l
}
@@ -45,6 +50,7 @@ type LibraryElementService struct {
folderService folder.Service
log log.Logger
features featuremgmt.FeatureToggles
+ AccessControl accesscontrol.AccessControl
}
var _ Service = (*LibraryElementService)(nil)
diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go
index d64c2cd4d82..2b3bae99b2a 100644
--- a/pkg/services/librarypanels/librarypanels_test.go
+++ b/pkg/services/librarypanels/librarypanels_test.go
@@ -830,7 +830,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo
features := featuremgmt.WithFeatures()
folderService := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, dashboardStore, folderStore, sqlStore, features)
- elementService := libraryelements.ProvideService(cfg, sqlStore, routing.NewRouteRegister(), folderService, featuremgmt.WithFeatures())
+ elementService := libraryelements.ProvideService(cfg, sqlStore, routing.NewRouteRegister(), folderService, featuremgmt.WithFeatures(), ac)
service := LibraryPanelService{
Cfg: cfg,
SQLStore: sqlStore,
diff --git a/pkg/services/live/live.go b/pkg/services/live/live.go
index bb6babd1292..eb802c8b236 100644
--- a/pkg/services/live/live.go
+++ b/pkg/services/live/live.go
@@ -124,8 +124,9 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r
// will be connected over Redis PUB/SUB. Presence will work
// globally since kept inside Redis.
redisAddress := g.Cfg.LiveHAEngineAddress
+ redisPassword := g.Cfg.LiveHAEnginePassword
redisShardConfigs := []centrifuge.RedisShardConfig{
- {Address: redisAddress},
+ {Address: redisAddress, Password: redisPassword},
}
var redisShards []*centrifuge.RedisShard
for _, redisConf := range redisShardConfigs {
@@ -160,7 +161,8 @@ func ProvideService(plugCtxProvider *plugincontext.Provider, cfg *setting.Cfg, r
var managedStreamRunner *managedstream.Runner
if g.IsHA() {
redisClient := redis.NewClient(&redis.Options{
- Addr: g.Cfg.LiveHAEngineAddress,
+ Addr: g.Cfg.LiveHAEngineAddress,
+ Password: g.Cfg.LiveHAEnginePassword,
})
cmd := redisClient.Ping(context.Background())
if _, err := cmd.Result(); err != nil {
diff --git a/pkg/services/navtree/models.go b/pkg/services/navtree/models.go
index 8b2e7e9c746..1054d87802e 100644
--- a/pkg/services/navtree/models.go
+++ b/pkg/services/navtree/models.go
@@ -38,6 +38,9 @@ const (
NavIDMonitoring = "monitoring"
NavIDReporting = "reports"
NavIDApps = "apps"
+ NavIDCfgGeneral = "cfg/general"
+ NavIDCfgPlugins = "cfg/plugins"
+ NavIDCfgAccess = "cfg/access"
)
type NavLink struct {
@@ -115,33 +118,103 @@ func Sort(nodes []*NavLink) {
}
}
-func (root *NavTreeRoot) ApplyAdminIA() {
+func (root *NavTreeRoot) ApplyAdminIA(navAdminSubsectionsEnabled bool) {
orgAdminNode := root.FindById(NavIDCfg)
if orgAdminNode != nil {
adminNodeLinks := []*NavLink{}
- adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("datasources"))
- adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("plugins"))
- adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("global-users"))
- adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("teams"))
- adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("serviceaccounts"))
- adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("apikeys"))
- adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("org-settings"))
- adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("authentication"))
- adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("server-settings"))
- adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("global-orgs"))
- adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("feature-toggles"))
+ if navAdminSubsectionsEnabled {
+ generalNodeLinks := []*NavLink{}
+ generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("upgrading")) // TODO does this even exist
+ generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("licensing"))
+ generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("org-settings"))
+ generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("server-settings"))
+ generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("global-orgs"))
+ generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("feature-toggles"))
+ generalNodeLinks = AppendIfNotNil(generalNodeLinks, root.FindById("storage"))
- adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("upgrading"))
- adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("licensing"))
- adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("recordedQueries")) // enterprise only
- adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("correlations"))
- adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("plugin-page-grafana-cloud-link-app"))
+ generalNode := &NavLink{
+ Text: "General",
+ SubTitle: "Manage default preferences and settings across Grafana",
+ Id: NavIDCfgGeneral,
+ Url: "/admin/general",
+ Icon: "shield",
+ Children: generalNodeLinks,
+ }
- adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("ldap"))
- adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("standalone-plugin-page-/a/grafana-auth-app")) // Cloud Access Policies
- adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("storage"))
+ pluginsNodeLinks := []*NavLink{}
+ pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("plugins"))
+ pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("datasources"))
+ pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("recordedQueries"))
+ pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("correlations"))
+ pluginsNodeLinks = AppendIfNotNil(pluginsNodeLinks, root.FindById("plugin-page-grafana-cloud-link-app"))
+
+ pluginsNode := &NavLink{
+ Text: "Plugins and data",
+ SubTitle: "Install plugins and define the relationships between data",
+ Id: NavIDCfgPlugins,
+ Url: "/admin/plugins",
+ Icon: "shield",
+ Children: pluginsNodeLinks,
+ }
+
+ accessNodeLinks := []*NavLink{}
+ accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("global-users"))
+ accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("teams"))
+ accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("standalone-plugin-page-/a/grafana-auth-app"))
+ accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("serviceaccounts"))
+ accessNodeLinks = AppendIfNotNil(accessNodeLinks, root.FindById("apikeys"))
+
+ usersNode := &NavLink{
+ Text: "Users and access",
+ SubTitle: "Configure access for individual users, teams, and service accounts",
+ Id: NavIDCfgAccess,
+ Url: "/admin/access",
+ Icon: "shield",
+ Children: accessNodeLinks,
+ }
+
+ if len(generalNode.Children) > 0 {
+ adminNodeLinks = append(adminNodeLinks, generalNode)
+ }
+
+ if len(pluginsNode.Children) > 0 {
+ adminNodeLinks = append(adminNodeLinks, pluginsNode)
+ }
+
+ if len(usersNode.Children) > 0 {
+ adminNodeLinks = append(adminNodeLinks, usersNode)
+ }
+
+ authenticationNode := root.FindById("authentication")
+ if authenticationNode != nil {
+ authenticationNode.IsSection = true
+ adminNodeLinks = append(adminNodeLinks, authenticationNode)
+ }
+ } else {
+ adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("datasources"))
+ adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("plugins"))
+ adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("global-users"))
+ adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("teams"))
+ adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("serviceaccounts"))
+ adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("apikeys"))
+ adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("org-settings"))
+ adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("authentication"))
+ adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("server-settings"))
+ adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("global-orgs"))
+ adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("feature-toggles"))
+
+ adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("upgrading"))
+ adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("licensing"))
+ adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("recordedQueries")) // enterprise only
+ adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("correlations"))
+ adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("plugin-page-grafana-cloud-link-app"))
+
+ adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("ldap"))
+ adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("standalone-plugin-page-/a/grafana-auth-app")) // Cloud Access Policies
+ adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("storage"))
+ }
if len(adminNodeLinks) > 0 {
orgAdminNode.Children = adminNodeLinks
diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go
index ace34b04ac3..c3d5602e8e4 100644
--- a/pkg/services/navtree/navtreeimpl/admin.go
+++ b/pkg/services/navtree/navtreeimpl/admin.go
@@ -17,7 +17,8 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
orgsAccessEvaluator := ac.EvalPermission(ac.ActionOrgsRead)
authConfigUIAvailable := s.license.FeatureEnabled("saml") || s.cfg.LDAPAuthEnabled
- // FIXME: while we don't have a permissions for listing plugins the legacy check has to stay as a default
+ // FIXME: If plugin admin is disabled or externally managed, server admins still need to access the page, this is why
+ // while we don't have a permissions for listing plugins the legacy check has to stay as a default
if pluginaccesscontrol.ReqCanAdminPlugins(s.cfg)(c) || hasAccess(pluginaccesscontrol.AdminAccessEvaluator) {
configNodes = append(configNodes, &navtree.NavLink{
Text: "Plugins",
diff --git a/pkg/services/ngalert/api/api_provisioning.go b/pkg/services/ngalert/api/api_provisioning.go
index 14733cbefd5..bd825349c28 100644
--- a/pkg/services/ngalert/api/api_provisioning.go
+++ b/pkg/services/ngalert/api/api_provisioning.go
@@ -57,7 +57,7 @@ type MuteTimingService interface {
}
type AlertRuleService interface {
- GetAlertRules(ctx context.Context, orgID int64) ([]*alerting_models.AlertRule, error)
+ GetAlertRules(ctx context.Context, orgID int64) ([]*alerting_models.AlertRule, map[string]alerting_models.Provenance, error)
GetAlertRule(ctx context.Context, orgID int64, ruleUID string) (alerting_models.AlertRule, alerting_models.Provenance, error)
CreateAlertRule(ctx context.Context, rule alerting_models.AlertRule, provenance alerting_models.Provenance, userID int64) (alerting_models.AlertRule, error)
UpdateAlertRule(ctx context.Context, rule alerting_models.AlertRule, provenance alerting_models.Provenance) (alerting_models.AlertRule, error)
@@ -300,11 +300,11 @@ func (srv *ProvisioningSrv) RouteDeleteMuteTiming(c *contextmodel.ReqContext, na
}
func (srv *ProvisioningSrv) RouteGetAlertRules(c *contextmodel.ReqContext) response.Response {
- rules, err := srv.alertRules.GetAlertRules(c.Req.Context(), c.SignedInUser.GetOrgID())
+ rules, provenances, err := srv.alertRules.GetAlertRules(c.Req.Context(), c.SignedInUser.GetOrgID())
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "")
}
- return response.JSON(http.StatusOK, ProvisionedAlertRuleFromAlertRules(rules))
+ return response.JSON(http.StatusOK, ProvisionedAlertRuleFromAlertRules(rules, provenances))
}
func (srv *ProvisioningSrv) RouteRouteGetAlertRule(c *contextmodel.ReqContext, UID string) response.Response {
diff --git a/pkg/services/ngalert/api/compat.go b/pkg/services/ngalert/api/compat.go
index fc5a01ba083..899609a7dc3 100644
--- a/pkg/services/ngalert/api/compat.go
+++ b/pkg/services/ngalert/api/compat.go
@@ -55,10 +55,10 @@ func ProvisionedAlertRuleFromAlertRule(rule models.AlertRule, provenance models.
}
// ProvisionedAlertRuleFromAlertRules converts a collection of models.AlertRule to definitions.ProvisionedAlertRules with provenance status models.ProvenanceNone
-func ProvisionedAlertRuleFromAlertRules(rules []*models.AlertRule) definitions.ProvisionedAlertRules {
+func ProvisionedAlertRuleFromAlertRules(rules []*models.AlertRule, provenances map[string]models.Provenance) definitions.ProvisionedAlertRules {
result := make([]definitions.ProvisionedAlertRule, 0, len(rules))
for _, r := range rules {
- result = append(result, ProvisionedAlertRuleFromAlertRule(*r, models.ProvenanceNone))
+ result = append(result, ProvisionedAlertRuleFromAlertRule(*r, provenances[r.UID]))
}
return result
}
diff --git a/pkg/services/ngalert/provisioning/alert_rules.go b/pkg/services/ngalert/provisioning/alert_rules.go
index 0c354d0d08c..043f7d8d989 100644
--- a/pkg/services/ngalert/provisioning/alert_rules.go
+++ b/pkg/services/ngalert/provisioning/alert_rules.go
@@ -45,16 +45,23 @@ func NewAlertRuleService(ruleStore RuleStore,
}
}
-func (service *AlertRuleService) GetAlertRules(ctx context.Context, orgID int64) ([]*models.AlertRule, error) {
+func (service *AlertRuleService) GetAlertRules(ctx context.Context, orgID int64) ([]*models.AlertRule, map[string]models.Provenance, error) {
q := models.ListAlertRulesQuery{
OrgID: orgID,
}
rules, err := service.ruleStore.ListAlertRules(ctx, &q)
if err != nil {
- return nil, err
+ return nil, nil, err
}
- // TODO: GET provenance
- return rules, nil
+ provenances := make(map[string]models.Provenance)
+ if len(rules) > 0 {
+ resourceType := rules[0].ResourceType()
+ provenances, err = service.provenanceStore.GetProvenances(ctx, orgID, resourceType)
+ if err != nil {
+ return nil, nil, err
+ }
+ }
+ return rules, provenances, nil
}
func (service *AlertRuleService) GetAlertRule(ctx context.Context, orgID int64, ruleUID string) (models.AlertRule, models.Provenance, error) {
@@ -62,15 +69,15 @@ func (service *AlertRuleService) GetAlertRule(ctx context.Context, orgID int64,
OrgID: orgID,
UID: ruleUID,
}
- rules, err := service.ruleStore.GetAlertRuleByUID(ctx, query)
+ rule, err := service.ruleStore.GetAlertRuleByUID(ctx, query)
if err != nil {
return models.AlertRule{}, models.ProvenanceNone, err
}
- provenance, err := service.provenanceStore.GetProvenance(ctx, rules, orgID)
+ provenance, err := service.provenanceStore.GetProvenance(ctx, rule, orgID)
if err != nil {
return models.AlertRule{}, models.ProvenanceNone, err
}
- return *rules, provenance, nil
+ return *rule, provenance, nil
}
type AlertRuleWithFolderTitle struct {
diff --git a/pkg/services/playlist/model.go b/pkg/services/playlist/model.go
index 8c1b564793d..f0a0b4b4ae7 100644
--- a/pkg/services/playlist/model.go
+++ b/pkg/services/playlist/model.go
@@ -8,9 +8,8 @@ import (
// Typed errors
var (
- ErrPlaylistNotFound = errors.New("Playlist not found")
- ErrPlaylistFailedGenerateUniqueUid = errors.New("failed to generate unique playlist UID")
- ErrCommandValidationFailed = errors.New("command missing required fields")
+ ErrPlaylistNotFound = errors.New("Playlist not found")
+ ErrCommandValidationFailed = errors.New("command missing required fields")
)
// Playlist model
@@ -20,6 +19,12 @@ type Playlist struct {
Name string `json:"name" db:"name"`
Interval string `json:"interval" db:"interval"`
OrgId int64 `json:"-" db:"org_id"`
+
+ // Added for kubernetes migration + synchronization
+ // Hidden from json because this is used for openapi generation
+ // Using int64 rather than time.Time to avoid database issues with time support
+ CreatedAt int64 `json:"-" db:"created_at"`
+ UpdatedAt int64 `json:"-" db:"updated_at"`
}
type PlaylistDTO = playlist.Spec
@@ -54,6 +59,8 @@ type CreatePlaylistCommand struct {
Interval string `json:"interval"`
Items []PlaylistItem `json:"items"`
OrgId int64 `json:"-"`
+ // Used to create playlists from kubectl with a known uid/name
+ UID string `json:"-"`
}
type DeletePlaylistCommand struct {
diff --git a/pkg/services/playlist/model_test.go b/pkg/services/playlist/model_test.go
deleted file mode 100644
index f0eb722e10f..00000000000
--- a/pkg/services/playlist/model_test.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package playlist
-
-import (
- "encoding/json"
- "fmt"
- "testing"
-
- "github.com/grafana/grafana/pkg/kinds/playlist"
- "github.com/grafana/grafana/pkg/util"
- "github.com/stretchr/testify/require"
-)
-
-func TestPlaylistConversion(t *testing.T) {
- src := PlaylistDTO{
- Uid: "abc",
- Name: "TeamA",
- Interval: "10s",
- Items: []playlist.Item{
- {Title: util.Pointer("First"), Type: playlist.ItemTypeDashboardByUid, Value: "UID0"},
- {Title: util.Pointer("Second"), Type: playlist.ItemTypeDashboardByTag, Value: "tagA"},
- {Title: util.Pointer("Third"), Type: playlist.ItemTypeDashboardById, Value: "123"},
- },
- }
-
- dst := PlaylistToResource(src)
-
- require.Equal(t, "abc", src.Uid)
- require.Equal(t, "abc", dst.Metadata.Name)
- require.Equal(t, src.Name, dst.Spec.Name)
-
- out, err := json.MarshalIndent(dst, "", " ")
- require.NoError(t, err)
- fmt.Printf("%s", string(out))
- require.JSONEq(t, `{
- "apiVersion": "v0-0-alpha",
- "kind": "Playlist",
- "metadata": {
- "name": "abc",
- "creationTimestamp": null
- },
- "spec": {
- "interval": "10s",
- "items": [
- {
- "title": "First",
- "type": "dashboard_by_uid",
- "value": "UID0"
- },
- {
- "title": "Second",
- "type": "dashboard_by_tag",
- "value": "tagA"
- },
- {
- "title": "Third",
- "type": "dashboard_by_id",
- "value": "123"
- }
- ],
- "name": "TeamA",
- "uid": ""
- }
- }`, string(out))
-}
diff --git a/pkg/services/playlist/playlistimpl/playlist.go b/pkg/services/playlist/playlistimpl/playlist.go
index 054b96a7257..3111d701e58 100644
--- a/pkg/services/playlist/playlistimpl/playlist.go
+++ b/pkg/services/playlist/playlistimpl/playlist.go
@@ -16,17 +16,8 @@ type Service struct {
var _ playlist.Service = &Service{}
func ProvideService(db db.DB, toggles featuremgmt.FeatureToggles, objserver entity.EntityStoreServer) playlist.Service {
- var sqlstore store
-
- // 🐢🐢🐢 pick the store
- if toggles.IsEnabled(featuremgmt.FlagNewDBLibrary) {
- sqlstore = &sqlxStore{
- sess: db.GetSqlxSession(),
- }
- } else {
- sqlstore = &sqlStore{
- db: db,
- }
+ sqlstore := &sqlStore{
+ db: db,
}
return &Service{store: sqlstore}
}
diff --git a/pkg/services/playlist/playlistimpl/sqlx_store.go b/pkg/services/playlist/playlistimpl/sqlx_store.go
deleted file mode 100644
index a6321ae44a0..00000000000
--- a/pkg/services/playlist/playlistimpl/sqlx_store.go
+++ /dev/null
@@ -1,201 +0,0 @@
-package playlistimpl
-
-import (
- "context"
- "database/sql"
- "errors"
-
- "github.com/grafana/grafana/pkg/services/playlist"
- "github.com/grafana/grafana/pkg/services/sqlstore/session"
- "github.com/grafana/grafana/pkg/services/star"
-)
-
-type sqlxStore struct {
- sess *session.SessionDB
-}
-
-func (s *sqlxStore) Insert(ctx context.Context, cmd *playlist.CreatePlaylistCommand) (*playlist.Playlist, error) {
- p := playlist.Playlist{}
- var err error
- uid, err := newGenerateAndValidateNewPlaylistUid(ctx, s.sess, cmd.OrgId)
- if err != nil {
- return nil, err
- }
-
- p = playlist.Playlist{
- Name: cmd.Name,
- Interval: cmd.Interval,
- OrgId: cmd.OrgId,
- UID: uid,
- }
-
- err = s.sess.WithTransaction(ctx, func(tx *session.SessionTx) error {
- query := `INSERT INTO playlist (name, "interval", org_id, uid) VALUES (?, ?, ?, ?)`
- var err error
- p.Id, err = tx.ExecWithReturningId(ctx, query, p.Name, p.Interval, p.OrgId, p.UID)
- if err != nil {
- return err
- }
-
- if len(cmd.Items) > 0 {
- playlistItems := make([]playlist.PlaylistItem, 0)
- for order, item := range cmd.Items {
- playlistItems = append(playlistItems, playlist.PlaylistItem{
- PlaylistId: p.Id,
- Type: item.Type,
- Value: item.Value,
- Order: order + 1,
- Title: item.Title,
- })
- }
- query := `INSERT INTO playlist_item (playlist_id, type, value, title, "order") VALUES (:playlist_id, :type, :value, :title, :order)`
- _, err = tx.NamedExec(ctx, query, playlistItems)
- if err != nil {
- return err
- }
- }
- return nil
- })
-
- return &p, err
-}
-
-func (s *sqlxStore) Update(ctx context.Context, cmd *playlist.UpdatePlaylistCommand) (*playlist.PlaylistDTO, error) {
- dto := playlist.PlaylistDTO{}
-
- // Get the id of playlist to be updated with orgId and UID
- existingPlaylist, err := s.Get(ctx, &playlist.GetPlaylistByUidQuery{UID: cmd.UID, OrgId: cmd.OrgId})
- if err != nil {
- return nil, err
- }
-
- // Create object to be update to
- p := playlist.Playlist{
- Id: existingPlaylist.Id,
- UID: cmd.UID,
- OrgId: cmd.OrgId,
- Name: cmd.Name,
- Interval: cmd.Interval,
- }
-
- err = s.sess.WithTransaction(ctx, func(tx *session.SessionTx) error {
- query := `UPDATE playlist SET uid=:uid, org_id=:org_id, name=:name, "interval"=:interval WHERE id=:id`
- _, err = tx.NamedExec(ctx, query, p)
- if err != nil {
- return err
- }
-
- if _, err = tx.Exec(ctx, "DELETE FROM playlist_item WHERE playlist_id = ?", p.Id); err != nil {
- return err
- }
-
- playlistItems := make([]playlist.PlaylistItem, 0)
-
- for index, item := range cmd.Items {
- playlistItems = append(playlistItems, playlist.PlaylistItem{
- PlaylistId: p.Id,
- Type: item.Type,
- Value: item.Value,
- Order: index + 1,
- Title: item.Title,
- })
- }
- query = `INSERT INTO playlist_item (playlist_id, type, value, title, "order") VALUES (:playlist_id, :type, :value, :title, :order)`
- _, err = tx.NamedExec(ctx, query, playlistItems)
- return err
- })
-
- return &dto, err
-}
-
-func (s *sqlxStore) Get(ctx context.Context, query *playlist.GetPlaylistByUidQuery) (*playlist.Playlist, error) {
- if query.UID == "" || query.OrgId == 0 {
- return nil, playlist.ErrCommandValidationFailed
- }
-
- p := playlist.Playlist{}
- err := s.sess.Get(ctx, &p, "SELECT * FROM playlist WHERE uid=? AND org_id=?", query.UID, query.OrgId)
- if err != nil {
- if errors.Is(err, sql.ErrNoRows) {
- return nil, playlist.ErrPlaylistNotFound
- }
- return nil, err
- }
- return &p, err
-}
-
-func (s *sqlxStore) Delete(ctx context.Context, cmd *playlist.DeletePlaylistCommand) error {
- if cmd.UID == "" || cmd.OrgId == 0 {
- return playlist.ErrCommandValidationFailed
- }
-
- p := playlist.Playlist{}
- if err := s.sess.Get(ctx, &p, "SELECT * FROM playlist WHERE uid=? AND org_id=?", cmd.UID, cmd.OrgId); err != nil {
- if errors.Is(err, sql.ErrNoRows) {
- return nil
- }
- return err
- }
-
- err := s.sess.WithTransaction(ctx, func(tx *session.SessionTx) error {
- if _, err := tx.Exec(ctx, "DELETE FROM playlist WHERE uid = ? and org_id = ?", cmd.UID, cmd.OrgId); err != nil {
- return err
- }
-
- if _, err := tx.Exec(ctx, "DELETE FROM playlist_item WHERE playlist_id = ?", p.Id); err != nil {
- return err
- }
- return nil
- })
-
- return err
-}
-
-func (s *sqlxStore) List(ctx context.Context, query *playlist.GetPlaylistsQuery) (playlist.Playlists, error) {
- playlists := make(playlist.Playlists, 0)
- if query.OrgId == 0 {
- return playlists, playlist.ErrCommandValidationFailed
- }
-
- var err error
- if query.Name == "" {
- err = s.sess.Select(
- ctx, &playlists, "SELECT * FROM playlist WHERE org_id = ? LIMIT ?", query.OrgId, query.Limit)
- } else {
- err = s.sess.Select(
- ctx, &playlists, "SELECT * FROM playlist WHERE org_id = ? AND name LIKE ? LIMIT ?", query.OrgId, "%"+query.Name+"%", query.Limit)
- }
- return playlists, err
-}
-
-func (s *sqlxStore) GetItems(ctx context.Context, query *playlist.GetPlaylistItemsByUidQuery) ([]playlist.PlaylistItem, error) {
- var playlistItems = make([]playlist.PlaylistItem, 0)
- if query.PlaylistUID == "" || query.OrgId == 0 {
- return playlistItems, star.ErrCommandValidationFailed
- }
-
- var p = playlist.Playlist{}
- err := s.sess.Get(ctx, &p, "SELECT * FROM playlist WHERE uid=? AND org_id=?", query.PlaylistUID, query.OrgId)
- if err != nil {
- return playlistItems, err
- }
-
- err = s.sess.Select(ctx, &playlistItems, "SELECT * FROM playlist_item WHERE playlist_id=?", p.Id)
- return playlistItems, err
-}
-
-func newGenerateAndValidateNewPlaylistUid(ctx context.Context, sess *session.SessionDB, orgId int64) (string, error) {
- for i := 0; i < 3; i++ {
- uid := generateNewUid()
- p := playlist.Playlist{OrgId: orgId, UID: uid}
- err := sess.Get(ctx, &p, "SELECT * FROM playlist WHERE uid=? AND org_id=?", uid, orgId)
- if err != nil {
- if errors.Is(err, sql.ErrNoRows) {
- return uid, nil
- }
- return "", err
- }
- }
-
- return "", playlist.ErrPlaylistFailedGenerateUniqueUid
-}
diff --git a/pkg/services/playlist/playlistimpl/sqlx_store_test.go b/pkg/services/playlist/playlistimpl/sqlx_store_test.go
deleted file mode 100644
index 5b77bd5b850..00000000000
--- a/pkg/services/playlist/playlistimpl/sqlx_store_test.go
+++ /dev/null
@@ -1,16 +0,0 @@
-package playlistimpl
-
-import (
- "testing"
-
- "github.com/grafana/grafana/pkg/infra/db"
-)
-
-func TestIntegrationSQLxPlaylistDataAccess(t *testing.T) {
- if testing.Short() {
- t.Skip("skipping integration test")
- }
- testIntegrationPlaylistDataAccess(t, func(ss db.DB) store {
- return &sqlxStore{sess: ss.GetSqlxSession()}
- })
-}
diff --git a/pkg/services/playlist/playlistimpl/store_test.go b/pkg/services/playlist/playlistimpl/store_test.go
index 1bb2d82d0ba..b2d79b59c83 100644
--- a/pkg/services/playlist/playlistimpl/store_test.go
+++ b/pkg/services/playlist/playlistimpl/store_test.go
@@ -3,6 +3,7 @@ package playlistimpl
import (
"context"
"testing"
+ "time"
"github.com/stretchr/testify/require"
@@ -15,6 +16,7 @@ type getStore func(db.DB) store
func testIntegrationPlaylistDataAccess(t *testing.T, fn getStore) {
t.Helper()
+ start := time.Now().UnixMilli()
ss := db.InitTestDB(t)
playlistStore := fn(ss)
@@ -33,6 +35,8 @@ func testIntegrationPlaylistDataAccess(t *testing.T, fn getStore) {
pl, err := playlistStore.Get(context.Background(), get)
require.NoError(t, err)
require.Equal(t, p.Id, pl.Id)
+ require.GreaterOrEqual(t, pl.CreatedAt, start)
+ require.GreaterOrEqual(t, pl.UpdatedAt, start)
})
t.Run("Can get playlist items", func(t *testing.T) {
@@ -43,6 +47,7 @@ func testIntegrationPlaylistDataAccess(t *testing.T, fn getStore) {
})
t.Run("Can update playlist", func(t *testing.T) {
+ time.Sleep(time.Millisecond * 2)
items := []playlist.PlaylistItem{
{Title: "influxdb", Value: "influxdb", Type: "dashboard_by_tag"},
{Title: "Backend response times", Value: "2", Type: "dashboard_by_id"},
@@ -50,6 +55,14 @@ func testIntegrationPlaylistDataAccess(t *testing.T, fn getStore) {
query := playlist.UpdatePlaylistCommand{Name: "NYC office ", OrgId: 1, UID: uid, Interval: "10s", Items: items}
_, err = playlistStore.Update(context.Background(), &query)
require.NoError(t, err)
+
+ // Now check that UpdatedAt has increased
+ pl, err := playlistStore.Get(context.Background(), &playlist.GetPlaylistByUidQuery{UID: uid, OrgId: 1})
+ require.NoError(t, err)
+ require.Equal(t, p.Id, pl.Id)
+ require.Equal(t, p.CreatedAt, pl.CreatedAt)
+ require.Greater(t, pl.UpdatedAt, p.UpdatedAt)
+ require.Greater(t, pl.UpdatedAt, pl.CreatedAt)
})
t.Run("Can remove playlist", func(t *testing.T) {
@@ -64,6 +77,32 @@ func testIntegrationPlaylistDataAccess(t *testing.T, fn getStore) {
})
})
+ t.Run("Can create playlist with known UID", func(t *testing.T) {
+ items := []playlist.PlaylistItem{
+ {Title: "graphite", Value: "graphite", Type: "dashboard_by_tag"},
+ {Title: "Backend response times", Value: "3", Type: "dashboard_by_id"},
+ }
+ cmd := playlist.CreatePlaylistCommand{Name: "NYC office", Interval: "10m", OrgId: 1,
+ Items: items,
+ UID: "abcd",
+ }
+ p, err := playlistStore.Insert(context.Background(), &cmd)
+ require.NoError(t, err)
+ require.Equal(t, "abcd", p.UID)
+
+ // Should get an error with an invalid UID
+ cmd.UID = "invalid uid"
+ _, err = playlistStore.Insert(context.Background(), &cmd)
+ require.Error(t, err)
+
+ // cleanup
+ err = playlistStore.Delete(context.Background(), &playlist.DeletePlaylistCommand{
+ OrgId: 1,
+ UID: "abcd",
+ })
+ require.NoError(t, err)
+ })
+
t.Run("Search playlist", func(t *testing.T) {
items := []playlist.PlaylistItem{
{Title: "graphite", Value: "graphite", Type: "dashboard_by_tag"},
diff --git a/pkg/services/playlist/playlistimpl/xorm_store.go b/pkg/services/playlist/playlistimpl/xorm_store.go
index 218588dbeba..871252d3a65 100644
--- a/pkg/services/playlist/playlistimpl/xorm_store.go
+++ b/pkg/services/playlist/playlistimpl/xorm_store.go
@@ -2,6 +2,7 @@ package playlistimpl
import (
"context"
+ "time"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/playlist"
@@ -13,22 +14,31 @@ type sqlStore struct {
db db.DB
}
+var _ store = &sqlStore{}
+
func (s *sqlStore) Insert(ctx context.Context, cmd *playlist.CreatePlaylistCommand) (*playlist.Playlist, error) {
p := playlist.Playlist{}
- err := s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
- uid, err := generateAndValidateNewPlaylistUid(sess, cmd.OrgId)
+ if cmd.UID == "" {
+ cmd.UID = util.GenerateShortUID()
+ } else {
+ err := util.ValidateUID(cmd.UID)
if err != nil {
- return err
+ return nil, err
}
+ }
+ err := s.db.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
+ ts := time.Now().UnixMilli()
p = playlist.Playlist{
- Name: cmd.Name,
- Interval: cmd.Interval,
- OrgId: cmd.OrgId,
- UID: uid,
+ Name: cmd.Name,
+ Interval: cmd.Interval,
+ OrgId: cmd.OrgId,
+ UID: cmd.UID,
+ CreatedAt: ts,
+ UpdatedAt: ts,
}
- _, err = sess.Insert(&p)
+ _, err := sess.Insert(&p)
if err != nil {
return err
}
@@ -67,6 +77,8 @@ func (s *sqlStore) Update(ctx context.Context, cmd *playlist.UpdatePlaylistComma
return err
}
p.Id = existingPlaylist.Id
+ p.CreatedAt = existingPlaylist.CreatedAt
+ p.UpdatedAt = time.Now().UnixMilli()
dto = playlist.PlaylistDTO{
Uid: p.UID,
@@ -74,7 +86,7 @@ func (s *sqlStore) Update(ctx context.Context, cmd *playlist.UpdatePlaylistComma
Interval: p.Interval,
}
- _, err = sess.Where("id=?", p.Id).Cols("name", "interval").Update(&p)
+ _, err = sess.Where("id=?", p.Id).Cols("name", "interval", "updated_at").Update(&p)
if err != nil {
return err
}
@@ -187,26 +199,3 @@ func (s *sqlStore) GetItems(ctx context.Context, query *playlist.GetPlaylistItem
})
return playlistItems, err
}
-
-// generateAndValidateNewPlaylistUid generates a playlistUID and verifies that
-// the uid isn't already in use. This is deliberately overly cautious, since users
-// can also specify playlist uids during provisioning.
-func generateAndValidateNewPlaylistUid(sess *db.Session, orgId int64) (string, error) {
- for i := 0; i < 3; i++ {
- uid := generateNewUid()
-
- playlist := playlist.Playlist{OrgId: orgId, UID: uid}
- exists, err := sess.Get(&playlist)
- if err != nil {
- return "", err
- }
-
- if !exists {
- return uid, nil
- }
- }
-
- return "", playlist.ErrPlaylistFailedGenerateUniqueUid
-}
-
-var generateNewUid func() string = util.GenerateShortUID
diff --git a/pkg/services/pluginsintegration/clientmiddleware/contextual_logger_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/contextual_logger_middleware.go
new file mode 100644
index 00000000000..cf97590294b
--- /dev/null
+++ b/pkg/services/pluginsintegration/clientmiddleware/contextual_logger_middleware.go
@@ -0,0 +1,69 @@
+package clientmiddleware
+
+import (
+ "context"
+
+ "github.com/grafana/grafana-plugin-sdk-go/backend"
+
+ "github.com/grafana/grafana/pkg/infra/log"
+ "github.com/grafana/grafana/pkg/plugins"
+)
+
+// NewContextualLoggerMiddleware creates a new plugins.ClientMiddleware that adds
+// a contextual logger to the request context.
+func NewContextualLoggerMiddleware() plugins.ClientMiddleware {
+ return plugins.ClientMiddlewareFunc(func(next plugins.Client) plugins.Client {
+ return &ContextualLoggerMiddleware{
+ next: next,
+ }
+ })
+}
+
+type ContextualLoggerMiddleware struct {
+ next plugins.Client
+}
+
+// instrumentContext adds a contextual logger with plugin and request details to the given context.
+func instrumentContext(ctx context.Context, endpoint string, pCtx backend.PluginContext) context.Context {
+ p := []any{"endpoint", endpoint, "pluginId", pCtx.PluginID}
+ if pCtx.DataSourceInstanceSettings != nil {
+ p = append(p, "dsName", pCtx.DataSourceInstanceSettings.Name)
+ p = append(p, "dsUID", pCtx.DataSourceInstanceSettings.UID)
+ }
+ if pCtx.User != nil {
+ p = append(p, "uname", pCtx.User.Login)
+ }
+ return log.WithContextualAttributes(ctx, p)
+}
+
+func (m *ContextualLoggerMiddleware) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
+ ctx = instrumentContext(ctx, endpointQueryData, req.PluginContext)
+ return m.next.QueryData(ctx, req)
+}
+
+func (m *ContextualLoggerMiddleware) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
+ ctx = instrumentContext(ctx, endpointCallResource, req.PluginContext)
+ return m.next.CallResource(ctx, req, sender)
+}
+
+func (m *ContextualLoggerMiddleware) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
+ ctx = instrumentContext(ctx, endpointCheckHealth, req.PluginContext)
+ return m.next.CheckHealth(ctx, req)
+}
+
+func (m *ContextualLoggerMiddleware) CollectMetrics(ctx context.Context, req *backend.CollectMetricsRequest) (*backend.CollectMetricsResult, error) {
+ ctx = instrumentContext(ctx, endpointCollectMetrics, req.PluginContext)
+ return m.next.CollectMetrics(ctx, req)
+}
+
+func (m *ContextualLoggerMiddleware) SubscribeStream(ctx context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) {
+ return m.next.SubscribeStream(ctx, req)
+}
+
+func (m *ContextualLoggerMiddleware) PublishStream(ctx context.Context, req *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) {
+ return m.next.PublishStream(ctx, req)
+}
+
+func (m *ContextualLoggerMiddleware) RunStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender) error {
+ return m.next.RunStream(ctx, req, sender)
+}
diff --git a/pkg/services/pluginsintegration/clientmiddleware/logger_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/logger_middleware.go
index 716df10bb8e..8230997e236 100644
--- a/pkg/services/pluginsintegration/clientmiddleware/logger_middleware.go
+++ b/pkg/services/pluginsintegration/clientmiddleware/logger_middleware.go
@@ -6,8 +6,8 @@ import (
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
+
"github.com/grafana/grafana/pkg/infra/log"
- "github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/plugins"
plog "github.com/grafana/grafana/pkg/plugins/log"
"github.com/grafana/grafana/pkg/setting"
@@ -33,10 +33,11 @@ type LoggerMiddleware struct {
logger plog.Logger
}
-func (m *LoggerMiddleware) logRequest(ctx context.Context, pluginCtx backend.PluginContext, endpoint string, fn func(ctx context.Context) error) error {
+func (m *LoggerMiddleware) logRequest(ctx context.Context, fn func(ctx context.Context) error) error {
status := statusOK
start := time.Now()
timeBeforePluginRequest := log.TimeSinceStart(ctx, start)
+
err := fn(ctx)
if err != nil {
status = statusError
@@ -48,31 +49,13 @@ func (m *LoggerMiddleware) logRequest(ctx context.Context, pluginCtx backend.Plu
logParams := []any{
"status", status,
"duration", time.Since(start),
- "pluginId", pluginCtx.PluginID,
- "endpoint", endpoint,
"eventName", "grafana-data-egress",
"time_before_plugin_request", timeBeforePluginRequest,
}
-
- if pluginCtx.User != nil {
- logParams = append(logParams, "uname", pluginCtx.User.Login)
- }
-
- traceID := tracing.TraceIDFromContext(ctx, false)
- if traceID != "" {
- logParams = append(logParams, "traceID", traceID)
- }
-
- if pluginCtx.DataSourceInstanceSettings != nil {
- logParams = append(logParams, "dsName", pluginCtx.DataSourceInstanceSettings.Name)
- logParams = append(logParams, "dsUID", pluginCtx.DataSourceInstanceSettings.UID)
- }
-
if status == statusError {
logParams = append(logParams, "error", err)
}
-
- m.logger.Info("Plugin Request Completed", logParams...)
+ m.logger.FromContext(ctx).Info("Plugin Request Completed", logParams...)
return err
}
@@ -82,7 +65,7 @@ func (m *LoggerMiddleware) QueryData(ctx context.Context, req *backend.QueryData
}
var resp *backend.QueryDataResponse
- err := m.logRequest(ctx, req.PluginContext, endpointQueryData, func(ctx context.Context) (innerErr error) {
+ err := m.logRequest(ctx, func(ctx context.Context) (innerErr error) {
resp, innerErr = m.next.QueryData(ctx, req)
return innerErr
})
@@ -95,7 +78,7 @@ func (m *LoggerMiddleware) CallResource(ctx context.Context, req *backend.CallRe
return m.next.CallResource(ctx, req, sender)
}
- err := m.logRequest(ctx, req.PluginContext, endpointCallResource, func(ctx context.Context) (innerErr error) {
+ err := m.logRequest(ctx, func(ctx context.Context) (innerErr error) {
innerErr = m.next.CallResource(ctx, req, sender)
return innerErr
})
@@ -109,7 +92,7 @@ func (m *LoggerMiddleware) CheckHealth(ctx context.Context, req *backend.CheckHe
}
var resp *backend.CheckHealthResult
- err := m.logRequest(ctx, req.PluginContext, endpointCheckHealth, func(ctx context.Context) (innerErr error) {
+ err := m.logRequest(ctx, func(ctx context.Context) (innerErr error) {
resp, innerErr = m.next.CheckHealth(ctx, req)
return innerErr
})
@@ -123,7 +106,7 @@ func (m *LoggerMiddleware) CollectMetrics(ctx context.Context, req *backend.Coll
}
var resp *backend.CollectMetricsResult
- err := m.logRequest(ctx, req.PluginContext, endpointCollectMetrics, func(ctx context.Context) (innerErr error) {
+ err := m.logRequest(ctx, func(ctx context.Context) (innerErr error) {
resp, innerErr = m.next.CollectMetrics(ctx, req)
return innerErr
})
diff --git a/pkg/services/pluginsintegration/clientmiddleware/instrumentation_middleware.go b/pkg/services/pluginsintegration/clientmiddleware/metrics_middleware.go
similarity index 67%
rename from pkg/services/pluginsintegration/clientmiddleware/instrumentation_middleware.go
rename to pkg/services/pluginsintegration/clientmiddleware/metrics_middleware.go
index ddbaaeaa3a1..fa765b80053 100644
--- a/pkg/services/pluginsintegration/clientmiddleware/instrumentation_middleware.go
+++ b/pkg/services/pluginsintegration/clientmiddleware/metrics_middleware.go
@@ -8,13 +8,12 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/prometheus/client_golang/prometheus"
- "github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/manager/registry"
)
-// pluginMetrics contains the prometheus metrics used by the InstrumentationMiddleware.
+// pluginMetrics contains the prometheus metrics used by the MetricsMiddleware.
type pluginMetrics struct {
pluginRequestCounter *prometheus.CounterVec
pluginRequestDuration *prometheus.HistogramVec
@@ -22,17 +21,15 @@ type pluginMetrics struct {
pluginRequestDurationSeconds *prometheus.HistogramVec
}
-// InstrumentationMiddleware is a middleware that instruments plugin requests.
+// MetricsMiddleware is a middleware that instruments plugin requests.
// It tracks requests count, duration and size as prometheus metrics.
-// It also enriches the [context.Context] with a contextual logger containing plugin and request details.
-// For those reasons, this middleware should live at the top of the middleware stack.
-type InstrumentationMiddleware struct {
+type MetricsMiddleware struct {
pluginMetrics
pluginRegistry registry.Service
next plugins.Client
}
-func newInstrumentationMiddleware(promRegisterer prometheus.Registerer, pluginRegistry registry.Service) *InstrumentationMiddleware {
+func newMetricsMiddleware(promRegisterer prometheus.Registerer, pluginRegistry registry.Service) *MetricsMiddleware {
pluginRequestCounter := prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: "grafana",
Name: "plugin_request_total",
@@ -64,7 +61,7 @@ func newInstrumentationMiddleware(promRegisterer prometheus.Registerer, pluginRe
pluginRequestSize,
pluginRequestDurationSeconds,
)
- return &InstrumentationMiddleware{
+ return &MetricsMiddleware{
pluginMetrics: pluginMetrics{
pluginRequestCounter: pluginRequestCounter,
pluginRequestDuration: pluginRequestDuration,
@@ -75,9 +72,9 @@ func newInstrumentationMiddleware(promRegisterer prometheus.Registerer, pluginRe
}
}
-// NewInstrumentationMiddleware returns a new InstrumentationMiddleware.
-func NewInstrumentationMiddleware(promRegisterer prometheus.Registerer, pluginRegistry registry.Service) plugins.ClientMiddleware {
- imw := newInstrumentationMiddleware(promRegisterer, pluginRegistry)
+// NewMetricsMiddleware returns a new MetricsMiddleware.
+func NewMetricsMiddleware(promRegisterer prometheus.Registerer, pluginRegistry registry.Service) plugins.ClientMiddleware {
+ imw := newMetricsMiddleware(promRegisterer, pluginRegistry)
return plugins.ClientMiddlewareFunc(func(next plugins.Client) plugins.Client {
imw.next = next
return imw
@@ -85,7 +82,7 @@ func NewInstrumentationMiddleware(promRegisterer prometheus.Registerer, pluginRe
}
// pluginTarget returns the value for the "target" Prometheus label for the given plugin ID.
-func (m *InstrumentationMiddleware) pluginTarget(ctx context.Context, pluginID string) (string, error) {
+func (m *MetricsMiddleware) pluginTarget(ctx context.Context, pluginID string) (string, error) {
p, exists := m.pluginRegistry.Plugin(ctx, pluginID)
if !exists {
return "", plugins.ErrPluginNotRegistered
@@ -93,21 +90,8 @@ func (m *InstrumentationMiddleware) pluginTarget(ctx context.Context, pluginID s
return string(p.Target()), nil
}
-// instrumentContext adds a contextual logger with plugin and request details to the given context.
-func instrumentContext(ctx context.Context, endpoint string, pCtx backend.PluginContext) context.Context {
- p := []any{"endpoint", endpoint, "pluginId", pCtx.PluginID}
- if pCtx.DataSourceInstanceSettings != nil {
- p = append(p, "dsName", pCtx.DataSourceInstanceSettings.Name)
- p = append(p, "dsUID", pCtx.DataSourceInstanceSettings.UID)
- }
- if pCtx.User != nil {
- p = append(p, "uname", pCtx.User.Login)
- }
- return log.WithContextualAttributes(ctx, p)
-}
-
// instrumentPluginRequestSize tracks the size of the given request in the m.pluginRequestSize metric.
-func (m *InstrumentationMiddleware) instrumentPluginRequestSize(ctx context.Context, pluginCtx backend.PluginContext, endpoint string, requestSize float64) error {
+func (m *MetricsMiddleware) instrumentPluginRequestSize(ctx context.Context, pluginCtx backend.PluginContext, endpoint string, requestSize float64) error {
target, err := m.pluginTarget(ctx, pluginCtx.PluginID)
if err != nil {
return err
@@ -117,7 +101,7 @@ func (m *InstrumentationMiddleware) instrumentPluginRequestSize(ctx context.Cont
}
// instrumentPluginRequest increments the m.pluginRequestCounter metric and tracks the duration of the given request.
-func (m *InstrumentationMiddleware) instrumentPluginRequest(ctx context.Context, pluginCtx backend.PluginContext, endpoint string, fn func(context.Context) error) error {
+func (m *MetricsMiddleware) instrumentPluginRequest(ctx context.Context, pluginCtx backend.PluginContext, endpoint string, fn func(context.Context) error) error {
target, err := m.pluginTarget(ctx, pluginCtx.PluginID)
if err != nil {
return err
@@ -126,7 +110,6 @@ func (m *InstrumentationMiddleware) instrumentPluginRequest(ctx context.Context,
status := statusOK
start := time.Now()
- ctx = instrumentContext(ctx, endpoint, pluginCtx)
err = fn(ctx)
if err != nil {
status = statusError
@@ -158,7 +141,7 @@ func (m *InstrumentationMiddleware) instrumentPluginRequest(ctx context.Context,
return err
}
-func (m *InstrumentationMiddleware) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
+func (m *MetricsMiddleware) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
var requestSize float64
for _, v := range req.Queries {
requestSize += float64(len(v.JSON))
@@ -174,7 +157,7 @@ func (m *InstrumentationMiddleware) QueryData(ctx context.Context, req *backend.
return resp, err
}
-func (m *InstrumentationMiddleware) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
+func (m *MetricsMiddleware) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
if err := m.instrumentPluginRequestSize(ctx, req.PluginContext, endpointCallResource, float64(len(req.Body))); err != nil {
return err
}
@@ -183,7 +166,7 @@ func (m *InstrumentationMiddleware) CallResource(ctx context.Context, req *backe
})
}
-func (m *InstrumentationMiddleware) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
+func (m *MetricsMiddleware) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
var result *backend.CheckHealthResult
err := m.instrumentPluginRequest(ctx, req.PluginContext, endpointCheckHealth, func(ctx context.Context) (innerErr error) {
result, innerErr = m.next.CheckHealth(ctx, req)
@@ -192,7 +175,7 @@ func (m *InstrumentationMiddleware) CheckHealth(ctx context.Context, req *backen
return result, err
}
-func (m *InstrumentationMiddleware) CollectMetrics(ctx context.Context, req *backend.CollectMetricsRequest) (*backend.CollectMetricsResult, error) {
+func (m *MetricsMiddleware) CollectMetrics(ctx context.Context, req *backend.CollectMetricsRequest) (*backend.CollectMetricsResult, error) {
var result *backend.CollectMetricsResult
err := m.instrumentPluginRequest(ctx, req.PluginContext, endpointCollectMetrics, func(ctx context.Context) (innerErr error) {
result, innerErr = m.next.CollectMetrics(ctx, req)
@@ -201,14 +184,14 @@ func (m *InstrumentationMiddleware) CollectMetrics(ctx context.Context, req *bac
return result, err
}
-func (m *InstrumentationMiddleware) SubscribeStream(ctx context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) {
+func (m *MetricsMiddleware) SubscribeStream(ctx context.Context, req *backend.SubscribeStreamRequest) (*backend.SubscribeStreamResponse, error) {
return m.next.SubscribeStream(ctx, req)
}
-func (m *InstrumentationMiddleware) PublishStream(ctx context.Context, req *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) {
+func (m *MetricsMiddleware) PublishStream(ctx context.Context, req *backend.PublishStreamRequest) (*backend.PublishStreamResponse, error) {
return m.next.PublishStream(ctx, req)
}
-func (m *InstrumentationMiddleware) RunStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender) error {
+func (m *MetricsMiddleware) RunStream(ctx context.Context, req *backend.RunStreamRequest, sender *backend.StreamSender) error {
return m.next.RunStream(ctx, req, sender)
}
diff --git a/pkg/services/pluginsintegration/clientmiddleware/instrumentation_middleware_test.go b/pkg/services/pluginsintegration/clientmiddleware/metrics_middleware_test.go
similarity index 98%
rename from pkg/services/pluginsintegration/clientmiddleware/instrumentation_middleware_test.go
rename to pkg/services/pluginsintegration/clientmiddleware/metrics_middleware_test.go
index 38dfea37df2..f9321fb22e3 100644
--- a/pkg/services/pluginsintegration/clientmiddleware/instrumentation_middleware_test.go
+++ b/pkg/services/pluginsintegration/clientmiddleware/metrics_middleware_test.go
@@ -75,7 +75,7 @@ func TestInstrumentationMiddleware(t *testing.T) {
JSONData: plugins.JSONData{ID: pluginID, Backend: true},
}))
- mw := newInstrumentationMiddleware(promRegistry, pluginsRegistry)
+ mw := newMetricsMiddleware(promRegistry, pluginsRegistry)
cdt := clienttest.NewClientDecoratorTest(t, clienttest.WithMiddlewares(
plugins.ClientMiddlewareFunc(func(next plugins.Client) plugins.Client {
mw.next = next
diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go
index d062955e7fa..c4fda2f7b14 100644
--- a/pkg/services/pluginsintegration/loader/loader_test.go
+++ b/pkg/services/pluginsintegration/loader/loader_test.go
@@ -1335,8 +1335,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
},
Screenshots: []plugins.Screenshots{},
Description: "Grafana App Plugin Template",
- Version: "%VERSION%",
- Updated: "%TODAY%",
+ Version: "",
+ Updated: "",
},
Dependencies: plugins.Dependencies{
GrafanaVersion: "7.0.0",
@@ -1415,8 +1415,8 @@ func TestLoader_Load_NestedPlugins(t *testing.T) {
},
Screenshots: []plugins.Screenshots{},
Description: "Grafana Panel Plugin Template",
- Version: "%VERSION%",
- Updated: "%TODAY%",
+ Version: "",
+ Updated: "",
},
Dependencies: plugins.Dependencies{
GrafanaDependency: ">=7.0.0",
diff --git a/pkg/services/pluginsintegration/pluginsintegration.go b/pkg/services/pluginsintegration/pluginsintegration.go
index cc09121be9f..7114142b66b 100644
--- a/pkg/services/pluginsintegration/pluginsintegration.go
+++ b/pkg/services/pluginsintegration/pluginsintegration.go
@@ -156,7 +156,8 @@ func CreateMiddlewares(cfg *setting.Cfg, oAuthTokenService oauthtoken.OAuthToken
skipCookiesNames := []string{cfg.LoginCookieName}
middlewares := []plugins.ClientMiddleware{
clientmiddleware.NewTracingMiddleware(tracer),
- clientmiddleware.NewInstrumentationMiddleware(promRegisterer, registry),
+ clientmiddleware.NewMetricsMiddleware(promRegisterer, registry),
+ clientmiddleware.NewContextualLoggerMiddleware(),
clientmiddleware.NewLoggerMiddleware(cfg, log.New("plugin.instrumentation")),
clientmiddleware.NewTracingHeaderMiddleware(),
clientmiddleware.NewClearAuthHeadersMiddleware(),
diff --git a/pkg/services/preference/prefapi/api.go b/pkg/services/preference/prefapi/api.go
new file mode 100644
index 00000000000..a77639571eb
--- /dev/null
+++ b/pkg/services/preference/prefapi/api.go
@@ -0,0 +1,107 @@
+// shared logic between httpserver and teamapi
+package prefapi
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/grafana/grafana/pkg/api/dtos"
+ "github.com/grafana/grafana/pkg/api/response"
+ "github.com/grafana/grafana/pkg/kinds/preferences"
+ "github.com/grafana/grafana/pkg/services/dashboards"
+ pref "github.com/grafana/grafana/pkg/services/preference"
+)
+
+func UpdatePreferencesFor(ctx context.Context,
+ dashboardService dashboards.DashboardService, preferenceService pref.Service,
+ orgID, userID, teamId int64, dtoCmd *dtos.UpdatePrefsCmd) response.Response {
+ if dtoCmd.Theme != "" && !pref.IsValidThemeID(dtoCmd.Theme) {
+ return response.Error(http.StatusBadRequest, "Invalid theme", nil)
+ }
+
+ dashboardID := dtoCmd.HomeDashboardID
+ if dtoCmd.HomeDashboardUID != nil {
+ query := dashboards.GetDashboardQuery{UID: *dtoCmd.HomeDashboardUID, OrgID: orgID}
+ if query.UID == "" {
+ // clear the value
+ dashboardID = 0
+ } else {
+ queryResult, err := dashboardService.GetDashboard(ctx, &query)
+ if err != nil {
+ return response.Error(http.StatusNotFound, "Dashboard not found", err)
+ }
+ dashboardID = queryResult.ID
+ }
+ }
+ dtoCmd.HomeDashboardID = dashboardID
+
+ saveCmd := pref.SavePreferenceCommand{
+ UserID: userID,
+ OrgID: orgID,
+ TeamID: teamId,
+ Theme: dtoCmd.Theme,
+ Language: dtoCmd.Language,
+ Timezone: dtoCmd.Timezone,
+ WeekStart: dtoCmd.WeekStart,
+ HomeDashboardID: dtoCmd.HomeDashboardID,
+ QueryHistory: dtoCmd.QueryHistory,
+ CookiePreferences: dtoCmd.Cookies,
+ }
+
+ if err := preferenceService.Save(ctx, &saveCmd); err != nil {
+ return response.ErrOrFallback(http.StatusInternalServerError, "Failed to save preferences", err)
+ }
+
+ return response.Success("Preferences updated")
+}
+
+func GetPreferencesFor(ctx context.Context,
+ dashboardService dashboards.DashboardService, preferenceService pref.Service,
+ orgID, userID, teamID int64) response.Response {
+ prefsQuery := pref.GetPreferenceQuery{UserID: userID, OrgID: orgID, TeamID: teamID}
+
+ preference, err := preferenceService.Get(ctx, &prefsQuery)
+ if err != nil {
+ return response.Error(http.StatusInternalServerError, "Failed to get preferences", err)
+ }
+
+ var dashboardUID string
+
+ // when homedashboardID is 0, that means it is the default home dashboard, no UID would be returned in the response
+ if preference.HomeDashboardID != 0 {
+ query := dashboards.GetDashboardQuery{ID: preference.HomeDashboardID, OrgID: orgID}
+ queryResult, err := dashboardService.GetDashboard(ctx, &query)
+ if err == nil {
+ dashboardUID = queryResult.UID
+ }
+ }
+
+ dto := preferences.Spec{}
+
+ if preference.WeekStart != nil && *preference.WeekStart != "" {
+ dto.WeekStart = preference.WeekStart
+ }
+ if preference.Theme != "" {
+ dto.Theme = &preference.Theme
+ }
+ if dashboardUID != "" {
+ dto.HomeDashboardUID = &dashboardUID
+ }
+ if preference.Timezone != "" {
+ dto.Timezone = &preference.Timezone
+ }
+
+ if preference.JSONData != nil {
+ if preference.JSONData.Language != "" {
+ dto.Language = &preference.JSONData.Language
+ }
+
+ if preference.JSONData.QueryHistory.HomeTab != "" {
+ dto.QueryHistory = &preferences.QueryHistoryPreference{
+ HomeTab: &preference.JSONData.QueryHistory.HomeTab,
+ }
+ }
+ }
+
+ return response.JSON(http.StatusOK, &dto)
+}
diff --git a/pkg/services/publicdashboards/api/api.go b/pkg/services/publicdashboards/api/api.go
index 708918f14f2..8ab2b0172e7 100644
--- a/pkg/services/publicdashboards/api/api.go
+++ b/pkg/services/publicdashboards/api/api.go
@@ -220,10 +220,15 @@ func (api *Api) UpdatePublicDashboard(c *contextmodel.ReqContext) response.Respo
func (api *Api) DeletePublicDashboard(c *contextmodel.ReqContext) response.Response {
uid := web.Params(c.Req)[":uid"]
if !validation.IsValidShortUID(uid) {
- return response.Err(ErrInvalidUid.Errorf("UpdatePublicDashboard: invalid Uid %s", uid))
+ return response.Err(ErrInvalidUid.Errorf("DeletePublicDashboard: invalid Uid %s", uid))
}
- err := api.PublicDashboardService.Delete(c.Req.Context(), uid)
+ dashboardUid := web.Params(c.Req)[":dashboardUid"]
+ if !validation.IsValidShortUID(dashboardUid) {
+ return response.Err(ErrInvalidUid.Errorf("DeletePublicDashboard: invalid dashboard Uid %s", dashboardUid))
+ }
+
+ err := api.PublicDashboardService.Delete(c.Req.Context(), uid, dashboardUid)
if err != nil {
return response.Err(err)
}
diff --git a/pkg/services/publicdashboards/public_dashboard_service_mock.go b/pkg/services/publicdashboards/public_dashboard_service_mock.go
index 4e8e03362dd..949e0d9b3d3 100644
--- a/pkg/services/publicdashboards/public_dashboard_service_mock.go
+++ b/pkg/services/publicdashboards/public_dashboard_service_mock.go
@@ -49,13 +49,13 @@ func (_m *FakePublicDashboardService) Create(ctx context.Context, u *user.Signed
return r0, r1
}
-// Delete provides a mock function with given fields: ctx, uid
-func (_m *FakePublicDashboardService) Delete(ctx context.Context, uid string) error {
- ret := _m.Called(ctx, uid)
+// Delete provides a mock function with given fields: ctx, uid, dashboardUid
+func (_m *FakePublicDashboardService) Delete(ctx context.Context, uid string, dashboardUid string) error {
+ ret := _m.Called(ctx, uid, dashboardUid)
var r0 error
- if rf, ok := ret.Get(0).(func(context.Context, string) error); ok {
- r0 = rf(ctx, uid)
+ if rf, ok := ret.Get(0).(func(context.Context, string, string) error); ok {
+ r0 = rf(ctx, uid, dashboardUid)
} else {
r0 = ret.Error(0)
}
diff --git a/pkg/services/publicdashboards/publicdashboard.go b/pkg/services/publicdashboards/publicdashboard.go
index df070738b07..d6fe478c87b 100644
--- a/pkg/services/publicdashboards/publicdashboard.go
+++ b/pkg/services/publicdashboards/publicdashboard.go
@@ -26,7 +26,7 @@ type Service interface {
Find(ctx context.Context, uid string) (*PublicDashboard, error)
Create(ctx context.Context, u *user.SignedInUser, dto *SavePublicDashboardDTO) (*PublicDashboard, error)
Update(ctx context.Context, u *user.SignedInUser, dto *SavePublicDashboardDTO) (*PublicDashboard, error)
- Delete(ctx context.Context, uid string) error
+ Delete(ctx context.Context, uid string, dashboardUid string) error
DeleteByDashboard(ctx context.Context, dashboard *dashboards.Dashboard) error
GetMetricRequest(ctx context.Context, dashboard *dashboards.Dashboard, publicDashboard *PublicDashboard, panelId int64, reqDTO PublicDashboardQueryDTO) (dtos.MetricRequest, error)
diff --git a/pkg/services/publicdashboards/service/service.go b/pkg/services/publicdashboards/service/service.go
index a091e33931b..b67c9b2a2cc 100644
--- a/pkg/services/publicdashboards/service/service.go
+++ b/pkg/services/publicdashboards/service/service.go
@@ -332,7 +332,20 @@ func (pd *PublicDashboardServiceImpl) GetOrgIdByAccessToken(ctx context.Context,
return pd.store.GetOrgIdByAccessToken(ctx, accessToken)
}
-func (pd *PublicDashboardServiceImpl) Delete(ctx context.Context, uid string) error {
+func (pd *PublicDashboardServiceImpl) Delete(ctx context.Context, uid string, dashboardUid string) error {
+ // get existing public dashboard if exists
+ existingPubdash, err := pd.store.Find(ctx, uid)
+ if err != nil {
+ return ErrInternalServerError.Errorf("Delete: failed to find public dashboard by uid: %s: %w", uid, err)
+ }
+ if existingPubdash == nil {
+ return ErrPublicDashboardNotFound.Errorf("Delete: public dashboard not found by uid: %s", uid)
+ }
+
+ // validate the public dashboard belongs to the dashboard
+ if existingPubdash.DashboardUid != dashboardUid {
+ return ErrInvalidUid.Errorf("Delete: the public dashboard does not belong to the dashboard")
+ }
return pd.serviceWrapper.Delete(ctx, uid)
}
diff --git a/pkg/services/publicdashboards/service/service_test.go b/pkg/services/publicdashboards/service/service_test.go
index dd2fa4fe6f4..4a85e60c020 100644
--- a/pkg/services/publicdashboards/service/service_test.go
+++ b/pkg/services/publicdashboards/service/service_test.go
@@ -1295,36 +1295,64 @@ func assertOldValueIfNull(t *testing.T, expectedValue bool, oldValue bool, nulla
}
func TestDeletePublicDashboard(t *testing.T) {
- testCases := []struct {
- Name string
+ pubdash := &PublicDashboard{Uid: "2", OrgId: 1, DashboardUid: "uid"}
+
+ type mockFindResponse struct {
+ PublicDashboard *PublicDashboard
+ Err error
+ }
+
+ type mockDeleteResponse struct {
AffectedRowsResp int64
- ExpectedErrResp error
StoreRespErr error
+ }
+
+ testCases := []struct {
+ Name string
+ ExpectedErrResp error
+ mockFindStore *mockFindResponse
+ mockDeleteStore *mockDeleteResponse
}{
{
- Name: "Successfully deletes a public dashboards",
- AffectedRowsResp: 1,
- ExpectedErrResp: nil,
- StoreRespErr: nil,
+ Name: "Successfully deletes a public dashboard",
+ ExpectedErrResp: nil,
+ mockFindStore: &mockFindResponse{pubdash, nil},
+ mockDeleteStore: &mockDeleteResponse{1, nil},
},
{
- Name: "Public dashboard not found",
- AffectedRowsResp: 0,
- ExpectedErrResp: nil,
- StoreRespErr: nil,
+ Name: "Public dashboard not found",
+ ExpectedErrResp: ErrInternalServerError.Errorf("Delete: failed to find public dashboard by uid: pubdashUID: error"),
+ mockFindStore: &mockFindResponse{pubdash, errors.New("error")},
+ mockDeleteStore: &mockDeleteResponse{0, nil},
},
{
- Name: "Database error",
- AffectedRowsResp: 0,
- ExpectedErrResp: ErrInternalServerError.Errorf("Delete: failed to delete a public dashboard by Uid: uid db error!"),
- StoreRespErr: errors.New("db error!"),
+ Name: "Public dashboard not found by UID",
+ ExpectedErrResp: ErrPublicDashboardNotFound.Errorf("Delete: public dashboard not found by uid: pubdashUID"),
+ mockFindStore: &mockFindResponse{nil, nil},
+ mockDeleteStore: &mockDeleteResponse{0, nil},
+ },
+ {
+ Name: "Public dashboard UID does not belong to the dashboard",
+ ExpectedErrResp: ErrInvalidUid.Errorf("Delete: the public dashboard does not belong to the dashboard"),
+ mockFindStore: &mockFindResponse{&PublicDashboard{Uid: "2", OrgId: 1, DashboardUid: "wrong"}, nil},
+ mockDeleteStore: &mockDeleteResponse{0, nil},
+ },
+
+ {
+ Name: "Failed to delete - Database error",
+ ExpectedErrResp: ErrInternalServerError.Errorf("Delete: failed to delete a public dashboard by Uid: pubdashUID db error!"),
+ mockFindStore: &mockFindResponse{pubdash, nil},
+ mockDeleteStore: &mockDeleteResponse{1, errors.New("db error!")},
},
}
for _, tt := range testCases {
t.Run(tt.Name, func(t *testing.T) {
store := NewFakePublicDashboardStore(t)
- store.On("Delete", mock.Anything, mock.Anything).Return(tt.AffectedRowsResp, tt.StoreRespErr)
+ store.On("Find", mock.Anything, mock.Anything).Return(tt.mockFindStore.PublicDashboard, tt.mockFindStore.Err)
+ if tt.ExpectedErrResp == nil || tt.mockDeleteStore.StoreRespErr != nil {
+ store.On("Delete", mock.Anything, mock.Anything).Return(tt.mockDeleteStore.AffectedRowsResp, tt.mockDeleteStore.StoreRespErr)
+ }
serviceWrapper := &PublicDashboardServiceWrapperImpl{
log: log.New("test.logger"),
store: store,
@@ -1335,10 +1363,9 @@ func TestDeletePublicDashboard(t *testing.T) {
serviceWrapper: serviceWrapper,
}
- err := service.Delete(context.Background(), "uid")
+ err := service.Delete(context.Background(), "pubdashUID", "uid")
if tt.ExpectedErrResp != nil {
assert.Equal(t, tt.ExpectedErrResp.Error(), err.Error())
- assert.Equal(t, tt.ExpectedErrResp.Error(), err.Error())
} else {
assert.NoError(t, err)
}
diff --git a/pkg/services/quota/quotaimpl/quota_test.go b/pkg/services/quota/quotaimpl/quota_test.go
index 9a3a1cf7d03..42960015213 100644
--- a/pkg/services/quota/quotaimpl/quota_test.go
+++ b/pkg/services/quota/quotaimpl/quota_test.go
@@ -98,6 +98,7 @@ func TestIntegrationQuotaCommandsAndQueries(t *testing.T) {
u, err := userService.Create(context.Background(), &user.CreateUserCommand{
Name: "TestUser",
+ Login: "TestUser",
SkipOrgSetup: true,
})
require.NoError(t, err)
diff --git a/pkg/services/signingkeys/signingkeys.go b/pkg/services/signingkeys/signingkeys.go
index bbb7ceba529..f5678829886 100644
--- a/pkg/services/signingkeys/signingkeys.go
+++ b/pkg/services/signingkeys/signingkeys.go
@@ -10,6 +10,7 @@ package signingkeys
import (
"context"
"crypto"
+ "time"
"github.com/go-jose/go-jose/v3"
)
@@ -26,3 +27,11 @@ type Service interface {
GetJWKS(ctx context.Context) (jose.JSONWebKeySet, error)
GetOrCreatePrivateKey(ctx context.Context, keyPrefix string, alg jose.SignatureAlgorithm) (string, crypto.Signer, error)
}
+
+type SigningKey struct {
+ KeyID string `xorm:"key_id"`
+ PrivateKey []byte `xorm:"private_key"`
+ AddedAt time.Time `xorm:"added_at"`
+ ExpiresAt *time.Time `xorm:"expires_at"`
+ Alg jose.SignatureAlgorithm `xorm:"alg"`
+}
diff --git a/pkg/services/signingkeys/signingkeysimpl/service.go b/pkg/services/signingkeys/signingkeysimpl/service.go
index 5e314d7edd0..500f0606dd9 100644
--- a/pkg/services/signingkeys/signingkeysimpl/service.go
+++ b/pkg/services/signingkeys/signingkeysimpl/service.go
@@ -6,7 +6,10 @@ import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
+ "crypto/x509"
+ "encoding/base64"
"encoding/json"
+ "encoding/pem"
"errors"
"net/http"
"strings"
@@ -17,6 +20,7 @@ import (
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/db"
+ "github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/remotecache"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
@@ -31,9 +35,11 @@ func ProvideEmbeddedSigningKeysService(dbStore db.DB, secretsService secrets.Ser
remoteCache remotecache.CacheStorage, routerRegister routing.RouteRegister,
) (*Service, error) {
s := &Service{
- log: log.New("auth.key_service"),
- store: signingkeystore.NewSigningKeyStore(dbStore, secretsService),
- remoteCache: remoteCache,
+ log: log.New("auth.key_service"),
+ store: signingkeystore.NewSigningKeyStore(dbStore),
+ secretsService: secretsService,
+ remoteCache: remoteCache,
+ localCache: localcache.New(1*time.Hour, 1*time.Hour),
}
s.registerAPIEndpoints(routerRegister)
@@ -46,14 +52,17 @@ func ProvideEmbeddedSigningKeysService(dbStore db.DB, secretsService secrets.Ser
//
// The service is under active development and is not yet ready for production use.
type Service struct {
- log log.Logger
- store signingkeystore.SigningStore
- remoteCache remotecache.CacheStorage
+ log log.Logger
+ store signingkeystore.SigningStore
+ secretsService secrets.Service
+ remoteCache remotecache.CacheStorage
+ localCache *localcache.CacheService
}
const (
jwksCacheKey = "signingkeys-jwks"
- defaultExpiry = 12 * time.Hour
+ jwksTTL = 12 * time.Hour
+ privateKeyTTL = 60 * time.Second
)
// GetJWKS returns the JSON Web Key Set (JWKS) with all the keys that can be used to verify tokens (public keys)
@@ -66,15 +75,20 @@ func (s *Service) GetJWKS(ctx context.Context) (jose.JSONWebKeySet, error) {
}
}
- jwks, err := s.store.GetJWKS(ctx)
+ keys, err := s.store.List(ctx)
if err != nil {
return jose.JSONWebKeySet{}, err
}
+ jwks, err := s.buildJWKS(ctx, keys)
+ if err != nil {
+ return jwks, err
+ }
+
// cache jwks
jwksBytes, err := json.Marshal(jwks)
if err == nil {
- if err := s.remoteCache.Set(ctx, jwksCacheKey, jwksBytes, defaultExpiry); err != nil {
+ if err := s.remoteCache.Set(ctx, jwksCacheKey, jwksBytes, jwksTTL); err != nil {
s.log.Warn("Failed to cache JWKS", "err", err)
}
}
@@ -82,6 +96,24 @@ func (s *Service) GetJWKS(ctx context.Context) (jose.JSONWebKeySet, error) {
return jwks, err
}
+func (s *Service) buildJWKS(ctx context.Context, keys []signingkeys.SigningKey) (jose.JSONWebKeySet, error) {
+ var jwks jose.JSONWebKeySet
+ for _, key := range keys {
+ assertedKey, err := s.decodePrivateKey(ctx, key.PrivateKey)
+ if err != nil {
+ return jwks, err
+ }
+
+ jwks.Keys = append(jwks.Keys, jose.JSONWebKey{
+ Key: assertedKey.Public(),
+ Algorithm: string(key.Alg),
+ KeyID: key.KeyID,
+ Use: "sig",
+ })
+ }
+ return jwks, nil
+}
+
// GetOrCreatePrivateKey returns the private key with the specified key ID. If the key does not exist, it will be
// created with the specified algorithm.
// The key will be automatically rotated at the beginning of each month. The previous key will be kept for 30 days.
@@ -93,30 +125,146 @@ func (s *Service) GetOrCreatePrivateKey(ctx context.Context,
}
keyID := keyMonthScopedID(keyPrefix, alg)
- signer, err := s.store.GetPrivateKey(ctx, keyID)
+ signer, err := s.getPrivateKey(ctx, keyID)
if err == nil {
return keyID, signer, nil
}
+
+ // we only want to create a new signing key if none exits for keyID
+ if !errors.Is(err, signingkeys.ErrSigningKeyNotFound) {
+ return "", nil, err
+ }
+
s.log.Debug("Private key not found, generating new key", "keyID", keyID, "err", err)
+ signer, err = s.addPrivateKey(ctx, keyID, alg, false)
+ if err != nil {
+ return "", nil, err
+ }
+
+ return keyID, signer, nil
+}
+
+func (s *Service) getPrivateKey(ctx context.Context, keyID string) (crypto.Signer, error) {
+ if key, ok := s.localCache.Get(keyID); ok {
+ return key.(crypto.Signer), nil
+ }
+
+ key, err := s.store.Get(ctx, keyID)
+ if err != nil {
+ return nil, err
+ }
+
+ singer, err := s.decodePrivateKey(ctx, key.PrivateKey)
+ if err != nil {
+ return nil, err
+ }
+
+ s.localCache.Set(keyID, singer, privateKeyTTL)
+ return singer, nil
+}
+
+func (s *Service) addPrivateKey(ctx context.Context, keyID string, alg jose.SignatureAlgorithm, force bool) (crypto.Signer, error) {
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
s.log.Error("Error generating private key", "err", err)
- return "", nil, signingkeys.ErrKeyGenerationFailed.Errorf("Error generating private key: %v", err)
+ return nil, signingkeys.ErrKeyGenerationFailed.Errorf("Error generating private key: %v", err)
+ }
+
+ encoded, err := s.encodePrivateKey(ctx, privateKey)
+ if err != nil {
+ s.log.Error("Error encoding private key", "err", err)
+ return nil, err
}
expiry := time.Now().Add(30 * 24 * time.Hour)
- if signer, err = s.store.AddPrivateKey(ctx, keyID, alg, privateKey, &expiry, false); err != nil && !errors.Is(err, signingkeys.ErrSigningKeyAlreadyExists) {
- return "", nil, err
+ key, err := s.store.Add(ctx, &signingkeys.SigningKey{
+ KeyID: keyID,
+ PrivateKey: encoded,
+ ExpiresAt: &expiry,
+ Alg: alg,
+ }, force)
+
+ if err != nil && !errors.Is(err, signingkeys.ErrSigningKeyAlreadyExists) {
+ return nil, err
}
+ signer, err := s.decodePrivateKey(ctx, key.PrivateKey)
+ if err != nil {
+ return nil, err
+ }
+
+ // invalidate local cache
+ s.localCache.Delete(keyID)
+
// invalidate cache
if err := s.remoteCache.Delete(ctx, jwksCacheKey); err != nil {
// not a critical error, key might not be in cache
s.log.Debug("Failed to invalidate JWKS cache", "err", err)
}
- return keyID, signer, nil
+ return signer, nil
+}
+
+func (s *Service) encodePrivateKey(ctx context.Context, privateKey crypto.Signer) ([]byte, error) {
+ // Encode private key to binary format
+ pKeyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
+ if err != nil {
+ return nil, err
+ }
+
+ // Encode private key to PEM format
+ privateKeyPEM := pem.EncodeToMemory(&pem.Block{
+ Type: "PRIVATE KEY",
+ Bytes: pKeyBytes,
+ })
+
+ encrypted, err := s.secretsService.Encrypt(ctx, privateKeyPEM, secrets.WithoutScope())
+ if err != nil {
+ return nil, err
+ }
+
+ encoded := make([]byte, base64.StdEncoding.EncodedLen(len(encrypted)))
+ base64.StdEncoding.Encode(encoded, encrypted)
+ return encoded, nil
+}
+
+func (s *Service) decodePrivateKey(ctx context.Context, privateKey []byte) (crypto.Signer, error) {
+ // Bail out if empty string since it'll cause a segfault in Decrypt
+ if len(privateKey) == 0 {
+ return nil, errors.New("private key is empty")
+ }
+
+ payload := make([]byte, base64.StdEncoding.DecodedLen(len(privateKey)))
+ _, err := base64.StdEncoding.Decode(payload, privateKey)
+ if err != nil {
+ return nil, err
+ }
+
+ decrypted, err := s.secretsService.Decrypt(ctx, payload)
+ if err != nil {
+ return nil, err
+ }
+
+ block, _ := pem.Decode(decrypted)
+ if block == nil {
+ return nil, errors.New("failed to decode private key PEM")
+ }
+
+ if block.Type != "PRIVATE KEY" {
+ return nil, errors.New("invalid block type")
+ }
+
+ parsedKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+ if err != nil {
+ return nil, err
+ }
+
+ assertedKey, ok := parsedKey.(crypto.Signer)
+ if !ok {
+ return nil, errors.New("failed to assert private key as crypto.Signer")
+ }
+ return assertedKey, nil
}
func keyMonthScopedID(keyPrefix string, alg jose.SignatureAlgorithm) string {
diff --git a/pkg/services/signingkeys/signingkeysimpl/service_test.go b/pkg/services/signingkeys/signingkeysimpl/service_test.go
index aac3953bc82..8bca186d0b9 100644
--- a/pkg/services/signingkeys/signingkeysimpl/service_test.go
+++ b/pkg/services/signingkeys/signingkeysimpl/service_test.go
@@ -13,13 +13,15 @@ import (
"time"
"github.com/go-jose/go-jose/v3"
+ "github.com/grafana/grafana/pkg/services/signingkeys"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/api/routing"
+ "github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/remotecache"
- "github.com/grafana/grafana/pkg/services/signingkeys"
+ secretstest "github.com/grafana/grafana/pkg/services/secrets/fakes"
"github.com/grafana/grafana/pkg/services/signingkeys/signingkeystore"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/web/webtest"
@@ -32,28 +34,29 @@ ielIkb6/Ys51o7KjHxtANhPesw==
-----END PRIVATE KEY-----`
)
-func getPrivateKey(t *testing.T) *ecdsa.PrivateKey {
+func getPrivateKey(t *testing.T, svc *Service) []byte {
pemBlock, _ := pem.Decode([]byte(privateKeyPem))
privateKey, err := x509.ParsePKCS8PrivateKey(pemBlock.Bytes)
require.NoError(t, err)
- return privateKey.(*ecdsa.PrivateKey)
+
+ bytes, err := svc.encodePrivateKey(context.Background(), privateKey.(*ecdsa.PrivateKey))
+ require.NoError(t, err)
+ return bytes
}
func TestEmbeddedKeyService_GetJWKS_OnlyPublicKeyShared(t *testing.T) {
- mockStore := signingkeystore.NewFakeStore()
- cacheStorage := remotecache.NewFakeCacheStorage()
-
- _, err := mockStore.AddPrivateKey(context.Background(), signingkeys.ServerPrivateKeyID, jose.ES256, getPrivateKey(t), nil, false)
- require.NoError(t, err)
-
- _, err = mockStore.AddPrivateKey(context.Background(), "other", jose.ES256, getPrivateKey(t), nil, false)
- require.NoError(t, err)
-
svc := &Service{
- log: log.NewNopLogger(),
- store: mockStore,
- remoteCache: cacheStorage,
+ log: log.NewNopLogger(),
+ store: signingkeystore.NewFakeStore(),
+ secretsService: secretstest.NewFakeSecretsService(),
+ remoteCache: remotecache.NewFakeCacheStorage(),
+ localCache: localcache.New(privateKeyTTL, 10*time.Hour),
}
+
+ _, _, err := svc.GetOrCreatePrivateKey(context.Background(), "key-1", jose.ES256)
+ require.NoError(t, err)
+ _, _, err = svc.GetOrCreatePrivateKey(context.Background(), "key-2", jose.ES256)
+ require.NoError(t, err)
jwks, err := svc.GetJWKS(context.Background())
require.NoError(t, err)
@@ -80,13 +83,13 @@ func TestEmbeddedKeyService_GetJWKS_OnlyPublicKeyShared(t *testing.T) {
}
func TestEmbeddedKeyService_GetOrCreatePrivateKey(t *testing.T) {
- mockStore := signingkeystore.NewFakeStore()
-
cacheStorage := remotecache.NewFakeCacheStorage()
svc := &Service{
- log: log.NewNopLogger(),
- store: mockStore,
- remoteCache: cacheStorage,
+ log: log.NewNopLogger(),
+ store: signingkeystore.NewFakeStore(),
+ secretsService: secretstest.NewFakeSecretsService(),
+ remoteCache: cacheStorage,
+ localCache: localcache.New(privateKeyTTL, 10*time.Hour),
}
wantedKeyID := keyMonthScopedID("test", jose.ES256)
@@ -110,7 +113,6 @@ func TestEmbeddedKeyService_GetOrCreatePrivateKey(t *testing.T) {
// new key is generated, so jwks cache should be voided
require.Len(t, cacheStorage.Storage, 0)
- assert.Contains(t, mockStore.PrivateKeys, wantedKeyID)
err = cacheStorage.Set(context.Background(), jwksCacheKey, []byte("invalid"), 0)
require.NoError(t, err)
@@ -122,7 +124,6 @@ func TestEmbeddedKeyService_GetOrCreatePrivateKey(t *testing.T) {
require.Equal(t, key, key2)
require.Equal(t, wantedKeyID, id)
- assert.Len(t, mockStore.PrivateKeys, 1)
// no new key is generated, so jwks cache should not be voided
require.Len(t, cacheStorage.Storage, 1)
}
@@ -132,9 +133,11 @@ func TestExposeJWKS(t *testing.T) {
mockStore := signingkeystore.NewFakeStore()
cacheStorage := remotecache.NewFakeCacheStorage()
svc := &Service{
- log: log.NewNopLogger(),
- store: mockStore,
- remoteCache: cacheStorage,
+ log: log.NewNopLogger(),
+ store: mockStore,
+ remoteCache: cacheStorage,
+ secretsService: secretstest.NewFakeSecretsService(),
+ localCache: localcache.New(privateKeyTTL, 10*time.Hour),
}
routerRegister := routing.NewRouteRegister()
@@ -142,8 +145,13 @@ func TestExposeJWKS(t *testing.T) {
svc.registerAPIEndpoints(routerRegister)
server := webtest.NewServer(t, routerRegister)
+ _, err := mockStore.Add(context.Background(), &signingkeys.SigningKey{
+ KeyID: "test-key",
+ PrivateKey: getPrivateKey(t, svc),
+ AddedAt: time.Now(),
+ Alg: jose.ES256,
+ }, false)
- _, err := mockStore.AddPrivateKey(context.Background(), "test-key", jose.ES256, getPrivateKey(t), nil, false)
require.NoError(t, err)
// create a new request context
diff --git a/pkg/services/signingkeys/signingkeystest/fake.go b/pkg/services/signingkeys/signingkeystest/fake.go
index 48620bfc0b1..3f7e23634a4 100644
--- a/pkg/services/signingkeys/signingkeystest/fake.go
+++ b/pkg/services/signingkeys/signingkeystest/fake.go
@@ -3,15 +3,14 @@ package signingkeystest
import (
"context"
"crypto"
- "time"
"github.com/go-jose/go-jose/v3"
)
type FakeSigningKeysService struct {
ExpectedJSONWebKeySet jose.JSONWebKeySet
- ExpectedJSONWebKey jose.JSONWebKey
- ExpectedKeys map[string]crypto.Signer
+ ExpectedKeyID string
+ ExpectedSinger crypto.Signer
ExpectedError error
}
@@ -19,30 +18,6 @@ func (s *FakeSigningKeysService) GetJWKS(ctx context.Context) (jose.JSONWebKeySe
return s.ExpectedJSONWebKeySet, nil
}
-// GetPublicKey returns the public key with the specified key ID
-func (s *FakeSigningKeysService) GetPublicKey(ctx context.Context, keyID string) (crypto.PublicKey, error) {
- return s.ExpectedKeys[keyID].Public(), s.ExpectedError
-}
-
-// GetPrivateKey returns the private key with the specified key ID
-func (s *FakeSigningKeysService) GetPrivateKey(ctx context.Context, keyID string) (crypto.PrivateKey, error) {
- return s.ExpectedKeys[keyID], s.ExpectedError
-}
-
-// AddPrivateKey adds a private key to the service
-func (s *FakeSigningKeysService) AddPrivateKey(ctx context.Context, keyID string,
- privateKey crypto.Signer, alg jose.SignatureAlgorithm, expiresAt *time.Time, force bool) error {
- if s.ExpectedError != nil {
- return s.ExpectedError
- }
- s.ExpectedKeys[keyID] = privateKey
- return nil
-}
-
-func (s *FakeSigningKeysService) GetOrCreatePrivateKey(ctx context.Context,
- keyPrefix string, alg jose.SignatureAlgorithm) (string, crypto.Signer, error) {
- if s.ExpectedError != nil {
- return "", nil, s.ExpectedError
- }
- return keyPrefix, s.ExpectedKeys[keyPrefix], nil
+func (s *FakeSigningKeysService) GetOrCreatePrivateKey(ctx context.Context, keyPrefix string, alg jose.SignatureAlgorithm) (string, crypto.Signer, error) {
+ return s.ExpectedKeyID, s.ExpectedSinger, s.ExpectedError
}
diff --git a/pkg/services/signingkeys/signingkeystore/fake.go b/pkg/services/signingkeys/signingkeystore/fake.go
index 63fed1841df..b78721353e7 100644
--- a/pkg/services/signingkeys/signingkeystore/fake.go
+++ b/pkg/services/signingkeys/signingkeystore/fake.go
@@ -3,13 +3,15 @@ package signingkeystore
import (
"context"
"crypto"
- "fmt"
- "time"
"github.com/go-jose/go-jose/v3"
+ "github.com/grafana/grafana/pkg/services/signingkeys"
)
+var _ SigningStore = (*FakeStore)(nil)
+
type FakeStore struct {
+ Keys map[string]signingkeys.SigningKey
PrivateKeys map[string]crypto.Signer
jwks jose.JSONWebKeySet
}
@@ -17,46 +19,34 @@ type FakeStore struct {
func NewFakeStore() *FakeStore {
return &FakeStore{
PrivateKeys: make(map[string]crypto.Signer),
+ Keys: make(map[string]signingkeys.SigningKey),
jwks: jose.JSONWebKeySet{},
}
}
-func (s *FakeStore) GetJWKS(ctx context.Context) (jose.JSONWebKeySet, error) {
- return s.jwks, nil
-}
-
-func (s *FakeStore) AddPrivateKey(ctx context.Context, keyID string, alg jose.SignatureAlgorithm,
- privateKey crypto.Signer, expiresAt *time.Time, force bool) (crypto.Signer, error) {
+func (s *FakeStore) Add(ctx context.Context, key *signingkeys.SigningKey, force bool) (*signingkeys.SigningKey, error) {
if !force {
- if key, ok := s.PrivateKeys[keyID]; ok {
- if !hasExpired(key) {
- return nil, fmt.Errorf("key already exists and has not expired")
- }
+ if _, ok := s.Keys[key.KeyID]; ok {
+ return nil, signingkeys.ErrSigningKeyAlreadyExists
}
}
- s.PrivateKeys[keyID] = privateKey
+ s.Keys[key.KeyID] = *key
+ return key, nil
+}
- jwk := jose.JSONWebKey{
- Key: privateKey.Public(),
- Algorithm: string(alg),
- KeyID: keyID,
- Use: "sig",
+func (s *FakeStore) List(ctx context.Context) ([]signingkeys.SigningKey, error) {
+ out := make([]signingkeys.SigningKey, 0, len(s.Keys))
+ for _, key := range s.Keys {
+ out = append(out, key)
+ }
+ return out, nil
+}
+
+func (s *FakeStore) Get(ctx context.Context, keyID string) (*signingkeys.SigningKey, error) {
+ if key, ok := s.Keys[keyID]; ok {
+ return &key, nil
}
- s.jwks.Keys = append(s.jwks.Keys, jwk)
-
- return privateKey, nil
-}
-
-func (s *FakeStore) GetPrivateKey(ctx context.Context, keyID string) (crypto.Signer, error) {
- if key, ok := s.PrivateKeys[keyID]; ok {
- return key, nil
- }
-
- return nil, fmt.Errorf("key not found")
-}
-
-func hasExpired(key crypto.Signer) bool {
- return false
+ return nil, signingkeys.ErrSigningKeyNotFound
}
diff --git a/pkg/services/signingkeys/signingkeystore/store.go b/pkg/services/signingkeys/signingkeystore/store.go
index 0f6f23b3d4f..0c01a2850a1 100644
--- a/pkg/services/signingkeys/signingkeystore/store.go
+++ b/pkg/services/signingkeys/signingkeystore/store.go
@@ -2,11 +2,7 @@ package signingkeystore
import (
"context"
- "crypto"
- "crypto/x509"
"database/sql"
- "encoding/base64"
- "encoding/pem"
"errors"
"time"
@@ -15,21 +11,19 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
- "github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/services/signingkeys"
"github.com/grafana/grafana/pkg/services/sqlstore"
)
type SigningStore interface {
- // GetJWKS returns the JSON Web Key Set for the service
- GetJWKS(ctx context.Context) (jose.JSONWebKeySet, error)
- // AddPrivateKey adds a private key to the service. If the key already exists, it will be updated if force is true.
+ // List returns all non expired keys
+ List(ctx context.Context) ([]signingkeys.SigningKey, error)
+ // Add adds a signing key to the database. If the key already exists, it will be updated if force is true.
// If force is false, the key will only be updated if it has expired. If the key does not exist, it will be added.
- // If expiresAt is nil, the key will not expire. Retrieve the result key with GetPrivateKey.
- AddPrivateKey(ctx context.Context, keyID string, alg jose.SignatureAlgorithm,
- privateKey crypto.Signer, expiresAt *time.Time, force bool) (crypto.Signer, error)
- // GetPrivateKey returns the private key with the specified key ID
- GetPrivateKey(ctx context.Context, keyID string) (crypto.Signer, error)
+ // If expiresAt is nil, the key will not expire. Retrieve the result key with Get.
+ Add(ctx context.Context, key *signingkeys.SigningKey, force bool) (*signingkeys.SigningKey, error)
+ // Get returns the signing key with the specified key ID
+ Get(ctx context.Context, keyID string) (*signingkeys.SigningKey, error)
}
var _ SigningStore = (*Store)(nil)
@@ -37,10 +31,9 @@ var _ SigningStore = (*Store)(nil)
const cleanupRateLimitKey = "signingkeys-cleanup"
type Store struct {
- dbStore db.DB
- secretsService secrets.Service
- log log.Logger
- localCache *localcache.CacheService
+ dbStore db.DB
+ log log.Logger
+ localCache *localcache.CacheService
}
type SigningKey struct {
@@ -52,90 +45,57 @@ type SigningKey struct {
Alg jose.SignatureAlgorithm `json:"alg" xorm:"alg" db:"alg"`
}
-func NewSigningKeyStore(dbStore db.DB, secretsService secrets.Service) *Store {
+func NewSigningKeyStore(dbStore db.DB) *Store {
return &Store{
- dbStore: dbStore,
- secretsService: secretsService,
- log: log.New("signing.key_service"),
- localCache: localcache.New(12*time.Hour, 4*time.Hour),
+ dbStore: dbStore,
+ log: log.New("signing.key_service"),
+ localCache: localcache.New(12*time.Hour, 4*time.Hour),
}
}
-// GetJWKS returns the JSON Web Key Set (JWKS) for the service. Expired keys will not be returned.
-func (s *Store) GetJWKS(ctx context.Context) (jose.JSONWebKeySet, error) {
- keySet := jose.JSONWebKeySet{}
+func (s *Store) List(ctx context.Context) ([]signingkeys.SigningKey, error) {
+ var keys []signingkeys.SigningKey
- keys := []*SigningKey{}
err := s.dbStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
return dbSession.SQL("SELECT * FROM signing_key WHERE expires_at IS NULL OR expires_at > ?", time.Now()).Find(&keys)
})
- if err != nil {
- return keySet, err
- }
-
- for _, key := range keys {
- assertedKey, err := s.decodePrivateKey(ctx, key)
- if err != nil {
- return keySet, err
- }
-
- keySet.Keys = append(keySet.Keys, jose.JSONWebKey{
- Key: assertedKey.Public(),
- Algorithm: string(key.Alg),
- KeyID: key.KeyID,
- Use: "sig",
- })
- }
-
- return keySet, nil
-}
-
-// AddPrivateKey adds a private key to the service.
-func (s *Store) AddPrivateKey(ctx context.Context,
- keyID string, alg jose.SignatureAlgorithm, privateKey crypto.Signer, expiresAt *time.Time, force bool) (crypto.Signer, error) {
- privateKeyPEM, err := s.encodePrivateKey(ctx, privateKey)
if err != nil {
return nil, err
}
- key := &SigningKey{
- KeyID: keyID,
- PrivateKey: privateKeyPEM,
- AddedAt: time.Now(),
- Alg: alg,
- ExpiresAt: expiresAt,
- }
+ return keys, nil
+}
- var signer crypto.Signer
- err = s.dbStore.WithTransactionalDbSession(ctx, func(tx *sqlstore.DBSession) error {
- existingKey := SigningKey{}
- _, err := tx.SQL("SELECT * FROM signing_key WHERE key_id = ?", keyID).Get(&existingKey)
+// Add adds a private key to the service.
+func (s *Store) Add(ctx context.Context, key *signingkeys.SigningKey, force bool) (*signingkeys.SigningKey, error) {
+ var result *signingkeys.SigningKey
+
+ err := s.dbStore.WithTransactionalDbSession(ctx, func(tx *sqlstore.DBSession) error {
+ existingKey := &signingkeys.SigningKey{}
+ exists, err := tx.SQL("SELECT * FROM signing_key WHERE key_id = ?", key.KeyID).Get(existingKey)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
- if len(existingKey.PrivateKey) == 0 {
+ if !exists {
_, err = tx.Exec("INSERT INTO signing_key (key_id, private_key, added_at, alg, expires_at) VALUES (?, ?, ?, ?, ?)",
key.KeyID, key.PrivateKey, key.AddedAt, key.Alg, key.ExpiresAt,
)
- signer = privateKey
+ result = key
return err
}
if force || (existingKey.ExpiresAt != nil && existingKey.ExpiresAt.Before(time.Now())) {
_, err = tx.Exec("UPDATE signing_key SET private_key = ?, added_at = ?, alg = ?, expires_at = ? WHERE key_id = ?",
key.PrivateKey, key.AddedAt, key.Alg, key.ExpiresAt, key.KeyID)
- signer = privateKey
+
+ result = key
return err
}
- signer, err = s.decodePrivateKey(ctx, &existingKey)
- if err != nil {
- return err
- }
-
- return signingkeys.ErrSigningKeyAlreadyExists.Errorf("The specified key already exists: %s", keyID)
+ result = existingKey
+ return signingkeys.ErrSigningKeyAlreadyExists.Errorf("The specified key already exists: %s", existingKey.KeyID)
})
if _, ok := s.localCache.Get(cleanupRateLimitKey); !ok {
@@ -156,14 +116,18 @@ func (s *Store) AddPrivateKey(ctx context.Context,
s.localCache.Set(cleanupRateLimitKey, true, 1*time.Hour)
}
- return signer, err
+ return result, err
}
-// GetPrivateKey returns the private key with the specified key ID. Expired keys will not be returned.
-func (s *Store) GetPrivateKey(ctx context.Context, keyID string) (crypto.Signer, error) {
- key := SigningKey{}
+// Get implements SigningStore.
+func (s *Store) Get(ctx context.Context, keyID string) (*signingkeys.SigningKey, error) {
+ key := signingkeys.SigningKey{}
err := s.dbStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error {
- _, err := dbSession.SQL("SELECT * FROM signing_key WHERE key_id = ?", keyID).Get(&key)
+ exists, err := dbSession.SQL("SELECT * FROM signing_key WHERE key_id = ?", keyID).Get(&key)
+ if !exists {
+ return signingkeys.ErrSigningKeyNotFound.Errorf("The specified key was not found: %s", keyID)
+ }
+
return err
})
@@ -176,73 +140,7 @@ func (s *Store) GetPrivateKey(ctx context.Context, keyID string) (crypto.Signer,
return nil, signingkeys.ErrSigningKeyNotFound.Errorf("The specified key was not found: %s", keyID)
}
- signKey, err := s.decodePrivateKey(ctx, &key)
- if err != nil {
- return nil, err
- }
-
- return signKey, nil
-}
-
-func (s *Store) encodePrivateKey(ctx context.Context, privateKey crypto.Signer) ([]byte, error) {
- // Encode private key to binary format
- pKeyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
- if err != nil {
- return nil, err
- }
-
- // Encode private key to PEM format
- privateKeyPEM := pem.EncodeToMemory(&pem.Block{
- Type: "PRIVATE KEY",
- Bytes: pKeyBytes,
- })
-
- encrypted, err := s.secretsService.Encrypt(ctx, privateKeyPEM, secrets.WithoutScope())
- if err != nil {
- return nil, err
- }
-
- encoded := make([]byte, base64.StdEncoding.EncodedLen(len(encrypted)))
- base64.StdEncoding.Encode(encoded, encrypted)
- return encoded, nil
-}
-
-func (s *Store) decodePrivateKey(ctx context.Context, signingKey *SigningKey) (crypto.Signer, error) {
- // Bail out if empty string since it'll cause a segfault in Decrypt
- if len(signingKey.PrivateKey) == 0 {
- return nil, errors.New("private key is empty")
- }
-
- payload := make([]byte, base64.StdEncoding.DecodedLen(len(signingKey.PrivateKey)))
- _, err := base64.StdEncoding.Decode(payload, signingKey.PrivateKey)
- if err != nil {
- return nil, err
- }
-
- decrypted, err := s.secretsService.Decrypt(ctx, payload)
- if err != nil {
- return nil, err
- }
-
- block, _ := pem.Decode(decrypted)
- if block == nil {
- return nil, errors.New("failed to decode private key PEM")
- }
-
- if block.Type != "PRIVATE KEY" {
- return nil, errors.New("invalid block type")
- }
-
- parsedKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
- if err != nil {
- return nil, err
- }
-
- assertedKey, ok := parsedKey.(crypto.Signer)
- if !ok {
- return nil, errors.New("failed to assert private key as crypto.Signer")
- }
- return assertedKey, nil
+ return &key, nil
}
// cleanupExpiredKeys removes expired keys from the database that have expired more than 61 days ago
diff --git a/pkg/services/signingkeys/signingkeystore/store_test.go b/pkg/services/signingkeys/signingkeystore/store_test.go
index 01375fad4c7..2ddebe006cb 100644
--- a/pkg/services/signingkeys/signingkeystore/store_test.go
+++ b/pkg/services/signingkeys/signingkeystore/store_test.go
@@ -2,204 +2,67 @@ package signingkeystore
import (
"context"
- "crypto"
- "crypto/ecdsa"
- "crypto/elliptic"
- "crypto/rand"
- "crypto/rsa"
"testing"
"time"
- "github.com/go-jose/go-jose/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db"
- "github.com/grafana/grafana/pkg/services/secrets/fakes"
"github.com/grafana/grafana/pkg/services/signingkeys"
)
func TestIntegrationSigningKeyStore(t *testing.T) {
- ctx := context.Background()
-
- testCases := []struct {
- name string
- keyFunc func() (crypto.Signer, error)
- keyID string
- alg jose.SignatureAlgorithm
- expected jose.JSONWebKey
- }{
- {
- name: "RSA key",
- keyFunc: func() (crypto.Signer, error) {
- return rsa.GenerateKey(rand.Reader, 2048)
- },
- keyID: "test-rsa-key",
- alg: jose.RS256,
- expected: jose.JSONWebKey{
- Key: &rsa.PublicKey{},
- Algorithm: "RS256",
- KeyID: "test-rsa-key",
- Use: "sig",
- },
- },
- {
- name: "Elliptic Curve key",
- keyFunc: func() (crypto.Signer, error) {
- return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
- },
- keyID: "test-ec-key",
- alg: jose.ES256,
- expected: jose.JSONWebKey{
- Key: &ecdsa.PublicKey{},
- Algorithm: "ES256",
- KeyID: "test-ec-key",
- Use: "sig",
- },
- },
+ if testing.Short() {
+ t.Skip("skipping integration test")
}
- for _, tc := range testCases {
- dbStore := db.InitTestDB(t)
- secretSvc := fakes.NewFakeSecretsService()
- store := NewSigningKeyStore(dbStore, secretSvc)
+ ctx, store := context.Background(), NewSigningKeyStore(db.InitTestDB(t))
- t.Run(tc.name, func(t *testing.T) {
- key, err := tc.keyFunc()
- assert.NoError(t, err)
+ t.Run("Should successfully add new singing key", func(_ *testing.T) {
+ key, err := store.Add(ctx, &signingkeys.SigningKey{KeyID: "1", AddedAt: time.Now().UTC(), PrivateKey: []byte{}}, false)
+ require.NoError(t, err)
+ assert.Equal(t, "1", key.KeyID)
+ })
- _, err = store.AddPrivateKey(ctx, tc.keyID, tc.alg, key, nil, true)
- assert.NoError(t, err)
+ t.Run("Should return old key if already exists", func(_ *testing.T) {
+ // try to add the same key again with a different AddedAt
+ key2, err := store.Add(ctx, &signingkeys.SigningKey{KeyID: "1", PrivateKey: []byte{}, AddedAt: time.Now().Add(10 * time.Minute).UTC()}, false)
+ require.ErrorIs(t, err, signingkeys.ErrSigningKeyAlreadyExists)
+ assert.Equal(t, "1", key2.KeyID)
+ })
- retrievedKey, err := store.GetPrivateKey(ctx, tc.keyID)
- require.NoError(t, err)
+ t.Run("Should update old key when force is true", func(t *testing.T) {
+ key, err := store.Add(ctx, &signingkeys.SigningKey{KeyID: "2", PrivateKey: []byte{}, AddedAt: time.Now().UTC()}, false)
+ require.NoError(t, err)
+ assert.Equal(t, "2", key.KeyID)
- assert.Equal(t, key.Public(), retrievedKey.Public())
+ // try to add the same key again with a different AddedAt and force is true
+ key2, err := store.Add(ctx, &signingkeys.SigningKey{KeyID: "2", PrivateKey: []byte{}, AddedAt: time.Now().Add(10 * time.Minute).UTC()}, true)
+ require.NoError(t, err)
+ assert.Equal(t, "2", key2.KeyID)
+ assert.NotEqual(t, key.AddedAt, key2.AddedAt)
+ })
- jwks, err := store.GetJWKS(ctx)
- assert.NoError(t, err)
+ t.Run("Should update old key when expired", func(t *testing.T) {
+ key, err := store.Add(ctx, &signingkeys.SigningKey{KeyID: "3", PrivateKey: []byte{}, AddedAt: time.Now().UTC(), ExpiresAt: &time.Time{}}, false)
+ require.NoError(t, err)
+ assert.Equal(t, "3", key.KeyID)
- require.Len(t, jwks.Keys, 1)
- assert.Equal(t, key.Public(), jwks.Keys[0].Key)
- assert.Equal(t, tc.expected.Algorithm, jwks.Keys[0].Algorithm)
- assert.Equal(t, tc.expected.KeyID, jwks.Keys[0].KeyID)
- assert.Equal(t, tc.expected.Use, jwks.Keys[0].Use)
- })
- }
-}
-
-func TestIntegrationAddPrivateKey(t *testing.T) {
- ctx := context.Background()
-
- dbStore := db.InitTestDB(t)
- secretSvc := fakes.NewFakeSecretsService()
- store := NewSigningKeyStore(dbStore, secretSvc)
-
- key1 := generateRSAKey(t)
- key2 := generateECKey(t)
- key3 := generateECKey(t)
-
- testCases := []struct {
- name string
- keyID string
- alg jose.SignatureAlgorithm
- privateKey crypto.Signer
- expiresAt *time.Time
- force bool
- expectedErr error
- expectedKey crypto.Signer
- expectedGot crypto.Signer
- }{
- {
- name: "Add new private key",
- keyID: "test-key-1",
- alg: jose.RS256,
- privateKey: key1,
- force: false,
- expectedKey: key1,
- expectedGot: key1,
- },
- {
- name: "Add new private key with expiration",
- keyID: "test-key-2",
- alg: jose.ES256,
- privateKey: key2,
- expiresAt: &[]time.Time{time.Now().Add(24 * time.Hour)}[0],
- force: false,
- expectedKey: key2,
- expectedGot: key2,
- },
- {
- name: "Fail to replace unexpired key",
- keyID: "test-key-1",
- alg: jose.RS256,
- privateKey: key3,
- expiresAt: &[]time.Time{time.Now().Add(-24 * time.Hour)}[0],
- force: false,
- expectedErr: signingkeys.ErrSigningKeyAlreadyExists,
- expectedKey: key1,
- expectedGot: key1,
- },
- {
- name: "Replace key1 private key with force, already expired",
- keyID: "test-key-1",
- alg: jose.ES256,
- privateKey: key3,
- expiresAt: &[]time.Time{time.Now().Add(-24 * time.Hour)}[0],
- force: true,
- expectedKey: nil,
- expectedGot: key3,
- },
- {
- name: "Replace key1 private key with no force, is expired",
- keyID: "test-key-1",
- alg: jose.ES256,
- privateKey: key1,
- expiresAt: &[]time.Time{time.Now().Add(24 * time.Hour)}[0],
- force: false,
- expectedKey: nil,
- expectedGot: key1,
- },
- }
-
- _, exists := store.localCache.Get(cleanupRateLimitKey)
- require.False(t, exists)
-
- for _, tc := range testCases {
- t.Run(tc.name, func(t *testing.T) {
- got, err := store.AddPrivateKey(ctx, tc.keyID, tc.alg, tc.privateKey, tc.expiresAt, tc.force)
- if tc.expectedErr != nil {
- assert.ErrorIs(t, err, tc.expectedErr)
- } else {
- assert.NoError(t, err)
- }
-
- if tc.expectedGot != nil {
- assert.Equal(t, tc.expectedGot.Public(), got.Public())
- } else {
- assert.Nil(t, got)
- }
-
- if tc.expectedKey != nil {
- retrievedKey, err := store.GetPrivateKey(ctx, tc.keyID)
- assert.NoError(t, err)
- assert.Equal(t, tc.expectedKey.Public(), retrievedKey.Public())
- }
- })
- }
-
- _, exists = store.localCache.Get(cleanupRateLimitKey)
- require.True(t, exists)
-}
-
-func generateRSAKey(t *testing.T) *rsa.PrivateKey {
- key, err := rsa.GenerateKey(rand.Reader, 2048)
- require.NoError(t, err)
- return key
-}
-
-func generateECKey(t *testing.T) *ecdsa.PrivateKey {
- key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
- require.NoError(t, err)
- return key
+ // try to add the same key again with a different AddedAt and force is false
+ key2, err := store.Add(ctx, &signingkeys.SigningKey{KeyID: "3", PrivateKey: []byte{}, AddedAt: time.Now().Add(10 * time.Minute).UTC()}, false)
+ require.NoError(t, err)
+ assert.Equal(t, "3", key2.KeyID)
+ assert.NotEqual(t, key.AddedAt, key2.AddedAt)
+ })
+
+ t.Run("List should return all keys that are not expired", func(t *testing.T) {
+ // expire key 3
+ _, err := store.Add(ctx, &signingkeys.SigningKey{KeyID: "3", PrivateKey: []byte{}, AddedAt: time.Now().UTC(), ExpiresAt: &time.Time{}}, true)
+ require.NoError(t, err)
+
+ keys, err := store.List(ctx)
+ require.NoError(t, err)
+ require.Len(t, keys, 2)
+ })
}
diff --git a/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go b/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go
index 51099bb4e7d..083a6fe2196 100644
--- a/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go
+++ b/pkg/services/sqlstore/migrations/accesscontrol/dashboard_permissions.go
@@ -555,6 +555,111 @@ func (m *managedFolderAlertActionsRepeatMigrator) Exec(sess *xorm.Session, mg *m
return nil
}
+const managedFolderLibraryPanelActionsMigratorID = "managed folder permissions library panel actions migration"
+
+func AddManagedFolderLibraryPanelActionsMigration(mg *migrator.Migrator) {
+ mg.AddMigration(managedFolderLibraryPanelActionsMigratorID, &managedFolderLibraryPanelActionsMigrator{})
+}
+
+type managedFolderLibraryPanelActionsMigrator struct {
+ migrator.MigrationBase
+}
+
+func (m *managedFolderLibraryPanelActionsMigrator) SQL(dialect migrator.Dialect) string {
+ return CodeMigrationSQL
+}
+
+// TODO: Refactor with alerts migration
+func (m *managedFolderLibraryPanelActionsMigrator) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
+ var ids []any
+ if err := sess.SQL("SELECT id FROM role WHERE name LIKE 'managed:%'").Find(&ids); err != nil {
+ return err
+ }
+
+ if len(ids) == 0 {
+ return nil
+ }
+
+ var permissions []ac.Permission
+ if err := sess.SQL("SELECT role_id, action, scope FROM permission WHERE role_id IN(?"+strings.Repeat(" ,?", len(ids)-1)+") AND scope LIKE 'folders:%'", ids...).Find(&permissions); err != nil {
+ return err
+ }
+
+ mapped := make(map[int64]map[string][]ac.Permission, len(ids)-1)
+ for _, p := range permissions {
+ if mapped[p.RoleID] == nil {
+ mapped[p.RoleID] = make(map[string][]ac.Permission)
+ }
+ mapped[p.RoleID][p.Scope] = append(mapped[p.RoleID][p.Scope], p)
+ }
+
+ var toAdd []ac.Permission
+ now := time.Now()
+
+ for id, a := range mapped {
+ for scope, p := range a {
+ if hasFolderView(p) {
+ if !hasAction(ac.ActionLibraryPanelsRead, p) {
+ toAdd = append(toAdd, ac.Permission{
+ RoleID: id,
+ Updated: now,
+ Created: now,
+ Scope: scope,
+ Action: ac.ActionLibraryPanelsRead,
+ })
+ }
+ }
+
+ if hasFolderAdmin(p) || hasFolderEdit(p) {
+ if !hasAction(ac.ActionLibraryPanelsCreate, p) {
+ toAdd = append(toAdd, ac.Permission{
+ RoleID: id,
+ Updated: now,
+ Created: now,
+ Scope: scope,
+ Action: ac.ActionLibraryPanelsCreate,
+ })
+ }
+ if !hasAction(ac.ActionLibraryPanelsDelete, p) {
+ toAdd = append(toAdd, ac.Permission{
+ RoleID: id,
+ Updated: now,
+ Created: now,
+ Scope: scope,
+ Action: ac.ActionLibraryPanelsDelete,
+ })
+ }
+ if !hasAction(ac.ActionLibraryPanelsWrite, p) {
+ toAdd = append(toAdd, ac.Permission{
+ RoleID: id,
+ Updated: now,
+ Created: now,
+ Scope: scope,
+ Action: ac.ActionLibraryPanelsWrite,
+ })
+ }
+ }
+ }
+ }
+
+ if len(toAdd) == 0 {
+ return nil
+ }
+
+ err := batch(len(toAdd), batchSize, func(start, end int) error {
+ if _, err := sess.InsertMulti(toAdd[start:end]); err != nil {
+ return err
+ }
+ return nil
+ })
+
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
func hasFolderAdmin(permissions []ac.Permission) bool {
return hasActions(folderPermissionTranslation[dashboards.PERMISSION_ADMIN], permissions)
}
diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go
index 56df4323cc9..4272a5cafff 100644
--- a/pkg/services/sqlstore/migrations/migrations.go
+++ b/pkg/services/sqlstore/migrations/migrations.go
@@ -89,6 +89,7 @@ func (*OSSMigrations) AddMigration(mg *Migrator) {
accesscontrol.AddAdminOnlyMigration(mg)
accesscontrol.AddSeedAssignmentMigrations(mg)
accesscontrol.AddManagedFolderAlertActionsRepeatFixedMigration(mg)
+ accesscontrol.AddManagedFolderLibraryPanelActionsMigration(mg)
AddExternalAlertmanagerToDatasourceMigration(mg)
diff --git a/pkg/services/sqlstore/migrations/playlist_mig.go b/pkg/services/sqlstore/migrations/playlist_mig.go
index 9ea365d45c8..443de889215 100644
--- a/pkg/services/sqlstore/migrations/playlist_mig.go
+++ b/pkg/services/sqlstore/migrations/playlist_mig.go
@@ -33,6 +33,14 @@ func addPlaylistMigrations(mg *Migrator) {
{Name: "value", Type: DB_Text, Nullable: false},
{Name: "title", Type: DB_Text, Nullable: false},
}))
+
+ // Add columns used for kubernetes dual write synchronization
+ mg.AddMigration("Add playlist column created_at", NewAddColumnMigration(playlistV2(), &Column{
+ Name: "created_at", Type: DB_BigInt, Nullable: false, Default: "0",
+ }))
+ mg.AddMigration("Add playlist column updated_at", NewAddColumnMigration(playlistV2(), &Column{
+ Name: "updated_at", Type: DB_BigInt, Nullable: false, Default: "0",
+ }))
}
func addPlaylistUIDMigration(mg *Migrator) {
diff --git a/pkg/services/team/team.go b/pkg/services/team/team.go
index 594b224f60b..22e4c77db59 100644
--- a/pkg/services/team/team.go
+++ b/pkg/services/team/team.go
@@ -20,4 +20,5 @@ type Service interface {
RemoveUsersMemberships(tx context.Context, userID int64) error
GetUserTeamMemberships(ctx context.Context, orgID, userID int64, external bool) ([]*TeamMemberDTO, error)
GetTeamMembers(ctx context.Context, query *GetTeamMembersQuery) ([]*TeamMemberDTO, error)
+ RegisterDelete(query string)
}
diff --git a/pkg/services/team/teamapi/api.go b/pkg/services/team/teamapi/api.go
new file mode 100644
index 00000000000..b744dfdaf5d
--- /dev/null
+++ b/pkg/services/team/teamapi/api.go
@@ -0,0 +1,82 @@
+package teamapi
+
+import (
+ "github.com/grafana/grafana/pkg/api/routing"
+ "github.com/grafana/grafana/pkg/middleware/requestmeta"
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
+ "github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/licensing"
+ pref "github.com/grafana/grafana/pkg/services/preference"
+ "github.com/grafana/grafana/pkg/services/team"
+ "github.com/grafana/grafana/pkg/setting"
+)
+
+type TeamAPI struct {
+ teamService team.Service
+ ac accesscontrol.Service
+ teamPermissionsService accesscontrol.TeamPermissionsService
+ license licensing.Licensing
+ cfg *setting.Cfg
+ preferenceService pref.Service
+ ds dashboards.DashboardService
+}
+
+func ProvideTeamAPI(
+ routeRegister routing.RouteRegister,
+ teamService team.Service,
+ ac accesscontrol.Service,
+ acEvaluator accesscontrol.AccessControl,
+ teamPermissionsService accesscontrol.TeamPermissionsService,
+ license licensing.Licensing,
+ cfg *setting.Cfg,
+ preferenceService pref.Service,
+ ds dashboards.DashboardService,
+) *TeamAPI {
+ tapi := &TeamAPI{
+ teamService: teamService,
+ ac: ac,
+ teamPermissionsService: teamPermissionsService,
+ license: license,
+ cfg: cfg,
+ preferenceService: preferenceService,
+ ds: ds,
+ }
+
+ tapi.registerRoutes(routeRegister, acEvaluator)
+ return tapi
+}
+
+func (tapi *TeamAPI) registerRoutes(router routing.RouteRegister, ac accesscontrol.AccessControl) {
+ authorize := accesscontrol.Middleware(ac)
+ router.Group("/api", func(apiRoute routing.RouteRegister) {
+ // team (admin permission required)
+ apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
+ teamsRoute.Post("/", authorize(accesscontrol.EvalPermission(accesscontrol.ActionTeamsCreate)),
+ routing.Wrap(tapi.createTeam))
+ teamsRoute.Put("/:teamId", authorize(accesscontrol.EvalPermission(accesscontrol.ActionTeamsWrite,
+ accesscontrol.ScopeTeamsID)), routing.Wrap(tapi.updateTeam))
+ teamsRoute.Delete("/:teamId", authorize(accesscontrol.EvalPermission(accesscontrol.ActionTeamsDelete,
+ accesscontrol.ScopeTeamsID)), routing.Wrap(tapi.deleteTeamByID))
+ teamsRoute.Get("/:teamId/members", authorize(accesscontrol.EvalPermission(accesscontrol.ActionTeamsPermissionsRead,
+ accesscontrol.ScopeTeamsID)), routing.Wrap(tapi.getTeamMembers))
+ teamsRoute.Post("/:teamId/members", authorize(accesscontrol.EvalPermission(accesscontrol.ActionTeamsPermissionsWrite,
+ accesscontrol.ScopeTeamsID)), routing.Wrap(tapi.addTeamMember))
+ teamsRoute.Put("/:teamId/members/:userId", authorize(accesscontrol.EvalPermission(accesscontrol.ActionTeamsPermissionsWrite,
+ accesscontrol.ScopeTeamsID)), routing.Wrap(tapi.updateTeamMember))
+ teamsRoute.Delete("/:teamId/members/:userId", authorize(accesscontrol.EvalPermission(accesscontrol.ActionTeamsPermissionsWrite,
+ accesscontrol.ScopeTeamsID)), routing.Wrap(tapi.removeTeamMember))
+ teamsRoute.Get("/:teamId/preferences", authorize(accesscontrol.EvalPermission(accesscontrol.ActionTeamsRead,
+ accesscontrol.ScopeTeamsID)), routing.Wrap(tapi.getTeamPreferences))
+ teamsRoute.Put("/:teamId/preferences", authorize(accesscontrol.EvalPermission(accesscontrol.ActionTeamsWrite,
+ accesscontrol.ScopeTeamsID)), routing.Wrap(tapi.updateTeamPreferences))
+ }, requestmeta.SetOwner(requestmeta.TeamAuth))
+
+ // team without requirement of user to be org admin
+ apiRoute.Group("/teams", func(teamsRoute routing.RouteRegister) {
+ teamsRoute.Get("/:teamId", authorize(accesscontrol.EvalPermission(accesscontrol.ActionTeamsRead,
+ accesscontrol.ScopeTeamsID)), routing.Wrap(tapi.getTeamByID))
+ teamsRoute.Get("/search", authorize(accesscontrol.EvalPermission(accesscontrol.ActionTeamsRead)),
+ routing.Wrap(tapi.searchTeams))
+ }, requestmeta.SetOwner(requestmeta.TeamAuth))
+ })
+}
diff --git a/pkg/api/team.go b/pkg/services/team/teamapi/team.go
similarity index 75%
rename from pkg/api/team.go
rename to pkg/services/team/teamapi/team.go
index 42f61252815..ebcde9222d9 100644
--- a/pkg/api/team.go
+++ b/pkg/services/team/teamapi/team.go
@@ -1,4 +1,4 @@
-package api
+package teamapi
import (
"errors"
@@ -7,9 +7,11 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth/identity"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/preference/prefapi"
"github.com/grafana/grafana/pkg/services/team"
"github.com/grafana/grafana/pkg/services/team/sortopts"
"github.com/grafana/grafana/pkg/util"
@@ -26,13 +28,13 @@ import (
// 403: forbiddenError
// 409: conflictError
// 500: internalServerError
-func (hs *HTTPServer) CreateTeam(c *contextmodel.ReqContext) response.Response {
+func (tapi *TeamAPI) createTeam(c *contextmodel.ReqContext) response.Response {
cmd := team.CreateTeamCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
}
- t, err := hs.teamService.CreateTeam(cmd.Name, cmd.Email, c.SignedInUser.GetOrgID())
+ t, err := tapi.teamService.CreateTeam(cmd.Name, cmd.Email, c.SignedInUser.GetOrgID())
if err != nil {
if errors.Is(err, team.ErrTeamNameTaken) {
return response.Error(http.StatusConflict, "Team name taken", err)
@@ -42,7 +44,7 @@ func (hs *HTTPServer) CreateTeam(c *contextmodel.ReqContext) response.Response {
// Clear permission cache for the user who's created the team, so that new permissions are fetched for their next call
// Required for cases when caller wants to immediately interact with the newly created object
- hs.accesscontrolService.ClearUserPermissionCache(c.SignedInUser)
+ tapi.ac.ClearUserPermissionCache(c.SignedInUser)
// if the request is authenticated using API tokens
// the SignedInUser is an empty struct therefore
@@ -55,7 +57,7 @@ func (hs *HTTPServer) CreateTeam(c *contextmodel.ReqContext) response.Response {
c.Logger.Error("Could not add creator to team because user id is not a number", "error", err)
break
}
- if err := addOrUpdateTeamMember(c.Req.Context(), hs.teamPermissionsService, userID, c.SignedInUser.GetOrgID(),
+ if err := addOrUpdateTeamMember(c.Req.Context(), tapi.teamPermissionsService, userID, c.SignedInUser.GetOrgID(),
t.ID, dashboards.PERMISSION_ADMIN.String()); err != nil {
c.Logger.Error("Could not add creator to team", "error", err)
}
@@ -80,7 +82,7 @@ func (hs *HTTPServer) CreateTeam(c *contextmodel.ReqContext) response.Response {
// 404: notFoundError
// 409: conflictError
// 500: internalServerError
-func (hs *HTTPServer) UpdateTeam(c *contextmodel.ReqContext) response.Response {
+func (tapi *TeamAPI) updateTeam(c *contextmodel.ReqContext) response.Response {
cmd := team.UpdateTeamCommand{}
var err error
if err := web.Bind(c.Req, &cmd); err != nil {
@@ -92,7 +94,7 @@ func (hs *HTTPServer) UpdateTeam(c *contextmodel.ReqContext) response.Response {
return response.Error(http.StatusBadRequest, "teamId is invalid", err)
}
- if err := hs.teamService.UpdateTeam(c.Req.Context(), &cmd); err != nil {
+ if err := tapi.teamService.UpdateTeam(c.Req.Context(), &cmd); err != nil {
if errors.Is(err, team.ErrTeamNameTaken) {
return response.Error(http.StatusBadRequest, "Team name taken", err)
}
@@ -112,14 +114,14 @@ func (hs *HTTPServer) UpdateTeam(c *contextmodel.ReqContext) response.Response {
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
-func (hs *HTTPServer) DeleteTeamByID(c *contextmodel.ReqContext) response.Response {
+func (tapi *TeamAPI) deleteTeamByID(c *contextmodel.ReqContext) response.Response {
orgID := c.SignedInUser.GetOrgID()
teamID, err := strconv.ParseInt(web.Params(c.Req)[":teamId"], 10, 64)
if err != nil {
return response.Error(http.StatusBadRequest, "teamId is invalid", err)
}
- if err := hs.teamService.DeleteTeam(c.Req.Context(), &team.DeleteTeamCommand{OrgID: orgID, ID: teamID}); err != nil {
+ if err := tapi.teamService.DeleteTeam(c.Req.Context(), &team.DeleteTeamCommand{OrgID: orgID, ID: teamID}); err != nil {
if errors.Is(err, team.ErrTeamNotFound) {
return response.Error(http.StatusNotFound, "Failed to delete Team. ID not found", nil)
}
@@ -137,7 +139,7 @@ func (hs *HTTPServer) DeleteTeamByID(c *contextmodel.ReqContext) response.Respon
// 401: unauthorisedError
// 403: forbiddenError
// 500: internalServerError
-func (hs *HTTPServer) SearchTeams(c *contextmodel.ReqContext) response.Response {
+func (tapi *TeamAPI) searchTeams(c *contextmodel.ReqContext) response.Response {
perPage := c.QueryInt("perpage")
if perPage <= 0 {
perPage = 1000
@@ -159,11 +161,11 @@ func (hs *HTTPServer) SearchTeams(c *contextmodel.ReqContext) response.Response
Page: page,
Limit: perPage,
SignedInUser: c.SignedInUser,
- HiddenUsers: hs.Cfg.HiddenUsers,
+ HiddenUsers: tapi.cfg.HiddenUsers,
SortOpts: sortOpts,
}
- queryResult, err := hs.teamService.SearchTeams(c.Req.Context(), &query)
+ queryResult, err := tapi.teamService.SearchTeams(c.Req.Context(), &query)
if err != nil {
return response.Error(http.StatusInternalServerError, "Failed to search Teams", err)
}
@@ -174,7 +176,7 @@ func (hs *HTTPServer) SearchTeams(c *contextmodel.ReqContext) response.Response
teamIDs[strconv.FormatInt(team.ID, 10)] = true
}
- metadata := hs.getMultiAccessControlMetadata(c, "teams:id:", teamIDs)
+ metadata := tapi.getMultiAccessControlMetadata(c, "teams:id:", teamIDs)
if len(metadata) > 0 {
for _, team := range queryResult.Teams {
team.AccessControl = metadata[strconv.FormatInt(team.ID, 10)]
@@ -197,7 +199,7 @@ func (hs *HTTPServer) SearchTeams(c *contextmodel.ReqContext) response.Response
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
-func (hs *HTTPServer) GetTeamByID(c *contextmodel.ReqContext) response.Response {
+func (tapi *TeamAPI) getTeamByID(c *contextmodel.ReqContext) response.Response {
teamId, err := strconv.ParseInt(web.Params(c.Req)[":teamId"], 10, 64)
if err != nil {
return response.Error(http.StatusBadRequest, "teamId is invalid", err)
@@ -207,10 +209,10 @@ func (hs *HTTPServer) GetTeamByID(c *contextmodel.ReqContext) response.Response
OrgID: c.SignedInUser.GetOrgID(),
ID: teamId,
SignedInUser: c.SignedInUser,
- HiddenUsers: hs.Cfg.HiddenUsers,
+ HiddenUsers: tapi.cfg.HiddenUsers,
}
- queryResult, err := hs.teamService.GetTeamByID(c.Req.Context(), &query)
+ queryResult, err := tapi.teamService.GetTeamByID(c.Req.Context(), &query)
if err != nil {
if errors.Is(err, team.ErrTeamNotFound) {
return response.Error(http.StatusNotFound, "Team not found", err)
@@ -220,7 +222,7 @@ func (hs *HTTPServer) GetTeamByID(c *contextmodel.ReqContext) response.Response
}
// Add accesscontrol metadata
- queryResult.AccessControl = hs.getAccessControlMetadata(c, c.SignedInUser.GetOrgID(), "teams:id:", strconv.FormatInt(queryResult.ID, 10))
+ queryResult.AccessControl = tapi.getAccessControlMetadata(c, c.SignedInUser.GetOrgID(), "teams:id:", strconv.FormatInt(queryResult.ID, 10))
queryResult.AvatarURL = dtos.GetGravatarUrlWithDefault(queryResult.Email, queryResult.Name)
return response.JSON(http.StatusOK, &queryResult)
@@ -234,13 +236,13 @@ func (hs *HTTPServer) GetTeamByID(c *contextmodel.ReqContext) response.Response
// 200: getPreferencesResponse
// 401: unauthorisedError
// 500: internalServerError
-func (hs *HTTPServer) GetTeamPreferences(c *contextmodel.ReqContext) response.Response {
+func (tapi *TeamAPI) getTeamPreferences(c *contextmodel.ReqContext) response.Response {
teamId, err := strconv.ParseInt(web.Params(c.Req)[":teamId"], 10, 64)
if err != nil {
return response.Error(http.StatusBadRequest, "teamId is invalid", err)
}
- return hs.getPreferencesFor(c.Req.Context(), c.SignedInUser.GetOrgID(), 0, teamId)
+ return prefapi.GetPreferencesFor(c.Req.Context(), tapi.ds, tapi.preferenceService, c.SignedInUser.GetOrgID(), 0, teamId)
}
// swagger:route PUT /teams/{team_id}/preferences teams updateTeamPreferences
@@ -252,7 +254,7 @@ func (hs *HTTPServer) GetTeamPreferences(c *contextmodel.ReqContext) response.Re
// 400: badRequestError
// 401: unauthorisedError
// 500: internalServerError
-func (hs *HTTPServer) UpdateTeamPreferences(c *contextmodel.ReqContext) response.Response {
+func (tapi *TeamAPI) updateTeamPreferences(c *contextmodel.ReqContext) response.Response {
dtoCmd := dtos.UpdatePrefsCmd{}
if err := web.Bind(c.Req, &dtoCmd); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
@@ -263,7 +265,7 @@ func (hs *HTTPServer) UpdateTeamPreferences(c *contextmodel.ReqContext) response
return response.Error(http.StatusBadRequest, "teamId is invalid", err)
}
- return hs.updatePreferencesFor(c.Req.Context(), c.SignedInUser.GetOrgID(), 0, teamId, &dtoCmd)
+ return prefapi.UpdatePreferencesFor(c.Req.Context(), tapi.ds, tapi.preferenceService, c.SignedInUser.GetOrgID(), 0, teamId, &dtoCmd)
}
// swagger:parameters updateTeamPreferences
@@ -355,3 +357,26 @@ type CreateTeamResponse struct {
Message string `json:"message"`
} `json:"body"`
}
+
+// getMultiAccessControlMetadata returns the accesscontrol metadata associated with a given set of resources
+// Context must contain permissions in the given org (see LoadPermissionsMiddleware or AuthorizeInOrgMiddleware)
+func (tapi *TeamAPI) getMultiAccessControlMetadata(c *contextmodel.ReqContext,
+ prefix string, resourceIDs map[string]bool) map[string]accesscontrol.Metadata {
+ if !c.QueryBool("accesscontrol") {
+ return map[string]accesscontrol.Metadata{}
+ }
+
+ if len(c.SignedInUser.GetPermissions()) == 0 {
+ return map[string]accesscontrol.Metadata{}
+ }
+
+ return accesscontrol.GetResourcesMetadata(c.Req.Context(), c.SignedInUser.GetPermissions(), prefix, resourceIDs)
+}
+
+// Metadata helpers
+// getAccessControlMetadata returns the accesscontrol metadata associated with a given resource
+func (tapi *TeamAPI) getAccessControlMetadata(c *contextmodel.ReqContext,
+ orgID int64, prefix string, resourceID string) accesscontrol.Metadata {
+ ids := map[string]bool{resourceID: true}
+ return tapi.getMultiAccessControlMetadata(c, prefix, ids)[resourceID]
+}
diff --git a/pkg/api/team_members.go b/pkg/services/team/teamapi/team_members.go
similarity index 74%
rename from pkg/api/team_members.go
rename to pkg/services/team/teamapi/team_members.go
index 000206758c7..a9f996a8c11 100644
--- a/pkg/api/team_members.go
+++ b/pkg/services/team/teamapi/team_members.go
@@ -1,4 +1,4 @@
-package api
+package teamapi
import (
"context"
@@ -28,7 +28,7 @@ import (
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
-func (hs *HTTPServer) GetTeamMembers(c *contextmodel.ReqContext) response.Response {
+func (tapi *TeamAPI) getTeamMembers(c *contextmodel.ReqContext) response.Response {
teamId, err := strconv.ParseInt(web.Params(c.Req)[":teamId"], 10, 64)
if err != nil {
return response.Error(http.StatusBadRequest, "teamId is invalid", err)
@@ -36,21 +36,21 @@ func (hs *HTTPServer) GetTeamMembers(c *contextmodel.ReqContext) response.Respon
query := team.GetTeamMembersQuery{OrgID: c.SignedInUser.GetOrgID(), TeamID: teamId, SignedInUser: c.SignedInUser}
- queryResult, err := hs.teamService.GetTeamMembers(c.Req.Context(), &query)
+ queryResult, err := tapi.teamService.GetTeamMembers(c.Req.Context(), &query)
if err != nil {
- return response.Error(500, "Failed to get Team Members", err)
+ return response.Error(http.StatusInternalServerError, "Failed to get Team Members", err)
}
filteredMembers := make([]*team.TeamMemberDTO, 0, len(queryResult))
for _, member := range queryResult {
- if dtos.IsHiddenUser(member.Login, c.SignedInUser, hs.Cfg) {
+ if dtos.IsHiddenUser(member.Login, c.SignedInUser, tapi.cfg) {
continue
}
member.AvatarURL = dtos.GetGravatarUrl(member.Email)
member.Labels = []string{}
- if hs.License.FeatureEnabled("teamgroupsync") && member.External {
+ if tapi.license.FeatureEnabled("teamgroupsync") && member.External {
authProvider := login.GetAuthProviderLabel(member.AuthModule)
member.Labels = append(member.Labels, authProvider)
}
@@ -71,7 +71,7 @@ func (hs *HTTPServer) GetTeamMembers(c *contextmodel.ReqContext) response.Respon
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
-func (hs *HTTPServer) AddTeamMember(c *contextmodel.ReqContext) response.Response {
+func (tapi *TeamAPI) addTeamMember(c *contextmodel.ReqContext) response.Response {
cmd := team.AddTeamMemberCommand{}
var err error
if err := web.Bind(c.Req, &cmd); err != nil {
@@ -83,17 +83,17 @@ func (hs *HTTPServer) AddTeamMember(c *contextmodel.ReqContext) response.Respons
return response.Error(http.StatusBadRequest, "teamId is invalid", err)
}
- isTeamMember, err := hs.teamService.IsTeamMember(c.SignedInUser.GetOrgID(), cmd.TeamID, cmd.UserID)
+ isTeamMember, err := tapi.teamService.IsTeamMember(c.SignedInUser.GetOrgID(), cmd.TeamID, cmd.UserID)
if err != nil {
- return response.Error(500, "Failed to add team member.", err)
+ return response.Error(http.StatusInternalServerError, "Failed to add team member.", err)
}
if isTeamMember {
- return response.Error(400, "User is already added to this team", nil)
+ return response.Error(http.StatusBadRequest, "User is already added to this team", nil)
}
- err = addOrUpdateTeamMember(c.Req.Context(), hs.teamPermissionsService, cmd.UserID, cmd.OrgID, cmd.TeamID, getPermissionName(cmd.Permission))
+ err = addOrUpdateTeamMember(c.Req.Context(), tapi.teamPermissionsService, cmd.UserID, cmd.OrgID, cmd.TeamID, getPermissionName(cmd.Permission))
if err != nil {
- return response.Error(500, "Failed to add Member to Team", err)
+ return response.Error(http.StatusInternalServerError, "Failed to add Member to Team", err)
}
return response.JSON(http.StatusOK, &util.DynMap{
@@ -111,7 +111,7 @@ func (hs *HTTPServer) AddTeamMember(c *contextmodel.ReqContext) response.Respons
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
-func (hs *HTTPServer) UpdateTeamMember(c *contextmodel.ReqContext) response.Response {
+func (tapi *TeamAPI) updateTeamMember(c *contextmodel.ReqContext) response.Response {
cmd := team.UpdateTeamMemberCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {
return response.Error(http.StatusBadRequest, "bad request data", err)
@@ -126,17 +126,17 @@ func (hs *HTTPServer) UpdateTeamMember(c *contextmodel.ReqContext) response.Resp
}
orgId := c.SignedInUser.GetOrgID()
- isTeamMember, err := hs.teamService.IsTeamMember(orgId, teamId, userId)
+ isTeamMember, err := tapi.teamService.IsTeamMember(orgId, teamId, userId)
if err != nil {
- return response.Error(500, "Failed to update team member.", err)
+ return response.Error(http.StatusInternalServerError, "Failed to update team member.", err)
}
if !isTeamMember {
- return response.Error(404, "Team member not found.", nil)
+ return response.Error(http.StatusNotFound, "Team member not found.", nil)
}
- err = addOrUpdateTeamMember(c.Req.Context(), hs.teamPermissionsService, userId, orgId, teamId, getPermissionName(cmd.Permission))
+ err = addOrUpdateTeamMember(c.Req.Context(), tapi.teamPermissionsService, userId, orgId, teamId, getPermissionName(cmd.Permission))
if err != nil {
- return response.Error(500, "Failed to update team member.", err)
+ return response.Error(http.StatusInternalServerError, "Failed to update team member.", err)
}
return response.Success("Team member updated")
}
@@ -161,7 +161,7 @@ func getPermissionName(permission dashboards.PermissionType) string {
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
-func (hs *HTTPServer) RemoveTeamMember(c *contextmodel.ReqContext) response.Response {
+func (tapi *TeamAPI) removeTeamMember(c *contextmodel.ReqContext) response.Response {
orgId := c.SignedInUser.GetOrgID()
teamId, err := strconv.ParseInt(web.Params(c.Req)[":teamId"], 10, 64)
if err != nil {
@@ -173,16 +173,16 @@ func (hs *HTTPServer) RemoveTeamMember(c *contextmodel.ReqContext) response.Resp
}
teamIDString := strconv.FormatInt(teamId, 10)
- if _, err := hs.teamPermissionsService.SetUserPermission(c.Req.Context(), orgId, accesscontrol.User{ID: userId}, teamIDString, ""); err != nil {
+ if _, err := tapi.teamPermissionsService.SetUserPermission(c.Req.Context(), orgId, accesscontrol.User{ID: userId}, teamIDString, ""); err != nil {
if errors.Is(err, team.ErrTeamNotFound) {
- return response.Error(404, "Team not found", nil)
+ return response.Error(http.StatusNotFound, "Team not found", nil)
}
if errors.Is(err, team.ErrTeamMemberNotFound) {
- return response.Error(404, "Team member not found", nil)
+ return response.Error(http.StatusNotFound, "Team member not found", nil)
}
- return response.Error(500, "Failed to remove Member from Team", err)
+ return response.Error(http.StatusInternalServerError, "Failed to remove Member from Team", err)
}
return response.Success("Team Member removed")
}
diff --git a/pkg/api/team_members_test.go b/pkg/services/team/teamapi/team_members_test.go
similarity index 59%
rename from pkg/api/team_members_test.go
rename to pkg/services/team/teamapi/team_members_test.go
index 85858bc2902..da9465c538c 100644
--- a/pkg/api/team_members_test.go
+++ b/pkg/services/team/teamapi/team_members_test.go
@@ -1,4 +1,4 @@
-package api
+package teamapi
import (
"net/http"
@@ -8,24 +8,52 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- ac "github.com/grafana/grafana/pkg/services/accesscontrol"
+ "github.com/grafana/grafana/pkg/api/routing"
+ "github.com/grafana/grafana/pkg/services/accesscontrol"
+ "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
+ "github.com/grafana/grafana/pkg/services/dashboards"
+ "github.com/grafana/grafana/pkg/services/licensing"
+ "github.com/grafana/grafana/pkg/services/org"
+ "github.com/grafana/grafana/pkg/services/preference/preftest"
"github.com/grafana/grafana/pkg/services/team/teamtest"
+ "github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web/webtest"
)
+func SetupAPITestServer(t *testing.T, opts ...func(a *TeamAPI)) *webtest.Server {
+ t.Helper()
+ router := routing.NewRouteRegister()
+ cfg := setting.NewCfg()
+ cfg.LDAPAuthEnabled = true
+
+ a := ProvideTeamAPI(router,
+ teamtest.NewFakeService(),
+ actest.FakeService{},
+ acimpl.ProvideAccessControl(cfg),
+ &actest.FakePermissionsService{},
+ &licensing.OSSLicensingService{},
+ cfg,
+ preftest.NewPreferenceServiceFake(),
+ dashboards.NewFakeDashboardService(t),
+ )
+ for _, o := range opts {
+ o(a)
+ }
+
+ server := webtest.NewServer(t, router)
+
+ return server
+}
+
func TestAddTeamMembersAPIEndpoint(t *testing.T) {
- server := SetupAPITestServer(t, func(hs *HTTPServer) {
- hs.Cfg = setting.NewCfg()
- hs.teamService = teamtest.NewFakeService()
- hs.teamPermissionsService = &actest.FakePermissionsService{}
- })
+ server := SetupAPITestServer(t)
t.Run("should be able to add team member with correct permission", func(t *testing.T) {
req := webtest.RequestWithSignedInUser(
server.NewRequest(http.MethodPost, "/api/teams/1/members", strings.NewReader("{\"userId\": 1}")),
- userWithPermissions(1, []ac.Permission{{Action: ac.ActionTeamsPermissionsWrite, Scope: "teams:id:1"}}),
+ authedUserWithPermissions(1, 1, []accesscontrol.Permission{{Action: accesscontrol.ActionTeamsPermissionsWrite, Scope: "teams:id:1"}}),
)
res, err := server.SendJSON(req)
require.NoError(t, err)
@@ -36,7 +64,7 @@ func TestAddTeamMembersAPIEndpoint(t *testing.T) {
t.Run("should not be able to add team member without correct permission", func(t *testing.T) {
req := webtest.RequestWithSignedInUser(
server.NewRequest(http.MethodPost, "/api/teams/1/members", strings.NewReader("{\"userId\": 1}")),
- userWithPermissions(1, []ac.Permission{{Action: ac.ActionTeamsPermissionsWrite, Scope: "teams:id:2"}}),
+ authedUserWithPermissions(1, 1, []accesscontrol.Permission{{Action: accesscontrol.ActionTeamsPermissionsWrite, Scope: "teams:id:2"}}),
)
res, err := server.SendJSON(req)
require.NoError(t, err)
@@ -46,16 +74,12 @@ func TestAddTeamMembersAPIEndpoint(t *testing.T) {
}
func TestGetTeamMembersAPIEndpoint(t *testing.T) {
- server := SetupAPITestServer(t, func(hs *HTTPServer) {
- hs.Cfg = setting.NewCfg()
- hs.teamService = teamtest.NewFakeService()
- hs.teamPermissionsService = &actest.FakePermissionsService{}
- })
+ server := SetupAPITestServer(t)
t.Run("should be able to get team members with correct permission", func(t *testing.T) {
req := webtest.RequestWithSignedInUser(
server.NewGetRequest("/api/teams/1/members"),
- userWithPermissions(1, []ac.Permission{{Action: ac.ActionTeamsPermissionsRead, Scope: "teams:id:1"}}),
+ authedUserWithPermissions(1, 1, []accesscontrol.Permission{{Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:id:1"}}),
)
res, err := server.SendJSON(req)
require.NoError(t, err)
@@ -65,7 +89,7 @@ func TestGetTeamMembersAPIEndpoint(t *testing.T) {
t.Run("should not be able to get team members without correct permission", func(t *testing.T) {
req := webtest.RequestWithSignedInUser(
server.NewGetRequest("/api/teams/1/members"),
- userWithPermissions(1, []ac.Permission{{Action: ac.ActionTeamsPermissionsRead, Scope: "teams:id:2"}}),
+ authedUserWithPermissions(1, 1, []accesscontrol.Permission{{Action: accesscontrol.ActionTeamsPermissionsRead, Scope: "teams:id:2"}}),
)
res, err := server.SendJSON(req)
require.NoError(t, err)
@@ -75,16 +99,14 @@ func TestGetTeamMembersAPIEndpoint(t *testing.T) {
}
func TestUpdateTeamMembersAPIEndpoint(t *testing.T) {
- server := SetupAPITestServer(t, func(hs *HTTPServer) {
- hs.Cfg = setting.NewCfg()
+ server := SetupAPITestServer(t, func(hs *TeamAPI) {
hs.teamService = &teamtest.FakeService{ExpectedIsMember: true}
- hs.teamPermissionsService = &actest.FakePermissionsService{}
})
t.Run("should be able to update team member with correct permission", func(t *testing.T) {
req := webtest.RequestWithSignedInUser(
server.NewRequest(http.MethodPut, "/api/teams/1/members/1", strings.NewReader("{\"permission\": 1}")),
- userWithPermissions(1, []ac.Permission{{Action: ac.ActionTeamsPermissionsWrite, Scope: "teams:id:1"}}),
+ authedUserWithPermissions(1, 1, []accesscontrol.Permission{{Action: accesscontrol.ActionTeamsPermissionsWrite, Scope: "teams:id:1"}}),
)
res, err := server.SendJSON(req)
require.NoError(t, err)
@@ -94,7 +116,7 @@ func TestUpdateTeamMembersAPIEndpoint(t *testing.T) {
t.Run("should not be able to update team member without correct permission", func(t *testing.T) {
req := webtest.RequestWithSignedInUser(
server.NewRequest(http.MethodPut, "/api/teams/1/members/1", strings.NewReader("{\"permission\": 1}")),
- userWithPermissions(1, []ac.Permission{{Action: ac.ActionTeamsPermissionsWrite, Scope: "teams:id:2"}}),
+ authedUserWithPermissions(1, 1, []accesscontrol.Permission{{Action: accesscontrol.ActionTeamsPermissionsWrite, Scope: "teams:id:2"}}),
)
res, err := server.SendJSON(req)
require.NoError(t, err)
@@ -104,8 +126,7 @@ func TestUpdateTeamMembersAPIEndpoint(t *testing.T) {
}
func TestDeleteTeamMembersAPIEndpoint(t *testing.T) {
- server := SetupAPITestServer(t, func(hs *HTTPServer) {
- hs.Cfg = setting.NewCfg()
+ server := SetupAPITestServer(t, func(hs *TeamAPI) {
hs.teamService = &teamtest.FakeService{ExpectedIsMember: true}
hs.teamPermissionsService = &actest.FakePermissionsService{}
})
@@ -113,7 +134,7 @@ func TestDeleteTeamMembersAPIEndpoint(t *testing.T) {
t.Run("should be able to delete team member with correct permission", func(t *testing.T) {
req := webtest.RequestWithSignedInUser(
server.NewRequest(http.MethodDelete, "/api/teams/1/members/1", nil),
- userWithPermissions(1, []ac.Permission{{Action: ac.ActionTeamsPermissionsWrite, Scope: "teams:id:1"}}),
+ authedUserWithPermissions(1, 1, []accesscontrol.Permission{{Action: accesscontrol.ActionTeamsPermissionsWrite, Scope: "teams:id:1"}}),
)
res, err := server.SendJSON(req)
require.NoError(t, err)
@@ -123,7 +144,7 @@ func TestDeleteTeamMembersAPIEndpoint(t *testing.T) {
t.Run("should not be able to delete member without correct permission", func(t *testing.T) {
req := webtest.RequestWithSignedInUser(
server.NewRequest(http.MethodDelete, "/api/teams/1/members/1", nil),
- userWithPermissions(1, []ac.Permission{{Action: ac.ActionTeamsPermissionsWrite, Scope: "teams:id:2"}}),
+ authedUserWithPermissions(1, 1, []accesscontrol.Permission{{Action: accesscontrol.ActionTeamsPermissionsWrite, Scope: "teams:id:2"}}),
)
res, err := server.SendJSON(req)
require.NoError(t, err)
@@ -131,3 +152,7 @@ func TestDeleteTeamMembersAPIEndpoint(t *testing.T) {
require.NoError(t, res.Body.Close())
})
}
+
+func authedUserWithPermissions(userID, orgID int64, permissions []accesscontrol.Permission) *user.SignedInUser {
+ return &user.SignedInUser{UserID: userID, OrgID: orgID, OrgRole: org.RoleViewer, Permissions: map[int64]map[string][]string{orgID: accesscontrol.GroupScopesByAction(permissions)}}
+}
diff --git a/pkg/api/team_test.go b/pkg/services/team/teamapi/team_test.go
similarity index 79%
rename from pkg/api/team_test.go
rename to pkg/services/team/teamapi/team_test.go
index 5c6646124a7..4cd3f541046 100644
--- a/pkg/api/team_test.go
+++ b/pkg/services/team/teamapi/team_test.go
@@ -1,4 +1,4 @@
-package api
+package teamapi
import (
"fmt"
@@ -10,14 +10,11 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/services/accesscontrol"
- "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
- "github.com/grafana/grafana/pkg/services/accesscontrol/actest"
pref "github.com/grafana/grafana/pkg/services/preference"
"github.com/grafana/grafana/pkg/services/preference/preftest"
"github.com/grafana/grafana/pkg/services/team"
"github.com/grafana/grafana/pkg/services/team/teamtest"
"github.com/grafana/grafana/pkg/services/user"
- "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web/webtest"
)
@@ -31,17 +28,14 @@ const (
)
func TestTeamAPIEndpoint_CreateTeam(t *testing.T) {
- server := SetupAPITestServer(t, func(hs *HTTPServer) {
- hs.Cfg = setting.NewCfg()
+ server := SetupAPITestServer(t, func(hs *TeamAPI) {
hs.teamService = teamtest.NewFakeService()
- hs.AccessControl = acimpl.ProvideAccessControl(setting.NewCfg())
- hs.accesscontrolService = actest.FakeService{}
})
input := strings.NewReader(fmt.Sprintf(teamCmd, 1))
t.Run("Access control allows creating teams with the correct permissions", func(t *testing.T) {
req := server.NewPostRequest(createTeamURL, input)
- req = webtest.RequestWithSignedInUser(req, userWithPermissions(1, []accesscontrol.Permission{{Action: accesscontrol.ActionTeamsCreate}}))
+ req = webtest.RequestWithSignedInUser(req, authedUserWithPermissions(1, 1, []accesscontrol.Permission{{Action: accesscontrol.ActionTeamsCreate}}))
res, err := server.SendJSON(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
@@ -51,7 +45,7 @@ func TestTeamAPIEndpoint_CreateTeam(t *testing.T) {
input = strings.NewReader(fmt.Sprintf(teamCmd, 2))
t.Run("Access control prevents creating teams with the incorrect permissions", func(t *testing.T) {
req := server.NewPostRequest(createTeamURL, input)
- req = webtest.RequestWithSignedInUser(req, userWithPermissions(1, []accesscontrol.Permission{}))
+ req = webtest.RequestWithSignedInUser(req, authedUserWithPermissions(1, 1, []accesscontrol.Permission{}))
res, err := server.SendJSON(req)
require.NoError(t, err)
assert.Equal(t, http.StatusForbidden, res.StatusCode)
@@ -60,14 +54,13 @@ func TestTeamAPIEndpoint_CreateTeam(t *testing.T) {
}
func TestTeamAPIEndpoint_SearchTeams(t *testing.T) {
- server := SetupAPITestServer(t, func(hs *HTTPServer) {
- hs.Cfg = setting.NewCfg()
+ server := SetupAPITestServer(t, func(hs *TeamAPI) {
hs.teamService = teamtest.NewFakeService()
})
t.Run("Access control prevents searching for teams with the incorrect permissions", func(t *testing.T) {
req := server.NewGetRequest(searchTeamsURL)
- req = webtest.RequestWithSignedInUser(req, userWithPermissions(1, []accesscontrol.Permission{}))
+ req = webtest.RequestWithSignedInUser(req, authedUserWithPermissions(1, 1, []accesscontrol.Permission{}))
res, err := server.Send(req)
require.NoError(t, err)
assert.Equal(t, http.StatusForbidden, res.StatusCode)
@@ -76,7 +69,7 @@ func TestTeamAPIEndpoint_SearchTeams(t *testing.T) {
t.Run("Access control allows searching for teams with the correct permissions", func(t *testing.T) {
req := server.NewGetRequest(searchTeamsURL)
- req = webtest.RequestWithSignedInUser(req, userWithPermissions(1, []accesscontrol.Permission{
+ req = webtest.RequestWithSignedInUser(req, authedUserWithPermissions(1, 1, []accesscontrol.Permission{
{Action: accesscontrol.ActionTeamsRead, Scope: accesscontrol.ScopeTeamsAll},
}))
res, err := server.Send(req)
@@ -87,8 +80,7 @@ func TestTeamAPIEndpoint_SearchTeams(t *testing.T) {
}
func TestTeamAPIEndpoint_GetTeamByID(t *testing.T) {
- server := SetupAPITestServer(t, func(hs *HTTPServer) {
- hs.Cfg = setting.NewCfg()
+ server := SetupAPITestServer(t, func(hs *TeamAPI) {
hs.teamService = &teamtest.FakeService{ExpectedTeamDTO: &team.TeamDTO{}}
})
@@ -96,7 +88,7 @@ func TestTeamAPIEndpoint_GetTeamByID(t *testing.T) {
t.Run("Access control prevents getting a team when missing permissions", func(t *testing.T) {
req := server.NewGetRequest(url)
- req = webtest.RequestWithSignedInUser(req, userWithPermissions(1, []accesscontrol.Permission{}))
+ req = webtest.RequestWithSignedInUser(req, authedUserWithPermissions(1, 1, []accesscontrol.Permission{}))
res, err := server.Send(req)
require.NoError(t, err)
assert.Equal(t, http.StatusForbidden, res.StatusCode)
@@ -105,7 +97,7 @@ func TestTeamAPIEndpoint_GetTeamByID(t *testing.T) {
t.Run("Access control allows getting a team with the correct permissions", func(t *testing.T) {
req := server.NewGetRequest(url)
- req = webtest.RequestWithSignedInUser(req, userWithPermissions(1, []accesscontrol.Permission{
+ req = webtest.RequestWithSignedInUser(req, authedUserWithPermissions(1, 1, []accesscontrol.Permission{
{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"},
}))
res, err := server.Send(req)
@@ -116,7 +108,7 @@ func TestTeamAPIEndpoint_GetTeamByID(t *testing.T) {
t.Run("Access control allows getting a team with wildcard scope", func(t *testing.T) {
req := server.NewGetRequest(url)
- req = webtest.RequestWithSignedInUser(req, userWithPermissions(1, []accesscontrol.Permission{
+ req = webtest.RequestWithSignedInUser(req, authedUserWithPermissions(1, 1, []accesscontrol.Permission{
{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:*"},
}))
res, err := server.Send(req)
@@ -130,8 +122,7 @@ func TestTeamAPIEndpoint_GetTeamByID(t *testing.T) {
// Then the endpoint should return 200 if the user has accesscontrol.ActionTeamsWrite with teams:id:1 scope
// else return 403
func TestTeamAPIEndpoint_UpdateTeam(t *testing.T) {
- server := SetupAPITestServer(t, func(hs *HTTPServer) {
- hs.Cfg = setting.NewCfg()
+ server := SetupAPITestServer(t, func(hs *TeamAPI) {
hs.teamService = &teamtest.FakeService{ExpectedTeamDTO: &team.TeamDTO{}}
})
@@ -142,7 +133,7 @@ func TestTeamAPIEndpoint_UpdateTeam(t *testing.T) {
}
t.Run("Access control allows updating team with the correct permissions", func(t *testing.T) {
- res, err := request(1, userWithPermissions(1, []accesscontrol.Permission{
+ res, err := request(1, authedUserWithPermissions(1, 1, []accesscontrol.Permission{
{Action: accesscontrol.ActionTeamsWrite, Scope: "teams:id:1"},
}))
require.NoError(t, err)
@@ -151,7 +142,7 @@ func TestTeamAPIEndpoint_UpdateTeam(t *testing.T) {
})
t.Run("Access control allows updating teams with the wildcard scope", func(t *testing.T) {
- res, err := request(1, userWithPermissions(1, []accesscontrol.Permission{
+ res, err := request(1, authedUserWithPermissions(1, 1, []accesscontrol.Permission{
{Action: accesscontrol.ActionTeamsWrite, Scope: "teams:*"},
}))
require.NoError(t, err)
@@ -160,7 +151,7 @@ func TestTeamAPIEndpoint_UpdateTeam(t *testing.T) {
})
t.Run("Access control prevent updating a team with wrong scope", func(t *testing.T) {
- res, err := request(1, userWithPermissions(1, []accesscontrol.Permission{
+ res, err := request(1, authedUserWithPermissions(1, 1, []accesscontrol.Permission{
{Action: accesscontrol.ActionTeamsWrite, Scope: "teams:id:2"},
}))
require.NoError(t, err)
@@ -173,8 +164,7 @@ func TestTeamAPIEndpoint_UpdateTeam(t *testing.T) {
// Then the endpoint should return 200 if the user has accesscontrol.ActionTeamsDelete with teams:id:1 scope
// else return 403
func TestTeamAPIEndpoint_DeleteTeam(t *testing.T) {
- server := SetupAPITestServer(t, func(hs *HTTPServer) {
- hs.Cfg = setting.NewCfg()
+ server := SetupAPITestServer(t, func(hs *TeamAPI) {
hs.teamService = &teamtest.FakeService{ExpectedTeamDTO: &team.TeamDTO{}}
})
@@ -185,7 +175,7 @@ func TestTeamAPIEndpoint_DeleteTeam(t *testing.T) {
}
t.Run("Access control prevents deleting teams with the incorrect permissions", func(t *testing.T) {
- res, err := request(1, userWithPermissions(1, []accesscontrol.Permission{
+ res, err := request(1, authedUserWithPermissions(1, 1, []accesscontrol.Permission{
{Action: accesscontrol.ActionTeamsDelete, Scope: "teams:id:2"},
}))
require.NoError(t, err)
@@ -194,7 +184,7 @@ func TestTeamAPIEndpoint_DeleteTeam(t *testing.T) {
})
t.Run("Access control allows deleting teams with the correct permissions", func(t *testing.T) {
- res, err := request(1, userWithPermissions(1, []accesscontrol.Permission{
+ res, err := request(1, authedUserWithPermissions(1, 1, []accesscontrol.Permission{
{Action: accesscontrol.ActionTeamsDelete, Scope: "teams:id:1"},
}))
require.NoError(t, err)
@@ -207,8 +197,7 @@ func TestTeamAPIEndpoint_DeleteTeam(t *testing.T) {
// Then the endpoint should return 200 if the user has accesscontrol.ActionTeamsRead with teams:id:1 scope
// else return 403
func TestTeamAPIEndpoint_GetTeamPreferences(t *testing.T) {
- server := SetupAPITestServer(t, func(hs *HTTPServer) {
- hs.Cfg = setting.NewCfg()
+ server := SetupAPITestServer(t, func(hs *TeamAPI) {
hs.preferenceService = &preftest.FakePreferenceService{ExpectedPreference: &pref.Preference{}}
})
@@ -219,7 +208,7 @@ func TestTeamAPIEndpoint_GetTeamPreferences(t *testing.T) {
}
t.Run("Access control allows getting team preferences with the correct permissions", func(t *testing.T) {
- res, err := request(1, userWithPermissions(1, []accesscontrol.Permission{
+ res, err := request(1, authedUserWithPermissions(1, 1, []accesscontrol.Permission{
{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:1"},
}))
require.NoError(t, err)
@@ -228,7 +217,7 @@ func TestTeamAPIEndpoint_GetTeamPreferences(t *testing.T) {
})
t.Run("Access control prevents getting team preferences with the incorrect permissions", func(t *testing.T) {
- res, err := request(1, userWithPermissions(1, []accesscontrol.Permission{
+ res, err := request(1, authedUserWithPermissions(1, 1, []accesscontrol.Permission{
{Action: accesscontrol.ActionTeamsRead, Scope: "teams:id:2"},
}))
require.NoError(t, err)
@@ -241,8 +230,7 @@ func TestTeamAPIEndpoint_GetTeamPreferences(t *testing.T) {
// Then the endpoint should return 200 if the user has accesscontrol.ActionTeamsWrite with teams:id:1 scope
// else return 403
func TestTeamAPIEndpoint_UpdateTeamPreferences(t *testing.T) {
- server := SetupAPITestServer(t, func(hs *HTTPServer) {
- hs.Cfg = setting.NewCfg()
+ server := SetupAPITestServer(t, func(hs *TeamAPI) {
hs.preferenceService = &preftest.FakePreferenceService{ExpectedPreference: &pref.Preference{}}
})
@@ -253,7 +241,7 @@ func TestTeamAPIEndpoint_UpdateTeamPreferences(t *testing.T) {
}
t.Run("Access control allows updating team preferences with the correct permissions", func(t *testing.T) {
- res, err := request(1, userWithPermissions(1, []accesscontrol.Permission{
+ res, err := request(1, authedUserWithPermissions(1, 1, []accesscontrol.Permission{
{Action: accesscontrol.ActionTeamsWrite, Scope: "teams:id:1"},
}))
require.NoError(t, err)
@@ -262,7 +250,7 @@ func TestTeamAPIEndpoint_UpdateTeamPreferences(t *testing.T) {
})
t.Run("Access control prevents updating team preferences with the incorrect permissions", func(t *testing.T) {
- res, err := request(1, userWithPermissions(1, []accesscontrol.Permission{
+ res, err := request(1, authedUserWithPermissions(1, 1, []accesscontrol.Permission{
{Action: accesscontrol.ActionTeamsWrite, Scope: "teams:id:2"},
}))
require.NoError(t, err)
diff --git a/pkg/services/team/teamimpl/store.go b/pkg/services/team/teamimpl/store.go
index f2f830a5565..c600ecfb443 100644
--- a/pkg/services/team/teamimpl/store.go
+++ b/pkg/services/team/teamimpl/store.go
@@ -30,11 +30,13 @@ type store interface {
RemoveMember(ctx context.Context, cmd *team.RemoveTeamMemberCommand) error
GetMemberships(ctx context.Context, orgID, userID int64, external bool) ([]*team.TeamMemberDTO, error)
GetMembers(ctx context.Context, query *team.GetTeamMembersQuery) ([]*team.TeamMemberDTO, error)
+ RegisterDelete(query string)
}
type xormStore struct {
- db db.DB
- cfg *setting.Cfg
+ db db.DB
+ cfg *setting.Cfg
+ deletes []string
}
func getFilteredUsers(signedInUser identity.Requester, hiddenUsers map[string]struct{}) []string {
@@ -142,6 +144,8 @@ func (ss *xormStore) Delete(ctx context.Context, cmd *team.DeleteTeamCommand) er
"DELETE FROM team_role WHERE org_id=? and team_id = ?",
}
+ deletes = append(deletes, ss.deletes...)
+
for _, sql := range deletes {
_, err := sess.Exec(sql, cmd.OrgID, cmd.ID)
if err != nil {
@@ -567,3 +571,8 @@ func (ss *xormStore) getTeamMembers(ctx context.Context, query *team.GetTeamMemb
}
return queryResult, nil
}
+
+// RegisterDelete registers a delete query to be executed when the transaction is committed
+func (ss *xormStore) RegisterDelete(query string) {
+ ss.deletes = append(ss.deletes, query)
+}
diff --git a/pkg/services/team/teamimpl/team.go b/pkg/services/team/teamimpl/team.go
index 5bbef67adb2..5895447bc4b 100644
--- a/pkg/services/team/teamimpl/team.go
+++ b/pkg/services/team/teamimpl/team.go
@@ -14,7 +14,7 @@ type Service struct {
}
func ProvideService(db db.DB, cfg *setting.Cfg) team.Service {
- return &Service{store: &xormStore{db: db, cfg: cfg}}
+ return &Service{store: &xormStore{db: db, cfg: cfg, deletes: []string{}}}
}
func (s *Service) CreateTeam(name, email string, orgID int64) (team.Team, error) {
@@ -68,3 +68,7 @@ func (s *Service) GetUserTeamMemberships(ctx context.Context, orgID, userID int6
func (s *Service) GetTeamMembers(ctx context.Context, query *team.GetTeamMembersQuery) ([]*team.TeamMemberDTO, error) {
return s.store.GetMembers(ctx, query)
}
+
+func (s *Service) RegisterDelete(query string) {
+ s.store.RegisterDelete(query)
+}
diff --git a/pkg/services/team/teamtest/team.go b/pkg/services/team/teamtest/team.go
index e0a295d859c..71ae3073036 100644
--- a/pkg/services/team/teamtest/team.go
+++ b/pkg/services/team/teamtest/team.go
@@ -72,3 +72,6 @@ func (s *FakeService) GetUserTeamMemberships(ctx context.Context, orgID, userID
func (s *FakeService) GetTeamMembers(ctx context.Context, query *team.GetTeamMembersQuery) ([]*team.TeamMemberDTO, error) {
return s.ExpectedMembers, s.ExpectedError
}
+
+func (s *FakeService) RegisterDelete(query string) {
+}
diff --git a/pkg/services/user/error.go b/pkg/services/user/error.go
new file mode 100644
index 00000000000..ba70fd24baf
--- /dev/null
+++ b/pkg/services/user/error.go
@@ -0,0 +1,24 @@
+package user
+
+import (
+ "errors"
+
+ "github.com/grafana/grafana/pkg/util/errutil"
+)
+
+var (
+ ErrCaseInsensitive = errors.New("case insensitive conflict")
+ ErrUserNotFound = errors.New("user not found")
+ ErrUserAlreadyExists = errors.New("user already exists")
+ ErrLastGrafanaAdmin = errors.New("cannot remove last grafana admin")
+ ErrProtectedUser = errors.New("cannot adopt protected user")
+ ErrNoUniqueID = errors.New("identifying id not found")
+ ErrLastSeenUpToDate = errors.New("last seen is already up to date")
+ ErrUpdateInvalidID = errors.New("unable to update invalid id")
+)
+
+var (
+ ErrEmptyUsernameAndEmail = errutil.BadRequest(
+ "user.empty-username-and-email", errutil.WithPublicMessage("Need to specify either username or email"),
+ )
+)
diff --git a/pkg/services/user/model.go b/pkg/services/user/model.go
index 9f8f816772a..81b184286ab 100644
--- a/pkg/services/user/model.go
+++ b/pkg/services/user/model.go
@@ -1,7 +1,6 @@
package user
import (
- "errors"
"fmt"
"strings"
"time"
@@ -20,18 +19,6 @@ const (
HelpFlagDashboardHelp1
)
-// Typed errors
-var (
- ErrCaseInsensitive = errors.New("case insensitive conflict")
- ErrUserNotFound = errors.New("user not found")
- ErrUserAlreadyExists = errors.New("user already exists")
- ErrLastGrafanaAdmin = errors.New("cannot remove last grafana admin")
- ErrProtectedUser = errors.New("cannot adopt protected user")
- ErrNoUniqueID = errors.New("identifying id not found")
- ErrLastSeenUpToDate = errors.New("last seen is already up to date")
- ErrUpdateInvalidID = errors.New("unable to update invalid id")
-)
-
type User struct {
ID int64 `xorm:"pk autoincr 'id'"`
Version int
diff --git a/pkg/services/user/userimpl/user.go b/pkg/services/user/userimpl/user.go
index 621546db957..fced32139d9 100644
--- a/pkg/services/user/userimpl/user.go
+++ b/pkg/services/user/userimpl/user.go
@@ -98,6 +98,15 @@ func (s *Service) Usage(ctx context.Context, _ *quota.ScopeParameters) (*quota.M
}
func (s *Service) Create(ctx context.Context, cmd *user.CreateUserCommand) (*user.User, error) {
+ if len(cmd.Login) == 0 {
+ cmd.Login = cmd.Email
+ }
+
+ // if login is still empty both email and login field is missing
+ if len(cmd.Login) == 0 {
+ return nil, user.ErrEmptyUsernameAndEmail.Errorf("user cannot be created with empty username and email")
+ }
+
cmdOrg := org.GetOrgIDForNewUserCommand{
Email: cmd.Email,
Login: cmd.Login,
@@ -215,10 +224,20 @@ func (s *Service) GetByEmail(ctx context.Context, query *user.GetUserByEmailQuer
}
func (s *Service) Update(ctx context.Context, cmd *user.UpdateUserCommand) error {
+ if len(cmd.Login) == 0 {
+ cmd.Login = cmd.Email
+ }
+
+ // if login is still empty both email and login field is missing
+ if len(cmd.Login) == 0 {
+ return user.ErrEmptyUsernameAndEmail.Errorf("user cannot be created with empty username and email")
+ }
+
if s.cfg.CaseInsensitiveLogin {
cmd.Login = strings.ToLower(cmd.Login)
cmd.Email = strings.ToLower(cmd.Email)
}
+
return s.store.Update(ctx, cmd)
}
diff --git a/pkg/services/user/userimpl/user_test.go b/pkg/services/user/userimpl/user_test.go
index 43c9c5d3b5d..dc6bcbbc6e1 100644
--- a/pkg/services/user/userimpl/user_test.go
+++ b/pkg/services/user/userimpl/user_test.go
@@ -38,6 +38,16 @@ func TestUserService(t *testing.T) {
require.NoError(t, err)
})
+ t.Run("create user should fail when username and email are empty", func(t *testing.T) {
+ _, err := userService.Create(context.Background(), &user.CreateUserCommand{
+ Email: "",
+ Login: "",
+ Name: "name",
+ })
+
+ require.ErrorIs(t, err, user.ErrEmptyUsernameAndEmail)
+ })
+
t.Run("get user by ID", func(t *testing.T) {
userService.cfg = setting.NewCfg()
userService.cfg.CaseInsensitiveLogin = false
@@ -88,6 +98,16 @@ func TestUserService(t *testing.T) {
require.NoError(t, err)
})
+ t.Run("update user should fail with empty username and password", func(t *testing.T) {
+ err := userService.Update(context.Background(), &user.UpdateUserCommand{
+ Email: "",
+ Login: "",
+ Name: "name",
+ })
+
+ require.ErrorIs(t, err, user.ErrEmptyUsernameAndEmail)
+ })
+
t.Run("GetByID - email conflict", func(t *testing.T) {
userService.cfg.CaseInsensitiveLogin = true
userStore.ExpectedError = errors.New("email conflict")
diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go
index 42260e9eae7..1ea1b0bfe77 100644
--- a/pkg/setting/setting.go
+++ b/pkg/setting/setting.go
@@ -482,7 +482,8 @@ type Cfg struct {
// Zero value means in-memory single node setup.
LiveHAEngine string
// LiveHAEngineAddress is a connection address for Live HA engine.
- LiveHAEngineAddress string
+ LiveHAEngineAddress string
+ LiveHAEnginePassword string
// LiveAllowedOrigins is a set of origins accepted by Live. If not provided
// then Live uses AppURL as the only allowed origin.
LiveAllowedOrigins []string
@@ -1497,9 +1498,7 @@ func readAuthGithubSettings(cfg *Cfg) {
func readAuthGoogleSettings(cfg *Cfg) {
sec := cfg.SectionWithEnvOverrides("auth.google")
cfg.GoogleAuthEnabled = sec.Key("enabled").MustBool(false)
- // FIXME: for now we skip org role sync for google auth
- // as we do not sync organization roles from Google
- cfg.GoogleSkipOrgRoleSync = true
+ cfg.GoogleSkipOrgRoleSync = sec.Key("skip_org_role_sync").MustBool(true)
}
func readAuthGitlabSettings(cfg *Cfg) {
@@ -1991,6 +1990,7 @@ func (cfg *Cfg) readLiveSettings(iniFile *ini.File) error {
return fmt.Errorf("unsupported live HA engine type: %s", cfg.LiveHAEngine)
}
cfg.LiveHAEngineAddress = section.Key("ha_engine_address").MustString("127.0.0.1:6379")
+ cfg.LiveHAEnginePassword = section.Key("ha_engine_password").MustString("")
var originPatterns []string
allowedOrigins := section.Key("allowed_origins").MustString("")
diff --git a/pkg/tests/api/alerting/api_provisioning_test.go b/pkg/tests/api/alerting/api_provisioning_test.go
index 4bef914bb2a..9a8c424abe5 100644
--- a/pkg/tests/api/alerting/api_provisioning_test.go
+++ b/pkg/tests/api/alerting/api_provisioning_test.go
@@ -2,13 +2,16 @@ package alerting
import (
"bytes"
+ "encoding/json"
"fmt"
"io"
"net/http"
+ "sort"
"testing"
"github.com/stretchr/testify/require"
+ "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/tests/testinfra"
@@ -43,6 +46,11 @@ func TestIntegrationProvisioning(t *testing.T) {
Login: "admin",
})
+ apiClient := newAlertingApiClient(grafanaListedAddr, "editor", "editor")
+ // Create the namespace we'll save our alerts to.
+ namespaceUID := "default"
+ apiClient.CreateFolder(t, namespaceUID, namespaceUID)
+
t.Run("when provisioning notification policies", func(t *testing.T) {
url := fmt.Sprintf("http://%s/api/v1/provisioning/policies", grafanaListedAddr)
body := `
@@ -333,6 +341,34 @@ func TestIntegrationProvisioning(t *testing.T) {
require.Equal(t, 200, resp.StatusCode)
})
})
+
+ t.Run("when provisioning alert rules", func(t *testing.T) {
+ url := fmt.Sprintf("http://%s/api/v1/provisioning/alert-rules", grafanaListedAddr)
+ body := `{"orgID":1,"folderUID":"default","ruleGroup":"Test Group","title":"Provisioned","condition":"A","data":[{"refId":"A","queryType":"","relativeTimeRange":{"from":600,"to":0},"datasourceUid":"f558c85f-66ad-4fd1-b31d-7979e6c93db4","model":{"editorMode":"code","exemplar":false,"expr":"sum(rate(low_card[5m])) \u003e 0","format":"time_series","instant":true,"intervalMs":1000,"legendFormat":"__auto","maxDataPoints":43200,"range":false,"refId":"A"}}],"noDataState":"NoData","execErrState":"Error","for":"0s"}`
+ req := createTestRequest("POST", url, "admin", body)
+ resp, err := http.DefaultClient.Do(req)
+ require.NoError(t, err)
+ require.NoError(t, resp.Body.Close())
+ require.Equal(t, 201, resp.StatusCode)
+
+ // We want to check the provenances of both provisioned and non-provisioned rules
+ createRule(t, apiClient, namespaceUID)
+
+ req = createTestRequest("GET", url, "admin", "")
+ resp, err = http.DefaultClient.Do(req)
+ require.NoError(t, err)
+
+ var rules definitions.ProvisionedAlertRules
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&rules))
+ require.NoError(t, resp.Body.Close())
+
+ require.Len(t, rules, 2)
+ sort.Slice(rules, func(i, j int) bool {
+ return rules[i].ID < rules[j].ID
+ })
+ require.Equal(t, definitions.Provenance("api"), rules[0].Provenance)
+ require.Equal(t, definitions.Provenance(""), rules[1].Provenance)
+ })
}
func createTestRequest(method string, url string, user string, body string) *http.Request {
diff --git a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go
index b483d2b40ce..c0632ec08b9 100644
--- a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go
+++ b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient.go
@@ -6,10 +6,12 @@ import (
"net/http"
"strings"
+ typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
+
"github.com/bufbuild/connect-go"
"github.com/grafana/grafana/pkg/infra/tracing"
- querierv1 "github.com/grafana/phlare/api/gen/proto/go/querier/v1"
- "github.com/grafana/phlare/api/gen/proto/go/querier/v1/querierv1connect"
+ querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1"
+ "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1/querierv1connect"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
@@ -205,7 +207,7 @@ func getUnits(profileTypeID string) string {
func (c *PyroscopeClient) LabelNames(ctx context.Context) ([]string, error) {
ctx, span := c.tracer.Start(ctx, "datasource.pyroscope.LabelNames")
defer span.End()
- resp, err := c.connectClient.LabelNames(ctx, connect.NewRequest(&querierv1.LabelNamesRequest{}))
+ resp, err := c.connectClient.LabelNames(ctx, connect.NewRequest(&typesv1.LabelNamesRequest{}))
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
@@ -225,7 +227,7 @@ func (c *PyroscopeClient) LabelNames(ctx context.Context) ([]string, error) {
func (c *PyroscopeClient) LabelValues(ctx context.Context, label string) ([]string, error) {
ctx, span := c.tracer.Start(ctx, "datasource.pyroscope.LabelValues")
defer span.End()
- resp, err := c.connectClient.LabelValues(ctx, connect.NewRequest(&querierv1.LabelValuesRequest{Name: label}))
+ resp, err := c.connectClient.LabelValues(ctx, connect.NewRequest(&typesv1.LabelValuesRequest{Name: label}))
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
diff --git a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go
index 4b07ff9cfd2..28c39e2108c 100644
--- a/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go
+++ b/pkg/tsdb/grafana-pyroscope-datasource/pyroscopeClient_test.go
@@ -5,9 +5,9 @@ import (
"testing"
"github.com/bufbuild/connect-go"
- googlev1 "github.com/grafana/phlare/api/gen/proto/go/google/v1"
- querierv1 "github.com/grafana/phlare/api/gen/proto/go/querier/v1"
- typesv1 "github.com/grafana/phlare/api/gen/proto/go/types/v1"
+ googlev1 "github.com/grafana/pyroscope/api/gen/proto/go/google/v1"
+ querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1"
+ typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
"github.com/stretchr/testify/require"
)
@@ -68,18 +68,25 @@ type FakePyroscopeConnectClient struct {
SendEmptyProfileResponse bool
}
+func (f *FakePyroscopeConnectClient) LabelValues(ctx context.Context, c *connect.Request[typesv1.LabelValuesRequest]) (*connect.Response[typesv1.LabelValuesResponse], error) {
+ //TODO implement me
+ panic("implement me")
+}
+
+func (f *FakePyroscopeConnectClient) LabelNames(ctx context.Context, c *connect.Request[typesv1.LabelNamesRequest]) (*connect.Response[typesv1.LabelNamesResponse], error) {
+ //TODO implement me
+ panic("implement me")
+}
+
+func (f *FakePyroscopeConnectClient) Diff(ctx context.Context, c *connect.Request[querierv1.DiffRequest]) (*connect.Response[querierv1.DiffResponse], error) {
+ //TODO implement me
+ panic("implement me")
+}
+
func (f *FakePyroscopeConnectClient) ProfileTypes(ctx context.Context, c *connect.Request[querierv1.ProfileTypesRequest]) (*connect.Response[querierv1.ProfileTypesResponse], error) {
panic("implement me")
}
-func (f *FakePyroscopeConnectClient) LabelValues(ctx context.Context, c *connect.Request[querierv1.LabelValuesRequest]) (*connect.Response[querierv1.LabelValuesResponse], error) {
- panic("implement me")
-}
-
-func (f *FakePyroscopeConnectClient) LabelNames(context.Context, *connect.Request[querierv1.LabelNamesRequest]) (*connect.Response[querierv1.LabelNamesResponse], error) {
- panic("implement me")
-}
-
func (f *FakePyroscopeConnectClient) Series(ctx context.Context, c *connect.Request[querierv1.SeriesRequest]) (*connect.Response[querierv1.SeriesResponse], error) {
panic("implement me")
}
diff --git a/public/app/angular/AngularApp.ts b/public/app/angular/AngularApp.ts
index 37df54085e2..045f1d27985 100644
--- a/public/app/angular/AngularApp.ts
+++ b/public/app/angular/AngularApp.ts
@@ -14,12 +14,14 @@ import { config } from 'app/core/config';
import { contextSrv } from 'app/core/services/context_srv';
import { DashboardLoaderSrv } from 'app/features/dashboard/services/DashboardLoaderSrv';
import { getTimeSrv } from 'app/features/dashboard/services/TimeSrv';
+import { setAngularPanelReactWrapper } from 'app/features/plugins/importPanelPlugin';
import { buildImportMap } from 'app/features/plugins/loader/utils';
import * as sdk from 'app/plugins/sdk';
import { registerAngularDirectives } from './angular_wrappers';
import { initAngularRoutingBridge } from './bridgeReactAngularRouting';
import { monkeyPatchInjectorWithPreAssignedBindings } from './injectorMonkeyPatch';
+import { getAngularPanelReactWrapper } from './panel/AngularPanelReactWrapper';
import { promiseToDigest } from './promiseToDigest';
import { registerComponents } from './registerComponents';
@@ -56,6 +58,8 @@ export class AngularApp {
init() {
const app = angular.module('grafana', []);
+ setAngularPanelReactWrapper(getAngularPanelReactWrapper);
+
app.config([
'$controllerProvider',
'$compileProvider',
diff --git a/public/app/angular/panel/AngularPanelReactWrapper.tsx b/public/app/angular/panel/AngularPanelReactWrapper.tsx
new file mode 100644
index 00000000000..d676a431ef8
--- /dev/null
+++ b/public/app/angular/panel/AngularPanelReactWrapper.tsx
@@ -0,0 +1,126 @@
+import React, { ComponentType, useEffect, useRef } from 'react';
+import { Observable, ReplaySubject } from 'rxjs';
+
+import { EventBusSrv, PanelData, PanelPlugin, PanelProps, FieldConfigSource } from '@grafana/data';
+import { AngularComponent, getAngularLoader, RefreshEvent } from '@grafana/runtime';
+import { DashboardModelCompatibilityWrapper } from 'app/features/dashboard-scene/utils/DashboardModelCompatibilityWrapper';
+import { GetDataOptions } from 'app/features/query/state/PanelQueryRunner';
+import { RenderEvent } from 'app/types/events';
+
+interface AngularScopeProps {
+ panel: PanelModelCompatibilityWrapper;
+ dashboard: DashboardModelCompatibilityWrapper;
+ queryRunner: FakeQueryRunner;
+ size: {
+ height: number;
+ width: number;
+ };
+}
+
+export function getAngularPanelReactWrapper(plugin: PanelPlugin): ComponentType
{
+ return function AngularWrapper(props: PanelProps) {
+ const divRef = useRef(null);
+ const angularState = useRef();
+ const angularComponent = useRef();
+
+ useEffect(() => {
+ if (!divRef.current) {
+ return;
+ }
+
+ const loader = getAngularLoader();
+ const template = '';
+ const queryRunner = new FakeQueryRunner();
+ const fakePanel = new PanelModelCompatibilityWrapper(plugin, props, queryRunner);
+
+ angularState.current = {
+ // @ts-ignore
+ panel: fakePanel,
+ // @ts-ignore
+ dashboard: new DashboardModelCompatibilityWrapper(),
+ size: { width: props.width, height: props.height },
+ queryRunner: queryRunner,
+ };
+
+ angularComponent.current = loader.load(divRef.current, angularState.current, template);
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Re-render angular panel when dimensions change
+ useEffect(() => {
+ if (!angularComponent.current) {
+ return;
+ }
+
+ angularState.current!.size.height = props.height;
+ angularState.current!.size.width = props.width;
+ angularState.current!.panel.events.publish(new RenderEvent());
+ }, [props.width, props.height]);
+
+ // Pass new data to angular panel
+ useEffect(() => {
+ if (!angularState.current?.panel) {
+ return;
+ }
+
+ angularState.current.queryRunner.forwardNewData(props.data);
+ }, [props.data]);
+
+ return ;
+ };
+}
+
+class PanelModelCompatibilityWrapper {
+ id: number;
+ type: string;
+ title: string;
+ plugin: PanelPlugin;
+ events: EventBusSrv;
+ queryRunner: FakeQueryRunner;
+ fieldConfig: FieldConfigSource;
+ options: Record;
+
+ constructor(plugin: PanelPlugin, props: PanelProps, queryRunner: FakeQueryRunner) {
+ // Assign legacy "root" level options
+ if (props.options.angularOptions) {
+ Object.assign(this, props.options.angularOptions);
+ }
+
+ this.id = props.id;
+ this.type = plugin.meta.id;
+ this.title = props.title;
+ this.fieldConfig = props.fieldConfig;
+ this.options = props.options;
+
+ this.plugin = plugin;
+ this.events = new EventBusSrv();
+ this.queryRunner = queryRunner;
+ }
+
+ refresh() {
+ this.events.publish(new RefreshEvent());
+ }
+
+ render() {
+ this.events.publish(new RenderEvent());
+ }
+
+ getQueryRunner() {
+ return this.queryRunner;
+ }
+}
+
+class FakeQueryRunner {
+ private subject = new ReplaySubject(1);
+
+ getData(options: GetDataOptions): Observable {
+ return this.subject;
+ }
+
+ forwardNewData(data: PanelData) {
+ this.subject.next(data);
+ }
+
+ run() {}
+}
diff --git a/public/app/core/components/AppChrome/AppChrome.tsx b/public/app/core/components/AppChrome/AppChrome.tsx
index 5c2f458f9c1..4dcc1c114db 100644
--- a/public/app/core/components/AppChrome/AppChrome.tsx
+++ b/public/app/core/components/AppChrome/AppChrome.tsx
@@ -3,13 +3,14 @@ import classNames from 'classnames';
import React, { PropsWithChildren } from 'react';
import { GrafanaTheme2, PageLayoutType } from '@grafana/data';
-import { useStyles2, LinkButton } from '@grafana/ui';
+import { useStyles2, LinkButton, useTheme2 } from '@grafana/ui';
import config from 'app/core/config';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { CommandPalette } from 'app/features/commandPalette/CommandPalette';
import { KioskMode } from 'app/types';
import { AppChromeMenu } from './AppChromeMenu';
+import { MegaMenu as DockedMegaMenu } from './DockedMegaMenu/MegaMenu';
import { MegaMenu } from './MegaMenu/MegaMenu';
import { NavToolbar } from './NavToolbar/NavToolbar';
import { SectionNav } from './SectionNav/SectionNav';
@@ -22,6 +23,7 @@ export function AppChrome({ children }: Props) {
const { chrome } = useGrafana();
const state = chrome.useState();
const searchBarHidden = state.searchBarHidden || state.kioskMode === KioskMode.TV;
+ const theme = useTheme2();
const styles = useStyles2(getStyles);
const contentClass = cx({
@@ -30,6 +32,23 @@ export function AppChrome({ children }: Props) {
[styles.contentChromeless]: state.chromeless,
});
+ const handleMegaMenu = () => {
+ switch (state.megaMenu) {
+ case 'closed':
+ chrome.setMegaMenu('open');
+ break;
+ case 'open':
+ chrome.setMegaMenu('closed');
+ break;
+ case 'docked':
+ // on desktop, clicking the button when the menu is docked should close the menu
+ // on mobile, the docked menu is hidden, so clicking the button should open the menu
+ const isDesktop = window.innerWidth > theme.breakpoints.values.md;
+ isDesktop ? chrome.setMegaMenu('closed') : chrome.setMegaMenu('open');
+ break;
+ }
+ };
+
// Chromeless routes are without topNav, mega menu, search & command palette
// We check chromeless twice here instead of having a separate path so {children}
// doesn't get re-mounted when chromeless goes from true to false.
@@ -53,7 +72,7 @@ export function AppChrome({ children }: Props) {
pageNav={state.pageNav}
actions={state.actions}
onToggleSearchBar={chrome.onToggleSearchBar}
- onToggleMegaMenu={chrome.onToggleMegaMenu}
+ onToggleMegaMenu={handleMegaMenu}
onToggleKioskMode={chrome.onToggleKioskMode}
/>
@@ -64,6 +83,9 @@ export function AppChrome({ children }: Props) {
{state.layout === PageLayoutType.Standard && state.sectionNav && !config.featureToggles.dockedMegaMenu && (