Unified Storage: Permissions can filter search results (#99042)

* fix bug when parsing results in search handler

* applies permissions filtering to bleve query

* formatting

* wraps in check for access being present, adds some comments

* update go mod

* fix tests

* add dep owner

* fix go mod

* add space after //

* clean up returns

Co-authored-by: Bruno Abrantes <bruno.abrantes@grafana.com>

* fixed formatting

* Uses single checker since index is for single resource. Passes folderId using dvReader to checker func. Adds debug logging.

* handles federation with index permission checkers

* formatting

* move import

---------

Co-authored-by: Bruno Abrantes <bruno.abrantes@grafana.com>
This commit is contained in:
owensmallwood 2025-01-20 14:30:09 -06:00 committed by GitHub
parent 9801a5a943
commit c45aff1251
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 246 additions and 44 deletions

4
go.mod
View File

@ -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

View File

@ -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])
}

View File

@ -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: <namespace>/<group>/<resourceType>/<name>
// 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
}

View File

@ -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
}