IAM: Add email, login field validation to User create/update API (#112391)
* wip
* wip
* wip
(cherry picked from commit 8cedf25892)
* Search seems to be working, the validation is still wip
* Use keyword.Name analyzer for Filterable fields
* Only string fields should be indexed with keyword analyzer
* Change search query for email and login fields to use term query
* Remove unnecessary Exact from the resource protobuf definitions
Co-Authored-By: Ryan McKinley <ryantxu@gmail.com>
* Add legacy search support to the API
* Tests for legacy search, validate and integration tests for user
* Lint
* Add snapshot tests to userDocumentBuilder
* Address CodeQL issues
* Improvements, handle Mode2, tests should pass
* Change default limit from 0 to 1 for requests
* Cleanup
* Add fixme
* Update pkg/registry/apis/iam/register.go
Co-authored-by: Stephanie Hingtgen <stephanie.hingtgen@grafana.com>
* Update pkg/registry/apis/iam/user/legacy_search.go
Co-authored-by: Stephanie Hingtgen <stephanie.hingtgen@grafana.com>
---------
Co-authored-by: Ryan McKinley <ryantxu@gmail.com>
Co-authored-by: Stephanie Hingtgen <stephanie.hingtgen@grafana.com>
This commit is contained in:
co-authored by
Ryan McKinley
Stephanie Hingtgen
parent
f191acf811
commit
ad9d8098ef
@@ -1542,6 +1542,14 @@ func requirementQuery(req *resourcepb.Requirement, prefix string) (query.Query,
|
||||
return query.NewMatchAllQuery(), nil
|
||||
}
|
||||
|
||||
// FIXME: special case for login and email to use term query only because those fields are using keyword analyzer
|
||||
// This should be fixed by using the info from the schema
|
||||
if (req.Key == "login" || req.Key == "email") && len(req.Values) == 1 {
|
||||
tq := bleve.NewTermQuery(req.Values[0])
|
||||
tq.SetField(prefix + req.Key)
|
||||
return tq, nil
|
||||
}
|
||||
|
||||
if len(req.Values) == 1 {
|
||||
filter := filterValue(req.Key, req.Values[0])
|
||||
return newQuery(req.Key, filter, prefix), nil
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/blevesearch/bleve/v2/mapping"
|
||||
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
)
|
||||
|
||||
func GetBleveMappings(fields resource.SearchableDocumentFields) (mapping.IndexMapping, error) {
|
||||
@@ -21,7 +22,7 @@ func GetBleveMappings(fields resource.SearchableDocumentFields) (mapping.IndexMa
|
||||
return mapper, nil
|
||||
}
|
||||
|
||||
func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentMapping {
|
||||
func getBleveDocMappings(fields resource.SearchableDocumentFields) *mapping.DocumentMapping {
|
||||
mapper := bleve.NewDocumentStaticMapping()
|
||||
|
||||
nameMapping := &mapping.FieldMapping{
|
||||
@@ -145,6 +146,23 @@ func getBleveDocMappings(_ resource.SearchableDocumentFields) *mapping.DocumentM
|
||||
mapper.AddSubDocumentMapping(resource.SEARCH_FIELD_LABELS, labelMapper)
|
||||
|
||||
fieldMapper := bleve.NewDocumentMapping()
|
||||
if fields != nil {
|
||||
for _, field := range fields.Fields() {
|
||||
def := fields.Field(field)
|
||||
|
||||
// Filterable should use keyword analyzer for exact matches
|
||||
if def.Properties != nil && def.Properties.Filterable && def.Type == resourcepb.ResourceTableColumnDefinition_STRING {
|
||||
keywordMapping := bleve.NewKeywordFieldMapping()
|
||||
keywordMapping.Store = true
|
||||
|
||||
fieldMapper.AddFieldMappingsAt(def.Name, keywordMapping)
|
||||
}
|
||||
// For all other fields, we do nothing.
|
||||
// Bleve will see them at index time and dynamically map them as
|
||||
// numeric, datetime, boolean, or standard text based on their content.
|
||||
}
|
||||
}
|
||||
|
||||
mapper.AddSubDocumentMapping("fields", fieldMapper)
|
||||
|
||||
return mapper
|
||||
|
||||
@@ -65,6 +65,15 @@ func (s *StandardDocumentBuilders) GetDocumentBuilders() ([]resource.DocumentBui
|
||||
}, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
users, err := GetUserBuilder()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []resource.DocumentBuilderInfo{
|
||||
// The default builder
|
||||
{
|
||||
@@ -72,5 +81,7 @@ func (s *StandardDocumentBuilders) GetDocumentBuilders() ([]resource.DocumentBui
|
||||
},
|
||||
// Dashboard builder
|
||||
dashboards,
|
||||
// User builder
|
||||
users,
|
||||
}, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"key": {
|
||||
"namespace": "default",
|
||||
"group": "iam.grafana.app",
|
||||
"resource": "users",
|
||||
"name": "example"
|
||||
},
|
||||
"name": "example",
|
||||
"rv": 1234,
|
||||
"title": "example",
|
||||
"title_ngram": "example",
|
||||
"title_phrase": "example",
|
||||
"fields": {
|
||||
"email": "example@example.com",
|
||||
"login": "example"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"apiVersion": "iam.grafana.app/v0alpha1",
|
||||
"kind": "User",
|
||||
"metadata": {
|
||||
"name": "example",
|
||||
"namespace": "default"
|
||||
},
|
||||
"spec": {
|
||||
"login": "example",
|
||||
"email": "example@example.com",
|
||||
"role": "Viewer"
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"key": {
|
||||
"namespace": "default",
|
||||
"group": "iam.grafana.app",
|
||||
"resource": "users",
|
||||
"name": "user-with-login-and-email"
|
||||
},
|
||||
"name": "user-with-login-and-email",
|
||||
"rv": 1234,
|
||||
"fields": {
|
||||
"email": "user.one@test.com",
|
||||
"login": "user.one"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "user-with-login-and-email",
|
||||
"namespace": "default"
|
||||
},
|
||||
"spec": {
|
||||
"login": "user.one",
|
||||
"email": "user.one@test.com",
|
||||
"role": "Viewer"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"key": {
|
||||
"namespace": "default",
|
||||
"group": "iam.grafana.app",
|
||||
"resource": "users",
|
||||
"name": "user-with-login-only"
|
||||
},
|
||||
"name": "user-with-login-only",
|
||||
"rv": 1234,
|
||||
"fields": {
|
||||
"login": "user.two"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "user-with-login-only",
|
||||
"namespace": "default"
|
||||
},
|
||||
"spec": {
|
||||
"login": "user.two",
|
||||
"role": "Viewer"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
)
|
||||
|
||||
const (
|
||||
USER_EMAIL = "email"
|
||||
USER_LOGIN = "login"
|
||||
)
|
||||
|
||||
var TableColumnDefinitions = map[string]*resourcepb.ResourceTableColumnDefinition{
|
||||
USER_EMAIL: {
|
||||
Name: USER_EMAIL,
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
Description: "The email address of the user",
|
||||
Properties: &resourcepb.ResourceTableColumnDefinition_Properties{
|
||||
UniqueValues: true,
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
USER_LOGIN: {
|
||||
Name: USER_LOGIN,
|
||||
Type: resourcepb.ResourceTableColumnDefinition_STRING,
|
||||
Description: "The login of the user",
|
||||
Properties: &resourcepb.ResourceTableColumnDefinition_Properties{
|
||||
UniqueValues: true,
|
||||
Filterable: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func GetUserBuilder() (resource.DocumentBuilderInfo, error) {
|
||||
values := make([]*resourcepb.ResourceTableColumnDefinition, 0, len(TableColumnDefinitions))
|
||||
for _, v := range TableColumnDefinitions {
|
||||
values = append(values, v)
|
||||
}
|
||||
fields, err := resource.NewSearchableDocumentFields(values)
|
||||
return resource.DocumentBuilderInfo{
|
||||
GroupResource: iamv0.UserResourceInfo.GroupResource(),
|
||||
Fields: fields,
|
||||
Builder: new(userDocumentBuilder),
|
||||
}, err
|
||||
}
|
||||
|
||||
var _ resource.DocumentBuilder = new(userDocumentBuilder)
|
||||
|
||||
type userDocumentBuilder struct{}
|
||||
|
||||
// BuildDocument implements resource.DocumentBuilder.
|
||||
func (u *userDocumentBuilder) BuildDocument(ctx context.Context, key *resourcepb.ResourceKey, rv int64, value []byte) (*resource.IndexableDocument, error) {
|
||||
user := &iamv0.User{}
|
||||
err := json.NewDecoder(bytes.NewReader(value)).Decode(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
obj, err := utils.MetaAccessor(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
doc := resource.NewIndexableDocument(key, rv, obj)
|
||||
|
||||
doc.Fields = make(map[string]any)
|
||||
if user.Spec.Email != "" {
|
||||
doc.Fields[USER_EMAIL] = user.Spec.Email
|
||||
}
|
||||
if user.Spec.Login != "" {
|
||||
doc.Fields[USER_LOGIN] = user.Spec.Login
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package search_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/tracing"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resource"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/search"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/selection"
|
||||
)
|
||||
|
||||
func TestUserDocumentBuilder(t *testing.T) {
|
||||
info, err := search.GetUserBuilder()
|
||||
require.NoError(t, err)
|
||||
doSnapshotTests(t, info.Builder, "user", &resourcepb.ResourceKey{
|
||||
Namespace: "default",
|
||||
Group: "iam.grafana.app",
|
||||
Resource: "users",
|
||||
}, []string{
|
||||
"user-with-login-and-email",
|
||||
"user-with-login-only",
|
||||
})
|
||||
}
|
||||
|
||||
func TestUserSearch(t *testing.T) {
|
||||
key := resource.NamespacedResource{
|
||||
Namespace: "default",
|
||||
Group: iamv0.UserResourceInfo.GroupResource().Group,
|
||||
Resource: iamv0.UserResourceInfo.GroupResource().Resource,
|
||||
}
|
||||
|
||||
index := newTestUsersIndex(t, 100, 2, func(index resource.ResourceIndex) (int64, error) {
|
||||
return 0, nil
|
||||
})
|
||||
users := []iamv0.User{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "user1",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Login: "user.one",
|
||||
Email: "user.one@test.com",
|
||||
Role: "Viewer",
|
||||
},
|
||||
},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "user2",
|
||||
Namespace: "default",
|
||||
},
|
||||
Spec: iamv0.UserSpec{
|
||||
Login: "user.two",
|
||||
Email: "user.two@test.com",
|
||||
Role: "Viewer",
|
||||
},
|
||||
},
|
||||
}
|
||||
indexUserDocuments(t, index, key, users)
|
||||
|
||||
// Sanity check - title search
|
||||
checkUserSearchQuery(t, index, newTestsUserQueryWithTitle(key, "user2"), []string{"user2"})
|
||||
|
||||
t.Run("can search users by login", func(t *testing.T) {
|
||||
// Search by login
|
||||
checkUserSearchQuery(t, index, newTestUserQueryWithReqs(key, []*resourcepb.Requirement{
|
||||
{
|
||||
Key: "fields.login",
|
||||
Operator: string(selection.Equals),
|
||||
Values: []string{"user.one"},
|
||||
},
|
||||
}), []string{"user1"})
|
||||
checkUserSearchQuery(t, index, newTestUserQueryWithReqs(key, []*resourcepb.Requirement{
|
||||
{
|
||||
Key: "fields.login",
|
||||
Operator: string(selection.Equals),
|
||||
Values: []string{"user.two"},
|
||||
},
|
||||
}), []string{"user2"})
|
||||
})
|
||||
|
||||
t.Run("can search users by wildcard login", func(t *testing.T) {
|
||||
checkUserSearchQuery(t, index, newTestUserQueryWithReqs(key, []*resourcepb.Requirement{
|
||||
{
|
||||
Key: "fields.login",
|
||||
Operator: string(selection.Equals),
|
||||
Values: []string{"user.*"},
|
||||
},
|
||||
}), []string{"user1", "user2"})
|
||||
})
|
||||
|
||||
t.Run("can search users by email", func(t *testing.T) {
|
||||
// Search by email
|
||||
checkUserSearchQuery(t, index, newTestUserQueryWithReqs(key, []*resourcepb.Requirement{
|
||||
{
|
||||
Key: "fields.email",
|
||||
Operator: string(selection.Equals),
|
||||
Values: []string{"user.one@test.com"},
|
||||
},
|
||||
}), []string{"user1"})
|
||||
|
||||
checkUserSearchQuery(t, index, newTestUserQueryWithReqs(key, []*resourcepb.Requirement{
|
||||
{
|
||||
Key: "fields.email",
|
||||
Operator: string(selection.Equals),
|
||||
Values: []string{"user.two@test.com"},
|
||||
},
|
||||
}), []string{"user2"})
|
||||
})
|
||||
}
|
||||
|
||||
func newTestUsersIndex(t testing.TB, threshold int64, size int64, writer resource.BuildFn) resource.ResourceIndex {
|
||||
t.Helper()
|
||||
gr := iamv0.UserResourceInfo.GroupResource()
|
||||
key := &resourcepb.ResourceKey{
|
||||
Namespace: "default",
|
||||
Group: gr.Group,
|
||||
Resource: gr.Resource,
|
||||
}
|
||||
backend, err := search.NewBleveBackend(search.BleveOptions{
|
||||
Root: t.TempDir(),
|
||||
FileThreshold: threshold, // use in-memory for tests
|
||||
}, tracing.NewNoopTracerService(), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(backend.Stop)
|
||||
|
||||
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{Namespace: "ns"})
|
||||
|
||||
info, err := search.GetUserBuilder()
|
||||
require.NoError(t, err)
|
||||
|
||||
index, err := backend.BuildIndex(ctx, resource.NamespacedResource{
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
}, size, info.Fields, "test", writer, nil, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
return index
|
||||
}
|
||||
|
||||
func indexUserDocuments(t *testing.T, index resource.ResourceIndex, key resource.NamespacedResource, users []iamv0.User) {
|
||||
t.Helper()
|
||||
items := make([]*resource.BulkIndexItem, 0, len(users))
|
||||
for _, user := range users {
|
||||
items = append(items, &resource.BulkIndexItem{
|
||||
Action: resource.ActionIndex,
|
||||
Doc: &resource.IndexableDocument{
|
||||
RV: 1,
|
||||
Name: user.Name,
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Name: user.Name,
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Title: user.Name,
|
||||
Fields: map[string]any{search.USER_LOGIN: user.Spec.Login, search.USER_EMAIL: user.Spec.Email},
|
||||
},
|
||||
})
|
||||
}
|
||||
req := &resource.BulkIndexRequest{Items: items}
|
||||
require.NoError(t, index.BulkIndex(req))
|
||||
}
|
||||
|
||||
func checkUserSearchQuery(t *testing.T, index resource.ResourceIndex, query *resourcepb.ResourceSearchRequest, orderedExpectedNames []string) {
|
||||
t.Helper()
|
||||
res, err := index.Search(context.Background(), nil, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(len(orderedExpectedNames)), res.TotalHits)
|
||||
names := make([]string, len(res.Results.Rows))
|
||||
for ix, row := range res.Results.Rows {
|
||||
names[ix] = row.Key.Name
|
||||
}
|
||||
assert.ElementsMatch(t, orderedExpectedNames, names)
|
||||
}
|
||||
|
||||
func newTestUserQueryWithReqs(key resource.NamespacedResource, filterReqs []*resourcepb.Requirement) *resourcepb.ResourceSearchRequest {
|
||||
return &resourcepb.ResourceSearchRequest{
|
||||
Options: &resourcepb.ListOptions{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
Fields: filterReqs,
|
||||
},
|
||||
Limit: 100,
|
||||
}
|
||||
}
|
||||
|
||||
func newTestsUserQueryWithTitle(key resource.NamespacedResource, title string) *resourcepb.ResourceSearchRequest {
|
||||
return &resourcepb.ResourceSearchRequest{
|
||||
Options: &resourcepb.ListOptions{
|
||||
Key: &resourcepb.ResourceKey{
|
||||
Namespace: key.Namespace,
|
||||
Group: key.Group,
|
||||
Resource: key.Resource,
|
||||
},
|
||||
},
|
||||
Query: title,
|
||||
Limit: 100,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user