diff --git a/go.mod b/go.mod index f56aeec6760..96223b25576 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/benbjohnson/clock v1.3.5 // @grafana/alerting-backend github.com/blang/semver/v4 v4.0.0 // indirect; @grafana/grafana-developer-enablement-squad github.com/blevesearch/bleve/v2 v2.4.3 // @grafana/grafana-search-and-storage + github.com/blevesearch/bleve_index_api v1.1.12 // @grafana/grafana-search-and-storage github.com/blugelabs/bluge v0.2.2 // @grafana/grafana-backend-group github.com/blugelabs/bluge_segment_api v0.2.0 // @grafana/grafana-backend-group github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // @grafana/grafana-backend-group @@ -105,7 +106,7 @@ require ( github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // @grafana/grafana-app-platform-squad github.com/jeremywohl/flatten v1.0.1 // @grafana/grafana-app-platform-squad github.com/jmespath-community/go-jmespath v1.1.1 // @grafana/identity-access-team - github.com/jmespath/go-jmespath v0.4.0 // indirect; @grafana/grafana-backend-group + github.com/jmespath/go-jmespath v0.4.0 // indirect; // @grafana/grafana-backend-group github.com/jmoiron/sqlx v1.3.5 // @grafana/grafana-backend-group github.com/json-iterator/go v1.1.12 // @grafana/grafana-backend-group github.com/lib/pq v1.10.9 // @grafana/grafana-backend-group @@ -254,7 +255,6 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.12.0 // indirect - github.com/blevesearch/bleve_index_api v1.1.12 // indirect github.com/blevesearch/geo v0.1.20 // indirect github.com/blevesearch/go-faiss v1.0.23 // indirect github.com/blevesearch/go-porterstemmer v1.0.3 // indirect diff --git a/pkg/services/dashboards/service/dashboard_service_test.go b/pkg/services/dashboards/service/dashboard_service_test.go index 108db4444a3..cada629f1d5 100644 --- a/pkg/services/dashboards/service/dashboard_service_test.go +++ b/pkg/services/dashboards/service/dashboard_service_test.go @@ -1843,6 +1843,10 @@ func TestParseResults(t *testing.T) { TotalHits: 1, } - _, err := ParseResults(resSearchResp, 0) + res, err := ParseResults(resSearchResp, 0) + require.NoError(t, err) + hitFields := res.Hits[0].Field.Object + require.Equal(t, int64(100), hitFields[search.DASHBOARD_ERRORS_LAST_1_DAYS]) + require.Equal(t, int64(25), hitFields[search.DASHBOARD_LINK_COUNT]) } diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index 101ee49497d..c969f8efc7d 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -13,8 +13,14 @@ import ( "time" "github.com/blevesearch/bleve/v2" + "github.com/blevesearch/bleve/v2/mapping" "github.com/blevesearch/bleve/v2/search" "github.com/blevesearch/bleve/v2/search/query" + bleveSearch "github.com/blevesearch/bleve/v2/search/searcher" + index "github.com/blevesearch/bleve_index_api" + "github.com/grafana/authlib/claims" + "github.com/grafana/grafana/pkg/apimachinery/utils" + "github.com/grafana/grafana/pkg/infra/log" "go.opentelemetry.io/otel/trace" "k8s.io/apimachinery/pkg/selection" @@ -413,7 +419,7 @@ func (b *bleveIndex) Search( } // convert protobuf request to bleve request - searchrequest, e := toBleveSearchRequest(req, access) + searchrequest, e := b.toBleveSearchRequest(ctx, req, access) if e != nil { response.Error = e return response, nil @@ -515,7 +521,7 @@ func (b *bleveIndex) getIndex( return b.index, nil } -func toBleveSearchRequest(req *resource.ResourceSearchRequest, access authz.AccessClient) (*bleve.SearchRequest, *resource.ErrorResult) { +func (b *bleveIndex) toBleveSearchRequest(ctx context.Context, req *resource.ResourceSearchRequest, access authz.AccessClient) (*bleve.SearchRequest, *resource.ErrorResult) { facets := bleve.FacetsRequest{} for _, f := range req.Facet { facets[f.Field] = bleve.NewFacetRequest(f.Field, int(f.Limit)) @@ -566,15 +572,6 @@ func toBleveSearchRequest(req *resource.ResourceSearchRequest, access authz.Acce queries = append(queries, bleve.NewFuzzyQuery(req.Query)) } - if access != nil { - // TODO AUTHZ!!!! - // Need to add an authz filter into the mix - // See: https://github.com/grafana/grafana/blob/v11.3.0/pkg/services/searchV2/bluge.go - // NOTE, we likely want to pass in the already called checker because the resource server - // will first need to check if we can see anything (or everything!) for this resource - fmt.Printf("TODO... check authorization\n") - } - switch len(queries) { case 0: searchrequest.Query = bleve.NewMatchAllQuery() @@ -584,6 +581,42 @@ func toBleveSearchRequest(req *resource.ResourceSearchRequest, access authz.Acce searchrequest.Query = bleve.NewConjunctionQuery(queries...) // AND } + // Can we remove this? Is access ever nil? + if access != nil { + auth, ok := claims.From(ctx) + if !ok { + return nil, resource.AsErrorResult(fmt.Errorf("missing claims")) + } + checker, err := access.Compile(ctx, auth, authz.ListRequest{ + Namespace: b.key.Namespace, + Group: b.key.Group, + Resource: b.key.Resource, + Verb: utils.VerbList, + }) + if err != nil { + return nil, resource.AsErrorResult(err) + } + checkers := map[string]authz.ItemChecker{ + b.key.Resource: checker, + } + + // handle federation + for _, federated := range req.Federated { + checker, err := access.Compile(ctx, auth, authz.ListRequest{ + Namespace: federated.Namespace, + Group: federated.Group, + Resource: federated.Resource, + Verb: utils.VerbList, + }) + if err != nil { + return nil, resource.AsErrorResult(err) + } + checkers[federated.Resource] = checker + } + + searchrequest.Query = newPermissionScopedQuery(searchrequest.Query, checkers) + } + for k, v := range req.Facet { if searchrequest.Facets == nil { searchrequest.Facets = make(bleve.FacetsRequest) @@ -826,3 +859,59 @@ func newResponseFacet(v *search.FacetResult) *resource.ResourceSearchResponse_Fa } return f } + +type permissionScopedQuery struct { + query.Query + checkers map[string]authz.ItemChecker // one checker per resource + log log.Logger +} + +func newPermissionScopedQuery(q query.Query, checkers map[string]authz.ItemChecker) *permissionScopedQuery { + return &permissionScopedQuery{ + Query: q, + checkers: checkers, + log: log.New("search_permissions"), + } +} + +func (q *permissionScopedQuery) Searcher(ctx context.Context, i index.IndexReader, m mapping.IndexMapping, options search.SearcherOptions) (search.Searcher, error) { + searcher, err := q.Query.Searcher(ctx, i, m, options) + if err != nil { + return nil, err + } + dvReader, err := i.DocValueReader([]string{"folder"}) + if err != nil { + return nil, err + } + + filteringSearcher := bleveSearch.NewFilteringSearcher(ctx, searcher, func(d *search.DocumentMatch) bool { + // The internal ID has the format: /// + // Only the internal ID is present on the document match here. Need to use the dvReader for any other fields. + id := string(d.IndexInternalID) + parts := strings.Split(id, "/") + // Exclude doc if id isn't expected format + if len(parts) != 4 { + return false + } + ns := parts[0] + resource := parts[2] + name := parts[3] + folder := "" + err = dvReader.VisitDocValues(d.IndexInternalID, func(field string, value []byte) { + if field == "folder" { + folder = string(value) + } + }) + if _, ok := q.checkers[resource]; !ok { + q.log.Debug("No resource checker found", "resource", resource) + return false + } + allowed := q.checkers[resource](ns, name, folder) + if !allowed { + q.log.Debug("Denying access", "ns", ns, "name", name, "folder", folder) + } + return allowed + }) + + return filteringSearcher, nil +} diff --git a/pkg/storage/unified/search/bleve_test.go b/pkg/storage/unified/search/bleve_test.go index 8f8f3bb902d..d38b821f585 100644 --- a/pkg/storage/unified/search/bleve_test.go +++ b/pkg/storage/unified/search/bleve_test.go @@ -6,10 +6,14 @@ import ( "fmt" "os" "path/filepath" - "slices" "testing" "time" + "github.com/grafana/authlib/authz" + "github.com/grafana/authlib/claims" + "github.com/grafana/grafana/pkg/apimachinery/identity" + authzextv1 "github.com/grafana/grafana/pkg/services/authz/proto/v1" + "github.com/grafana/grafana/pkg/services/user" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -43,7 +47,7 @@ func TestBleveBackend(t *testing.T) { resource.NewIndexMetrics(backend.opts.Root, backend) rv := int64(10) - ctx := context.Background() + ctx := identity.WithRequester(context.Background(), &user.SignedInUser{Namespace: "ns"}) var dashboardsIndex resource.ResourceIndex var foldersIndex resource.ResourceIndex @@ -149,7 +153,7 @@ func TestBleveBackend(t *testing.T) { require.NotNil(t, index) dashboardsIndex = index - rsp, err := index.Search(ctx, nil, &resource.ResourceSearchRequest{ + rsp, err := index.Search(ctx, NewStubAccessClient(map[string]bool{"dashboards": true}), &resource.ResourceSearchRequest{ Options: &resource.ListOptions{ Key: key, }, @@ -198,7 +202,7 @@ func TestBleveBackend(t *testing.T) { count, _ = index.DocCount(ctx, "zzz") assert.Equal(t, int64(1), count) - rsp, err = index.Search(ctx, nil, &resource.ResourceSearchRequest{ + rsp, err = index.Search(ctx, NewStubAccessClient(map[string]bool{"dashboards": true}), &resource.ResourceSearchRequest{ Options: &resource.ListOptions{ Key: key, Labels: []*resource.Requirement{{ @@ -216,8 +220,8 @@ func TestBleveBackend(t *testing.T) { rsp.Results.Rows[1].Key.Name, }) - // can get sprinkles fields - rsp, err = index.Search(ctx, nil, &resource.ResourceSearchRequest{ + // can get sprinkles fields and sort by them + rsp, err = index.Search(ctx, NewStubAccessClient(map[string]bool{"dashboards": true}), &resource.ResourceSearchRequest{ Options: &resource.ListOptions{ Key: key, }, @@ -231,12 +235,25 @@ func TestBleveBackend(t *testing.T) { require.Equal(t, 2, len(rsp.Results.Columns)) require.Equal(t, DASHBOARD_ERRORS_TODAY, rsp.Results.Columns[0].Name) require.Equal(t, DASHBOARD_VIEWS_LAST_1_DAYS, rsp.Results.Columns[1].Name) - // sorted descending so should start with highest dashboard_views_last_1_days (100) val, err := resource.DecodeCell(rsp.Results.Columns[1], 0, rsp.Results.Rows[0].Cells[1]) require.NoError(t, err) require.Equal(t, int64(100), val) + // check auth will exclude results we don't have access to + rsp, err = index.Search(ctx, NewStubAccessClient(map[string]bool{"dashboards": false}), &resource.ResourceSearchRequest{ + Options: &resource.ListOptions{ + Key: key, + }, + Limit: 100000, + Fields: []string{DASHBOARD_ERRORS_TODAY, DASHBOARD_VIEWS_LAST_1_DAYS, "fieldThatDoesntExist"}, + SortBy: []*resource.ResourceSearchRequest_Sort{ + {Field: "fields." + DASHBOARD_VIEWS_LAST_1_DAYS, Desc: true}, + }, + }, nil) + require.NoError(t, err) + require.Equal(t, 0, len(rsp.Results.Rows)) + // Now look for repositories found, err := index.ListRepositoryObjects(ctx, &resource.ListRepositoryObjectsRequest{ Name: "repo-1", @@ -343,7 +360,7 @@ func TestBleveBackend(t *testing.T) { require.NotNil(t, index) foldersIndex = index - rsp, err := index.Search(ctx, nil, &resource.ResourceSearchRequest{ + rsp, err := index.Search(ctx, NewStubAccessClient(map[string]bool{"folders": true}), &resource.ResourceSearchRequest{ Options: &resource.ListOptions{ Key: key, }, @@ -363,7 +380,7 @@ func TestBleveBackend(t *testing.T) { require.NotNil(t, foldersIndex) // Use a federated query to get both results together, sorted by title - rsp, err := dashboardsIndex.Search(ctx, nil, &resource.ResourceSearchRequest{ + rsp, err := dashboardsIndex.Search(ctx, NewStubAccessClient(map[string]bool{"dashboards": true, "folders": true}), &resource.ResourceSearchRequest{ Options: &resource.ListOptions{ Key: dashboardskey, }, @@ -425,29 +442,89 @@ func TestBleveBackend(t *testing.T) { } ] }`, string(disp)) - }) -} -func TestToBleveSearchRequest(t *testing.T) { - t.Run("will prepend 'fields.' to all dashboard fields", func(t *testing.T) { - fields := []string{"title", "name", "folder"} - fields = append(fields, DashboardFields()...) - resReq := &resource.ResourceSearchRequest{ - Options: &resource.ListOptions{}, - Fields: fields, - } - bleveReq, err := toBleveSearchRequest(resReq, nil) - if err != nil { - t.Fatalf("error creating bleve search request: %v", err) - } + // now only when we have permissions to see dashboards + rsp, err = dashboardsIndex.Search(ctx, NewStubAccessClient(map[string]bool{"dashboards": true, "folders": false}), &resource.ResourceSearchRequest{ + Options: &resource.ListOptions{ + Key: dashboardskey, + }, + Fields: []string{ + "title", "_id", + }, + Federated: []*resource.ResourceKey{ + folderKey, // This will join in the + }, + Limit: 100000, + SortBy: []*resource.ResourceSearchRequest_Sort{ + {Field: "title", Desc: false}, + }, + Facet: map[string]*resource.ResourceSearchRequest_Facet{ + "region": { + Field: "labels.region", + Limit: 100, + }, + }, + }, []resource.ResourceIndex{foldersIndex}) // << note the folder index matches the federation request - require.Equal(t, len(fields), len(bleveReq.Fields)) - for _, field := range DashboardFields() { - require.True(t, slices.Contains(bleveReq.Fields, "fields."+field)) - } - require.Contains(t, bleveReq.Fields, "title") - require.Contains(t, bleveReq.Fields, "name") - require.Contains(t, bleveReq.Fields, "folder") + require.NoError(t, err) + require.Equal(t, 3, len(rsp.Results.Rows)) + require.Equal(t, "dashboards", rsp.Results.Rows[0].Key.Resource) + require.Equal(t, "dashboards", rsp.Results.Rows[1].Key.Resource) + require.Equal(t, "dashboards", rsp.Results.Rows[2].Key.Resource) + + // now only when we have permissions to see folders + rsp, err = dashboardsIndex.Search(ctx, NewStubAccessClient(map[string]bool{"dashboards": false, "folders": true}), &resource.ResourceSearchRequest{ + Options: &resource.ListOptions{ + Key: dashboardskey, + }, + Fields: []string{ + "title", "_id", + }, + Federated: []*resource.ResourceKey{ + folderKey, // This will join in the + }, + Limit: 100000, + SortBy: []*resource.ResourceSearchRequest_Sort{ + {Field: "title", Desc: false}, + }, + Facet: map[string]*resource.ResourceSearchRequest_Facet{ + "region": { + Field: "labels.region", + Limit: 100, + }, + }, + }, []resource.ResourceIndex{foldersIndex}) // << note the folder index matches the federation request + + require.NoError(t, err) + require.Equal(t, 2, len(rsp.Results.Rows)) + require.Equal(t, "folders", rsp.Results.Rows[0].Key.Resource) + require.Equal(t, "folders", rsp.Results.Rows[1].Key.Resource) + + // now when we have permissions to see nothing + rsp, err = dashboardsIndex.Search(ctx, NewStubAccessClient(map[string]bool{"dashboards": false, "folders": false}), &resource.ResourceSearchRequest{ + Options: &resource.ListOptions{ + Key: dashboardskey, + }, + Fields: []string{ + "title", "_id", + }, + Federated: []*resource.ResourceKey{ + folderKey, // This will join in the + }, + Limit: 100000, + SortBy: []*resource.ResourceSearchRequest_Sort{ + {Field: "title", Desc: false}, + }, + Facet: map[string]*resource.ResourceSearchRequest_Facet{ + "region": { + Field: "labels.region", + Limit: 100, + }, + }, + }, []resource.ResourceIndex{foldersIndex}) // << note the folder index matches the federation request + + require.NoError(t, err) + require.Equal(t, 0, len(rsp.Results.Rows)) }) } @@ -458,3 +535,35 @@ func asTimePointer(milli int64) *time.Time { } return nil } + +var _ authz.AccessClient = (*StubAccessClient)(nil) + +func NewStubAccessClient(permissions map[string]bool) *StubAccessClient { + return &StubAccessClient{resourceResponses: permissions} +} + +type StubAccessClient struct { + resourceResponses map[string]bool // key is the resource name, and bool if what the checker will return +} + +func (nc *StubAccessClient) Check(ctx context.Context, id claims.AuthInfo, req authz.CheckRequest) (authz.CheckResponse, error) { + return authz.CheckResponse{Allowed: nc.resourceResponses[req.Resource]}, nil +} + +func (nc *StubAccessClient) Compile(ctx context.Context, id claims.AuthInfo, req authz.ListRequest) (authz.ItemChecker, error) { + return func(namespace string, name, folder string) bool { + return nc.resourceResponses[req.Resource] + }, nil +} + +func (nc StubAccessClient) Read(ctx context.Context, req *authzextv1.ReadRequest) (*authzextv1.ReadResponse, error) { + return nil, nil +} + +func (nc StubAccessClient) Write(ctx context.Context, req *authzextv1.WriteRequest) error { + return nil +} + +func (nc StubAccessClient) BatchCheck(ctx context.Context, req *authzextv1.BatchCheckRequest) (*authzextv1.BatchCheckResponse, error) { + return nil, nil +}