mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
9801a5a943
commit
c45aff1251
4
go.mod
4
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
|
||||
|
@ -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])
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user