diff --git a/internal/api/grpc/user/v2beta/convert/user.go b/internal/api/grpc/user/v2beta/convert/user.go new file mode 100644 index 0000000000..cceb6768cc --- /dev/null +++ b/internal/api/grpc/user/v2beta/convert/user.go @@ -0,0 +1,305 @@ +package convert + +import ( + "github.com/muhlemmer/gu" + "google.golang.org/protobuf/types/known/timestamppb" + + object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func UsersToPb(users []*query.User, assetPrefix string) []*user.User { + u := make([]*user.User, len(users)) + for i, user := range users { + u[i] = UserToPb(user, assetPrefix) + } + return u +} + +func UserToPb(userQ *query.User, assetPrefix string) *user.User { + return &user.User{ + UserId: userQ.ID, + Details: object.DomainToDetailsPb(&domain.ObjectDetails{ + Sequence: userQ.Sequence, + EventDate: userQ.ChangeDate, + ResourceOwner: userQ.ResourceOwner, + }), + State: userStateToPb(userQ.State), + Username: userQ.Username, + LoginNames: userQ.LoginNames, + PreferredLoginName: userQ.PreferredLoginName, + Type: userTypeToPb(userQ, assetPrefix), + } +} + +func userTypeToPb(userQ *query.User, assetPrefix string) user.UserType { + if userQ.Human != nil { + return &user.User_Human{ + Human: humanToPb(userQ.Human, assetPrefix, userQ.ResourceOwner), + } + } + if userQ.Machine != nil { + return &user.User_Machine{ + Machine: machineToPb(userQ.Machine), + } + } + return nil +} + +func humanToPb(userQ *query.Human, assetPrefix, owner string) *user.HumanUser { + var passwordChanged *timestamppb.Timestamp + if !userQ.PasswordChanged.IsZero() { + passwordChanged = timestamppb.New(userQ.PasswordChanged) + } + return &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: userQ.FirstName, + FamilyName: userQ.LastName, + NickName: gu.Ptr(userQ.NickName), + DisplayName: gu.Ptr(userQ.DisplayName), + PreferredLanguage: gu.Ptr(userQ.PreferredLanguage.String()), + Gender: gu.Ptr(genderToPb(userQ.Gender)), + AvatarUrl: domain.AvatarURL(assetPrefix, owner, userQ.AvatarKey), + }, + Email: &user.HumanEmail{ + Email: string(userQ.Email), + IsVerified: userQ.IsEmailVerified, + }, + Phone: &user.HumanPhone{ + Phone: string(userQ.Phone), + IsVerified: userQ.IsPhoneVerified, + }, + PasswordChangeRequired: userQ.PasswordChangeRequired, + PasswordChanged: passwordChanged, + } +} + +func machineToPb(userQ *query.Machine) *user.MachineUser { + return &user.MachineUser{ + Name: userQ.Name, + Description: userQ.Description, + HasSecret: userQ.EncodedSecret != "", + AccessTokenType: accessTokenTypeToPb(userQ.AccessTokenType), + } +} + +func userStateToPb(state domain.UserState) user.UserState { + switch state { + case domain.UserStateActive: + return user.UserState_USER_STATE_ACTIVE + case domain.UserStateInactive: + return user.UserState_USER_STATE_INACTIVE + case domain.UserStateDeleted: + return user.UserState_USER_STATE_DELETED + case domain.UserStateInitial: + return user.UserState_USER_STATE_INITIAL + case domain.UserStateLocked: + return user.UserState_USER_STATE_LOCKED + case domain.UserStateUnspecified: + return user.UserState_USER_STATE_UNSPECIFIED + case domain.UserStateSuspend: + return user.UserState_USER_STATE_UNSPECIFIED + default: + return user.UserState_USER_STATE_UNSPECIFIED + } +} + +func genderToPb(gender domain.Gender) user.Gender { + switch gender { + case domain.GenderDiverse: + return user.Gender_GENDER_DIVERSE + case domain.GenderFemale: + return user.Gender_GENDER_FEMALE + case domain.GenderMale: + return user.Gender_GENDER_MALE + case domain.GenderUnspecified: + return user.Gender_GENDER_UNSPECIFIED + default: + return user.Gender_GENDER_UNSPECIFIED + } +} + +func accessTokenTypeToPb(accessTokenType domain.OIDCTokenType) user.AccessTokenType { + switch accessTokenType { + case domain.OIDCTokenTypeBearer: + return user.AccessTokenType_ACCESS_TOKEN_TYPE_BEARER + case domain.OIDCTokenTypeJWT: + return user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT + default: + return user.AccessTokenType_ACCESS_TOKEN_TYPE_BEARER + } +} + +func ListUsersRequestToModel(req *user.ListUsersRequest) (*query.UserSearchQueries, error) { + offset, limit, asc := object.ListQueryToQuery(req.Query) + queries, err := userQueriesToQuery(req.Queries, 0 /*start from level 0*/) + if err != nil { + return nil, err + } + return &query.UserSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: userFieldNameToSortingColumn(req.SortingColumn), + }, + Queries: queries, + }, nil +} + +func userFieldNameToSortingColumn(field user.UserFieldName) query.Column { + switch field { + case user.UserFieldName_USER_FIELD_NAME_EMAIL: + return query.HumanEmailCol + case user.UserFieldName_USER_FIELD_NAME_FIRST_NAME: + return query.HumanFirstNameCol + case user.UserFieldName_USER_FIELD_NAME_LAST_NAME: + return query.HumanLastNameCol + case user.UserFieldName_USER_FIELD_NAME_DISPLAY_NAME: + return query.HumanDisplayNameCol + case user.UserFieldName_USER_FIELD_NAME_USER_NAME: + return query.UserUsernameCol + case user.UserFieldName_USER_FIELD_NAME_STATE: + return query.UserStateCol + case user.UserFieldName_USER_FIELD_NAME_TYPE: + return query.UserTypeCol + case user.UserFieldName_USER_FIELD_NAME_NICK_NAME: + return query.HumanNickNameCol + case user.UserFieldName_USER_FIELD_NAME_CREATION_DATE: + return query.UserCreationDateCol + case user.UserFieldName_USER_FIELD_NAME_UNSPECIFIED: + return query.UserIDCol + default: + return query.UserIDCol + } +} + +func userQueriesToQuery(queries []*user.SearchQuery, level uint8) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, query := range queries { + q[i], err = userQueryToQuery(query, level) + if err != nil { + return nil, err + } + } + return q, nil +} + +func userQueryToQuery(query *user.SearchQuery, level uint8) (query.SearchQuery, error) { + if level > 20 { + // can't go deeper than 20 levels of nesting. + return nil, zerrors.ThrowInvalidArgument(nil, "USER-zsQ97", "Errors.Query.TooManyNestingLevels") + } + switch q := query.Query.(type) { + case *user.SearchQuery_UserNameQuery: + return userNameQueryToQuery(q.UserNameQuery) + case *user.SearchQuery_FirstNameQuery: + return firstNameQueryToQuery(q.FirstNameQuery) + case *user.SearchQuery_LastNameQuery: + return lastNameQueryToQuery(q.LastNameQuery) + case *user.SearchQuery_NickNameQuery: + return nickNameQueryToQuery(q.NickNameQuery) + case *user.SearchQuery_DisplayNameQuery: + return displayNameQueryToQuery(q.DisplayNameQuery) + case *user.SearchQuery_EmailQuery: + return emailQueryToQuery(q.EmailQuery) + case *user.SearchQuery_PhoneQuery: + return phoneQueryToQuery(q.PhoneQuery) + case *user.SearchQuery_StateQuery: + return stateQueryToQuery(q.StateQuery) + case *user.SearchQuery_TypeQuery: + return typeQueryToQuery(q.TypeQuery) + case *user.SearchQuery_LoginNameQuery: + return loginNameQueryToQuery(q.LoginNameQuery) + case *user.SearchQuery_OrganizationIdQuery: + return resourceOwnerQueryToQuery(q.OrganizationIdQuery) + case *user.SearchQuery_InUserIdsQuery: + return inUserIdsQueryToQuery(q.InUserIdsQuery) + case *user.SearchQuery_OrQuery: + return orQueryToQuery(q.OrQuery, level) + case *user.SearchQuery_AndQuery: + return andQueryToQuery(q.AndQuery, level) + case *user.SearchQuery_NotQuery: + return notQueryToQuery(q.NotQuery, level) + case *user.SearchQuery_InUserEmailsQuery: + return inUserEmailsQueryToQuery(q.InUserEmailsQuery) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-vR9nC", "List.Query.Invalid") + } +} + +func userNameQueryToQuery(q *user.UserNameQuery) (query.SearchQuery, error) { + return query.NewUserUsernameSearchQuery(q.UserName, object.TextMethodToQuery(q.Method)) +} + +func firstNameQueryToQuery(q *user.FirstNameQuery) (query.SearchQuery, error) { + return query.NewUserFirstNameSearchQuery(q.FirstName, object.TextMethodToQuery(q.Method)) +} + +func lastNameQueryToQuery(q *user.LastNameQuery) (query.SearchQuery, error) { + return query.NewUserLastNameSearchQuery(q.LastName, object.TextMethodToQuery(q.Method)) +} + +func nickNameQueryToQuery(q *user.NickNameQuery) (query.SearchQuery, error) { + return query.NewUserNickNameSearchQuery(q.NickName, object.TextMethodToQuery(q.Method)) +} + +func displayNameQueryToQuery(q *user.DisplayNameQuery) (query.SearchQuery, error) { + return query.NewUserDisplayNameSearchQuery(q.DisplayName, object.TextMethodToQuery(q.Method)) +} + +func emailQueryToQuery(q *user.EmailQuery) (query.SearchQuery, error) { + return query.NewUserEmailSearchQuery(q.EmailAddress, object.TextMethodToQuery(q.Method)) +} + +func phoneQueryToQuery(q *user.PhoneQuery) (query.SearchQuery, error) { + return query.NewUserPhoneSearchQuery(q.Number, object.TextMethodToQuery(q.Method)) +} + +func stateQueryToQuery(q *user.StateQuery) (query.SearchQuery, error) { + return query.NewUserStateSearchQuery(q.State.ToDomain()) +} + +func typeQueryToQuery(q *user.TypeQuery) (query.SearchQuery, error) { + return query.NewUserTypeSearchQuery(q.Type.ToDomain()) +} + +func loginNameQueryToQuery(q *user.LoginNameQuery) (query.SearchQuery, error) { + return query.NewUserLoginNameExistsQuery(q.LoginName, object.TextMethodToQuery(q.Method)) +} + +func resourceOwnerQueryToQuery(q *user.OrganizationIdQuery) (query.SearchQuery, error) { + return query.NewUserResourceOwnerSearchQuery(q.OrganizationId, query.TextEquals) +} + +func inUserIdsQueryToQuery(q *user.InUserIDQuery) (query.SearchQuery, error) { + return query.NewUserInUserIdsSearchQuery(q.UserIds) +} +func orQueryToQuery(q *user.OrQuery, level uint8) (query.SearchQuery, error) { + mappedQueries, err := userQueriesToQuery(q.Queries, level+1) + if err != nil { + return nil, err + } + return query.NewUserOrSearchQuery(mappedQueries) +} +func andQueryToQuery(q *user.AndQuery, level uint8) (query.SearchQuery, error) { + mappedQueries, err := userQueriesToQuery(q.Queries, level+1) + if err != nil { + return nil, err + } + return query.NewUserAndSearchQuery(mappedQueries) +} +func notQueryToQuery(q *user.NotQuery, level uint8) (query.SearchQuery, error) { + mappedQuery, err := userQueryToQuery(q.Query, level+1) + if err != nil { + return nil, err + } + return query.NewUserNotSearchQuery(mappedQuery) +} + +func inUserEmailsQueryToQuery(q *user.InUserEmailsQuery) (query.SearchQuery, error) { + return query.NewUserInUserEmailsSearchQuery(q.UserEmails) +} diff --git a/internal/api/grpc/user/v2beta/convert/user_test.go b/internal/api/grpc/user/v2beta/convert/user_test.go new file mode 100644 index 0000000000..9eb5cb643b --- /dev/null +++ b/internal/api/grpc/user/v2beta/convert/user_test.go @@ -0,0 +1,293 @@ +package convert + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/text/language" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func TestUsersToPb(t *testing.T) { + t.Parallel() + users := []*query.User{ + { + ID: "user1", + Sequence: 1, + ChangeDate: time.Now(), + ResourceOwner: "owner1", + State: domain.UserStateActive, + Username: "username1", + LoginNames: []string{"login1"}, + PreferredLoginName: "preferred1", + Human: &query.Human{ + FirstName: "John", + LastName: "Doe", + NickName: "JD", + DisplayName: "John Doe", + PreferredLanguage: language.English, + Gender: domain.GenderMale, + AvatarKey: "avatar1", + Email: "john@example.com", + IsEmailVerified: true, + Phone: "123456789", + IsPhoneVerified: false, + PasswordChangeRequired: true, + PasswordChanged: time.Now(), + }, + }, + } + pbUsers := UsersToPb(users, "prefix") + require.Len(t, pbUsers, 1) + assert.Equal(t, "user1", pbUsers[0].UserId) + assert.Equal(t, user.UserState_USER_STATE_ACTIVE, pbUsers[0].State) + assert.NotNil(t, pbUsers[0].Type) +} + +func TestUserToPb_Human(t *testing.T) { + t.Parallel() + now := time.Now() + u := &query.User{ + ID: "id", + Sequence: 2, + ChangeDate: now, + ResourceOwner: "owner", + State: domain.UserStateInactive, + Username: "uname", + LoginNames: []string{"ln"}, + PreferredLoginName: "pln", + Human: &query.Human{ + FirstName: "Jane", + LastName: "Smith", + NickName: "JS", + DisplayName: "Jane Smith", + PreferredLanguage: language.German, + Gender: domain.GenderFemale, + AvatarKey: "avatar2", + Email: "jane@example.com", + IsEmailVerified: false, + Phone: "987654321", + IsPhoneVerified: true, + PasswordChangeRequired: false, + PasswordChanged: now, + }, + } + pb := UserToPb(u, "prefix") + assert.Equal(t, "id", pb.UserId) + assert.Equal(t, user.UserState_USER_STATE_INACTIVE, pb.State) + assert.NotNil(t, pb.Type) + human, ok := pb.Type.(*user.User_Human) + require.True(t, ok) + assert.Equal(t, "Jane", human.Human.Profile.GivenName) + assert.Equal(t, "Smith", human.Human.Profile.FamilyName) + assert.Equal(t, "avatar2", u.Human.AvatarKey) + assert.Equal(t, "jane@example.com", human.Human.Email.Email) + assert.Equal(t, false, human.Human.Email.IsVerified) + assert.Equal(t, "987654321", human.Human.Phone.Phone) + assert.Equal(t, true, human.Human.Phone.IsVerified) + assert.Equal(t, false, human.Human.PasswordChangeRequired) + assert.Equal(t, timestamppb.New(now), human.Human.PasswordChanged) +} + +func TestUserToPb_Machine(t *testing.T) { + t.Parallel() + u := &query.User{ + ID: "id2", + Sequence: 3, + ChangeDate: time.Now(), + ResourceOwner: "owner2", + State: domain.UserStateDeleted, + Username: "uname2", + LoginNames: []string{"ln2"}, + PreferredLoginName: "pln2", + Machine: &query.Machine{ + Name: "machine1", + Description: "desc", + EncodedSecret: "secret", + AccessTokenType: domain.OIDCTokenTypeJWT, + }, + } + pb := UserToPb(u, "prefix") + assert.Equal(t, "id2", pb.UserId) + assert.Equal(t, user.UserState_USER_STATE_DELETED, pb.State) + assert.NotNil(t, pb.Type) + machine, ok := pb.Type.(*user.User_Machine) + require.True(t, ok) + assert.Equal(t, "machine1", machine.Machine.Name) + assert.Equal(t, "desc", machine.Machine.Description) + assert.True(t, machine.Machine.HasSecret) + assert.Equal(t, user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT, machine.Machine.AccessTokenType) +} + +func TestUserTypeToPb_Nil(t *testing.T) { + t.Parallel() + u := &query.User{} + assert.Nil(t, userTypeToPb(u, "prefix")) +} + +func TestHumanToPb_PasswordChangedZero(t *testing.T) { + t.Parallel() + h := &query.Human{ + FirstName: "A", + LastName: "B", + NickName: "C", + DisplayName: "D", + PreferredLanguage: language.French, + Gender: domain.GenderDiverse, + AvatarKey: "avatar", + Email: "a@b.com", + IsEmailVerified: true, + Phone: "123", + IsPhoneVerified: false, + PasswordChangeRequired: true, + PasswordChanged: time.Time{}, + } + pb := humanToPb(h, "prefix", "owner") + assert.Nil(t, pb.PasswordChanged) +} + +func TestMachineToPb(t *testing.T) { + t.Parallel() + m := &query.Machine{ + Name: "mach", + Description: "desc", + EncodedSecret: "", + AccessTokenType: domain.OIDCTokenTypeBearer, + } + pb := machineToPb(m) + assert.Equal(t, "mach", pb.Name) + assert.Equal(t, "desc", pb.Description) + assert.False(t, pb.HasSecret) + assert.Equal(t, user.AccessTokenType_ACCESS_TOKEN_TYPE_BEARER, pb.AccessTokenType) +} + +func TestUserStateToPb(t *testing.T) { + t.Parallel() + assert.Equal(t, user.UserState_USER_STATE_ACTIVE, userStateToPb(domain.UserStateActive)) + assert.Equal(t, user.UserState_USER_STATE_INACTIVE, userStateToPb(domain.UserStateInactive)) + assert.Equal(t, user.UserState_USER_STATE_DELETED, userStateToPb(domain.UserStateDeleted)) + assert.Equal(t, user.UserState_USER_STATE_INITIAL, userStateToPb(domain.UserStateInitial)) + assert.Equal(t, user.UserState_USER_STATE_LOCKED, userStateToPb(domain.UserStateLocked)) + assert.Equal(t, user.UserState_USER_STATE_UNSPECIFIED, userStateToPb(domain.UserStateUnspecified)) + assert.Equal(t, user.UserState_USER_STATE_UNSPECIFIED, userStateToPb(domain.UserStateSuspend)) + assert.Equal(t, user.UserState_USER_STATE_UNSPECIFIED, userStateToPb(999)) +} + +func TestGenderToPb(t *testing.T) { + t.Parallel() + assert.Equal(t, user.Gender_GENDER_DIVERSE, genderToPb(domain.GenderDiverse)) + assert.Equal(t, user.Gender_GENDER_FEMALE, genderToPb(domain.GenderFemale)) + assert.Equal(t, user.Gender_GENDER_MALE, genderToPb(domain.GenderMale)) + assert.Equal(t, user.Gender_GENDER_UNSPECIFIED, genderToPb(domain.GenderUnspecified)) + assert.Equal(t, user.Gender_GENDER_UNSPECIFIED, genderToPb(999)) +} + +func TestAccessTokenTypeToPb(t *testing.T) { + t.Parallel() + assert.Equal(t, user.AccessTokenType_ACCESS_TOKEN_TYPE_BEARER, accessTokenTypeToPb(domain.OIDCTokenTypeBearer)) + assert.Equal(t, user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT, accessTokenTypeToPb(domain.OIDCTokenTypeJWT)) + assert.Equal(t, user.AccessTokenType_ACCESS_TOKEN_TYPE_BEARER, accessTokenTypeToPb(999)) +} + +func TestUserFieldNameToSortingColumn(t *testing.T) { + t.Parallel() + assert.Equal(t, query.HumanEmailCol, userFieldNameToSortingColumn(user.UserFieldName_USER_FIELD_NAME_EMAIL)) + assert.Equal(t, query.HumanFirstNameCol, userFieldNameToSortingColumn(user.UserFieldName_USER_FIELD_NAME_FIRST_NAME)) + assert.Equal(t, query.HumanLastNameCol, userFieldNameToSortingColumn(user.UserFieldName_USER_FIELD_NAME_LAST_NAME)) + assert.Equal(t, query.HumanDisplayNameCol, userFieldNameToSortingColumn(user.UserFieldName_USER_FIELD_NAME_DISPLAY_NAME)) + assert.Equal(t, query.UserUsernameCol, userFieldNameToSortingColumn(user.UserFieldName_USER_FIELD_NAME_USER_NAME)) + assert.Equal(t, query.UserStateCol, userFieldNameToSortingColumn(user.UserFieldName_USER_FIELD_NAME_STATE)) + assert.Equal(t, query.UserTypeCol, userFieldNameToSortingColumn(user.UserFieldName_USER_FIELD_NAME_TYPE)) + assert.Equal(t, query.HumanNickNameCol, userFieldNameToSortingColumn(user.UserFieldName_USER_FIELD_NAME_NICK_NAME)) + assert.Equal(t, query.UserCreationDateCol, userFieldNameToSortingColumn(user.UserFieldName_USER_FIELD_NAME_CREATION_DATE)) + assert.Equal(t, query.UserIDCol, userFieldNameToSortingColumn(user.UserFieldName_USER_FIELD_NAME_UNSPECIFIED)) + assert.Equal(t, query.UserIDCol, userFieldNameToSortingColumn(999)) +} + +func TestListUsersRequestToModel(t *testing.T) { + t.Parallel() + req := &user.ListUsersRequest{ + Query: &object.ListQuery{ + Offset: 1, + Limit: 2, + Asc: true, + }, + SortingColumn: user.UserFieldName_USER_FIELD_NAME_EMAIL, + Queries: []*user.SearchQuery{ + { + Query: &user.SearchQuery_UserNameQuery{ + UserNameQuery: &user.UserNameQuery{ + UserName: "test", + Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS, + }, + }, + }, + }, + } + model, err := ListUsersRequestToModel(req) + require.NoError(t, err) + assert.EqualValues(t, 1, model.Offset) + assert.EqualValues(t, 2, model.Limit) + assert.True(t, model.Asc) + assert.Equal(t, query.HumanEmailCol, model.SortingColumn) + require.Len(t, model.Queries, 1) +} + +func TestUserQueriesToQuery_TooDeep(t *testing.T) { + t.Parallel() + q := &user.SearchQuery{ + Query: &user.SearchQuery_OrQuery{ + OrQuery: &user.OrQuery{ + Queries: []*user.SearchQuery{}, + }, + }, + } + _, err := userQueryToQuery(q, 21) + assert.Error(t, err) +} + +func TestUserQueryToQuery_Invalid(t *testing.T) { + t.Parallel() + q := &user.SearchQuery{} + _, err := userQueryToQuery(q, 0) + assert.Error(t, err) +} + +func TestUserQueryToQuery_AllTypes(t *testing.T) { + t.Parallel() + queries := []*user.SearchQuery{ + {Query: &user.SearchQuery_UserNameQuery{UserNameQuery: &user.UserNameQuery{UserName: "u", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS}}}, + {Query: &user.SearchQuery_FirstNameQuery{FirstNameQuery: &user.FirstNameQuery{FirstName: "f", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS}}}, + {Query: &user.SearchQuery_LastNameQuery{LastNameQuery: &user.LastNameQuery{LastName: "l", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS}}}, + {Query: &user.SearchQuery_NickNameQuery{NickNameQuery: &user.NickNameQuery{NickName: "n", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS}}}, + {Query: &user.SearchQuery_DisplayNameQuery{DisplayNameQuery: &user.DisplayNameQuery{DisplayName: "d", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS}}}, + {Query: &user.SearchQuery_EmailQuery{EmailQuery: &user.EmailQuery{EmailAddress: "e", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS}}}, + {Query: &user.SearchQuery_PhoneQuery{PhoneQuery: &user.PhoneQuery{Number: "p", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS}}}, + {Query: &user.SearchQuery_StateQuery{StateQuery: &user.StateQuery{State: user.UserState_USER_STATE_ACTIVE}}}, + {Query: &user.SearchQuery_TypeQuery{TypeQuery: &user.TypeQuery{Type: user.Type_TYPE_HUMAN}}}, + {Query: &user.SearchQuery_LoginNameQuery{LoginNameQuery: &user.LoginNameQuery{LoginName: "ln", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS}}}, + {Query: &user.SearchQuery_OrganizationIdQuery{OrganizationIdQuery: &user.OrganizationIdQuery{OrganizationId: "org"}}}, + {Query: &user.SearchQuery_InUserIdsQuery{InUserIdsQuery: &user.InUserIDQuery{UserIds: []string{"id"}}}}, + {Query: &user.SearchQuery_OrQuery{OrQuery: &user.OrQuery{Queries: []*user.SearchQuery{ + {Query: &user.SearchQuery_UserNameQuery{UserNameQuery: &user.UserNameQuery{UserName: "u", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS}}}, + {Query: &user.SearchQuery_DisplayNameQuery{DisplayNameQuery: &user.DisplayNameQuery{DisplayName: "d", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS}}}, + }}}}, + {Query: &user.SearchQuery_AndQuery{AndQuery: &user.AndQuery{Queries: []*user.SearchQuery{ + {Query: &user.SearchQuery_UserNameQuery{UserNameQuery: &user.UserNameQuery{UserName: "u", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS}}}, + {Query: &user.SearchQuery_DisplayNameQuery{DisplayNameQuery: &user.DisplayNameQuery{DisplayName: "d", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS}}}, + }}}}, + {Query: &user.SearchQuery_NotQuery{NotQuery: &user.NotQuery{Query: &user.SearchQuery{Query: &user.SearchQuery_UserNameQuery{UserNameQuery: &user.UserNameQuery{UserName: "u", Method: object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS}}}}}}, + {Query: &user.SearchQuery_InUserEmailsQuery{InUserEmailsQuery: &user.InUserEmailsQuery{UserEmails: []string{"e"}}}}, + } + for _, q := range queries { + _, err := userQueryToQuery(q, 0) + assert.NoError(t, err) + } +} diff --git a/internal/api/grpc/user/v2beta/query.go b/internal/api/grpc/user/v2beta/query.go index b9654ea97c..9ab3f35ea4 100644 --- a/internal/api/grpc/user/v2beta/query.go +++ b/internal/api/grpc/user/v2beta/query.go @@ -4,13 +4,10 @@ import ( "context" "connectrpc.com/connect" - "github.com/muhlemmer/gu" - "google.golang.org/protobuf/types/known/timestamppb" object "github.com/zitadel/zitadel/internal/api/grpc/object/v2beta" + "github.com/zitadel/zitadel/internal/api/grpc/user/v2beta/convert" "github.com/zitadel/zitadel/internal/domain" - "github.com/zitadel/zitadel/internal/query" - "github.com/zitadel/zitadel/internal/zerrors" user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) @@ -25,12 +22,12 @@ func (s *Server) GetUserByID(ctx context.Context, req *connect.Request[user.GetU EventDate: resp.ChangeDate, ResourceOwner: resp.ResourceOwner, }), - User: userToPb(resp, s.assetAPIPrefix(ctx)), + User: convert.UserToPb(resp, s.assetAPIPrefix(ctx)), }), nil } func (s *Server) ListUsers(ctx context.Context, req *connect.Request[user.ListUsersRequest]) (*connect.Response[user.ListUsersResponse], error) { - queries, err := listUsersRequestToModel(req.Msg) + queries, err := convert.ListUsersRequestToModel(req.Msg) if err != nil { return nil, err } @@ -39,300 +36,11 @@ func (s *Server) ListUsers(ctx context.Context, req *connect.Request[user.ListUs return nil, err } return connect.NewResponse(&user.ListUsersResponse{ - Result: UsersToPb(res.Users, s.assetAPIPrefix(ctx)), + Result: convert.UsersToPb(res.Users, s.assetAPIPrefix(ctx)), Details: object.ToListDetails(res.SearchResponse), }), nil } -func UsersToPb(users []*query.User, assetPrefix string) []*user.User { - u := make([]*user.User, len(users)) - for i, user := range users { - u[i] = userToPb(user, assetPrefix) - } - return u -} - -func userToPb(userQ *query.User, assetPrefix string) *user.User { - return &user.User{ - UserId: userQ.ID, - Details: object.DomainToDetailsPb(&domain.ObjectDetails{ - Sequence: userQ.Sequence, - EventDate: userQ.ChangeDate, - ResourceOwner: userQ.ResourceOwner, - }), - State: userStateToPb(userQ.State), - Username: userQ.Username, - LoginNames: userQ.LoginNames, - PreferredLoginName: userQ.PreferredLoginName, - Type: userTypeToPb(userQ, assetPrefix), - } -} - -func userTypeToPb(userQ *query.User, assetPrefix string) user.UserType { - if userQ.Human != nil { - return &user.User_Human{ - Human: humanToPb(userQ.Human, assetPrefix, userQ.ResourceOwner), - } - } - if userQ.Machine != nil { - return &user.User_Machine{ - Machine: machineToPb(userQ.Machine), - } - } - return nil -} - -func humanToPb(userQ *query.Human, assetPrefix, owner string) *user.HumanUser { - var passwordChanged *timestamppb.Timestamp - if !userQ.PasswordChanged.IsZero() { - passwordChanged = timestamppb.New(userQ.PasswordChanged) - } - return &user.HumanUser{ - Profile: &user.HumanProfile{ - GivenName: userQ.FirstName, - FamilyName: userQ.LastName, - NickName: gu.Ptr(userQ.NickName), - DisplayName: gu.Ptr(userQ.DisplayName), - PreferredLanguage: gu.Ptr(userQ.PreferredLanguage.String()), - Gender: gu.Ptr(genderToPb(userQ.Gender)), - AvatarUrl: domain.AvatarURL(assetPrefix, owner, userQ.AvatarKey), - }, - Email: &user.HumanEmail{ - Email: string(userQ.Email), - IsVerified: userQ.IsEmailVerified, - }, - Phone: &user.HumanPhone{ - Phone: string(userQ.Phone), - IsVerified: userQ.IsPhoneVerified, - }, - PasswordChangeRequired: userQ.PasswordChangeRequired, - PasswordChanged: passwordChanged, - } -} - -func machineToPb(userQ *query.Machine) *user.MachineUser { - return &user.MachineUser{ - Name: userQ.Name, - Description: userQ.Description, - HasSecret: userQ.EncodedSecret != "", - AccessTokenType: accessTokenTypeToPb(userQ.AccessTokenType), - } -} - -func userStateToPb(state domain.UserState) user.UserState { - switch state { - case domain.UserStateActive: - return user.UserState_USER_STATE_ACTIVE - case domain.UserStateInactive: - return user.UserState_USER_STATE_INACTIVE - case domain.UserStateDeleted: - return user.UserState_USER_STATE_DELETED - case domain.UserStateInitial: - return user.UserState_USER_STATE_INITIAL - case domain.UserStateLocked: - return user.UserState_USER_STATE_LOCKED - case domain.UserStateUnspecified: - return user.UserState_USER_STATE_UNSPECIFIED - case domain.UserStateSuspend: - return user.UserState_USER_STATE_UNSPECIFIED - default: - return user.UserState_USER_STATE_UNSPECIFIED - } -} - -func genderToPb(gender domain.Gender) user.Gender { - switch gender { - case domain.GenderDiverse: - return user.Gender_GENDER_DIVERSE - case domain.GenderFemale: - return user.Gender_GENDER_FEMALE - case domain.GenderMale: - return user.Gender_GENDER_MALE - case domain.GenderUnspecified: - return user.Gender_GENDER_UNSPECIFIED - default: - return user.Gender_GENDER_UNSPECIFIED - } -} - -func accessTokenTypeToPb(accessTokenType domain.OIDCTokenType) user.AccessTokenType { - switch accessTokenType { - case domain.OIDCTokenTypeBearer: - return user.AccessTokenType_ACCESS_TOKEN_TYPE_BEARER - case domain.OIDCTokenTypeJWT: - return user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT - default: - return user.AccessTokenType_ACCESS_TOKEN_TYPE_BEARER - } -} - -func listUsersRequestToModel(req *user.ListUsersRequest) (*query.UserSearchQueries, error) { - offset, limit, asc := object.ListQueryToQuery(req.Query) - queries, err := userQueriesToQuery(req.Queries, 0 /*start from level 0*/) - if err != nil { - return nil, err - } - return &query.UserSearchQueries{ - SearchRequest: query.SearchRequest{ - Offset: offset, - Limit: limit, - Asc: asc, - SortingColumn: userFieldNameToSortingColumn(req.SortingColumn), - }, - Queries: queries, - }, nil -} - -func userFieldNameToSortingColumn(field user.UserFieldName) query.Column { - switch field { - case user.UserFieldName_USER_FIELD_NAME_EMAIL: - return query.HumanEmailCol - case user.UserFieldName_USER_FIELD_NAME_FIRST_NAME: - return query.HumanFirstNameCol - case user.UserFieldName_USER_FIELD_NAME_LAST_NAME: - return query.HumanLastNameCol - case user.UserFieldName_USER_FIELD_NAME_DISPLAY_NAME: - return query.HumanDisplayNameCol - case user.UserFieldName_USER_FIELD_NAME_USER_NAME: - return query.UserUsernameCol - case user.UserFieldName_USER_FIELD_NAME_STATE: - return query.UserStateCol - case user.UserFieldName_USER_FIELD_NAME_TYPE: - return query.UserTypeCol - case user.UserFieldName_USER_FIELD_NAME_NICK_NAME: - return query.HumanNickNameCol - case user.UserFieldName_USER_FIELD_NAME_CREATION_DATE: - return query.UserCreationDateCol - case user.UserFieldName_USER_FIELD_NAME_UNSPECIFIED: - return query.UserIDCol - default: - return query.UserIDCol - } -} - -func userQueriesToQuery(queries []*user.SearchQuery, level uint8) (_ []query.SearchQuery, err error) { - q := make([]query.SearchQuery, len(queries)) - for i, query := range queries { - q[i], err = userQueryToQuery(query, level) - if err != nil { - return nil, err - } - } - return q, nil -} - -func userQueryToQuery(query *user.SearchQuery, level uint8) (query.SearchQuery, error) { - if level > 20 { - // can't go deeper than 20 levels of nesting. - return nil, zerrors.ThrowInvalidArgument(nil, "USER-zsQ97", "Errors.Query.TooManyNestingLevels") - } - switch q := query.Query.(type) { - case *user.SearchQuery_UserNameQuery: - return userNameQueryToQuery(q.UserNameQuery) - case *user.SearchQuery_FirstNameQuery: - return firstNameQueryToQuery(q.FirstNameQuery) - case *user.SearchQuery_LastNameQuery: - return lastNameQueryToQuery(q.LastNameQuery) - case *user.SearchQuery_NickNameQuery: - return nickNameQueryToQuery(q.NickNameQuery) - case *user.SearchQuery_DisplayNameQuery: - return displayNameQueryToQuery(q.DisplayNameQuery) - case *user.SearchQuery_EmailQuery: - return emailQueryToQuery(q.EmailQuery) - case *user.SearchQuery_PhoneQuery: - return phoneQueryToQuery(q.PhoneQuery) - case *user.SearchQuery_StateQuery: - return stateQueryToQuery(q.StateQuery) - case *user.SearchQuery_TypeQuery: - return typeQueryToQuery(q.TypeQuery) - case *user.SearchQuery_LoginNameQuery: - return loginNameQueryToQuery(q.LoginNameQuery) - case *user.SearchQuery_OrganizationIdQuery: - return resourceOwnerQueryToQuery(q.OrganizationIdQuery) - case *user.SearchQuery_InUserIdsQuery: - return inUserIdsQueryToQuery(q.InUserIdsQuery) - case *user.SearchQuery_OrQuery: - return orQueryToQuery(q.OrQuery, level) - case *user.SearchQuery_AndQuery: - return andQueryToQuery(q.AndQuery, level) - case *user.SearchQuery_NotQuery: - return notQueryToQuery(q.NotQuery, level) - case *user.SearchQuery_InUserEmailsQuery: - return inUserEmailsQueryToQuery(q.InUserEmailsQuery) - default: - return nil, zerrors.ThrowInvalidArgument(nil, "GRPC-vR9nC", "List.Query.Invalid") - } -} - -func userNameQueryToQuery(q *user.UserNameQuery) (query.SearchQuery, error) { - return query.NewUserUsernameSearchQuery(q.UserName, object.TextMethodToQuery(q.Method)) -} - -func firstNameQueryToQuery(q *user.FirstNameQuery) (query.SearchQuery, error) { - return query.NewUserFirstNameSearchQuery(q.FirstName, object.TextMethodToQuery(q.Method)) -} - -func lastNameQueryToQuery(q *user.LastNameQuery) (query.SearchQuery, error) { - return query.NewUserLastNameSearchQuery(q.LastName, object.TextMethodToQuery(q.Method)) -} - -func nickNameQueryToQuery(q *user.NickNameQuery) (query.SearchQuery, error) { - return query.NewUserNickNameSearchQuery(q.NickName, object.TextMethodToQuery(q.Method)) -} - -func displayNameQueryToQuery(q *user.DisplayNameQuery) (query.SearchQuery, error) { - return query.NewUserDisplayNameSearchQuery(q.DisplayName, object.TextMethodToQuery(q.Method)) -} - -func emailQueryToQuery(q *user.EmailQuery) (query.SearchQuery, error) { - return query.NewUserEmailSearchQuery(q.EmailAddress, object.TextMethodToQuery(q.Method)) -} - -func phoneQueryToQuery(q *user.PhoneQuery) (query.SearchQuery, error) { - return query.NewUserPhoneSearchQuery(q.Number, object.TextMethodToQuery(q.Method)) -} - -func stateQueryToQuery(q *user.StateQuery) (query.SearchQuery, error) { - return query.NewUserStateSearchQuery(q.State.ToDomain()) -} - -func typeQueryToQuery(q *user.TypeQuery) (query.SearchQuery, error) { - return query.NewUserTypeSearchQuery(q.Type.ToDomain()) -} - -func loginNameQueryToQuery(q *user.LoginNameQuery) (query.SearchQuery, error) { - return query.NewUserLoginNameExistsQuery(q.LoginName, object.TextMethodToQuery(q.Method)) -} - -func resourceOwnerQueryToQuery(q *user.OrganizationIdQuery) (query.SearchQuery, error) { - return query.NewUserResourceOwnerSearchQuery(q.OrganizationId, query.TextEquals) -} - -func inUserIdsQueryToQuery(q *user.InUserIDQuery) (query.SearchQuery, error) { - return query.NewUserInUserIdsSearchQuery(q.UserIds) -} -func orQueryToQuery(q *user.OrQuery, level uint8) (query.SearchQuery, error) { - mappedQueries, err := userQueriesToQuery(q.Queries, level+1) - if err != nil { - return nil, err - } - return query.NewUserOrSearchQuery(mappedQueries) -} -func andQueryToQuery(q *user.AndQuery, level uint8) (query.SearchQuery, error) { - mappedQueries, err := userQueriesToQuery(q.Queries, level+1) - if err != nil { - return nil, err - } - return query.NewUserAndSearchQuery(mappedQueries) -} -func notQueryToQuery(q *user.NotQuery, level uint8) (query.SearchQuery, error) { - mappedQuery, err := userQueryToQuery(q.Query, level+1) - if err != nil { - return nil, err - } - return query.NewUserNotSearchQuery(mappedQuery) -} - -func inUserEmailsQueryToQuery(q *user.InUserEmailsQuery) (query.SearchQuery, error) { - return query.NewUserInUserEmailsSearchQuery(q.UserEmails) +func (s *Server) ListUsersByMetadata(ctx context.Context, req *connect.Request[user.ListUsersByMetadataRequest]) (*connect.Response[user.ListUsersByMetadataResponse], error) { + return nil, nil } diff --git a/proto/zitadel/metadata/v2/metadata.proto b/proto/zitadel/metadata/v2/metadata.proto index c04548ba4e..612e939cbc 100644 --- a/proto/zitadel/metadata/v2/metadata.proto +++ b/proto/zitadel/metadata/v2/metadata.proto @@ -54,4 +54,28 @@ message MetadataKeyFilter { description: "defines which text equality method is used"; } ]; +} + +message UserByMetadataSearchFilter { + oneof filter { + option (validate.required) = true; + MetadataKeyFilter key_filter = 1; + MetadataValueFilter value_filter = 2; + } +} + +message MetadataValueFilter { + string value = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"my value\"" + description: "A value matching the metadata values" + } + ]; + zitadel.filter.v2.TextFilterMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used"; + } + ]; } \ No newline at end of file diff --git a/proto/zitadel/user/v2beta/user_service.proto b/proto/zitadel/user/v2beta/user_service.proto index bcb091abf2..1040380d41 100644 --- a/proto/zitadel/user/v2beta/user_service.proto +++ b/proto/zitadel/user/v2beta/user_service.proto @@ -11,6 +11,8 @@ import "zitadel/user/v2beta/idp.proto"; import "zitadel/user/v2beta/password.proto"; import "zitadel/user/v2beta/user.proto"; import "zitadel/user/v2beta/query.proto"; +import "zitadel/metadata/v2/metadata.proto"; +import "zitadel/filter/v2/filter.proto"; import "google/api/annotations.proto"; import "google/api/field_behavior.proto"; import "google/protobuf/duration.proto"; @@ -1065,6 +1067,35 @@ service UserService { }; }; } + + // List users by metadata + // + // Returns a list of users matching the input metadata key, value or both. + // + // Required permission: + // - `user.read` + rpc ListUsersByMetadata (ListUsersByMetadataRequest) returns (ListUsersByMetadataResponse) { + option (google.api.http) = { + post: "/v2beta/users/metadata/search" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200" + value: { + description: "The list of users matching the search criteria"; + } + }; + }; + + } } message AddHumanUserRequest{ @@ -1948,3 +1979,17 @@ enum AuthenticationMethodType { AUTHENTICATION_METHOD_TYPE_OTP_SMS = 6; AUTHENTICATION_METHOD_TYPE_OTP_EMAIL = 7; } + +message ListUsersByMetadataRequest { + repeated zitadel.metadata.v2.UserByMetadataSearchFilter filters = 1; + + // Pagination and sorting. + zitadel.filter.v2.PaginationRequest pagination = 2; +} + +message ListUsersByMetadataResponse { + repeated User users = 1; + + // Contains the total number of apps matching the query and the applied limit. + zitadel.filter.v2.PaginationResponse pagination = 2; +} \ No newline at end of file