mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Zanzana: Initial dashboard search (#93093)
* Zanzana: Search in a background and compare results * refactor * Search with check * instrument zanzana client * add single_read option * refactor * refactor move check into separate function * Fix tests * refactor * refactor getFindDashboardsFn * add resource type to span attributes * run ListObjects concurrently * Use list and search in less cases * adjust metrics buckets * refactor: move Check and ListObjects to AccessControl implementation * Revert "Fix tests" This reverts commitb0c2f072a2. * refactor: use own types for Check and ListObjects inside accesscontrol package * Fix search scenario with low limit and empty query string * more accurate search with checks * revert * fix linter * Revert "revert" This reverts commitee5f14eea8. * add search errors metric * fix query performance under some conditions * simplify check strategy * fix pagination * refactor findDashboardsZanzanaList * Iterate over multiple pages while making check request * refactor listUserResources * avoid unnecessary db call * remove unused zclient * Add notes for SkipAccessControlFilter * use more accurate check loop * always use check for search with provided UIDs * rename single_read to zanzana_only_evaluation * refactor * update go workspace * fix linter * don't use deprecated fields * refactor * fail if no org specified * refactor * initial integration tests * Fix tests * fix linter errors * fix linter * Fix tests * review suggestions Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com> * fix limit * refactor * refactor tests * fix db config in tests * fix migrator (postgres) --------- Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:%'
|
||||
`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
342
pkg/services/dashboards/service/zanzana.go
Normal file
342
pkg/services/dashboards/service/zanzana.go
Normal file
@@ -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:<orgId>-
|
||||
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
|
||||
}
|
||||
121
pkg/services/dashboards/service/zanzana_integration_test.go
Normal file
121
pkg/services/dashboards/service/zanzana_integration_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user