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:
Misi
2025-10-23 11:29:02 +02:00
committed by GitHub
co-authored by Ryan McKinley Stephanie Hingtgen
parent f191acf811
commit ad9d8098ef
26 changed files with 1269 additions and 45 deletions
+166
View File
@@ -0,0 +1,166 @@
package user
import (
"context"
"fmt"
"log/slog"
"math"
"google.golang.org/grpc"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/user"
res "github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
"github.com/grafana/grafana/pkg/storage/unified/search"
)
const (
UserResource = "users"
UserResourceGroup = "iam.grafana.com"
)
// UserLegacySearchClient is a client for searching for users in the legacy search engine.
type UserLegacySearchClient struct {
resourcepb.ResourceIndexClient
userService user.Service
log *slog.Logger
}
// NewUserLegacySearchClient creates a new UserLegacySearchClient.
func NewUserLegacySearchClient(userService user.Service) *UserLegacySearchClient {
return &UserLegacySearchClient{
userService: userService,
log: slog.Default().With("logger", "legacy-user-search-client"),
}
}
// Search searches for users in the legacy search engine.
// It only supports exact matching for title, login, or email.
// FIXME: This implementation only supports a single field query and will be extended in the future.
func (c *UserLegacySearchClient) Search(ctx context.Context, req *resourcepb.ResourceSearchRequest, _ ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) {
signedInUser, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
if req.Limit > 100 {
req.Limit = 100
}
if req.Limit <= 0 {
req.Limit = 1
}
if req.Page > math.MaxInt32 || req.Page < 0 {
return nil, fmt.Errorf("invalid page number: %d", req.Page)
}
query := &user.SearchUsersQuery{
SignedInUser: signedInUser,
Limit: int(req.Limit),
Page: int(req.Page),
}
var title, login, email string
for _, field := range req.Options.Fields {
vals := field.GetValues()
if len(vals) != 1 {
c.log.Warn("only single value fields are supported for legacy search, using first value", "field", field.Key, "values", vals)
}
switch field.Key {
case res.SEARCH_FIELD_TITLE:
title = vals[0]
case "fields.login":
login = vals[0]
case "fields.email":
email = vals[0]
}
}
if title == "" && login == "" && email == "" {
return nil, fmt.Errorf("at least one of title, login, or email must be provided for the query")
}
// The user store's Search method combines these into an OR.
// For legacy search we can only supply one.
if title != "" {
query.Query = title
} else if login != "" {
query.Query = login
} else {
query.Query = email
}
columns := getColumns(req.Fields)
list := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: columns,
},
}
res, err := c.userService.Search(ctx, query)
if err != nil {
return nil, err
}
for _, u := range res.Users {
cells := createBaseCells(u, req.Fields)
list.Results.Rows = append(list.Results.Rows, &resourcepb.ResourceTableRow{
Key: getResourceKey(u, req.Options.Key.Namespace),
Cells: cells,
})
}
list.TotalHits = res.TotalCount
return list, nil
}
func getResourceKey(item *user.UserSearchHitDTO, namespace string) *resourcepb.ResourceKey {
return &resourcepb.ResourceKey{
Namespace: namespace,
Group: UserResourceGroup,
Resource: UserResource,
Name: item.UID,
}
}
func getColumns(fields []string) []*resourcepb.ResourceTableColumnDefinition {
columns := defaultColumns()
for _, field := range fields {
switch field {
case "email":
columns = append(columns, search.TableColumnDefinitions[search.USER_EMAIL])
case "login":
columns = append(columns, search.TableColumnDefinitions[search.USER_LOGIN])
}
}
return columns
}
func createBaseCells(u *user.UserSearchHitDTO, fields []string) [][]byte {
cells := createDefaultCells(u)
for _, field := range fields {
switch field {
case "email":
cells = append(cells, []byte(u.Email))
case "login":
cells = append(cells, []byte(u.Login))
}
}
return cells
}
func createDefaultCells(u *user.UserSearchHitDTO) [][]byte {
return [][]byte{
[]byte(u.UID),
[]byte(u.Name),
}
}
func defaultColumns() []*resourcepb.ResourceTableColumnDefinition {
searchFields := res.StandardSearchFields()
return []*resourcepb.ResourceTableColumnDefinition{
searchFields.Field(res.SEARCH_FIELD_NAME),
searchFields.Field(res.SEARCH_FIELD_TITLE),
}
}
@@ -0,0 +1,57 @@
package user
import (
"context"
"google.golang.org/grpc"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
// FakeUserLegacySearchClient is a fake implementation of UserLegacySearchClient for testing.
type FakeUserLegacySearchClient struct {
resourcepb.ResourceIndexClient
SearchFunc func(ctx context.Context, req *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error)
Users []*user.UserSearchHitDTO
}
// Search calls the underlying SearchFunc or simulates a search over the Users slice.
func (c *FakeUserLegacySearchClient) Search(ctx context.Context, req *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) {
if c.SearchFunc != nil {
return c.SearchFunc(ctx, req, opts...)
}
// Basic filtering for testing purposes
var filteredUsers []*user.UserSearchHitDTO
var queryValue string
for _, field := range req.Options.Fields {
if len(field.Values) > 0 {
queryValue = field.Values[0]
break
}
}
for _, u := range c.Users {
if u.Login == queryValue || u.Email == queryValue {
filteredUsers = append(filteredUsers, u)
}
}
rows := make([]*resourcepb.ResourceTableRow, 0, len(filteredUsers))
for _, u := range filteredUsers {
rows = append(rows, &resourcepb.ResourceTableRow{
Key: getResourceKey(u, req.Options.Key.Namespace),
Cells: createBaseCells(u, req.Fields),
})
}
return &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: getColumns(req.Fields),
Rows: rows,
},
TotalHits: int64(len(filteredUsers)),
}, nil
}
@@ -0,0 +1,148 @@
package user
import (
"context"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/services/user/usertest"
res "github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
func TestUserLegacySearchClient_Search(t *testing.T) {
t.Run("should return error if no query fields are provided", func(t *testing.T) {
mockUserService := usertest.NewMockService(t)
client := NewUserLegacySearchClient(mockUserService)
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1})
req := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: &resourcepb.ResourceKey{Namespace: "default"},
},
}
_, err := client.Search(ctx, req)
require.Error(t, err)
require.Equal(t, "at least one of title, login, or email must be provided for the query", err.Error())
})
testCases := []struct {
name string
fieldKey string
fieldValues []string
expectedQuery string
}{
{
name: "search by title",
fieldKey: res.SEARCH_FIELD_TITLE,
fieldValues: []string{"test user"},
expectedQuery: "test user",
},
{
name: "search by title (multiple values)",
fieldKey: res.SEARCH_FIELD_TITLE,
fieldValues: []string{"user1", "user2"},
expectedQuery: "user1",
},
{
name: "search by login",
fieldKey: "fields.login",
fieldValues: []string{"testlogin"},
expectedQuery: "testlogin",
},
{
name: "search by email",
fieldKey: "fields.email",
fieldValues: []string{"test@example.com"},
expectedQuery: "test@example.com",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
mockUserService := usertest.NewMockService(t)
client := NewUserLegacySearchClient(mockUserService)
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1})
req := &resourcepb.ResourceSearchRequest{
Limit: 10,
Page: 1,
Options: &resourcepb.ListOptions{
Key: &resourcepb.ResourceKey{Namespace: "default"},
Fields: []*resourcepb.Requirement{
{Key: tc.fieldKey, Values: tc.fieldValues},
},
},
Fields: []string{"email", "login"},
}
mockUsers := []*user.UserSearchHitDTO{
{ID: 1, UID: "uid1", Name: "Test User 1", Email: "test1@example.com", Login: "testlogin1"},
}
mockUserService.On("Search", mock.Anything, mock.MatchedBy(func(q *user.SearchUsersQuery) bool {
return q.Query == tc.expectedQuery && q.Limit == 10 && q.Page == 1
})).Return(&user.SearchUserQueryResult{
Users: mockUsers,
TotalCount: 1,
}, nil)
resp, err := client.Search(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, int64(1), resp.TotalHits)
require.Len(t, resp.Results.Rows, 1)
// Verify columns
expectedColumns := getColumns(req.Fields)
require.Equal(t, len(expectedColumns), len(resp.Results.Columns))
for i, col := range resp.Results.Columns {
require.Equal(t, expectedColumns[i].Name, col.Name)
}
// Verify rows
for i, u := range mockUsers {
row := resp.Results.Rows[i]
require.Equal(t, "default", row.Key.Namespace)
require.Equal(t, UserResourceGroup, row.Key.Group)
require.Equal(t, UserResource, row.Key.Resource)
require.Equal(t, u.UID, row.Key.Name)
expectedCells := createBaseCells(&user.UserSearchHitDTO{
UID: u.UID,
Name: u.Name,
Email: u.Email,
Login: u.Login,
}, req.Fields)
require.Equal(t, expectedCells, row.Cells)
}
})
}
t.Run("title should have precedence over login and email", func(t *testing.T) {
mockUserService := usertest.NewMockService(t)
client := NewUserLegacySearchClient(mockUserService)
ctx := identity.WithRequester(context.Background(), &user.SignedInUser{OrgID: 1, UserID: 1})
req := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: &resourcepb.ResourceKey{Namespace: "default"},
Fields: []*resourcepb.Requirement{
{Key: res.SEARCH_FIELD_TITLE, Values: []string{"title"}},
{Key: "fields.login", Values: []string{"login"}},
{Key: "fields.email", Values: []string{"email"}},
},
},
}
mockUserService.On("Search", mock.Anything, mock.MatchedBy(func(q *user.SearchUsersQuery) bool {
return q.Query == "title"
})).Return(&user.SearchUserQueryResult{Users: []*user.UserSearchHitDTO{}, TotalCount: 0}, nil)
_, err := client.Search(ctx, req)
require.NoError(t, err)
})
}
+114 -18
View File
@@ -5,13 +5,15 @@ import (
"fmt"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/selection"
"github.com/grafana/authlib/types"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
)
func ValidateOnCreate(ctx context.Context, obj *iamv0alpha1.User) error {
func ValidateOnCreate(ctx context.Context, userSearchClient resourcepb.ResourceIndexClient, obj *iamv0alpha1.User) error {
requester, err := identity.GetRequester(ctx)
if err != nil {
return apierrors.NewUnauthorized("no identity found")
@@ -28,27 +30,22 @@ func ValidateOnCreate(ctx context.Context, obj *iamv0alpha1.User) error {
return apierrors.NewBadRequest("user must have either login or email")
}
err = validateRole(obj)
if err != nil {
if err := validateRole(obj); err != nil {
return err
}
if err := validateEmail(ctx, userSearchClient, requester.GetNamespace(), obj.Name, obj.Spec.Email); err != nil {
return err
}
if err := validateLogin(ctx, userSearchClient, requester.GetNamespace(), obj.Name, obj.Spec.Login); err != nil {
return err
}
return nil
}
func validateRole(obj *iamv0alpha1.User) error {
if obj.Spec.Role == "" {
return apierrors.NewBadRequest("role is required")
}
if !identity.RoleType(obj.Spec.Role).IsValid() {
return apierrors.NewBadRequest(fmt.Sprintf("invalid role '%s'", obj.Spec.Role))
}
return nil
}
func ValidateOnUpdate(ctx context.Context, oldObj, newObj *iamv0alpha1.User) error {
func ValidateOnUpdate(ctx context.Context, userSearchClient resourcepb.ResourceIndexClient, oldObj, newObj *iamv0alpha1.User) error {
requester, err := identity.GetRequester(ctx)
if err != nil {
return apierrors.NewUnauthorized("no identity found")
@@ -93,10 +90,109 @@ func ValidateOnUpdate(ctx context.Context, oldObj, newObj *iamv0alpha1.User) err
return apierrors.NewBadRequest("user must have either login or email")
}
err = validateRole(newObj)
if err != nil {
if err := validateRole(newObj); err != nil {
return err
}
if newObj.Spec.Email != oldObj.Spec.Email {
if err := validateEmail(ctx, userSearchClient, requester.GetNamespace(), newObj.Name, newObj.Spec.Email); err != nil {
return err
}
}
if newObj.Spec.Login != oldObj.Spec.Login {
if err := validateLogin(ctx, userSearchClient, requester.GetNamespace(), newObj.Name, newObj.Spec.Login); err != nil {
return err
}
}
return nil
}
func validateRole(obj *iamv0alpha1.User) error {
if obj.Spec.Role == "" {
return apierrors.NewBadRequest("role is required")
}
if !identity.RoleType(obj.Spec.Role).IsValid() {
return apierrors.NewBadRequest(fmt.Sprintf("invalid role '%s'", obj.Spec.Role))
}
return nil
}
func validateEmail(ctx context.Context, searchClient resourcepb.ResourceIndexClient, namespace, name, email string) error {
req := createUserSearchRequest(namespace, []*resourcepb.Requirement{
{
Key: "fields.email",
Operator: string(selection.Equals),
Values: []string{email},
},
}, []string{"name", "email", "login"})
resp, err := searchClient.Search(ctx, req)
if err != nil {
return err
}
// FIXME(mgyongyosi): Improve the exact match validation
if resp.TotalHits > 0 {
// If the found user is the same as the one being created/updated, it's not a conflict.
// This is required for Mode 2 when the resource is written to LegacyStorage and UnifiedStorage.
rows := resp.Results.Rows
if len(rows) > 0 && rows[0].Key.Name == name {
return nil
}
return apierrors.NewConflict(iamv0alpha1.UserResourceInfo.GroupResource(),
name,
fmt.Errorf("email '%s' is already taken", email))
}
return nil
}
func validateLogin(ctx context.Context, searchClient resourcepb.ResourceIndexClient, namespace, name, login string) error {
req := createUserSearchRequest(namespace, []*resourcepb.Requirement{
{
Key: "fields.login",
Operator: string(selection.Equals),
Values: []string{login},
},
}, []string{"name", "email", "login"})
resp, err := searchClient.Search(ctx, req)
if err != nil {
return err
}
// FIXME(mgyongyosi): Improve the exact match validation
if resp.TotalHits > 0 {
// If the found user is the same as the one being created/updated, it's not a conflict.
// This is required for Mode 2 when the resource is written to LegacyStorage and UnifiedStorage.
rows := resp.Results.Rows
if len(rows) > 0 && rows[0].Key.Name == name {
return nil
}
return apierrors.NewConflict(iamv0alpha1.UserResourceInfo.GroupResource(),
name,
fmt.Errorf("login '%s' is already taken", login))
}
return nil
}
func createUserSearchRequest(namespace string, requirements []*resourcepb.Requirement, fields []string) *resourcepb.ResourceSearchRequest {
userGvr := iamv0alpha1.UserResourceInfo.GroupResource()
return &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: &resourcepb.ResourceKey{
Group: userGvr.Group,
Resource: userGvr.Resource,
Namespace: namespace,
},
Fields: requirements,
},
Fields: fields,
}
}
+146 -12
View File
@@ -9,6 +9,9 @@ import (
"github.com/grafana/authlib/types"
iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestValidateOnCreate(t *testing.T) {
@@ -16,6 +19,7 @@ func TestValidateOnCreate(t *testing.T) {
name string
user *iamv0alpha1.User
requester *identity.StaticRequester
searchClient resourcepb.ResourceIndexClient
expectError bool
errorContains string
}{
@@ -31,7 +35,8 @@ func TestValidateOnCreate(t *testing.T) {
Type: types.TypeUser,
IsGrafanaAdmin: true,
},
expectError: false,
searchClient: &FakeUserLegacySearchClient{},
expectError: false,
},
{
name: "grafana admin creating another grafana admin",
@@ -46,7 +51,8 @@ func TestValidateOnCreate(t *testing.T) {
Type: types.TypeUser,
IsGrafanaAdmin: true,
},
expectError: false,
searchClient: &FakeUserLegacySearchClient{},
expectError: false,
},
{
name: "non-admin trying to create a grafana admin",
@@ -61,6 +67,7 @@ func TestValidateOnCreate(t *testing.T) {
Type: types.TypeUser,
IsGrafanaAdmin: false,
},
searchClient: &FakeUserLegacySearchClient{},
expectError: true,
errorContains: "only grafana admins can create grafana admins",
},
@@ -75,6 +82,7 @@ func TestValidateOnCreate(t *testing.T) {
Type: types.TypeUser,
IsGrafanaAdmin: false,
},
searchClient: &FakeUserLegacySearchClient{},
expectError: true,
errorContains: "user must have either login or email",
},
@@ -90,13 +98,14 @@ func TestValidateOnCreate(t *testing.T) {
Type: types.TypeUser,
IsGrafanaAdmin: false,
},
expectError: false,
searchClient: &FakeUserLegacySearchClient{},
expectError: false,
},
{
name: "user with only email",
user: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{
Email: "test@test.com",
Email: "test@example",
Role: "Viewer",
},
},
@@ -104,7 +113,8 @@ func TestValidateOnCreate(t *testing.T) {
Type: types.TypeUser,
IsGrafanaAdmin: false,
},
expectError: false,
searchClient: &FakeUserLegacySearchClient{},
expectError: false,
},
{
name: "user with empty role",
@@ -117,6 +127,7 @@ func TestValidateOnCreate(t *testing.T) {
Type: types.TypeUser,
IsGrafanaAdmin: false,
},
searchClient: &FakeUserLegacySearchClient{},
expectError: true,
errorContains: "role is required",
},
@@ -132,6 +143,7 @@ func TestValidateOnCreate(t *testing.T) {
Type: types.TypeUser,
IsGrafanaAdmin: false,
},
searchClient: &FakeUserLegacySearchClient{},
expectError: true,
errorContains: "invalid role 'InvalidRole'",
},
@@ -147,7 +159,55 @@ func TestValidateOnCreate(t *testing.T) {
Type: types.TypeUser,
IsGrafanaAdmin: true,
},
expectError: false,
searchClient: &FakeUserLegacySearchClient{},
expectError: false,
},
{
name: "user with existing email",
user: &iamv0alpha1.User{
ObjectMeta: metav1.ObjectMeta{
Name: "userx",
},
Spec: iamv0alpha1.UserSpec{
Email: "existing@example",
Role: "Viewer",
},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
IsGrafanaAdmin: false,
},
searchClient: &FakeUserLegacySearchClient{
Users: []*user.UserSearchHitDTO{
{Email: "existing@example"},
},
},
expectError: true,
errorContains: "email 'existing@example' is already taken",
},
{
name: "user with existing login",
user: &iamv0alpha1.User{
ObjectMeta: metav1.ObjectMeta{
Name: "userx",
},
Spec: iamv0alpha1.UserSpec{
Login: "existinguser",
Email: "existinguser@example",
Role: "Viewer",
},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
IsGrafanaAdmin: false,
},
searchClient: &FakeUserLegacySearchClient{
Users: []*user.UserSearchHitDTO{
{Login: "existinguser"},
},
},
expectError: true,
errorContains: "login 'existinguser' is already taken",
},
}
@@ -158,7 +218,7 @@ func TestValidateOnCreate(t *testing.T) {
tt.requester,
)
err := ValidateOnCreate(ctx, tt.user)
err := ValidateOnCreate(ctx, tt.searchClient, tt.user)
if tt.expectError {
require.Error(t, err)
@@ -178,6 +238,7 @@ func TestValidateOnUpdate(t *testing.T) {
oldUser *iamv0alpha1.User
newUser *iamv0alpha1.User
requester *identity.StaticRequester
searchClient resourcepb.ResourceIndexClient
expectError bool
errorContains string
}{
@@ -254,7 +315,7 @@ func TestValidateOnUpdate(t *testing.T) {
{
name: "update with only login",
oldUser: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{Email: "test@test.com", Role: "Viewer"},
Spec: iamv0alpha1.UserSpec{Email: "test@example", Role: "Viewer"},
},
newUser: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{Login: "testuser", Email: "", Role: "Viewer"},
@@ -263,7 +324,8 @@ func TestValidateOnUpdate(t *testing.T) {
Type: types.TypeUser,
IsGrafanaAdmin: true,
},
expectError: false,
searchClient: &FakeUserLegacySearchClient{},
expectError: false,
},
{
name: "update with only email",
@@ -271,13 +333,14 @@ func TestValidateOnUpdate(t *testing.T) {
Spec: iamv0alpha1.UserSpec{Login: "testuser", Role: "Viewer"},
},
newUser: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{Login: "", Email: "test@test.com", Role: "Viewer"},
Spec: iamv0alpha1.UserSpec{Login: "", Email: "test@example", Role: "Viewer"},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
IsGrafanaAdmin: true,
},
expectError: false,
searchClient: &FakeUserLegacySearchClient{},
expectError: false,
},
{
name: "service user verifies email",
@@ -408,6 +471,77 @@ func TestValidateOnUpdate(t *testing.T) {
},
expectError: false,
},
{
name: "update with existing email",
oldUser: &iamv0alpha1.User{
ObjectMeta: metav1.ObjectMeta{
Name: "userx",
},
Spec: iamv0alpha1.UserSpec{Email: "one@example", Role: "Viewer"},
},
newUser: &iamv0alpha1.User{
ObjectMeta: metav1.ObjectMeta{
Name: "userx",
},
Spec: iamv0alpha1.UserSpec{Email: "two@example", Role: "Viewer"},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
IsGrafanaAdmin: true,
},
searchClient: &FakeUserLegacySearchClient{
Users: []*user.UserSearchHitDTO{
{Email: "two@example"},
},
},
expectError: true,
errorContains: "email 'two@example' is already taken",
},
{
name: "update with existing login",
oldUser: &iamv0alpha1.User{
ObjectMeta: metav1.ObjectMeta{
Name: "userx",
},
Spec: iamv0alpha1.UserSpec{Login: "one", Role: "Viewer"},
},
newUser: &iamv0alpha1.User{
ObjectMeta: metav1.ObjectMeta{
Name: "userx",
},
Spec: iamv0alpha1.UserSpec{Login: "two", Role: "Viewer"},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
IsGrafanaAdmin: true,
},
searchClient: &FakeUserLegacySearchClient{
Users: []*user.UserSearchHitDTO{
{Name: "other", UID: "uid456", Login: "two"},
},
},
expectError: true,
errorContains: "login 'two' is already taken",
},
{
name: "update with no change to login or email",
oldUser: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{Login: "testuser", Email: "test@example", Role: "Viewer"},
},
newUser: &iamv0alpha1.User{
Spec: iamv0alpha1.UserSpec{Login: "testuser", Email: "test@example", Role: "Editor"},
},
requester: &identity.StaticRequester{
Type: types.TypeUser,
IsGrafanaAdmin: true,
},
searchClient: &FakeUserLegacySearchClient{
Users: []*user.UserSearchHitDTO{
{Login: "testuser", Email: "test@example"},
},
},
expectError: false,
},
}
for _, tt := range tests {
@@ -417,7 +551,7 @@ func TestValidateOnUpdate(t *testing.T) {
tt.requester,
)
err := ValidateOnUpdate(ctx, tt.oldUser, tt.newUser)
err := ValidateOnUpdate(ctx, tt.searchClient, tt.oldUser, tt.newUser)
if tt.expectError {
require.Error(t, err)