merge with main

This commit is contained in:
Will Browne
2025-02-21 17:49:24 +00:00
5474 changed files with 285763 additions and 137703 deletions
+2
View File
@@ -12,6 +12,8 @@ Developers modifying the HTTP API endpoints need to make sure to add the necessa
The following route defines a `PATCH` endpoint under the `/serviceaccounts/{serviceAccountId}` path with tag `service_accounts` (used for grouping together several routes) and operation ID `updateServiceAccount` (used for uniquely identifying routes and associate parameters and response with them).
> For enterprise endpoints make sure you add the `enterprise` tag as well.
```go
// swagger:route PATCH /serviceaccounts/{serviceAccountId} service_accounts updateServiceAccount
+45 -2
View File
@@ -60,12 +60,15 @@ func (hs *HTTPServer) declareFixedRoles() error {
Role: ac.RoleDTO{
Name: "fixed:datasources:explorer",
DisplayName: "Explorer",
Description: "Enable the Explore feature. Data source permissions still apply; you can only query data sources for which you have query permissions.",
Description: "Enable the Explore and Drilldown features. Data source permissions still apply; you can only query data sources for which you have query permissions.",
Group: "Data sources",
Permissions: []ac.Permission{
{
Action: ac.ActionDatasourcesExplore,
},
{
Action: ac.ActionDatasourcesDrilldown,
},
},
},
Grants: []string{string(org.RoleEditor)},
@@ -606,6 +609,45 @@ func (hs *HTTPServer) declareFixedRoles() error {
Grants: []string{"Admin"},
}
snapshotsCreatorRole := ac.RoleRegistration{
Role: ac.RoleDTO{
Name: "fixed:snapshots:creator",
DisplayName: "Creator",
Description: "Create snapshots",
Group: "Snapshots",
Permissions: []ac.Permission{
{Action: dashboards.ActionSnapshotsCreate},
},
},
Grants: []string{string(org.RoleEditor)},
}
snapshotsDeleterRole := ac.RoleRegistration{
Role: ac.RoleDTO{
Name: "fixed:snapshots:deleter",
DisplayName: "Deleter",
Description: "Delete snapshots",
Group: "Snapshots",
Permissions: []ac.Permission{
{Action: dashboards.ActionSnapshotsDelete},
},
},
Grants: []string{string(org.RoleEditor)},
}
snapshotsReaderRole := ac.RoleRegistration{
Role: ac.RoleDTO{
Name: "fixed:snapshots:reader",
DisplayName: "Reader",
Description: "Read snapshots",
Group: "Snapshots",
Permissions: []ac.Permission{
{Action: dashboards.ActionSnapshotsRead},
},
},
Grants: []string{string(org.RoleViewer)},
}
roles := []ac.RoleRegistration{provisioningWriterRole, datasourcesReaderRole, builtInDatasourceReader, datasourcesWriterRole,
datasourcesIdReaderRole, datasourcesCreatorRole, orgReaderRole, orgWriterRole,
orgMaintainerRole, teamsCreatorRole, teamsWriterRole, teamsReaderRole, datasourcesExplorerRole,
@@ -613,7 +655,8 @@ func (hs *HTTPServer) declareFixedRoles() error {
dashboardsCreatorRole, dashboardsReaderRole, dashboardsWriterRole,
foldersCreatorRole, foldersReaderRole, generalFolderReaderRole, foldersWriterRole, apikeyReaderRole, apikeyWriterRole,
publicDashboardsWriterRole, featuremgmtReaderRole, featuremgmtWriterRole, libraryPanelsCreatorRole,
libraryPanelsReaderRole, libraryPanelsWriterRole, libraryPanelsGeneralReaderRole, libraryPanelsGeneralWriterRole}
libraryPanelsReaderRole, libraryPanelsWriterRole, libraryPanelsGeneralReaderRole, libraryPanelsGeneralWriterRole,
snapshotsCreatorRole, snapshotsDeleterRole, snapshotsReaderRole}
if hs.Features.IsEnabled(context.Background(), featuremgmt.FlagAnnotationPermissionUpdate) {
allAnnotationsReaderRole := ac.RoleRegistration{
+6 -6
View File
@@ -8,10 +8,9 @@ import (
"golang.org/x/sync/errgroup"
"github.com/grafana/authlib/claims"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/auth"
@@ -71,6 +70,7 @@ func (hs *HTTPServer) AdminCreateUser(c *contextmodel.ReqContext) response.Respo
result := user.AdminCreateUserResponse{
Message: "User created",
ID: usr.ID,
UID: usr.UID,
}
return response.JSON(http.StatusOK, result)
@@ -158,8 +158,7 @@ func (hs *HTTPServer) AdminUpdateUserPermissions(c *contextmodel.ReqContext) res
}
if authInfo, err := hs.authInfoService.GetAuthInfo(c.Req.Context(), &login.GetAuthInfoQuery{UserId: userID}); err == nil && authInfo != nil {
oauthInfo := hs.SocialService.GetOAuthInfoProvider(authInfo.AuthModule)
if login.IsGrafanaAdminExternallySynced(hs.Cfg, oauthInfo, authInfo.AuthModule) {
if hs.isGrafanaAdminExternallySynced(hs.Cfg, authInfo.AuthModule) {
return response.Error(http.StatusForbidden, "Cannot change Grafana Admin role for externally synced user", nil)
}
}
@@ -362,12 +361,13 @@ func (hs *HTTPServer) AdminEnableUser(c *contextmodel.ReqContext) response.Respo
// 404: notFoundError
// 500: internalServerError
func (hs *HTTPServer) AdminLogoutUser(c *contextmodel.ReqContext) response.Response {
userID, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64)
id := web.Params(c.Req)[":id"]
userID, err := strconv.ParseInt(id, 10, 64)
if err != nil {
return response.Error(http.StatusBadRequest, "id is invalid", err)
}
if c.SignedInUser.GetID() == identity.NewTypedID(claims.TypeUser, userID) {
if c.SignedInUser.GetID() == authlib.NewTypeID(authlib.TypeUser, id) {
return response.Error(http.StatusBadRequest, "You cannot logout yourself", nil)
}
+64 -29
View File
@@ -14,10 +14,11 @@ import (
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/db/dbtest"
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/login/social/socialtest"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/auth/authtest"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/authntest"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/login"
"github.com/grafana/grafana/pkg/services/login/authinfotest"
@@ -243,39 +244,72 @@ func Test_AdminUpdateUserPermissions(t *testing.T) {
authEnabled bool
skipOrgRoleSync bool
expectedRespCode int
enabledAuthnClients []string
authnClientConfig authn.SSOClientConfig
}{
// oauth
{
name: "Should allow updating an externally synced OAuth user if Grafana Admin role is not synced",
authModule: login.GenericOAuthModule,
authEnabled: true,
allowAssignGrafanaAdmin: false,
skipOrgRoleSync: false,
expectedRespCode: http.StatusOK,
name: "Should allow updating an externally synced OAuth user if Grafana Admin role is not synced",
authModule: login.GenericOAuthModule,
enabledAuthnClients: []string{authn.ClientWithPrefix("generic_oauth")},
authnClientConfig: &authntest.FakeSSOClientConfig{
ExpectedIsSkipOrgRoleSyncEnabled: false,
ExpectedIsAllowAssignGrafanaAdminEnabled: false,
},
expectedRespCode: http.StatusOK,
},
{
name: "Should allow updating an externally synced OAuth user if OAuth provider is not enabled",
authModule: login.GenericOAuthModule,
authEnabled: false,
allowAssignGrafanaAdmin: true,
skipOrgRoleSync: false,
expectedRespCode: http.StatusOK,
name: "Should allow updating an externally synced OAuth user if OAuth provider is not enabled",
authModule: login.GenericOAuthModule,
expectedRespCode: http.StatusOK,
enabledAuthnClients: []string{},
authnClientConfig: &authntest.FakeSSOClientConfig{
ExpectedIsSkipOrgRoleSyncEnabled: false,
ExpectedIsAllowAssignGrafanaAdminEnabled: true,
},
},
{
name: "Should allow updating an externally synced OAuth user if org roles are not being synced",
authModule: login.GenericOAuthModule,
authEnabled: true,
allowAssignGrafanaAdmin: true,
skipOrgRoleSync: true,
expectedRespCode: http.StatusOK,
name: "Should allow updating an externally synced OAuth user if org roles are not being synced",
authModule: login.GenericOAuthModule,
expectedRespCode: http.StatusOK,
enabledAuthnClients: []string{authn.ClientWithPrefix("generic_oauth")},
authnClientConfig: &authntest.FakeSSOClientConfig{
ExpectedIsSkipOrgRoleSyncEnabled: true,
ExpectedIsAllowAssignGrafanaAdminEnabled: true,
},
},
{
name: "Should not allow updating an externally synced OAuth user",
authModule: login.GenericOAuthModule,
authEnabled: true,
allowAssignGrafanaAdmin: true,
skipOrgRoleSync: false,
expectedRespCode: http.StatusForbidden,
name: "Should not allow updating an externally synced OAuth user",
authModule: login.GenericOAuthModule,
expectedRespCode: http.StatusForbidden,
enabledAuthnClients: []string{authn.ClientWithPrefix("generic_oauth")},
authnClientConfig: &authntest.FakeSSOClientConfig{
ExpectedIsSkipOrgRoleSyncEnabled: false,
ExpectedIsAllowAssignGrafanaAdminEnabled: true,
},
},
// saml
{
name: "Should allow updating an externally synced SAML user if org roles are not being synced",
authModule: login.SAMLAuthModule,
expectedRespCode: http.StatusOK,
enabledAuthnClients: []string{authn.ClientSAML},
authnClientConfig: &authntest.FakeSSOClientConfig{
ExpectedIsSkipOrgRoleSyncEnabled: true,
ExpectedIsAllowAssignGrafanaAdminEnabled: true,
},
},
{
name: "Should not allow updating an externally synced SAML user",
authModule: login.SAMLAuthModule,
expectedRespCode: http.StatusForbidden,
enabledAuthnClients: []string{authn.ClientSAML},
authnClientConfig: &authntest.FakeSSOClientConfig{
ExpectedIsSkipOrgRoleSyncEnabled: false,
ExpectedIsAllowAssignGrafanaAdminEnabled: true,
},
},
// jwt
{
name: "Should allow updating an externally synced JWT user if Grafana Admin role is not synced",
authModule: login.JWTModule,
@@ -316,10 +350,7 @@ func Test_AdminUpdateUserPermissions(t *testing.T) {
socialService := &socialtest.FakeSocialService{}
cfg := setting.NewCfg()
switch tc.authModule {
case login.GenericOAuthModule:
socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{AllowAssignGrafanaAdmin: tc.allowAssignGrafanaAdmin, Enabled: tc.authEnabled, SkipOrgRoleSync: tc.skipOrgRoleSync}
case login.JWTModule:
if tc.authModule == login.JWTModule {
cfg.JWTAuth.Enabled = tc.authEnabled
cfg.JWTAuth.SkipOrgRoleSync = tc.skipOrgRoleSync
cfg.JWTAuth.AllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin
@@ -330,6 +361,10 @@ func Test_AdminUpdateUserPermissions(t *testing.T) {
authInfoService: authInfoService,
SocialService: socialService,
userService: usertest.NewUserServiceFake(),
authnService: &authntest.FakeService{
ExpectedClientConfig: tc.authnClientConfig,
EnabledClients: tc.enabledAuthnClients,
},
}
sc := setupScenarioContext(t, "/api/admin/users/1/permissions")
+2 -2
View File
@@ -587,7 +587,7 @@ func (hs *HTTPServer) GetAnnotationTags(c *contextmodel.ReqContext) response.Res
// where <type> is the type of annotation with id <id>.
// If annotationPermissionUpdate feature toggle is enabled, dashboard annotation scope will be resolved to the corresponding
// dashboard and folder scopes (eg, "dashboards:uid:<annotation_dashboard_uid>", "folders:uid:<parent_folder_uid>" etc).
func AnnotationTypeScopeResolver(annotationsRepo annotations.Repository, features featuremgmt.FeatureToggles, dashSvc dashboards.DashboardService, folderStore folder.Store) (string, accesscontrol.ScopeAttributeResolver) {
func AnnotationTypeScopeResolver(annotationsRepo annotations.Repository, features featuremgmt.FeatureToggles, dashSvc dashboards.DashboardService, folderSvc folder.Service) (string, accesscontrol.ScopeAttributeResolver) {
prefix := accesscontrol.ScopeAnnotationsProvider.GetResourceScope("")
return prefix, accesscontrol.ScopeAttributeResolverFunc(func(ctx context.Context, orgID int64, initialScope string) ([]string, error) {
scopeParts := strings.Split(initialScope, ":")
@@ -649,7 +649,7 @@ func AnnotationTypeScopeResolver(annotationsRepo annotations.Repository, feature
// Append dashboard parent scopes if dashboard is in a folder or the general scope if dashboard is not in a folder
if dashboard.FolderUID != "" {
scopes = append(scopes, dashboards.ScopeFoldersProvider.GetResourceScopeUID(dashboard.FolderUID))
inheritedScopes, err := dashboards.GetInheritedScopes(ctx, orgID, dashboard.FolderUID, folderStore)
inheritedScopes, err := dashboards.GetInheritedScopes(ctx, orgID, dashboard.FolderUID, folderSvc)
if err != nil {
return nil, err
}
+4 -6
View File
@@ -15,7 +15,6 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/annotations"
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
@@ -397,15 +396,14 @@ func TestAPI_Annotations(t *testing.T) {
dashService := &dashboards.FakeDashboardService{}
dashService.On("GetDashboard", mock.Anything, mock.Anything).Return(&dashboards.Dashboard{UID: dashUID, FolderUID: folderUID, FolderID: 1}, nil)
folderService := &foldertest.FakeService{}
fStore := folder.NewFakeStore()
folderService.ExpectedFolder = &folder.Folder{UID: folderUID, ID: 1}
folderDB := &foldertest.FakeFolderStore{}
folderDB.On("GetFolderByID", mock.Anything, mock.Anything, mock.Anything).Return(&folder.Folder{UID: folderUID, ID: 1}, nil)
hs.DashboardService = dashService
hs.folderService = folderService
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient())
hs.AccessControl.RegisterScopeAttributeResolver(AnnotationTypeScopeResolver(hs.annotationsRepo, hs.Features, dashService, fStore))
hs.AccessControl.RegisterScopeAttributeResolver(dashboards.NewDashboardIDScopeResolver(folderDB, dashService, fStore))
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
hs.AccessControl.RegisterScopeAttributeResolver(AnnotationTypeScopeResolver(hs.annotationsRepo, hs.Features, dashService, folderService))
hs.AccessControl.RegisterScopeAttributeResolver(dashboards.NewDashboardIDScopeResolver(dashService, folderService))
})
var body io.Reader
if tt.body != "" {
@@ -505,7 +503,7 @@ func TestService_AnnotationTypeScopeResolver(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
features := featuremgmt.WithFeatures(tc.featureToggles...)
prefix, resolver := AnnotationTypeScopeResolver(fakeAnnoRepo, features, dashSvc, folder.NewFakeStore())
prefix, resolver := AnnotationTypeScopeResolver(fakeAnnoRepo, features, dashSvc, &foldertest.FakeService{})
require.Equal(t, "annotations:id:", prefix)
resolved, err := resolver.Resolve(context.Background(), 1, tc.given)
+66 -34
View File
@@ -30,6 +30,11 @@
package api
import (
"errors"
"net/http"
"go.opentelemetry.io/otel"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/middleware"
"github.com/grafana/grafana/pkg/middleware/requestmeta"
@@ -37,6 +42,8 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol/ssoutils"
"github.com/grafana/grafana/pkg/services/apikey"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/cloudmigration"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/correlations"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
@@ -46,7 +53,7 @@ import (
publicdashboardsapi "github.com/grafana/grafana/pkg/services/publicdashboards/api"
"github.com/grafana/grafana/pkg/services/serviceaccounts"
"github.com/grafana/grafana/pkg/services/user"
"go.opentelemetry.io/otel"
"github.com/grafana/grafana/pkg/web"
)
var tracer = otel.Tracer("github.com/grafana/grafana/pkg/api")
@@ -60,11 +67,13 @@ func (hs *HTTPServer) registerRoutes() {
reqGrafanaAdmin := middleware.ReqGrafanaAdmin
reqOrgAdmin := middleware.ReqOrgAdmin
reqRoleForAppRoute := middleware.RoleAppPluginAuth(hs.AccessControl, hs.pluginStore, hs.Features, hs.log)
reqSnapshotPublicModeOrSignedIn := middleware.SnapshotPublicModeOrSignedIn(hs.Cfg)
reqSnapshotPublicModeOrCreate := middleware.SnapshotPublicModeOrCreate(hs.Cfg, hs.AccessControl)
reqSnapshotPublicModeOrDelete := middleware.SnapshotPublicModeOrDelete(hs.Cfg, hs.AccessControl)
redirectFromLegacyPanelEditURL := middleware.RedirectFromLegacyPanelEditURL(hs.Cfg)
authorize := ac.Middleware(hs.AccessControl)
authorizeInOrg := ac.AuthorizeInOrgMiddleware(hs.AccessControl, hs.authnService)
quota := middleware.Quota(hs.QuotaService)
userUIDResolver := middlewareUserUIDResolver(hs.userService, ":id")
r := hs.RouteRegister
@@ -72,6 +81,7 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/logout", hs.Logout)
r.Post("/login", requestmeta.SetOwner(requestmeta.TeamAuth), quota(string(auth.QuotaTargetSrv)), routing.Wrap(hs.LoginPost))
r.Get("/login/:name", quota(string(auth.QuotaTargetSrv)), hs.OAuthLogin)
r.Get("/login", hs.LoginView)
r.Get("/invite/:code", hs.Index)
@@ -106,13 +116,9 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/admin/orgs", authorizeInOrg(ac.UseGlobalOrg, ac.OrgsAccessEvaluator), hs.Index)
r.Get("/admin/orgs/edit/:id", authorizeInOrg(ac.UseGlobalOrg, ac.OrgsAccessEvaluator), hs.Index)
r.Get("/admin/stats", authorize(ac.EvalPermission(ac.ActionServerStatsRead)), hs.Index)
if hs.Features.IsEnabledGlobally(featuremgmt.FlagStorage) {
r.Get("/admin/storage", reqSignedIn, hs.Index)
r.Get("/admin/storage/*", reqSignedIn, hs.Index)
}
if hs.Features.IsEnabledGlobally(featuremgmt.FlagOnPremToCloudMigrations) {
r.Get("/admin/migrate-to-cloud", reqOrgAdmin, hs.Index)
r.Get("/admin/migrate-to-cloud", authorize(cloudmigration.MigrationAssistantAccess), hs.Index)
}
// feature toggle admin page
@@ -158,7 +164,7 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/dashboards/*", reqSignedIn, hs.Index)
r.Get("/goto/:uid", reqSignedIn, hs.redirectFromShortURL, hs.Index)
if hs.Features.IsEnabledGlobally(featuremgmt.FlagPublicDashboards) && hs.Cfg.PublicDashboardsEnabled {
if hs.Cfg.PublicDashboardsEnabled {
// list public dashboards
r.Get("/public-dashboards/list", reqSignedIn, hs.Index)
@@ -177,6 +183,7 @@ func (hs *HTTPServer) registerRoutes() {
}
r.Get("/explore", authorize(ac.EvalPermission(ac.ActionDatasourcesExplore)), hs.Index)
r.Get("/drilldown", authorize(ac.EvalPermission(ac.ActionDatasourcesDrilldown)), hs.Index)
r.Get("/playlists/", reqSignedIn, hs.Index)
r.Get("/playlists/*", reqSignedIn, hs.Index)
@@ -201,6 +208,11 @@ func (hs *HTTPServer) registerRoutes() {
r.Post("/api/user/email/start-verify", reqSignedInNoAnonymous, routing.Wrap(hs.StartEmailVerificaton))
}
if hs.Cfg.PasswordlessMagicLinkAuth.Enabled && hs.Features.IsEnabledGlobally(featuremgmt.FlagPasswordlessMagicLinkAuthentication) {
r.Post("/api/login/passwordless/start", requestmeta.SetOwner(requestmeta.TeamAuth), quota(string(auth.QuotaTargetSrv)), hs.StartPasswordless)
r.Post("/api/login/passwordless/authenticate", requestmeta.SetOwner(requestmeta.TeamAuth), quota(string(auth.QuotaTargetSrv)), routing.Wrap(hs.LoginPasswordless))
}
// invited
r.Get("/api/user/invite/:code", routing.Wrap(hs.GetInviteInfoByCode))
r.Post("/api/user/invite/complete", routing.Wrap(hs.CompleteInvite))
@@ -282,13 +294,13 @@ func (hs *HTTPServer) registerRoutes() {
userIDScope := ac.Scope("global.users", "id", ac.Parameter(":id"))
usersRoute.Get("/", authorize(ac.EvalPermission(ac.ActionUsersRead)), routing.Wrap(hs.searchUsersService.SearchUsers))
usersRoute.Get("/search", authorize(ac.EvalPermission(ac.ActionUsersRead)), routing.Wrap(hs.searchUsersService.SearchUsersWithPaging))
usersRoute.Get("/:id", authorize(ac.EvalPermission(ac.ActionUsersRead, userIDScope)), routing.Wrap(hs.GetUserByID))
usersRoute.Get("/:id/teams", authorize(ac.EvalPermission(ac.ActionUsersRead, userIDScope)), routing.Wrap(hs.GetUserTeams))
usersRoute.Get("/:id/orgs", authorize(ac.EvalPermission(ac.ActionUsersRead, userIDScope)), routing.Wrap(hs.GetUserOrgList))
usersRoute.Get("/:id", userUIDResolver, authorize(ac.EvalPermission(ac.ActionUsersRead, userIDScope)), routing.Wrap(hs.GetUserByID))
usersRoute.Get("/:id/teams", userUIDResolver, authorize(ac.EvalPermission(ac.ActionUsersRead, userIDScope)), routing.Wrap(hs.GetUserTeams))
usersRoute.Get("/:id/orgs", userUIDResolver, authorize(ac.EvalPermission(ac.ActionUsersRead, userIDScope)), routing.Wrap(hs.GetUserOrgList))
// query parameters /users/lookup?loginOrEmail=admin@example.com
usersRoute.Get("/lookup", authorize(ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)), routing.Wrap(hs.GetUserByLoginOrEmail))
usersRoute.Put("/:id", authorize(ac.EvalPermission(ac.ActionUsersWrite, userIDScope)), routing.Wrap(hs.UpdateUser))
usersRoute.Post("/:id/using/:orgId", authorize(ac.EvalPermission(ac.ActionUsersWrite, userIDScope)), routing.Wrap(hs.UpdateUserActiveOrg))
usersRoute.Put("/:id", userUIDResolver, authorize(ac.EvalPermission(ac.ActionUsersWrite, userIDScope)), routing.Wrap(hs.UpdateUser))
usersRoute.Post("/:id/using/:orgId", userUIDResolver, authorize(ac.EvalPermission(ac.ActionUsersWrite, userIDScope)), routing.Wrap(hs.UpdateUserActiveOrg))
}, requestmeta.SetOwner(requestmeta.TeamAuth))
// org information available to all users.
@@ -306,10 +318,6 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Group("/search-v2", hs.SearchV2HTTPService.RegisterHTTPRoutes)
}
if hs.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageSearch) {
apiRoute.Group("/unified-search", hs.UnifiedSearchHTTPService.RegisterHTTPRoutes)
}
// current org
apiRoute.Group("/org", func(orgRoute routing.RouteRegister) {
userIDScope := ac.Scope("users", "id", ac.Parameter(":userId"))
@@ -355,6 +363,7 @@ func (hs *HTTPServer) registerRoutes() {
// search all orgs
apiRoute.Get("/orgs", authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionOrgsRead)), routing.Wrap(hs.SearchOrgs))
orgUserUIDResolver := middlewareUserUIDResolver(hs.userService, ":userId")
// orgs (admin routes)
apiRoute.Group("/orgs/:orgId", func(orgsRoute routing.RouteRegister) {
userIDScope := ac.Scope("users", "id", ac.Parameter(":userId"))
@@ -365,8 +374,8 @@ func (hs *HTTPServer) registerRoutes() {
orgsRoute.Get("/users", requestmeta.SetOwner(requestmeta.TeamAuth), authorizeInOrg(ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersRead)), routing.Wrap(hs.GetOrgUsers))
orgsRoute.Get("/users/search", requestmeta.SetOwner(requestmeta.TeamAuth), authorizeInOrg(ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersRead)), routing.Wrap(hs.SearchOrgUsers))
orgsRoute.Post("/users", requestmeta.SetOwner(requestmeta.TeamAuth), authorizeInOrg(ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersAdd, ac.ScopeUsersAll)), routing.Wrap(hs.AddOrgUser))
orgsRoute.Patch("/users/:userId", requestmeta.SetOwner(requestmeta.TeamAuth), authorizeInOrg(ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersWrite, userIDScope)), routing.Wrap(hs.UpdateOrgUser))
orgsRoute.Delete("/users/:userId", requestmeta.SetOwner(requestmeta.TeamAuth), authorizeInOrg(ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersRemove, userIDScope)), routing.Wrap(hs.RemoveOrgUser))
orgsRoute.Patch("/users/:userId", orgUserUIDResolver, requestmeta.SetOwner(requestmeta.TeamAuth), authorizeInOrg(ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersWrite, userIDScope)), routing.Wrap(hs.UpdateOrgUser))
orgsRoute.Delete("/users/:userId", orgUserUIDResolver, requestmeta.SetOwner(requestmeta.TeamAuth), authorizeInOrg(ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgUsersRemove, userIDScope)), routing.Wrap(hs.RemoveOrgUser))
orgsRoute.Get("/quotas", authorizeInOrg(ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgsQuotasRead)), routing.Wrap(hs.GetOrgQuotas))
orgsRoute.Put("/quotas/:target", authorizeInOrg(ac.UseOrgFromContextParams, ac.EvalPermission(ac.ActionOrgsQuotasWrite)), routing.Wrap(hs.UpdateOrgQuota))
})
@@ -416,7 +425,7 @@ func (hs *HTTPServer) registerRoutes() {
apiRoute.Any("/plugin-proxy/:pluginId/*", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(pluginaccesscontrol.ActionAppAccess, pluginIDScope)), checkAppEnabled(hs.pluginStore, hs.PluginSettings), hs.ProxyPluginRequest)
apiRoute.Any("/plugin-proxy/:pluginId", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), authorize(ac.EvalPermission(pluginaccesscontrol.ActionAppAccess, pluginIDScope)), checkAppEnabled(hs.pluginStore, hs.PluginSettings), hs.ProxyPluginRequest)
if hs.Cfg.PluginAdminEnabled && (hs.Features.IsEnabledGlobally(featuremgmt.FlagManagedPluginsInstall) || !hs.Cfg.PluginAdminExternalManageEnabled) {
if hs.Cfg.PluginAdminEnabled {
apiRoute.Group("/plugins", func(pluginRoute routing.RouteRegister) {
pluginRoute.Post("/:pluginId/install", authorizeInOrg(ac.UseGlobalOrSingleOrg(hs.Cfg), ac.EvalPermission(pluginaccesscontrol.ActionInstall)), routing.Wrap(hs.InstallPlugin))
pluginRoute.Post("/:pluginId/uninstall", authorizeInOrg(ac.UseGlobalOrSingleOrg(hs.Cfg), ac.EvalPermission(pluginaccesscontrol.ActionInstall)), routing.Wrap(hs.UninstallPlugin))
@@ -499,7 +508,7 @@ func (hs *HTTPServer) registerRoutes() {
// Dashboard snapshots
apiRoute.Group("/dashboard/snapshots", func(dashboardRoute routing.RouteRegister) {
dashboardRoute.Get("/", routing.Wrap(hs.SearchDashboardSnapshots))
dashboardRoute.Get("/", authorize(ac.EvalPermission(dashboards.ActionSnapshotsRead)), routing.Wrap(hs.SearchDashboardSnapshots))
})
// Playlist
@@ -577,17 +586,17 @@ func (hs *HTTPServer) registerRoutes() {
userIDScope := ac.Scope("global.users", "id", ac.Parameter(":id"))
adminUserRoute.Post("/", authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersCreate)), routing.Wrap(hs.AdminCreateUser))
adminUserRoute.Put("/:id/password", authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersPasswordUpdate, userIDScope)), routing.Wrap(hs.AdminUpdateUserPassword))
adminUserRoute.Put("/:id/permissions", reqGrafanaAdmin, authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersPermissionsUpdate, userIDScope)), routing.Wrap(hs.AdminUpdateUserPermissions))
adminUserRoute.Delete("/:id", authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersDelete, userIDScope)), routing.Wrap(hs.AdminDeleteUser))
adminUserRoute.Post("/:id/disable", authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersDisable, userIDScope)), routing.Wrap(hs.AdminDisableUser))
adminUserRoute.Post("/:id/enable", authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersEnable, userIDScope)), routing.Wrap(hs.AdminEnableUser))
adminUserRoute.Get("/:id/quotas", authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersQuotasList, userIDScope)), routing.Wrap(hs.GetUserQuotas))
adminUserRoute.Put("/:id/quotas/:target", authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersQuotasUpdate, userIDScope)), routing.Wrap(hs.UpdateUserQuota))
adminUserRoute.Put("/:id/password", userUIDResolver, authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersPasswordUpdate, userIDScope)), routing.Wrap(hs.AdminUpdateUserPassword))
adminUserRoute.Put("/:id/permissions", userUIDResolver, reqGrafanaAdmin, authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersPermissionsUpdate, userIDScope)), routing.Wrap(hs.AdminUpdateUserPermissions))
adminUserRoute.Delete("/:id", userUIDResolver, authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersDelete, userIDScope)), routing.Wrap(hs.AdminDeleteUser))
adminUserRoute.Post("/:id/disable", userUIDResolver, authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersDisable, userIDScope)), routing.Wrap(hs.AdminDisableUser))
adminUserRoute.Post("/:id/enable", userUIDResolver, authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersEnable, userIDScope)), routing.Wrap(hs.AdminEnableUser))
adminUserRoute.Get("/:id/quotas", userUIDResolver, authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersQuotasList, userIDScope)), routing.Wrap(hs.GetUserQuotas))
adminUserRoute.Put("/:id/quotas/:target", userUIDResolver, authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersQuotasUpdate, userIDScope)), routing.Wrap(hs.UpdateUserQuota))
adminUserRoute.Post("/:id/logout", authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersLogout, userIDScope)), routing.Wrap(hs.AdminLogoutUser))
adminUserRoute.Get("/:id/auth-tokens", authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersAuthTokenList, userIDScope)), routing.Wrap(hs.AdminGetUserAuthTokens))
adminUserRoute.Post("/:id/revoke-auth-token", authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersAuthTokenUpdate, userIDScope)), routing.Wrap(hs.AdminRevokeUserAuthToken))
adminUserRoute.Post("/:id/logout", userUIDResolver, authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersLogout, userIDScope)), routing.Wrap(hs.AdminLogoutUser))
adminUserRoute.Get("/:id/auth-tokens", userUIDResolver, authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersAuthTokenList, userIDScope)), routing.Wrap(hs.AdminGetUserAuthTokens))
adminUserRoute.Post("/:id/revoke-auth-token", userUIDResolver, authorizeInOrg(ac.UseGlobalOrg, ac.EvalPermission(ac.ActionUsersAuthTokenUpdate, userIDScope)), routing.Wrap(hs.AdminRevokeUserAuthToken))
}, reqSignedIn)
// rendering
@@ -600,9 +609,32 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/avatar/:hash", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), hs.AvatarCacheServer.Handler)
// Snapshots
r.Post("/api/snapshots/", reqSnapshotPublicModeOrSignedIn, hs.getCreatedSnapshotHandler())
r.Get("/api/snapshot/shared-options/", reqSignedIn, hs.GetSharingOptions)
r.Post("/api/snapshots/", reqSnapshotPublicModeOrCreate, hs.getCreatedSnapshotHandler())
r.Get("/api/snapshots/:key", routing.Wrap(hs.GetDashboardSnapshot))
r.Get("/api/snapshots-delete/:deleteKey", reqSnapshotPublicModeOrSignedIn, routing.Wrap(hs.DeleteDashboardSnapshotByDeleteKey))
r.Delete("/api/snapshots/:key", reqSignedIn, routing.Wrap(hs.DeleteDashboardSnapshot))
r.Delete("/api/snapshots/:key", authorize(ac.EvalPermission(dashboards.ActionSnapshotsDelete)), routing.Wrap(hs.DeleteDashboardSnapshot))
// Snapshots delete for public mode or using the deleteKey
r.Get("/api/snapshots-delete/:deleteKey", reqSnapshotPublicModeOrDelete, routing.Wrap(hs.DeleteDashboardSnapshotByDeleteKey))
}
func middlewareUserUIDResolver(userService user.Service, paramName string) web.Handler {
handler := user.UIDToIDHandler(userService)
return func(c *contextmodel.ReqContext) {
userID := web.Params(c.Req)[paramName]
id, err := handler(c.Req.Context(), userID)
if err == nil {
gotParams := web.Params(c.Req)
gotParams[paramName] = id
web.SetURLParams(c.Req, gotParams)
} else {
if errors.Is(err, user.ErrUserNotFound) {
c.JsonApiErr(http.StatusNotFound, "User not found", nil)
} else {
c.JsonApiErr(http.StatusInternalServerError, "Failed to resolve user", err)
}
}
}
}
+35 -9
View File
@@ -1,6 +1,7 @@
package apierrors
import (
"encoding/json"
"errors"
"net/http"
@@ -9,6 +10,7 @@ import (
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/util"
)
@@ -34,8 +36,7 @@ func ToFolderErrorResponse(err error) response.Response {
return response.JSON(http.StatusNotFound, util.DynMap{"status": "not-found", "message": dashboards.ErrFolderNotFound.Error()})
}
if errors.Is(err, dashboards.ErrFolderSameNameExists) ||
errors.Is(err, dashboards.ErrFolderWithSameUIDExists) {
if errors.Is(err, dashboards.ErrFolderWithSameUIDExists) {
return response.Error(http.StatusConflict, err.Error(), nil)
}
@@ -43,26 +44,51 @@ func ToFolderErrorResponse(err error) response.Response {
return response.JSON(http.StatusPreconditionFailed, util.DynMap{"status": "version-mismatch", "message": dashboards.ErrFolderVersionMismatch.Error()})
}
// folder errors are wrapped in an error util, so this is the only way of comparing errors
if err.Error() == folder.ErrMaximumDepthReached.Error() {
return response.JSON(http.StatusBadRequest, util.DynMap{"messageId": "folder.maximum-depth-reached", "message": "Maximum nested folder depth reached"})
}
return response.ErrOrFallback(http.StatusInternalServerError, "Folder API error", err)
}
func ToFolderStatusError(err error) k8sErrors.StatusError {
resp := ToFolderErrorResponse(err)
defaultErr := k8sErrors.StatusError{
ErrStatus: metav1.Status{
Message: "Folder API error",
Code: http.StatusInternalServerError,
},
}
normResp, ok := resp.(*response.NormalResponse)
if !ok {
return k8sErrors.StatusError{
ErrStatus: metav1.Status{
Message: "Folder API error",
Code: http.StatusInternalServerError,
},
}
return defaultErr
}
var dat map[string]interface{}
if err := json.Unmarshal(normResp.Body(), &dat); err != nil {
return defaultErr
}
m, ok := dat["message"]
if !ok {
return defaultErr
}
message, ok := m.(string)
if !ok {
return defaultErr
}
return k8sErrors.StatusError{
ErrStatus: metav1.Status{
Message: normResp.ErrMessage(),
Message: message,
Code: int32(normResp.Status()),
},
}
}
func IsForbidden(err error) bool {
return k8sErrors.IsForbidden(err) || errors.Is(err, dashboards.ErrFolderAccessDenied)
}
+83
View File
@@ -0,0 +1,83 @@
package apierrors
import (
"errors"
"net/http"
"testing"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/util"
"github.com/stretchr/testify/require"
)
func TestToFolderErrorResponse(t *testing.T) {
tests := []struct {
name string
input error
want response.Response
}{
{
name: "dashboard error",
input: dashboards.DashboardErr{StatusCode: 400, Reason: "Dashboard Error", Status: "error"},
want: response.Error(400, "Dashboard Error", dashboards.DashboardErr{StatusCode: 400, Reason: "Dashboard Error", Status: "error"}),
},
{
name: "folder title empty",
input: dashboards.ErrFolderTitleEmpty,
want: response.Error(400, "folder title cannot be empty", nil),
},
{
name: "dashboard type mismatch",
input: dashboards.ErrDashboardTypeMismatch,
want: response.Error(400, "Dashboard cannot be changed to a folder", dashboards.ErrDashboardTypeMismatch),
},
{
name: "dashboard invalid uid",
input: dashboards.ErrDashboardInvalidUid,
want: response.Error(400, "uid contains illegal characters", dashboards.ErrDashboardInvalidUid),
},
{
name: "dashboard uid too long",
input: dashboards.ErrDashboardUidTooLong,
want: response.Error(400, "uid too long, max 40 characters", dashboards.ErrDashboardUidTooLong),
},
{
name: "folder access denied",
input: dashboards.ErrFolderAccessDenied,
want: response.Error(http.StatusForbidden, "Access denied", dashboards.ErrFolderAccessDenied),
},
{
name: "folder not found",
input: dashboards.ErrFolderNotFound,
want: response.JSON(http.StatusNotFound, util.DynMap{"status": "not-found", "message": dashboards.ErrFolderNotFound.Error()}),
},
{
name: "folder with same uid exists",
input: dashboards.ErrFolderWithSameUIDExists,
want: response.Error(http.StatusConflict, dashboards.ErrFolderWithSameUIDExists.Error(), nil),
},
{
name: "folder version mismatch",
input: dashboards.ErrFolderVersionMismatch,
want: response.JSON(http.StatusPreconditionFailed, util.DynMap{"status": "version-mismatch", "message": dashboards.ErrFolderVersionMismatch.Error()}),
},
{
name: "folder max depth reached",
input: folder.ErrMaximumDepthReached,
want: response.JSON(http.StatusBadRequest, util.DynMap{"messageId": "folder.maximum-depth-reached", "message": "Maximum nested folder depth reached"}),
},
{
name: "fallback error",
input: errors.New("some error"),
want: response.ErrOrFallback(http.StatusInternalServerError, "Folder API error", errors.New("some error")),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp := ToFolderErrorResponse(tt.input)
require.Equal(t, tt.want, resp)
})
}
}
+4 -5
View File
@@ -11,10 +11,11 @@ import (
"path/filepath"
"testing"
"github.com/grafana/authlib/claims"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/infra/db"
@@ -27,7 +28,6 @@ import (
"github.com/grafana/grafana/pkg/services/auth/authtest"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/authntest"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/contexthandler"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
@@ -189,7 +189,6 @@ func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHa
return contexthandler.ProvideService(
cfg,
tracing.InitializeTracerForTest(),
&authntest.FakeService{ExpectedIdentity: &authn.Identity{ID: "0", Type: claims.TypeAnonymous, SessionToken: &usertoken.UserToken{}}},
featuremgmt.WithFeatures(),
)
@@ -271,7 +270,7 @@ func setupSimpleHTTPServer(features featuremgmt.FeatureToggles) *HTTPServer {
Cfg: cfg,
Features: features,
License: &licensing.OSSLicensingService{},
AccessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()),
AccessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures()),
annotationsRepo: annotationstest.NewFakeAnnotationsRepo(),
authInfoService: &authinfotest.FakeService{
ExpectedLabels: map[int64]string{int64(1): login.GetAuthProviderLabel(login.LDAPAuthModule)},
@@ -314,7 +313,7 @@ func SetupAPITestServer(t *testing.T, opts ...APITestServerOption) *webtest.Serv
}
if hs.AccessControl == nil {
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient())
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
}
hs.registerRoutes()
+78 -36
View File
@@ -11,7 +11,7 @@ import (
"strconv"
"strings"
"github.com/grafana/authlib/claims"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/api/apierrors"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
@@ -24,7 +24,9 @@ import (
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
dashver "github.com/grafana/grafana/pkg/services/dashboardversion"
"github.com/grafana/grafana/pkg/services/dashboardversion/dashverimpl"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/org"
pref "github.com/grafana/grafana/pkg/services/preference"
@@ -84,6 +86,8 @@ func dashboardGuardianResponse(err error) response.Response {
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
//
//nolint:gocyclo
func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response {
ctx, span := tracer.Start(c.Req.Context(), "api.GetDashboard")
defer span.End()
@@ -101,7 +105,7 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response
)
// If public dashboards is enabled and we have a public dashboard, update meta values
if hs.Features.IsEnabledGlobally(featuremgmt.FlagPublicDashboards) && hs.Cfg.PublicDashboardsEnabled {
if hs.Cfg.PublicDashboardsEnabled {
publicDashboard, err := hs.PublicDashboardsApi.PublicDashboardService.FindByDashboardUid(ctx, c.SignedInUser.GetOrgID(), dash.UID)
if err != nil && !errors.Is(err, publicdashboardModels.ErrPublicDashboardNotFound) {
return response.Error(http.StatusInternalServerError, "Error while retrieving public dashboards", err)
@@ -124,6 +128,11 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response
if isEmptyData {
return response.Error(http.StatusInternalServerError, "Error while loading dashboard, dashboard data is invalid", nil)
}
// the dashboard id is no longer set in the spec for unified storage, set it here to keep api compatibility
if dash.Data.Get("id").MustString() == "" {
dash.Data.Set("id", dash.ID)
}
}
guardian, err := guardian.NewByDashboard(ctx, dash, c.SignedInUser.GetOrgID(), c.SignedInUser)
if err != nil {
@@ -145,10 +154,10 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response
// Finding creator and last updater of the dashboard
updater, creator := anonString, anonString
if dash.UpdatedBy > 0 {
updater = hs.getUserLogin(ctx, dash.UpdatedBy)
updater = hs.getIdentityName(ctx, dash.OrgID, dash.UpdatedBy)
}
if dash.CreatedBy > 0 {
creator = hs.getUserLogin(ctx, dash.CreatedBy)
creator = hs.getIdentityName(ctx, dash.OrgID, dash.CreatedBy)
}
annotationPermissions := &dashboardsV0.AnnotationPermission{}
@@ -182,11 +191,35 @@ func (hs *HTTPServer) GetDashboard(c *contextmodel.ReqContext) response.Response
PublicDashboardEnabled: publicDashboardEnabled,
}
metrics.MFolderIDsAPICount.WithLabelValues(metrics.GetDashboard).Inc()
// lookup folder title
// nolint:staticcheck
if dash.FolderID > 0 {
// nolint:staticcheck
query := dashboards.GetDashboardQuery{ID: dash.FolderID, OrgID: c.SignedInUser.GetOrgID()}
// lookup folder title & url
if dash.FolderUID != "" && hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesClientDashboardsFolders) {
queryResult, err := hs.folderService.Get(ctx, &folder.GetFolderQuery{
OrgID: c.SignedInUser.GetOrgID(),
UID: &dash.FolderUID,
SignedInUser: c.SignedInUser,
})
if errors.Is(err, dashboards.ErrFolderNotFound) {
return response.Error(http.StatusNotFound, "Folder not found", err)
}
if apierrors.IsForbidden(err) {
// the dashboard is in a folder the user can't access, so return the dashboard without folder info
err = nil
queryResult = &folder.Folder{
UID: dash.FolderUID,
}
}
if err != nil {
hs.log.Error("Failed to get dashboard folder", "error", err)
return response.Error(http.StatusInternalServerError, "Dashboard folder could not be read", err)
}
meta.FolderUid = queryResult.UID
meta.FolderTitle = queryResult.Title
meta.FolderId = queryResult.ID // nolint:staticcheck
queryResult = queryResult.WithURL()
meta.FolderUrl = queryResult.URL
} else if dash.FolderID > 0 { // nolint:staticcheck
query := dashboards.GetDashboardQuery{ID: dash.FolderID, OrgID: c.SignedInUser.GetOrgID()} // nolint:staticcheck
metrics.MFolderIDsAPICount.WithLabelValues(metrics.GetDashboard).Inc()
queryResult, err := hs.DashboardService.GetDashboard(ctx, &query)
if err != nil {
@@ -260,16 +293,24 @@ func (hs *HTTPServer) getAnnotationPermissionsByScope(c *contextmodel.ReqContext
}
}
func (hs *HTTPServer) getUserLogin(ctx context.Context, userID int64) string {
ctx, span := tracer.Start(ctx, "api.getUserLogin")
// getIdentityName returns name of either user or service account
func (hs *HTTPServer) getIdentityName(ctx context.Context, orgID, id int64) string {
ctx, span := tracer.Start(ctx, "api.getIdentityName")
defer span.End()
query := user.GetUserByIDQuery{ID: userID}
user, err := hs.userService.GetByID(ctx, &query)
// We use GetSignedInUser here instead of GetByID so both user and service accounts are resolved.
ident, err := hs.userService.GetSignedInUser(ctx, &user.GetSignedInUserQuery{
UserID: id,
OrgID: orgID,
})
if err != nil {
return anonString
}
return user.Login
if ident.IsIdentityType(claims.TypeServiceAccount) {
return ident.GetName()
}
return ident.GetLogin()
}
func (hs *HTTPServer) getDashboardHelper(ctx context.Context, orgID int64, id int64, uid string) (*dashboards.Dashboard, response.Response) {
@@ -472,13 +513,7 @@ func (hs *HTTPServer) deleteDashboard(c *contextmodel.ReqContext) response.Respo
"error", err)
}
// deletes all related public dashboard entities
err = hs.PublicDashboardsApi.PublicDashboardService.DeleteByDashboard(c.Req.Context(), dash)
if err != nil {
hs.log.Error("Failed to delete public dashboard")
}
err = hs.DashboardService.DeleteDashboard(c.Req.Context(), dash.ID, c.SignedInUser.GetOrgID())
err = hs.DashboardService.DeleteDashboard(c.Req.Context(), dash.ID, dash.UID, c.SignedInUser.GetOrgID())
if err != nil {
var dashboardErr dashboards.DashboardErr
if ok := errors.As(err, &dashboardErr); ok {
@@ -806,21 +841,22 @@ func (hs *HTTPServer) GetDashboardVersions(c *contextmodel.ReqContext) response.
}
query := dashver.ListDashboardVersionsQuery{
OrgID: c.SignedInUser.GetOrgID(),
DashboardID: dash.ID,
DashboardUID: dash.UID,
Limit: c.QueryInt("limit"),
Start: c.QueryInt("start"),
OrgID: c.SignedInUser.GetOrgID(),
DashboardID: dash.ID,
DashboardUID: dash.UID,
Limit: c.QueryInt("limit"),
Start: c.QueryInt("start"),
ContinueToken: c.Query("continueToken"),
}
versions, err := hs.dashboardVersionService.List(c.Req.Context(), &query)
resp, err := hs.dashboardVersionService.List(c.Req.Context(), &query)
if err != nil {
return response.Error(http.StatusNotFound, fmt.Sprintf("No versions found for dashboardId %d", dash.ID), err)
}
loginMem := make(map[int64]string, len(versions))
res := make([]dashver.DashboardVersionMeta, 0, len(versions))
for _, version := range versions {
loginMem := make(map[int64]string, len(resp.Versions))
res := make([]dashver.DashboardVersionMeta, 0, len(resp.Versions))
for _, version := range resp.Versions {
msg := version.Message
if version.RestoredFrom == version.Version {
msg = "Initial save (created by migration)"
@@ -840,7 +876,7 @@ func (hs *HTTPServer) GetDashboardVersions(c *contextmodel.ReqContext) response.
if found {
creator = login
} else {
creator = hs.getUserLogin(c.Req.Context(), version.CreatedBy)
creator = hs.getIdentityName(c.Req.Context(), c.SignedInUser.GetOrgID(), version.CreatedBy)
if creator != anonString {
loginMem[version.CreatedBy] = creator
}
@@ -861,7 +897,10 @@ func (hs *HTTPServer) GetDashboardVersions(c *contextmodel.ReqContext) response.
})
}
return response.JSON(http.StatusOK, res)
return response.JSON(http.StatusOK, dashver.DashboardVersionResponseMeta{
Versions: res,
ContinueToken: resp.ContinueToken,
})
}
// swagger:route GET /dashboards/id/{DashboardID}/versions/{DashboardVersionID} dashboard_versions getDashboardVersionByID
@@ -921,12 +960,15 @@ func (hs *HTTPServer) GetDashboardVersion(c *contextmodel.ReqContext) response.R
return dashboardGuardianResponse(err)
}
version, _ := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 32)
version, err := strconv.ParseInt(web.Params(c.Req)[":id"], 10, 64)
if err != nil {
return response.Err(err)
}
query := dashver.GetDashboardVersionQuery{
OrgID: c.SignedInUser.GetOrgID(),
DashboardID: dash.ID,
DashboardUID: dash.UID,
Version: int(version),
Version: version,
}
res, err := hs.dashboardVersionService.Get(c.Req.Context(), &query)
@@ -936,7 +978,7 @@ func (hs *HTTPServer) GetDashboardVersion(c *contextmodel.ReqContext) response.R
creator := anonString
if res.CreatedBy > 0 {
creator = hs.getUserLogin(c.Req.Context(), res.CreatedBy)
creator = hs.getIdentityName(c.Req.Context(), dash.OrgID, res.CreatedBy)
}
dashVersionMeta := &dashver.DashboardVersionMeta{
@@ -1136,7 +1178,7 @@ func (hs *HTTPServer) RestoreDashboardVersion(c *contextmodel.ReqContext) respon
saveCmd.Dashboard = version.Data
saveCmd.Dashboard.Set("version", dash.Version)
saveCmd.Dashboard.Set("uid", dash.UID)
saveCmd.Message = fmt.Sprintf("Restored from version %d", version.Version)
saveCmd.Message = dashverimpl.DashboardRestoreMessage(version.Version)
// nolint:staticcheck
saveCmd.FolderID = dash.FolderID
metrics.MFolderIDsAPICount.WithLabelValues(metrics.RestoreDashboardVersion).Inc()
+2 -2
View File
@@ -212,10 +212,10 @@ func (hs *HTTPServer) getDashboardACL(ctx context.Context, user identity.Request
FolderID: dashboard.FolderID, // nolint:staticcheck
Created: p.Created,
Updated: p.Updated,
UserID: p.UserId,
UserID: p.UserID,
UserLogin: p.UserLogin,
UserEmail: p.UserEmail,
TeamID: p.TeamId,
TeamID: p.TeamID,
TeamEmail: p.TeamEmail,
Team: p.Team,
Role: role,
+2 -2
View File
@@ -58,8 +58,8 @@ func TestHTTPServer_GetDashboardPermissionList(t *testing.T) {
hs.DashboardService = svc
hs.dashboardPermissionsService = &actest.FakePermissionsService{
ExpectedPermissions: []accesscontrol.ResourcePermission{
{UserId: 1, UserLogin: "regular", IsManaged: true},
{UserId: 2, UserLogin: "hidden", IsManaged: true},
{UserID: 1, UserLogin: "regular", IsManaged: true},
{UserID: 2, UserLogin: "hidden", IsManaged: true},
},
}
})
+1 -1
View File
@@ -77,7 +77,7 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *contextmodel.ReqContext) {
// Do not check permissions when the instance snapshot public mode is enabled
if !hs.Cfg.SnapshotPublicMode {
evaluator := ac.EvalPermission(dashboards.ActionDashboardsWrite, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(cmd.Dashboard.GetNestedString("uid")))
evaluator := ac.EvalAll(ac.EvalPermission(dashboards.ActionSnapshotsCreate), ac.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(cmd.Dashboard.GetNestedString("uid"))))
if canSave, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator); err != nil || !canSave {
c.JsonApiErr(http.StatusForbidden, "forbidden", err)
return
+6 -6
View File
@@ -15,9 +15,9 @@ import (
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db/dbtest"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboardsnapshots"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -40,18 +40,18 @@ func TestHTTPServer_DeleteDashboardSnapshot(t *testing.T) {
hs.DashboardService = svc
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient())
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService)
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService, hs.folderService, log.NewNopLogger())
})
}
allowedUser := userWithPermissions(1, []accesscontrol.Permission{
{Action: dashboards.ActionDashboardsWrite, Scope: "dashboards:uid:1"},
{Action: dashboards.ActionSnapshotsDelete},
})
t.Run("User should not be able to delete snapshot without permissions", func(t *testing.T) {
svc := dashboards.NewFakeDashboardService(t)
svc.On("GetDashboard", mock.Anything, mock.Anything).Return(&dashboards.Dashboard{UID: "1"}, nil)
server := setup(t, svc, 0, "")
res, err := server.Send(webtest.RequestWithSignedInUser(
@@ -116,7 +116,7 @@ func TestHTTPServer_DeleteDashboardSnapshot(t *testing.T) {
server := setup(t, svc, 1, "")
res, err := server.Send(webtest.RequestWithSignedInUser(
server.NewRequest(http.MethodDelete, "/api/snapshots/12345", nil),
&user.SignedInUser{UserID: 1, OrgID: 1},
allowedUser,
))
require.NoError(t, err)
@@ -406,7 +406,7 @@ func setUpSnapshotTest(t *testing.T, userId int64, deleteUrl string) dashboardsn
res.External = true
res.ExternalDeleteURL = deleteUrl
}
dashSnapSvc.On("GetDashboardSnapshot", mock.Anything, mock.AnythingOfType("*dashboardsnapshots.GetDashboardSnapshotQuery")).Return(res, nil)
dashSnapSvc.On("GetDashboardSnapshot", mock.Anything, mock.AnythingOfType("*dashboardsnapshots.GetDashboardSnapshotQuery")).Return(res, nil).Maybe()
dashSnapSvc.On("DeleteDashboardSnapshot", mock.Anything, mock.AnythingOfType("*dashboardsnapshots.DeleteDashboardSnapshotCommand")).Return(nil).Maybe()
return dashSnapSvc
}
+32 -31
View File
@@ -30,7 +30,7 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/annotations/annotationstest"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/apiserver/client"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboards/database"
@@ -55,12 +55,14 @@ import (
"github.com/grafana/grafana/pkg/services/publicdashboards/api"
publicdashboardModels "github.com/grafana/grafana/pkg/services/publicdashboards/models"
"github.com/grafana/grafana/pkg/services/quota/quotatest"
"github.com/grafana/grafana/pkg/services/search/sort"
"github.com/grafana/grafana/pkg/services/star/startest"
"github.com/grafana/grafana/pkg/services/supportbundles/supportbundlestest"
"github.com/grafana/grafana/pkg/services/tag/tagimpl"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/web"
"github.com/grafana/grafana/pkg/web/webtest"
)
@@ -131,7 +133,7 @@ func newTestLive(t *testing.T, store db.DB) *live.GrafanaLive {
nil,
&usagestats.UsageStatsMock{T: t},
nil,
features, acimpl.ProvideAccessControl(features, zanzana.NewNoopClient()), &dashboards.FakeDashboardService{}, annotationstest.NewFakeAnnotationsRepo(), nil)
features, acimpl.ProvideAccessControl(features), &dashboards.FakeDashboardService{}, annotationstest.NewFakeAnnotationsRepo(), nil)
require.NoError(t, err)
return gLive
}
@@ -148,11 +150,11 @@ func TestHTTPServer_GetDashboard_AccessControl(t *testing.T) {
hs.DashboardService = dashSvc
hs.Cfg = setting.NewCfg()
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient())
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
hs.starService = startest.NewStarServiceFake()
hs.dashboardProvisioningService = mockDashboardProvisioningService{}
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService)
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService, hs.folderService, log.NewNopLogger())
})
}
@@ -263,24 +265,22 @@ func TestHTTPServer_DeleteDashboardByUID_AccessControl(t *testing.T) {
dashSvc := dashboards.NewFakeDashboardService(t)
dashSvc.On("GetDashboard", mock.Anything, mock.Anything).Return(dash, nil).Maybe()
dashSvc.On("DeleteDashboard", mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
dashSvc.On("DeleteDashboard", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
hs.DashboardService = dashSvc
hs.Cfg = setting.NewCfg()
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient())
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
hs.starService = startest.NewStarServiceFake()
hs.LibraryPanelService = &mockLibraryPanelService{}
hs.LibraryElementService = &libraryelementsfake.LibraryElementService{}
pubDashService := publicdashboards.NewFakePublicDashboardService(t)
pubDashService.On("DeleteByDashboard", mock.Anything, mock.Anything).Return(nil).Maybe()
middleware := publicdashboards.NewFakePublicDashboardMiddleware(t)
license := licensingtest.NewFakeLicensing()
license.On("FeatureEnabled", publicdashboardModels.FeaturePublicDashboardsEmailSharing).Return(false)
hs.PublicDashboardsApi = api.ProvideApi(pubDashService, nil, hs.AccessControl, featuremgmt.WithFeatures(), middleware, hs.Cfg, license)
hs.PublicDashboardsApi = api.ProvideApi(nil, nil, hs.AccessControl, featuremgmt.WithFeatures(), middleware, hs.Cfg, license)
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService)
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService, hs.folderService, log.NewNopLogger())
})
}
deleteDashboard := func(server *webtest.Server, permissions []accesscontrol.Permission) (*http.Response, error) {
@@ -323,7 +323,7 @@ func TestHTTPServer_GetDashboardVersions_AccessControl(t *testing.T) {
hs.DashboardService = dashSvc
hs.Cfg = setting.NewCfg()
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient())
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
hs.starService = startest.NewStarServiceFake()
hs.dashboardVersionService = &dashvertest.FakeDashboardVersionService{
@@ -331,7 +331,7 @@ func TestHTTPServer_GetDashboardVersions_AccessControl(t *testing.T) {
ExpectedDashboardVersion: &dashver.DashboardVersionDTO{},
}
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService)
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService, hs.folderService, log.NewNopLogger())
})
}
@@ -476,13 +476,10 @@ func TestDashboardAPIEndpoint(t *testing.T) {
{SaveError: dashboards.ErrDashboardNotFound, ExpectedStatusCode: http.StatusNotFound},
{SaveError: dashboards.ErrFolderNotFound, ExpectedStatusCode: http.StatusBadRequest},
{SaveError: dashboards.ErrDashboardWithSameUIDExists, ExpectedStatusCode: http.StatusBadRequest},
{SaveError: dashboards.ErrDashboardWithSameNameInFolderExists, ExpectedStatusCode: http.StatusPreconditionFailed},
{SaveError: dashboards.ErrDashboardVersionMismatch, ExpectedStatusCode: http.StatusPreconditionFailed},
{SaveError: dashboards.ErrDashboardTitleEmpty, ExpectedStatusCode: http.StatusBadRequest},
{SaveError: dashboards.ErrDashboardFolderCannotHaveParent, ExpectedStatusCode: http.StatusBadRequest},
{SaveError: dashboards.ErrDashboardTypeMismatch, ExpectedStatusCode: http.StatusBadRequest},
{SaveError: dashboards.ErrDashboardFolderWithSameNameAsDashboard, ExpectedStatusCode: http.StatusBadRequest},
{SaveError: dashboards.ErrDashboardWithSameNameAsFolder, ExpectedStatusCode: http.StatusBadRequest},
{SaveError: dashboards.ErrDashboardFolderNameExists, ExpectedStatusCode: http.StatusBadRequest},
{SaveError: dashboards.ErrDashboardUpdateAccessDenied, ExpectedStatusCode: http.StatusForbidden},
{SaveError: dashboards.ErrDashboardInvalidUid, ExpectedStatusCode: http.StatusBadRequest},
@@ -745,14 +742,14 @@ func TestDashboardVersionsAPIEndpoint(t *testing.T) {
},
}
getHS(&usertest.FakeUserService{
ExpectedUser: &user.User{ID: 1, Login: "test-user"},
ExpectedSignedInUser: &user.SignedInUser{Login: "test-user"},
}).callGetDashboardVersions(sc)
assert.Equal(t, http.StatusOK, sc.resp.Code)
var versions []dashver.DashboardVersionMeta
var versions dashver.DashboardVersionResponseMeta
err := json.NewDecoder(sc.resp.Body).Decode(&versions)
require.NoError(t, err)
for _, v := range versions {
for _, v := range versions.Versions {
assert.Equal(t, "test-user", v.CreatedBy)
}
}, mockSQLStore)
@@ -775,10 +772,10 @@ func TestDashboardVersionsAPIEndpoint(t *testing.T) {
}).callGetDashboardVersions(sc)
assert.Equal(t, http.StatusOK, sc.resp.Code)
var versions []dashver.DashboardVersionMeta
var versions dashver.DashboardVersionResponseMeta
err := json.NewDecoder(sc.resp.Body).Decode(&versions)
require.NoError(t, err)
for _, v := range versions {
for _, v := range versions.Versions {
assert.Equal(t, anonString, v.CreatedBy)
}
}, mockSQLStore)
@@ -801,10 +798,10 @@ func TestDashboardVersionsAPIEndpoint(t *testing.T) {
}).callGetDashboardVersions(sc)
assert.Equal(t, http.StatusOK, sc.resp.Code)
var versions []dashver.DashboardVersionMeta
var versions dashver.DashboardVersionResponseMeta
err := json.NewDecoder(sc.resp.Body).Decode(&versions)
require.NoError(t, err)
for _, v := range versions {
for _, v := range versions.Versions {
assert.Equal(t, anonString, v.CreatedBy)
}
}, mockSQLStore)
@@ -821,8 +818,7 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr
var err error
if dashboardStore == nil {
sql, cfg := db.InitTestDBWithCfg(t)
quotaService := quotatest.New(false, nil)
dashboardStore, err = database.ProvideDashboardStore(sql, cfg, features, tagimpl.ProvideService(sql), quotaService)
dashboardStore, err = database.ProvideDashboardStore(sql, cfg, features, tagimpl.ProvideService(sql))
require.NoError(t, err)
}
@@ -835,20 +831,25 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr
db := db.InitTestDB(t)
fStore := folderimpl.ProvideStore(db)
folderSvc := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()),
dashboardStore, folderStore, db, features, cfg, folderPermissions,
supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest())
quotaService := quotatest.New(false, nil)
folderSvc := folderimpl.ProvideService(
fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashboardStore, folderStore,
nil, db, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil,
dualwrite.ProvideTestService(), sort.ProvideService())
if dashboardService == nil {
dashboardService, err = service.ProvideDashboardServiceImpl(
cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions,
ac, folderSvc, fStore, nil,
cfg, dashboardStore, folderStore, features, folderPermissions,
ac, folderSvc, fStore, nil, client.MockTestRestConfig{}, nil, quotaService, nil, nil, nil,
dualwrite.ProvideTestService(), sort.ProvideService(),
)
require.NoError(t, err)
dashboardService.(dashboards.PermissionsRegistrationService).RegisterDashboardPermissions(dashboardPermissions)
}
dashboardProvisioningService, err := service.ProvideDashboardServiceImpl(
cfg, dashboardStore, folderStore, features, folderPermissions, dashboardPermissions,
ac, folderSvc, fStore, nil,
cfg, dashboardStore, folderStore, features, folderPermissions,
ac, folderSvc, fStore, nil, client.MockTestRestConfig{}, nil, quotaService, nil, nil, nil,
dualwrite.ProvideTestService(), sort.ProvideService(),
)
require.NoError(t, err)
+14 -109
View File
@@ -6,14 +6,10 @@ import (
"errors"
"fmt"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"github.com/prometheus/prometheus/promql/parser"
"golang.org/x/exp/slices"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/api/datasource"
@@ -22,10 +18,8 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/log"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
@@ -339,7 +333,7 @@ func validateURL(cmdType string, url string) response.Response {
// validateJSONData prevents the user from adding a custom header with name that matches the auth proxy header name.
// This is done to prevent data source proxy from being used to circumvent auth proxy.
// For more context take a look at CVE-2022-35957
func validateJSONData(ctx context.Context, jsonData *simplejson.Json, cfg *setting.Cfg, features featuremgmt.FeatureToggles) error {
func validateJSONData(jsonData *simplejson.Json, cfg *setting.Cfg) error {
if jsonData == nil {
return nil
}
@@ -356,69 +350,9 @@ func validateJSONData(ctx context.Context, jsonData *simplejson.Json, cfg *setti
}
}
// Prevent adding a data source team header with a name that matches the auth proxy header name
if features.IsEnabled(ctx, featuremgmt.FlagTeamHttpHeaders) {
err := validateTeamHTTPHeaderJSON(jsonData)
if err != nil {
return err
}
}
return nil
}
// we only allow for now the following headers to be added to a data source team header
var validHeaders = []string{"X-Prom-Label-Policy"}
func validateTeamHTTPHeaderJSON(jsonData *simplejson.Json) error {
teamHTTPHeadersJSON, err := datasources.GetTeamHTTPHeaders(jsonData)
if err != nil {
datasourcesLogger.Error("Unable to marshal TeamHTTPHeaders")
return errors.New("validation error, invalid format of TeamHTTPHeaders")
}
if teamHTTPHeadersJSON == nil {
return nil
}
// whitelisting ValidHeaders
// each teams headers
for _, teamheaders := range teamHTTPHeadersJSON.Headers {
for _, header := range teamheaders {
if !slices.ContainsFunc(validHeaders, func(v string) bool {
return http.CanonicalHeaderKey(v) == http.CanonicalHeaderKey(header.Header)
}) {
datasourcesLogger.Error("Cannot add a data source team header that is different than", "headerName", header.Header)
return errors.New("validation error, invalid header name specified")
}
if !validateLBACHeader(header.Value) {
datasourcesLogger.Error("Cannot add a data source team header value with invalid value", "headerValue", header.Value)
return errors.New("validation error, invalid header value syntax")
}
}
}
return nil
}
// validateLBACHeader returns true if the header value matches the syntax
// 1234:{ name!="value",foo!~"bar" }
func validateLBACHeader(headervalue string) bool {
exp := `^\d+:(.+)`
pattern, err := regexp.Compile(exp)
if err != nil {
return false
}
match := pattern.FindSubmatch([]byte(strings.TrimSpace(headervalue)))
if match == nil || len(match) < 2 {
return false
}
_, err = parser.ParseMetricSelector(string(match[1]))
return err == nil
}
func evaluateTeamHTTPHeaderPermissions(hs *HTTPServer, c *contextmodel.ReqContext, scope string) (bool, error) {
ev := ac.EvalPermission(datasources.ActionPermissionsWrite, ac.Scope(scope))
return hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, ev)
}
// swagger:route POST /datasources datasources addDataSource
//
// Create a data source.
@@ -453,10 +387,18 @@ func (hs *HTTPServer) AddDataSource(c *contextmodel.ReqContext) response.Respons
}
}
if err := validateJSONData(c.Req.Context(), cmd.JsonData, hs.Cfg, hs.Features); err != nil {
if err := validateJSONData(cmd.JsonData, hs.Cfg); err != nil {
return response.Error(http.StatusBadRequest, "Failed to add datasource", err)
}
// It's forbidden to update the rules from the datasource api.
// team HTTP headers update have to be done through `updateDatasourceLBACRules`
if cmd.JsonData != nil {
if _, ok := cmd.JsonData.CheckGet("teamHttpHeaders"); ok {
return response.Error(http.StatusForbidden, "Cannot create datasource with team HTTP headers, need to use updateDatasourceLBACRules API", nil)
}
}
dataSource, err := hs.DataSourcesService.AddDataSource(c.Req.Context(), &cmd)
if err != nil {
if errors.Is(err, datasources.ErrDataSourceNameExists) || errors.Is(err, datasources.ErrDataSourceUidExists) {
@@ -518,7 +460,7 @@ func (hs *HTTPServer) UpdateDataSourceByID(c *contextmodel.ReqContext) response.
if resp := validateURL(cmd.Type, cmd.URL); resp != nil {
return resp
}
if err := validateJSONData(c.Req.Context(), cmd.JsonData, hs.Cfg, hs.Features); err != nil {
if err := validateJSONData(cmd.JsonData, hs.Cfg); err != nil {
return response.Error(http.StatusBadRequest, "Failed to update datasource", err)
}
ds, err := hs.getRawDataSourceById(c.Req.Context(), cmd.ID, cmd.OrgID)
@@ -529,12 +471,6 @@ func (hs *HTTPServer) UpdateDataSourceByID(c *contextmodel.ReqContext) response.
return response.Error(http.StatusInternalServerError, "Failed to update datasource", err)
}
// check if LBAC rules have been modified
hasAccess, errAccess := checkTeamHTTPHeaderPermissions(hs, c, ds, cmd)
if !hasAccess {
return response.Error(http.StatusForbidden, fmt.Sprintf("You'll need additional permissions to perform this action. Permissions needed: %s", datasources.ActionPermissionsWrite), errAccess)
}
return hs.updateDataSourceByID(c, ds, cmd)
}
@@ -553,6 +489,7 @@ func (hs *HTTPServer) UpdateDataSourceByID(c *contextmodel.ReqContext) response.
// 200: createOrUpdateDatasourceResponse
// 401: unauthorisedError
// 403: forbiddenError
// 409: conflictError
// 500: internalServerError
func (hs *HTTPServer) UpdateDataSourceByUID(c *contextmodel.ReqContext) response.Response {
cmd := datasources.UpdateDataSourceCommand{}
@@ -564,7 +501,7 @@ func (hs *HTTPServer) UpdateDataSourceByUID(c *contextmodel.ReqContext) response
if resp := validateURL(cmd.Type, cmd.URL); resp != nil {
return resp
}
if err := validateJSONData(c.Req.Context(), cmd.JsonData, hs.Cfg, hs.Features); err != nil {
if err := validateJSONData(cmd.JsonData, hs.Cfg); err != nil {
return response.Error(http.StatusBadRequest, "Failed to update datasource", err)
}
@@ -577,36 +514,9 @@ func (hs *HTTPServer) UpdateDataSourceByUID(c *contextmodel.ReqContext) response
}
cmd.ID = ds.ID
// check if LBAC rules have been modified
hasAccess, errAccess := checkTeamHTTPHeaderPermissions(hs, c, ds, cmd)
if !hasAccess {
return response.Error(http.StatusForbidden, fmt.Sprintf("You'll need additional permissions to perform this action. Permissions needed: %s", datasources.ActionPermissionsWrite), errAccess)
}
return hs.updateDataSourceByID(c, ds, cmd)
}
func getEncodedString(jsonData *simplejson.Json, key string) string {
if jsonData == nil {
return ""
}
jsonValues, exists := jsonData.CheckGet(key)
if !exists {
return ""
}
val, _ := jsonValues.Encode()
return string(val)
}
func checkTeamHTTPHeaderPermissions(hs *HTTPServer, c *contextmodel.ReqContext, ds *datasources.DataSource, cmd datasources.UpdateDataSourceCommand) (bool, error) {
currentTeamHTTPHeaders := getEncodedString(ds.JsonData, "teamHttpHeaders")
newTeamHTTPHeaders := getEncodedString(cmd.JsonData, "teamHttpHeaders")
if (currentTeamHTTPHeaders != "" || newTeamHTTPHeaders != "") && currentTeamHTTPHeaders != newTeamHTTPHeaders {
return evaluateTeamHTTPHeaderPermissions(hs, c, datasources.ScopePrefix+ds.UID)
}
return true, nil
}
func (hs *HTTPServer) updateDataSourceByID(c *contextmodel.ReqContext, ds *datasources.DataSource, cmd datasources.UpdateDataSourceCommand) response.Response {
if ds.ReadOnly {
return response.Error(http.StatusForbidden, "Cannot update read-only data source", nil)
@@ -928,12 +838,7 @@ func (hs *HTTPServer) checkDatasourceHealth(c *contextmodel.ReqContext, ds *data
Headers: map[string]string{},
}
var dsURL string
if req.PluginContext.DataSourceInstanceSettings != nil {
dsURL = req.PluginContext.DataSourceInstanceSettings.URL
}
err = hs.PluginRequestValidator.Validate(dsURL, c.Req)
err = hs.DataSourceRequestValidator.Validate(ds, c.Req)
if err != nil {
return response.Error(http.StatusForbidden, "Access denied", err)
}
+49 -125
View File
@@ -19,7 +19,6 @@ import (
ac "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/authz/zanzana"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/datasources/guardian"
@@ -116,7 +115,7 @@ func TestAddDataSource_URLWithoutProtocol(t *testing.T) {
expectedDatasource: &datasources.DataSource{},
},
Cfg: setting.NewCfg(),
AccessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()),
AccessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures()),
accesscontrolService: actest.FakeService{},
}
@@ -223,104 +222,58 @@ func TestUpdateDataSource_InvalidJSONData(t *testing.T) {
assert.Equal(t, 400, sc.resp.Code)
}
// Using a team HTTP header whose name matches the name specified for auth proxy header should fail
func TestUpdateDataSourceTeamHTTPHeaders_InvalidJSONData(t *testing.T) {
func TestAddDataSourceTeamHTTPHeaders(t *testing.T) {
tenantID := "1234"
testcases := []struct {
desc string
data datasources.TeamHTTPHeaders
want int
}{
{
desc: "We should only allow for headers being X-Prom-Label-Policy",
data: datasources.TeamHTTPHeaders{
Headers: datasources.TeamHeaders{
tenantID: []datasources.TeamHTTPHeader{
{
Header: "Authorization",
Value: "foo!=bar",
},
},
}},
want: 400,
hs := &HTTPServer{
DataSourcesService: &dataSourcesServiceMock{
expectedDatasource: &datasources.DataSource{},
},
{
desc: "Allowed header but no team id",
data: datasources.TeamHTTPHeaders{
Headers: datasources.TeamHeaders{"": []datasources.TeamHTTPHeader{
{
Header: "X-Prom-Label-Policy",
Value: "foo=bar",
},
},
}},
want: 400,
},
{
desc: "Allowed team id and header name with invalid header values ",
data: datasources.TeamHTTPHeaders{
Headers: datasources.TeamHeaders{tenantID: []datasources.TeamHTTPHeader{
{
Header: "X-Prom-Label-Policy",
Value: "Bad value",
},
},
}},
want: 400,
},
// Complete valid case, with team id, header name and header value
{
desc: "Allowed header and header values ",
data: datasources.TeamHTTPHeaders{
Headers: datasources.TeamHeaders{tenantID: []datasources.TeamHTTPHeader{
{
Header: "X-Prom-Label-Policy",
Value: `1234:{ name!="value",foo!~"bar" }`,
},
},
}},
want: 200,
Cfg: setting.NewCfg(),
Features: featuremgmt.WithFeatures(),
accesscontrolService: actest.FakeService{},
AccessControl: actest.FakeAccessControl{
ExpectedEvaluate: true,
ExpectedErr: nil,
},
}
for _, tc := range testcases {
t.Run(tc.desc, func(t *testing.T) {
hs := &HTTPServer{
DataSourcesService: &dataSourcesServiceMock{
expectedDatasource: &datasources.DataSource{},
sc := setupScenarioContext(t, fmt.Sprintf("/api/datasources/%s", tenantID))
hs.Cfg.AuthProxy.Enabled = true
jsonData := simplejson.New()
jsonData.Set("teamHttpHeaders", datasources.TeamHTTPHeaders{
Headers: datasources.TeamHeaders{
tenantID: []datasources.TeamHTTPHeader{
{
Header: "Authorization",
Value: "foo!=bar",
},
Cfg: setting.NewCfg(),
Features: featuremgmt.WithFeatures(featuremgmt.FlagTeamHttpHeaders),
accesscontrolService: actest.FakeService{},
AccessControl: actest.FakeAccessControl{
ExpectedEvaluate: true,
ExpectedErr: nil,
},
}
sc := setupScenarioContext(t, fmt.Sprintf("/api/datasources/%s", tenantID))
hs.Cfg.AuthProxy.Enabled = true
jsonData := simplejson.New()
jsonData.Set("teamHttpHeaders", tc.data)
sc.m.Put(sc.url, routing.Wrap(func(c *contextmodel.ReqContext) response.Response {
c.Req.Body = mockRequestBody(datasources.AddDataSourceCommand{
Name: "Test",
URL: "localhost:5432",
Access: "direct",
Type: "test",
JsonData: jsonData,
})
c.SignedInUser = authedUserWithPermissions(1, 1, []ac.Permission{
{Action: datasources.ActionPermissionsWrite, Scope: datasources.ScopeAll},
})
return hs.AddDataSource(c)
}))
sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec()
assert.Equal(t, tc.want, sc.resp.Code)
},
},
})
sc.m.Put(sc.url, routing.Wrap(func(c *contextmodel.ReqContext) response.Response {
c.Req.Body = mockRequestBody(datasources.AddDataSourceCommand{
Name: "Test",
URL: "localhost:5432",
Access: "direct",
Type: "test",
JsonData: jsonData,
})
}
c.SignedInUser = authedUserWithPermissions(1, 1, []ac.Permission{
{Action: datasources.ActionPermissionsWrite, Scope: datasources.ScopeAll},
})
return hs.AddDataSource(c)
}))
sc.fakeReqWithParams("PUT", sc.url, map[string]string{}).exec()
assert.Equal(t, http.StatusForbidden, sc.resp.Code)
// Parse the JSON response
var response map[string]string
err := json.Unmarshal(sc.resp.Body.Bytes(), &response)
assert.NoError(t, err, "Failed to parse JSON response")
// Check the error message in the JSON response
assert.Equal(t, "Cannot create datasource with team HTTP headers, need to use updateDatasourceLBACRules API", response["message"])
}
// Updating data sources with URLs not specifying protocol should work.
@@ -333,7 +286,7 @@ func TestUpdateDataSource_URLWithoutProtocol(t *testing.T) {
expectedDatasource: &datasources.DataSource{},
},
Cfg: setting.NewCfg(),
AccessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()),
AccessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures()),
accesscontrolService: actest.FakeService{},
}
@@ -366,7 +319,7 @@ func TestUpdateDataSourceByID_DataSourceNameExists(t *testing.T) {
},
},
Cfg: setting.NewCfg(),
AccessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()),
AccessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures()),
accesscontrolService: actest.FakeService{},
Live: newTestLive(t, nil),
}
@@ -493,35 +446,6 @@ func TestAPI_datasources_AccessControl(t *testing.T) {
}
}
func TestValidateLBACHeader(t *testing.T) {
testcases := []struct {
desc string
teamHeaderValue string
want bool
}{
{
desc: "Should allow valid header",
teamHeaderValue: `1234:{ name!="value",foo!~"bar" }`,
want: true,
},
{
desc: "Should allow valid selector",
teamHeaderValue: `1234:{ name!="value",foo!~"bar/baz.foo" }`,
want: true,
},
{
desc: "Should return false for incorrect header value",
teamHeaderValue: `1234:!="value",foo!~"bar" }`,
want: false,
},
}
for _, tc := range testcases {
t.Run(tc.desc, func(t *testing.T) {
assert.Equal(t, tc.want, validateLBACHeader(tc.teamHeaderValue))
})
}
}
type dataSourcesServiceMock struct {
datasources.DataSourceService
+6 -6
View File
@@ -36,7 +36,7 @@ import (
"github.com/grafana/grafana/pkg/web/webtest"
)
type fakePluginRequestValidator struct {
type fakeDataSourceRequestValidator struct {
err error
}
@@ -45,7 +45,7 @@ type secretsErrorResponseBody struct {
Message string `json:"message"`
}
func (rv *fakePluginRequestValidator) Validate(dsURL string, req *http.Request) error {
func (rv *fakeDataSourceRequestValidator) Validate(ds *datasources.DataSource, req *http.Request) error {
return rv.err
}
@@ -56,7 +56,7 @@ func TestAPIEndpoint_Metrics_QueryMetricsV2(t *testing.T) {
cfg,
nil,
nil,
&fakePluginRequestValidator{},
&fakeDataSourceRequestValidator{},
&fakePluginClient{
QueryDataHandlerFunc: func(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
resp := backend.Responses{
@@ -114,7 +114,7 @@ func TestAPIEndpoint_Metrics_PluginDecryptionFailure(t *testing.T) {
cfg,
nil,
nil,
&fakePluginRequestValidator{},
&fakeDataSourceRequestValidator{},
&fakePluginClient{
QueryDataHandlerFunc: func(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
resp := backend.Responses{
@@ -249,7 +249,7 @@ func TestDataSourceQueryError(t *testing.T) {
expectedStatus: errutil.StatusInternal.HTTPStatus(),
expectedBody: body{
Message: "An error occurred within the plugin",
MessageId: "plugin.downstreamError",
MessageId: "plugin.requestFailureError",
StatusCode: 500,
},
},
@@ -309,7 +309,7 @@ func TestDataSourceQueryError(t *testing.T) {
cfg,
&fakeDatasources.FakeCacheService{},
nil,
&fakePluginRequestValidator{},
&fakeDataSourceRequestValidator{},
pluginClient.ProvideService(r),
plugincontext.ProvideService(cfg, localcache.ProvideService(), &pluginstore.FakePluginStore{
PluginList: []pluginstore.Plugin{pluginstore.ToGrafanaDTO(p)},
+2 -2
View File
@@ -54,10 +54,10 @@ type CalculateDiffOptions struct {
type CalculateDiffTarget struct {
DashboardId int64 `json:"dashboardId"`
Version int `json:"version"`
Version int64 `json:"version"`
UnsavedDashboard *simplejson.Json `json:"unsavedDashboard"`
}
type RestoreDashboardVersionCommand struct {
Version int `json:"version" binding:"Required"`
Version int64 `json:"version" binding:"Required"`
}
+8
View File
@@ -28,6 +28,10 @@ type Folder struct {
ParentUID string `json:"parentUid,omitempty"`
// the parent folders starting from the root going down
Parents []Folder `json:"parents,omitempty"`
// When the folder belongs to a repository
// NOTE: this is only populated when folders are managed by unified storage
Repository string `json:"repository,omitempty"`
}
type FolderSearchHit struct {
@@ -35,4 +39,8 @@ type FolderSearchHit struct {
UID string `json:"uid" xorm:"uid"`
Title string `json:"title"`
ParentUID string `json:"parentUid,omitempty"`
// When the folder belongs to a repository
// NOTE: this is only populated when folders are managed by unified storage
Repository string `json:"repository,omitempty"`
}
+11
View File
@@ -33,6 +33,7 @@ type FrontendSettingsAuthDTO struct {
DisableLogin bool `json:"disableLogin"`
BasicAuthStrongPasswordPolicy bool `json:"basicAuthStrongPasswordPolicy"`
PasswordlessEnabled bool `json:"passwordlessEnabled"`
}
type FrontendSettingsBuildInfoDTO struct {
@@ -194,6 +195,10 @@ type FrontendSettingsDTO struct {
RudderstackConfigUrl string `json:"rudderstackConfigUrl"`
RudderstackIntegrationsUrl string `json:"rudderstackIntegrationsUrl"`
AnalyticsConsoleReporting bool `json:"analyticsConsoleReporting"`
DashboardPerformanceMetrics []string `json:"dashboardPerformanceMetrics"`
FeedbackLinksEnabled bool `json:"feedbackLinksEnabled"`
ApplicationInsightsConnectionString string `json:"applicationInsightsConnectionString"`
ApplicationInsightsEndpointUrl string `json:"applicationInsightsEndpointUrl"`
@@ -204,6 +209,8 @@ type FrontendSettingsDTO struct {
ExternalUserMngInfo string `json:"externalUserMngInfo"`
ExternalUserMngLinkUrl string `json:"externalUserMngLinkUrl"`
ExternalUserMngLinkName string `json:"externalUserMngLinkName"`
ExternalUserMngAnalytics bool `json:"externalUserMngAnalytics"`
ExternalUserMngAnalyticsParams string `json:"externalUserMngAnalyticsParams"`
ViewersCanEdit bool `json:"viewersCanEdit"`
AngularSupportEnabled bool `json:"angularSupportEnabled"`
EditorsCanAdmin bool `json:"editorsCanAdmin"`
@@ -212,6 +219,7 @@ type FrontendSettingsDTO struct {
CSPReportOnlyEnabled bool `json:"cspReportOnlyEnabled"`
EnableFrontendSandboxForPlugins []string `json:"enableFrontendSandboxForPlugins"`
ExploreDefaultTimeOffset string `json:"exploreDefaultTimeOffset"`
ExploreHideLogsDownload bool `json:"exploreHideLogsDownload"`
Auth FrontendSettingsAuthDTO `json:"auth"`
@@ -247,6 +255,8 @@ type FrontendSettingsDTO struct {
Azure FrontendSettingsAzureDTO `json:"azure"`
DefaultDatasourceManageAlertsUIToggle bool `json:"defaultDatasourceManageAlertsUiToggle"`
Caching FrontendSettingsCachingDTO `json:"caching"`
RecordedQueries FrontendSettingsRecordedQueriesDTO `json:"recordedQueries"`
Reporting FrontendSettingsReportingDTO `json:"reporting"`
@@ -259,6 +269,7 @@ type FrontendSettingsDTO struct {
TokenExpirationDayLimit int `json:"tokenExpirationDayLimit"`
SharedWithMeFolderUID string `json:"sharedWithMeFolderUID"`
RootFolderUID string `json:"rootFolderUID"`
PasswordlessEnabled string `json:"passwordlessEnabled"`
GeomapDefaultBaseLayerConfig *map[string]any `json:"geomapDefaultBaseLayerConfig,omitempty"`
GeomapDisableCustomBaseLayer bool `json:"geomapDisableCustomBaseLayer"`
+3 -2
View File
@@ -2,7 +2,7 @@ package dtos
import (
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/pfs"
"github.com/grafana/grafana/pkg/plugins/auth"
"github.com/grafana/grafana/pkg/services/accesscontrol"
)
@@ -12,6 +12,7 @@ type PluginSetting struct {
Id string `json:"id"`
Enabled bool `json:"enabled"`
Pinned bool `json:"pinned"`
AutoEnabled bool `json:"autoEnabled"`
Module string `json:"module"`
BaseUrl string `json:"baseUrl"`
Info plugins.Info `json:"info"`
@@ -51,7 +52,7 @@ type PluginListItem struct {
SignatureOrg string `json:"signatureOrg"`
AccessControl accesscontrol.Metadata `json:"accessControl,omitempty"`
AngularDetected bool `json:"angularDetected"`
IAM *pfs.IAM `json:"iam,omitempty"`
IAM *auth.IAM `json:"iam,omitempty"`
}
type PluginList []PluginListItem
+1
View File
@@ -43,6 +43,7 @@ type ResetUserPasswordForm struct {
type UserLookupDTO struct {
UserID int64 `json:"userId"`
UID string `json:"uid"`
Login string `json:"login"`
AvatarURL string `json:"avatarUrl"`
}
+1 -1
View File
@@ -22,7 +22,7 @@ func NewFakePluginInstaller() *fakePluginInstaller {
return &fakePluginInstaller{plugins: map[string]fakePlugin{}}
}
func (pm *fakePluginInstaller) Add(_ context.Context, pluginID, version string, _ plugins.CompatOpts) error {
func (pm *fakePluginInstaller) Add(_ context.Context, pluginID, version string, _ plugins.AddOpts) error {
pm.plugins[pluginID] = fakePlugin{
pluginID: pluginID,
version: version,
+56 -375
View File
@@ -5,25 +5,15 @@ import (
"errors"
"net/http"
"strconv"
"strings"
k8sErrors "k8s.io/apimachinery/pkg/api/errors"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/api/apierrors"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
folderalpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/infra/slugify"
internalfolders "github.com/grafana/grafana/pkg/registry/apis/folders"
"github.com/grafana/grafana/pkg/services/accesscontrol"
grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess"
@@ -31,10 +21,9 @@ import (
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/guardian"
"github.com/grafana/grafana/pkg/services/libraryelements/model"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/services/search"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/util/errhttp"
"github.com/grafana/grafana/pkg/web"
)
@@ -45,38 +34,24 @@ func (hs *HTTPServer) registerFolderAPI(apiRoute routing.RouteRegister, authoriz
apiRoute.Group("/folders", func(folderRoute routing.RouteRegister) {
idScope := dashboards.ScopeFoldersProvider.GetResourceScope(accesscontrol.Parameter(":id"))
uidScope := dashboards.ScopeFoldersProvider.GetResourceScopeUID(accesscontrol.Parameter(":uid"))
folderRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead)), routing.Wrap(hs.GetFolders))
folderRoute.Get("/id/:id", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, idScope)), routing.Wrap(hs.GetFolderByID))
folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
folderUidRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderByUID))
folderUidRoute.Put("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.UpdateFolder))
folderUidRoute.Post("/move", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder))
folderUidRoute.Delete("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersDelete, uidScope)), routing.Wrap(hs.DeleteFolder))
folderUidRoute.Get("/counts", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts))
folderUidRoute.Group("/permissions", func(folderPermissionRoute routing.RouteRegister) {
folderPermissionRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsRead, uidScope)), routing.Wrap(hs.GetFolderPermissionList))
folderPermissionRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersPermissionsWrite, uidScope)), routing.Wrap(hs.UpdateFolderPermissions))
})
})
if hs.Features.IsEnabledGlobally(featuremgmt.FlagKubernetesFolders) {
// Use k8s client to implement legacy API
handler := newFolderK8sHandler(hs)
folderRoute.Post("/", handler.createFolder)
} else {
folderRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersCreate)), routing.Wrap(hs.CreateFolder))
}
// Only adding support for some routes with the k8s handler for now. Include the rest here.
if false {
handler := newFolderK8sHandler(hs)
folderRoute.Get("/", handler.searchFolders)
folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
folderUidRoute.Get("/", handler.getFolder)
folderUidRoute.Delete("/", handler.deleteFolder)
folderUidRoute.Put("/:uid", handler.updateFolder)
})
}
folderRoute.Post("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersCreate)), routing.Wrap(hs.CreateFolder))
folderRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead)), routing.Wrap(hs.GetFolders))
folderRoute.Group("/:uid", func(folderUidRoute routing.RouteRegister) {
folderUidRoute.Put("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.UpdateFolder))
folderUidRoute.Delete("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersDelete, uidScope)), routing.Wrap(hs.DeleteFolder))
folderUidRoute.Get("/", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderByUID))
folderUidRoute.Get("/counts", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersRead, uidScope)), routing.Wrap(hs.GetFolderDescendantCounts))
folderUidRoute.Post("/move", authorize(accesscontrol.EvalPermission(dashboards.ActionFoldersWrite, uidScope)), routing.Wrap(hs.MoveFolder))
})
})
}
@@ -119,10 +94,11 @@ func (hs *HTTPServer) GetFolders(c *contextmodel.ReqContext) response.Response {
hits := make([]dtos.FolderSearchHit, 0)
for _, f := range folders {
hits = append(hits, dtos.FolderSearchHit{
ID: f.ID, // nolint:staticcheck
UID: f.UID,
Title: f.Title,
ParentUID: f.ParentUID,
ID: f.ID, // nolint:staticcheck
UID: f.UID,
Title: f.Title,
ParentUID: f.ParentUID,
Repository: f.Repository,
})
metrics.MFolderIDsAPICount.WithLabelValues(metrics.GetFolders).Inc()
}
@@ -223,6 +199,10 @@ func (hs *HTTPServer) CreateFolder(c *contextmodel.ReqContext) response.Response
return apierrors.ToFolderErrorResponse(err)
}
if err := hs.setDefaultFolderPermissions(c.Req.Context(), cmd.OrgID, cmd.SignedInUser, folder); err != nil {
hs.log.Error("Could not set the default folder permissions", "folder", folder.Title, "user", cmd.SignedInUser, "error", err)
}
// Clear permission cache for the user who's created the folder, 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)
@@ -236,6 +216,36 @@ func (hs *HTTPServer) CreateFolder(c *contextmodel.ReqContext) response.Response
return response.JSON(http.StatusOK, folderDTO)
}
func (hs *HTTPServer) setDefaultFolderPermissions(ctx context.Context, orgID int64, user identity.Requester, folder *folder.Folder) error {
if !hs.Cfg.RBAC.PermissionsOnCreation("folder") {
return nil
}
var permissions []accesscontrol.SetResourcePermissionCommand
if user.IsIdentityType(claims.TypeUser) {
userID, err := user.GetInternalID()
if err != nil {
return err
}
permissions = append(permissions, accesscontrol.SetResourcePermissionCommand{
UserID: userID, Permission: dashboardaccess.PERMISSION_ADMIN.String(),
})
}
isNested := folder.ParentUID != ""
if !isNested || !hs.Features.IsEnabled(ctx, featuremgmt.FlagNestedFolders) {
permissions = append(permissions, []accesscontrol.SetResourcePermissionCommand{
{BuiltinRole: string(org.RoleEditor), Permission: dashboardaccess.PERMISSION_EDIT.String()},
{BuiltinRole: string(org.RoleViewer), Permission: dashboardaccess.PERMISSION_VIEW.String()},
}...)
}
_, err := hs.folderPermissionsService.SetPermissions(ctx, orgID, folder.UID, permissions...)
return err
}
// swagger:route POST /folders/{folder_uid}/move folders moveFolder
//
// Move folder.
@@ -381,10 +391,10 @@ func (hs *HTTPServer) newToFolderDto(c *contextmodel.ReqContext, f *folder.Folde
// Finding creator and last updater of the folder
updater, creator := anonString, anonString
if f.CreatedBy > 0 {
creator = hs.getUserLogin(ctx, f.CreatedBy)
creator = hs.getIdentityName(ctx, f.OrgID, f.CreatedBy)
}
if f.UpdatedBy > 0 {
updater = hs.getUserLogin(ctx, f.UpdatedBy)
updater = hs.getIdentityName(ctx, f.OrgID, f.UpdatedBy)
}
acMetadata, _ := hs.getFolderACMetadata(c, f)
@@ -402,6 +412,7 @@ func (hs *HTTPServer) newToFolderDto(c *contextmodel.ReqContext, f *folder.Folde
return dtos.Folder{
ID: f.ID, // nolint:staticcheck
UID: f.UID,
OrgID: f.OrgID,
Title: f.Title,
URL: f.URL,
HasACL: f.HasACL,
@@ -416,6 +427,7 @@ func (hs *HTTPServer) newToFolderDto(c *contextmodel.ReqContext, f *folder.Folde
Version: f.Version,
AccessControl: acMetadata,
ParentUID: f.ParentUID,
Repository: f.Repository,
}, nil
}
@@ -637,334 +649,3 @@ type GetFolderDescendantCountsResponse struct {
// in: body
Body folder.DescendantCounts `json:"body"`
}
type folderK8sHandler struct {
namespacer request.NamespaceMapper
gvr schema.GroupVersionResource
clientConfigProvider grafanaapiserver.DirectRestConfigProvider
// #TODO check if it makes more sense to move this to FolderAPIBuilder
accesscontrolService accesscontrol.Service
userService user.Service
}
//-----------------------------------------------------------------------------------------
// Folder k8s wrapper functions
//-----------------------------------------------------------------------------------------
func newFolderK8sHandler(hs *HTTPServer) *folderK8sHandler {
return &folderK8sHandler{
gvr: folderalpha1.FolderResourceInfo.GroupVersionResource(),
namespacer: request.GetNamespaceMapper(hs.Cfg),
clientConfigProvider: hs.clientConfigProvider,
accesscontrolService: hs.accesscontrolService,
userService: hs.userService,
}
}
func (fk8s *folderK8sHandler) searchFolders(c *contextmodel.ReqContext) {
client, ok := fk8s.getClient(c)
if !ok {
return // error is already sent
}
out, err := client.List(c.Req.Context(), v1.ListOptions{})
if err != nil {
fk8s.writeError(c, err)
return
}
query := strings.ToUpper(c.Query("query"))
folders := []folder.Folder{}
for _, item := range out.Items {
p := internalfolders.UnstructuredToLegacyFolder(item, c.SignedInUser.GetOrgID())
if p == nil {
continue
}
if query != "" && !strings.Contains(strings.ToUpper(p.Title), query) {
continue // query filter
}
folders = append(folders, *p)
}
c.JSON(http.StatusOK, folders)
}
func (fk8s *folderK8sHandler) createFolder(c *contextmodel.ReqContext) {
client, ok := fk8s.getClient(c)
if !ok {
return // error is already sent
}
cmd := folder.CreateFolderCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {
c.JsonApiErr(http.StatusBadRequest, "bad request data", err)
return
}
obj, err := internalfolders.LegacyCreateCommandToUnstructured(cmd)
if err != nil {
fk8s.writeError(c, err)
return
}
out, err := client.Create(c.Req.Context(), &obj, v1.CreateOptions{})
if err != nil {
fk8s.writeError(c, err)
return
}
fk8s.accesscontrolService.ClearUserPermissionCache(c.SignedInUser)
folderDTO, err := fk8s.newToFolderDto(c, *out, c.SignedInUser.GetOrgID())
if err != nil {
fk8s.writeError(c, err)
return
}
c.JSON(http.StatusOK, folderDTO)
}
func (fk8s *folderK8sHandler) getFolder(c *contextmodel.ReqContext) {
client, ok := fk8s.getClient(c)
if !ok {
return // error is already sent
}
uid := web.Params(c.Req)[":uid"]
out, err := client.Get(c.Req.Context(), uid, v1.GetOptions{})
if err != nil {
fk8s.writeError(c, err)
return
}
folderDTO, err := fk8s.newToFolderDto(c, *out, c.SignedInUser.GetOrgID())
if err != nil {
fk8s.writeError(c, err)
return
}
c.JSON(http.StatusOK, folderDTO)
}
func (fk8s *folderK8sHandler) deleteFolder(c *contextmodel.ReqContext) {
client, ok := fk8s.getClient(c)
if !ok {
return // error is already sent
}
uid := web.Params(c.Req)[":uid"]
err := client.Delete(c.Req.Context(), uid, v1.DeleteOptions{})
if err != nil {
fk8s.writeError(c, err)
return
}
c.JSON(http.StatusOK, "")
}
func (fk8s *folderK8sHandler) updateFolder(c *contextmodel.ReqContext) {
client, ok := fk8s.getClient(c)
if !ok {
return // error is already sent
}
cmd := folder.UpdateFolderCommand{}
if err := web.Bind(c.Req, &cmd); err != nil {
c.JsonApiErr(http.StatusBadRequest, "bad request data", err)
return
}
cmd.OrgID = c.SignedInUser.GetOrgID()
cmd.UID = web.Params(c.Req)[":uid"]
cmd.SignedInUser = c.SignedInUser
// #TODO add version?
obj, err := internalfolders.LegacyUpdateCommandToUnstructured(cmd)
if err != nil {
fk8s.writeError(c, err)
return
}
out, err := client.Update(c.Req.Context(), &obj, v1.UpdateOptions{})
if err != nil {
fk8s.writeError(c, err)
return
}
folderDTO, err := fk8s.newToFolderDto(c, *out, c.SignedInUser.GetOrgID())
if err != nil {
fk8s.writeError(c, err)
return
}
c.JSON(http.StatusOK, folderDTO)
}
//-----------------------------------------------------------------------------------------
// Utility functions
//-----------------------------------------------------------------------------------------
func (fk8s *folderK8sHandler) getClient(c *contextmodel.ReqContext) (dynamic.ResourceInterface, bool) {
dyn, err := dynamic.NewForConfig(fk8s.clientConfigProvider.GetDirectRestConfig(c))
if err != nil {
c.JsonApiErr(500, "client", err)
return nil, false
}
return dyn.Resource(fk8s.gvr).Namespace(fk8s.namespacer(c.OrgID)), true
}
func (fk8s *folderK8sHandler) writeError(c *contextmodel.ReqContext, err error) {
//nolint:errorlint
statusError, ok := err.(*k8sErrors.StatusError)
if ok {
c.JsonApiErr(int(statusError.Status().Code), statusError.Status().Message, err)
return
}
errhttp.Write(c.Req.Context(), err, c.Resp)
}
func (fk8s *folderK8sHandler) newToFolderDto(c *contextmodel.ReqContext, item unstructured.Unstructured, orgID int64) (dtos.Folder, error) {
// #TODO revisit how/where we get orgID
ctx := c.Req.Context()
f := internalfolders.UnstructuredToLegacyFolder(item, orgID)
fDTO, err := internalfolders.UnstructuredToLegacyFolderDTO(item)
if err != nil {
return dtos.Folder{}, err
}
// #TODO Is there a preexisting function we can use instead, something along the lines of UserIdentifier?
toUID := func(rawIdentifier string) string {
parts := strings.Split(rawIdentifier, ":")
if len(parts) < 2 {
return ""
}
return parts[1]
}
toDTO := func(fold *folder.Folder, checkCanView bool) (dtos.Folder, error) {
g, err := guardian.NewByFolder(c.Req.Context(), fold, c.SignedInUser.GetOrgID(), c.SignedInUser)
if err != nil {
return dtos.Folder{}, err
}
canEdit, _ := g.CanEdit()
canSave, _ := g.CanSave()
canAdmin, _ := g.CanAdmin()
canDelete, _ := g.CanDelete()
// Finding creator and last updater of the folder
updater, creator := anonString, anonString
// #TODO refactor the various conversions of the folder so that we either set created by in folder.Folder or
// we convert from unstructured to folder DTO without an intermediate conversion to folder.Folder
if len(fDTO.CreatedBy) > 0 {
creator = fk8s.getUserLogin(ctx, toUID(fDTO.CreatedBy), orgID)
}
if len(fDTO.UpdatedBy) > 0 {
updater = fk8s.getUserLogin(ctx, toUID(fDTO.UpdatedBy), orgID)
}
acMetadata, _ := fk8s.getFolderACMetadata(c, fold)
if checkCanView {
canView, _ := g.CanView()
if !canView {
return dtos.Folder{
UID: REDACTED,
Title: REDACTED,
}, nil
}
}
metrics.MFolderIDsAPICount.WithLabelValues(metrics.NewToFolderDTO).Inc()
fDTO.CanSave = canSave
fDTO.CanEdit = canEdit
fDTO.CanAdmin = canAdmin
fDTO.CanDelete = canDelete
fDTO.CreatedBy = creator
fDTO.UpdatedBy = updater
fDTO.AccessControl = acMetadata
fDTO.OrgID = f.OrgID
// #TODO version doesn't seem to be used--confirm or set it properly
fDTO.Version = 1
return *fDTO, nil
}
// no need to check view permission for the starting folder since it's already checked by the callers
folderDTO, err := toDTO(f, false)
if err != nil {
return dtos.Folder{}, err
}
if len(f.Fullpath) == 0 || len(f.FullpathUIDs) == 0 {
return folderDTO, nil
}
parentsFullPath, err := internalfolders.GetParentTitles(f.Fullpath)
if err != nil {
return dtos.Folder{}, err
}
parentsFullPathUIDs := strings.Split(f.FullpathUIDs, "/")
// The first part of the path is the newly created folder which we don't need to include
// in the parents field
if len(parentsFullPath) < 2 || len(parentsFullPathUIDs) < 2 {
return folderDTO, nil
}
parents := []dtos.Folder{}
for i, v := range parentsFullPath[1:] {
slug := slugify.Slugify(v)
uid := parentsFullPathUIDs[1:][i]
url := dashboards.GetFolderURL(uid, slug)
parents = append(parents, dtos.Folder{
UID: uid,
OrgID: c.SignedInUser.GetOrgID(),
Title: v,
URL: url,
})
}
folderDTO.Parents = parents
return folderDTO, nil
}
func (fk8s *folderK8sHandler) getUserLogin(ctx context.Context, userUID string, orgID int64) string {
ctx, span := tracer.Start(ctx, "api.getUserLogin")
defer span.End()
query := user.GetUserByUIDQuery{
UID: userUID,
OrgID: orgID,
}
user, err := fk8s.userService.GetByUID(ctx, &query)
if err != nil {
return anonString
}
return user.Login
}
func (fk8s *folderK8sHandler) getFolderACMetadata(c *contextmodel.ReqContext, f *folder.Folder) (accesscontrol.Metadata, error) {
if !c.QueryBool("accesscontrol") {
return nil, nil
}
if len(f.FullpathUIDs) == 0 {
return map[string]bool{}, nil
}
parentsFullPathUIDs := strings.Split(f.FullpathUIDs, "/")
// The first part of the path is the newly created folder which we don't need to check here
if len(parentsFullPathUIDs) < 2 {
return map[string]bool{}, nil
}
folderIDs := map[string]bool{f.UID: true}
for _, uid := range parentsFullPathUIDs[1:] {
folderIDs[uid] = true
}
allMetadata := getMultiAccessControlMetadata(c, dashboards.ScopeFoldersPrefix, folderIDs)
metadata := map[string]bool{}
// Flatten metadata - if any parent has a permission, the child folder inherits it
for _, md := range allMetadata {
for action := range md {
metadata[action] = true
}
}
return metadata, nil
}
+21 -20
View File
@@ -27,7 +27,7 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol/ossaccesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/permreg"
"github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/apiserver/client"
"github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards"
@@ -40,6 +40,7 @@ import (
"github.com/grafana/grafana/pkg/services/org/orgimpl"
"github.com/grafana/grafana/pkg/services/quota/quotatest"
"github.com/grafana/grafana/pkg/services/search"
"github.com/grafana/grafana/pkg/services/search/sort"
"github.com/grafana/grafana/pkg/services/sqlstore"
"github.com/grafana/grafana/pkg/services/star"
"github.com/grafana/grafana/pkg/services/star/startest"
@@ -51,6 +52,7 @@ import (
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/userimpl"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
"github.com/grafana/grafana/pkg/web"
"github.com/grafana/grafana/pkg/web/webtest"
)
@@ -451,39 +453,38 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog
quotaSrv := quotatest.New(false, nil)
dashStore, err := database.ProvideDashboardStore(sc.db, sc.cfg, features, tagimpl.ProvideService(sc.db), quotaSrv)
dashStore, err := database.ProvideDashboardStore(sc.db, sc.cfg, features, tagimpl.ProvideService(sc.db))
require.NoError(b, err)
folderStore := folderimpl.ProvideDashboardFolderStore(sc.db)
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient())
ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
cfg := setting.NewCfg()
actionSets := resourcepermissions.NewActionSetService(features)
fStore := folderimpl.ProvideStore(sc.db)
folderServiceWithFlagOn := folderimpl.ProvideService(
fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore, folderStore,
nil, sc.db, features, supportbundlestest.NewFakeBundleService(), nil, cfg, nil, tracing.InitializeTracerForTest(), nil, dualwrite.ProvideTestService(), sort.ProvideService())
acSvc := acimpl.ProvideOSSService(
sc.cfg, acdb.ProvideService(sc.db), actionSets, localcache.ProvideService(),
features, tracing.InitializeTracerForTest(), zanzana.NewNoopClient(), sc.db, permreg.ProvidePermissionRegistry(), nil,
features, tracing.InitializeTracerForTest(), sc.db, permreg.ProvidePermissionRegistry(), nil,
)
fStore := folderimpl.ProvideStore(sc.db)
folderPermissions, err := ossaccesscontrol.ProvideFolderPermissions(
cfg, features, routing.NewRouteRegister(), sc.db, ac, license, &dashboards.FakeDashboardStore{}, fStore, acSvc, sc.teamSvc, sc.userSvc, actionSets)
cfg, features, routing.NewRouteRegister(), sc.db, ac, license, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc, actionSets)
require.NoError(b, err)
folderServiceWithFlagOn := folderimpl.ProvideService(fStore, ac, bus.ProvideBus(tracing.InitializeTracerForTest()), dashStore,
folderStore, sc.db, features, cfg, folderPermissions, supportbundlestest.NewFakeBundleService(), nil, tracing.InitializeTracerForTest())
dashboardPermissions, err := ossaccesscontrol.ProvideDashboardPermissions(
cfg, features, routing.NewRouteRegister(), sc.db, ac, license, &dashboards.FakeDashboardStore{}, fStore, acSvc, sc.teamSvc, sc.userSvc, actionSets)
require.NoError(b, err)
dashboardSvc, err := dashboardservice.ProvideDashboardServiceImpl(
sc.cfg, dashStore, folderStore,
features, folderPermissions, dashboardPermissions, ac,
folderServiceWithFlagOn, fStore, nil,
features, folderPermissions, ac,
folderServiceWithFlagOn, fStore, nil, client.MockTestRestConfig{}, nil, quotaSrv, nil, nil, nil, dualwrite.ProvideTestService(), sort.ProvideService(),
)
require.NoError(b, err)
_, err = ossaccesscontrol.ProvideDashboardPermissions(
cfg, features, routing.NewRouteRegister(), sc.db, ac, license, dashboardSvc, folderServiceWithFlagOn, acSvc, sc.teamSvc, sc.userSvc, actionSets, dashboardSvc)
require.NoError(b, err)
starSvc := startest.NewStarServiceFake()
starSvc.ExpectedUserStars = &star.GetUserStarsResult{UserStars: make(map[int64]bool)}
starSvc.ExpectedUserStars = &star.GetUserStarsResult{UserStars: make(map[string]bool)}
hs := &HTTPServer{
CacheService: localcache.New(5*time.Minute, 10*time.Minute),
@@ -491,13 +492,13 @@ func setupServer(b testing.TB, sc benchScenario, features featuremgmt.FeatureTog
SQLStore: sc.db,
Features: features,
QuotaService: quotaSrv,
SearchService: search.ProvideService(sc.cfg, sc.db, starSvc, dashboardSvc),
SearchService: search.ProvideService(sc.cfg, sc.db, starSvc, dashboardSvc, folderServiceWithFlagOn, features, sort.ProvideService()),
folderService: folderServiceWithFlagOn,
DashboardService: dashboardSvc,
}
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient())
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService)
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
guardian.InitAccessControlGuardian(hs.Cfg, hs.AccessControl, hs.DashboardService, hs.folderService, log.NewNopLogger())
m.Get("/api/folders", hs.GetFolders)
m.Get("/api/search", hs.Search)
+4 -2
View File
@@ -153,10 +153,12 @@ func (hs *HTTPServer) getFolderACL(ctx context.Context, user identity.Requester,
FolderUID: folder.ParentUID,
Created: p.Created,
Updated: p.Updated,
UserID: p.UserId,
UserID: p.UserID,
UserUID: p.UserUID,
UserLogin: p.UserLogin,
UserEmail: p.UserEmail,
TeamID: p.TeamId,
TeamID: p.TeamID,
TeamUID: p.TeamUID,
TeamEmail: p.TeamEmail,
Team: p.Team,
Role: role,
+2 -2
View File
@@ -54,8 +54,8 @@ func TestHTTPServer_GetFolderPermissionList(t *testing.T) {
hs.folderPermissionsService = &actest.FakePermissionsService{
ExpectedPermissions: []accesscontrol.ResourcePermission{
{UserId: 1, UserLogin: "regular", IsManaged: true},
{UserId: 2, UserLogin: "hidden", IsManaged: true},
{UserID: 1, UserLogin: "regular", IsManaged: true},
{UserID: 2, UserLogin: "hidden", IsManaged: true},
},
}
})
+135 -86
View File
@@ -15,6 +15,8 @@ import (
clientrest "k8s.io/client-go/rest"
"github.com/grafana/grafana/pkg/api/dtos"
folderv0alpha1 "github.com/grafana/grafana/pkg/apis/folder/v0alpha1"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
@@ -27,6 +29,7 @@ import (
"github.com/grafana/grafana/pkg/services/quota/quotatest"
"github.com/grafana/grafana/pkg/services/search/model"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web/webtest"
)
@@ -88,13 +91,6 @@ func TestFoldersCreateAPIEndpoint(t *testing.T) {
expectedFolderSvcError: dashboards.ErrDashboardUidTooLong,
permissions: []accesscontrol.Permission{{Action: dashboards.ActionFoldersCreate}},
},
{
description: "folder creation fails given folder service error %s",
input: folderWithoutParentInput,
expectedCode: http.StatusConflict,
expectedFolderSvcError: dashboards.ErrFolderSameNameExists,
permissions: []accesscontrol.Permission{{Action: dashboards.ActionFoldersCreate}},
},
{
description: "folder creation fails given folder service error %s",
input: folderWithoutParentInput,
@@ -203,12 +199,6 @@ func TestFoldersUpdateAPIEndpoint(t *testing.T) {
expectedFolderSvcError: dashboards.ErrDashboardUidTooLong,
permissions: []accesscontrol.Permission{{Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersAll}},
},
{
description: "folder updating fails given folder service error %s",
expectedCode: http.StatusConflict,
expectedFolderSvcError: dashboards.ErrFolderSameNameExists,
permissions: []accesscontrol.Permission{{Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersAll}},
},
{
description: "folder updating fails given folder service error %s",
expectedCode: http.StatusForbidden,
@@ -539,92 +529,151 @@ func (m mockClientConfigProvider) GetDirectRestConfig(c *contextmodel.ReqContext
func (m mockClientConfigProvider) DirectlyServeHTTP(w http.ResponseWriter, r *http.Request) {}
func TestHTTPServer_FolderMetadataK8s(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//nolint:errcheck
fmt.Fprintln(w,
`{
"kind": "Folder",
"apiVersion": "folder.grafana.app/v0alpha1",
"metadata": {
"name": "ady4yobv315a8e",
"namespace": "default",
"uid": "28f306ee-ada1-40f4-8011-b2d1df462aad",
"creationTimestamp": "2024-09-17T04:16:35Z",
"annotations": {
"grafana.app/createdBy": "user:fdxsqt7t5ryf4a",
"grafana.app/originName": "SQL",
"grafana.app/originPath": "3"
}
},
"spec": {
"title": "Example folder 226"
}
}`)
}))
defer ts.Close()
// for now, test only the general folder
func TestGetFolderLegacyAndUnifiedStorage(t *testing.T) {
testuser := &user.User{ID: 99, UID: "fdxsqt7t5ryf4a", Login: "testuser"}
mockClientConfigProvider := mockClientConfigProvider{
host: ts.URL,
legacyFolder := *folder.RootFolder
expectedFolder := dtos.Folder{
UID: legacyFolder.UID,
OrgID: 0,
Title: legacyFolder.Title,
URL: legacyFolder.URL,
HasACL: false,
CanSave: false,
CanEdit: true,
CanAdmin: false,
CanDelete: false,
CreatedBy: "Anonymous",
UpdatedBy: "Anonymous",
}
setUpRBACGuardian(t)
folderService := &foldertest.FakeService{}
features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders, featuremgmt.FlagKubernetesFolders)
server := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.Cfg = setting.NewCfg()
hs.folderService = folderService
hs.QuotaService = quotatest.New(false, nil)
hs.SearchService = &mockSearchService{
ExpectedResult: model.HitList{},
mux := http.NewServeMux()
folderApiServerMock := httptest.NewServer(mux)
defer folderApiServerMock.Close()
t.Run("happy path", func(t *testing.T) {
type testCase struct {
description string
folderUID string
legacyFolder folder.Folder
expectedFolder dtos.Folder
expectedFolderServiceError error
unifiedStorageEnabled bool
unifiedStorageMode grafanarest.DualWriterMode
expectedCode int
}
hs.Features = features
hs.clientConfigProvider = mockClientConfigProvider
})
t.Run("Should attach access control metadata to folder response", func(t *testing.T) {
folderService.ExpectedFolder = &folder.Folder{UID: "ady4yobv315a8e"}
tcs := []testCase{
{
description: "General folder - Legacy",
expectedCode: http.StatusOK,
legacyFolder: legacyFolder,
folderUID: legacyFolder.UID,
expectedFolder: expectedFolder,
unifiedStorageEnabled: false,
},
{
description: "General folder - Unified storage, mode 1",
expectedCode: http.StatusOK,
legacyFolder: legacyFolder,
folderUID: legacyFolder.UID,
expectedFolder: expectedFolder,
unifiedStorageEnabled: true,
unifiedStorageMode: grafanarest.Mode1,
},
{
description: "General folder - Unified storage, mode 2",
expectedCode: http.StatusOK,
legacyFolder: legacyFolder,
folderUID: legacyFolder.UID,
expectedFolder: expectedFolder,
unifiedStorageEnabled: true,
unifiedStorageMode: grafanarest.Mode2,
},
{
description: "General folder - Unified storage, mode 3",
expectedCode: http.StatusOK,
legacyFolder: legacyFolder,
folderUID: legacyFolder.UID,
expectedFolder: expectedFolder,
unifiedStorageEnabled: true,
unifiedStorageMode: grafanarest.Mode3,
},
{
description: "General folder - Unified storage, mode 4",
expectedCode: http.StatusOK,
legacyFolder: legacyFolder,
folderUID: legacyFolder.UID,
expectedFolder: expectedFolder,
unifiedStorageEnabled: true,
unifiedStorageMode: grafanarest.Mode4,
},
}
req := server.NewGetRequest("/api/folders/ady4yobv315a8e?accesscontrol=true")
webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{
1: accesscontrol.GroupScopesByActionContext(context.Background(), []accesscontrol.Permission{
{Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll},
{Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID("ady4yobv315a8e")},
}),
}})
for _, tc := range tcs {
t.Run(tc.description, func(t *testing.T) {
setUpRBACGuardian(t)
res, err := server.Send(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
defer func() { require.NoError(t, res.Body.Close()) }()
cfg := setting.NewCfg()
cfg.UnifiedStorage = map[string]setting.UnifiedStorageConfig{
folderv0alpha1.RESOURCEGROUP: {
DualWriterMode: tc.unifiedStorageMode,
},
}
body := dtos.Folder{}
require.NoError(t, json.NewDecoder(res.Body).Decode(&body))
featuresArr := []any{featuremgmt.FlagNestedFolders}
if tc.unifiedStorageEnabled {
featuresArr = append(featuresArr, featuremgmt.FlagKubernetesClientDashboardsFolders)
}
assert.True(t, body.AccessControl[dashboards.ActionFoldersRead])
assert.True(t, body.AccessControl[dashboards.ActionFoldersWrite])
})
server := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.Cfg = cfg
hs.folderService = &foldertest.FakeService{
ExpectedFolder: &tc.legacyFolder,
ExpectedError: tc.expectedFolderServiceError,
}
hs.QuotaService = quotatest.New(false, nil)
hs.SearchService = &mockSearchService{
ExpectedResult: model.HitList{},
}
hs.userService = &usertest.FakeUserService{
ExpectedUser: testuser,
}
hs.Features = featuremgmt.WithFeatures(
featuresArr...,
)
hs.clientConfigProvider = mockClientConfigProvider{
host: folderApiServerMock.URL,
}
})
t.Run("Should not attach access control metadata to folder response", func(t *testing.T) {
folderService.ExpectedFolder = &folder.Folder{UID: "ady4yobv315a8e"}
req := server.NewRequest(http.MethodGet, fmt.Sprintf("/api/folders/%s", tc.folderUID), nil)
req.Header.Set("Content-Type", "application/json")
webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{
1: accesscontrol.GroupScopesByActionContext(context.Background(), []accesscontrol.Permission{
{Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll},
}),
}})
req := server.NewGetRequest("/api/folders/ady4yobv315a8e")
webtest.RequestWithSignedInUser(req, &user.SignedInUser{UserID: 1, OrgID: 1, Permissions: map[int64]map[string][]string{
1: accesscontrol.GroupScopesByActionContext(context.Background(), []accesscontrol.Permission{
{Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll},
{Action: dashboards.ActionFoldersWrite, Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID("ady4yobv315a8e")},
}),
}})
res, err := server.Send(req)
require.NoError(t, err)
res, err := server.Send(req)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, res.StatusCode)
defer func() { require.NoError(t, res.Body.Close()) }()
require.Equal(t, tc.expectedCode, res.StatusCode)
defer func() { require.NoError(t, res.Body.Close()) }()
body := dtos.Folder{}
require.NoError(t, json.NewDecoder(res.Body).Decode(&body))
if tc.expectedCode == http.StatusOK {
body := dtos.Folder{}
require.NoError(t, json.NewDecoder(res.Body).Decode(&body))
assert.False(t, body.AccessControl[dashboards.ActionFoldersRead])
assert.False(t, body.AccessControl[dashboards.ActionFoldersWrite])
//nolint:staticcheck
body.ID = 0
body.Version = 0
tc.expectedFolder.Version = 0
require.Equal(t, tc.expectedFolder, body)
}
})
}
})
}
+30 -3
View File
@@ -15,6 +15,7 @@ import (
"github.com/grafana/grafana/pkg/login/social"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/authn"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -157,7 +158,7 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
}
}
hideVersion := hs.Cfg.AnonymousHideVersion && !c.IsSignedIn
hideVersion := hs.Cfg.Anonymous.HideVersion && !c.IsSignedIn
version := setting.BuildVersion
commit := setting.BuildCommit
commitShort := getShortCommitHash(setting.BuildCommit, 10)
@@ -213,6 +214,8 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
RudderstackSdkUrl: hs.Cfg.RudderstackSDKURL,
RudderstackConfigUrl: hs.Cfg.RudderstackConfigURL,
RudderstackIntegrationsUrl: hs.Cfg.RudderstackIntegrationsURL,
AnalyticsConsoleReporting: hs.Cfg.FrontendAnalyticsConsoleReporting,
DashboardPerformanceMetrics: hs.Cfg.DashboardPerformanceMetrics,
FeedbackLinksEnabled: hs.Cfg.FeedbackLinksEnabled,
ApplicationInsightsConnectionString: hs.Cfg.ApplicationInsightsConnectionString,
ApplicationInsightsEndpointUrl: hs.Cfg.ApplicationInsightsEndpointUrl,
@@ -223,6 +226,8 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
ExternalUserMngInfo: hs.Cfg.ExternalUserMngInfo,
ExternalUserMngLinkUrl: hs.Cfg.ExternalUserMngLinkUrl,
ExternalUserMngLinkName: hs.Cfg.ExternalUserMngLinkName,
ExternalUserMngAnalytics: hs.Cfg.ExternalUserMngAnalytics,
ExternalUserMngAnalyticsParams: hs.Cfg.ExternalUserMngAnalyticsParams,
ViewersCanEdit: hs.Cfg.ViewersCanEdit,
AngularSupportEnabled: hs.Cfg.AngularSupportEnabled,
EditorsCanAdmin: hs.Cfg.EditorsCanAdmin,
@@ -242,6 +247,9 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
LocalFileSystemAvailable: hs.Cfg.LocalFileSystemAvailable,
ReportingStaticContext: hs.Cfg.ReportingStaticContext,
ExploreDefaultTimeOffset: hs.Cfg.ExploreDefaultTimeOffset,
ExploreHideLogsDownload: hs.Cfg.ExploreHideLogsDownload,
DefaultDatasourceManageAlertsUIToggle: hs.Cfg.DefaultDatasourceManageAlertsUIToggle,
PluginDependencies: pluginDependencyMap(c.Req.Context(), hs.pluginStore),
BuildInfo: dtos.FrontendSettingsBuildInfoDTO{
@@ -266,8 +274,8 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
},
FeatureToggles: featureToggles,
AnonymousEnabled: hs.Cfg.AnonymousEnabled,
AnonymousDeviceLimit: hs.Cfg.AnonymousDeviceLimit,
AnonymousEnabled: hs.Cfg.Anonymous.Enabled,
AnonymousDeviceLimit: hs.Cfg.Anonymous.DeviceLimit,
RendererAvailable: hs.RenderService.IsAvailable(c.Req.Context()),
RendererVersion: hs.RenderService.Version(),
RendererDefaultImageWidth: hs.Cfg.RendererDefaultImageWidth,
@@ -362,6 +370,24 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
BasicAuthStrongPasswordPolicy: hs.Cfg.BasicAuthStrongPasswordPolicy,
}
if hs.Cfg.PasswordlessMagicLinkAuth.Enabled && hs.Features.IsEnabled(c.Req.Context(), featuremgmt.FlagPasswordlessMagicLinkAuthentication) {
hasEnabledProviders := hs.samlEnabled() || hs.authnService.IsClientEnabled(authn.ClientLDAP)
if !hasEnabledProviders {
oauthInfos := hs.SocialService.GetOAuthInfoProviders()
for _, provider := range oauthInfos {
if provider.Enabled {
hasEnabledProviders = true
break
}
}
}
if !hasEnabledProviders {
frontendSettings.Auth.PasswordlessEnabled = true
}
}
if hs.pluginsCDNService != nil && hs.pluginsCDNService.IsEnabled() {
cdnBaseURL, err := hs.pluginsCDNService.BaseURL()
if err != nil {
@@ -720,6 +746,7 @@ func (hs *HTTPServer) pluginSettings(ctx context.Context, orgID int64) (map[stri
OrgID: orgID,
Enabled: plugin.AutoEnabled,
Pinned: plugin.AutoEnabled,
AutoEnabled: plugin.AutoEnabled,
PluginVersion: plugin.Info.Version,
}
+1 -1
View File
@@ -160,7 +160,7 @@ func TestHTTPServer_GetFrontendSettings_hideVersionAnonymous(t *testing.T) {
for _, test := range tests {
t.Run(test.desc, func(t *testing.T) {
hs.Cfg.AnonymousHideVersion = test.hideVersion
hs.Cfg.Anonymous.HideVersion = test.hideVersion
expected := test.expected
recorder := httptest.NewRecorder()
+6 -2
View File
@@ -23,7 +23,7 @@ var grafanaComProxyTransport = &http.Transport{
TLSHandshakeTimeout: 10 * time.Second,
}
func ReverseProxyGnetReq(logger log.Logger, proxyPath string, version string, grafanaComAPIUrl string) *httputil.ReverseProxy {
func ReverseProxyGnetReq(logger log.Logger, proxyPath, version, grafanaComAPIUrl, grafanaComAPIToken string) *httputil.ReverseProxy {
url, _ := url.Parse(grafanaComAPIUrl)
director := func(req *http.Request) {
@@ -40,6 +40,10 @@ func ReverseProxyGnetReq(logger log.Logger, proxyPath string, version string, gr
// send the current Grafana version for each request proxied to GCOM
req.Header.Add("grafana-version", version)
if grafanaComAPIToken != "" {
req.Header.Set("Authorization", "Bearer "+grafanaComAPIToken)
}
}
return proxyutil.NewReverseProxy(logger, director)
@@ -47,7 +51,7 @@ func ReverseProxyGnetReq(logger log.Logger, proxyPath string, version string, gr
func (hs *HTTPServer) ProxyGnetRequest(c *contextmodel.ReqContext) {
proxyPath := web.Params(c.Req)["*"]
proxy := ReverseProxyGnetReq(c.Logger, proxyPath, hs.Cfg.BuildVersion, hs.Cfg.GrafanaComAPIURL)
proxy := ReverseProxyGnetReq(c.Logger, proxyPath, hs.Cfg.BuildVersion, hs.Cfg.GrafanaComAPIURL, hs.Cfg.GrafanaComSSOAPIToken)
proxy.Transport = grafanaComProxyTransport
proxy.ServeHTTP(c.Resp, c.Req)
}
+4 -4
View File
@@ -61,7 +61,7 @@ func TestHealthAPI_VersionEnterprise(t *testing.T) {
func TestHealthAPI_AnonymousHideVersion(t *testing.T) {
m, hs := setupHealthAPITestEnvironment(t)
hs.Cfg.AnonymousHideVersion = true
hs.Cfg.Anonymous.HideVersion = true
req := httptest.NewRequest(http.MethodGet, "/api/health", nil)
rec := httptest.NewRecorder()
@@ -80,7 +80,7 @@ func TestHealthAPI_DatabaseHealthy(t *testing.T) {
const cacheKey = "db-healthy"
m, hs := setupHealthAPITestEnvironment(t)
hs.Cfg.AnonymousHideVersion = true
hs.Cfg.Anonymous.HideVersion = true
healthy, found := hs.CacheService.Get(cacheKey)
require.False(t, found)
@@ -107,7 +107,7 @@ func TestHealthAPI_DatabaseUnhealthy(t *testing.T) {
const cacheKey = "db-healthy"
m, hs := setupHealthAPITestEnvironment(t)
hs.Cfg.AnonymousHideVersion = true
hs.Cfg.Anonymous.HideVersion = true
hs.SQLStore.(*dbtest.FakeDB).ExpectedError = errors.New("bad")
healthy, found := hs.CacheService.Get(cacheKey)
@@ -135,7 +135,7 @@ func TestHealthAPI_DatabaseHealthCached(t *testing.T) {
const cacheKey = "db-healthy"
m, hs := setupHealthAPITestEnvironment(t)
hs.Cfg.AnonymousHideVersion = true
hs.Cfg.Anonymous.HideVersion = true
// Mock unhealthy database in cache.
hs.CacheService.Set(cacheKey, false, 5*time.Minute)
+15 -17
View File
@@ -25,6 +25,8 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/youmark/pkcs8"
"github.com/grafana/grafana/pkg/api/avatar"
"github.com/grafana/grafana/pkg/api/routing"
httpstatic "github.com/grafana/grafana/pkg/api/static"
@@ -80,6 +82,7 @@ import (
"github.com/grafana/grafana/pkg/services/pluginsintegration/managedplugins"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugincontext"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugininstaller"
pluginSettings "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
pref "github.com/grafana/grafana/pkg/services/preference"
@@ -104,14 +107,12 @@ import (
"github.com/grafana/grafana/pkg/services/tag"
"github.com/grafana/grafana/pkg/services/team"
tempUser "github.com/grafana/grafana/pkg/services/temp_user"
"github.com/grafana/grafana/pkg/services/unifiedSearch"
"github.com/grafana/grafana/pkg/services/updatechecker"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/validations"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
"github.com/youmark/pkcs8"
)
type HTTPServer struct {
@@ -140,7 +141,7 @@ type HTTPServer struct {
License licensing.Licensing
AccessControl accesscontrol.AccessControl
DataProxy *datasourceproxy.DataSourceProxyService
PluginRequestValidator validations.PluginRequestValidator
DataSourceRequestValidator validations.DataSourceRequestValidator
pluginClient plugins.Client
pluginStore pluginstore.Store
pluginInstaller plugins.Installer
@@ -149,6 +150,7 @@ type HTTPServer struct {
pluginStaticRouteResolver plugins.StaticRouteResolver
pluginErrorResolver plugins.ErrorResolver
pluginAssets *pluginassets.Service
pluginPreinstall plugininstaller.Preinstall
SearchService search.Service
ShortURLService shorturls.Service
QueryHistoryService queryhistory.Service
@@ -157,7 +159,6 @@ type HTTPServer struct {
LivePushGateway *pushhttp.Gateway
StorageService store.StorageService
SearchV2HTTPService searchV2.SearchHTTPService
UnifiedSearchHTTPService unifiedSearch.SearchHTTPService
ContextHandler *contexthandler.ContextHandler
LoggerMiddleware loggermw.Logger
SQLStore db.DB
@@ -206,6 +207,7 @@ type HTTPServer struct {
tempUserService tempUser.Service
loginAttemptService loginAttempt.Service
orgService org.Service
orgDeletionService org.DeletionService
TeamService team.Service
accesscontrolService accesscontrol.Service
annotationsRepo annotations.Repository
@@ -237,7 +239,7 @@ type ServerOptions struct {
func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routing.RouteRegister, bus bus.Bus,
renderService rendering.Service, licensing licensing.Licensing, hooksService *hooks.HooksService,
cacheService *localcache.CacheService, sqlStore db.DB,
pluginRequestValidator validations.PluginRequestValidator, pluginStaticRouteResolver plugins.StaticRouteResolver,
dataSourceRequestValidator validations.DataSourceRequestValidator, pluginStaticRouteResolver plugins.StaticRouteResolver,
pluginDashboardService plugindashboards.Service, pluginStore pluginstore.Store, pluginClient plugins.Client,
pluginErrorResolver plugins.ErrorResolver, pluginInstaller plugins.Installer, settingsProvider setting.Provider,
dataSourceCache datasources.CacheService, userTokenService auth.UserTokenService,
@@ -255,7 +257,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
authInfoService login.AuthInfoService, storageService store.StorageService,
notificationService notifications.Service, dashboardService dashboards.DashboardService,
dashboardProvisioningService dashboards.DashboardProvisioningService, folderService folder.Service,
folderStore folder.Store,
dsGuardian guardian.DatasourceGuardianProvider,
dashboardsnapshotsService dashboardsnapshots.Service, pluginSettings pluginSettings.Service,
avatarCacheServer *avatar.AvatarCacheServer, preferenceService pref.Service,
@@ -266,12 +267,12 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
secretsMigrator secrets.Migrator, secretsPluginManager plugins.SecretsPluginManager, secretsService secrets.Service,
secretsPluginMigrator spm.SecretMigrationProvider, secretsStore secretsKV.SecretsKVStore,
publicDashboardsApi *publicdashboardsApi.Api, userService user.Service, tempUserService tempUser.Service,
loginAttemptService loginAttempt.Service, orgService org.Service, teamService team.Service,
loginAttemptService loginAttempt.Service, orgService org.Service, orgDeletionService org.DeletionService, teamService team.Service,
accesscontrolService accesscontrol.Service, navTreeService navtree.Service,
annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService, unifiedSearchHTTPService unifiedSearch.SearchHTTPService, oauthTokenService oauthtoken.OAuthTokenService,
annotationRepo annotations.Repository, tagService tag.Service, searchv2HTTPService searchV2.SearchHTTPService, oauthTokenService oauthtoken.OAuthTokenService,
statsService stats.Service, authnService authn.Service, pluginsCDNService *pluginscdn.Service, promGatherer prometheus.Gatherer,
starApi *starApi.API, promRegister prometheus.Registerer, clientConfigProvider grafanaapiserver.DirectRestConfigProvider, anonService anonymous.Service,
userVerifier user.Verifier,
userVerifier user.Verifier, pluginPreinstall plugininstaller.Preinstall,
) (*HTTPServer, error) {
web.Env = cfg.Env
m := web.New()
@@ -285,7 +286,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
HooksService: hooksService,
CacheService: cacheService,
SQLStore: sqlStore,
PluginRequestValidator: pluginRequestValidator,
DataSourceRequestValidator: dataSourceRequestValidator,
pluginInstaller: pluginInstaller,
pluginClient: pluginClient,
pluginStore: pluginStore,
@@ -296,6 +297,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
pluginFileStore: pluginFileStore,
grafanaUpdateChecker: grafanaUpdateChecker,
pluginsUpdateChecker: pluginsUpdateChecker,
pluginPreinstall: pluginPreinstall,
SettingsProvider: settingsProvider,
DataSourceCache: dataSourceCache,
AuthTokenService: userTokenService,
@@ -310,7 +312,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
AccessControl: accessControl,
DataProxy: dataSourceProxy,
SearchV2HTTPService: searchv2HTTPService,
UnifiedSearchHTTPService: unifiedSearchHTTPService,
SearchService: searchService,
Live: live,
LivePushGateway: livePushGateway,
@@ -359,6 +360,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
tempUserService: tempUserService,
loginAttemptService: loginAttemptService,
orgService: orgService,
orgDeletionService: orgDeletionService,
TeamService: teamService,
navTreeService: navTreeService,
accesscontrolService: accesscontrolService,
@@ -383,7 +385,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
hs.registerRoutes()
// Register access control scope resolver for annotations
hs.AccessControl.RegisterScopeAttributeResolver(AnnotationTypeScopeResolver(hs.annotationsRepo, features, dashboardService, folderStore))
hs.AccessControl.RegisterScopeAttributeResolver(AnnotationTypeScopeResolver(hs.annotationsRepo, features, dashboardService, folderService))
if err := hs.declareFixedRoles(); err != nil {
return nil, err
@@ -718,7 +720,7 @@ func (hs *HTTPServer) apiHealthHandler(ctx *web.Context) {
data := healthResponse{
Database: "ok",
}
if !hs.Cfg.AnonymousHideVersion {
if !hs.Cfg.Anonymous.HideVersion {
data.Version = hs.Cfg.BuildVersion
data.Commit = hs.Cfg.BuildCommit
if hs.Cfg.EnterpriseBuildCommit != "NA" && hs.Cfg.EnterpriseBuildCommit != "" {
@@ -794,13 +796,9 @@ func (hs *HTTPServer) getDefaultCiphers(tlsVersion uint16, protocol string) []ui
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_RSA_WITH_AES_128_CBC_SHA,
tls.TLS_RSA_WITH_AES_256_CBC_SHA,
}
}
if protocol == "h2" {
+3 -2
View File
@@ -8,7 +8,7 @@ import (
"net/http"
"strings"
"github.com/grafana/authlib/claims"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/webassets"
"github.com/grafana/grafana/pkg/apimachinery/identity"
@@ -250,7 +250,8 @@ func (hs *HTTPServer) getThemeForIndexData(themePrefId string, themeURLParam str
if pref.IsValidThemeID(themePrefId) {
theme := pref.GetThemeByID(themePrefId)
if !theme.IsExtra || hs.Features.IsEnabledGlobally(featuremgmt.FlagExtraThemes) {
// TODO refactor
if !theme.IsExtra || hs.Features.IsEnabledGlobally(featuremgmt.FlagExtraThemes) || hs.Features.IsEnabledGlobally(featuremgmt.FlagGrafanaconThemes) {
return theme
}
}
+110
View File
@@ -24,6 +24,7 @@ import (
pref "github.com/grafana/grafana/pkg/services/preference"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
@@ -241,6 +242,26 @@ func (hs *HTTPServer) LoginPost(c *contextmodel.ReqContext) response.Response {
return authn.HandleLoginResponse(c.Req, c.Resp, hs.Cfg, identity, hs.ValidateRedirectTo, hs.Features)
}
func (hs *HTTPServer) LoginPasswordless(c *contextmodel.ReqContext) response.Response {
identity, err := hs.authnService.Login(c.Req.Context(), authn.ClientPasswordless, &authn.Request{HTTPRequest: c.Req})
if err != nil {
tokenErr := &auth.CreateTokenErr{}
if errors.As(err, &tokenErr) {
return response.Error(tokenErr.StatusCode, tokenErr.ExternalErr, tokenErr.InternalErr)
}
return response.Err(err)
}
return authn.HandleLoginResponse(c.Req, c.Resp, hs.Cfg, identity, hs.ValidateRedirectTo, hs.Features)
}
func (hs *HTTPServer) StartPasswordless(c *contextmodel.ReqContext) {
redirect, err := hs.authnService.RedirectURL(c.Req.Context(), authn.ClientPasswordless, &authn.Request{HTTPRequest: c.Req})
if err != nil {
c.Redirect(hs.redirectURLWithErrorCookie(c, err))
}
c.JSON(http.StatusOK, redirect)
}
func (hs *HTTPServer) loginUserWithUser(user *user.User, c *contextmodel.ReqContext) error {
if user == nil {
return errors.New("could not login user")
@@ -382,6 +403,22 @@ func (hs *HTTPServer) samlAutoLoginEnabled() bool {
return hs.samlEnabled() && config.IsAutoLoginEnabled()
}
func (hs *HTTPServer) samlSkipOrgRoleSyncEnabled() bool {
config, ok := hs.authnService.GetClientConfig(authn.ClientSAML)
if !ok {
return false
}
return hs.samlEnabled() && config.IsSkipOrgRoleSyncEnabled()
}
func (hs *HTTPServer) samlAllowAssignGrafanaAdminEnabled() bool {
config, ok := hs.authnService.GetClientConfig(authn.ClientSAML)
if !ok {
return false
}
return hs.samlEnabled() && config.IsAllowAssignGrafanaAdminEnabled()
}
func getLoginExternalError(err error) string {
var createTokenErr *auth.CreateTokenErr
if errors.As(err, &createTokenErr) {
@@ -411,3 +448,76 @@ func getFirstPublicErrorMessage(err *errutil.Error) string {
return errPublic.Message
}
// isExternalySynced is used to tell if the user roles are externally synced
// true means that the org role sync is handled by Grafana
// Note: currently the users authinfo is overridden each time the user logs in
// https://github.com/grafana/grafana/blob/4181acec72f76df7ad02badce13769bae4a1f840/pkg/services/login/authinfoservice/database/database.go#L61
// this means that if the user has multiple auth providers and one of them is set to sync org roles
// then isExternallySynced will be true for this one provider and false for the others
func (hs *HTTPServer) isExternallySynced(cfg *setting.Cfg, authModule string) bool {
// provider enabled in config
if !hs.isProviderEnabled(cfg, authModule) {
return false
}
// first check SAML, LDAP and JWT
switch authModule {
case loginservice.SAMLAuthModule:
return !hs.samlSkipOrgRoleSyncEnabled()
case loginservice.LDAPAuthModule:
return !cfg.LDAPSkipOrgRoleSync
case loginservice.JWTModule:
return !cfg.JWTAuth.SkipOrgRoleSync
}
switch authModule {
case loginservice.GoogleAuthModule, loginservice.OktaAuthModule, loginservice.AzureADAuthModule, loginservice.GitLabAuthModule, loginservice.GithubAuthModule, loginservice.GrafanaComAuthModule, loginservice.GenericOAuthModule:
config, ok := hs.authnService.GetClientConfig(oauthModuleToAuthnClient(authModule))
if !ok {
return false
}
return !config.IsSkipOrgRoleSyncEnabled()
}
return true
}
// isGrafanaAdminExternallySynced returns true if Grafana server admin role is being managed by an external auth provider, and false otherwise.
// Grafana admin role sync is available for JWT, OAuth providers and LDAP.
// For JWT and OAuth providers there is an additional config option `allow_assign_grafana_admin` that has to be enabled for Grafana Admin role to be synced.
func (hs *HTTPServer) isGrafanaAdminExternallySynced(cfg *setting.Cfg, authModule string) bool {
if !hs.isExternallySynced(cfg, authModule) {
return false
}
switch authModule {
case loginservice.JWTModule:
return cfg.JWTAuth.AllowAssignGrafanaAdmin
case loginservice.SAMLAuthModule:
return hs.samlAllowAssignGrafanaAdminEnabled()
case loginservice.LDAPAuthModule:
return true
default:
config, ok := hs.authnService.GetClientConfig(oauthModuleToAuthnClient(authModule))
if !ok {
return false
}
return config.IsAllowAssignGrafanaAdminEnabled()
}
}
func (hs *HTTPServer) isProviderEnabled(cfg *setting.Cfg, authModule string) bool {
switch authModule {
case loginservice.SAMLAuthModule:
return hs.authnService.IsClientEnabled(authn.ClientSAML)
case loginservice.LDAPAuthModule:
return cfg.LDAPAuthEnabled
case loginservice.JWTModule:
return cfg.JWTAuth.Enabled
case loginservice.GoogleAuthModule, loginservice.OktaAuthModule, loginservice.AzureADAuthModule, loginservice.GitLabAuthModule, loginservice.GithubAuthModule, loginservice.GrafanaComAuthModule, loginservice.GenericOAuthModule:
return hs.authnService.IsClientEnabled(oauthModuleToAuthnClient(authModule))
}
return false
}
func oauthModuleToAuthnClient(authModule string) string {
return authn.ClientWithPrefix(strings.TrimPrefix(authModule, "oauth_"))
}
+168 -1
View File
@@ -12,10 +12,11 @@ import (
"strings"
"testing"
"github.com/grafana/authlib/claims"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
@@ -688,6 +689,172 @@ func TestLogoutSaml(t *testing.T) {
require.Equal(t, 302, sc.resp.Code)
}
func TestIsExternallySynced(t *testing.T) {
testcases := []struct {
name string
cfg *setting.Cfg
provider string
enabledAuthnClients []string
authnClientConfig authn.SSOClientConfig
expected bool
}{
// Same for all of the OAuth providers
{
name: "AzureAD external user should return that it is externally synced",
cfg: &setting.Cfg{},
provider: loginservice.AzureADAuthModule,
enabledAuthnClients: []string{authn.ClientWithPrefix("azuread")},
authnClientConfig: &authntest.FakeSSOClientConfig{
ExpectedIsSkipOrgRoleSyncEnabled: false,
},
expected: true,
},
{
name: "AzureAD external user should return that it is not externally synced when org role sync is set",
cfg: &setting.Cfg{},
provider: loginservice.AzureADAuthModule,
enabledAuthnClients: []string{authn.ClientWithPrefix(strings.TrimPrefix(loginservice.AzureADAuthModule, "oauth_"))},
authnClientConfig: &authntest.FakeSSOClientConfig{
ExpectedIsSkipOrgRoleSyncEnabled: true,
},
expected: false,
},
{
name: "AzureAD external user should return that it is not externally synced when the provider is not enabled",
cfg: &setting.Cfg{},
enabledAuthnClients: []string{},
authnClientConfig: &authntest.FakeSSOClientConfig{
ExpectedIsSkipOrgRoleSyncEnabled: false,
},
provider: loginservice.AzureADAuthModule,
expected: false,
},
// saml
{
name: "SAML synced user should return that it is not externally synced when the provider is disabled",
cfg: &setting.Cfg{},
provider: loginservice.SAMLAuthModule,
enabledAuthnClients: []string{},
authnClientConfig: &authntest.FakeSSOClientConfig{
ExpectedIsSkipOrgRoleSyncEnabled: false,
},
expected: false,
},
{
name: "SAML synced user should return that it is externally synced",
cfg: &setting.Cfg{},
provider: loginservice.SAMLAuthModule,
enabledAuthnClients: []string{authn.ClientSAML},
authnClientConfig: &authntest.FakeSSOClientConfig{
ExpectedIsSkipOrgRoleSyncEnabled: false,
},
expected: true,
},
{
name: "SAML synced user should return that it is not externally synced when org role sync is set",
cfg: &setting.Cfg{},
provider: loginservice.SAMLAuthModule,
enabledAuthnClients: []string{authn.ClientSAML},
authnClientConfig: &authntest.FakeSSOClientConfig{
ExpectedIsSkipOrgRoleSyncEnabled: true,
},
expected: false,
},
// ldap
{
name: "LDAP synced user should return that it is externally synced",
cfg: &setting.Cfg{LDAPAuthEnabled: true, LDAPSkipOrgRoleSync: false},
provider: loginservice.LDAPAuthModule,
expected: true,
},
{
name: "LDAP synced user should return that it is not externally synced when org role sync is set",
cfg: &setting.Cfg{LDAPAuthEnabled: true, LDAPSkipOrgRoleSync: true},
provider: loginservice.LDAPAuthModule,
expected: false,
},
// jwt
{
name: "JWT synced user should return that it is externally synced",
cfg: &setting.Cfg{JWTAuth: setting.AuthJWTSettings{Enabled: true, SkipOrgRoleSync: false}},
provider: loginservice.JWTModule,
expected: true,
},
{
name: "JWT synced user should return that it is not externally synced when org role sync is set",
cfg: &setting.Cfg{JWTAuth: setting.AuthJWTSettings{Enabled: true, SkipOrgRoleSync: true}},
provider: loginservice.JWTModule,
expected: false,
},
// IsProvider test
{
name: "If no provider enabled should return false",
cfg: &setting.Cfg{JWTAuth: setting.AuthJWTSettings{Enabled: false, SkipOrgRoleSync: true}},
provider: loginservice.JWTModule,
expected: false,
},
}
for _, tc := range testcases {
hs := &HTTPServer{
authnService: &authntest.FakeService{
ExpectedClientConfig: tc.authnClientConfig,
EnabledClients: tc.enabledAuthnClients,
},
}
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, hs.isExternallySynced(tc.cfg, tc.provider))
})
}
}
func TestIsProviderEnabled(t *testing.T) {
testcases := []struct {
name string
provider string
enabledAuthnClients []string
expected bool
}{
{
name: "Github should return true if enabled",
provider: loginservice.GithubAuthModule,
enabledAuthnClients: []string{authn.ClientWithPrefix(strings.TrimPrefix(loginservice.GithubAuthModule, "oauth_"))},
expected: true,
},
{
name: "Github should return false if not enabled",
provider: loginservice.GithubAuthModule,
enabledAuthnClients: []string{},
expected: false,
},
// saml
{
name: "SAML should return true if enabled",
provider: loginservice.SAMLAuthModule,
enabledAuthnClients: []string{authn.ClientSAML},
expected: true,
},
{
name: "SAML should return false if not enabled",
provider: loginservice.SAMLAuthModule,
enabledAuthnClients: []string{},
expected: false,
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
hs := &HTTPServer{
authnService: &authntest.FakeService{
EnabledClients: tc.enabledAuthnClients,
},
}
assert.Equal(t, tc.expected, hs.isProviderEnabled(setting.NewCfg(), tc.provider))
})
}
}
type mockSocialService struct {
oAuthInfo *social.OAuthInfo
oAuthInfos map[string]*social.OAuthInfo
+2 -2
View File
@@ -6,7 +6,7 @@ import (
"net/http"
"strconv"
"github.com/grafana/authlib/claims"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/infra/metrics"
@@ -297,7 +297,7 @@ func (hs *HTTPServer) DeleteOrgByID(c *contextmodel.ReqContext) response.Respons
return response.Error(http.StatusBadRequest, "Can not delete org for current user", nil)
}
if err := hs.orgService.Delete(c.Req.Context(), &org.DeleteOrgCommand{ID: orgID}); err != nil {
if err := hs.orgDeletionService.Delete(c.Req.Context(), &org.DeleteOrgCommand{ID: orgID}); err != nil {
if errors.Is(err, org.ErrOrgNotFound) {
return response.Error(http.StatusNotFound, "Failed to delete organization. ID not found", nil)
}
+16 -2
View File
@@ -129,7 +129,7 @@ func (hs *HTTPServer) AddOrgInvite(c *contextmodel.ReqContext) response.Response
"OrgName": c.SignedInUser.GetOrgName(),
"Email": c.SignedInUser.GetEmail(),
"LinkUrl": setting.ToAbsUrl("invite/" + cmd.Code),
"InvitedBy": c.SignedInUser.GetDisplayName(),
"InvitedBy": c.SignedInUser.GetName(),
},
}
@@ -169,7 +169,7 @@ func (hs *HTTPServer) inviteExistingUserToOrg(c *contextmodel.ReqContext, user *
Data: map[string]any{
"Name": user.NameOrFallback(),
"OrgName": c.SignedInUser.GetOrgName(),
"InvitedBy": c.SignedInUser.GetDisplayName(),
"InvitedBy": c.SignedInUser.GetName(),
},
}
@@ -195,6 +195,20 @@ func (hs *HTTPServer) inviteExistingUserToOrg(c *contextmodel.ReqContext, user *
// 404: notFoundError
// 500: internalServerError
func (hs *HTTPServer) RevokeInvite(c *contextmodel.ReqContext) response.Response {
query := tempuser.GetTempUserByCodeQuery{Code: web.Params(c.Req)[":code"]}
queryResult, err := hs.tempUserService.GetTempUserByCode(c.Req.Context(), &query)
if err != nil {
if errors.Is(err, tempuser.ErrTempUserNotFound) {
return response.Error(http.StatusNotFound, "Invite not found", nil)
}
return response.Error(http.StatusInternalServerError, "Failed to get invite", err)
}
canRevoke := c.SignedInUser.GetOrgID() == queryResult.OrgID || c.SignedInUser.GetIsGrafanaAdmin()
if !canRevoke {
return response.Error(http.StatusForbidden, "Permission denied: not permitted to revoke invite", nil)
}
if ok, rsp := hs.updateTempUserStatus(c.Req.Context(), web.Params(c.Req)[":code"], tempuser.TmpUserRevoked); !ok {
return rsp
}
+3 -1
View File
@@ -6,10 +6,11 @@ import (
"strings"
"testing"
"github.com/grafana/authlib/claims"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
"github.com/grafana/grafana/pkg/services/authn"
@@ -228,6 +229,7 @@ func TestAPIEndpoint_DeleteOrgs(t *testing.T) {
server := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.Cfg = setting.NewCfg()
hs.orgService = &orgtest.FakeOrgService{ExpectedOrg: &org.Org{}}
hs.orgDeletionService = &orgtest.FakeOrgDeletionService{}
hs.userService = &usertest.FakeUserService{ExpectedSignedInUser: &user.SignedInUser{OrgID: 1}}
hs.accesscontrolService = actest.FakeService{ExpectedPermissions: tt.permission}
hs.authnService = &authntest.FakeService{}
+3 -4
View File
@@ -161,6 +161,7 @@ func (hs *HTTPServer) GetOrgUsersForCurrentOrgLookup(c *contextmodel.ReqContext)
for _, u := range orgUsersResult.OrgUsers {
result = append(result, &dtos.UserLookupDTO{
UID: u.UID,
UserID: u.UserID,
Login: u.Login,
AvatarURL: u.AvatarURL,
@@ -338,9 +339,8 @@ func (hs *HTTPServer) searchOrgUsersHelper(c *contextmodel.ReqContext, query *or
for i := range filteredUsers {
filteredUsers[i].AccessControl = accessControlMetadata[fmt.Sprint(filteredUsers[i].UserID)]
if module, ok := modules[filteredUsers[i].UserID]; ok {
oauthInfo := hs.SocialService.GetOAuthInfoProvider(module)
filteredUsers[i].AuthLabels = []string{login.GetAuthProviderLabel(module)}
filteredUsers[i].IsExternallySynced = login.IsExternallySynced(hs.Cfg, module, oauthInfo)
filteredUsers[i].IsExternallySynced = hs.isExternallySynced(hs.Cfg, module)
}
}
@@ -427,8 +427,7 @@ func (hs *HTTPServer) updateOrgUserHelper(c *contextmodel.ReqContext, cmd org.Up
}
}
if authInfo != nil && authInfo.AuthModule != "" {
oauthInfo := hs.SocialService.GetOAuthInfoProvider(authInfo.AuthModule)
if login.IsExternallySynced(hs.Cfg, authInfo.AuthModule, oauthInfo) {
if hs.isExternallySynced(hs.Cfg, authInfo.AuthModule) {
return response.Err(org.ErrCannotChangeRoleForExternallySyncedUser.Errorf("Cannot change role for externally synced user"))
}
}
+11 -1
View File
@@ -9,6 +9,8 @@ import (
"strings"
"testing"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/authntest"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -258,9 +260,17 @@ func TestOrgUsersAPIEndpoint_updateOrgRole(t *testing.T) {
server := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.Cfg = setting.NewCfg()
hs.Cfg.LDAPAuthEnabled = tt.AuthEnabled
if tt.AuthModule == login.LDAPAuthModule {
switch tt.AuthModule {
case login.LDAPAuthModule:
hs.Cfg.LDAPAuthEnabled = tt.AuthEnabled
hs.Cfg.LDAPSkipOrgRoleSync = tt.SkipOrgRoleSync
case login.GrafanaComAuthModule:
hs.authnService = &authntest.FakeService{
EnabledClients: []string{authn.ClientWithPrefix("grafana_com")},
ExpectedClientConfig: &authntest.FakeSSOClientConfig{
ExpectedIsSkipOrgRoleSyncEnabled: tt.SkipOrgRoleSync,
},
}
}
// AuthModule empty means basic auth
+1 -2
View File
@@ -39,8 +39,7 @@ func (hs *HTTPServer) SendResetPasswordEmail(c *contextmodel.ReqContext) respons
getAuthQuery := login.GetAuthInfoQuery{UserId: usr.ID}
if authInfo, err := hs.authInfoService.GetAuthInfo(c.Req.Context(), &getAuthQuery); err == nil {
oauthInfo := hs.SocialService.GetOAuthInfoProvider(authInfo.AuthModule)
if login.IsProviderEnabled(hs.Cfg, authInfo.AuthModule, oauthInfo) {
if hs.isProviderEnabled(hs.Cfg, authInfo.AuthModule) {
c.Logger.Info("Requested password reset for external user", nil)
return response.Error(http.StatusOK, "Email sent", nil)
}
+2 -2
View File
@@ -9,12 +9,12 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"github.com/grafana/grafana/apps/playlist/apis/playlist/v0alpha1"
"github.com/grafana/grafana/apps/playlist/pkg/apis/playlist/v0alpha1"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/middleware"
internalplaylist "github.com/grafana/grafana/pkg/registry/apis/playlist"
internalplaylist "github.com/grafana/grafana/pkg/registry/apps/playlist"
grafanaapiserver "github.com/grafana/grafana/pkg/services/apiserver"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
+1 -6
View File
@@ -62,12 +62,7 @@ func (hs *HTTPServer) callPluginResourceWithDataSource(c *contextmodel.ReqContex
return
}
var dsURL string
if pCtx.DataSourceInstanceSettings != nil {
dsURL = pCtx.DataSourceInstanceSettings.URL
}
err = hs.PluginRequestValidator.Validate(dsURL, c.Req)
err = hs.DataSourceRequestValidator.Validate(ds, c.Req)
if err != nil {
c.JsonApiErr(http.StatusForbidden, "Access denied", err)
return
+1 -1
View File
@@ -50,7 +50,7 @@ func TestCallResource(t *testing.T) {
cfg.Azure = &azsettings.AzureSettings{}
coreRegistry := coreplugin.ProvideCoreRegistry(tracing.InitializeTracerForTest(), nil, &cloudwatch.CloudWatchService{}, nil, nil, nil, nil,
nil, nil, nil, nil, testdatasource.ProvideService(), nil, nil, nil, nil, nil, nil)
nil, nil, nil, nil, testdatasource.ProvideService(), nil, nil, nil, nil, nil, nil, nil, nil)
testCtx := pluginsintegration.CreateIntegrationTestCtx(t, cfg, coreRegistry)
+8 -2
View File
@@ -32,7 +32,6 @@ func ApplyRoute(ctx context.Context, req *http.Request, proxyPath string, route
}
ctxLogger := logger.FromContext(ctx)
if len(route.URL) > 0 {
interpolatedURL, err := interpolateString(route.URL, data)
if err != nil {
@@ -49,7 +48,14 @@ func ApplyRoute(ctx context.Context, req *http.Request, proxyPath string, route
req.URL.Scheme = routeURL.Scheme
req.URL.Host = routeURL.Host
req.Host = routeURL.Host
req.URL.Path = util.JoinURLFragments(routeURL.Path, proxyPath)
req.URL.RawPath = util.JoinURLFragments(routeURL.Path, proxyPath)
unescapedPath, err := url.PathUnescape(req.URL.RawPath)
if err != nil {
ctxLogger.Error("Failed to unescape raw path", "rawPath", req.URL.RawPath, "error", err)
return
}
req.URL.Path = unescapedPath
}
if err := addQueryString(req, route, data); err != nil {
+6 -3
View File
@@ -19,6 +19,7 @@ import (
glog "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/services/contexthandler"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
@@ -57,7 +58,8 @@ type httpClient interface {
func NewDataSourceProxy(ds *datasources.DataSource, pluginRoutes []*plugins.Route, ctx *contextmodel.ReqContext,
proxyPath string, cfg *setting.Cfg, clientProvider httpclient.Provider,
oAuthTokenService oauthtoken.OAuthTokenService, dsService datasources.DataSourceService,
tracer tracing.Tracer, features featuremgmt.FeatureToggles) (*DataSourceProxy, error) {
tracer tracing.Tracer, features featuremgmt.FeatureToggles,
) (*DataSourceProxy, error) {
targetURL, err := datasource.ValidateURL(ds.Type, ds.URL)
if err != nil {
return nil, err
@@ -261,7 +263,8 @@ func (proxy *DataSourceProxy) director(req *http.Request) {
}
if proxy.oAuthTokenService.IsOAuthPassThruEnabled(proxy.ds) {
if token := proxy.oAuthTokenService.GetCurrentOAuthToken(req.Context(), proxy.ctx.SignedInUser); token != nil {
reqCtx := contexthandler.FromContext(req.Context())
if token := proxy.oAuthTokenService.GetCurrentOAuthToken(req.Context(), proxy.ctx.SignedInUser, reqCtx.UserToken); token != nil {
req.Header.Set("Authorization", fmt.Sprintf("%s %s", token.Type(), token.AccessToken))
idToken, ok := token.Extra("id_token").(string)
@@ -372,7 +375,7 @@ func (proxy *DataSourceProxy) logRequest() {
panelPluginId := proxy.ctx.Req.Header.Get("X-Panel-Plugin-Id")
uri, err := util.SanitizeURI(proxy.ctx.Req.RequestURI)
if err == nil {
if err != nil {
proxy.ctx.Logger.Error("Could not sanitize RequestURI", "error", err)
}
+23 -6
View File
@@ -19,7 +19,8 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"github.com/grafana/authlib/claims"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/api/datasource"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/components/simplejson"
@@ -32,7 +33,8 @@ import (
pluginfakes "github.com/grafana/grafana/pkg/plugins/manager/fakes"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/auth"
"github.com/grafana/grafana/pkg/services/contexthandler/ctxkey"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/datasources"
datasourceservice "github.com/grafana/grafana/pkg/services/datasources/service"
@@ -117,6 +119,10 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
Path: "api/rbac-restricted",
ReqAction: "test-app.settings:read",
},
{
Path: "encodedPath",
URL: "http://encoded.com",
},
}
ds := &datasources.DataSource{
@@ -233,6 +239,16 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
assert.Equal(t, "https://example.com/api/v1/some-route/", req.URL.String())
})
t.Run("When matching proxy path is already encoded", func(t *testing.T) {
ctx, req := setUp()
proxy, err := setupDSProxyTest(t, ctx, ds, routes, "/our%20devices")
require.NoError(t, err)
proxy.matchedRoute = routes[9]
ApplyRoute(proxy.ctx.Req.Context(), req, proxy.proxyPath, proxy.matchedRoute, dsInfo, proxy.cfg)
assert.Equal(t, "http://encoded.com/our%20devices", req.URL.String())
})
t.Run("Validating request", func(t *testing.T) {
t.Run("plugin route with valid role", func(t *testing.T) {
ctx, _ := setUp()
@@ -557,7 +573,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
var routes []*plugins.Route
proxy, err := setupDSProxyTest(t, ctx, ds, routes, "/path/to/folder/", func(proxy *DataSourceProxy) {
proxy.oAuthTokenService = &oauthtokentest.MockOauthTokenService{
GetCurrentOauthTokenFunc: func(_ context.Context, _ identity.Requester) *oauth2.Token {
GetCurrentOauthTokenFunc: func(_ context.Context, _ identity.Requester, _ *auth.UserToken) *oauth2.Token {
return (&oauth2.Token{
AccessToken: "testtoken",
RefreshToken: "testrefreshtoken",
@@ -573,6 +589,7 @@ func TestDataSourceProxy_routeRule(t *testing.T) {
require.NoError(t, err)
req, err = http.NewRequest(http.MethodGet, "http://grafana.com/sub", nil)
req = req.WithContext(context.WithValue(req.Context(), ctxkey.Key{}, &contextmodel.ReqContext{UserToken: nil}))
require.NoError(t, err)
proxy.director(req)
@@ -900,7 +917,7 @@ func getDatasourceProxiedRequest(t *testing.T, ctx *contextmodel.ReqContext, cfg
secretsStore := secretskvs.NewSQLSecretsKVStore(sqlStore, secretsService, log.New("test.logger"))
features := featuremgmt.WithFeatures()
quotaService := quotatest.New(false, nil)
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features, zanzana.NewNoopClient()),
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
&actest.FakePermissionsService{}, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
require.NoError(t, err)
@@ -1022,7 +1039,7 @@ func runDatasourceAuthTest(t *testing.T, secretsService secrets.Service, secrets
var routes []*plugins.Route
features := featuremgmt.WithFeatures()
quotaService := quotatest.New(false, nil)
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features, zanzana.NewNoopClient()),
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
&actest.FakePermissionsService{}, quotaService, &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
require.NoError(t, err)
@@ -1078,7 +1095,7 @@ func setupDSProxyTest(t *testing.T, ctx *contextmodel.ReqContext, ds *datasource
secretsService := secretsmng.SetupTestService(t, fakes.NewFakeSecretsStore())
secretsStore := secretskvs.NewSQLSecretsKVStore(dbtest.NewFakeDB(), secretsService, log.NewNopLogger())
features := featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall)
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features, zanzana.NewNoopClient()),
dsService, err := datasourceservice.ProvideService(nil, secretsService, secretsStore, cfg, features, acimpl.ProvideAccessControl(features),
&actest.FakePermissionsService{}, quotatest.New(false, nil), &pluginstore.FakePluginStore{}, &pluginfakes.FakePluginClient{},
plugincontext.ProvideBaseService(cfg, pluginconfig.NewFakePluginRequestConfigProvider()))
require.NoError(t, err)
+6 -6
View File
@@ -12,11 +12,11 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/authlib/claims"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org"
@@ -267,7 +267,7 @@ func TestPluginProxy(t *testing.T) {
SecureJSONData: map[string][]byte{},
}
cfg := &setting.Cfg{}
proxy, err := NewPluginProxy(ps, routes, ctx, "", cfg, secretsService, tracing.InitializeTracerForTest(), &http.Transport{}, acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()), featuremgmt.WithFeatures())
proxy, err := NewPluginProxy(ps, routes, ctx, "", cfg, secretsService, tracing.InitializeTracerForTest(), &http.Transport{}, acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), featuremgmt.WithFeatures())
require.NoError(t, err)
proxy.HandleRequest()
@@ -423,7 +423,7 @@ func TestPluginProxyRoutes(t *testing.T) {
SecureJSONData: map[string][]byte{},
}
cfg := &setting.Cfg{}
proxy, err := NewPluginProxy(ps, testRoutes, ctx, tc.proxyPath, cfg, secretsService, tracing.InitializeTracerForTest(), &http.Transport{}, acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()), featuremgmt.WithFeatures(tc.withFeatures...))
proxy, err := NewPluginProxy(ps, testRoutes, ctx, tc.proxyPath, cfg, secretsService, tracing.InitializeTracerForTest(), &http.Transport{}, acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), featuremgmt.WithFeatures(tc.withFeatures...))
require.NoError(t, err)
proxy.HandleRequest()
@@ -557,7 +557,7 @@ func TestPluginProxyRoutesAccessControl(t *testing.T) {
SecureJSONData: map[string][]byte{},
}
cfg := &setting.Cfg{}
proxy, err := NewPluginProxy(ps, testRoutes, ctx, tc.proxyPath, cfg, secretsService, tracing.InitializeTracerForTest(), &http.Transport{}, acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()), featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall))
proxy, err := NewPluginProxy(ps, testRoutes, ctx, tc.proxyPath, cfg, secretsService, tracing.InitializeTracerForTest(), &http.Transport{}, acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), featuremgmt.WithFeatures(featuremgmt.FlagAccessControlOnCall))
require.NoError(t, err)
proxy.HandleRequest()
@@ -588,7 +588,7 @@ func getPluginProxiedRequest(t *testing.T, ps *pluginsettings.DTO, secretsServic
ReqRole: org.RoleEditor,
}
}
proxy, err := NewPluginProxy(ps, []*plugins.Route{}, ctx, "", cfg, secretsService, tracing.InitializeTracerForTest(), &http.Transport{}, acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()), featuremgmt.WithFeatures())
proxy, err := NewPluginProxy(ps, []*plugins.Route{}, ctx, "", cfg, secretsService, tracing.InitializeTracerForTest(), &http.Transport{}, acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), featuremgmt.WithFeatures())
require.NoError(t, err)
req, err := http.NewRequest(http.MethodGet, "/api/plugin-proxy/grafana-simple-app/api/v4/alerts", nil)
+19 -18
View File
@@ -13,6 +13,7 @@ import (
"sort"
"strings"
"github.com/grafana/grafana/pkg/plugins/auth"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
@@ -21,7 +22,6 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/pfs"
"github.com/grafana/grafana/pkg/plugins/repo"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
@@ -74,7 +74,7 @@ func (hs *HTTPServer) GetPluginList(c *contextmodel.ReqContext) response.Respons
// Filter plugins
pluginDefinitions := hs.pluginStore.Plugins(c.Req.Context())
filteredPluginDefinitions := []pluginstore.Plugin{}
filteredPluginDefinitions := make([]pluginstore.Plugin, 0)
filteredPluginIDs := map[string]bool{}
for _, pluginDef := range pluginDefinitions {
// filter out app sub plugins
@@ -216,6 +216,7 @@ func (hs *HTTPServer) GetPluginSettingByID(c *contextmodel.ReqContext) response.
if plugin.IsApp() {
dto.Enabled = plugin.AutoEnabled
dto.Pinned = plugin.AutoEnabled
dto.AutoEnabled = plugin.AutoEnabled
}
ps, err := hs.PluginSettings.GetPluginSettingByPluginID(c.Req.Context(), &pluginsettings.GetByPluginIDArgs{
@@ -254,9 +255,13 @@ func (hs *HTTPServer) UpdatePluginSetting(c *contextmodel.ReqContext) response.R
}
pluginID := web.Params(c.Req)[":pluginId"]
if _, exists := hs.pluginStore.Plugin(c.Req.Context(), pluginID); !exists {
p, exists := hs.pluginStore.Plugin(c.Req.Context(), pluginID)
if !exists {
return response.Error(http.StatusNotFound, "Plugin not installed", nil)
}
if p.AutoEnabled && !cmd.Enabled {
return response.Error(http.StatusBadRequest, "Cannot disable auto-enabled plugin", nil)
}
cmd.OrgId = c.SignedInUser.GetOrgID()
cmd.PluginId = pluginID
@@ -461,13 +466,11 @@ func (hs *HTTPServer) InstallPlugin(c *contextmodel.ReqContext) response.Respons
hs.log.Info("Plugin install/update requested", "pluginId", pluginID, "user", c.Login)
for _, preinstalled := range hs.Cfg.PreinstallPlugins {
if preinstalled.ID == pluginID && preinstalled.Version != "" {
return response.Error(http.StatusConflict, "Cannot update a pinned pre-installed plugin", nil)
}
for hs.pluginPreinstall.IsPinned(pluginID) {
return response.Error(http.StatusConflict, "Cannot update a pinned pre-installed plugin", nil)
}
compatOpts := plugins.NewCompatOpts(hs.Cfg.BuildVersion, runtime.GOOS, runtime.GOARCH)
compatOpts := plugins.NewAddOpts(hs.Cfg.BuildVersion, runtime.GOOS, runtime.GOARCH, "")
ctx := repo.WithRequestOrigin(c.Req.Context(), "api")
err := hs.pluginInstaller.Add(ctx, pluginID, dto.Version, compatOpts)
if err != nil {
@@ -506,10 +509,8 @@ func (hs *HTTPServer) UninstallPlugin(c *contextmodel.ReqContext) response.Respo
return response.Error(http.StatusNotFound, "Plugin not installed", nil)
}
for _, preinstalled := range hs.Cfg.PreinstallPlugins {
if preinstalled.ID == pluginID {
return response.Error(http.StatusConflict, "Cannot uninstall a pre-installed plugin", nil)
}
for hs.pluginPreinstall.IsPreinstalled(pluginID) {
return response.Error(http.StatusConflict, "Cannot uninstall a pre-installed plugin", nil)
}
err := hs.pluginInstaller.Remove(c.Req.Context(), pluginID, plugin.Info.Version)
@@ -578,14 +579,14 @@ func (hs *HTTPServer) hasPluginRequestedPermissions(c *contextmodel.ReqContext,
}
// evalAllPermissions generates an evaluator with all permissions from the input slice
func evalAllPermissions(ps []pfs.Permission) ac.Evaluator {
res := []ac.Evaluator{}
for _, p := range ps {
if p.Scope != nil {
res = append(res, ac.EvalPermission(p.Action, *p.Scope))
func evalAllPermissions(ps []auth.Permission) ac.Evaluator {
res := make([]ac.Evaluator, len(ps))
for i, p := range ps {
if p.Scope != "" {
res[i] = ac.EvalPermission(p.Action, p.Scope)
continue
}
res = append(res, ac.EvalPermission(p.Action))
res[i] = ac.EvalPermission(p.Action)
}
return ac.EvalAll(res...)
}
+49 -10
View File
@@ -12,12 +12,14 @@ import (
"strings"
"testing"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/plugins/auth"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/log/logtest"
@@ -29,14 +31,12 @@ import (
"github.com/grafana/grafana/pkg/plugins/manager/registry"
"github.com/grafana/grafana/pkg/plugins/manager/signature"
"github.com/grafana/grafana/pkg/plugins/manager/signature/statickey"
"github.com/grafana/grafana/pkg/plugins/pfs"
"github.com/grafana/grafana/pkg/plugins/pluginscdn"
ac "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/authn"
"github.com/grafana/grafana/pkg/services/authn/authntest"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org"
@@ -45,6 +45,7 @@ import (
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginassets"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs"
"github.com/grafana/grafana/pkg/services/pluginsintegration/plugininstaller"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore"
"github.com/grafana/grafana/pkg/services/updatechecker"
@@ -72,7 +73,7 @@ func Test_PluginsInstallAndUninstall(t *testing.T) {
preInstalledPlugin bool
}
tcs := []testCase{
{expectedCode: http.StatusNotFound, permissionOrg: globalOrg, permissions: canInstall, pluginAdminEnabled: true, pluginAdminExternalManageEnabled: true},
{expectedCode: http.StatusOK, permissionOrg: globalOrg, permissions: canInstall, pluginAdminEnabled: true, pluginAdminExternalManageEnabled: true},
{expectedCode: http.StatusNotFound, permissionOrg: globalOrg, permissions: canInstall, pluginAdminEnabled: false, pluginAdminExternalManageEnabled: true},
{expectedCode: http.StatusNotFound, permissionOrg: globalOrg, permissions: canInstall, pluginAdminEnabled: false, pluginAdminExternalManageEnabled: false},
{expectedCode: http.StatusForbidden, permissionOrg: globalOrg, permissions: cannotInstall, pluginAdminEnabled: true, pluginAdminExternalManageEnabled: false},
@@ -111,6 +112,7 @@ func Test_PluginsInstallAndUninstall(t *testing.T) {
},
})
hs.managedPluginsService = managedplugins.NewNoop()
hs.pluginPreinstall = plugininstaller.ProvidePreinstall(hs.Cfg)
expectedIdentity := &authn.Identity{
OrgID: tc.permissionOrg,
@@ -682,14 +684,11 @@ func createPlugin(jd plugins.JSONData, class plugins.Class, files plugins.FS) *p
}
func TestHTTPServer_hasPluginRequestedPermissions(t *testing.T) {
newStr := func(s string) *string {
return &s
}
pluginReg := pluginstore.Plugin{
JSONData: plugins.JSONData{
ID: "grafana-test-app",
IAM: &pfs.IAM{
Permissions: []pfs.Permission{{Action: ac.ActionUsersRead, Scope: newStr(ac.ScopeUsersAll)}, {Action: ac.ActionUsersCreate}},
IAM: &auth.IAM{
Permissions: []auth.Permission{{Action: ac.ActionUsersRead, Scope: ac.ScopeUsersAll}, {Action: ac.ActionUsersCreate}},
},
},
}
@@ -762,7 +761,7 @@ func TestHTTPServer_hasPluginRequestedPermissions(t *testing.T) {
}
hs.log = logger
hs.accesscontrolService = actest.FakeService{}
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient())
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
expectedIdentity := &authn.Identity{
OrgID: tt.orgID,
@@ -871,3 +870,43 @@ func Test_PluginsSettings(t *testing.T) {
})
}
}
func Test_UpdatePluginSetting(t *testing.T) {
pID := "test-app"
p1 := createPlugin(plugins.JSONData{
ID: pID, Type: "app", Name: pID,
Info: plugins.Info{
Version: "1.0.0",
},
AutoEnabled: true,
}, plugins.ClassExternal, plugins.NewFakeFS(),
)
pluginRegistry := &fakes.FakePluginRegistry{
Store: map[string]*plugins.Plugin{
p1.ID: p1,
},
}
pluginSettings := pluginsettings.FakePluginSettings{Plugins: map[string]*pluginsettings.DTO{
pID: {ID: 0, OrgID: 1, PluginID: pID, PluginVersion: "1.0.0", Enabled: true},
}}
t.Run("should return an error when trying to disable an auto-enabled plugin", func(t *testing.T) {
server := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.Cfg = setting.NewCfg()
hs.PluginSettings = &pluginSettings
hs.pluginStore = pluginstore.New(pluginRegistry, &fakes.FakeLoader{})
hs.pluginFileStore = filestore.ProvideService(pluginRegistry)
hs.managedPluginsService = managedplugins.NewNoop()
hs.log = log.NewNopLogger()
})
input := strings.NewReader(`{"enabled": false}`)
endpoint := fmt.Sprintf("/api/plugins/%s/settings", pID)
req := webtest.RequestWithSignedInUser(server.NewPostRequest(endpoint, input), userWithPermissions(1, []ac.Permission{{Action: pluginaccesscontrol.ActionWrite, Scope: "plugins:id:test-app"}}))
res, err := server.SendJSON(req)
require.NoError(t, err)
require.Equal(t, http.StatusBadRequest, res.StatusCode)
require.NoError(t, res.Body.Close())
})
}
+6 -2
View File
@@ -46,12 +46,16 @@ func (hs *HTTPServer) redirectFromShortURL(c *contextmodel.ReqContext) {
shortURL, err := hs.ShortURLService.GetShortURLByUID(c.Req.Context(), c.SignedInUser, shortURLUID)
if err != nil {
// If we didn't get the URL for whatever reason, we redirect to the
// main page, otherwise we get into an endless loops of redirects, as
// we would try to redirect again.
if shorturls.ErrShortURLNotFound.Is(err) {
hs.log.Debug("Not redirecting short URL since not found")
hs.log.Debug("Not redirecting short URL since not found", "uid", shortURLUID)
c.Redirect(hs.Cfg.AppURL, 308)
return
}
hs.log.Error("Short URL redirection error", "err", err)
c.Redirect(hs.Cfg.AppURL, 307)
return
}
+5 -6
View File
@@ -8,7 +8,7 @@ import (
"strconv"
"strings"
"github.com/grafana/authlib/claims"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
@@ -36,7 +36,7 @@ func (hs *HTTPServer) GetSignedInUser(c *contextmodel.ReqContext) response.Respo
IsGrafanaAdmin: c.SignedInUser.GetIsGrafanaAdmin(),
OrgID: c.SignedInUser.GetOrgID(),
UID: c.SignedInUser.GetID(),
Name: c.SignedInUser.NameOrFallback(),
Name: c.SignedInUser.GetName(),
Email: c.SignedInUser.GetEmail(),
Login: c.SignedInUser.GetLogin(),
})
@@ -86,9 +86,8 @@ func (hs *HTTPServer) getUserUserProfile(c *contextmodel.ReqContext, userID int6
userProfile.AuthLabels = append(userProfile.AuthLabels, authLabel)
userProfile.IsExternal = true
oauthInfo := hs.SocialService.GetOAuthInfoProvider(authInfo.AuthModule)
userProfile.IsExternallySynced = login.IsExternallySynced(hs.Cfg, authInfo.AuthModule, oauthInfo)
userProfile.IsGrafanaAdminExternallySynced = login.IsGrafanaAdminExternallySynced(hs.Cfg, oauthInfo, authInfo.AuthModule)
userProfile.IsExternallySynced = hs.isExternallySynced(hs.Cfg, authInfo.AuthModule)
userProfile.IsGrafanaAdminExternallySynced = hs.isGrafanaAdminExternallySynced(hs.Cfg, authInfo.AuthModule)
}
userProfile.AccessControl = getAccessControlMetadata(c, "global.users:id:", strconv.FormatInt(userID, 10))
@@ -281,7 +280,7 @@ func (hs *HTTPServer) StartEmailVerificaton(c *contextmodel.ReqContext) response
return response.Error(http.StatusBadRequest, "Only users can verify their email", nil)
}
if c.SignedInUser.IsEmailVerified() {
if c.SignedInUser.GetEmailVerified() {
// email is already verified so we don't need to trigger the flow.
return response.Respond(http.StatusNotModified, nil)
}
+61 -7
View File
@@ -14,6 +14,9 @@ import (
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"github.com/grafana/grafana/pkg/services/authn"
"github.com/grafana/grafana/pkg/services/authn/authntest"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
@@ -28,7 +31,6 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
"github.com/grafana/grafana/pkg/services/auth/idtest"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/login"
@@ -61,7 +63,7 @@ func TestUserAPIEndpoint_userLoggedIn(t *testing.T) {
hs := &HTTPServer{
Cfg: settings,
SQLStore: sqlStore,
AccessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()),
AccessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures()),
}
mockResult := user.SearchUserQueryResult{
@@ -243,6 +245,7 @@ func Test_GetUserByID(t *testing.T) {
authEnabled bool
skipOrgRoleSync bool
expectedIsGrafanaAdminSynced bool
expectedIsExternallySynced bool
}{
{
name: "Should return IsGrafanaAdminExternallySynced = false for an externally synced OAuth user if Grafana Admin role is not synced",
@@ -251,6 +254,7 @@ func Test_GetUserByID(t *testing.T) {
allowAssignGrafanaAdmin: false,
skipOrgRoleSync: false,
expectedIsGrafanaAdminSynced: false,
expectedIsExternallySynced: true,
},
{
name: "Should return IsGrafanaAdminExternallySynced = false for an externally synced OAuth user if OAuth provider is not enabled",
@@ -259,6 +263,7 @@ func Test_GetUserByID(t *testing.T) {
allowAssignGrafanaAdmin: true,
skipOrgRoleSync: false,
expectedIsGrafanaAdminSynced: false,
expectedIsExternallySynced: false,
},
{
name: "Should return IsGrafanaAdminExternallySynced = false for an externally synced OAuth user if org roles are not being synced",
@@ -267,6 +272,7 @@ func Test_GetUserByID(t *testing.T) {
allowAssignGrafanaAdmin: true,
skipOrgRoleSync: true,
expectedIsGrafanaAdminSynced: false,
expectedIsExternallySynced: false,
},
{
name: "Should return IsGrafanaAdminExternallySynced = true for an externally synced OAuth user",
@@ -275,6 +281,7 @@ func Test_GetUserByID(t *testing.T) {
allowAssignGrafanaAdmin: true,
skipOrgRoleSync: false,
expectedIsGrafanaAdminSynced: true,
expectedIsExternallySynced: true,
},
{
name: "Should return IsGrafanaAdminExternallySynced = false for an externally synced JWT user if Grafana Admin role is not synced",
@@ -283,6 +290,7 @@ func Test_GetUserByID(t *testing.T) {
allowAssignGrafanaAdmin: false,
skipOrgRoleSync: false,
expectedIsGrafanaAdminSynced: false,
expectedIsExternallySynced: true,
},
{
name: "Should return IsGrafanaAdminExternallySynced = false for an externally synced JWT user if JWT provider is not enabled",
@@ -291,6 +299,7 @@ func Test_GetUserByID(t *testing.T) {
allowAssignGrafanaAdmin: true,
skipOrgRoleSync: false,
expectedIsGrafanaAdminSynced: false,
expectedIsExternallySynced: false,
},
{
name: "Should return IsGrafanaAdminExternallySynced = false for an externally synced JWT user if org roles are not being synced",
@@ -299,6 +308,7 @@ func Test_GetUserByID(t *testing.T) {
allowAssignGrafanaAdmin: true,
skipOrgRoleSync: true,
expectedIsGrafanaAdminSynced: false,
expectedIsExternallySynced: false,
},
{
name: "Should return IsGrafanaAdminExternallySynced = true for an externally synced JWT user",
@@ -307,6 +317,31 @@ func Test_GetUserByID(t *testing.T) {
allowAssignGrafanaAdmin: true,
skipOrgRoleSync: false,
expectedIsGrafanaAdminSynced: true,
expectedIsExternallySynced: true,
},
{
name: "Should return IsExternallySynced = true for an externally synced SAML user",
authModule: login.SAMLAuthModule,
authEnabled: true,
skipOrgRoleSync: false,
expectedIsGrafanaAdminSynced: false,
expectedIsExternallySynced: true,
},
{
name: "Should return IsExternallySynced = false for an externally synced SAML user if SAML provider is not enabled",
authModule: login.SAMLAuthModule,
authEnabled: false,
skipOrgRoleSync: false,
expectedIsGrafanaAdminSynced: false,
expectedIsExternallySynced: false,
},
{
name: "Should return IsExternallySynced = false for an externally synced SAML user if if org roles are not being synced",
authModule: login.SAMLAuthModule,
authEnabled: true,
skipOrgRoleSync: true,
expectedIsGrafanaAdminSynced: false,
expectedIsExternallySynced: false,
},
}
for _, tc := range testcases {
@@ -315,15 +350,28 @@ func Test_GetUserByID(t *testing.T) {
authInfoService := &authinfotest.FakeService{ExpectedUserAuth: userAuth}
socialService := &socialtest.FakeSocialService{}
userService := &usertest.FakeUserService{ExpectedUserProfileDTO: &user.UserProfileDTO{}}
authnService := &authntest.FakeService{
ExpectedClientConfig: &authntest.FakeSSOClientConfig{
ExpectedIsSkipOrgRoleSyncEnabled: tc.skipOrgRoleSync,
ExpectedIsAllowAssignGrafanaAdminEnabled: tc.allowAssignGrafanaAdmin,
},
EnabledClients: []string{},
}
cfg := setting.NewCfg()
switch tc.authModule {
case login.GenericOAuthModule:
socialService.ExpectedAuthInfoProvider = &social.OAuthInfo{AllowAssignGrafanaAdmin: tc.allowAssignGrafanaAdmin, Enabled: tc.authEnabled, SkipOrgRoleSync: tc.skipOrgRoleSync}
if tc.authEnabled {
authnService.EnabledClients = []string{authn.ClientWithPrefix("generic_oauth")}
}
case login.JWTModule:
cfg.JWTAuth.Enabled = tc.authEnabled
cfg.JWTAuth.SkipOrgRoleSync = tc.skipOrgRoleSync
cfg.JWTAuth.AllowAssignGrafanaAdmin = tc.allowAssignGrafanaAdmin
case login.SAMLAuthModule:
if tc.authEnabled {
authnService.EnabledClients = []string{authn.ClientSAML}
}
}
hs := &HTTPServer{
@@ -331,6 +379,7 @@ func Test_GetUserByID(t *testing.T) {
authInfoService: authInfoService,
SocialService: socialService,
userService: userService,
authnService: authnService,
}
sc := setupScenarioContext(t, "/api/users/1")
@@ -348,13 +397,13 @@ func Test_GetUserByID(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, tc.expectedIsGrafanaAdminSynced, resp.IsGrafanaAdminExternallySynced)
assert.Equal(t, tc.expectedIsExternallySynced, resp.IsExternallySynced)
})
}
}
func TestHTTPServer_UpdateUser(t *testing.T) {
settings := setting.NewCfg()
settings.SAMLAuthEnabled = true
sqlStore := db.InitTestDB(t)
hs := &HTTPServer{
@@ -362,6 +411,9 @@ func TestHTTPServer_UpdateUser(t *testing.T) {
SQLStore: sqlStore,
AccessControl: acmock.New(),
SocialService: &socialtest.FakeSocialService{ExpectedAuthInfoProvider: &social.OAuthInfo{Enabled: true}},
authnService: &authntest.FakeService{
EnabledClients: []string{authn.ClientSAML},
},
}
updateUserCommand := user.UpdateUserCommand{
@@ -411,7 +463,7 @@ func setupUpdateEmailTests(t *testing.T, cfg *setting.Cfg) (*user.User, *HTTPSer
require.NoError(t, err)
nsMock := notifications.MockNotificationService()
verifier := userimpl.ProvideVerifier(cfg, userSvc, tempUserService, nsMock, &idtest.MockService{})
verifier := userimpl.ProvideVerifier(cfg, userSvc, tempUserService, nsMock, &idtest.FakeService{})
hs := &HTTPServer{
Cfg: cfg,
@@ -636,7 +688,7 @@ func TestUser_UpdateEmail(t *testing.T) {
hs.tempUserService = tempUserSvc
hs.NotificationService = nsMock
hs.SecretsService = fakes.NewFakeSecretsService()
hs.userVerifier = userimpl.ProvideVerifier(settings, userSvc, tempUserSvc, nsMock, &idtest.MockService{})
hs.userVerifier = userimpl.ProvideVerifier(settings, userSvc, tempUserSvc, nsMock, &idtest.FakeService{})
// User is internal
hs.authInfoService = &authinfotest.FakeService{ExpectedError: user.ErrUserNotFound}
})
@@ -1102,13 +1154,15 @@ func updateUserScenario(t *testing.T, ctx updateUserContext, hs *HTTPServer) {
func TestHTTPServer_UpdateSignedInUser(t *testing.T) {
settings := setting.NewCfg()
sqlStore := db.InitTestDB(t)
settings.SAMLAuthEnabled = true
hs := &HTTPServer{
Cfg: settings,
SQLStore: sqlStore,
AccessControl: acmock.New(),
SocialService: &socialtest.FakeSocialService{},
authnService: &authntest.FakeService{
EnabledClients: []string{authn.ClientSAML},
},
}
updateUserCommand := user.UpdateUserCommand{
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/ua-parser/uap-go/uaparser"
"github.com/grafana/authlib/claims"
claims "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/infra/network"
+1 -1
View File
@@ -49,7 +49,7 @@ func (hs *HTTPServer) isExternalUser(ctx context.Context, userID int64) (bool, e
return true, err
}
return login.IsProviderEnabled(hs.Cfg, info.AuthModule, hs.SocialService.GetOAuthInfoProvider(info.AuthModule)), nil
return hs.isProviderEnabled(hs.Cfg, info.AuthModule), nil
}
func ValidateAndNormalizeEmail(email string) (string, error) {