diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index 3ee940ded3d..64504e2d957 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -6,12 +6,11 @@ import ( "strconv" "strings" + "github.com/grafana/authlib/claims" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - "github.com/grafana/authlib/claims" - "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/services/authn" @@ -32,6 +31,8 @@ type AccessControl interface { // This is useful when we don't want to reuse any pre-configured resolvers // for a authorization call. WithoutResolvers() AccessControl + Check(ctx context.Context, req CheckRequest) (bool, error) + ListObjects(ctx context.Context, req ListObjectsRequest) ([]string, error) } type Service interface { diff --git a/pkg/services/accesscontrol/acimpl/accesscontrol.go b/pkg/services/accesscontrol/acimpl/accesscontrol.go index acd1edc7149..d540474abfc 100644 --- a/pkg/services/accesscontrol/acimpl/accesscontrol.go +++ b/pkg/services/accesscontrol/acimpl/accesscontrol.go @@ -119,26 +119,24 @@ func (a *AccessControl) evaluateZanzana(ctx context.Context, user identity.Reque return eval.EvaluateCustom(func(action, scope string) (bool, error) { kind, _, identifier := accesscontrol.SplitScope(scope) - key, ok := zanzana.TranslateToTuple(user.GetUID(), action, kind, identifier, user.GetOrgID()) + tupleKey, ok := zanzana.TranslateToTuple(user.GetUID(), action, kind, identifier, user.GetOrgID()) if !ok { // unsupported translation return false, errAccessNotImplemented } - a.log.Debug("evaluating zanzana", "user", key.User, "relation", key.Relation, "object", key.Object) - res, err := a.zclient.Check(ctx, &openfgav1.CheckRequest{ - TupleKey: &openfgav1.CheckRequestTupleKey{ - User: key.User, - Relation: key.Relation, - Object: key.Object, - }, + a.log.Debug("evaluating zanzana", "user", tupleKey.User, "relation", tupleKey.Relation, "object", tupleKey.Object) + allowed, err := a.Check(ctx, accesscontrol.CheckRequest{ + User: tupleKey.User, + Relation: tupleKey.Relation, + Object: tupleKey.Object, }) if err != nil { return false, err } - return res.Allowed, nil + return allowed, nil }) } @@ -221,3 +219,30 @@ func (a *AccessControl) debug(ctx context.Context, ident identity.Requester, msg a.log.FromContext(ctx).Debug(msg, "id", ident.GetID(), "orgID", ident.GetOrgID(), "permissions", eval.GoString()) } + +func (a *AccessControl) Check(ctx context.Context, req accesscontrol.CheckRequest) (bool, error) { + key := &openfgav1.CheckRequestTupleKey{ + User: req.User, + Relation: req.Relation, + Object: req.Object, + } + in := &openfgav1.CheckRequest{TupleKey: key} + res, err := a.zclient.Check(ctx, in) + if err != nil { + return false, err + } + return res.Allowed, err +} + +func (a *AccessControl) ListObjects(ctx context.Context, req accesscontrol.ListObjectsRequest) ([]string, error) { + in := &openfgav1.ListObjectsRequest{ + Type: req.Type, + User: req.User, + Relation: req.Relation, + } + res, err := a.zclient.ListObjects(ctx, in) + if err != nil { + return nil, err + } + return res.Objects, err +} diff --git a/pkg/services/accesscontrol/acimpl/service.go b/pkg/services/accesscontrol/acimpl/service.go index f9300fc1545..8127ea6d074 100644 --- a/pkg/services/accesscontrol/acimpl/service.go +++ b/pkg/services/accesscontrol/acimpl/service.go @@ -8,11 +8,10 @@ import ( "strings" "time" + "github.com/grafana/authlib/claims" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel/attribute" - "github.com/grafana/authlib/claims" - "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/db" diff --git a/pkg/services/accesscontrol/actest/fake.go b/pkg/services/accesscontrol/actest/fake.go index ce23a52544e..537002d077a 100644 --- a/pkg/services/accesscontrol/actest/fake.go +++ b/pkg/services/accesscontrol/actest/fake.go @@ -75,6 +75,14 @@ func (f FakeAccessControl) Evaluate(ctx context.Context, user identity.Requester func (f FakeAccessControl) RegisterScopeAttributeResolver(prefix string, resolver accesscontrol.ScopeAttributeResolver) { } +func (f FakeAccessControl) Check(ctx context.Context, in accesscontrol.CheckRequest) (bool, error) { + return false, nil +} + +func (f FakeAccessControl) ListObjects(ctx context.Context, in accesscontrol.ListObjectsRequest) ([]string, error) { + return nil, nil +} + func (f FakeAccessControl) WithoutResolvers() accesscontrol.AccessControl { return f } diff --git a/pkg/services/accesscontrol/migrator/zanzana.go b/pkg/services/accesscontrol/migrator/zanzana.go index fe21f0101fe..f27316f2630 100644 --- a/pkg/services/accesscontrol/migrator/zanzana.go +++ b/pkg/services/accesscontrol/migrator/zanzana.go @@ -94,12 +94,12 @@ func (z *ZanzanaSynchroniser) Sync(ctx context.Context) error { func managedPermissionsCollector(store db.DB) TupleCollector { return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error { const collectorID = "managed" - const query = ` + query := ` SELECT u.uid as user_uid, t.uid as team_uid, p.action, p.kind, p.identifier, r.org_id FROM permission p INNER JOIN role r ON p.role_id = r.id LEFT JOIN user_role ur ON r.id = ur.role_id - LEFT JOIN user u ON u.id = ur.user_id + LEFT JOIN ` + store.GetDialect().Quote("user") + ` u ON u.id = ur.user_id LEFT JOIN team_role tr ON r.id = tr.role_id LEFT JOIN team t ON tr.team_id = t.id LEFT JOIN builtin_role br ON r.id = br.role_id @@ -156,11 +156,11 @@ func teamMembershipCollector(store db.DB) TupleCollector { defer span.End() const collectorID = "team_membership" - const query = ` + query := ` SELECT t.uid as team_uid, u.uid as user_uid, tm.permission FROM team_member tm INNER JOIN team t ON tm.team_id = t.id - INNER JOIN user u ON tm.user_id = u.id + INNER JOIN ` + store.GetDialect().Quote("user") + ` u ON tm.user_id = u.id ` type membership struct { @@ -253,8 +253,10 @@ func dashboardFolderCollector(store db.DB) TupleCollector { defer span.End() const collectorID = "folder" - const query = ` - SELECT org_id, uid, folder_uid, is_folder FROM dashboard WHERE is_folder = 0 AND folder_uid IS NOT NULL + query := ` + SELECT org_id, uid, folder_uid, is_folder FROM dashboard + WHERE is_folder = ` + store.GetDialect().BooleanStr(false) + ` + AND folder_uid IS NOT NULL ` type dashboard struct { OrgID int64 `xorm:"org_id"` @@ -426,10 +428,10 @@ func customRolesCollector(store db.DB) TupleCollector { func basicRoleAssignemtCollector(store db.DB) TupleCollector { return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error { const collectorID = "basic_role_assignment" - const query = ` + query := ` SELECT ou.org_id, u.uid as user_uid, ou.role as org_role, u.is_admin FROM org_user ou - LEFT JOIN user u ON u.id = ou.user_id + LEFT JOIN ` + store.GetDialect().Quote("user") + ` u ON u.id = ou.user_id ` type Assignment struct { OrgID int64 `xorm:"org_id"` @@ -474,11 +476,11 @@ func basicRoleAssignemtCollector(store db.DB) TupleCollector { func userRoleAssignemtCollector(store db.DB) TupleCollector { return func(ctx context.Context, tuples map[string][]*openfgav1.TupleKey) error { const collectorID = "user_role_assignment" - const query = ` + query := ` SELECT ur.org_id, u.uid AS user_uid, r.uid AS role_uid, r.name AS role_name FROM user_role ur LEFT JOIN role r ON r.id = ur.role_id - LEFT JOIN user u ON u.id = ur.user_id + LEFT JOIN ` + store.GetDialect().Quote("user") + ` u ON u.id = ur.user_id WHERE r.name NOT LIKE 'managed:%' ` diff --git a/pkg/services/accesscontrol/mock/mock.go b/pkg/services/accesscontrol/mock/mock.go index 747df53d23e..459425addaf 100644 --- a/pkg/services/accesscontrol/mock/mock.go +++ b/pkg/services/accesscontrol/mock/mock.go @@ -266,6 +266,14 @@ func (m *Mock) SyncUserRoles(ctx context.Context, orgID int64, cmd accesscontrol return nil } +func (m *Mock) Check(ctx context.Context, in accesscontrol.CheckRequest) (bool, error) { + return false, nil +} + +func (m *Mock) ListObjects(ctx context.Context, in accesscontrol.ListObjectsRequest) ([]string, error) { + return nil, nil +} + // WithoutResolvers implements fullAccessControl. func (m *Mock) WithoutResolvers() accesscontrol.AccessControl { return m diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index 83e7b390f73..9c09c77ae9f 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -585,3 +585,15 @@ type QueryWithOrg struct { OrgId *int64 `json:"orgId"` Global bool `json:"global"` } + +type CheckRequest struct { + User string + Relation string + Object string +} + +type ListObjectsRequest struct { + Type string + Relation string + User string +} diff --git a/pkg/services/authz/zanzana/client/client.go b/pkg/services/authz/zanzana/client/client.go index 6bd77f8afe8..8be64a91217 100644 --- a/pkg/services/authz/zanzana/client/client.go +++ b/pkg/services/authz/zanzana/client/client.go @@ -7,6 +7,8 @@ import ( openfgav1 "github.com/openfga/api/proto/openfga/v1" "github.com/openfga/language/pkg/go/transformer" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" "google.golang.org/grpc" "google.golang.org/protobuf/types/known/wrapperspb" @@ -14,6 +16,8 @@ import ( "github.com/grafana/grafana/pkg/services/authz/zanzana/schema" ) +var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/authz/zanzana/client") + type ClientOption func(c *Client) func WithTenantID(tenantID string) ClientOption { @@ -82,12 +86,19 @@ func New(ctx context.Context, cc grpc.ClientConnInterface, opts ...ClientOption) } func (c *Client) Check(ctx context.Context, in *openfgav1.CheckRequest) (*openfgav1.CheckResponse, error) { + ctx, span := tracer.Start(ctx, "authz.zanzana.client.Check") + defer span.End() + in.StoreId = c.storeID in.AuthorizationModelId = c.modelID return c.client.Check(ctx, in) } func (c *Client) ListObjects(ctx context.Context, in *openfgav1.ListObjectsRequest) (*openfgav1.ListObjectsResponse, error) { + ctx, span := tracer.Start(ctx, "authz.zanzana.client.ListObjects") + span.SetAttributes(attribute.String("resource.type", in.Type)) + defer span.End() + in.StoreId = c.storeID in.AuthorizationModelId = c.modelID return c.client.ListObjects(ctx, in) diff --git a/pkg/services/dashboards/database/database.go b/pkg/services/dashboards/database/database.go index 1eeb5fa5569..5e33ddcca73 100644 --- a/pkg/services/dashboards/database/database.go +++ b/pkg/services/dashboards/database/database.go @@ -8,6 +8,8 @@ import ( "time" "github.com/grafana/authlib/claims" + "go.opentelemetry.io/otel" + "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" @@ -25,7 +27,6 @@ import ( "github.com/grafana/grafana/pkg/services/tag" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" - "go.opentelemetry.io/otel" ) var tracer = otel.Tracer("github.com/grafana/grafana/pkg/services/dashboard/database") @@ -942,7 +943,9 @@ func (d *dashboardStore) FindDashboards(ctx context.Context, query *dashboards.F filters = append(filters, searchstore.K6FolderFilter{}) } - filters = append(filters, permissions.NewAccessControlDashboardPermissionFilter(query.SignedInUser, query.Permission, query.Type, d.features, recursiveQueriesAreSupported)) + if !query.SkipAccessControlFilter { + filters = append(filters, permissions.NewAccessControlDashboardPermissionFilter(query.SignedInUser, query.Permission, query.Type, d.features, recursiveQueriesAreSupported)) + } filters = append(filters, searchstore.DeletedFilter{Deleted: query.IsDeleted}) diff --git a/pkg/services/dashboards/errors.go b/pkg/services/dashboards/errors.go index 6e1528124cf..0dafc5aebc0 100644 --- a/pkg/services/dashboards/errors.go +++ b/pkg/services/dashboards/errors.go @@ -130,6 +130,7 @@ var ( ErrFolderInvalidUID = errors.New("invalid uid for folder provided") ErrFolderSameNameExists = errors.New("a folder with the same name already exists in the current location") ErrFolderAccessDenied = errors.New("access denied to folder") + ErrUserIsNotSignedInToOrg = errors.New("user is not signed in to organization") ErrMoveAccessDenied = errutil.Forbidden("folders.forbiddenMove", errutil.WithPublicMessage("Access denied to the destination folder")) ErrFolderAccessEscalation = errutil.Forbidden("folders.accessEscalation", errutil.WithPublicMessage("Cannot move a folder to a folder where you have higher permissions")) ErrFolderCreationAccessDenied = errutil.Forbidden("folders.forbiddenCreation", errutil.WithPublicMessage("not enough permissions to create a folder in the selected location")) diff --git a/pkg/services/dashboards/models.go b/pkg/services/dashboards/models.go index 9428fd961e5..48e10536d55 100644 --- a/pkg/services/dashboards/models.go +++ b/pkg/services/dashboards/models.go @@ -423,4 +423,8 @@ type FindPersistedDashboardsQuery struct { IsDeleted bool Filters []any + + // Skip access control checks. This field is used by OpenFGA search implementation. + // Should not be used anywhere else. + SkipAccessControlFilter bool } diff --git a/pkg/services/dashboards/service/dashboard_service.go b/pkg/services/dashboards/service/dashboard_service.go index 90653059b10..54ef55b4446 100644 --- a/pkg/services/dashboards/service/dashboard_service.go +++ b/pkg/services/dashboards/service/dashboard_service.go @@ -8,12 +8,13 @@ import ( "time" "github.com/grafana/authlib/claims" - "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel" "golang.org/x/exp/slices" + "github.com/grafana/grafana-plugin-sdk-go/backend/gtime" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/metrics" @@ -716,7 +717,13 @@ func (dr *DashboardServiceImpl) SearchDashboards(ctx context.Context, query *das ctx, span := tracer.Start(ctx, "dashboards.service.SearchDashboards") defer span.End() - res, err := dr.FindDashboards(ctx, query) + var res []dashboards.DashboardSearchProjection + var err error + if dr.features.IsEnabled(ctx, featuremgmt.FlagZanzana) { + res, err = dr.FindDashboardsZanzana(ctx, query) + } else { + res, err = dr.FindDashboards(ctx, query) + } if err != nil { return nil, err } diff --git a/pkg/services/dashboards/service/metrics.go b/pkg/services/dashboards/service/metrics.go index 2fb45131a60..d1fb59b70cc 100644 --- a/pkg/services/dashboards/service/metrics.go +++ b/pkg/services/dashboards/service/metrics.go @@ -10,8 +10,12 @@ const ( metricsSubSystem = "dashboards" ) +var defaultBuckets = []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 25} + type dashboardsMetrics struct { sharedWithMeFetchDashboardsRequestsDuration *prometheus.HistogramVec + searchRequestsDuration *prometheus.HistogramVec + searchRequestStatusTotal *prometheus.CounterVec } func newDashboardsMetrics(r prometheus.Registerer) *dashboardsMetrics { @@ -20,7 +24,27 @@ func newDashboardsMetrics(r prometheus.Registerer) *dashboardsMetrics { prometheus.HistogramOpts{ Name: "sharedwithme_fetch_dashboards_duration_seconds", Help: "Duration of fetching dashboards with permissions directly assigned to user", - Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10, 25, 50, 100}, + Buckets: defaultBuckets, + Namespace: metricsNamespace, + Subsystem: metricsSubSystem, + }, + []string{"status"}, + ), + searchRequestsDuration: promauto.With(r).NewHistogramVec( + prometheus.HistogramOpts{ + Name: "search_dashboards_duration_seconds", + Help: "Duration of dashboards search (by authorization engine)", + Buckets: defaultBuckets, + Namespace: metricsNamespace, + Subsystem: metricsSubSystem, + }, + []string{"engine"}, + ), + + searchRequestStatusTotal: promauto.With(r).NewCounterVec( + prometheus.CounterOpts{ + Name: "search_dashboards_status_total", + Help: "Search status (success or error) for zanzana", Namespace: metricsNamespace, Subsystem: metricsSubSystem, }, diff --git a/pkg/services/dashboards/service/zanzana.go b/pkg/services/dashboards/service/zanzana.go new file mode 100644 index 00000000000..f7bfb4d23f6 --- /dev/null +++ b/pkg/services/dashboards/service/zanzana.go @@ -0,0 +1,342 @@ +package service + +import ( + "context" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/grafana/grafana/pkg/services/accesscontrol" + "github.com/grafana/grafana/pkg/services/authz/zanzana" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/sqlstore/searchstore" +) + +const ( + // If search query string shorter than this value, then "List, then check" strategy will be used + listQueryLengthThreshold = 8 + // If query limit set to value higher than this value, then "List, then check" strategy will be used + listQueryLimitThreshold = 50 + defaultQueryLimit = 1000 +) + +type searchResult struct { + runner string + result []dashboards.DashboardSearchProjection + err error + duration time.Duration +} + +func (dr *DashboardServiceImpl) FindDashboardsZanzana(ctx context.Context, query *dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { + if dr.cfg.Zanzana.ZanzanaOnlyEvaluation { + return dr.findDashboardsZanzanaOnly(ctx, *query) + } + return dr.findDashboardsZanzanaCompare(ctx, *query) +} + +func (dr *DashboardServiceImpl) findDashboardsZanzanaOnly(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { + timer := prometheus.NewTimer(dr.metrics.searchRequestsDuration.WithLabelValues("zanzana")) + defer timer.ObserveDuration() + + return dr.findDashboardsZanzana(ctx, query) +} + +func (dr *DashboardServiceImpl) findDashboardsZanzanaCompare(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { + result := make(chan searchResult, 2) + + go func() { + timer := prometheus.NewTimer(dr.metrics.searchRequestsDuration.WithLabelValues("zanzana")) + defer timer.ObserveDuration() + start := time.Now() + + queryZanzana := query + res, err := dr.findDashboardsZanzana(ctx, queryZanzana) + result <- searchResult{"zanzana", res, err, time.Since(start)} + }() + + go func() { + timer := prometheus.NewTimer(dr.metrics.searchRequestsDuration.WithLabelValues("grafana")) + defer timer.ObserveDuration() + start := time.Now() + + res, err := dr.FindDashboards(ctx, &query) + result <- searchResult{"grafana", res, err, time.Since(start)} + }() + + first, second := <-result, <-result + close(result) + + if second.runner == "grafana" { + first, second = second, first + } + + if second.err != nil { + dr.log.Error("zanzana search failed", "error", second.err) + dr.metrics.searchRequestStatusTotal.WithLabelValues("error").Inc() + } else if len(first.result) != len(second.result) { + dr.metrics.searchRequestStatusTotal.WithLabelValues("error").Inc() + dr.log.Warn( + "zanzana search result does not match grafana", + "grafana_result_len", len(first.result), + "zanana_result_len", len(second.result), + "grafana_duration", first.duration, + "zanzana_duration", second.duration, + ) + } else { + dr.metrics.searchRequestStatusTotal.WithLabelValues("success").Inc() + dr.log.Debug("zanzana search is correct", "result_len", len(first.result), "grafana_duration", first.duration, "zanzana_duration", second.duration) + } + + return first.result, first.err +} + +func (dr *DashboardServiceImpl) findDashboardsZanzana(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { + findDashboards := dr.getFindDashboardsFn(query) + return findDashboards(ctx, query) +} + +type findDashboardsFn func(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) + +// getFindDashboardsFn makes a decision which search method should be used +func (dr *DashboardServiceImpl) getFindDashboardsFn(query dashboards.FindPersistedDashboardsQuery) findDashboardsFn { + if query.Limit > 0 && query.Limit < listQueryLimitThreshold && len(query.Title) > 0 { + return dr.findDashboardsZanzanaCheck + } + if len(query.DashboardUIDs) > 0 || len(query.DashboardIds) > 0 { + return dr.findDashboardsZanzanaCheck + } + if len(query.FolderUIDs) > 0 { + return dr.findDashboardsZanzanaCheck + } + if len(query.Title) <= listQueryLengthThreshold { + return dr.findDashboardsZanzanaList + } + return dr.findDashboardsZanzanaCheck +} + +// findDashboardsZanzanaCheck implements "Search, then check" strategy. It first performs search query, then filters out results +// by checking access to each item. +func (dr *DashboardServiceImpl) findDashboardsZanzanaCheck(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { + ctx, span := tracer.Start(ctx, "dashboards.service.findDashboardsZanzanaCheck") + defer span.End() + + result := make([]dashboards.DashboardSearchProjection, 0, query.Limit) + var page int64 = 1 + query.SkipAccessControlFilter = true + // Remember initial query limit + limit := query.Limit + // Set limit to default to prevent pagination issues + query.Limit = defaultQueryLimit + + for len(result) < int(limit) { + query.Page = page + findRes, err := dr.dashboardStore.FindDashboards(ctx, &query) + if err != nil { + return nil, err + } + + remains := limit - int64(len(result)) + res, err := dr.checkDashboards(ctx, query, findRes, remains) + if err != nil { + return nil, err + } + + result = append(result, res...) + page++ + + // Stop when last page reached + if len(findRes) < defaultQueryLimit { + break + } + } + + return result, nil +} + +func (dr *DashboardServiceImpl) checkDashboards(ctx context.Context, query dashboards.FindPersistedDashboardsQuery, searchRes []dashboards.DashboardSearchProjection, remains int64) ([]dashboards.DashboardSearchProjection, error) { + ctx, span := tracer.Start(ctx, "dashboards.service.checkDashboards") + defer span.End() + + if len(searchRes) == 0 { + return nil, nil + } + + orgId := query.OrgId + if orgId == 0 && query.SignedInUser.GetOrgID() != 0 { + orgId = query.SignedInUser.GetOrgID() + } else { + return nil, dashboards.ErrUserIsNotSignedInToOrg + } + + concurrentRequests := dr.cfg.Zanzana.ConcurrentChecks + var wg sync.WaitGroup + res := make([]dashboards.DashboardSearchProjection, 0) + resToCheck := make(chan dashboards.DashboardSearchProjection, concurrentRequests) + allowedResults := make(chan dashboards.DashboardSearchProjection, len(searchRes)) + + for i := 0; i < int(concurrentRequests); i++ { + wg.Add(1) + go func() { + defer wg.Done() + for d := range resToCheck { + if int64(len(allowedResults)) >= remains { + return + } + + objectType := zanzana.TypeDashboard + if d.IsFolder { + objectType = zanzana.TypeFolder + } + + req := accesscontrol.CheckRequest{ + User: query.SignedInUser.GetUID(), + Relation: "read", + Object: zanzana.NewScopedTupleEntry(objectType, d.UID, "", strconv.FormatInt(orgId, 10)), + } + + allowed, err := dr.ac.Check(ctx, req) + if err != nil { + dr.log.Error("error checking access", "error", err) + } else if allowed { + allowedResults <- d + } + } + }() + } + + for _, r := range searchRes { + resToCheck <- r + } + close(resToCheck) + + wg.Wait() + close(allowedResults) + + for r := range allowedResults { + if int64(len(res)) >= remains { + break + } + res = append(res, r) + } + + return res, nil +} + +// findDashboardsZanzanaList implements "List, then search" strategy. It first retrieve a list of resources +// with given type available to the user and then passes that list as a filter to the search query. +func (dr *DashboardServiceImpl) findDashboardsZanzanaList(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]dashboards.DashboardSearchProjection, error) { + // Always use "search, then check" if dashboard or folder UIDs provided. Otherwise we should make intersection + // of user's resources and provided UIDs which might not be correct if ListObjects() request is limited by OpenFGA. + if len(query.DashboardUIDs) > 0 || len(query.DashboardIds) > 0 || len(query.FolderUIDs) > 0 { + return dr.findDashboardsZanzanaCheck(ctx, query) + } + + ctx, span := tracer.Start(ctx, "dashboards.service.findDashboardsZanzanaList") + defer span.End() + + resourceUIDs, err := dr.listUserResources(ctx, query) + if err != nil { + return nil, err + } + if len(resourceUIDs) == 0 { + return []dashboards.DashboardSearchProjection{}, nil + } + + query.DashboardUIDs = resourceUIDs + query.SkipAccessControlFilter = true + return dr.dashboardStore.FindDashboards(ctx, &query) +} + +func (dr *DashboardServiceImpl) listUserResources(ctx context.Context, query dashboards.FindPersistedDashboardsQuery) ([]string, error) { + tasks := make([]func() ([]string, error), 0) + var resourceTypes []string + + // For some search types we need dashboards or folders only + switch query.Type { + case searchstore.TypeDashboard: + resourceTypes = []string{zanzana.TypeDashboard} + case searchstore.TypeFolder, searchstore.TypeAlertFolder: + resourceTypes = []string{zanzana.TypeFolder} + default: + resourceTypes = []string{zanzana.TypeDashboard, zanzana.TypeFolder} + } + + for _, resourceType := range resourceTypes { + tasks = append(tasks, func() ([]string, error) { + return dr.listAllowedResources(ctx, query, resourceType) + }) + } + + uids, err := runBatch(tasks) + if err != nil { + return nil, err + } + + return uids, nil +} + +func (dr *DashboardServiceImpl) listAllowedResources(ctx context.Context, query dashboards.FindPersistedDashboardsQuery, resourceType string) ([]string, error) { + res, err := dr.ac.ListObjects(ctx, accesscontrol.ListObjectsRequest{ + User: query.SignedInUser.GetUID(), + Type: resourceType, + Relation: "read", + }) + if err != nil { + return nil, err + } + + orgId := query.OrgId + if orgId == 0 && query.SignedInUser.GetOrgID() != 0 { + orgId = query.SignedInUser.GetOrgID() + } else { + return nil, dashboards.ErrUserIsNotSignedInToOrg + } + // dashboard:- + prefix := fmt.Sprintf("%s:%d-", resourceType, orgId) + + resourceUIDs := make([]string, 0) + for _, d := range res { + if uid, found := strings.CutPrefix(d, prefix); found { + resourceUIDs = append(resourceUIDs, uid) + } + } + + return resourceUIDs, nil +} + +func runBatch(tasks []func() ([]string, error)) ([]string, error) { + var wg sync.WaitGroup + tasksNum := len(tasks) + resChan := make(chan []string, tasksNum) + errChan := make(chan error, tasksNum) + + for _, task := range tasks { + wg.Add(1) + go func() { + defer wg.Done() + res, err := task() + resChan <- res + errChan <- err + }() + } + + wg.Wait() + close(resChan) + close(errChan) + + for err := range errChan { + if err != nil { + return nil, err + } + } + + result := make([]string, 0) + for res := range resChan { + result = append(result, res...) + } + return result, nil +} diff --git a/pkg/services/dashboards/service/zanzana_integration_test.go b/pkg/services/dashboards/service/zanzana_integration_test.go new file mode 100644 index 00000000000..e65e73de154 --- /dev/null +++ b/pkg/services/dashboards/service/zanzana_integration_test.go @@ -0,0 +1,121 @@ +package service + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" + "github.com/grafana/grafana/pkg/services/accesscontrol/migrator" + accesscontrolmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock" + "github.com/grafana/grafana/pkg/services/authz" + "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/folderimpl" + "github.com/grafana/grafana/pkg/services/folder/foldertest" + "github.com/grafana/grafana/pkg/services/guardian" + "github.com/grafana/grafana/pkg/services/quota/quotatest" + "github.com/grafana/grafana/pkg/services/tag/tagimpl" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" +) + +func TestIntegrationDashboardServiceZanzana(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + t.Run("Zanzana enabled", func(t *testing.T) { + // t.Helper() + + features := featuremgmt.WithFeatures(featuremgmt.FlagZanzana) + + db, cfg := db.InitTestDBWithCfg(t) + + // Enable zanzana and run in embedded mode (part of grafana server) + cfg.Zanzana.ZanzanaOnlyEvaluation = true + cfg.Zanzana.Mode = setting.ZanzanaModeEmbedded + cfg.Zanzana.ConcurrentChecks = 10 + + _, err := cfg.Raw.Section("rbac").NewKey("resources_with_managed_permissions_on_creation", "dashboard, folder") + require.NoError(t, err) + + quotaService := quotatest.New(false, nil) + tagService := tagimpl.ProvideService(db) + folderStore := folderimpl.ProvideDashboardFolderStore(db) + fStore := folderimpl.ProvideStore(db) + dashboardStore, err := database.ProvideDashboardStore(db, cfg, features, tagService, quotaService) + require.NoError(t, err) + + zclient, err := authz.ProvideZanzana(cfg, db, features) + require.NoError(t, err) + ac := acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zclient) + + service, err := ProvideDashboardServiceImpl( + cfg, dashboardStore, folderStore, + featuremgmt.WithFeatures(), + accesscontrolmock.NewMockedPermissionsService(), + accesscontrolmock.NewMockedPermissionsService(), + ac, + foldertest.NewFakeService(), + fStore, + nil, + ) + require.NoError(t, err) + + guardianMock := &guardian.FakeDashboardGuardian{ + CanSaveValue: true, + } + guardian.MockDashboardGuardian(guardianMock) + + createDashboards(t, service, 100, "test-a") + createDashboards(t, service, 100, "test-b") + + // Sync Grafana DB with zanzana (migrate data) + zanzanaSyncronizer := migrator.NewZanzanaSynchroniser(zclient, db) + err = zanzanaSyncronizer.Sync(context.Background()) + require.NoError(t, err) + + query := &dashboards.FindPersistedDashboardsQuery{ + Title: "test-a", + Limit: 1000, + SignedInUser: &user.SignedInUser{ + OrgID: 1, + UserID: 1, + }, + } + res, err := service.FindDashboardsZanzana(context.Background(), query) + + require.NoError(t, err) + assert.Equal(t, 0, len(res)) + }) +} + +func createDashboard(t *testing.T, service dashboards.DashboardService, uid, title string) { + dto := &dashboards.SaveDashboardDTO{ + OrgID: 1, + // User: user, + User: &user.SignedInUser{ + OrgID: 1, + UserID: 1, + }, + } + dto.Dashboard = dashboards.NewDashboard(title) + dto.Dashboard.SetUID(uid) + + _, err := service.SaveDashboard(context.Background(), dto, false) + require.NoError(t, err) +} + +func createDashboards(t *testing.T, service dashboards.DashboardService, number int, prefix string) { + for i := 0; i < number; i++ { + title := fmt.Sprintf("%s-%d", prefix, i) + uid := fmt.Sprintf("dash-%s", title) + createDashboard(t, service, uid, title) + } +} diff --git a/pkg/services/ngalert/accesscontrol/testing.go b/pkg/services/ngalert/accesscontrol/testing.go index 7b79a533a8d..1ea8c64be98 100644 --- a/pkg/services/ngalert/accesscontrol/testing.go +++ b/pkg/services/ngalert/accesscontrol/testing.go @@ -36,4 +36,12 @@ func (a *recordingAccessControlFake) WithoutResolvers() accesscontrol.AccessCont panic("unimplemented") } +func (a *recordingAccessControlFake) Check(ctx context.Context, in accesscontrol.CheckRequest) (bool, error) { + return false, nil +} + +func (a *recordingAccessControlFake) ListObjects(ctx context.Context, in accesscontrol.ListObjectsRequest) ([]string, error) { + return nil, nil +} + var _ accesscontrol.AccessControl = &recordingAccessControlFake{} diff --git a/pkg/services/ngalert/api/testing.go b/pkg/services/ngalert/api/testing.go index c219a96610e..1f87a5336f5 100644 --- a/pkg/services/ngalert/api/testing.go +++ b/pkg/services/ngalert/api/testing.go @@ -8,7 +8,6 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/data" - "github.com/grafana/grafana/pkg/apimachinery/identity" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/ngalert/eval" @@ -138,6 +137,14 @@ func (a *recordingAccessControlFake) IsDisabled() bool { return a.Disabled } +func (a *recordingAccessControlFake) Check(ctx context.Context, in ac.CheckRequest) (bool, error) { + return false, nil +} + +func (a *recordingAccessControlFake) ListObjects(ctx context.Context, in ac.ListObjectsRequest) ([]string, error) { + return nil, nil +} + var _ ac.AccessControl = &recordingAccessControlFake{} type fakeRuleAccessControlService struct { diff --git a/pkg/setting/settings_zanzana.go b/pkg/setting/settings_zanzana.go index fd692cd32b1..158d71d191f 100644 --- a/pkg/setting/settings_zanzana.go +++ b/pkg/setting/settings_zanzana.go @@ -20,6 +20,11 @@ type ZanzanaSettings struct { ListenHTTP bool // OpenFGA http server address which allows to connect with fga cli HttpAddr string + // Number of check requests running concurrently + ConcurrentChecks int64 + // If enabled, authorization cheks will be only performed by zanzana. + // This bypasses the performance comparison with the legacy system. + ZanzanaOnlyEvaluation bool } func (cfg *Cfg) readZanzanaSettings() { @@ -38,6 +43,8 @@ func (cfg *Cfg) readZanzanaSettings() { s.Addr = sec.Key("address").MustString("") s.ListenHTTP = sec.Key("listen_http").MustBool(false) s.HttpAddr = sec.Key("http_addr").MustString("127.0.0.1:8080") + s.ConcurrentChecks = sec.Key("concurrent_checks").MustInt64(10) + s.ZanzanaOnlyEvaluation = sec.Key("zanzana_only_evaluation").MustBool(false) cfg.Zanzana = s }