From d9d376a2751a13fa3e26a7fa8c4de07913962d45 Mon Sep 17 00:00:00 2001 From: Stefan Benz <46600784+stebenz@users.noreply.github.com> Date: Wed, 17 Jan 2024 10:00:10 +0100 Subject: [PATCH] feat: user v2 service query (#7095) * feat: add query endpoints for user v2 api * fix: correct integration tests * fix: correct linting * fix: correct linting * fix: comment out permission check on user get and list * fix: permission check on user v2 query * fix: merge back origin/main * fix: add search query in user emails * fix: reset count for SearchUser if users are removed due to permissions * fix: reset count for SearchUser if users are removed due to permissions --------- Co-authored-by: Elio Bischof --- cmd/start/start.go | 4 +- internal/api/grpc/object/v2/converter.go | 23 + internal/api/grpc/session/v2/server.go | 12 +- internal/api/grpc/user/converter.go | 1 + internal/api/grpc/user/v2/query.go | 328 +++++++++ .../grpc/user/v2/query_integration_test.go | 627 ++++++++++++++++++ internal/api/grpc/user/v2/server.go | 21 +- .../api/grpc/user/v2/user_integration_test.go | 12 +- internal/integration/assert.go | 18 + internal/integration/client.go | 58 ++ internal/query/user.go | 24 +- internal/query/user_test.go | 125 ++++ pkg/grpc/user/v2beta/user.go | 13 + proto/zitadel/object/v2beta/object.proto | 23 + proto/zitadel/user/v2beta/email.proto | 1 - proto/zitadel/user/v2beta/query.proto | 220 ++++++ proto/zitadel/user/v2beta/user.proto | 81 ++- proto/zitadel/user/v2beta/user_service.proto | 121 +++- 18 files changed, 1667 insertions(+), 45 deletions(-) create mode 100644 internal/api/grpc/user/v2/query.go create mode 100644 internal/api/grpc/user/v2/query_integration_test.go create mode 100644 pkg/grpc/user/v2beta/user.go create mode 100644 proto/zitadel/user/v2beta/query.proto diff --git a/cmd/start/start.go b/cmd/start/start.go index 3e095eb2b3..6c4bc74e6a 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -384,10 +384,10 @@ func startAPIs( if err := apis.RegisterServer(ctx, auth.CreateServer(commands, queries, authRepo, config.SystemDefaults, keys.User, config.ExternalSecure), tlsConfig); err != nil { return err } - if err := apis.RegisterService(ctx, user_v2.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(config.ExternalSecure), idp.SAMLRootURL(config.ExternalSecure))); err != nil { + if err := apis.RegisterService(ctx, user_v2.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(config.ExternalSecure), idp.SAMLRootURL(config.ExternalSecure), assets.AssetAPI(config.ExternalSecure), permissionCheck)); err != nil { return err } - if err := apis.RegisterService(ctx, session.CreateServer(commands, queries, permissionCheck)); err != nil { + if err := apis.RegisterService(ctx, session.CreateServer(commands, queries)); err != nil { return err } diff --git a/internal/api/grpc/object/v2/converter.go b/internal/api/grpc/object/v2/converter.go index 7253c919b5..cc7e02c7fe 100644 --- a/internal/api/grpc/object/v2/converter.go +++ b/internal/api/grpc/object/v2/converter.go @@ -47,3 +47,26 @@ func ResourceOwnerFromReq(ctx context.Context, req *object.RequestContext) strin } return authz.GetCtxData(ctx).OrgID } + +func TextMethodToQuery(method object.TextQueryMethod) query.TextComparison { + switch method { + case object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS: + return query.TextEquals + case object.TextQueryMethod_TEXT_QUERY_METHOD_EQUALS_IGNORE_CASE: + return query.TextEqualsIgnoreCase + case object.TextQueryMethod_TEXT_QUERY_METHOD_STARTS_WITH: + return query.TextStartsWith + case object.TextQueryMethod_TEXT_QUERY_METHOD_STARTS_WITH_IGNORE_CASE: + return query.TextStartsWithIgnoreCase + case object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS: + return query.TextContains + case object.TextQueryMethod_TEXT_QUERY_METHOD_CONTAINS_IGNORE_CASE: + return query.TextContainsIgnoreCase + case object.TextQueryMethod_TEXT_QUERY_METHOD_ENDS_WITH: + return query.TextEndsWith + case object.TextQueryMethod_TEXT_QUERY_METHOD_ENDS_WITH_IGNORE_CASE: + return query.TextEndsWithIgnoreCase + default: + return -1 + } +} diff --git a/internal/api/grpc/session/v2/server.go b/internal/api/grpc/session/v2/server.go index 7450d6efd1..550d013ad5 100644 --- a/internal/api/grpc/session/v2/server.go +++ b/internal/api/grpc/session/v2/server.go @@ -6,7 +6,6 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/command" - "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" ) @@ -15,9 +14,8 @@ var _ session.SessionServiceServer = (*Server)(nil) type Server struct { session.UnimplementedSessionServiceServer - command *command.Commands - query *query.Queries - checkPermission domain.PermissionCheck + command *command.Commands + query *query.Queries } type Config struct{} @@ -25,12 +23,10 @@ type Config struct{} func CreateServer( command *command.Commands, query *query.Queries, - checkPermission domain.PermissionCheck, ) *Server { return &Server{ - command: command, - query: query, - checkPermission: checkPermission, + command: command, + query: query, } } diff --git a/internal/api/grpc/user/converter.go b/internal/api/grpc/user/converter.go index eca346bad8..f5381f9001 100644 --- a/internal/api/grpc/user/converter.go +++ b/internal/api/grpc/user/converter.go @@ -15,6 +15,7 @@ func UsersToPb(users []*query.User, assetPrefix string) []*user_pb.User { } return u } + func UserToPb(user *query.User, assetPrefix string) *user_pb.User { return &user_pb.User{ Id: user.ID, diff --git a/internal/api/grpc/user/v2/query.go b/internal/api/grpc/user/v2/query.go new file mode 100644 index 0000000000..95733152c7 --- /dev/null +++ b/internal/api/grpc/user/v2/query.go @@ -0,0 +1,328 @@ +package user + +import ( + "context" + + "github.com/muhlemmer/gu" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" + "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 (s *Server) GetUserByID(ctx context.Context, req *user.GetUserByIDRequest) (_ *user.GetUserByIDResponse, err error) { + ctxData := authz.GetCtxData(ctx) + if ctxData.UserID != req.GetUserId() { + if err := s.checkPermission(ctx, domain.PermissionUserRead, ctxData.OrgID, req.GetUserId()); err != nil { + return nil, err + } + } + + resp, err := s.query.GetUserByID(ctx, true, req.GetUserId()) + if err != nil { + return nil, err + } + return &user.GetUserByIDResponse{ + Details: object.DomainToDetailsPb(&domain.ObjectDetails{ + Sequence: resp.Sequence, + EventDate: resp.ChangeDate, + ResourceOwner: resp.ResourceOwner, + }), + User: userToPb(resp, s.assetAPIPrefix(ctx)), + }, nil +} + +func (s *Server) ListUsers(ctx context.Context, req *user.ListUsersRequest) (*user.ListUsersResponse, error) { + queries, err := listUsersRequestToModel(req) + if err != nil { + return nil, err + } + res, err := s.query.SearchUsers(ctx, queries) + if err != nil { + return nil, err + } + res.RemoveNoPermission(ctx, s.checkPermission) + return &user.ListUsersResponse{ + Result: 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, + 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 { + 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, + }, + } +} + +func machineToPb(userQ *query.Machine) *user.MachineUser { + return &user.MachineUser{ + Name: userQ.Name, + Description: userQ.Description, + HasSecret: userQ.Secret != nil, + 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.User.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_StateQuery: + return stateQueryToQuery(q.StateQuery) + case *user.SearchQuery_TypeQuery: + return typeQueryToQuery(q.TypeQuery) + case *user.SearchQuery_LoginNameQuery: + return loginNameQueryToQuery(q.LoginNameQuery) + case *user.SearchQuery_ResourceOwner: + return resourceOwnerQueryToQuery(q.ResourceOwner) + 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 stateQueryToQuery(q *user.StateQuery) (query.SearchQuery, error) { + return query.NewUserStateSearchQuery(int32(q.State)) +} + +func typeQueryToQuery(q *user.TypeQuery) (query.SearchQuery, error) { + return query.NewUserTypeSearchQuery(int32(q.Type)) +} + +func loginNameQueryToQuery(q *user.LoginNameQuery) (query.SearchQuery, error) { + return query.NewUserLoginNameExistsQuery(q.LoginName, object.TextMethodToQuery(q.Method)) +} + +func resourceOwnerQueryToQuery(q *user.ResourceOwnerQuery) (query.SearchQuery, error) { + return query.NewUserResourceOwnerSearchQuery(q.OrgID, 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/v2/query_integration_test.go b/internal/api/grpc/user/v2/query_integration_test.go new file mode 100644 index 0000000000..792cdf87c8 --- /dev/null +++ b/internal/api/grpc/user/v2/query_integration_test.go @@ -0,0 +1,627 @@ +//go:build integration + +package user_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/integration" + object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" + user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" +) + +func TestServer_GetUserByID(t *testing.T) { + orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("GetUserByIDOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + type args struct { + ctx context.Context + req *user.GetUserByIDRequest + dep func(ctx context.Context, username string, request *user.GetUserByIDRequest) error + } + tests := []struct { + name string + args args + want *user.GetUserByIDResponse + wantErr bool + }{ + { + name: "user by ID, no id provided", + args: args{ + IamCTX, + &user.GetUserByIDRequest{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + UserId: "", + }, + func(ctx context.Context, username string, request *user.GetUserByIDRequest) error { + return nil + }, + }, + wantErr: true, + }, + { + name: "user by ID, not found", + args: args{ + IamCTX, + &user.GetUserByIDRequest{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + UserId: "unknown", + }, + func(ctx context.Context, username string, request *user.GetUserByIDRequest) error { + return nil + }, + }, + wantErr: true, + }, + { + name: "user by ID, ok", + args: args{ + IamCTX, + &user.GetUserByIDRequest{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: Tester.Organisation.ID, + }, + }, + }, + func(ctx context.Context, username string, request *user.GetUserByIDRequest) error { + resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + request.UserId = resp.GetUserId() + return nil + }, + }, + want: &user.GetUserByIDResponse{ + User: &user.User{ + State: user.UserState_USER_STATE_ACTIVE, + Username: "", + LoginNames: nil, + PreferredLoginName: "", + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + AvatarUrl: "", + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, + Details: &object.Details{ + ChangeDate: timestamppb.Now(), + ResourceOwner: orgResp.OrganizationId, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + username := fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()) + err := tt.args.dep(tt.args.ctx, username, tt.args.req) + require.NoError(t, err) + + var got *user.GetUserByIDResponse + for { + got, err = Client.GetUserByID(tt.args.ctx, tt.args.req) + if err == nil || (tt.wantErr && err != nil) { + break + } + select { + case <-CTX.Done(): + t.Fatal(CTX.Err(), err) + case <-time.After(time.Second): + t.Log("retrying GetUserByID") + continue + } + } + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + tt.want.User.UserId = tt.args.req.GetUserId() + tt.want.User.Username = username + tt.want.User.PreferredLoginName = username + tt.want.User.LoginNames = []string{username} + if human := tt.want.User.GetHuman(); human != nil { + human.Email.Email = username + } + require.Equal(t, tt.want.User, got.User) + integration.AssertDetails(t, tt.want, got) + } + }) + } +} + +type userAttr struct { + UserID string + Username string +} + +func TestServer_ListUsers(t *testing.T) { + orgResp := Tester.CreateOrganization(IamCTX, fmt.Sprintf("ListUsersOrg%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano())) + userResp := Tester.CreateHumanUserVerified(IamCTX, orgResp.OrganizationId, fmt.Sprintf("%d@listusers.com", time.Now().UnixNano())) + type args struct { + ctx context.Context + count int + req *user.ListUsersRequest + dep func(ctx context.Context, org string, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) + } + tests := []struct { + name string + args args + want *user.ListUsersResponse + wantErr bool + }{ + { + name: "list user by id, no permission", + args: args{ + UserCTX, + 0, + &user.ListUsersRequest{}, + func(ctx context.Context, org string, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + request.Queries = append(request.Queries, InUserIDsQuery([]string{userResp.UserId})) + return []userAttr{}, nil + }, + }, + want: &user.ListUsersResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{}, + }, + }, + { + name: "list user by id, ok", + args: args{ + IamCTX, + 1, + &user.ListUsersRequest{}, + func(ctx context.Context, org string, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + infos := make([]userAttr, len(usernames)) + userIDs := make([]string, len(usernames)) + for i, username := range usernames { + resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + userIDs[i] = resp.GetUserId() + infos[i] = userAttr{resp.GetUserId(), username} + } + request.Queries = append(request.Queries, InUserIDsQuery(userIDs)) + return infos, nil + }, + }, + want: &user.ListUsersResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, + }, + }, + }, + { + name: "list user by id multiple, ok", + args: args{ + IamCTX, + 3, + &user.ListUsersRequest{}, + func(ctx context.Context, org string, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + infos := make([]userAttr, len(usernames)) + userIDs := make([]string, len(usernames)) + for i, username := range usernames { + resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + userIDs[i] = resp.GetUserId() + infos[i] = userAttr{resp.GetUserId(), username} + } + request.Queries = append(request.Queries, InUserIDsQuery(userIDs)) + return infos, nil + }, + }, + want: &user.ListUsersResponse{ + Details: &object.ListDetails{ + TotalResult: 3, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, + }, + }, + }, + { + name: "list user by username, ok", + args: args{ + IamCTX, + 1, + &user.ListUsersRequest{}, + func(ctx context.Context, org string, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + infos := make([]userAttr, len(usernames)) + userIDs := make([]string, len(usernames)) + for i, username := range usernames { + resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + userIDs[i] = resp.GetUserId() + infos[i] = userAttr{resp.GetUserId(), username} + request.Queries = append(request.Queries, UsernameQuery(username)) + } + return infos, nil + }, + }, + want: &user.ListUsersResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, + }, + }, + }, + { + name: "list user in emails, ok", + args: args{ + IamCTX, + 1, + &user.ListUsersRequest{}, + func(ctx context.Context, org string, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + infos := make([]userAttr, len(usernames)) + for i, username := range usernames { + resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + infos[i] = userAttr{resp.GetUserId(), username} + } + request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) + return infos, nil + }, + }, + want: &user.ListUsersResponse{ + Details: &object.ListDetails{ + TotalResult: 1, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, + }, + }, + }, + { + name: "list user in emails multiple, ok", + args: args{ + IamCTX, + 3, + &user.ListUsersRequest{}, + func(ctx context.Context, org string, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + infos := make([]userAttr, len(usernames)) + for i, username := range usernames { + resp := Tester.CreateHumanUserVerified(ctx, orgResp.OrganizationId, username) + infos[i] = userAttr{resp.GetUserId(), username} + } + request.Queries = append(request.Queries, InUserEmailsQuery(usernames)) + return infos, nil + }, + }, + want: &user.ListUsersResponse{ + Details: &object.ListDetails{ + TotalResult: 3, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{ + { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, { + State: user.UserState_USER_STATE_ACTIVE, + Type: &user.User_Human{ + Human: &user.HumanUser{ + Profile: &user.HumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + DisplayName: gu.Ptr("Mickey Mouse"), + PreferredLanguage: gu.Ptr("nl"), + Gender: user.Gender_GENDER_MALE.Enum(), + }, + Email: &user.HumanEmail{ + IsVerified: true, + }, + Phone: &user.HumanPhone{ + Phone: "+41791234567", + IsVerified: true, + }, + }, + }, + }, + }, + }, + }, + { + name: "list user in emails no found, ok", + args: args{ + IamCTX, + 3, + &user.ListUsersRequest{Queries: []*user.SearchQuery{ + InUserEmailsQuery([]string{"notfound"}), + }, + }, + func(ctx context.Context, org string, usernames []string, request *user.ListUsersRequest) ([]userAttr, error) { + return []userAttr{}, nil + }, + }, + want: &user.ListUsersResponse{ + Details: &object.ListDetails{ + TotalResult: 0, + Timestamp: timestamppb.Now(), + }, + SortingColumn: 0, + Result: []*user.User{}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + usernames := make([]string, tt.args.count) + for i := 0; i < tt.args.count; i++ { + usernames[i] = fmt.Sprintf("%d%d@mouse.com", time.Now().UnixNano(), i) + } + infos, err := tt.args.dep(tt.args.ctx, orgResp.OrganizationId, usernames, tt.args.req) + require.NoError(t, err) + + var got *user.ListUsersResponse + for { + got, err = Client.ListUsers(tt.args.ctx, tt.args.req) + if err == nil || (tt.wantErr && err != nil) { + break + } + select { + case <-CTX.Done(): + t.Fatal(tt.args.ctx.Err(), err) + case <-time.After(time.Second): + t.Log("retrying ListUsers") + continue + } + } + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + + // always only give back dependency infos which are required for the response + require.Len(t, tt.want.Result, len(infos)) + // always first check length, otherwise its failed anyway + require.Len(t, got.Result, len(tt.want.Result)) + // fill in userid and username as it is generated + for i := range infos { + tt.want.Result[i].UserId = infos[i].UserID + tt.want.Result[i].Username = infos[i].Username + tt.want.Result[i].PreferredLoginName = infos[i].Username + tt.want.Result[i].LoginNames = []string{infos[i].Username} + if human := tt.want.Result[i].GetHuman(); human != nil { + human.Email.Email = infos[i].Username + } + } + for i := range tt.want.Result { + require.Contains(t, got.Result, tt.want.Result[i]) + } + integration.AssertListDetails(t, tt.want, got) + } + }) + } +} + +func InUserIDsQuery(ids []string) *user.SearchQuery { + return &user.SearchQuery{Query: &user.SearchQuery_InUserIdsQuery{ + InUserIdsQuery: &user.InUserIDQuery{ + UserIds: ids, + }, + }, + } +} + +func InUserEmailsQuery(emails []string) *user.SearchQuery { + return &user.SearchQuery{Query: &user.SearchQuery_InUserEmailsQuery{ + InUserEmailsQuery: &user.InUserEmailsQuery{ + UserEmails: emails, + }, + }, + } +} + +func UsernameQuery(username string) *user.SearchQuery { + return &user.SearchQuery{Query: &user.SearchQuery_UserNameQuery{ + UserNameQuery: &user.UserNameQuery{ + UserName: username, + }, + }, + } +} diff --git a/internal/api/grpc/user/v2/server.go b/internal/api/grpc/user/v2/server.go index 8e0f24d5dc..93af47f58b 100644 --- a/internal/api/grpc/user/v2/server.go +++ b/internal/api/grpc/user/v2/server.go @@ -9,6 +9,7 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/crypto" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) @@ -23,6 +24,10 @@ type Server struct { idpAlg crypto.EncryptionAlgorithm idpCallback func(ctx context.Context) string samlRootURL func(ctx context.Context, idpID string) string + + assetAPIPrefix func(context.Context) string + + checkPermission domain.PermissionCheck } type Config struct{} @@ -34,14 +39,18 @@ func CreateServer( idpAlg crypto.EncryptionAlgorithm, idpCallback func(ctx context.Context) string, samlRootURL func(ctx context.Context, idpID string) string, + assetAPIPrefix func(ctx context.Context) string, + checkPermission domain.PermissionCheck, ) *Server { return &Server{ - command: command, - query: query, - userCodeAlg: userCodeAlg, - idpAlg: idpAlg, - idpCallback: idpCallback, - samlRootURL: samlRootURL, + command: command, + query: query, + userCodeAlg: userCodeAlg, + idpAlg: idpAlg, + idpCallback: idpCallback, + samlRootURL: samlRootURL, + assetAPIPrefix: assetAPIPrefix, + checkPermission: checkPermission, } } diff --git a/internal/api/grpc/user/v2/user_integration_test.go b/internal/api/grpc/user/v2/user_integration_test.go index 56dfc47f38..d35261f87c 100644 --- a/internal/api/grpc/user/v2/user_integration_test.go +++ b/internal/api/grpc/user/v2/user_integration_test.go @@ -27,10 +27,12 @@ import ( ) var ( - CTX context.Context - ErrCTX context.Context - Tester *integration.Tester - Client user.UserServiceClient + CTX context.Context + IamCTX context.Context + UserCTX context.Context + ErrCTX context.Context + Tester *integration.Tester + Client user.UserServiceClient ) func TestMain(m *testing.M) { @@ -41,6 +43,8 @@ func TestMain(m *testing.M) { Tester = integration.NewTester(ctx) defer Tester.Done() + UserCTX = Tester.WithAuthorization(ctx, integration.Login) + IamCTX = Tester.WithAuthorization(ctx, integration.IAMOwner) CTX, ErrCTX = Tester.WithAuthorization(ctx, integration.OrgOwner), errCtx Client = Tester.Client.UserV2 return m.Run() diff --git a/internal/integration/assert.go b/internal/integration/assert.go index 3638c831a3..82bb731685 100644 --- a/internal/integration/assert.go +++ b/internal/integration/assert.go @@ -13,6 +13,10 @@ type DetailsMsg interface { GetDetails() *object.Details } +type ListDetailsMsg interface { + GetDetails() *object.ListDetails +} + // AssertDetails asserts values in a message's object Details, // if the object Details in expected is a non-nil value. // It targets API v2 messages that have the `GetDetails()` method. @@ -39,3 +43,17 @@ func AssertDetails[D DetailsMsg](t testing.TB, expected, actual D) { assert.Equal(t, wantDetails.GetResourceOwner(), gotDetails.GetResourceOwner()) } + +func AssertListDetails[D ListDetailsMsg](t testing.TB, expected, actual D) { + wantDetails, gotDetails := expected.GetDetails(), actual.GetDetails() + if wantDetails == nil { + assert.Nil(t, gotDetails) + return + } + + gotCD := gotDetails.GetTimestamp().AsTime() + now := time.Now() + assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) + + assert.Equal(t, wantDetails.GetTotalResult(), gotDetails.GetTotalResult()) +} diff --git a/internal/integration/client.go b/internal/integration/client.go index 6c4498344e..5465560b1e 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -29,6 +29,7 @@ import ( mgmt "github.com/zitadel/zitadel/pkg/grpc/management" object "github.com/zitadel/zitadel/pkg/grpc/object/v2beta" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" + org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" organisation "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" "github.com/zitadel/zitadel/pkg/grpc/system" @@ -136,6 +137,63 @@ func (s *Tester) CreateHumanUser(ctx context.Context) *user.AddHumanUserResponse return resp } +func (s *Tester) CreateOrganization(ctx context.Context, name, adminEmail string) *org.AddOrganizationResponse { + resp, err := s.Client.OrgV2.AddOrganization(ctx, &org.AddOrganizationRequest{ + Name: name, + Admins: []*org.AddOrganizationRequest_Admin{ + { + UserType: &org.AddOrganizationRequest_Admin_Human{ + Human: &user.AddHumanUserRequest{ + Profile: &user.SetHumanProfile{ + GivenName: "firstname", + FamilyName: "lastname", + }, + Email: &user.SetHumanEmail{ + Email: adminEmail, + Verification: &user.SetHumanEmail_ReturnCode{ + ReturnCode: &user.ReturnEmailVerificationCode{}, + }, + }, + }, + }, + }, + }, + }) + logging.OnError(err).Fatal("create org") + return resp +} + +func (s *Tester) CreateHumanUserVerified(ctx context.Context, org, email string) *user.AddHumanUserResponse { + resp, err := s.Client.UserV2.AddHumanUser(ctx, &user.AddHumanUserRequest{ + Organization: &object.Organization{ + Org: &object.Organization_OrgId{ + OrgId: org, + }, + }, + Profile: &user.SetHumanProfile{ + GivenName: "Mickey", + FamilyName: "Mouse", + NickName: gu.Ptr("Mickey"), + PreferredLanguage: gu.Ptr("nl"), + Gender: gu.Ptr(user.Gender_GENDER_MALE), + }, + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_IsVerified{ + IsVerified: true, + }, + }, + Phone: &user.SetHumanPhone{ + Phone: "+41791234567", + Verification: &user.SetHumanPhone_IsVerified{ + IsVerified: true, + }, + }, + }) + logging.OnError(err).Fatal("create human user") + return resp +} + func (s *Tester) CreateMachineUser(ctx context.Context) *mgmt.AddMachineUserResponse { resp, err := s.Client.Mgmt.AddMachineUser(ctx, &mgmt.AddMachineUserRequest{ UserName: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()), diff --git a/internal/query/user.go b/internal/query/user.go index 350189bd33..0edd8deb34 100644 --- a/internal/query/user.go +++ b/internal/query/user.go @@ -122,6 +122,29 @@ type NotifyUser struct { PasswordSet bool } +func (u *Users) RemoveNoPermission(ctx context.Context, permissionCheck domain.PermissionCheck) { + removableIndexes := make([]int, 0) + for i := range u.Users { + ctxData := authz.GetCtxData(ctx) + if ctxData.UserID != u.Users[i].ID { + if err := permissionCheck(ctx, domain.PermissionUserRead, ctxData.OrgID, u.Users[i].ID); err != nil { + removableIndexes = append(removableIndexes, i) + } + } + } + removed := 0 + for _, removeIndex := range removableIndexes { + u.Users = removeUser(u.Users, removeIndex-removed) + removed++ + } + // reset count as some users could be removed + u.SearchResponse.Count = uint64(len(u.Users)) +} + +func removeUser(slice []*User, s int) []*User { + return append(slice[:s], slice[s+1:]...) +} + type UserSearchQueries struct { SearchRequest Queries []SearchQuery @@ -579,7 +602,6 @@ func (q *Queries) SearchUsers(ctx context.Context, queries *UserSearchQueries) ( if err != nil { return nil, zerrors.ThrowInternal(err, "QUERY-AG4gs", "Errors.Internal") } - users.State, err = q.latestState(ctx, userTable) return users, err } diff --git a/internal/query/user_test.go b/internal/query/user_test.go index 35cd2730ee..ff3c98faa4 100644 --- a/internal/query/user_test.go +++ b/internal/query/user_test.go @@ -1,6 +1,7 @@ package query import ( + "context" "database/sql" "database/sql/driver" "errors" @@ -8,6 +9,7 @@ import ( "regexp" "testing" + "github.com/stretchr/testify/require" "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/crypto" @@ -16,6 +18,129 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) +func Test_RemoveNoPermission(t *testing.T) { + type want struct { + users []*User + } + tests := []struct { + name string + want want + users *Users + permissions []string + }{ + { + "permissions for all users", + want{ + users: []*User{ + {ID: "first"}, {ID: "second"}, {ID: "third"}, + }, + }, + &Users{ + Users: []*User{ + {ID: "first"}, {ID: "second"}, {ID: "third"}, + }, + }, + []string{"first", "second", "third"}, + }, + { + "permissions for one user, first", + want{ + users: []*User{ + {ID: "first"}, + }, + }, + &Users{ + Users: []*User{ + {ID: "first"}, {ID: "second"}, {ID: "third"}, + }, + }, + []string{"first"}, + }, + { + "permissions for one user, second", + want{ + users: []*User{ + {ID: "second"}, + }, + }, + &Users{ + Users: []*User{ + {ID: "first"}, {ID: "second"}, {ID: "third"}, + }, + }, + []string{"second"}, + }, + { + "permissions for one user, third", + want{ + users: []*User{ + {ID: "third"}, + }, + }, + &Users{ + Users: []*User{ + {ID: "first"}, {ID: "second"}, {ID: "third"}, + }, + }, + []string{"third"}, + }, + { + "permissions for two users, first", + want{ + users: []*User{ + {ID: "first"}, {ID: "third"}, + }, + }, + &Users{ + Users: []*User{ + {ID: "first"}, {ID: "second"}, {ID: "third"}, + }, + }, + []string{"first", "third"}, + }, + { + "permissions for two users, second", + want{ + users: []*User{ + {ID: "second"}, {ID: "third"}, + }, + }, + &Users{ + Users: []*User{ + {ID: "first"}, {ID: "second"}, {ID: "third"}, + }, + }, + []string{"second", "third"}, + }, + { + "no permissions", + want{ + users: []*User{}, + }, + &Users{ + Users: []*User{ + {ID: "first"}, {ID: "second"}, {ID: "third"}, + }, + }, + []string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkPermission := func(ctx context.Context, permission, orgID, resourceID string) (err error) { + for _, perm := range tt.permissions { + if resourceID == perm { + return nil + } + } + return errors.New("failed") + } + tt.users.RemoveNoPermission(context.Background(), checkPermission) + require.Equal(t, tt.want.users, tt.users.Users) + }) + } +} + var ( loginNamesQuery = `SELECT login_names.user_id, ARRAY_AGG(login_names.login_name)::TEXT[] AS loginnames, ARRAY_AGG(LOWER(login_names.login_name))::TEXT[] AS loginnames_lower, login_names.instance_id` + ` FROM projections.login_names3 AS login_names` + diff --git a/pkg/grpc/user/v2beta/user.go b/pkg/grpc/user/v2beta/user.go new file mode 100644 index 0000000000..8309a57f0a --- /dev/null +++ b/pkg/grpc/user/v2beta/user.go @@ -0,0 +1,13 @@ +package user + +type SearchQuery_ResourceOwner struct { + ResourceOwner *ResourceOwnerQuery +} + +func (SearchQuery_ResourceOwner) isSearchQuery_Query() {} + +type ResourceOwnerQuery struct { + OrgID string +} + +type UserType = isUser_Type diff --git a/proto/zitadel/object/v2beta/object.proto b/proto/zitadel/object/v2beta/object.proto index c7c68f31ce..df8e77319a 100644 --- a/proto/zitadel/object/v2beta/object.proto +++ b/proto/zitadel/object/v2beta/object.proto @@ -97,3 +97,26 @@ message ListDetails { } ]; } + +enum TextQueryMethod { + TEXT_QUERY_METHOD_EQUALS = 0; + TEXT_QUERY_METHOD_EQUALS_IGNORE_CASE = 1; + TEXT_QUERY_METHOD_STARTS_WITH = 2; + TEXT_QUERY_METHOD_STARTS_WITH_IGNORE_CASE = 3; + TEXT_QUERY_METHOD_CONTAINS = 4; + TEXT_QUERY_METHOD_CONTAINS_IGNORE_CASE = 5; + TEXT_QUERY_METHOD_ENDS_WITH = 6; + TEXT_QUERY_METHOD_ENDS_WITH_IGNORE_CASE = 7; +} + +enum ListQueryMethod { + LIST_QUERY_METHOD_IN = 0; +} + +enum TimestampQueryMethod { + TIMESTAMP_QUERY_METHOD_EQUALS = 0; + TIMESTAMP_QUERY_METHOD_GREATER = 1; + TIMESTAMP_QUERY_METHOD_GREATER_OR_EQUALS = 2; + TIMESTAMP_QUERY_METHOD_LESS = 3; + TIMESTAMP_QUERY_METHOD_LESS_OR_EQUALS = 4; +} \ No newline at end of file diff --git a/proto/zitadel/user/v2beta/email.proto b/proto/zitadel/user/v2beta/email.proto index 8a76a2eb0d..33473f3168 100644 --- a/proto/zitadel/user/v2beta/email.proto +++ b/proto/zitadel/user/v2beta/email.proto @@ -38,7 +38,6 @@ message HumanEmail { bool is_verified = 2; } - message SendEmailVerificationCode { optional string url_template = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, diff --git a/proto/zitadel/user/v2beta/query.proto b/proto/zitadel/user/v2beta/query.proto new file mode 100644 index 0000000000..b6db2bd532 --- /dev/null +++ b/proto/zitadel/user/v2beta/query.proto @@ -0,0 +1,220 @@ +syntax = "proto3"; + +package zitadel.user.v2beta; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v2beta;user"; + +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; +import "zitadel/user/v2beta/user.proto"; +import "zitadel/object/v2beta/object.proto"; + +message SearchQuery { + oneof query { + option (validate.required) = true; + + UserNameQuery user_name_query = 1; + FirstNameQuery first_name_query = 2; + LastNameQuery last_name_query = 3; + NickNameQuery nick_name_query = 4; + DisplayNameQuery display_name_query = 5; + EmailQuery email_query = 6; + StateQuery state_query = 7; + TypeQuery type_query = 8; + LoginNameQuery login_name_query = 9; + InUserIDQuery in_user_ids_query = 10; + OrQuery or_query = 11; + AndQuery and_query = 12; + NotQuery not_query = 13; + InUserEmailsQuery in_user_emails_query = 14; + } +} + +message OrQuery { + repeated SearchQuery queries = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the sub queries to 'OR'" + } + ]; +} +message AndQuery { + repeated SearchQuery queries = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the sub queries to 'AND'" + } + ]; +} + +message NotQuery { + SearchQuery query = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the sub query to negate (NOT)" + } + ]; +} + +message InUserIDQuery { + repeated string user_ids = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the ids of the users to include" + example: "[\"69629023906488334\",\"69622366012355662\"]"; + } + ]; +} + +message UserNameQuery { + string user_name = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"gigi-giraffe\""; + } + ]; + zitadel.object.v2beta.TextQueryMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used"; + } + ]; +} + +message FirstNameQuery { + string first_name = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"Gigi\""; + } + ]; + zitadel.object.v2beta.TextQueryMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used"; + } + ]; +} + +message LastNameQuery { + string last_name = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"Giraffe\""; + } + ]; + zitadel.object.v2beta.TextQueryMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used"; + } + ]; +} + +message NickNameQuery { + string nick_name = 1 [(validate.rules).string = {max_len: 200}]; + zitadel.object.v2beta.TextQueryMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used"; + } + ]; +} + +message DisplayNameQuery { + string display_name = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"Gigi Giraffe\""; + } + ]; + zitadel.object.v2beta.TextQueryMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used"; + } + ]; +} + +message EmailQuery { + string email_address = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "email address of the user. (spec: https://tools.ietf.org/html/rfc2822#section-3.4.1)" + max_length: 200; + example: "\"gigi@zitadel.com\""; + } + ]; + zitadel.object.v2beta.TextQueryMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used"; + } + ]; +} + +message LoginNameQuery { + string login_name = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"gigi@zitadel.cloud\""; + } + ]; + zitadel.object.v2beta.TextQueryMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used"; + } + ]; +} + +//UserStateQuery always equals +message StateQuery { + UserState state = 1 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "current state of the user"; + } + ]; +} + +//UserTypeQuery always equals +message TypeQuery { + Type type = 1 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the type of the user"; + } + ]; +} + +message InUserEmailsQuery { + repeated string user_emails = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "the emails of the users to include" + example: "[\"test@example.com\",\"test@example.org\"]"; + } + ]; +} + +enum Type { + TYPE_UNSPECIFIED = 0; + TYPE_HUMAN = 1; + TYPE_MACHINE = 2; +} + +enum UserFieldName { + USER_FIELD_NAME_UNSPECIFIED = 0; + USER_FIELD_NAME_USER_NAME = 1; + USER_FIELD_NAME_FIRST_NAME = 2; + USER_FIELD_NAME_LAST_NAME = 3; + USER_FIELD_NAME_NICK_NAME = 4; + USER_FIELD_NAME_DISPLAY_NAME = 5; + USER_FIELD_NAME_EMAIL = 6; + USER_FIELD_NAME_STATE = 7; + USER_FIELD_NAME_TYPE = 8; + USER_FIELD_NAME_CREATION_DATE = 9; +} diff --git a/proto/zitadel/user/v2beta/user.proto b/proto/zitadel/user/v2beta/user.proto index 57482a23dd..9443dd5117 100644 --- a/proto/zitadel/user/v2beta/user.proto +++ b/proto/zitadel/user/v2beta/user.proto @@ -58,7 +58,7 @@ message SetHumanProfile { example: "\"en\""; } ]; - optional zitadel.user.v2beta.Gender gender = 6 [ + optional Gender gender = 6 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "\"GENDER_FEMALE\""; } @@ -98,11 +98,17 @@ message HumanProfile { example: "\"en\""; } ]; - optional zitadel.user.v2beta.Gender gender = 6 [ + optional Gender gender = 6 [ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "\"GENDER_FEMALE\""; } ]; + string avatar_url = 7 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "avatar URL of the user" + example: "\"https://api.zitadel.ch/assets/v1/avatar-32432jkh4kj32\""; + } + ]; } message SetMetadataEntry { @@ -158,12 +164,79 @@ message HumanUser { HumanPhone phone = 8; } +message User { + string user_id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\""; + } + ]; + UserState state = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "current state of the user"; + } + ]; + string username = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"minnie-mouse\""; + } + ]; + repeated string login_names = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[\"gigi@zitadel.com\", \"gigi@zitadel.zitadel.ch\"]"; + } + ]; + string preferred_login_name = 5 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"gigi@zitadel.com\""; + } + ]; + oneof type { + HumanUser human = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "one of type use human or machine" + } + ]; + MachineUser machine = 7 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "one of type use human or machine" + } + ]; + } +} + +message MachineUser { + string name = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"zitadel\""; + } + ]; + string description = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"The one and only IAM\""; + } + ]; + bool has_secret = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"true\""; + } + ]; + AccessTokenType access_token_type = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "Type of access token to receive"; + } + ]; +} + +enum AccessTokenType { + ACCESS_TOKEN_TYPE_BEARER = 0; + ACCESS_TOKEN_TYPE_JWT = 1; +} + enum UserState { USER_STATE_UNSPECIFIED = 0; USER_STATE_ACTIVE = 1; USER_STATE_INACTIVE = 2; USER_STATE_DELETED = 3; USER_STATE_LOCKED = 4; - USER_STATE_SUSPEND = 5; - USER_STATE_INITIAL = 6; + USER_STATE_INITIAL = 5; } \ 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 0a34f7c558..82821734cb 100644 --- a/proto/zitadel/user/v2beta/user_service.proto +++ b/proto/zitadel/user/v2beta/user_service.proto @@ -10,6 +10,7 @@ import "zitadel/user/v2beta/phone.proto"; 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 "google/api/annotations.proto"; import "google/api/field_behavior.proto"; import "google/protobuf/duration.proto"; @@ -138,6 +139,73 @@ service UserService { }; } + + rpc GetUserByID(GetUserByIDRequest) returns (GetUserByIDResponse) { + option (google.api.http) = { + get: "/v2beta/users/{user_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + http_response: { + success_code: 200 + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + summary: "User by ID"; + description: "Returns the full user object (human or machine) including the profile, email, etc." + tags: "Users"; + responses: { + key: "200" + value: { + description: "OK"; + } + }; + }; + } + + rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) { + option (google.api.http) = { + post: "/v2beta/users" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + http_response: { + success_code: 200 + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + tags: "Users"; + summary: "Search Users"; + description: "Search for users. By default, we will return users of your organization. Make sure to include a limit and sorting for pagination." + responses: { + key: "200"; + value: { + description: "A list of all users matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + }; + }; + }; + } + // Change the email of a user rpc SetEmail (SetEmailRequest) returns (SetEmailResponse) { option (google.api.http) = { @@ -368,7 +436,6 @@ service UserService { } }; - option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { summary: "Delete user"; description: "The state of the user will be changed to 'deleted'. The user will not be able to log in anymore. Endpoints requesting this user will return an error 'User not found" @@ -829,6 +896,40 @@ message AddHumanUserResponse { optional string phone_code = 4; } +message GetUserByIDRequest { + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + description: "User ID of the user you like to get." + } + ]; + zitadel.object.v2beta.Organization organization = 2; +} + +message GetUserByIDResponse { + zitadel.object.v2beta.Details details = 1; + zitadel.user.v2beta.User user = 2; +} + +message ListUsersRequest { + //list limitations and ordering + zitadel.object.v2beta.ListQuery query = 1; + // the field the result is sorted + zitadel.user.v2beta.UserFieldName sorting_column = 2; + //criteria the client is looking for + repeated zitadel.user.v2beta.SearchQuery queries = 3; +} + +message ListUsersResponse { + zitadel.object.v2beta.ListDetails details = 1; + zitadel.user.v2beta.UserFieldName sorting_column = 2; + repeated zitadel.user.v2beta.User result = 3; +} + message SetEmailRequest{ string user_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, @@ -962,24 +1063,6 @@ message DeleteUserResponse { zitadel.object.v2beta.Details details = 1; } -message GetUserByIDRequest { - string user_id = 1 [ - (validate.rules).string = {min_len: 1, max_len: 200}, - (google.api.field_behavior) = REQUIRED, - (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { - min_length: 1; - max_length: 200; - example: "\"69629012906488334\""; - description: "User ID of the user you like to get." - } - ]; -} - -message GetUserByIDResponse { - zitadel.object.v2beta.Details details = 1; - HumanUser user = 2; -} - message UpdateHumanUserRequest{ string user_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200},