diff --git a/pkg/infra/db/sqlbuilder.go b/pkg/infra/db/sqlbuilder.go index 1b3c8427879..8d55f41fa7c 100644 --- a/pkg/infra/db/sqlbuilder.go +++ b/pkg/infra/db/sqlbuilder.go @@ -5,20 +5,26 @@ import ( ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) -func NewSqlBuilder(cfg *setting.Cfg, dialect migrator.Dialect) SQLBuilder { - return SQLBuilder{cfg: cfg, dialect: dialect} +func NewSqlBuilder(cfg *setting.Cfg, features featuremgmt.FeatureToggles, dialect migrator.Dialect, recursiveQueriesAreSupported bool) SQLBuilder { + return SQLBuilder{cfg: cfg, features: features, dialect: dialect, recursiveQueriesAreSupported: recursiveQueriesAreSupported} } type SQLBuilder struct { - cfg *setting.Cfg - sql bytes.Buffer - params []interface{} + cfg *setting.Cfg + features featuremgmt.FeatureToggles + sql bytes.Buffer + params []interface{} + recQry string + recQryParams []interface{} + recursiveQueriesAreSupported bool + dialect migrator.Dialect } @@ -31,10 +37,22 @@ func (sb *SQLBuilder) Write(sql string, params ...interface{}) { } func (sb *SQLBuilder) GetSQLString() string { - return sb.sql.String() + if sb.recQry == "" { + return sb.sql.String() + } + + var bf bytes.Buffer + bf.WriteString(sb.recQry) + bf.WriteString(sb.sql.String()) + return bf.String() } func (sb *SQLBuilder) GetParams() []interface{} { + if len(sb.recQryParams) == 0 { + return sb.params + } + + sb.params = append(sb.recQryParams, sb.params...) return sb.params } @@ -44,11 +62,15 @@ func (sb *SQLBuilder) AddParams(params ...interface{}) { func (sb *SQLBuilder) WriteDashboardPermissionFilter(user *user.SignedInUser, permission dashboards.PermissionType) { var ( - sql string - params []interface{} + sql string + params []interface{} + recQry string + recQryParams []interface{} ) if !ac.IsDisabled(sb.cfg) { - sql, params = permissions.NewAccessControlDashboardPermissionFilter(user, permission, "").Where() + filterRBAC := permissions.NewAccessControlDashboardPermissionFilter(user, permission, "", sb.features, sb.recursiveQueriesAreSupported) + sql, params = filterRBAC.Where() + recQry, recQryParams = filterRBAC.With() } else { sql, params = permissions.DashboardPermissionFilter{ OrgRole: user.OrgRole, @@ -61,4 +83,6 @@ func (sb *SQLBuilder) WriteDashboardPermissionFilter(user *user.SignedInUser, pe sb.sql.WriteString(" AND " + sql) sb.params = append(sb.params, params...) + sb.recQry = recQry + sb.recQryParams = recQryParams } diff --git a/pkg/infra/db/sqlbuilder_test.go b/pkg/infra/db/sqlbuilder_test.go index ce88c870a56..0d1f3e45ba4 100644 --- a/pkg/infra/db/sqlbuilder_test.go +++ b/pkg/infra/db/sqlbuilder_test.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/services/dashboards" dashver "github.com/grafana/grafana/pkg/services/dashboardversion" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/user" @@ -24,6 +25,7 @@ func TestIntegrationSQLBuilder(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") } + t.Run("WriteDashboardPermissionFilter", func(t *testing.T) { t.Run("user ACL", func(t *testing.T) { test(t, @@ -31,6 +33,7 @@ func TestIntegrationSQLBuilder(t *testing.T) { &DashboardPermission{User: true, Permission: dashboards.PERMISSION_VIEW}, Search{UserFromACL: true, RequiredPermission: dashboards.PERMISSION_VIEW}, shouldFind, + featuremgmt.WithFeatures(), ) test(t, @@ -38,6 +41,7 @@ func TestIntegrationSQLBuilder(t *testing.T) { &DashboardPermission{User: true, Permission: dashboards.PERMISSION_VIEW}, Search{UserFromACL: true, RequiredPermission: dashboards.PERMISSION_EDIT}, shouldNotFind, + featuremgmt.WithFeatures(), ) test(t, @@ -45,6 +49,7 @@ func TestIntegrationSQLBuilder(t *testing.T) { &DashboardPermission{User: true, Permission: dashboards.PERMISSION_EDIT}, Search{UserFromACL: true, RequiredPermission: dashboards.PERMISSION_EDIT}, shouldFind, + featuremgmt.WithFeatures(), ) test(t, @@ -52,6 +57,41 @@ func TestIntegrationSQLBuilder(t *testing.T) { &DashboardPermission{User: true, Permission: dashboards.PERMISSION_VIEW}, Search{RequiredPermission: dashboards.PERMISSION_VIEW}, shouldNotFind, + featuremgmt.WithFeatures(), + ) + }) + + t.Run("user ACL with nested folders", func(t *testing.T) { + test(t, + DashboardProps{}, + &DashboardPermission{User: true, Permission: dashboards.PERMISSION_VIEW}, + Search{UserFromACL: true, RequiredPermission: dashboards.PERMISSION_VIEW}, + shouldFind, + featuremgmt.WithFeatures(featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)), + ) + + test(t, + DashboardProps{}, + &DashboardPermission{User: true, Permission: dashboards.PERMISSION_VIEW}, + Search{UserFromACL: true, RequiredPermission: dashboards.PERMISSION_EDIT}, + shouldNotFind, + featuremgmt.WithFeatures(featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)), + ) + + test(t, + DashboardProps{}, + &DashboardPermission{User: true, Permission: dashboards.PERMISSION_EDIT}, + Search{UserFromACL: true, RequiredPermission: dashboards.PERMISSION_EDIT}, + shouldFind, + featuremgmt.WithFeatures(featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)), + ) + + test(t, + DashboardProps{}, + &DashboardPermission{User: true, Permission: dashboards.PERMISSION_VIEW}, + Search{RequiredPermission: dashboards.PERMISSION_VIEW}, + shouldNotFind, + featuremgmt.WithFeatures(featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)), ) }) @@ -61,6 +101,7 @@ func TestIntegrationSQLBuilder(t *testing.T) { &DashboardPermission{Role: org.RoleViewer, Permission: dashboards.PERMISSION_VIEW}, Search{UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_VIEW}, shouldFind, + featuremgmt.WithFeatures(), ) test(t, @@ -68,6 +109,7 @@ func TestIntegrationSQLBuilder(t *testing.T) { &DashboardPermission{Role: org.RoleViewer, Permission: dashboards.PERMISSION_VIEW}, Search{UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_EDIT}, shouldNotFind, + featuremgmt.WithFeatures(), ) test(t, @@ -75,6 +117,7 @@ func TestIntegrationSQLBuilder(t *testing.T) { &DashboardPermission{Role: org.RoleEditor, Permission: dashboards.PERMISSION_VIEW}, Search{UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_VIEW}, shouldNotFind, + featuremgmt.WithFeatures(), ) test(t, @@ -82,6 +125,41 @@ func TestIntegrationSQLBuilder(t *testing.T) { &DashboardPermission{Role: org.RoleEditor, Permission: dashboards.PERMISSION_VIEW}, Search{UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_VIEW}, shouldNotFind, + featuremgmt.WithFeatures(), + ) + }) + + t.Run("role ACL with nested folders", func(t *testing.T) { + test(t, + DashboardProps{}, + &DashboardPermission{Role: org.RoleViewer, Permission: dashboards.PERMISSION_VIEW}, + Search{UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_VIEW}, + shouldFind, + featuremgmt.WithFeatures(featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)), + ) + + test(t, + DashboardProps{}, + &DashboardPermission{Role: org.RoleViewer, Permission: dashboards.PERMISSION_VIEW}, + Search{UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_EDIT}, + shouldNotFind, + featuremgmt.WithFeatures(featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)), + ) + + test(t, + DashboardProps{}, + &DashboardPermission{Role: org.RoleEditor, Permission: dashboards.PERMISSION_VIEW}, + Search{UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_VIEW}, + shouldNotFind, + featuremgmt.WithFeatures(featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)), + ) + + test(t, + DashboardProps{}, + &DashboardPermission{Role: org.RoleEditor, Permission: dashboards.PERMISSION_VIEW}, + Search{UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_VIEW}, + shouldNotFind, + featuremgmt.WithFeatures(featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)), ) }) @@ -91,6 +169,7 @@ func TestIntegrationSQLBuilder(t *testing.T) { &DashboardPermission{Team: true, Permission: dashboards.PERMISSION_VIEW}, Search{UserFromACL: true, RequiredPermission: dashboards.PERMISSION_VIEW}, shouldFind, + featuremgmt.WithFeatures(), ) test(t, @@ -98,6 +177,7 @@ func TestIntegrationSQLBuilder(t *testing.T) { &DashboardPermission{Team: true, Permission: dashboards.PERMISSION_VIEW}, Search{UserFromACL: true, RequiredPermission: dashboards.PERMISSION_EDIT}, shouldNotFind, + featuremgmt.WithFeatures(), ) test(t, @@ -105,6 +185,7 @@ func TestIntegrationSQLBuilder(t *testing.T) { &DashboardPermission{Team: true, Permission: dashboards.PERMISSION_EDIT}, Search{UserFromACL: true, RequiredPermission: dashboards.PERMISSION_EDIT}, shouldFind, + featuremgmt.WithFeatures(), ) test(t, @@ -112,6 +193,41 @@ func TestIntegrationSQLBuilder(t *testing.T) { &DashboardPermission{Team: true, Permission: dashboards.PERMISSION_EDIT}, Search{UserFromACL: false, RequiredPermission: dashboards.PERMISSION_EDIT}, shouldNotFind, + featuremgmt.WithFeatures(), + ) + }) + + t.Run("team ACL with nested folders", func(t *testing.T) { + test(t, + DashboardProps{}, + &DashboardPermission{Team: true, Permission: dashboards.PERMISSION_VIEW}, + Search{UserFromACL: true, RequiredPermission: dashboards.PERMISSION_VIEW}, + shouldFind, + featuremgmt.WithFeatures(featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)), + ) + + test(t, + DashboardProps{}, + &DashboardPermission{Team: true, Permission: dashboards.PERMISSION_VIEW}, + Search{UserFromACL: true, RequiredPermission: dashboards.PERMISSION_EDIT}, + shouldNotFind, + featuremgmt.WithFeatures(featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)), + ) + + test(t, + DashboardProps{}, + &DashboardPermission{Team: true, Permission: dashboards.PERMISSION_EDIT}, + Search{UserFromACL: true, RequiredPermission: dashboards.PERMISSION_EDIT}, + shouldFind, + featuremgmt.WithFeatures(featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)), + ) + + test(t, + DashboardProps{}, + &DashboardPermission{Team: true, Permission: dashboards.PERMISSION_EDIT}, + Search{UserFromACL: false, RequiredPermission: dashboards.PERMISSION_EDIT}, + shouldNotFind, + featuremgmt.WithFeatures(featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)), ) }) @@ -121,6 +237,7 @@ func TestIntegrationSQLBuilder(t *testing.T) { nil, Search{OrgId: -1, UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_VIEW}, shouldNotFind, + featuremgmt.WithFeatures(), ) test(t, @@ -128,6 +245,7 @@ func TestIntegrationSQLBuilder(t *testing.T) { nil, Search{OrgId: -1, UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_VIEW}, shouldFind, + featuremgmt.WithFeatures(), ) test(t, @@ -135,6 +253,7 @@ func TestIntegrationSQLBuilder(t *testing.T) { nil, Search{OrgId: -1, UsersOrgRole: org.RoleEditor, RequiredPermission: dashboards.PERMISSION_EDIT}, shouldFind, + featuremgmt.WithFeatures(), ) test(t, @@ -142,6 +261,41 @@ func TestIntegrationSQLBuilder(t *testing.T) { nil, Search{OrgId: -1, UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_EDIT}, shouldNotFind, + featuremgmt.WithFeatures(), + ) + }) + + t.Run("defaults for user ACL with nested folders", func(t *testing.T) { + test(t, + DashboardProps{}, + nil, + Search{OrgId: -1, UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_VIEW}, + shouldNotFind, + featuremgmt.WithFeatures(featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)), + ) + + test(t, + DashboardProps{OrgId: -1}, + nil, + Search{OrgId: -1, UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_VIEW}, + shouldFind, + featuremgmt.WithFeatures(featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)), + ) + + test(t, + DashboardProps{OrgId: -1}, + nil, + Search{OrgId: -1, UsersOrgRole: org.RoleEditor, RequiredPermission: dashboards.PERMISSION_EDIT}, + shouldFind, + featuremgmt.WithFeatures(featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)), + ) + + test(t, + DashboardProps{OrgId: -1}, + nil, + Search{OrgId: -1, UsersOrgRole: org.RoleViewer, RequiredPermission: dashboards.PERMISSION_EDIT}, + shouldNotFind, + featuremgmt.WithFeatures(featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)), ) }) }) @@ -172,7 +326,7 @@ type dashboardResponse struct { Id int64 } -func test(t *testing.T, dashboardProps DashboardProps, dashboardPermission *DashboardPermission, search Search, shouldFind bool) { +func test(t *testing.T, dashboardProps DashboardProps, dashboardPermission *DashboardPermission, search Search, shouldFind bool, features featuremgmt.FeatureToggles) { t.Helper() t.Run("", func(t *testing.T) { @@ -186,7 +340,7 @@ func test(t *testing.T, dashboardProps DashboardProps, dashboardPermission *Dash aclUserID = createDummyACL(t, sqlStore, dashboardPermission, search, dashboard.ID) t.Logf("Created ACL with user ID %d\n", aclUserID) } - dashboards := getDashboards(t, sqlStore, search, aclUserID) + dashboards := getDashboards(t, sqlStore, search, aclUserID, features) if shouldFind { require.Len(t, dashboards, 1, "Should return one dashboard") @@ -292,7 +446,7 @@ func createDummyACL(t *testing.T, sqlStore *sqlstore.SQLStore, dashboardPermissi return 0 } -func getDashboards(t *testing.T, sqlStore *sqlstore.SQLStore, search Search, aclUserID int64) []*dashboardResponse { +func getDashboards(t *testing.T, sqlStore *sqlstore.SQLStore, search Search, aclUserID int64, features featuremgmt.FeatureToggles) []*dashboardResponse { t.Helper() old := sqlStore.Cfg.RBACEnabled @@ -301,7 +455,10 @@ func getDashboards(t *testing.T, sqlStore *sqlstore.SQLStore, search Search, acl sqlStore.Cfg.RBACEnabled = old }() - builder := NewSqlBuilder(sqlStore.Cfg, sqlStore.GetDialect()) + recursiveQueriesAreSupported, err := sqlStore.RecursiveQueriesAreSupported() + require.NoError(t, err) + + builder := NewSqlBuilder(sqlStore.Cfg, features, sqlStore.GetDialect(), recursiveQueriesAreSupported) signedInUser := &user.SignedInUser{ UserID: 9999999999, } @@ -325,7 +482,7 @@ func getDashboards(t *testing.T, sqlStore *sqlstore.SQLStore, search Search, acl builder.Write("SELECT * FROM dashboard WHERE true") builder.WriteDashboardPermissionFilter(signedInUser, search.RequiredPermission) t.Logf("Searching for dashboards, SQL: %q\n", builder.GetSQLString()) - err := sqlStore.GetEngine().SQL(builder.GetSQLString(), builder.params...).Find(&res) + err = sqlStore.GetEngine().SQL(builder.GetSQLString(), builder.params...).Find(&res) require.NoError(t, err) return res } diff --git a/pkg/services/alerting/conditions/query_interval_test.go b/pkg/services/alerting/conditions/query_interval_test.go index 289a769ebde..443b0f74d83 100644 --- a/pkg/services/alerting/conditions/query_interval_test.go +++ b/pkg/services/alerting/conditions/query_interval_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/datasources" fd "github.com/grafana/grafana/pkg/services/datasources/fakes" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/validations" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/intervalv2" @@ -141,7 +142,7 @@ func (rh fakeIntervalTestReqHandler) HandleRequest(ctx context.Context, dsInfo * func applyScenario(t *testing.T, timeRange string, dataSourceJsonData *simplejson.Json, queryModel string, verifier func(query legacydata.DataSubQuery)) { t.Run("desc", func(t *testing.T) { db := dbtest.NewFakeDB() - store := alerting.ProvideAlertStore(db, localcache.ProvideService(), &setting.Cfg{}, nil) + store := alerting.ProvideAlertStore(db, localcache.ProvideService(), &setting.Cfg{}, nil, featuremgmt.WithFeatures()) ctx := &queryIntervalTestContext{} ctx.result = &alerting.EvalContext{ diff --git a/pkg/services/alerting/conditions/query_test.go b/pkg/services/alerting/conditions/query_test.go index d3dcea599d6..11e98da1aca 100644 --- a/pkg/services/alerting/conditions/query_test.go +++ b/pkg/services/alerting/conditions/query_test.go @@ -18,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/services/alerting" "github.com/grafana/grafana/pkg/services/datasources" fd "github.com/grafana/grafana/pkg/services/datasources/fakes" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/validations" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tsdb/legacydata" @@ -38,7 +39,7 @@ func TestQueryCondition(t *testing.T) { setup := func() *queryConditionTestContext { ctx := &queryConditionTestContext{} db := dbtest.NewFakeDB() - store := alerting.ProvideAlertStore(db, localcache.ProvideService(), &setting.Cfg{}, nil) + store := alerting.ProvideAlertStore(db, localcache.ProvideService(), &setting.Cfg{}, nil, featuremgmt.WithFeatures()) ctx.reducer = `{"type":"avg"}` ctx.evaluator = `{"type":"gt","params":[100]}` ctx.result = &alerting.EvalContext{ diff --git a/pkg/services/alerting/extractor_test.go b/pkg/services/alerting/extractor_test.go index 9e2db68b0bc..fa15c9190c3 100644 --- a/pkg/services/alerting/extractor_test.go +++ b/pkg/services/alerting/extractor_test.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/datasources/permissions" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/setting" ) @@ -42,7 +43,8 @@ func TestAlertRuleExtraction(t *testing.T) { dsService := &fakeDatasourceService{ExpectedDatasource: defaultDs} db := dbtest.NewFakeDB() - store := ProvideAlertStore(db, localcache.ProvideService(), &setting.Cfg{}, nil) + cfg := &setting.Cfg{} + store := ProvideAlertStore(db, localcache.ProvideService(), cfg, nil, featuremgmt.WithFeatures()) extractor := ProvideDashAlertExtractorService(dsPermissions, dsService, store) t.Run("Parsing alert rules from dashboard json", func(t *testing.T) { diff --git a/pkg/services/alerting/store.go b/pkg/services/alerting/store.go index 5f764131dfd..26613090fb4 100644 --- a/pkg/services/alerting/store.go +++ b/pkg/services/alerting/store.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" alertmodels "github.com/grafana/grafana/pkg/services/alerting/models" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/tag" "github.com/grafana/grafana/pkg/setting" @@ -39,17 +40,19 @@ type sqlStore struct { log *log.ConcreteLogger cfg *setting.Cfg tagService tag.Service + features featuremgmt.FeatureToggles } func ProvideAlertStore( db db.DB, - cacheService *localcache.CacheService, cfg *setting.Cfg, tagService tag.Service) AlertStore { + cacheService *localcache.CacheService, cfg *setting.Cfg, tagService tag.Service, features featuremgmt.FeatureToggles) AlertStore { return &sqlStore{ db: db, cache: cacheService, log: log.New("alerting.store"), cfg: cfg, tagService: tagService, + features: features, } } @@ -107,8 +110,13 @@ func deleteAlertByIdInternal(alertId int64, reason string, sess *db.Session, log } func (ss *sqlStore) HandleAlertsQuery(ctx context.Context, query *alertmodels.GetAlertsQuery) (res []*alertmodels.AlertListItemDTO, err error) { + recursiveQueriesAreSupported, err := ss.db.RecursiveQueriesAreSupported() + if err != nil { + return res, err + } + err = ss.db.WithDbSession(ctx, func(sess *db.Session) error { - builder := db.NewSqlBuilder(ss.cfg, ss.db.GetDialect()) + builder := db.NewSqlBuilder(ss.cfg, ss.features, ss.db.GetDialect(), recursiveQueriesAreSupported) builder.Write(`SELECT alert.id, diff --git a/pkg/services/annotations/annotationsimpl/annotations.go b/pkg/services/annotations/annotationsimpl/annotations.go index fd1bfa4e8f6..9929984db1f 100644 --- a/pkg/services/annotations/annotationsimpl/annotations.go +++ b/pkg/services/annotations/annotationsimpl/annotations.go @@ -6,6 +6,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/annotations" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/tag" "github.com/grafana/grafana/pkg/setting" ) @@ -14,10 +15,11 @@ type RepositoryImpl struct { store store } -func ProvideService(db db.DB, cfg *setting.Cfg, tagService tag.Service) *RepositoryImpl { +func ProvideService(db db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles, tagService tag.Service) *RepositoryImpl { return &RepositoryImpl{ store: &xormRepositoryImpl{ cfg: cfg, + features: features, db: db, log: log.New("annotations"), tagService: tagService, diff --git a/pkg/services/annotations/annotationsimpl/cleanup.go b/pkg/services/annotations/annotationsimpl/cleanup.go index af275c97314..a3380d9b4df 100644 --- a/pkg/services/annotations/annotationsimpl/cleanup.go +++ b/pkg/services/annotations/annotationsimpl/cleanup.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" ) @@ -13,12 +14,13 @@ type CleanupServiceImpl struct { store store } -func ProvideCleanupService(db db.DB, cfg *setting.Cfg) *CleanupServiceImpl { +func ProvideCleanupService(db db.DB, cfg *setting.Cfg, features featuremgmt.FeatureToggles) *CleanupServiceImpl { return &CleanupServiceImpl{ store: &xormRepositoryImpl{ - cfg: cfg, - db: db, - log: log.New("annotations"), + cfg: cfg, + features: features, + db: db, + log: log.New("annotations"), }, } } diff --git a/pkg/services/annotations/annotationsimpl/cleanup_test.go b/pkg/services/annotations/annotationsimpl/cleanup_test.go index feb11585914..b072224d446 100644 --- a/pkg/services/annotations/annotationsimpl/cleanup_test.go +++ b/pkg/services/annotations/annotationsimpl/cleanup_test.go @@ -11,6 +11,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/annotations" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" ) @@ -91,7 +92,7 @@ func TestAnnotationCleanUp(t *testing.T) { t.Run(test.name, func(t *testing.T) { cfg := setting.NewCfg() cfg.AnnotationCleanupJobBatchSize = 1 - cleaner := ProvideCleanupService(fakeSQL, cfg) + cleaner := ProvideCleanupService(fakeSQL, cfg, featuremgmt.WithFeatures()) affectedAnnotations, affectedAnnotationTags, err := cleaner.Run(context.Background(), test.cfg) require.NoError(t, err) @@ -146,7 +147,7 @@ func TestOldAnnotationsAreDeletedFirst(t *testing.T) { // run the clean up task to keep one annotation. cfg := setting.NewCfg() cfg.AnnotationCleanupJobBatchSize = 1 - cleaner := &xormRepositoryImpl{cfg: cfg, log: log.New("test-logger"), db: fakeSQL} + cleaner := &xormRepositoryImpl{cfg: cfg, log: log.New("test-logger"), db: fakeSQL, features: featuremgmt.WithFeatures()} _, err = cleaner.CleanAnnotations(context.Background(), setting.AnnotationCleanupSettings{MaxCount: 1}, alertAnnotationType) require.NoError(t, err) diff --git a/pkg/services/annotations/annotationsimpl/xorm_store.go b/pkg/services/annotations/annotationsimpl/xorm_store.go index 7d74fd529f8..a78584f07a2 100644 --- a/pkg/services/annotations/annotationsimpl/xorm_store.go +++ b/pkg/services/annotations/annotationsimpl/xorm_store.go @@ -13,6 +13,7 @@ import ( ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" @@ -42,6 +43,7 @@ func validateTimeRange(item *annotations.Item) error { type xormRepositoryImpl struct { cfg *setting.Cfg + features featuremgmt.FeatureToggles db db.DB log log.Logger maximumTagsLength int64 @@ -299,13 +301,15 @@ func (r *xormRepositoryImpl) Get(ctx context.Context, query *annotations.ItemQue } } + var acFilter acFilter if !ac.IsDisabled(r.cfg) { - acFilter, acArgs, err := getAccessControlFilter(query.SignedInUser) + var err error + acFilter, err = r.getAccessControlFilter(query.SignedInUser) if err != nil { return err } - sql.WriteString(fmt.Sprintf(" AND (%s)", acFilter)) - params = append(params, acArgs...) + sql.WriteString(fmt.Sprintf(" AND (%s)", acFilter.where)) + params = append(params, acFilter.whereParams...) } if query.Limit == 0 { @@ -314,6 +318,14 @@ func (r *xormRepositoryImpl) Get(ctx context.Context, query *annotations.ItemQue // order of ORDER BY arguments match the order of a sql index for performance sql.WriteString(" ORDER BY a.org_id, a.epoch_end DESC, a.epoch DESC" + r.db.GetDialect().Limit(query.Limit) + " ) dt on dt.id = annotation.id") + if acFilter.recQueries != "" { + var sb bytes.Buffer + sb.WriteString(acFilter.recQueries) + sb.WriteString(sql.String()) + sql = sb + params = append(acFilter.recParams, params...) + } + if err := sess.SQL(sql.String(), params...).Find(&items); err != nil { items = nil return err @@ -325,13 +337,23 @@ func (r *xormRepositoryImpl) Get(ctx context.Context, query *annotations.ItemQue return items, err } -func getAccessControlFilter(user *user.SignedInUser) (string, []interface{}, error) { +type acFilter struct { + where string + whereParams []interface{} + recQueries string + recParams []interface{} +} + +func (r *xormRepositoryImpl) getAccessControlFilter(user *user.SignedInUser) (acFilter, error) { + var recQueries string + var recQueriesParams []interface{} + if user == nil || user.Permissions[user.OrgID] == nil { - return "", nil, errors.New("missing permissions") + return acFilter{}, errors.New("missing permissions") } scopes, has := user.Permissions[user.OrgID][ac.ActionAnnotationsRead] if !has { - return "", nil, errors.New("missing permissions") + return acFilter{}, errors.New("missing permissions") } types, hasWildcardScope := ac.ParseScopes(ac.ScopeAnnotationsProvider.GetResourceScopeType(""), scopes) if hasWildcardScope { @@ -347,13 +369,27 @@ func getAccessControlFilter(user *user.SignedInUser) (string, []interface{}, err } // annotation read permission with scope annotations:type:dashboard allows listing annotations from dashboards which the user can view if t == annotations.Dashboard.String() { - dashboardFilter, dashboardParams := permissions.NewAccessControlDashboardPermissionFilter(user, dashboards.PERMISSION_VIEW, searchstore.TypeDashboard).Where() + recursiveQueriesAreSupported, err := r.db.RecursiveQueriesAreSupported() + if err != nil { + return acFilter{}, err + } + + filterRBAC := permissions.NewAccessControlDashboardPermissionFilter(user, dashboards.PERMISSION_VIEW, searchstore.TypeDashboard, r.features, recursiveQueriesAreSupported) + dashboardFilter, dashboardParams := filterRBAC.Where() + recQueries, recQueriesParams = filterRBAC.With() filter := fmt.Sprintf("a.dashboard_id IN(SELECT id FROM dashboard WHERE %s)", dashboardFilter) filters = append(filters, filter) params = dashboardParams } } - return strings.Join(filters, " OR "), params, nil + + f := acFilter{ + where: strings.Join(filters, " OR "), + whereParams: params, + recQueries: recQueries, + recParams: recQueriesParams, + } + return f, nil } func (r *xormRepositoryImpl) Delete(ctx context.Context, params *annotations.DeleteParams) error { diff --git a/pkg/services/annotations/annotationsimpl/xorm_store_test.go b/pkg/services/annotations/annotationsimpl/xorm_store_test.go index 3f949efb763..630e266911e 100644 --- a/pkg/services/annotations/annotationsimpl/xorm_store_test.go +++ b/pkg/services/annotations/annotationsimpl/xorm_store_test.go @@ -2,6 +2,7 @@ package annotationsimpl import ( "context" + "errors" "fmt" "strings" "testing" @@ -10,14 +11,20 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/annotations" "github.com/grafana/grafana/pkg/services/dashboards" dashboardstore "github.com/grafana/grafana/pkg/services/dashboards/database" "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/folder/folderimpl" + "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/tag/tagimpl" @@ -495,7 +502,7 @@ func TestIntegrationAnnotationListingWithRBAC(t *testing.T) { sql := db.InitTestDB(t) var maximumTagsLength int64 = 60 - repo := xormRepositoryImpl{db: sql, cfg: setting.NewCfg(), log: log.New("annotation.test"), tagService: tagimpl.ProvideService(sql, sql.Cfg), maximumTagsLength: maximumTagsLength} + repo := xormRepositoryImpl{db: sql, cfg: setting.NewCfg(), log: log.New("annotation.test"), tagService: tagimpl.ProvideService(sql, sql.Cfg), maximumTagsLength: maximumTagsLength, features: featuremgmt.WithFeatures()} quotaService := quotatest.New(false, nil) dashboardStore, err := dashboardstore.ProvideDashboardStore(sql, sql.Cfg, featuremgmt.WithFeatures(), tagimpl.ProvideService(sql, sql.Cfg), quotaService) require.NoError(t, err) @@ -550,7 +557,7 @@ func TestIntegrationAnnotationListingWithRBAC(t *testing.T) { UserID: 1, OrgID: 1, } - role := setupRBACRole(t, repo, user) + role := setupRBACRole(t, sql, user) type testStruct struct { description string @@ -611,7 +618,7 @@ func TestIntegrationAnnotationListingWithRBAC(t *testing.T) { for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { user.Permissions = map[int64]map[string][]string{1: tc.permissions} - setupRBACPermission(t, repo, role, user) + setupRBACPermission(t, sql, role, user) results, err := repo.Get(context.Background(), &annotations.ItemQuery{ OrgID: 1, @@ -630,10 +637,163 @@ func TestIntegrationAnnotationListingWithRBAC(t *testing.T) { } } -func setupRBACRole(t *testing.T, repo xormRepositoryImpl, user *user.SignedInUser) *accesscontrol.Role { +func TestIntegrationAnnotationListingWithInheritedRBAC(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + orgID := int64(1) + permissions := []accesscontrol.Permission{ + { + Action: dashboards.ActionFoldersCreate, + }, { + Action: dashboards.ActionFoldersWrite, + Scope: dashboards.ScopeFoldersAll, + }, + } + usr := &user.SignedInUser{ + UserID: 1, + OrgID: orgID, + Permissions: map[int64]map[string][]string{orgID: accesscontrol.GroupScopesByAction(permissions)}, + } + + var role *accesscontrol.Role + + dashboardIDs := make([]int64, 0, folder.MaxNestedFolderDepth+1) + annotationsTexts := make([]string, 0, folder.MaxNestedFolderDepth+1) + + setupFolderStructure := func() *sqlstore.SQLStore { + db := db.InitTestDB(t) + + // enable nested folders so that the folder table is populated for all the tests + features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders) + + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanViewValue: true, CanSaveValue: true}) + t.Cleanup(func() { + guardian.New = origNewGuardian + }) + + // dashboard store commands that should be called. + dashStore, err := dashboardstore.ProvideDashboardStore(db, db.Cfg, features, tagimpl.ProvideService(db, db.Cfg), quotatest.New(false, nil)) + require.NoError(t, err) + + folderSvc := folderimpl.ProvideService(mock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), db.Cfg, dashStore, folderimpl.ProvideDashboardFolderStore(db), db, features) + + var maximumTagsLength int64 = 60 + repo := xormRepositoryImpl{db: db, cfg: setting.NewCfg(), log: log.New("annotation.test"), tagService: tagimpl.ProvideService(db, db.Cfg), maximumTagsLength: maximumTagsLength, features: features} + + parentUID := "" + for i := 0; ; i++ { + uid := fmt.Sprintf("f%d", i) + f, err := folderSvc.Create(context.Background(), &folder.CreateFolderCommand{ + UID: uid, + OrgID: orgID, + Title: uid, + SignedInUser: usr, + ParentUID: parentUID, + }) + if err != nil { + if errors.Is(err, folder.ErrMaximumDepthReached) { + break + } + + t.Log("unexpected error", "error", err) + t.Fail() + } + + dashInFolder := dashboards.SaveDashboardCommand{ + UserID: usr.UserID, + OrgID: orgID, + IsFolder: false, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": fmt.Sprintf("Dashboard under %s", f.UID), + }), + FolderID: f.ID, + FolderUID: f.UID, + } + dashboard, err := dashStore.SaveDashboard(context.Background(), dashInFolder) + require.NoError(t, err) + + dashboardIDs = append(dashboardIDs, dashboard.ID) + + parentUID = f.UID + + annotationTxt := fmt.Sprintf("annotation %d", i) + dash1Annotation := &annotations.Item{ + OrgID: orgID, + DashboardID: dashboard.ID, + Epoch: 10, + Text: annotationTxt, + } + err = repo.Add(context.Background(), dash1Annotation) + require.NoError(t, err) + + annotationsTexts = append(annotationsTexts, annotationTxt) + } + + role = setupRBACRole(t, db, usr) + return db + } + + db := setupFolderStructure() + + testCases := []struct { + desc string + features featuremgmt.FeatureToggles + permissions map[string][]string + expectedAnnotationText []string + expectedError bool + }{ + { + desc: "Should find only annotations from dashboards under folders that user can read", + features: featuremgmt.WithFeatures(), + permissions: map[string][]string{ + accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeDashboard}, + dashboards.ActionDashboardsRead: {"folders:uid:f0"}, + }, + expectedAnnotationText: annotationsTexts[:1], + }, + { + desc: "Should find only annotations from dashboards under inherited folders if nested folder are enabled", + features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders), + permissions: map[string][]string{ + accesscontrol.ActionAnnotationsRead: {accesscontrol.ScopeAnnotationsTypeDashboard}, + dashboards.ActionDashboardsRead: {"folders:uid:f0"}, + }, + expectedAnnotationText: annotationsTexts[:], + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + var maximumTagsLength int64 = 60 + repo := xormRepositoryImpl{db: db, cfg: setting.NewCfg(), log: log.New("annotation.test"), tagService: tagimpl.ProvideService(db, db.Cfg), maximumTagsLength: maximumTagsLength, features: tc.features} + + usr.Permissions = map[int64]map[string][]string{1: tc.permissions} + setupRBACPermission(t, db, role, usr) + + results, err := repo.Get(context.Background(), &annotations.ItemQuery{ + OrgID: 1, + SignedInUser: usr, + }) + if tc.expectedError { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Len(t, results, len(tc.expectedAnnotationText)) + for _, r := range results { + assert.Contains(t, tc.expectedAnnotationText, r.Text) + } + }) + } +} + +func setupRBACRole(t *testing.T, db *sqlstore.SQLStore, user *user.SignedInUser) *accesscontrol.Role { t.Helper() var role *accesscontrol.Role - err := repo.db.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + err := db.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { role = &accesscontrol.Role{ OrgID: user.OrgID, UID: "test_role", @@ -662,9 +822,9 @@ func setupRBACRole(t *testing.T, repo xormRepositoryImpl, user *user.SignedInUse return role } -func setupRBACPermission(t *testing.T, repo xormRepositoryImpl, role *accesscontrol.Role, user *user.SignedInUser) { +func setupRBACPermission(t *testing.T, db *sqlstore.SQLStore, role *accesscontrol.Role, user *user.SignedInUser) { t.Helper() - err := repo.db.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + err := db.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { if _, err := sess.Exec("DELETE FROM permission WHERE role_id = ?", role.ID); err != nil { return err } diff --git a/pkg/services/dashboards/database/acl.go b/pkg/services/dashboards/database/acl.go index 66408182727..f5b29c069a6 100644 --- a/pkg/services/dashboards/database/acl.go +++ b/pkg/services/dashboards/database/acl.go @@ -5,6 +5,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/org" ) @@ -103,8 +104,14 @@ func (d *dashboardStore) HasEditPermissionInFolders(ctx context.Context, query * queryResult = true return queryResult, nil } - err := d.store.WithDbSession(ctx, func(dbSession *db.Session) error { - builder := db.NewSqlBuilder(d.cfg, d.store.GetDialect()) + + recursiveQueriesAreSupported, err := d.store.RecursiveQueriesAreSupported() + if err != nil { + return queryResult, err + } + + err = d.store.WithDbSession(ctx, func(dbSession *db.Session) error { + builder := db.NewSqlBuilder(d.cfg, featuremgmt.WithFeatures(), d.store.GetDialect(), recursiveQueriesAreSupported) builder.Write("SELECT COUNT(dashboard.id) AS count FROM dashboard WHERE dashboard.org_id = ? AND dashboard.is_folder = ?", query.SignedInUser.OrgID, d.store.GetDialect().BooleanStr(true)) builder.WriteDashboardPermissionFilter(query.SignedInUser, dashboards.PERMISSION_EDIT) @@ -131,13 +138,18 @@ func (d *dashboardStore) HasEditPermissionInFolders(ctx context.Context, query * func (d *dashboardStore) HasAdminPermissionInDashboardsOrFolders(ctx context.Context, query *folder.HasAdminPermissionInDashboardsOrFoldersQuery) (bool, error) { var queryResult bool - err := d.store.WithDbSession(ctx, func(dbSession *db.Session) error { + recursiveQueriesAreSupported, err := d.store.RecursiveQueriesAreSupported() + if err != nil { + return queryResult, err + } + + err = d.store.WithDbSession(ctx, func(dbSession *db.Session) error { if query.SignedInUser.HasRole(org.RoleAdmin) { queryResult = true return nil } - builder := db.NewSqlBuilder(d.cfg, d.store.GetDialect()) + builder := db.NewSqlBuilder(d.cfg, featuremgmt.WithFeatures(), d.store.GetDialect(), recursiveQueriesAreSupported) builder.Write("SELECT COUNT(dashboard.id) AS count FROM dashboard WHERE dashboard.org_id = ?", query.SignedInUser.OrgID) builder.WriteDashboardPermissionFilter(query.SignedInUser, dashboards.PERMISSION_ADMIN) diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index a6b5725f68b..979de85fe25 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -934,9 +934,14 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F } if !ac.IsDisabled(d.cfg) { + recursiveQueriesAreSupported, err := d.store.RecursiveQueriesAreSupported() + if err != nil { + return nil, err + } + // if access control is enabled, overwrite the filters so far filters = []interface{}{ - permissions.NewAccessControlDashboardPermissionFilter(query.SignedInUser, query.Permission, query.Type), + permissions.NewAccessControlDashboardPermissionFilter(query.SignedInUser, query.Permission, query.Type, d.features, recursiveQueriesAreSupported), } } diff --git a/pkg/services/dashboards/database/database_folder_test.go b/pkg/services/dashboards/database/database_folder_test.go index efb4785899e..615f87e6694 100644 --- a/pkg/services/dashboards/database/database_folder_test.go +++ b/pkg/services/dashboards/database/database_folder_test.go @@ -2,21 +2,33 @@ package database import ( "context" + "errors" + "fmt" "testing" + "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/folder/folderimpl" + "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/org/orgimpl" "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" + "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/setting" + "github.com/grafana/grafana/pkg/services/user/userimpl" ) var testFeatureToggles = featuremgmt.WithFeatures(featuremgmt.FlagPanelTitleSearch) @@ -36,7 +48,7 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { sqlStore.Cfg.RBACEnabled = false quotaService := quotatest.New(false, nil) var err error - dashboardStore, err = ProvideDashboardStore(sqlStore, &setting.Cfg{}, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + dashboardStore, err = ProvideDashboardStore(sqlStore, sqlStore.Cfg, testFeatureToggles, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) require.NoError(t, err) flder = insertTestDashboard(t, dashboardStore, "1 test dash folder", 1, 0, true, "prod", "webapp") dashInRoot = insertTestDashboard(t, dashboardStore, "test dash 67", 1, 0, false, "prod", "webapp") @@ -477,6 +489,216 @@ func TestIntegrationDashboardFolderDataAccess(t *testing.T) { }) } +func TestIntegrationDashboardInheritedFolderRBAC(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + // the maximux nested folder hierarchy starting from parent down to subfolders + nestedFolders := make([]*folder.Folder, 0, folder.MaxNestedFolderDepth+1) + + var sqlStore *sqlstore.SQLStore + const ( + dashInRootTitle = "dashboard in root" + dashInParentTitle = "dashboard in parent" + dashInSubfolderTitle = "dashboard in subfolder" + ) + var viewer user.SignedInUser + var role *accesscontrol.Role + + setup := func() { + sqlStore = db.InitTestDB(t) + sqlStore.Cfg.RBACEnabled = true + quotaService := quotatest.New(false, nil) + + // enable nested folders so that the folder table is populated for all the tests + features := featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders) + + var err error + dashboardWriteStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, features, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotaService) + require.NoError(t, err) + + usr := createUser(t, sqlStore, "viewer", "Viewer", false) + viewer = user.SignedInUser{ + UserID: usr.ID, + OrgID: usr.OrgID, + OrgRole: org.RoleViewer, + } + + orgService, err := orgimpl.ProvideService(sqlStore, sqlStore.Cfg, quotaService) + require.NoError(t, err) + usrSvc, err := userimpl.ProvideService(sqlStore, orgService, sqlStore.Cfg, nil, nil, quotaService, supportbundlestest.NewFakeBundleService()) + require.NoError(t, err) + + // create admin user in the same org + currentUserCmd := user.CreateUserCommand{Login: "admin", Email: "admin@test.com", Name: "an admin", IsAdmin: false, OrgID: viewer.OrgID} + u, err := usrSvc.Create(context.Background(), ¤tUserCmd) + require.NoError(t, err) + admin := user.SignedInUser{ + UserID: u.ID, + OrgID: u.OrgID, + OrgRole: org.RoleAdmin, + Permissions: map[int64]map[string][]string{u.OrgID: accesscontrol.GroupScopesByAction([]accesscontrol.Permission{ + { + Action: dashboards.ActionFoldersCreate, + }, { + Action: dashboards.ActionFoldersWrite, + Scope: dashboards.ScopeFoldersAll, + }}), + }, + } + require.NotEqual(t, viewer.UserID, admin.UserID) + + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanViewValue: true, CanSaveValue: true}) + t.Cleanup(func() { + guardian.New = origNewGuardian + }) + + folderSvc := folderimpl.ProvideService(mock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), sqlStore.Cfg, dashboardWriteStore, folderimpl.ProvideDashboardFolderStore(sqlStore), sqlStore, features) + + parentUID := "" + for i := 0; ; i++ { + uid := fmt.Sprintf("f%d", i) + f, err := folderSvc.Create(context.Background(), &folder.CreateFolderCommand{ + UID: uid, + OrgID: admin.OrgID, + Title: uid, + SignedInUser: &admin, + ParentUID: parentUID, + }) + if err != nil { + if errors.Is(err, folder.ErrMaximumDepthReached) { + break + } + + t.Log("unexpected error", "error", err) + t.Fail() + } + + nestedFolders = append(nestedFolders, f) + + parentUID = f.UID + } + require.LessOrEqual(t, 2, len(nestedFolders)) + + saveDashboardCmd := dashboards.SaveDashboardCommand{ + UserID: admin.UserID, + OrgID: admin.OrgID, + IsFolder: false, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": dashInRootTitle, + }), + } + _, err = dashboardWriteStore.SaveDashboard(context.Background(), saveDashboardCmd) + require.NoError(t, err) + + saveDashboardCmd = dashboards.SaveDashboardCommand{ + UserID: admin.UserID, + OrgID: admin.OrgID, + IsFolder: false, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": dashInParentTitle, + }), + FolderID: nestedFolders[0].ID, + FolderUID: nestedFolders[0].UID, + } + _, err = dashboardWriteStore.SaveDashboard(context.Background(), saveDashboardCmd) + require.NoError(t, err) + + saveDashboardCmd = dashboards.SaveDashboardCommand{ + UserID: admin.UserID, + OrgID: admin.OrgID, + IsFolder: false, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": dashInSubfolderTitle, + }), + FolderID: nestedFolders[1].ID, + FolderUID: nestedFolders[1].UID, + } + _, err = dashboardWriteStore.SaveDashboard(context.Background(), saveDashboardCmd) + require.NoError(t, err) + + role = setupRBACRole(t, *sqlStore, &viewer) + } + + setup() + + nestedFolderTitles := make([]string, 0, len(nestedFolders)) + for _, f := range nestedFolders { + nestedFolderTitles = append(nestedFolderTitles, f.Title) + } + + testCases := []struct { + desc string + features featuremgmt.FeatureToggles + permissions map[string][]string + expectedTitles []string + }{ + { + desc: "it should not return folder if ACL is not set for parent folder", + features: featuremgmt.WithFeatures(featuremgmt.FlagPanelTitleSearch), + permissions: nil, + expectedTitles: nil, + }, + { + desc: "it should not return dashboard in subfolder if nested folders are disabled and the user has permission to read dashboards under parent folder", + features: featuremgmt.WithFeatures(featuremgmt.FlagPanelTitleSearch), + permissions: map[string][]string{ + dashboards.ActionDashboardsRead: {fmt.Sprintf("folders:uid:%s", nestedFolders[0].UID)}, + }, + expectedTitles: []string{dashInParentTitle}, + }, + { + desc: "it should return dashboard in subfolder if nested folders are enabled and the user has permission to read dashboards under parent folder", + features: featuremgmt.WithFeatures(featuremgmt.FlagPanelTitleSearch, featuremgmt.FlagNestedFolders), + permissions: map[string][]string{ + dashboards.ActionDashboardsRead: {fmt.Sprintf("folders:uid:%s", nestedFolders[0].UID)}, + }, + expectedTitles: []string{dashInParentTitle, dashInSubfolderTitle}, + }, + { + desc: "it should not return subfolder if nested folders are disabled and the user has permission to read folders under parent folder", + features: featuremgmt.WithFeatures(featuremgmt.FlagPanelTitleSearch), + permissions: map[string][]string{ + dashboards.ActionFoldersRead: {fmt.Sprintf("folders:uid:%s", nestedFolders[0].UID)}, + }, + expectedTitles: []string{nestedFolders[0].Title}, + }, + { + desc: "it should return subfolder if nested folders are enabled and the user has permission to read folders under parent folder", + features: featuremgmt.WithFeatures(featuremgmt.FlagPanelTitleSearch, featuremgmt.FlagNestedFolders), + permissions: map[string][]string{ + dashboards.ActionFoldersRead: {fmt.Sprintf("folders:uid:%s", nestedFolders[0].UID)}, + }, + expectedTitles: nestedFolderTitles, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + dashboardReadStore, err := ProvideDashboardStore(sqlStore, sqlStore.Cfg, tc.features, tagimpl.ProvideService(sqlStore, sqlStore.Cfg), quotatest.New(false, nil)) + require.NoError(t, err) + + viewer.Permissions = map[int64]map[string][]string{viewer.OrgID: tc.permissions} + setupRBACPermission(t, *sqlStore, role, &viewer) + + query := &dashboards.FindPersistedDashboardsQuery{ + SignedInUser: &viewer, + OrgId: viewer.OrgID, + } + + res, err := testSearchDashboards(dashboardReadStore, query) + require.NoError(t, err) + + require.Equal(t, len(tc.expectedTitles), len(res)) + for i, tlt := range tc.expectedTitles { + assert.Equal(t, tlt, res[i].Title) + } + }) + } +} + func moveDashboard(t *testing.T, dashboardStore dashboards.Store, orgId int64, dashboard *simplejson.Json, newFolderId int64) *dashboards.Dashboard { t.Helper() @@ -492,3 +714,61 @@ func moveDashboard(t *testing.T, dashboardStore dashboards.Store, orgId int64, d return dash } + +func setupRBACRole(t *testing.T, db sqlstore.SQLStore, user *user.SignedInUser) *accesscontrol.Role { + t.Helper() + var role *accesscontrol.Role + err := db.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + role = &accesscontrol.Role{ + OrgID: user.OrgID, + UID: "test_role", + Name: "test:role", + Updated: time.Now(), + Created: time.Now(), + } + _, err := sess.Insert(role) + if err != nil { + return err + } + + _, err = sess.Insert(accesscontrol.UserRole{ + OrgID: role.OrgID, + RoleID: role.ID, + UserID: user.UserID, + Created: time.Now(), + }) + if err != nil { + return err + } + return nil + }) + + require.NoError(t, err) + return role +} + +func setupRBACPermission(t *testing.T, db sqlstore.SQLStore, role *accesscontrol.Role, user *user.SignedInUser) { + t.Helper() + err := db.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + if _, err := sess.Exec("DELETE FROM permission WHERE role_id = ?", role.ID); err != nil { + return err + } + + var acPermission []accesscontrol.Permission + for action, scopes := range user.Permissions[user.OrgID] { + for _, scope := range scopes { + acPermission = append(acPermission, accesscontrol.Permission{ + RoleID: role.ID, Action: action, Scope: scope, Created: time.Now(), Updated: time.Now(), + }) + } + } + + if _, err := sess.InsertMulti(&acPermission); err != nil { + return err + } + + return nil + }) + + require.NoError(t, err) +} diff --git a/pkg/services/libraryelements/database.go b/pkg/services/libraryelements/database.go index 15fe4406a33..79262251670 100644 --- a/pkg/services/libraryelements/database.go +++ b/pkg/services/libraryelements/database.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/kinds/librarypanel" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/libraryelements/model" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/search" @@ -227,10 +228,16 @@ func (l *LibraryElementService) deleteLibraryElement(c context.Context, signedIn } // getLibraryElements gets a Library Element where param == value -func getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signedInUser *user.SignedInUser, params []Pair) ([]model.LibraryElementDTO, error) { +func getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signedInUser *user.SignedInUser, params []Pair, features featuremgmt.FeatureToggles) ([]model.LibraryElementDTO, error) { libraryElements := make([]model.LibraryElementWithMeta, 0) - err := store.WithDbSession(c, func(session *db.Session) error { - builder := db.NewSqlBuilder(cfg, store.GetDialect()) + + recursiveQueriesAreSupported, err := store.RecursiveQueriesAreSupported() + if err != nil { + return nil, err + } + + err = store.WithDbSession(c, func(session *db.Session) error { + builder := db.NewSqlBuilder(cfg, features, store.GetDialect(), recursiveQueriesAreSupported) builder.Write(selectLibraryElementDTOWithMeta) builder.Write(", 'General' as folder_name ") builder.Write(", '' as folder_uid ") @@ -299,7 +306,7 @@ func getLibraryElements(c context.Context, store db.DB, cfg *setting.Cfg, signed // getLibraryElementByUid gets a Library Element by uid. func (l *LibraryElementService) getLibraryElementByUid(c context.Context, signedInUser *user.SignedInUser, UID string) (model.LibraryElementDTO, error) { - libraryElements, err := getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{key: "org_id", value: signedInUser.OrgID}, {key: "uid", value: UID}}) + libraryElements, err := getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{key: "org_id", value: signedInUser.OrgID}, {key: "uid", value: UID}}, l.features) if err != nil { return model.LibraryElementDTO{}, err } @@ -312,13 +319,19 @@ func (l *LibraryElementService) getLibraryElementByUid(c context.Context, signed // getLibraryElementByName gets a Library Element by name. func (l *LibraryElementService) getLibraryElementsByName(c context.Context, signedInUser *user.SignedInUser, name string) ([]model.LibraryElementDTO, error) { - return getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{"org_id", signedInUser.OrgID}, {"name", name}}) + return getLibraryElements(c, l.SQLStore, l.Cfg, signedInUser, []Pair{{"org_id", signedInUser.OrgID}, {"name", name}}, l.features) } // getAllLibraryElements gets all Library Elements. func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedInUser *user.SignedInUser, query model.SearchLibraryElementsQuery) (model.LibraryElementSearchResult, error) { elements := make([]model.LibraryElementWithMeta, 0) result := model.LibraryElementSearchResult{} + + recursiveQueriesAreSupported, err := l.SQLStore.RecursiveQueriesAreSupported() + if err != nil { + return result, err + } + if query.PerPage <= 0 { query.PerPage = 100 } @@ -333,8 +346,8 @@ func (l *LibraryElementService) getAllLibraryElements(c context.Context, signedI if folderFilter.parseError != nil { return model.LibraryElementSearchResult{}, folderFilter.parseError } - err := l.SQLStore.WithDbSession(c, func(session *db.Session) error { - builder := db.NewSqlBuilder(l.Cfg, l.SQLStore.GetDialect()) + err = l.SQLStore.WithDbSession(c, func(session *db.Session) error { + builder := db.NewSqlBuilder(l.Cfg, l.features, l.SQLStore.GetDialect(), recursiveQueriesAreSupported) if folderFilter.includeGeneralFolder { builder.Write(selectLibraryElementDTOWithMeta) builder.Write(", 'General' as folder_name ") @@ -563,13 +576,18 @@ func (l *LibraryElementService) patchLibraryElement(c context.Context, signedInU // getConnections gets all connections for a Library Element. func (l *LibraryElementService) getConnections(c context.Context, signedInUser *user.SignedInUser, uid string) ([]model.LibraryElementConnectionDTO, error) { connections := make([]model.LibraryElementConnectionDTO, 0) - err := l.SQLStore.WithDbSession(c, func(session *db.Session) error { + recursiveQueriesAreSupported, err := l.SQLStore.RecursiveQueriesAreSupported() + if err != nil { + return nil, err + } + + err = l.SQLStore.WithDbSession(c, func(session *db.Session) error { element, err := getLibraryElement(l.SQLStore.GetDialect(), session, uid, signedInUser.OrgID) if err != nil { return err } var libraryElementConnections []model.LibraryElementConnectionWithMeta - builder := db.NewSqlBuilder(l.Cfg, l.SQLStore.GetDialect()) + builder := db.NewSqlBuilder(l.Cfg, l.features, l.SQLStore.GetDialect(), recursiveQueriesAreSupported) builder.Write("SELECT lec.*, u1.login AS created_by_name, u1.email AS created_by_email, dashboard.uid AS connection_uid") builder.Write(" FROM " + model.LibraryElementConnectionTableName + " AS lec") builder.Write(" LEFT JOIN " + l.SQLStore.GetDialect().Quote("user") + " AS u1 ON lec.created_by = u1.id") diff --git a/pkg/services/libraryelements/libraryelements.go b/pkg/services/libraryelements/libraryelements.go index 53a3998a41a..562bc4b2160 100644 --- a/pkg/services/libraryelements/libraryelements.go +++ b/pkg/services/libraryelements/libraryelements.go @@ -6,19 +6,21 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/libraryelements/model" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" ) -func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.RouteRegister, folderService folder.Service) *LibraryElementService { +func ProvideService(cfg *setting.Cfg, sqlStore db.DB, routeRegister routing.RouteRegister, folderService folder.Service, features featuremgmt.FeatureToggles) *LibraryElementService { l := &LibraryElementService{ Cfg: cfg, SQLStore: sqlStore, RouteRegister: routeRegister, folderService: folderService, log: log.New("library-elements"), + features: features, } l.registerAPIEndpoints() return l @@ -41,6 +43,7 @@ type LibraryElementService struct { RouteRegister routing.RouteRegister folderService folder.Service log log.Logger + features featuremgmt.FeatureToggles } // CreateElement creates a Library Element. diff --git a/pkg/services/librarypanels/librarypanels_test.go b/pkg/services/librarypanels/librarypanels_test.go index 4c5c07e9f37..8c3f363e3f7 100644 --- a/pkg/services/librarypanels/librarypanels_test.go +++ b/pkg/services/librarypanels/librarypanels_test.go @@ -843,7 +843,7 @@ func testScenario(t *testing.T, desc string, fn func(t *testing.T, sc scenarioCo folderStore := folderimpl.ProvideDashboardFolderStore(sqlStore) folderService := folderimpl.ProvideService(ac, bus.ProvideBus(tracing.InitializeTracerForTest()), cfg, dashboardStore, folderStore, nil, features) - elementService := libraryelements.ProvideService(cfg, sqlStore, routing.NewRouteRegister(), folderService) + elementService := libraryelements.ProvideService(cfg, sqlStore, routing.NewRouteRegister(), folderService, featuremgmt.WithFeatures()) service := LibraryPanelService{ Cfg: cfg, SQLStore: sqlStore, diff --git a/pkg/services/publicdashboards/service/query_test.go b/pkg/services/publicdashboards/service/query_test.go index 2dbd20ec3a8..ecc0aff01db 100644 --- a/pkg/services/publicdashboards/service/query_test.go +++ b/pkg/services/publicdashboards/service/query_test.go @@ -712,7 +712,7 @@ func TestFindAnnotations(t *testing.T) { sqlStore := sqlstore.InitTestDB(t) config := setting.NewCfg() tagService := tagimpl.ProvideService(sqlStore, sqlStore.Cfg) - annotationsRepo := annotationsimpl.ProvideService(sqlStore, config, tagService) + annotationsRepo := annotationsimpl.ProvideService(sqlStore, config, featuremgmt.WithFeatures(), tagService) fakeStore := FakePublicDashboardStore{} service := &PublicDashboardServiceImpl{ log: log.New("test.logger"), diff --git a/pkg/services/sqlstore/permissions/dashboard.go b/pkg/services/sqlstore/permissions/dashboard.go index bb50a034061..0a1501795c1 100644 --- a/pkg/services/sqlstore/permissions/dashboard.go +++ b/pkg/services/sqlstore/permissions/dashboard.go @@ -1,16 +1,23 @@ package permissions import ( + "bytes" + "fmt" "strings" "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/sqlstore/migrator" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" "github.com/grafana/grafana/pkg/services/user" ) +// maximum possible capacity for recursive queries array: one query for folder and one for dashboard actions +const maximumRecursiveQueries = 2 + type DashboardPermissionFilter struct { OrgRole org.RoleType Dialect migrator.Dialect @@ -78,14 +85,25 @@ func (d DashboardPermissionFilter) Where() (string, []interface{}) { return sql, params } -type AccessControlDashboardPermissionFilter struct { +type clause struct { + string + params []interface{} +} + +type accessControlDashboardPermissionFilter struct { user *user.SignedInUser dashboardActions []string folderActions []string + features featuremgmt.FeatureToggles + + where clause + // any recursive CTE queries (if supported) + recQueries []clause + recursiveQueriesAreSupported bool } // NewAccessControlDashboardPermissionFilter creates a new AccessControlDashboardPermissionFilter that is configured with specific actions calculated based on the dashboards.PermissionType and query type -func NewAccessControlDashboardPermissionFilter(user *user.SignedInUser, permissionLevel dashboards.PermissionType, queryType string) AccessControlDashboardPermissionFilter { +func NewAccessControlDashboardPermissionFilter(user *user.SignedInUser, permissionLevel dashboards.PermissionType, queryType string, features featuremgmt.FeatureToggles, recursiveQueriesAreSupported bool) *accessControlDashboardPermissionFilter { needEdit := permissionLevel > dashboards.PERMISSION_VIEW var folderActions []string @@ -121,12 +139,26 @@ func NewAccessControlDashboardPermissionFilter(user *user.SignedInUser, permissi } } - return AccessControlDashboardPermissionFilter{user: user, folderActions: folderActions, dashboardActions: dashboardActions} + f := accessControlDashboardPermissionFilter{user: user, folderActions: folderActions, dashboardActions: dashboardActions, features: features, + recursiveQueriesAreSupported: recursiveQueriesAreSupported, + } + + f.buildClauses() + + return &f } -func (f AccessControlDashboardPermissionFilter) Where() (string, []interface{}) { +// Where returns: +// - a where clause for filtering dashboards with expected permissions +// - an array with the query parameters +func (f *accessControlDashboardPermissionFilter) Where() (string, []interface{}) { + return f.where.string, f.where.params +} + +func (f *accessControlDashboardPermissionFilter) buildClauses() { if f.user == nil || f.user.Permissions == nil || f.user.Permissions[f.user.OrgID] == nil { - return "(1 = 0)", nil + f.where = clause{string: "(1 = 0)"} + return } dashWildcards := accesscontrol.WildcardsFromPrefix(dashboards.ScopeDashboardsPrefix) folderWildcards := accesscontrol.WildcardsFromPrefix(dashboards.ScopeFoldersPrefix) @@ -136,6 +168,10 @@ func (f AccessControlDashboardPermissionFilter) Where() (string, []interface{}) var args []interface{} builder := strings.Builder{} builder.WriteRune('(') + + permSelector := strings.Builder{} + var permSelectorArgs []interface{} + if len(f.dashboardActions) > 0 { toCheck := actionsToCheck(f.dashboardActions, f.user.Permissions[f.user.OrgID], dashWildcards, folderWildcards) @@ -155,24 +191,50 @@ func (f AccessControlDashboardPermissionFilter) Where() (string, []interface{}) builder.WriteString(") AND NOT dashboard.is_folder)") builder.WriteString(" OR ") - builder.WriteString("(dashboard.folder_id IN (SELECT id FROM dashboard as d WHERE d.uid IN (SELECT substr(scope, 13) FROM permission WHERE scope LIKE 'folders:uid:%' ") - builder.WriteString(rolesFilter) - args = append(args, params...) + permSelector.WriteString("(SELECT substr(scope, 13) FROM permission WHERE scope LIKE 'folders:uid:%' ") + permSelector.WriteString(rolesFilter) + permSelectorArgs = append(permSelectorArgs, params...) if len(toCheck) == 1 { - builder.WriteString(" AND action = ?") - args = append(args, toCheck[0]) + permSelector.WriteString(" AND action = ?") + permSelectorArgs = append(permSelectorArgs, toCheck[0]) } else { - builder.WriteString(" AND action IN (?" + strings.Repeat(", ?", len(toCheck)-1) + ") GROUP BY role_id, scope HAVING COUNT(action) = ?") - args = append(args, toCheck...) - args = append(args, len(toCheck)) + permSelector.WriteString(" AND action IN (?" + strings.Repeat(", ?", len(toCheck)-1) + ") GROUP BY role_id, scope HAVING COUNT(action) = ?") + permSelectorArgs = append(permSelectorArgs, toCheck...) + permSelectorArgs = append(permSelectorArgs, len(toCheck)) } - builder.WriteString(")) AND NOT dashboard.is_folder)") + permSelector.WriteRune(')') + + switch f.features.IsEnabled(featuremgmt.FlagNestedFolders) { + case true: + switch f.recursiveQueriesAreSupported { + case true: + recQueryName := fmt.Sprintf("RecQry%d", len(f.recQueries)) + f.addRecQry(recQueryName, permSelector.String(), permSelectorArgs) + builder.WriteString("(dashboard.folder_id IN (SELECT d.id FROM dashboard as d ") + builder.WriteString(fmt.Sprintf("WHERE d.uid IN (SELECT uid FROM %s)", recQueryName)) + default: + nestedFoldersSelectors, nestedFoldersArgs := nestedFoldersSelectors(permSelector.String(), permSelectorArgs, "folder_id", "id") + builder.WriteRune('(') + builder.WriteString(nestedFoldersSelectors) + args = append(args, nestedFoldersArgs...) + } + default: + builder.WriteString("(dashboard.folder_id IN (SELECT d.id FROM dashboard as d ") + builder.WriteString("WHERE d.uid IN ") + builder.WriteString(permSelector.String()) + args = append(args, permSelectorArgs...) + } + builder.WriteString(") AND NOT dashboard.is_folder)") } else { builder.WriteString("NOT dashboard.is_folder") } } + // recycle and reuse + permSelector.Reset() + permSelectorArgs = permSelectorArgs[:0] + if len(f.folderActions) > 0 { if len(f.dashboardActions) > 0 { builder.WriteString(" OR ") @@ -180,24 +242,80 @@ func (f AccessControlDashboardPermissionFilter) Where() (string, []interface{}) toCheck := actionsToCheck(f.folderActions, f.user.Permissions[f.user.OrgID], folderWildcards) if len(toCheck) > 0 { - builder.WriteString("(dashboard.uid IN (SELECT substr(scope, 13) FROM permission WHERE scope LIKE 'folders:uid:%'") - builder.WriteString(rolesFilter) - args = append(args, params...) + permSelector.WriteString("(SELECT substr(scope, 13) FROM permission WHERE scope LIKE 'folders:uid:%'") + permSelector.WriteString(rolesFilter) + permSelectorArgs = append(permSelectorArgs, params...) if len(toCheck) == 1 { - builder.WriteString(" AND action = ?") - args = append(args, toCheck[0]) + permSelector.WriteString(" AND action = ?") + permSelectorArgs = append(permSelectorArgs, toCheck[0]) } else { - builder.WriteString(" AND action IN (?" + strings.Repeat(", ?", len(toCheck)-1) + ") GROUP BY role_id, scope HAVING COUNT(action) = ?") - args = append(args, toCheck...) - args = append(args, len(toCheck)) + permSelector.WriteString(" AND action IN (?" + strings.Repeat(", ?", len(toCheck)-1) + ") GROUP BY role_id, scope HAVING COUNT(action) = ?") + permSelectorArgs = append(permSelectorArgs, toCheck...) + permSelectorArgs = append(permSelectorArgs, len(toCheck)) } - builder.WriteString(") AND dashboard.is_folder)") + permSelector.WriteRune(')') + + switch f.features.IsEnabled(featuremgmt.FlagNestedFolders) { + case true: + switch f.recursiveQueriesAreSupported { + case true: + recQueryName := fmt.Sprintf("RecQry%d", len(f.recQueries)) + f.addRecQry(recQueryName, permSelector.String(), permSelectorArgs) + builder.WriteString("(dashboard.uid IN ") + builder.WriteString(fmt.Sprintf("(SELECT uid FROM %s)", recQueryName)) + default: + nestedFoldersSelectors, nestedFoldersArgs := nestedFoldersSelectors(permSelector.String(), permSelectorArgs, "uid", "uid") + builder.WriteRune('(') + builder.WriteString(nestedFoldersSelectors) + builder.WriteRune(')') + args = append(args, nestedFoldersArgs...) + } + default: + builder.WriteString("(dashboard.uid IN ") + builder.WriteString(permSelector.String()) + args = append(args, permSelectorArgs...) + } + builder.WriteString(" AND dashboard.is_folder)") } else { builder.WriteString("dashboard.is_folder") } } builder.WriteRune(')') - return builder.String(), args + + f.where = clause{string: builder.String(), params: args} +} + +// With returns: +// - a with clause for fetching folders with inherited permissions if nested folders are enabled or an empty string +func (f *accessControlDashboardPermissionFilter) With() (string, []interface{}) { + var sb bytes.Buffer + var params []interface{} + if len(f.recQueries) > 0 { + sb.WriteString("WITH RECURSIVE ") + sb.WriteString(f.recQueries[0].string) + params = append(params, f.recQueries[0].params...) + for _, r := range f.recQueries[1:] { + sb.WriteRune(',') + sb.WriteString(r.string) + params = append(params, r.params...) + } + } + return sb.String(), params +} + +func (f *accessControlDashboardPermissionFilter) addRecQry(queryName string, whereUIDSelect string, whereParams []interface{}) { + if f.recQueries == nil { + f.recQueries = make([]clause, 0, maximumRecursiveQueries) + } + c := make([]interface{}, len(whereParams)) + copy(c, whereParams) + f.recQueries = append(f.recQueries, clause{ + string: fmt.Sprintf(`%s AS ( + SELECT uid, parent_uid, org_id FROM folder WHERE uid IN %s + UNION ALL SELECT f.uid, f.parent_uid, f.org_id FROM folder f INNER JOIN %s r ON f.parent_uid = r.uid and f.org_id = r.org_id + )`, queryName, whereUIDSelect, queryName), + params: c, + }) } func actionsToCheck(actions []string, permissions map[string][]string, wildcards ...accesscontrol.Wildcards) []interface{} { @@ -222,3 +340,28 @@ func actionsToCheck(actions []string, permissions map[string][]string, wildcards } return toCheck } + +func nestedFoldersSelectors(permSelector string, permSelectorArgs []interface{}, leftTableCol string, rightTableCol string) (string, []interface{}) { + wheres := make([]string, 0, folder.MaxNestedFolderDepth+1) + args := make([]interface{}, 0, len(permSelectorArgs)*(folder.MaxNestedFolderDepth+1)) + + joins := make([]string, 0, folder.MaxNestedFolderDepth+2) + + tmpl := "INNER JOIN folder %s ON %s.%s = %s.uid AND %s.org_id = %s.org_id " + + prev := "d" + onCol := "uid" + for i := 1; i <= folder.MaxNestedFolderDepth+2; i++ { + t := fmt.Sprintf("f%d", i) + s := fmt.Sprintf(tmpl, t, prev, onCol, t, prev, t) + joins = append(joins, s) + + wheres = append(wheres, fmt.Sprintf("(dashboard.%s IN (SELECT d.%s FROM dashboard d %s WHERE %s.uid IN %s)", leftTableCol, rightTableCol, strings.Join(joins, " "), t, permSelector)) + args = append(args, permSelectorArgs...) + + prev = t + onCol = "parent_uid" + } + + return strings.Join(wheres, ") OR "), args +} diff --git a/pkg/services/sqlstore/permissions/dashboard_test.go b/pkg/services/sqlstore/permissions/dashboard_test.go index d9552da351c..071e6ace713 100644 --- a/pkg/services/sqlstore/permissions/dashboard_test.go +++ b/pkg/services/sqlstore/permissions/dashboard_test.go @@ -9,14 +9,24 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/dashboards/database" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/folder/folderimpl" + "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" + "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" ) @@ -129,13 +139,18 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { store := setupTest(t, 10, 100, tt.permissions) + recursiveQueriesAreSupported, err := store.RecursiveQueriesAreSupported() + require.NoError(t, err) + usr := &user.SignedInUser{OrgID: 1, OrgRole: org.RoleViewer, Permissions: map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tt.permissions)}} - filter := permissions.NewAccessControlDashboardPermissionFilter(usr, tt.permission, tt.queryType) + filter := permissions.NewAccessControlDashboardPermissionFilter(usr, tt.permission, tt.queryType, featuremgmt.WithFeatures(), recursiveQueriesAreSupported) var result int - err := store.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + err = store.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { q, params := filter.Where() - _, err := sess.SQL("SELECT COUNT(*) FROM dashboard WHERE "+q, params...).Get(&result) + recQry, recQryParams := filter.With() + params = append(recQryParams, params...) + _, err := sess.SQL(recQry+"\nSELECT COUNT(*) FROM dashboard WHERE "+q, params...).Get(&result) return err }) require.NoError(t, err) @@ -145,7 +160,115 @@ func TestIntegration_DashboardPermissionFilter(t *testing.T) { } } +func TestIntegration_DashboardNestedPermissionFilter(t *testing.T) { + testCases := []struct { + desc string + queryType string + permission dashboards.PermissionType + permissions []accesscontrol.Permission + expectedResult []string + features featuremgmt.FeatureToggles + }{ + { + desc: "Should be able to view dashboards under inherited folders if nested folders are enabled", + queryType: searchstore.TypeDashboard, + permission: dashboards.PERMISSION_VIEW, + permissions: []accesscontrol.Permission{ + {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:parent"}, + }, + features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders), + expectedResult: []string{"dashboard under parent folder", "dashboard under subfolder"}, + }, + { + desc: "Should not be able to view dashboards under inherited folders if nested folders are not enabled", + queryType: searchstore.TypeDashboard, + permission: dashboards.PERMISSION_VIEW, + permissions: []accesscontrol.Permission{ + {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:parent"}, + }, + features: featuremgmt.WithFeatures(), + expectedResult: []string{"dashboard under parent folder"}, + }, + { + desc: "Should be able to view inherited folders if nested folders are enabled", + queryType: searchstore.TypeFolder, + permission: dashboards.PERMISSION_VIEW, + permissions: []accesscontrol.Permission{ + {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:parent"}, + }, + features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders), + expectedResult: []string{"parent", "subfolder"}, + }, + { + desc: "Should not be able to view inherited folders if nested folders are not enabled", + queryType: searchstore.TypeFolder, + permission: dashboards.PERMISSION_VIEW, + permissions: []accesscontrol.Permission{ + {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:parent"}, + }, + features: featuremgmt.WithFeatures(), + expectedResult: []string{"parent"}, + }, + { + desc: "Should be able to view inherited dashboards and folders if nested folders are enabled", + permission: dashboards.PERMISSION_VIEW, + permissions: []accesscontrol.Permission{ + {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:parent"}, + {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:parent"}, + }, + features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders), + expectedResult: []string{"parent", "subfolder", "dashboard under parent folder", "dashboard under subfolder"}, + }, + { + desc: "Should not be able to view inherited dashboards and folders if nested folders are not enabled", + permission: dashboards.PERMISSION_VIEW, + permissions: []accesscontrol.Permission{ + {Action: dashboards.ActionFoldersRead, Scope: "folders:uid:parent"}, + {Action: dashboards.ActionDashboardsRead, Scope: "folders:uid:parent"}, + }, + features: featuremgmt.WithFeatures(), + expectedResult: []string{"parent", "dashboard under parent folder"}, + }, + } + + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanViewValue: true, CanSaveValue: true}) + t.Cleanup(func() { + guardian.New = origNewGuardian + }) + + var orgID int64 = 1 + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + tc.permissions = append(tc.permissions, accesscontrol.Permission{ + Action: dashboards.ActionFoldersCreate, + }, accesscontrol.Permission{ + Action: dashboards.ActionFoldersWrite, + Scope: dashboards.ScopeFoldersAll, + }) + usr := &user.SignedInUser{OrgID: orgID, OrgRole: org.RoleViewer, Permissions: map[int64]map[string][]string{orgID: accesscontrol.GroupScopesByAction(tc.permissions)}} + db := setupNestedTest(t, usr, tc.permissions, orgID, tc.features) + recursiveQueriesAreSupported, err := db.RecursiveQueriesAreSupported() + require.NoError(t, err) + filter := permissions.NewAccessControlDashboardPermissionFilter(usr, tc.permission, tc.queryType, tc.features, recursiveQueriesAreSupported) + var result []string + err = db.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + q, params := filter.Where() + recQry, recQryParams := filter.With() + params = append(recQryParams, params...) + err := sess.SQL(recQry+"\nSELECT title FROM dashboard WHERE "+q, params...).Find(&result) + return err + }) + require.NoError(t, err) + assert.Equal(t, tc.expectedResult, result) + }) + } +} + func setupTest(t *testing.T, numFolders, numDashboards int, permissions []accesscontrol.Permission) db.DB { + t.Helper() + store := db.InitTestDB(t) err := store.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { dashes := make([]dashboards.Dashboard, 0, numFolders+numDashboards) @@ -227,3 +350,95 @@ func setupTest(t *testing.T, numFolders, numDashboards int, permissions []access require.NoError(t, err) return store } + +func setupNestedTest(t *testing.T, usr *user.SignedInUser, perms []accesscontrol.Permission, orgID int64, features featuremgmt.FeatureToggles) db.DB { + t.Helper() + + db := sqlstore.InitTestDB(t) + + // dashboard store commands that should be called. + dashStore, err := database.ProvideDashboardStore(db, db.Cfg, features, tagimpl.ProvideService(db, db.Cfg), quotatest.New(false, nil)) + require.NoError(t, err) + + folderSvc := folderimpl.ProvideService(mock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), db.Cfg, dashStore, folderimpl.ProvideDashboardFolderStore(db), db, features) + + // create parent folder + parent, err := folderSvc.Create(context.Background(), &folder.CreateFolderCommand{ + UID: "parent", + OrgID: orgID, + Title: "parent", + SignedInUser: usr, + }) + require.NoError(t, err) + + // create subfolder + subfolder, err := folderSvc.Create(context.Background(), &folder.CreateFolderCommand{ + UID: "subfolder", + ParentUID: "parent", + OrgID: orgID, + Title: "subfolder", + SignedInUser: usr, + }) + require.NoError(t, err) + + // create dashboard under parent folder + _, err = dashStore.SaveDashboard(context.Background(), dashboards.SaveDashboardCommand{ + OrgID: orgID, + FolderID: parent.ID, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "dashboard under parent folder", + }), + }) + require.NoError(t, err) + + // create dashboard under subfolder + _, err = dashStore.SaveDashboard(context.Background(), dashboards.SaveDashboardCommand{ + OrgID: orgID, + FolderID: subfolder.ID, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": "dashboard under subfolder", + }), + }) + require.NoError(t, err) + + err = db.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + role := &accesscontrol.Role{ + OrgID: 0, + UID: "basic_viewer", + Name: "basic:viewer", + Updated: time.Now(), + Created: time.Now(), + } + _, err = sess.Insert(role) + if err != nil { + return err + } + _, err = sess.Insert(accesscontrol.BuiltinRole{ + OrgID: 0, + RoleID: role.ID, + Role: "Viewer", + Created: time.Now(), + Updated: time.Now(), + }) + if err != nil { + return err + } + + for i := range perms { + perms[i].RoleID = role.ID + perms[i].Created = time.Now() + perms[i].Updated = time.Now() + } + if len(perms) > 0 { + _, err = sess.InsertMulti(&perms) + if err != nil { + return err + } + } + + return nil + }) + require.NoError(t, err) + + return db +} diff --git a/pkg/services/sqlstore/permissions/dashboards_bench_test.go b/pkg/services/sqlstore/permissions/dashboards_bench_test.go index d213fee5ac8..84e8cb94718 100644 --- a/pkg/services/sqlstore/permissions/dashboards_bench_test.go +++ b/pkg/services/sqlstore/permissions/dashboards_bench_test.go @@ -10,26 +10,59 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/accesscontrol/mock" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/dashboards/database" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/folder/folderimpl" + "github.com/grafana/grafana/pkg/services/guardian" "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/quota/quotatest" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" + "github.com/grafana/grafana/pkg/services/tag/tagimpl" "github.com/grafana/grafana/pkg/services/user" ) -func benchmarkDashboardPermissionFilter(b *testing.B, numUsers, numDashboards int) { - store := setupBenchMark(b, numUsers, numDashboards) +func benchmarkDashboardPermissionFilter(b *testing.B, numUsers, numDashboards, numFolders, nestingLevel int) { + usr := user.SignedInUser{UserID: 1, OrgID: 1, OrgRole: org.RoleViewer, Permissions: map[int64]map[string][]string{ + 1: accesscontrol.GroupScopesByAction([]accesscontrol.Permission{ + { + Action: dashboards.ActionFoldersCreate, + }, + { + Action: dashboards.ActionFoldersWrite, + Scope: dashboards.ScopeFoldersAll, + }, + }), + }} + + features := featuremgmt.WithFeatures() + // if nestingLevel > 0 enable nested folders + if nestingLevel > 0 { + features = featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders) + } + + store := setupBenchMark(b, usr, features, numUsers, numDashboards, numFolders, nestingLevel) + + recursiveQueriesAreSupported, err := store.RecursiveQueriesAreSupported() + require.NoError(b, err) + b.ResetTimer() for i := 0; i < b.N; i++ { - usr := &user.SignedInUser{UserID: 1, OrgID: 1, OrgRole: org.RoleViewer, Permissions: map[int64]map[string][]string{1: {}}} - filter := permissions.NewAccessControlDashboardPermissionFilter(usr, dashboards.PERMISSION_VIEW, "") + filter := permissions.NewAccessControlDashboardPermissionFilter(&usr, dashboards.PERMISSION_VIEW, "", features, recursiveQueriesAreSupported) var result int err := store.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { q, params := filter.Where() - _, err := sess.SQL("SELECT COUNT(*) FROM dashboard WHERE "+q, params...).Get(&result) + recQry, recQryParams := filter.With() + params = append(recQryParams, params...) + _, err := sess.SQL(recQry+"SELECT COUNT(*) FROM dashboard WHERE "+q, params...).Get(&result) return err }) require.NoError(b, err) @@ -37,15 +70,78 @@ func benchmarkDashboardPermissionFilter(b *testing.B, numUsers, numDashboards in } } -func setupBenchMark(b *testing.B, numUsers, numDashboards int) db.DB { +func setupBenchMark(b *testing.B, usr user.SignedInUser, features featuremgmt.FeatureToggles, numUsers, numDashboards, numFolders, nestingLevel int) db.DB { + if nestingLevel > folder.MaxNestedFolderDepth { + nestingLevel = folder.MaxNestedFolderDepth + } + store := db.InitTestDB(b) - now := time.Now() - err := store.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { - dashes := make([]dashboards.Dashboard, 0, numDashboards) - for i := 1; i <= numDashboards; i++ { + + quotaService := quotatest.New(false, nil) + + dashboardWriteStore, err := database.ProvideDashboardStore(store, store.Cfg, features, tagimpl.ProvideService(store, store.Cfg), quotaService) + require.NoError(b, err) + + folderSvc := folderimpl.ProvideService(mock.New(), bus.ProvideBus(tracing.InitializeTracerForTest()), store.Cfg, dashboardWriteStore, folderimpl.ProvideDashboardFolderStore(store), store, features) + + origNewGuardian := guardian.New + guardian.MockDashboardGuardian(&guardian.FakeDashboardGuardian{CanViewValue: true, CanSaveValue: true}) + b.Cleanup(func() { + guardian.New = origNewGuardian + }) + + rootFolders := make([]*folder.Folder, 0, numFolders) + dashes := make([]dashboards.Dashboard, 0, numDashboards) + parentUID := "" + for i := 0; i < numFolders; i++ { + uid := fmt.Sprintf("f%d", i) + f, err := folderSvc.Create(context.Background(), &folder.CreateFolderCommand{ + UID: uid, + OrgID: usr.OrgID, + Title: uid, + SignedInUser: &usr, + ParentUID: parentUID, + }) + require.NoError(b, err) + rootFolders = append(rootFolders, f) + + parentUID := f.UID + var leaf *folder.Folder + for j := 1; j <= nestingLevel; j++ { + uid := fmt.Sprintf("f%d_%d", i, j) + sf, err := folderSvc.Create(context.Background(), &folder.CreateFolderCommand{ + UID: uid, + OrgID: usr.OrgID, + Title: uid, + SignedInUser: &usr, + ParentUID: parentUID, + }) + require.NoError(b, err) + parentUID = sf.UID + leaf = sf + } + + str := fmt.Sprintf("dashboard under folder %s", leaf.Title) + now := time.Now() + dashes = append(dashes, dashboards.Dashboard{ + OrgID: usr.OrgID, + IsFolder: false, + UID: str, + Slug: str, + Title: str, + Data: simplejson.New(), + Created: now, + Updated: now, + FolderID: leaf.ID, + }) + } + + err = store.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + now := time.Now() + for i := len(dashes); i < numDashboards; i++ { str := strconv.Itoa(i) dashes = append(dashes, dashboards.Dashboard{ - OrgID: 1, + OrgID: usr.OrgID, IsFolder: false, UID: str, Slug: str, @@ -79,10 +175,24 @@ func setupBenchMark(b *testing.B, numUsers, numDashboards int) db.DB { Created: now, }) for _, dash := range dashes { + // add permission to read dashboards under the general + if dash.FolderID == 0 { + permissions = append(permissions, accesscontrol.Permission{ + RoleID: int64(i), + Action: dashboards.ActionDashboardsRead, + Scope: dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dash.UID), + Updated: now, + Created: now, + }) + } + } + + for _, f := range rootFolders { + // add permission to read folders under specific folders permissions = append(permissions, accesscontrol.Permission{ RoleID: int64(i), Action: dashboards.ActionDashboardsRead, - Scope: dashboards.ScopeDashboardsProvider.GetResourceScopeUID(dash.UID), + Scope: dashboards.ScopeFoldersProvider.GetResourceScopeUID(f.UID), Updated: now, Created: now, }) @@ -113,16 +223,52 @@ func setupBenchMark(b *testing.B, numUsers, numDashboards int) db.DB { return store } -func BenchmarkDashboardPermissionFilter_100_100(b *testing.B) { - benchmarkDashboardPermissionFilter(b, 100, 100) +func BenchmarkDashboardPermissionFilter_100_100_0_0(b *testing.B) { + benchmarkDashboardPermissionFilter(b, 100, 100, 0, 0) } -func BenchmarkDashboardPermissionFilter_100_1000(b *testing.B) { - benchmarkDashboardPermissionFilter(b, 100, 1000) +func BenchmarkDashboardPermissionFilter_100_100_10_2(b *testing.B) { + benchmarkDashboardPermissionFilter(b, 100, 100, 10, 2) } -func BenchmarkDashboardPermissionFilter_300_10000(b *testing.B) { - benchmarkDashboardPermissionFilter(b, 300, 10000) +func BenchmarkDashboardPermissionFilter_100_100_10_4(b *testing.B) { + benchmarkDashboardPermissionFilter(b, 100, 100, 10, 4) +} + +func BenchmarkDashboardPermissionFilter_100_100_10_8(b *testing.B) { + benchmarkDashboardPermissionFilter(b, 100, 100, 10, 8) +} + +func BenchmarkDashboardPermissionFilter_100_1000_0_0(b *testing.B) { + benchmarkDashboardPermissionFilter(b, 100, 1000, 0, 0) +} + +func BenchmarkDashboardPermissionFilter_100_1000_10_2(b *testing.B) { + benchmarkDashboardPermissionFilter(b, 100, 1000, 10, 2) +} + +func BenchmarkDashboardPermissionFilter_100_1000_10_4(b *testing.B) { + benchmarkDashboardPermissionFilter(b, 100, 1000, 10, 4) +} + +func BenchmarkDashboardPermissionFilter_100_1000_10_8(b *testing.B) { + benchmarkDashboardPermissionFilter(b, 100, 1000, 10, 8) +} + +func BenchmarkDashboardPermissionFilter_300_10000_0_0(b *testing.B) { + benchmarkDashboardPermissionFilter(b, 300, 10000, 0, 0) +} + +func BenchmarkDashboardPermissionFilter_300_10000_10_2(b *testing.B) { + benchmarkDashboardPermissionFilter(b, 300, 10000, 10, 2) +} + +func BenchmarkDashboardPermissionFilter_300_10000_10_4(b *testing.B) { + benchmarkDashboardPermissionFilter(b, 300, 10000, 10, 4) +} + +func BenchmarkDashboardPermissionFilter_300_10000_10_8(b *testing.B) { + benchmarkDashboardPermissionFilter(b, 300, 10000, 10, 8) } func batch(count, batchSize int, eachFn func(start, end int) error) error { diff --git a/pkg/services/sqlstore/searchstore/builder.go b/pkg/services/sqlstore/searchstore/builder.go index 1ae89d05de9..3fb805262e4 100644 --- a/pkg/services/sqlstore/searchstore/builder.go +++ b/pkg/services/sqlstore/searchstore/builder.go @@ -44,6 +44,9 @@ func (b *Builder) ToSQL(limit, page int64) (string, []interface{}) { } func (b *Builder) buildSelect() { + var recQuery string + var recQueryParams []interface{} + b.sql.WriteString( `SELECT dashboard.id, @@ -61,9 +64,25 @@ func (b *Builder) buildSelect() { if f, ok := f.(FilterSelect); ok { b.sql.WriteString(fmt.Sprintf(", %s", f.Select())) } + + if f, ok := f.(FilterWith); ok { + recQuery, recQueryParams = f.With() + } } b.sql.WriteString(` FROM `) + + if recQuery == "" { + return + } + + // prepend recursive queries + var bf bytes.Buffer + bf.WriteString(recQuery) + bf.WriteString(b.sql.String()) + + b.sql = bf + b.params = append(recQueryParams, b.params...) } func (b *Builder) applyFilters() (ordering string) { diff --git a/pkg/services/sqlstore/searchstore/filters.go b/pkg/services/sqlstore/searchstore/filters.go index f5cc63e421a..170d6a2cf4d 100644 --- a/pkg/services/sqlstore/searchstore/filters.go +++ b/pkg/services/sqlstore/searchstore/filters.go @@ -14,6 +14,12 @@ type FilterWhere interface { Where() (string, []interface{}) } +// FilterWith returns any recursive CTE queries (if supported) +// and their parameters +type FilterWith interface { + With() (string, []interface{}) +} + // FilterGroupBy should be used after performing an outer join on the // search result to ensure there is only one of each ID in the results. // The id column must be present in the result. diff --git a/pkg/services/sqlstore/searchstore/search_test.go b/pkg/services/sqlstore/searchstore/search_test.go index 9d0945aac3d..b092579ab17 100644 --- a/pkg/services/sqlstore/searchstore/search_test.go +++ b/pkg/services/sqlstore/searchstore/search_test.go @@ -10,7 +10,9 @@ import ( "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/sqlstore/permissions" "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" @@ -148,6 +150,145 @@ func TestBuilder_Permissions(t *testing.T) { assert.Len(t, res, 0) } +func TestBuilder_RBAC(t *testing.T) { + testsCases := []struct { + desc string + userPermissions []accesscontrol.Permission + features featuremgmt.FeatureToggles + expectedParams []interface{} + }{ + { + desc: "no user permissions", + features: featuremgmt.WithFeatures(), + expectedParams: []interface{}{ + int64(1), + }, + }, + { + desc: "user with view permission", + userPermissions: []accesscontrol.Permission{ + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:1"}, + }, + features: featuremgmt.WithFeatures(), + expectedParams: []interface{}{ + int64(1), + int64(1), + int64(1), + 0, + "Viewer", + int64(1), + 0, + "dashboards:read", + "dashboards:write", + 2, + int64(1), + int64(1), + 0, + "Viewer", + int64(1), + 0, + "dashboards:read", + "dashboards:write", + 2, + int64(1), + int64(1), + 0, + "Viewer", + int64(1), + 0, + "folders:read", + "dashboards:create", + 2, + }, + }, + { + desc: "user with view permission with nesting", + userPermissions: []accesscontrol.Permission{ + {Action: dashboards.ActionDashboardsRead, Scope: "dashboards:uid:1"}, + }, + features: featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders), + expectedParams: []interface{}{ + int64(1), + int64(1), + 0, + "Viewer", + int64(1), + 0, + "dashboards:read", + "dashboards:write", + 2, + int64(1), + int64(1), + 0, + "Viewer", + int64(1), + 0, + "folders:read", + "dashboards:create", + 2, + int64(1), + int64(1), + int64(1), + 0, + "Viewer", + int64(1), + 0, + "dashboards:read", + "dashboards:write", + 2, + }, + }, + } + + user := &user.SignedInUser{ + UserID: 1, + OrgID: 1, + OrgRole: org.RoleViewer, + } + + store := setupTestEnvironment(t) + createDashboards(t, store, 0, 1, user.OrgID) + + recursiveQueriesAreSupported, err := store.RecursiveQueriesAreSupported() + require.NoError(t, err) + + for _, tc := range testsCases { + t.Run(tc.desc, func(t *testing.T) { + if len(tc.userPermissions) > 0 { + user.Permissions = map[int64]map[string][]string{1: accesscontrol.GroupScopesByAction(tc.userPermissions)} + } + + level := dashboards.PERMISSION_EDIT + + builder := &searchstore.Builder{ + Filters: []interface{}{ + searchstore.OrgFilter{OrgId: user.OrgID}, + searchstore.TitleSorter{}, + permissions.NewAccessControlDashboardPermissionFilter( + user, + level, + "", + tc.features, + recursiveQueriesAreSupported, + ), + }, + Dialect: store.GetDialect(), + } + + res := []dashboards.DashboardSearchProjection{} + err := store.WithDbSession(context.Background(), func(sess *db.Session) error { + sql, params := builder.ToSQL(limit, page) + // TODO: replace with a proper test + assert.Equal(t, tc.expectedParams, params) + return sess.SQL(sql, params...).Find(&res) + }) + require.NoError(t, err) + + assert.Len(t, res, 0) + }) + } +} + func setupTestEnvironment(t *testing.T) db.DB { t.Helper() store := db.InitTestDB(t)