From 0fad770d8a5e4a07f653f58a4c1a3877b10ff360 Mon Sep 17 00:00:00 2001 From: Todd Date: Wed, 20 Dec 2023 16:51:57 -0800 Subject: [PATCH] Paginate iam user (#4185) * paginate iam user --- go.sum | 2 - internal/daemon/controller/handler.go | 2 +- .../controller/handlers/users/user_service.go | 172 +++-- .../handlers/users/user_service_test.go | 483 ++++++++++++-- .../oss/postgres/80/08_iam_updates.up.sql | 8 +- .../db/sqltest/tests/pagination/iam_user.sql | 12 + internal/gen/controller.swagger.json | 43 ++ .../api/services/user_service.pb.go | 447 ++++++++----- internal/iam/query.go | 4 + internal/iam/repository_role_test.go | 3 +- internal/iam/repository_user.go | 191 ++++-- internal/iam/repository_user_iam_pkg_test.go | 268 ++++++++ internal/iam/repository_user_test.go | 16 +- internal/iam/service_list.go | 41 ++ internal/iam/service_list_ext_test.go | 627 ++++++++++++++++++ internal/iam/service_list_page.go | 56 ++ internal/iam/service_list_refresh.go | 61 ++ internal/iam/service_list_refresh_page.go | 68 ++ internal/iam/testing.go | 4 + internal/iam/user.go | 18 + .../api/services/v1/user_service.proto | 37 ++ 21 files changed, 2233 insertions(+), 330 deletions(-) create mode 100644 internal/db/sqltest/tests/pagination/iam_user.sql diff --git a/go.sum b/go.sum index 7a78d07ea0..737f2dcb17 100644 --- a/go.sum +++ b/go.sum @@ -321,8 +321,6 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/mql v0.1.2 h1:Hw1FkOltYvAH13K2VTGVUZWUU1OU/vSo+b4uzRBUePs= -github.com/hashicorp/mql v0.1.2/go.mod h1:pSghNOmPNBD9/Bvx0M4EC2JB/mEVA6qlB9IKpNCiqOA= github.com/hashicorp/mql v0.1.3 h1:SZdOsocDPovwp3Q5AzoH6s000BD5zcr+hV8xAobOvuo= github.com/hashicorp/mql v0.1.3/go.mod h1:CrbXH2f2ndS1X35x0E8aHdNYc3POYrEWpx/1Q+pq+iw= github.com/hashicorp/nodeenrollment v0.2.9 h1:pFdMjd6VqDpqWtzr2+p//7DHKKPwxmbNoENCKCOYuvs= diff --git a/internal/daemon/controller/handler.go b/internal/daemon/controller/handler.go index b711f87bc2..aa4d4270fc 100644 --- a/internal/daemon/controller/handler.go +++ b/internal/daemon/controller/handler.go @@ -185,7 +185,7 @@ func (c *Controller) registerGrpcServices(s *grpc.Server) error { services.RegisterScopeServiceServer(s, os) } if _, ok := currentServices[services.UserService_ServiceDesc.ServiceName]; !ok { - us, err := users.NewService(c.baseContext, c.IamRepoFn) + us, err := users.NewService(c.baseContext, c.IamRepoFn, c.conf.RawConfig.Controller.MaxPageSize) if err != nil { return fmt.Errorf("failed to create user handler service: %w", err) } diff --git a/internal/daemon/controller/handlers/users/user_service.go b/internal/daemon/controller/handlers/users/user_service.go index f66b175c4f..fc1ec3b211 100644 --- a/internal/daemon/controller/handlers/users/user_service.go +++ b/internal/daemon/controller/handlers/users/user_service.go @@ -16,11 +16,14 @@ import ( pbs "github.com/hashicorp/boundary/internal/gen/controller/api/services" "github.com/hashicorp/boundary/internal/iam" "github.com/hashicorp/boundary/internal/iam/store" + "github.com/hashicorp/boundary/internal/listtoken" + "github.com/hashicorp/boundary/internal/pagination" "github.com/hashicorp/boundary/internal/perms" "github.com/hashicorp/boundary/internal/requests" "github.com/hashicorp/boundary/internal/types/action" "github.com/hashicorp/boundary/internal/types/resource" "github.com/hashicorp/boundary/internal/types/scope" + "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/scopes" pb "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/users" "github.com/hashicorp/go-secure-stdlib/strutil" "google.golang.org/grpc/codes" @@ -67,22 +70,27 @@ func init() { type Service struct { pbs.UnsafeUserServiceServer - repoFn common.IamRepoFactory + repoFn common.IamRepoFactory + maxPageSize uint } var _ pbs.UserServiceServer = (*Service)(nil) // NewService returns a user service which handles user related requests to boundary. -func NewService(ctx context.Context, repo common.IamRepoFactory) (Service, error) { +func NewService(ctx context.Context, repo common.IamRepoFactory, maxPageSize uint) (Service, error) { const op = "users.NewService" if repo == nil { return Service{}, errors.New(ctx, errors.InvalidParameter, op, "missing iam repository") } - return Service{repoFn: repo}, nil + if maxPageSize == 0 { + maxPageSize = uint(globals.DefaultMaxPageSize) + } + return Service{repoFn: repo, maxPageSize: maxPageSize}, nil } // ListUsers implements the interface pbs.UserServiceServer. func (s Service) ListUsers(ctx context.Context, req *pbs.ListUsersRequest) (*pbs.ListUsersResponse, error) { + const op = "users.(Service).ListUsers" if err := validateListRequest(ctx, req); err != nil { return nil, err } @@ -110,50 +118,115 @@ func (s Service) ListUsers(ctx context.Context, req *pbs.ListUsersRequest) (*pbs return &pbs.ListUsersResponse{}, nil } - ul, err := s.listFromRepo(ctx, scopeIds) - if err != nil { - return nil, err + pageSize := int(s.maxPageSize) + // Use the requested page size only if it is smaller than + // the configured max. + if req.GetPageSize() != 0 && uint(req.GetPageSize()) < s.maxPageSize { + pageSize = int(req.GetPageSize()) } - if len(ul) == 0 { - return &pbs.ListUsersResponse{}, nil + + var filterItemFn func(ctx context.Context, item *iam.User) (bool, error) + switch { + case req.GetFilter() != "": + // Only use a filter if we need to + filter, err := handlers.NewFilter(ctx, req.GetFilter()) + if err != nil { + return nil, err + } + filterItemFn = func(ctx context.Context, item *iam.User) (bool, error) { + outputOpts, ok := newOutputOpts(ctx, item, scopeInfoMap, authResults) + if !ok { + return false, nil + } + pbItem, err := toProto(ctx, item, nil, outputOpts...) + if err != nil { + return false, err + } + return filter.Match(pbItem), nil + } + default: + filterItemFn = func(ctx context.Context, item *iam.User) (bool, error) { + return true, nil + } } - filter, err := handlers.NewFilter(ctx, req.GetFilter()) + grantsHash, err := authResults.GrantsHash(ctx) if err != nil { - return nil, err + return nil, errors.Wrap(ctx, err, op) } - finalItems := make([]*pb.User, 0, len(ul)) - res := perms.Resource{ - Type: resource.User, + + repo, err := s.repoFn() + if err != nil { + return nil, errors.Wrap(ctx, err, op) } - for _, item := range ul { - res.Id = item.GetPublicId() - res.ScopeId = item.GetScopeId() - authorizedActions := authResults.FetchActionSetForId(ctx, item.GetPublicId(), IdActions, auth.WithResource(&res)).Strings() - if len(authorizedActions) == 0 { - continue + var listResp *pagination.ListResponse[*iam.User] + var sortBy string + if req.GetListToken() == "" { + sortBy = "created_time" + listResp, err = iam.ListUsers(ctx, grantsHash, pageSize, filterItemFn, repo, scopeIds) + if err != nil { + return nil, err } - - outputFields := authResults.FetchOutputFields(res, action.List).SelfOrDefaults(*authResults.UserData.User.Id) - outputOpts := make([]handlers.Option, 0, 3) - outputOpts = append(outputOpts, handlers.WithOutputFields(outputFields)) - if outputFields.Has(globals.ScopeField) { - outputOpts = append(outputOpts, handlers.WithScope(scopeInfoMap[item.GetScopeId()])) + } else { + listToken, err := handlers.ParseListToken(ctx, req.GetListToken(), resource.User, grantsHash) + if err != nil { + return nil, err } - if outputFields.Has(globals.AuthorizedActionsField) { - outputOpts = append(outputOpts, handlers.WithAuthorizedActions(authorizedActions)) + switch st := listToken.Subtype.(type) { + case *listtoken.PaginationToken: + sortBy = "created_time" + listResp, err = iam.ListUsersPage(ctx, grantsHash, pageSize, filterItemFn, listToken, repo, scopeIds) + if err != nil { + return nil, err + } + case *listtoken.StartRefreshToken: + sortBy = "updated_time" + listResp, err = iam.ListUsersRefresh(ctx, grantsHash, pageSize, filterItemFn, listToken, repo, scopeIds) + if err != nil { + return nil, err + } + case *listtoken.RefreshToken: + sortBy = "updated_time" + listResp, err = iam.ListUsersRefreshPage(ctx, grantsHash, pageSize, filterItemFn, listToken, repo, scopeIds) + if err != nil { + return nil, err + } + default: + return nil, handlers.ApiErrorWithCodeAndMessage(codes.InvalidArgument, "unexpected list token subtype: %T", st) } + } + finalItems := make([]*pb.User, 0, len(listResp.Items)) + for _, item := range listResp.Items { + outputOpts, ok := newOutputOpts(ctx, item, scopeInfoMap, authResults) + if !ok { + continue + } item, err := toProto(ctx, item, nil, outputOpts...) if err != nil { - return nil, err + return nil, errors.Wrap(ctx, err, op) } - - if filter.Match(item) { - finalItems = append(finalItems, item) + finalItems = append(finalItems, item) + } + respType := "delta" + if listResp.CompleteListing { + respType = "complete" + } + resp := &pbs.ListUsersResponse{ + Items: finalItems, + EstItemCount: uint32(listResp.EstimatedItemCount), + RemovedIds: listResp.DeletedIds, + ResponseType: respType, + SortBy: sortBy, + SortDir: "desc", + } + if listResp.ListToken != nil { + resp.ListToken, err = handlers.MarshalListToken(ctx, listResp.ListToken, pbs.ResourceType_RESOURCE_TYPE_USER) + if err != nil { + return nil, err } } - return &pbs.ListUsersResponse{Items: finalItems}, nil + return resp, nil } // GetUsers implements the interface pbs.UserServiceServer. @@ -493,18 +566,6 @@ func (s Service) deleteFromRepo(ctx context.Context, id string) (bool, error) { return rows > 0, nil } -func (s Service) listFromRepo(ctx context.Context, scopeIds []string) ([]*iam.User, error) { - repo, err := s.repoFn() - if err != nil { - return nil, err - } - ul, err := repo.ListUsers(ctx, scopeIds, iam.WithLimit(-1)) - if err != nil { - return nil, err - } - return ul, nil -} - func (s Service) addInRepo(ctx context.Context, userId string, accountIds []string, version uint32) (*iam.User, []string, error) { const op = "users.(Service).addInRepo" repo, err := s.repoFn() @@ -792,3 +853,26 @@ func validateRemoveUserAccountsRequest(req *pbs.RemoveUserAccountsRequest) error } return nil } + +func newOutputOpts(ctx context.Context, item *iam.User, scopeInfoMap map[string]*scopes.ScopeInfo, authResults auth.VerifyResults) ([]handlers.Option, bool) { + res := perms.Resource{ + Type: resource.User, + } + res.Id = item.GetPublicId() + res.ScopeId = item.GetScopeId() + authorizedActions := authResults.FetchActionSetForId(ctx, item.GetPublicId(), IdActions, auth.WithResource(&res)) + if len(authorizedActions) == 0 { + return nil, false + } + + outputFields := authResults.FetchOutputFields(res, action.List).SelfOrDefaults(authResults.UserId) + outputOpts := make([]handlers.Option, 0, 3) + outputOpts = append(outputOpts, handlers.WithOutputFields(outputFields)) + if outputFields.Has(globals.ScopeField) { + outputOpts = append(outputOpts, handlers.WithScope(scopeInfoMap[item.GetScopeId()])) + } + if outputFields.Has(globals.AuthorizedActionsField) { + outputOpts = append(outputOpts, handlers.WithAuthorizedActions(authorizedActions.Strings())) + } + return outputOpts, true +} diff --git a/internal/daemon/controller/handlers/users/user_service_test.go b/internal/daemon/controller/handlers/users/user_service_test.go index 45c99df4e5..2e75eb893f 100644 --- a/internal/daemon/controller/handlers/users/user_service_test.go +++ b/internal/daemon/controller/handlers/users/user_service_test.go @@ -7,7 +7,7 @@ import ( "context" "errors" "fmt" - "sort" + "slices" "strings" "testing" @@ -17,13 +17,17 @@ import ( "github.com/hashicorp/boundary/internal/auth/ldap" "github.com/hashicorp/boundary/internal/auth/oidc" "github.com/hashicorp/boundary/internal/auth/password" + "github.com/hashicorp/boundary/internal/authtoken" "github.com/hashicorp/boundary/internal/daemon/controller/auth" "github.com/hashicorp/boundary/internal/daemon/controller/handlers" "github.com/hashicorp/boundary/internal/daemon/controller/handlers/users" "github.com/hashicorp/boundary/internal/db" pbs "github.com/hashicorp/boundary/internal/gen/controller/api/services" + authpb "github.com/hashicorp/boundary/internal/gen/controller/auth" "github.com/hashicorp/boundary/internal/iam" "github.com/hashicorp/boundary/internal/kms" + "github.com/hashicorp/boundary/internal/requests" + "github.com/hashicorp/boundary/internal/server" "github.com/hashicorp/boundary/internal/types/scope" "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/scopes" pb "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/users" @@ -145,7 +149,7 @@ func TestGet(t *testing.T) { req := proto.Clone(toMerge).(*pbs.GetUserRequest) proto.Merge(req, tc.req) - s, err := users.NewService(context.Background(), repoFn) + s, err := users.NewService(context.Background(), repoFn, 1000) require.NoError(err, "Couldn't create new user service.") got, gErr := s.GetUser(auth.DisabledAuthTestContext(repoFn, u.GetScopeId()), req) @@ -166,6 +170,7 @@ func TestGet(t *testing.T) { } func TestList(t *testing.T) { + ctx := context.Background() conn, _ := db.TestSetup(t, "postgres") wrap := db.TestWrapper(t) iamRepo := iam.TestRepo(t, conn, wrap) @@ -191,23 +196,25 @@ func TestList(t *testing.T) { secondaryAm := password.TestAuthMethods(t, conn, oWithUsers.PublicId, 1) require.Len(t, secondaryAm, 1) - s, err := users.NewService(context.Background(), repoFn) + s, err := users.NewService(context.Background(), repoFn, 1000) require.NoError(t, err) var wantUsers []*pb.User // Populate expected values for recursive test var totalUsers []*pb.User - ctx := auth.DisabledAuthTestContext(repoFn, "global") - anon, err := s.GetUser(ctx, &pbs.GetUserRequest{Id: globals.AnonymousUserId}) - require.NoError(t, err) - totalUsers = append(totalUsers, anon.GetItem()) - authUser, err := s.GetUser(ctx, &pbs.GetUserRequest{Id: globals.AnyAuthenticatedUserId}) - require.NoError(t, err) - totalUsers = append(totalUsers, authUser.GetItem()) - recovery, err := s.GetUser(ctx, &pbs.GetUserRequest{Id: globals.RecoveryUserId}) - require.NoError(t, err) - totalUsers = append(totalUsers, recovery.GetItem()) + { + disabledAuthCtx := auth.DisabledAuthTestContext(repoFn, "global") + anon, err := s.GetUser(disabledAuthCtx, &pbs.GetUserRequest{Id: globals.AnonymousUserId}) + require.NoError(t, err) + totalUsers = append(totalUsers, anon.GetItem()) + authUser, err := s.GetUser(disabledAuthCtx, &pbs.GetUserRequest{Id: globals.AnyAuthenticatedUserId}) + require.NoError(t, err) + totalUsers = append(totalUsers, authUser.GetItem()) + recovery, err := s.GetUser(disabledAuthCtx, &pbs.GetUserRequest{Id: globals.RecoveryUserId}) + require.NoError(t, err) + totalUsers = append(totalUsers, recovery.GetItem()) + } // Add new users for i := 0; i < 10; i++ { @@ -238,12 +245,17 @@ func TestList(t *testing.T) { PrimaryAccountId: oidcAcct.GetPublicId(), }) } - // Populate these users into the total - ctx = auth.DisabledAuthTestContext(repoFn, oWithUsers.GetPublicId(), auth.WithUserId(globals.AnyAuthenticatedUserId)) - usersInOrg, err := s.ListUsers(ctx, &pbs.ListUsersRequest{ScopeId: oWithUsers.GetPublicId()}) + totalUsers = append(totalUsers, wantUsers...) + slices.Reverse(totalUsers) + slices.Reverse(wantUsers) + + // Run analyze to update postgres estimates + sqlDb, err := conn.SqlDB(ctx) + require.NoError(t, err) + _, err = sqlDb.ExecContext(ctx, "analyze") require.NoError(t, err) - totalUsers = append(totalUsers, usersInOrg.GetItems()...) + cases := []struct { name string req *pbs.ListUsersRequest @@ -253,32 +265,64 @@ func TestList(t *testing.T) { { name: "List Many Users", req: &pbs.ListUsersRequest{ScopeId: oWithUsers.GetPublicId()}, - res: &pbs.ListUsersResponse{Items: wantUsers}, + res: &pbs.ListUsersResponse{ + Items: wantUsers, + EstItemCount: uint32(len(wantUsers)), + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, }, { name: "List No Users", req: &pbs.ListUsersRequest{ScopeId: oNoUsers.GetPublicId()}, - res: &pbs.ListUsersResponse{}, + res: &pbs.ListUsersResponse{ + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, }, { name: "List Recursively in Org", req: &pbs.ListUsersRequest{ScopeId: oWithUsers.GetPublicId(), Recursive: true}, - res: &pbs.ListUsersResponse{Items: wantUsers}, + res: &pbs.ListUsersResponse{ + Items: wantUsers, + EstItemCount: uint32(len(wantUsers)), + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, }, { name: "List Recursively in Global", req: &pbs.ListUsersRequest{ScopeId: "global", Recursive: true}, - res: &pbs.ListUsersResponse{Items: totalUsers}, + res: &pbs.ListUsersResponse{ + Items: totalUsers, + EstItemCount: uint32(len(totalUsers)), + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, }, { name: "Filter Many Users", req: &pbs.ListUsersRequest{ScopeId: "global", Recursive: true, Filter: fmt.Sprintf(`"/item/scope/id"==%q`, oWithUsers.GetPublicId())}, - res: &pbs.ListUsersResponse{Items: wantUsers}, + res: &pbs.ListUsersResponse{ + Items: wantUsers, + EstItemCount: uint32(len(wantUsers)), + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, }, { name: "Filter To No Users", req: &pbs.ListUsersRequest{ScopeId: oWithUsers.GetPublicId(), Filter: `"/item/id"=="doesntmatch"`}, - res: &pbs.ListUsersResponse{}, + res: &pbs.ListUsersResponse{ + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, }, { name: "Filter Bad Format", @@ -298,20 +342,6 @@ func TestList(t *testing.T) { assert.True(errors.Is(gErr, tc.err), "ListUsers(%+v) got error %v, wanted %v", tc.req, gErr, tc.err) return } - gotUsers := sortableUsers{ - users: got.GetItems(), - } - resUsers := sortableUsers{ - users: tc.res.GetItems(), - } - if got != nil { - sort.Sort(gotUsers) - got.Items = gotUsers.users - } - if tc.res != nil { - sort.Sort(resUsers) - tc.res.Items = resUsers.users - } assert.Empty(cmp.Diff( got, tc.res, @@ -319,6 +349,7 @@ func TestList(t *testing.T) { cmpopts.SortSlices(func(a, b string) bool { return a < b }), + protocmp.IgnoreFields(&pbs.ListUsersResponse{}, "list_token"), ), "ListUsers(%q) got response %q, wanted %q", tc.req, got, tc.res) // Test with anon user @@ -336,18 +367,372 @@ func TestList(t *testing.T) { } } -type sortableUsers struct { - users []*pb.User +func userToProto(u *iam.User, si *scopes.ScopeInfo, authorizedActions []string) *pb.User { + pu := &pb.User{ + Id: u.GetPublicId(), + ScopeId: u.GetScopeId(), + Scope: si, + CreatedTime: u.GetCreateTime().GetTimestamp(), + UpdatedTime: u.GetUpdateTime().GetTimestamp(), + Version: u.GetVersion(), + AuthorizedActions: testAuthorizedActions, + } + if u.GetName() != "" { + pu.Name = wrapperspb.String(u.GetName()) + } + if u.GetDescription() != "" { + pu.Description = wrapperspb.String(u.GetDescription()) + } + return pu } -func (s sortableUsers) Len() int { return len(s.users) } -func (s sortableUsers) Less(i, j int) bool { return s.users[i].GetId() < s.users[j].GetId() } -func (s sortableUsers) Swap(i, j int) { s.users[i], s.users[j] = s.users[j], s.users[i] } +func TestListPagination(t *testing.T) { + // Set database read timeout to avoid duplicates in response + oldReadTimeout := globals.RefreshReadLookbackDuration + globals.RefreshReadLookbackDuration = 0 + t.Cleanup(func() { + globals.RefreshReadLookbackDuration = oldReadTimeout + }) + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + sqlDB, err := conn.SqlDB(ctx) + require.NoError(t, err) + wrapper := db.TestWrapper(t) + kms := kms.TestKms(t, conn, wrapper) + rw := db.New(conn) + + iamRepo := iam.TestRepo(t, conn, wrapper) + iamRepoFn := func() (*iam.Repository, error) { + return iamRepo, nil + } + tokenRepoFn := func() (*authtoken.Repository, error) { + return authtoken.NewRepository(ctx, rw, rw, kms) + } + serversRepoFn := func() (*server.Repository, error) { + return server.NewRepository(ctx, rw, rw, kms) + } + tokenRepo, err := tokenRepoFn() + require.NoError(t, err) + + // Run analyze to update postgres meta tables + _, err = sqlDB.ExecContext(ctx, "analyze") + require.NoError(t, err) + + oNoUsers, _ := iam.TestScopes(t, iamRepo) + oWithUsers, _ := iam.TestScopes(t, iamRepo) + + var allUsers []*pb.User + // Get the 3 system users (u_recovery, u_anon, u_auth) + us, _, err := iamRepo.ListUsers(ctx, []string{"global"}) + require.NoError(t, err) + require.Len(t, us, 3) + // They (should) be returned in reverse order by create time, so we reverse + slices.Reverse(us) + for _, u := range us { + allUsers = append(allUsers, userToProto(u, &scopes.ScopeInfo{ + Id: u.ScopeId, + Name: scope.Global.String(), + Description: "Global Scope", + Type: scope.Global.String(), + }, testAuthorizedActions)) + } + + authMethod := password.TestAuthMethods(t, conn, "global", 1)[0] + acct := password.TestAccount(t, conn, authMethod.GetPublicId(), "test_user") + u := iam.TestUser(t, iamRepo, "global", iam.WithAccountIds(acct.PublicId)) + allUsers = append(allUsers, userToProto(u, &scopes.ScopeInfo{ + Id: u.ScopeId, + Name: scope.Global.String(), + Description: "Global Scope", + Type: scope.Global.String(), + }, testAuthorizedActions)) + + // add roles to be able to see all users + allowedRole := iam.TestRole(t, conn, "global") + iam.TestRoleGrant(t, conn, allowedRole.GetPublicId(), "id=*;type=*;actions=*") + iam.TestUserRole(t, conn, allowedRole.GetPublicId(), u.GetPublicId()) + for _, scope := range []*iam.Scope{oWithUsers, oNoUsers} { + allowedRole := iam.TestRole(t, conn, scope.GetPublicId()) + iam.TestRoleGrant(t, conn, allowedRole.GetPublicId(), "id=*;type=*;actions=*") + iam.TestUserRole(t, conn, allowedRole.GetPublicId(), u.GetPublicId()) + } + + at, err := tokenRepo.CreateAuthToken(ctx, u, acct.GetPublicId()) + require.NoError(t, err) + + // Test without anon user + requestInfo := authpb.RequestInfo{ + TokenFormat: uint32(auth.AuthTokenTypeBearer), + PublicId: at.GetPublicId(), + Token: at.GetToken(), + } + requestContext := context.WithValue(context.Background(), requests.ContextRequestInformationKey, &requests.RequestContext{}) + ctx = auth.NewVerifierContext(requestContext, iamRepoFn, tokenRepoFn, serversRepoFn, kms, &requestInfo) + + var safeToDeleteUser string + orgScopeInfo := &scopes.ScopeInfo{Id: oWithUsers.GetPublicId(), Type: scope.Org.String(), ParentScopeId: scope.Global.String()} + for i := 0; i < 10; i++ { + ou := iam.TestUser(t, iamRepo, oWithUsers.GetPublicId()) + allUsers = append(allUsers, userToProto(ou, orgScopeInfo, testAuthorizedActions)) + safeToDeleteUser = ou.GetPublicId() + } + slices.Reverse(allUsers) + + a, err := users.NewService(ctx, iamRepoFn, 1000) + require.NoError(t, err, "Couldn't create new user service.") + + // Run analyze to update postgres estimates + _, err = sqlDB.ExecContext(context.Background(), "analyze") + require.NoError(t, err) + + itemCount := uint32(len(allUsers)) + testPageSize := int((itemCount - 2) / 2) + + // Start paginating, recursively + req := &pbs.ListUsersRequest{ + ScopeId: "global", + Recursive: true, + Filter: "", + ListToken: "", + PageSize: uint32(testPageSize), + } + got, err := a.ListUsers(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), testPageSize) + // Compare without comparing the list token + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListUsersResponse{ + Items: allUsers[0:testPageSize], + ResponseType: "delta", + SortBy: "created_time", + SortDir: "desc", + RemovedIds: nil, + // In addition to the added users, there are the users added + // by the test setup when specifying the permissions of the + // requester + EstItemCount: itemCount, + }, + protocmp.SortRepeated(func(a, b string) bool { + return strings.Compare(a, b) < 0 + }), + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListUsersResponse{}, "list_token"), + ), + ) + + // Request second page + req.ListToken = got.ListToken + got, err = a.ListUsers(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), testPageSize) + // Compare without comparing the list token + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListUsersResponse{ + Items: allUsers[testPageSize : testPageSize*2], + ResponseType: "delta", + SortBy: "created_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: itemCount, + }, + protocmp.Transform(), + protocmp.SortRepeated(func(a, b string) bool { + return strings.Compare(a, b) < 0 + }), + protocmp.IgnoreFields(&pbs.ListUsersResponse{}, "list_token"), + ), + ) + + // Request rest of results + req.ListToken = got.ListToken + req.PageSize = 10 + got, err = a.ListUsers(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 2) + // Compare without comparing the list token + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListUsersResponse{ + Items: allUsers[testPageSize*2:], + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: itemCount, + }, + protocmp.Transform(), + protocmp.SortRepeated(func(a, b string) bool { + return strings.Compare(a, b) < 0 + }), + protocmp.IgnoreFields(&pbs.ListUsersResponse{}, "list_token"), + ), + ) + + // Update 2 users and see them in the refresh + r1 := allUsers[len(allUsers)-1] + r1.Description = wrapperspb.String("updated1") + resp1, err := a.UpdateUser(ctx, &pbs.UpdateUserRequest{ + Id: r1.GetId(), + Item: &pb.User{Description: r1.GetDescription(), Version: r1.GetVersion()}, + UpdateMask: &field_mask.FieldMask{Paths: []string{"description"}}, + }) + require.NoError(t, err) + r1.UpdatedTime = resp1.GetItem().GetUpdatedTime() + r1.Version = resp1.GetItem().GetVersion() + allUsers = append([]*pb.User{r1}, allUsers[:len(allUsers)-1]...) + + r2 := allUsers[len(allUsers)-1] + r2.Description = wrapperspb.String("updated2") + resp2, err := a.UpdateUser(ctx, &pbs.UpdateUserRequest{ + Id: r2.GetId(), + Item: &pb.User{Description: r2.GetDescription(), Version: r2.GetVersion()}, + UpdateMask: &field_mask.FieldMask{Paths: []string{"description"}}, + }) + require.NoError(t, err) + r2.UpdatedTime = resp2.GetItem().GetUpdatedTime() + r2.Version = resp2.GetItem().GetVersion() + allUsers = append([]*pb.User{r2}, allUsers[:len(allUsers)-1]...) + + // Run analyze to update postgres estimates + _, err = sqlDB.ExecContext(ctx, "analyze") + require.NoError(t, err) + + // Request updated results + req.ListToken = got.ListToken + req.PageSize = 1 + got, err = a.ListUsers(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 1) + // Compare without comparing the list token + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListUsersResponse{ + Items: []*pb.User{allUsers[0]}, + ResponseType: "delta", + SortBy: "updated_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: itemCount, + }, + protocmp.Transform(), + protocmp.SortRepeated(func(a, b string) bool { + return strings.Compare(a, b) < 0 + }), + protocmp.IgnoreFields(&pbs.ListUsersResponse{}, "list_token"), + ), + ) + + // Get next page + req.ListToken = got.ListToken + got, err = a.ListUsers(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 1) + // Compare without comparing the list token + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListUsersResponse{ + Items: []*pb.User{allUsers[1]}, + ResponseType: "complete", + SortBy: "updated_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: itemCount, + }, + protocmp.Transform(), + protocmp.SortRepeated(func(a, b string) bool { + return strings.Compare(a, b) < 0 + }), + protocmp.IgnoreFields(&pbs.ListUsersResponse{}, "list_token"), + ), + ) + + // Request new page with filter requiring looping + // to fill the page. + req.ListToken = "" + req.PageSize = 1 + req.Filter = fmt.Sprintf(`"/item/id"==%q or "/item/id"==%q`, allUsers[len(allUsers)-2].Id, allUsers[len(allUsers)-1].Id) + got, err = a.ListUsers(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 1) + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListUsersResponse{ + Items: []*pb.User{allUsers[len(allUsers)-2]}, + ResponseType: "delta", + SortBy: "created_time", + SortDir: "desc", + // Should be empty again + RemovedIds: nil, + EstItemCount: itemCount, + }, + protocmp.Transform(), + protocmp.SortRepeated(func(a, b string) bool { + return strings.Compare(a, b) < 0 + }), + protocmp.IgnoreFields(&pbs.ListUsersResponse{}, "list_token"), + ), + ) + req.ListToken = got.ListToken + // Get the second page + got, err = a.ListUsers(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 1) + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListUsersResponse{ + Items: []*pb.User{allUsers[len(allUsers)-1]}, + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: itemCount, + }, + protocmp.Transform(), + protocmp.SortRepeated(func(a, b string) bool { + return strings.Compare(a, b) < 0 + }), + protocmp.IgnoreFields(&pbs.ListUsersResponse{}, "list_token"), + ), + ) + + _, err = iamRepo.DeleteUser(ctx, safeToDeleteUser) + require.NoError(t, err) + req.ListToken = got.ListToken + got, err = a.ListUsers(ctx, req) + require.NoError(t, err) + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListUsersResponse{ + Items: nil, + ResponseType: "complete", + SortBy: "updated_time", + SortDir: "desc", + RemovedIds: []string{safeToDeleteUser}, + EstItemCount: itemCount, + }, + protocmp.Transform(), + protocmp.SortRepeated(func(a, b string) bool { + return strings.Compare(a, b) < 0 + }), + protocmp.IgnoreFields(&pbs.ListUsersResponse{}, "list_token"), + ), + ) +} func TestDelete(t *testing.T) { u, _, repoFn := createDefaultUserAndRepo(t, false) - s, err := users.NewService(context.Background(), repoFn) + s, err := users.NewService(context.Background(), repoFn, 1000) require.NoError(t, err, "Error when getting new user service.") cases := []struct { @@ -394,7 +779,7 @@ func TestDelete_twice(t *testing.T) { assert, require := assert.New(t), require.New(t) u, _, repoFn := createDefaultUserAndRepo(t, false) - s, err := users.NewService(context.Background(), repoFn) + s, err := users.NewService(context.Background(), repoFn, 1000) require.NoError(err, "Error when getting new user service") req := &pbs.DeleteUserRequest{ Id: u.GetPublicId(), @@ -486,7 +871,7 @@ func TestCreate(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { assert, require := assert.New(t), require.New(t) - s, err := users.NewService(context.Background(), repoFn) + s, err := users.NewService(context.Background(), repoFn, 1000) require.NoError(err, "Error when getting new user service.") got, gErr := s.CreateUser(auth.DisabledAuthTestContext(repoFn, tc.req.GetItem().GetScopeId()), tc.req) @@ -522,7 +907,7 @@ func TestCreate(t *testing.T) { func TestUpdate(t *testing.T) { u, _, repoFn := createDefaultUserAndRepo(t, false) - tested, err := users.NewService(context.Background(), repoFn) + tested, err := users.NewService(context.Background(), repoFn, 1000) require.NoError(t, err, "Error when getting new user service.") created := u.GetCreateTime().GetTimestamp().AsTime() @@ -806,7 +1191,7 @@ func TestAddAccount(t *testing.T) { repoFn := func() (*iam.Repository, error) { return iamRepo, nil } - s, err := users.NewService(ctx, repoFn) + s, err := users.NewService(ctx, repoFn, 1000) require.NoError(t, err, "Error when getting new user service.") o, _ := iam.TestScopes(t, iamRepo) @@ -966,7 +1351,7 @@ func TestSetAccount(t *testing.T) { repoFn := func() (*iam.Repository, error) { return iamRepo, nil } - s, err := users.NewService(ctx, repoFn) + s, err := users.NewService(ctx, repoFn, 1000) require.NoError(t, err, "Error when getting new user service.") o, _ := iam.TestScopes(t, iamRepo) @@ -1128,7 +1513,7 @@ func TestRemoveAccount(t *testing.T) { repoFn := func() (*iam.Repository, error) { return iamRepo, nil } - s, err := users.NewService(ctx, repoFn) + s, err := users.NewService(ctx, repoFn, 1000) require.NoError(t, err, "Error when getting new user service.") o, _ := iam.TestScopes(t, iamRepo) diff --git a/internal/db/schema/migrations/oss/postgres/80/08_iam_updates.up.sql b/internal/db/schema/migrations/oss/postgres/80/08_iam_updates.up.sql index 4486eb130e..52d544bda9 100644 --- a/internal/db/schema/migrations/oss/postgres/80/08_iam_updates.up.sql +++ b/internal/db/schema/migrations/oss/postgres/80/08_iam_updates.up.sql @@ -8,7 +8,13 @@ begin; on iam_role (create_time desc, public_id desc); create index iam_role_update_time_public_id_idx on iam_role (update_time desc, public_id desc); - analyze iam_role; + -- Add new indexes for the create time and update time queries. + create index iam_user_create_time_public_id_idx + on iam_user (create_time desc, public_id desc); + create index iam_user_update_time_public_id_idx + on iam_user (update_time desc, public_id desc); + analyze iam_user; + commit; diff --git a/internal/db/sqltest/tests/pagination/iam_user.sql b/internal/db/sqltest/tests/pagination/iam_user.sql new file mode 100644 index 0000000000..bb899ce99b --- /dev/null +++ b/internal/db/sqltest/tests/pagination/iam_user.sql @@ -0,0 +1,12 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + select plan(2); + + select has_index('iam_user', 'iam_user_create_time_public_id_idx', array['create_time', 'public_id']); + select has_index('iam_user', 'iam_user_update_time_public_id_idx', array['update_time', 'public_id']); + + select * from finish(); + +rollback; \ No newline at end of file diff --git a/internal/gen/controller.swagger.json b/internal/gen/controller.swagger.json index 3c3b9a96eb..91b6487e68 100644 --- a/internal/gen/controller.swagger.json +++ b/internal/gen/controller.swagger.json @@ -3851,6 +3851,21 @@ "in": "query", "required": false, "type": "string" + }, + { + "name": "list_token", + "description": "An opaque token used to continue an existing iteration or\nrequest updated items. If not specified, pagination\nwill start from the beginning.", + "in": "query", + "required": false, + "type": "string" + }, + { + "name": "page_size", + "description": "The maximum size of a page in this iteration.\nIf unset, the default page size configured will be used.\nIf the page_size is greater than the default page configured,\nan error will be returned.", + "in": "query", + "required": false, + "type": "integer", + "format": "int64" } ], "tags": [ @@ -8173,6 +8188,34 @@ "type": "object", "$ref": "#/definitions/controller.api.resources.users.v1.User" } + }, + "response_type": { + "type": "string", + "description": "The type of response, either \"delta\" or \"complete\".\nDelta signifies that this is part of a paginated result\nor an update to a previously completed pagination.\nComplete signifies that it is the last page." + }, + "list_token": { + "type": "string", + "description": "An opaque token used to continue an existing pagination or\nrequest updated items. Use this token in the next list request\nto request the next page." + }, + "sort_by": { + "type": "string", + "description": "The name of the field which the items are sorted by." + }, + "sort_dir": { + "type": "string", + "description": "The direction of the sort, either \"asc\" or \"desc\"." + }, + "removed_ids": { + "type": "array", + "items": { + "type": "string" + }, + "description": "A list of item IDs that have been removed since they were returned\nas part of a pagination. They should be dropped from any client cache.\nThis may contain items that are not known to the cache, if they were\ncreated and deleted between listings." + }, + "est_item_count": { + "type": "integer", + "format": "int64", + "description": "An estimate at the total items available. This may change during pagination." } } }, diff --git a/internal/gen/controller/api/services/user_service.pb.go b/internal/gen/controller/api/services/user_service.pb.go index 25024527fb..f5f8f34cc4 100644 --- a/internal/gen/controller/api/services/user_service.pb.go +++ b/internal/gen/controller/api/services/user_service.pb.go @@ -129,6 +129,15 @@ type ListUsersRequest struct { ScopeId string `protobuf:"bytes,1,opt,name=scope_id,json=scopeId,proto3" json:"scope_id,omitempty" class:"public" eventstream:"observation"` // @gotags: `class:"public" eventstream:"observation"` Recursive bool `protobuf:"varint,20,opt,name=recursive,proto3" json:"recursive,omitempty" class:"public" eventstream:"observation"` // @gotags: `class:"public" eventstream:"observation"` Filter string `protobuf:"bytes,30,opt,name=filter,proto3" json:"filter,omitempty" class:"sensitive"` // @gotags: `class:"sensitive"` + // An opaque token used to continue an existing iteration or + // request updated items. If not specified, pagination + // will start from the beginning. + ListToken string `protobuf:"bytes,40,opt,name=list_token,proto3" json:"list_token,omitempty" class:"public"` // @gotags: `class:"public"` + // The maximum size of a page in this iteration. + // If unset, the default page size configured will be used. + // If the page_size is greater than the default page configured, + // an error will be returned. + PageSize uint32 `protobuf:"varint,50,opt,name=page_size,proto3" json:"page_size,omitempty" class:"public"` // @gotags: `class:"public"` } func (x *ListUsersRequest) Reset() { @@ -184,12 +193,46 @@ func (x *ListUsersRequest) GetFilter() string { return "" } +func (x *ListUsersRequest) GetListToken() string { + if x != nil { + return x.ListToken + } + return "" +} + +func (x *ListUsersRequest) GetPageSize() uint32 { + if x != nil { + return x.PageSize + } + return 0 +} + type ListUsersResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields Items []*users.User `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"` + // The type of response, either "delta" or "complete". + // Delta signifies that this is part of a paginated result + // or an update to a previously completed pagination. + // Complete signifies that it is the last page. + ResponseType string `protobuf:"bytes,2,opt,name=response_type,proto3" json:"response_type,omitempty" class:"public"` // @gotags: `class:"public"` + // An opaque token used to continue an existing pagination or + // request updated items. Use this token in the next list request + // to request the next page. + ListToken string `protobuf:"bytes,3,opt,name=list_token,proto3" json:"list_token,omitempty" class:"public"` // @gotags: `class:"public"` + // The name of the field which the items are sorted by. + SortBy string `protobuf:"bytes,4,opt,name=sort_by,proto3" json:"sort_by,omitempty" class:"public"` // @gotags: `class:"public"` + // The direction of the sort, either "asc" or "desc". + SortDir string `protobuf:"bytes,5,opt,name=sort_dir,proto3" json:"sort_dir,omitempty" class:"public"` // @gotags: `class:"public"` + // A list of item IDs that have been removed since they were returned + // as part of a pagination. They should be dropped from any client cache. + // This may contain items that are not known to the cache, if they were + // created and deleted between listings. + RemovedIds []string `protobuf:"bytes,6,rep,name=removed_ids,proto3" json:"removed_ids,omitempty" class:"public"` // @gotags: `class:"public"` + // An estimate at the total items available. This may change during pagination. + EstItemCount uint32 `protobuf:"varint,7,opt,name=est_item_count,proto3" json:"est_item_count,omitempty" class:"public"` // @gotags: `class:"public"` } func (x *ListUsersResponse) Reset() { @@ -231,6 +274,48 @@ func (x *ListUsersResponse) GetItems() []*users.User { return nil } +func (x *ListUsersResponse) GetResponseType() string { + if x != nil { + return x.ResponseType + } + return "" +} + +func (x *ListUsersResponse) GetListToken() string { + if x != nil { + return x.ListToken + } + return "" +} + +func (x *ListUsersResponse) GetSortBy() string { + if x != nil { + return x.SortBy + } + return "" +} + +func (x *ListUsersResponse) GetSortDir() string { + if x != nil { + return x.SortDir + } + return "" +} + +func (x *ListUsersResponse) GetRemovedIds() []string { + if x != nil { + return x.RemovedIds + } + return nil +} + +func (x *ListUsersResponse) GetEstItemCount() uint32 { + if x != nil { + return x.EstItemCount + } + return 0 +} + type CreateUserRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -885,190 +970,206 @@ var file_controller_api_services_v1_user_service_proto_rawDesc = []byte{ 0x0a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2e, 0x76, 0x31, - 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x63, 0x0a, 0x10, 0x4c, - 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x19, 0x0a, 0x08, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x07, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, - 0x63, 0x75, 0x72, 0x73, 0x69, 0x76, 0x65, 0x18, 0x14, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x72, - 0x65, 0x63, 0x75, 0x72, 0x73, 0x69, 0x76, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x69, 0x6c, 0x74, - 0x65, 0x72, 0x18, 0x1e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, - 0x22, 0x52, 0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, + 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0xa1, 0x01, 0x0a, 0x10, + 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x19, 0x0a, 0x08, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x72, + 0x65, 0x63, 0x75, 0x72, 0x73, 0x69, 0x76, 0x65, 0x18, 0x14, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, + 0x72, 0x65, 0x63, 0x75, 0x72, 0x73, 0x69, 0x76, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x69, 0x6c, + 0x74, 0x65, 0x72, 0x18, 0x1e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x69, 0x6c, 0x74, 0x65, + 0x72, 0x12, 0x1e, 0x0a, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, + 0x28, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, + 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x32, + 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x22, + 0x98, 0x02, 0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x05, 0x69, - 0x74, 0x65, 0x6d, 0x73, 0x22, 0x50, 0x0a, 0x11, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x73, - 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x3b, 0x0a, 0x04, 0x69, 0x74, 0x65, - 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, - 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, - 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x63, 0x0a, 0x12, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, - 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, - 0x75, 0x72, 0x69, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x69, 0x12, 0x3b, - 0x0a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x63, - 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x72, 0x65, - 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2e, 0x76, 0x31, - 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x9e, 0x01, 0x0a, 0x11, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, - 0x64, 0x12, 0x3b, 0x0a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x74, 0x65, 0x6d, 0x73, 0x12, 0x24, 0x0a, 0x0d, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x72, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x6c, 0x69, + 0x73, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, + 0x6c, 0x69, 0x73, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x6f, + 0x72, 0x74, 0x5f, 0x62, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x6f, 0x72, + 0x74, 0x5f, 0x62, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x6f, 0x72, 0x74, 0x5f, 0x64, 0x69, 0x72, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x6f, 0x72, 0x74, 0x5f, 0x64, 0x69, 0x72, + 0x12, 0x20, 0x0a, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x64, 0x5f, 0x69, 0x64, 0x73, 0x18, + 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x64, 0x5f, 0x69, + 0x64, 0x73, 0x12, 0x26, 0x0a, 0x0e, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x63, + 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0e, 0x65, 0x73, 0x74, 0x5f, + 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x50, 0x0a, 0x11, 0x43, 0x72, + 0x65, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x3b, 0x0a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, + 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2e, 0x76, + 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x63, 0x0a, 0x12, + 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x75, 0x72, 0x69, 0x12, 0x3b, 0x0a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, + 0x61, 0x70, 0x69, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2e, 0x75, 0x73, + 0x65, 0x72, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x69, 0x74, 0x65, + 0x6d, 0x22, 0x9e, 0x01, 0x0a, 0x11, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x3b, 0x0a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, + 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, + 0x2e, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, + 0x69, 0x74, 0x65, 0x6d, 0x12, 0x3c, 0x0a, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x6d, + 0x61, 0x73, 0x6b, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x46, 0x69, 0x65, 0x6c, + 0x64, 0x4d, 0x61, 0x73, 0x6b, 0x52, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x6d, 0x61, + 0x73, 0x6b, 0x22, 0x51, 0x0a, 0x12, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x04, 0x69, 0x74, 0x65, 0x6d, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, + 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, + 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, + 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x23, 0x0a, 0x11, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x55, + 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x14, 0x0a, 0x12, 0x44, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x64, 0x0a, 0x16, 0x41, 0x64, 0x64, 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x07, 0x76, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, + 0x69, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x63, 0x63, 0x6f, 0x75, + 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x73, 0x22, 0x56, 0x0a, 0x17, 0x41, 0x64, 0x64, 0x55, 0x73, 0x65, + 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x12, 0x3b, 0x0a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x73, - 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x12, 0x3c, - 0x0a, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x6d, 0x61, 0x73, 0x6b, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x4d, 0x61, 0x73, 0x6b, 0x52, - 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x6d, 0x61, 0x73, 0x6b, 0x22, 0x51, 0x0a, 0x12, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x27, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, - 0x69, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72, - 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, - 0x23, 0x0a, 0x11, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x02, 0x69, 0x64, 0x22, 0x14, 0x0a, 0x12, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x55, 0x73, - 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x64, 0x0a, 0x16, 0x41, 0x64, - 0x64, 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x02, 0x69, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x20, - 0x0a, 0x0b, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x03, 0x20, - 0x03, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x73, - 0x22, 0x56, 0x0a, 0x17, 0x41, 0x64, 0x64, 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, - 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x04, 0x69, - 0x74, 0x65, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x63, 0x6f, 0x6e, 0x74, - 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, - 0x65, 0x72, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x64, 0x0a, 0x16, 0x53, 0x65, 0x74, 0x55, - 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, - 0x69, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0d, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, - 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, - 0x09, 0x52, 0x0b, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x73, 0x22, 0x56, - 0x0a, 0x17, 0x53, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, - 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x04, 0x69, 0x74, 0x65, - 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, - 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, - 0x65, 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, - 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x67, 0x0a, 0x19, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, - 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x02, 0x69, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0d, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, - 0x0b, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, - 0x28, 0x09, 0x52, 0x0b, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x73, 0x22, - 0x59, 0x0a, 0x1a, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, - 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, - 0x04, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x63, 0x6f, - 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x72, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2e, 0x76, 0x31, 0x2e, - 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x32, 0xb4, 0x0c, 0x0a, 0x0b, 0x55, - 0x73, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x98, 0x01, 0x0a, 0x07, 0x47, - 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x12, 0x2a, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, - 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, - 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, - 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, - 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, - 0x34, 0x92, 0x41, 0x15, 0x12, 0x13, 0x47, 0x65, 0x74, 0x73, 0x20, 0x61, 0x20, 0x73, 0x69, 0x6e, - 0x67, 0x6c, 0x65, 0x20, 0x55, 0x73, 0x65, 0x72, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x16, 0x62, - 0x04, 0x69, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x73, - 0x2f, 0x7b, 0x69, 0x64, 0x7d, 0x12, 0x90, 0x01, 0x0a, 0x09, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, - 0x65, 0x72, 0x73, 0x12, 0x2c, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, - 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, - 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x2d, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, - 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4c, - 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x22, 0x26, 0x92, 0x41, 0x12, 0x12, 0x10, 0x4c, 0x69, 0x73, 0x74, 0x73, 0x20, 0x61, 0x6c, 0x6c, - 0x20, 0x55, 0x73, 0x65, 0x72, 0x73, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x0b, 0x12, 0x09, 0x2f, - 0x76, 0x31, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x12, 0xa5, 0x01, 0x0a, 0x0a, 0x43, 0x72, 0x65, - 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x12, 0x2d, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, + 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x64, + 0x0a, 0x16, 0x53, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, + 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, + 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x5f, 0x69, 0x64, 0x73, 0x22, 0x56, 0x0a, 0x17, 0x53, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x41, + 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x3b, 0x0a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, + 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x72, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2e, 0x76, + 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x67, 0x0a, 0x19, + 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, + 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, + 0x69, 0x6f, 0x6e, 0x12, 0x20, 0x0a, 0x0b, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, + 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, + 0x74, 0x5f, 0x69, 0x64, 0x73, 0x22, 0x59, 0x0a, 0x1a, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x55, + 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x3b, 0x0a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x27, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, + 0x70, 0x69, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2e, 0x75, 0x73, 0x65, + 0x72, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, + 0x32, 0xb4, 0x0c, 0x0a, 0x0b, 0x55, 0x73, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x12, 0x98, 0x01, 0x0a, 0x07, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x12, 0x2a, 0x2e, 0x63, + 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, + 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, + 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, + 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x34, 0x92, 0x41, 0x15, 0x12, 0x13, 0x47, 0x65, 0x74, 0x73, + 0x20, 0x61, 0x20, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x20, 0x55, 0x73, 0x65, 0x72, 0x2e, 0x82, + 0xd3, 0xe4, 0x93, 0x02, 0x16, 0x62, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x2f, 0x76, 0x31, + 0x2f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x7d, 0x12, 0x90, 0x01, 0x0a, 0x09, + 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x2c, 0x2e, 0x63, 0x6f, 0x6e, 0x74, + 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2d, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, - 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, - 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x38, 0x92, 0x41, 0x18, 0x12, 0x16, 0x43, 0x72, 0x65, - 0x61, 0x74, 0x65, 0x73, 0x20, 0x61, 0x20, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x20, 0x55, 0x73, - 0x65, 0x72, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x17, 0x3a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x62, - 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x09, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x73, - 0x12, 0xa3, 0x01, 0x0a, 0x0a, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x12, - 0x2d, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, - 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, - 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, - 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, - 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x36, - 0x92, 0x41, 0x11, 0x12, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x73, 0x20, 0x61, 0x20, 0x55, - 0x73, 0x65, 0x72, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1c, 0x3a, 0x04, 0x69, 0x74, 0x65, 0x6d, - 0x62, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x32, 0x0e, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x73, 0x65, 0x72, - 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x7d, 0x12, 0x97, 0x01, 0x0a, 0x0a, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x73, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x26, 0x92, 0x41, 0x12, 0x12, 0x10, 0x4c, 0x69, 0x73, + 0x74, 0x73, 0x20, 0x61, 0x6c, 0x6c, 0x20, 0x55, 0x73, 0x65, 0x72, 0x73, 0x2e, 0x82, 0xd3, 0xe4, + 0x93, 0x02, 0x0b, 0x12, 0x09, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x12, 0xa5, + 0x01, 0x0a, 0x0a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x12, 0x2d, 0x2e, + 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x63, + 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x38, 0x92, 0x41, + 0x18, 0x12, 0x16, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x73, 0x20, 0x61, 0x20, 0x73, 0x69, 0x6e, + 0x67, 0x6c, 0x65, 0x20, 0x55, 0x73, 0x65, 0x72, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x17, 0x3a, + 0x04, 0x69, 0x74, 0x65, 0x6d, 0x62, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x09, 0x2f, 0x76, 0x31, + 0x2f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x12, 0xa3, 0x01, 0x0a, 0x0a, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x12, 0x2d, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, - 0x76, 0x31, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, + 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, - 0x31, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x2a, 0x92, 0x41, 0x11, 0x12, 0x0f, 0x44, 0x65, 0x6c, 0x65, 0x74, - 0x65, 0x73, 0x20, 0x61, 0x20, 0x55, 0x73, 0x65, 0x72, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x10, - 0x2a, 0x0e, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x7d, - 0x12, 0xcd, 0x01, 0x0a, 0x0f, 0x41, 0x64, 0x64, 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, - 0x75, 0x6e, 0x74, 0x73, 0x12, 0x32, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, - 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, - 0x31, 0x2e, 0x41, 0x64, 0x64, 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, - 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x33, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, - 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x64, 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, - 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x51, 0x92, - 0x41, 0x22, 0x12, 0x20, 0x41, 0x73, 0x73, 0x6f, 0x63, 0x69, 0x61, 0x74, 0x65, 0x73, 0x20, 0x61, - 0x6e, 0x20, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x20, 0x74, 0x6f, 0x20, 0x61, 0x20, 0x55, - 0x73, 0x65, 0x72, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x26, 0x3a, 0x01, 0x2a, 0x62, 0x04, 0x69, - 0x74, 0x65, 0x6d, 0x22, 0x1b, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2f, 0x7b, - 0x69, 0x64, 0x7d, 0x3a, 0x61, 0x64, 0x64, 0x2d, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, - 0x12, 0xb5, 0x02, 0x0a, 0x0f, 0x53, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, - 0x75, 0x6e, 0x74, 0x73, 0x12, 0x32, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, - 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, - 0x31, 0x2e, 0x53, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, - 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x33, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, - 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, - 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x01, - 0x92, 0x41, 0x88, 0x01, 0x12, 0x85, 0x01, 0x53, 0x65, 0x74, 0x20, 0x74, 0x68, 0x65, 0x20, 0x41, - 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x20, 0x61, 0x73, 0x73, 0x6f, 0x63, 0x69, 0x61, 0x74, - 0x65, 0x64, 0x20, 0x74, 0x6f, 0x20, 0x74, 0x68, 0x65, 0x20, 0x55, 0x73, 0x65, 0x72, 0x20, 0x74, - 0x6f, 0x20, 0x65, 0x78, 0x61, 0x63, 0x74, 0x6c, 0x79, 0x20, 0x74, 0x68, 0x65, 0x20, 0x6c, 0x69, - 0x73, 0x74, 0x20, 0x6f, 0x66, 0x20, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x64, 0x20, 0x69, - 0x6e, 0x20, 0x74, 0x68, 0x65, 0x20, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2c, 0x20, 0x72, - 0x65, 0x6d, 0x6f, 0x76, 0x69, 0x6e, 0x67, 0x20, 0x61, 0x6e, 0x79, 0x20, 0x41, 0x63, 0x63, 0x6f, - 0x75, 0x6e, 0x74, 0x73, 0x20, 0x74, 0x68, 0x61, 0x74, 0x20, 0x61, 0x72, 0x65, 0x20, 0x6e, 0x6f, - 0x74, 0x20, 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x65, 0x64, 0x2e, 0x82, 0xd3, 0xe4, 0x93, - 0x02, 0x26, 0x3a, 0x01, 0x2a, 0x62, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x1b, 0x2f, 0x76, 0x31, - 0x2f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x7d, 0x3a, 0x73, 0x65, 0x74, 0x2d, - 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x12, 0x86, 0x02, 0x0a, 0x12, 0x52, 0x65, 0x6d, - 0x6f, 0x76, 0x65, 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x12, - 0x35, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, - 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x6d, - 0x6f, 0x76, 0x65, 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x36, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, - 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, - 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, - 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x80, - 0x01, 0x92, 0x41, 0x4e, 0x12, 0x4c, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x73, 0x20, 0x74, 0x68, - 0x65, 0x20, 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x65, 0x64, 0x20, 0x41, 0x63, 0x63, 0x6f, - 0x75, 0x6e, 0x74, 0x73, 0x20, 0x66, 0x72, 0x6f, 0x6d, 0x20, 0x62, 0x65, 0x69, 0x6e, 0x67, 0x20, - 0x61, 0x73, 0x73, 0x6f, 0x63, 0x69, 0x61, 0x74, 0x65, 0x64, 0x20, 0x77, 0x69, 0x74, 0x68, 0x20, - 0x74, 0x68, 0x65, 0x20, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x64, 0x20, 0x55, 0x73, 0x65, - 0x72, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x29, 0x3a, 0x01, 0x2a, 0x62, 0x04, 0x69, 0x74, 0x65, - 0x6d, 0x22, 0x1e, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2f, 0x7b, 0x69, 0x64, - 0x7d, 0x3a, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x2d, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, - 0x73, 0x42, 0x4d, 0x5a, 0x4b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, - 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x61, - 0x72, 0x79, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x67, 0x65, 0x6e, 0x2f, - 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x73, - 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x3b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x36, 0x92, 0x41, 0x11, 0x12, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x73, 0x20, 0x61, 0x20, 0x55, 0x73, 0x65, 0x72, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1c, + 0x3a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x62, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x32, 0x0e, 0x2f, 0x76, + 0x31, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x7d, 0x12, 0x97, 0x01, 0x0a, + 0x0a, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x12, 0x2d, 0x2e, 0x63, 0x6f, + 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x55, + 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x63, 0x6f, 0x6e, + 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x55, 0x73, + 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x2a, 0x92, 0x41, 0x11, 0x12, + 0x0f, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x20, 0x61, 0x20, 0x55, 0x73, 0x65, 0x72, 0x2e, + 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x10, 0x2a, 0x0e, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x73, 0x65, 0x72, + 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x7d, 0x12, 0xcd, 0x01, 0x0a, 0x0f, 0x41, 0x64, 0x64, 0x55, 0x73, + 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x12, 0x32, 0x2e, 0x63, 0x6f, 0x6e, + 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x64, 0x55, 0x73, 0x65, 0x72, 0x41, + 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x33, + 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x64, 0x55, + 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x51, 0x92, 0x41, 0x22, 0x12, 0x20, 0x41, 0x73, 0x73, 0x6f, 0x63, 0x69, + 0x61, 0x74, 0x65, 0x73, 0x20, 0x61, 0x6e, 0x20, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x20, + 0x74, 0x6f, 0x20, 0x61, 0x20, 0x55, 0x73, 0x65, 0x72, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x26, + 0x3a, 0x01, 0x2a, 0x62, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x1b, 0x2f, 0x76, 0x31, 0x2f, 0x75, + 0x73, 0x65, 0x72, 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x7d, 0x3a, 0x61, 0x64, 0x64, 0x2d, 0x61, 0x63, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x12, 0xb5, 0x02, 0x0a, 0x0f, 0x53, 0x65, 0x74, 0x55, 0x73, + 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x12, 0x32, 0x2e, 0x63, 0x6f, 0x6e, + 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x41, + 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x33, + 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x55, + 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x01, 0x92, 0x41, 0x88, 0x01, 0x12, 0x85, 0x01, 0x53, 0x65, 0x74, + 0x20, 0x74, 0x68, 0x65, 0x20, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x20, 0x61, 0x73, + 0x73, 0x6f, 0x63, 0x69, 0x61, 0x74, 0x65, 0x64, 0x20, 0x74, 0x6f, 0x20, 0x74, 0x68, 0x65, 0x20, + 0x55, 0x73, 0x65, 0x72, 0x20, 0x74, 0x6f, 0x20, 0x65, 0x78, 0x61, 0x63, 0x74, 0x6c, 0x79, 0x20, + 0x74, 0x68, 0x65, 0x20, 0x6c, 0x69, 0x73, 0x74, 0x20, 0x6f, 0x66, 0x20, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x64, 0x65, 0x64, 0x20, 0x69, 0x6e, 0x20, 0x74, 0x68, 0x65, 0x20, 0x72, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x2c, 0x20, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x69, 0x6e, 0x67, 0x20, 0x61, 0x6e, + 0x79, 0x20, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x20, 0x74, 0x68, 0x61, 0x74, 0x20, + 0x61, 0x72, 0x65, 0x20, 0x6e, 0x6f, 0x74, 0x20, 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x65, + 0x64, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x26, 0x3a, 0x01, 0x2a, 0x62, 0x04, 0x69, 0x74, 0x65, + 0x6d, 0x22, 0x1b, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2f, 0x7b, 0x69, 0x64, + 0x7d, 0x3a, 0x73, 0x65, 0x74, 0x2d, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x12, 0x86, + 0x02, 0x0a, 0x12, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, + 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x12, 0x35, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, + 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, + 0x76, 0x31, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, + 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x36, 0x2e, 0x63, + 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x73, 0x65, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, + 0x55, 0x73, 0x65, 0x72, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x80, 0x01, 0x92, 0x41, 0x4e, 0x12, 0x4c, 0x52, 0x65, 0x6d, 0x6f, + 0x76, 0x65, 0x73, 0x20, 0x74, 0x68, 0x65, 0x20, 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x65, + 0x64, 0x20, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x20, 0x66, 0x72, 0x6f, 0x6d, 0x20, + 0x62, 0x65, 0x69, 0x6e, 0x67, 0x20, 0x61, 0x73, 0x73, 0x6f, 0x63, 0x69, 0x61, 0x74, 0x65, 0x64, + 0x20, 0x77, 0x69, 0x74, 0x68, 0x20, 0x74, 0x68, 0x65, 0x20, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, + 0x65, 0x64, 0x20, 0x55, 0x73, 0x65, 0x72, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x29, 0x3a, 0x01, + 0x2a, 0x62, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x1e, 0x2f, 0x76, 0x31, 0x2f, 0x75, 0x73, 0x65, + 0x72, 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x7d, 0x3a, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x2d, 0x61, + 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x42, 0x4d, 0x5a, 0x4b, 0x67, 0x69, 0x74, 0x68, 0x75, + 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, + 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x72, 0x79, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, + 0x6c, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, + 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x3b, 0x73, 0x65, + 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/internal/iam/query.go b/internal/iam/query.go index 85ed094de5..736fb4e8d9 100644 --- a/internal/iam/query.go +++ b/internal/iam/query.go @@ -180,4 +180,8 @@ const ( estimateCountRoles = ` select reltuples::bigint as estimate from pg_class where oid in ('iam_role'::regclass) ` + + estimateCountUsers = ` + select reltuples::bigint as estimate from pg_class where oid in ('iam_user'::regclass) + ` ) diff --git a/internal/iam/repository_role_test.go b/internal/iam/repository_role_test.go index 966a0884c2..1219d01d63 100644 --- a/internal/iam/repository_role_test.go +++ b/internal/iam/repository_role_test.go @@ -765,7 +765,8 @@ func TestRepository_ListRoles_Multiple_Scopes(t *testing.T) { got, ttime, err := repo.listRoles(context.Background(), []string{"global", org.GetPublicId(), proj.GetPublicId()}) require.NoError(t, err) - assert.Equal(t, total, len(got)) // Transaction timestamp should be within ~10 seconds of now + assert.Equal(t, total, len(got)) + // Transaction timestamp should be within ~10 seconds of now assert.True(t, time.Now().Before(ttime.Add(10*time.Second))) assert.True(t, time.Now().After(ttime.Add(-10*time.Second))) } diff --git a/internal/iam/repository_user.go b/internal/iam/repository_user.go index e55462157f..596a8a8c39 100644 --- a/internal/iam/repository_user.go +++ b/internal/iam/repository_user.go @@ -5,11 +5,14 @@ package iam import ( "context" + "database/sql" "fmt" "strings" + "time" "github.com/hashicorp/boundary/globals" "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/db/timestamp" "github.com/hashicorp/boundary/internal/errors" "github.com/hashicorp/boundary/internal/kms" "github.com/hashicorp/boundary/internal/oplog" @@ -209,19 +212,6 @@ func (r *Repository) DeleteUser(ctx context.Context, withPublicId string, _ ...O return rowsDeleted, nil } -// ListUsers lists users in the given scopes and supports the WithLimit option. -func (r *Repository) ListUsers(ctx context.Context, withScopeIds []string, opt ...Option) ([]*User, error) { - const op = "iam.(Repository).ListUsers" - if len(withScopeIds) == 0 { - return nil, errors.New(ctx, errors.InvalidParameter, op, "missing scope id") - } - users, err := r.getUsers(ctx, "", withScopeIds, opt...) - if err != nil { - return nil, errors.Wrap(ctx, err, op) - } - return users, nil -} - // LookupUserWithLogin will attempt to lookup the user with a matching // account id and return the user if found. If a user is not found and the // account's scope is not the PrimaryAuthMethod, then an error is returned. @@ -841,65 +831,160 @@ func associationChanges(ctx context.Context, reader db.Reader, userId string, ac } // lookupUser will lookup a single user and returns nil, nil when no user is found. +// no options are currently supported func (r *Repository) lookupUser(ctx context.Context, userId string, opt ...Option) (*User, error) { const op = "iam.(Repository).lookupUser" - users, err := r.getUsers(ctx, userId, nil, opt...) - if err != nil { + if userId == "" { + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing user id") + } + ret := allocUserAccountInfo() + ret.PublicId = userId + if err := r.reader.LookupById(ctx, ret); err != nil { + if errors.IsNotFoundError(err) { + return nil, nil + } return nil, errors.Wrap(ctx, err, op) } + return ret.shallowConversion(), nil +} + +// listUsers lists users in the given scopes and supports WithLimit option. +func (r *Repository) ListUsers(ctx context.Context, withScopeIds []string, opt ...Option) ([]*User, time.Time, error) { + const op = "iam.(Repository).listUsers" + if len(withScopeIds) == 0 { + return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "missing scope id") + } + opts := getOpts(opt...) + + limit := r.defaultLimit switch { - case len(users) == 0: - return nil, nil // not an error to return no rows for a lookup - case len(users) > 1: - return nil, errors.New(ctx, errors.NotSpecificIntegrity, op, fmt.Sprintf("%s matched more than 1 ", userId)) - default: - return users[0], nil + case opts.withLimit > 0: + // non-zero signals an override of the default limit for the repo. + limit = opts.withLimit + case opts.withLimit < 0: + return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "limit must be non-negative") } + + var args []any + whereClause := "scope_id in @scope_ids" + args = append(args, sql.Named("scope_ids", withScopeIds)) + + if opts.withStartPageAfterItem != nil { + whereClause = fmt.Sprintf("(create_time, public_id) < (@last_item_create_time, @last_item_id) and %s", whereClause) + args = append(args, + sql.Named("last_item_create_time", opts.withStartPageAfterItem.GetCreateTime()), + sql.Named("last_item_id", opts.withStartPageAfterItem.GetPublicId()), + ) + } + dbOpts := []db.Option{db.WithLimit(limit), db.WithOrder("create_time desc, public_id desc")} + return r.queryUsers(ctx, whereClause, args, dbOpts...) } -// getUsers allows the caller to specify to either lookup a specific User via -// its ID or search for a set of Users within a set of scopes. Passing both -// scopeIds and a userId is an error. The WithLimit option is supported and all -// other options are ignored. -// -// When no record is found then it returns nil, nil -func (r *Repository) getUsers(ctx context.Context, userId string, scopeIds []string, opt ...Option) ([]*User, error) { - const op = "iam.(Repository).getUsers" - if userId == "" && len(scopeIds) == 0 { - return nil, errors.New(ctx, errors.InvalidParameter, op, "missing search criteria: both user id and scope ids are empty") - } - if userId != "" && len(scopeIds) > 0 { - return nil, errors.New(ctx, errors.InvalidParameter, op, "searching for both a specific user id and scope ids is not supported") +// listUsersRefresh lists users in the given scopes and supports the +// WithLimit and WithStartPageAfterItem options. +func (r *Repository) listUsersRefresh(ctx context.Context, updatedAfter time.Time, withScopeIds []string, opt ...Option) ([]*User, time.Time, error) { + const op = "iam.(Repository).listUsersRefresh" + + switch { + case updatedAfter.IsZero(): + return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "missing updated after time") + + case len(withScopeIds) == 0: + return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "missing scope id") } opts := getOpts(opt...) - dbArgs := []db.Option{} + limit := r.defaultLimit - if opts.withLimit != 0 { + switch { + case opts.withLimit > 0: // non-zero signals an override of the default limit for the repo. limit = opts.withLimit + case opts.withLimit < 0: + return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "limit must be non-negative") } - dbArgs = append(dbArgs, db.WithLimit(limit)) var args []any - var where []string - switch { - case userId != "": - where, args = append(where, "public_id = ?"), append(args, userId) - default: - where, args = append(where, "scope_id in(?)"), append(args, scopeIds) - } - var usersAcctInfo []*userAccountInfo - err := r.reader.SearchWhere(ctx, &usersAcctInfo, strings.Join(where, " and "), args, dbArgs...) - if err != nil { - return nil, errors.Wrap(ctx, err, op) + whereClause := "update_time > @updated_after_time and scope_id in @scope_ids" + args = append(args, + sql.Named("updated_after_time", timestamp.New(updatedAfter)), + sql.Named("scope_ids", withScopeIds), + ) + if opts.withStartPageAfterItem != nil { + whereClause = fmt.Sprintf("(update_time, public_id) < (@last_item_update_time, @last_item_id) and %s", whereClause) + args = append(args, + sql.Named("last_item_update_time", opts.withStartPageAfterItem.GetUpdateTime()), + sql.Named("last_item_id", opts.withStartPageAfterItem.GetPublicId()), + ) } - if len(usersAcctInfo) == 0 { // we're done if nothing is found. - return nil, nil + + dbOpts := []db.Option{db.WithLimit(limit), db.WithOrder("update_time desc, public_id desc")} + return r.queryUsers(ctx, whereClause, args, dbOpts...) +} + +func (r *Repository) queryUsers(ctx context.Context, whereClause string, args []any, opt ...db.Option) ([]*User, time.Time, error) { + const op = "iam.(Repository).queryUsers" + + var transactionTimestamp time.Time + var ret []*userAccountInfo + if _, err := r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(rd db.Reader, w db.Writer) error { + var inRet []*userAccountInfo + if err := rd.SearchWhere(ctx, &inRet, whereClause, args, opt...); err != nil { + return errors.Wrap(ctx, err, op) + } + ret = inRet + var err error + transactionTimestamp, err = rd.Now(ctx) + return err + }); err != nil { + return nil, time.Time{}, err } - users := make([]*User, 0, len(usersAcctInfo)) - for _, u := range usersAcctInfo { + + users := make([]*User, 0, len(ret)) + for _, u := range ret { users = append(users, u.shallowConversion()) } - return users, nil + + return users, transactionTimestamp, nil +} + +// listUserDeletedIds lists the public IDs of any users deleted since the timestamp provided. +func (r *Repository) listUserDeletedIds(ctx context.Context, since time.Time) ([]string, time.Time, error) { + const op = "iam.(Repository).listUserDeletedIds" + var deletedResources []*deletedUser + var transactionTimestamp time.Time + if _, err := r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(r db.Reader, _ db.Writer) error { + if err := r.SearchWhere(ctx, &deletedResources, "delete_time >= ?", []any{since}); err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("failed to query deleted users")) + } + var err error + transactionTimestamp, err = r.Now(ctx) + if err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("failed to get transaction timestamp")) + } + return nil + }); err != nil { + return nil, time.Time{}, err + } + var dIds []string + for _, res := range deletedResources { + dIds = append(dIds, res.PublicId) + } + return dIds, transactionTimestamp, nil +} + +// estimatedUserCount returns an estimate of the total number of items in the iam_user table. +func (r *Repository) estimatedUserCount(ctx context.Context) (int, error) { + const op = "iam.(Repository).estimatedUserCount" + rows, err := r.reader.Query(ctx, estimateCountUsers, nil) + if err != nil { + return 0, errors.Wrap(ctx, err, op, errors.WithMsg("failed to query total users")) + } + var count int + for rows.Next() { + if err := r.reader.ScanRows(ctx, rows, &count); err != nil { + return 0, errors.Wrap(ctx, err, op, errors.WithMsg("failed to query total users")) + } + } + return count, nil } diff --git a/internal/iam/repository_user_iam_pkg_test.go b/internal/iam/repository_user_iam_pkg_test.go index 96dcafb9c1..959674658b 100644 --- a/internal/iam/repository_user_iam_pkg_test.go +++ b/internal/iam/repository_user_iam_pkg_test.go @@ -6,6 +6,7 @@ package iam import ( "context" "testing" + "time" "github.com/hashicorp/boundary/internal/db" dbassert "github.com/hashicorp/boundary/internal/db/assert" @@ -332,3 +333,270 @@ func TestRepository_dissociateUserWithAccount(t *testing.T) { }) } } + +func TestRepository_ListUsers_internal(t *testing.T) { + t.Parallel() + conn, _ := db.TestSetup(t, "postgres") + const testLimit = 10 + wrapper := db.TestWrapper(t) + repo := TestRepo(t, conn, wrapper, WithLimit(testLimit)) + org, _ := TestScopes(t, repo) + + type args struct { + withOrgId string + opt []Option + } + tests := []struct { + name string + createCnt int + args args + wantCnt int + wantErr bool + }{ + { + name: "negative limit", + createCnt: testLimit + 1, + args: args{ + withOrgId: org.PublicId, + opt: []Option{WithLimit(-1)}, + }, + wantErr: true, + }, + { + name: "default-limit", + createCnt: testLimit + 1, + args: args{ + withOrgId: org.PublicId, + }, + wantCnt: testLimit, + wantErr: false, + }, + { + name: "custom-limit", + createCnt: testLimit + 1, + args: args{ + withOrgId: org.PublicId, + opt: []Option{WithLimit(3)}, + }, + wantCnt: 3, + wantErr: false, + }, + { + name: "bad-org", + createCnt: 1, + args: args{ + withOrgId: "bad-id", + }, + wantCnt: 0, + wantErr: false, + }, + } + type userInfo struct { + email string + fullName string + primaryAcctId string + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + t.Cleanup(func() { + db.TestDeleteWhere(t, conn, func() any { u := AllocUser(); return &u }(), "public_id != 'u_anon' and public_id != 'u_auth' and public_id != 'u_recovery'") + }) + testUsers := []*User{} + wantUserInfo := map[string]userInfo{} + for i := 0; i < tt.createCnt; i++ { + u := TestUser(t, repo, org.PublicId) + testUsers = append(testUsers, u) + } + assert.Equal(tt.createCnt, len(testUsers)) + got, ttime, err := repo.ListUsers(context.Background(), []string{tt.args.withOrgId}, tt.args.opt...) + if tt.wantErr { + require.Error(err) + return + } + require.NoError(err) + assert.Equal(tt.wantCnt, len(got)) + for _, u := range got { + assert.Equal(wantUserInfo[u.PublicId], userInfo{ + email: u.Email, + fullName: u.FullName, + primaryAcctId: u.PrimaryAccountId, + }) + } + // Transaction timestamp should be within ~10 seconds of now + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + }) + } + t.Run("withStartPageAfter", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + ctx := context.Background() + + for i := 0; i < 10; i++ { + _ = TestUser(t, repo, org.GetPublicId()) + } + + page1, ttime, err := repo.ListUsers(ctx, []string{org.GetPublicId()}, WithLimit(2)) + require.NoError(err) + require.Len(page1, 2) + // Transaction timestamp should be within ~10 seconds of now + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + page2, ttime, err := repo.ListUsers(ctx, []string{org.GetPublicId()}, WithLimit(2), WithStartPageAfterItem(page1[1])) + require.NoError(err) + require.Len(page2, 2) + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + for _, item := range page1 { + assert.NotEqual(item.GetPublicId(), page2[0].GetPublicId()) + assert.NotEqual(item.GetPublicId(), page2[1].GetPublicId()) + } + page3, ttime, err := repo.ListUsers(ctx, []string{org.GetPublicId()}, WithLimit(2), WithStartPageAfterItem(page2[1])) + require.NoError(err) + require.Len(page3, 2) + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + for _, item := range page2 { + assert.NotEqual(item.GetPublicId(), page3[0].GetPublicId()) + assert.NotEqual(item.GetPublicId(), page3[1].GetPublicId()) + } + page4, ttime, err := repo.ListUsers(ctx, []string{org.GetPublicId()}, WithLimit(2), WithStartPageAfterItem(page3[1])) + require.NoError(err) + assert.Len(page4, 2) + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + for _, item := range page3 { + assert.NotEqual(item.GetPublicId(), page4[0].GetPublicId()) + assert.NotEqual(item.GetPublicId(), page4[1].GetPublicId()) + } + page5, ttime, err := repo.ListUsers(ctx, []string{org.GetPublicId()}, WithLimit(2), WithStartPageAfterItem(page4[1])) + require.NoError(err) + assert.Len(page5, 2) + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + for _, item := range page4 { + assert.NotEqual(item.GetPublicId(), page5[0].GetPublicId()) + assert.NotEqual(item.GetPublicId(), page5[1].GetPublicId()) + } + page6, ttime, err := repo.ListUsers(ctx, []string{org.GetPublicId()}, WithLimit(2), WithStartPageAfterItem(page5[1])) + require.NoError(err) + assert.Empty(page6) + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + + // Create 2 new users + newR1 := TestUser(t, repo, org.GetPublicId()) + newR2 := TestUser(t, repo, org.GetPublicId()) + + // since it will return newest to oldest, we get page1[1] first + page7, ttime, err := repo.listUsersRefresh( + ctx, + time.Now().Add(-1*time.Second), + []string{org.GetPublicId()}, + WithLimit(1), + ) + require.NoError(err) + require.Len(page7, 1) + require.Equal(page7[0].GetPublicId(), newR2.GetPublicId()) + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + + page8, ttime, err := repo.listUsersRefresh( + context.Background(), + time.Now().Add(-1*time.Second), + []string{org.GetPublicId()}, + WithLimit(1), + WithStartPageAfterItem(page7[0]), + ) + require.NoError(err) + require.Len(page8, 1) + require.Equal(page8[0].GetPublicId(), newR1.GetPublicId()) + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + }) +} + +func Test_listUserDeletedIds(t *testing.T) { + t.Parallel() + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + wrapper := db.TestWrapper(t) + repo := TestRepo(t, conn, wrapper) + org, _ := TestScopes(t, repo) + r := TestUser(t, repo, org.GetPublicId()) + + // Expect no entries at the start + deletedIds, ttime, err := repo.listUserDeletedIds(ctx, time.Now().AddDate(-1, 0, 0)) + require.NoError(t, err) + require.Empty(t, deletedIds) + // Transaction timestamp should be within ~10 seconds of now + assert.True(t, time.Now().Before(ttime.Add(10*time.Second))) + assert.True(t, time.Now().After(ttime.Add(-10*time.Second))) + + // Delete a user + _, err = repo.DeleteUser(ctx, r.GetPublicId()) + require.NoError(t, err) + + // Expect a single entry + deletedIds, ttime, err = repo.listUserDeletedIds(ctx, time.Now().AddDate(-1, 0, 0)) + require.NoError(t, err) + require.Equal(t, []string{r.GetPublicId()}, deletedIds) + // Transaction timestamp should be within ~10 seconds of now + assert.True(t, time.Now().Before(ttime.Add(10*time.Second))) + assert.True(t, time.Now().After(ttime.Add(-10*time.Second))) + + // Try again with the time set to now, expect no entries + deletedIds, ttime, err = repo.listUserDeletedIds(ctx, time.Now()) + require.NoError(t, err) + require.Empty(t, deletedIds) + // Transaction timestamp should be within ~10 seconds of now + assert.True(t, time.Now().Before(ttime.Add(10*time.Second))) + assert.True(t, time.Now().After(ttime.Add(-10*time.Second))) +} + +func Test_estimatedUserCount(t *testing.T) { + t.Parallel() + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + sqlDb, err := conn.SqlDB(ctx) + require.NoError(t, err) + rw := db.New(conn) + wrapper := db.TestWrapper(t) + kms := kms.TestKms(t, conn, wrapper) + repo, err := NewRepository(ctx, rw, rw, kms) + require.NoError(t, err) + + // Run analyze to update estimate + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + + // Check total entries at start, expect 3 + // u_anon, u_auth, and u_recovery + numItems, err := repo.estimatedUserCount(ctx) + require.NoError(t, err) + assert.Equal(t, 3, numItems) + + iamRepo := TestRepo(t, conn, wrapper) + org, _ := TestScopes(t, iamRepo) + // Create a user, expect 1 entry + u := TestUser(t, repo, org.GetPublicId()) + + // Run analyze to update estimate + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + numItems, err = repo.estimatedUserCount(ctx) + require.NoError(t, err) + assert.Equal(t, 4, numItems) + + // Delete the user, expect 3 again + _, err = repo.DeleteUser(ctx, u.GetPublicId()) + require.NoError(t, err) + + // Run analyze to update estimate + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + + numItems, err = repo.estimatedUserCount(ctx) + require.NoError(t, err) + assert.Equal(t, 3, numItems) +} diff --git a/internal/iam/repository_user_test.go b/internal/iam/repository_user_test.go index 77c5a5941b..7bda995f62 100644 --- a/internal/iam/repository_user_test.go +++ b/internal/iam/repository_user_test.go @@ -608,14 +608,13 @@ func TestRepository_ListUsers(t *testing.T) { wantErr bool }{ { - name: "no-limit", + name: "negative-limit", createCnt: testLimit + 1, args: args{ withOrgId: org.PublicId, opt: []iam.Option{iam.WithLimit(-1)}, }, - wantCnt: testLimit + 1, - wantErr: false, + wantErr: true, }, { name: "default-limit", @@ -674,7 +673,7 @@ func TestRepository_ListUsers(t *testing.T) { } assert.Equal(tt.createCnt, len(testUsers)) - got, err := repo.ListUsers(context.Background(), []string{tt.args.withOrgId}, tt.args.opt...) + got, _, err := repo.ListUsers(context.Background(), []string{tt.args.withOrgId}, tt.args.opt...) if tt.wantErr { require.Error(err) return @@ -699,7 +698,9 @@ func TestRepository_ListUsers_Multiple_Scopes(t *testing.T) { repo := iam.TestRepo(t, conn, wrapper) org, _ := iam.TestScopes(t, repo) - db.TestDeleteWhere(t, conn, func() any { i := iam.AllocUser(); return &i }(), "public_id != 'u_anon' and public_id != 'u_auth' and public_id != 'u_recovery'") + t.Cleanup(func() { + db.TestDeleteWhere(t, conn, func() any { i := iam.AllocUser(); return &i }(), "public_id != 'u_anon' and public_id != 'u_auth' and public_id != 'u_recovery'") + }) const numPerScope = 10 var total int = 3 // anon, auth, recovery @@ -710,9 +711,12 @@ func TestRepository_ListUsers_Multiple_Scopes(t *testing.T) { total++ } - got, err := repo.ListUsers(context.Background(), []string{"global", org.GetPublicId()}) + got, ttime, err := repo.ListUsers(context.Background(), []string{"global", org.GetPublicId()}) require.NoError(t, err) assert.Equal(t, total, len(got)) + // Transaction timestamp should be within ~10 seconds of now + assert.True(t, time.Now().Before(ttime.Add(10*time.Second))) + assert.True(t, time.Now().After(ttime.Add(-10*time.Second))) } func TestRepository_LookupUserWithLogin(t *testing.T) { diff --git a/internal/iam/service_list.go b/internal/iam/service_list.go index ffb470984a..aeb74094ed 100644 --- a/internal/iam/service_list.go +++ b/internal/iam/service_list.go @@ -51,3 +51,44 @@ func ListRoles( return pagination.List(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedRoleCount) } + +// ListUsers lists up to page size users, filtering out entries that +// do not pass the filter item function. It will automatically request +// more users from the database, at page size chunks, to fill the page. +// It returns a new list token used to continue pagination or refresh items. +// Users are ordered by create time descending (most recently created first). +func ListUsers( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[*User], + repo *Repository, + withScopeIds []string, +) (*pagination.ListResponse[*User], error) { + const op = "iam.ListUsers" + + switch { + case len(grantsHash) == 0: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing grants hash") + case pageSize < 1: + return nil, errors.New(ctx, errors.InvalidParameter, op, "page size must be at least 1") + case filterItemFn == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing filter item callback") + case repo == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing repo") + case withScopeIds == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing scope ids") + } + + listItemsFn := func(ctx context.Context, lastPageItem *User, limit int) ([]*User, time.Time, error) { + opts := []Option{ + WithLimit(limit), + } + if lastPageItem != nil { + opts = append(opts, WithStartPageAfterItem(lastPageItem)) + } + return repo.ListUsers(ctx, withScopeIds, opts...) + } + + return pagination.List(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedUserCount) +} diff --git a/internal/iam/service_list_ext_test.go b/internal/iam/service_list_ext_test.go index 652679365f..6e866d8ba7 100644 --- a/internal/iam/service_list_ext_test.go +++ b/internal/iam/service_list_ext_test.go @@ -611,3 +611,630 @@ func TestService_ListRoles(t *testing.T) { require.Empty(t, resp3.Items) }) } + +func TestService_ListUsers(t *testing.T) { + fiveDaysAgo := time.Now() + // Set database read timeout to avoid duplicates in response + oldReadTimeout := globals.RefreshReadLookbackDuration + globals.RefreshReadLookbackDuration = 0 + t.Cleanup(func() { + globals.RefreshReadLookbackDuration = oldReadTimeout + }) + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + sqlDB, err := conn.SqlDB(context.Background()) + require.NoError(t, err) + rw := db.New(conn) + wrapper := db.TestWrapper(t) + kms := kms.TestKms(t, conn, wrapper) + iamRepo := iam.TestRepo(t, conn, wrapper) + org, _ := iam.TestScopes(t, iamRepo) + + us, _, err := iamRepo.ListUsers(ctx, []string{"global"}) + require.NoError(t, err) + require.Len(t, us, 3) + + // Since ListUsers is supposed to return the newest users first, we need to + // reverse the slice to match the order of the allResources slice. + slices.Reverse(us) + var allResources []*iam.User + allResources = append(allResources, us...) + + // Create 5 users, which, with u_anon, u_recovery, and u_auth, will add + // up to 8 users. + for i := 0; i < 5; i++ { + r := iam.TestUser(t, iamRepo, org.GetPublicId()) + allResources = append(allResources, r) + } + + repo, err := iam.NewRepository(ctx, rw, rw, kms) + require.NoError(t, err) + + // Reverse since we read items in descending order (newest first) + slices.Reverse(allResources) + + // Run analyze to update postgres estimates + _, err = sqlDB.ExecContext(ctx, "analyze") + require.NoError(t, err) + + cmpIgnoreUnexportedOpts := cmpopts.IgnoreUnexported(iam.User{}, store.User{}, timestamp.Timestamp{}, timestamppb.Timestamp{}) + + t.Run("List validation", func(t *testing.T) { + t.Parallel() + t.Run("missing grants hash", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + _, err := iam.ListUsers(ctx, nil, 1, filterFunc, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + _, err := iam.ListUsers(ctx, []byte("some hash"), 0, filterFunc, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "page size must be at least 1") + }) + t.Run("negative page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + _, err := iam.ListUsers(ctx, []byte("some hash"), -1, filterFunc, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "page size must be at least 1") + }) + t.Run("nil filter func", func(t *testing.T) { + t.Parallel() + _, err := iam.ListUsers(ctx, []byte("some hash"), 1, nil, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("nil repo", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + _, err := iam.ListUsers(ctx, []byte("some hash"), 1, filterFunc, nil, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing scope ids", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + _, err := iam.ListUsers(ctx, []byte("some hash"), 1, filterFunc, repo, nil) + require.ErrorContains(t, err, "missing scope ids") + }) + }) + t.Run("ListPage validation", func(t *testing.T) { + t.Parallel() + t.Run("missing grants hash", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.User, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersPage(ctx, nil, 1, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.User, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersPage(ctx, []byte("some hash"), 0, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "page size must be at least 1") + }) + t.Run("negative page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.User, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersPage(ctx, []byte("some hash"), -1, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "page size must be at least 1") + }) + t.Run("nil filter func", func(t *testing.T) { + t.Parallel() + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.User, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersPage(ctx, []byte("some hash"), 1, nil, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("nil token", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + _, err = iam.ListUsersPage(ctx, []byte("some hash"), 1, filterFunc, nil, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing token") + }) + t.Run("wrong token type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.User, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "token did not have a pagination token component") + }) + t.Run("nil repo", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.User, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersPage(ctx, []byte("some hash"), 1, filterFunc, tok, nil, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing scope ids", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.User, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, nil) + require.ErrorContains(t, err, "missing scope ids") + }) + t.Run("wrong token resource type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.Target, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, []string{"global"}) + require.ErrorContains(t, err, "token did not have a user resource type") + }) + }) + t.Run("ListRefresh validation", func(t *testing.T) { + t.Parallel() + t.Run("missing grants hash", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.User, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersRefresh(ctx, nil, 1, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.User, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersRefresh(ctx, []byte("some hash"), 0, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "page size must be at least 1") + }) + t.Run("negative page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.User, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersRefresh(ctx, []byte("some hash"), -1, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "page size must be at least 1") + }) + t.Run("nil filter func", func(t *testing.T) { + t.Parallel() + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.User, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersRefresh(ctx, []byte("some hash"), 1, nil, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("nil token", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + _, err = iam.ListUsersRefresh(ctx, []byte("some hash"), 1, filterFunc, nil, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing token") + }) + t.Run("wrong token type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.User, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersRefresh(ctx, []byte("some hash"), 1, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "token did not have a start-refresh token component") + }) + t.Run("nil repo", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.User, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersRefresh(ctx, []byte("some hash"), 1, filterFunc, tok, nil, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing scope ids", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.User, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersRefresh(ctx, []byte("some hash"), 1, filterFunc, tok, repo, nil) + require.ErrorContains(t, err, "missing scope ids") + }) + t.Run("wrong token resource type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.Target, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersRefresh(ctx, []byte("some hash"), 1, filterFunc, tok, repo, []string{"global"}) + require.ErrorContains(t, err, "token did not have a user resource type") + }) + }) + t.Run("ListRefreshPage validation", func(t *testing.T) { + t.Parallel() + t.Run("missing grants hash", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.User, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersRefreshPage(ctx, nil, 1, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.User, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersRefreshPage(ctx, []byte("some hash"), 0, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "page size must be at least 1") + }) + t.Run("negative page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.User, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersRefreshPage(ctx, []byte("some hash"), -1, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "page size must be at least 1") + }) + t.Run("nil filter func", func(t *testing.T) { + t.Parallel() + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.User, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersRefreshPage(ctx, []byte("some hash"), 1, nil, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("nil token", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + _, err = iam.ListUsersRefreshPage(ctx, []byte("some hash"), 1, filterFunc, nil, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing token") + }) + t.Run("wrong token type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.User, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersRefreshPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "token did not have a refresh token component") + }) + t.Run("nil repo", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.User, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersRefreshPage(ctx, []byte("some hash"), 1, filterFunc, tok, nil, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing scope ids", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.User, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersRefreshPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, nil) + require.ErrorContains(t, err, "missing scope ids") + }) + t.Run("wrong token resource type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.Target, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = iam.ListUsersRefreshPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, []string{"global"}) + require.ErrorContains(t, err, "token did not have a user resource type") + }) + }) + + t.Run("simple pagination", func(t *testing.T) { + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + resp, err := iam.ListUsers(ctx, []byte("some hash"), 1, filterFunc, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.NotNil(t, resp.ListToken) + require.Equal(t, resp.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp.CompleteListing) + require.Equal(t, resp.EstimatedItemCount, len(allResources)) + require.Empty(t, resp.DeletedIds) + require.Len(t, resp.Items, 1) + require.Empty(t, cmp.Diff(resp.Items[0], allResources[0], cmpIgnoreUnexportedOpts)) + + resp2, err := iam.ListUsersPage(ctx, []byte("some hash"), 1, filterFunc, resp.ListToken, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.Equal(t, resp2.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp2.CompleteListing) + require.Equal(t, resp2.EstimatedItemCount, 8) + require.Empty(t, resp2.DeletedIds) + require.Len(t, resp2.Items, 1) + require.Empty(t, cmp.Diff(resp2.Items[0], allResources[1], cmpIgnoreUnexportedOpts)) + + resp3, err := iam.ListUsersPage(ctx, []byte("some hash"), 1, filterFunc, resp2.ListToken, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.Equal(t, resp3.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp3.CompleteListing) + require.Equal(t, resp3.EstimatedItemCount, 8) + require.Empty(t, resp3.DeletedIds) + require.Len(t, resp3.Items, 1) + require.Empty(t, cmp.Diff(resp3.Items[0], allResources[2], cmpIgnoreUnexportedOpts)) + + resp4, err := iam.ListUsersPage(ctx, []byte("some hash"), 1, filterFunc, resp3.ListToken, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.Equal(t, resp4.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp4.CompleteListing) + require.Equal(t, resp4.EstimatedItemCount, 8) + require.Empty(t, resp4.DeletedIds) + require.Len(t, resp4.Items, 1) + require.Empty(t, cmp.Diff(resp4.Items[0], allResources[3], cmpIgnoreUnexportedOpts)) + + resp5, err := iam.ListUsersPage(ctx, []byte("some hash"), 1, filterFunc, resp4.ListToken, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.Equal(t, resp5.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp5.CompleteListing) + require.Equal(t, resp5.EstimatedItemCount, 8) + require.Empty(t, resp5.DeletedIds) + require.Len(t, resp5.Items, 1) + require.Empty(t, cmp.Diff(resp5.Items[0], allResources[4], cmpIgnoreUnexportedOpts)) + + resp6, err := iam.ListUsersPage(ctx, []byte("some hash"), 1, filterFunc, resp5.ListToken, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.Equal(t, resp6.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp6.CompleteListing) + require.Equal(t, resp6.EstimatedItemCount, 8) + require.Empty(t, resp6.DeletedIds) + require.Len(t, resp6.Items, 1) + require.Empty(t, cmp.Diff(resp6.Items[0], allResources[5], cmpIgnoreUnexportedOpts)) + + resp7, err := iam.ListUsersPage(ctx, []byte("some hash"), 1, filterFunc, resp6.ListToken, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.Equal(t, resp7.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp7.CompleteListing) + require.Equal(t, resp7.EstimatedItemCount, 8) + require.Empty(t, resp7.DeletedIds) + require.Len(t, resp7.Items, 1) + require.Empty(t, cmp.Diff(resp7.Items[0], allResources[6], cmpIgnoreUnexportedOpts)) + + resp8, err := iam.ListUsersPage(ctx, []byte("some hash"), 1, filterFunc, resp7.ListToken, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.Equal(t, resp8.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp8.CompleteListing) + require.Equal(t, resp8.EstimatedItemCount, 8) + require.Empty(t, resp8.DeletedIds) + require.Len(t, resp8.Items, 1) + require.Empty(t, cmp.Diff(resp8.Items[0], allResources[7], cmpIgnoreUnexportedOpts)) + + // Finished initial pagination phase, request refresh + // Expect no results. + resp9, err := iam.ListUsersRefresh(ctx, []byte("some hash"), 1, filterFunc, resp8.ListToken, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.Equal(t, resp9.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp9.CompleteListing) + require.Equal(t, resp9.EstimatedItemCount, 8) + require.Empty(t, resp9.DeletedIds) + require.Empty(t, resp9.Items) + + // Create some new users + newR1 := iam.TestUser(t, iamRepo, org.GetPublicId()) + newR2 := iam.TestUser(t, iamRepo, org.GetPublicId()) + t.Cleanup(func() { + _, err = repo.DeleteUser(ctx, newR1.GetPublicId()) + require.NoError(t, err) + _, err = repo.DeleteUser(ctx, newR2.GetPublicId()) + require.NoError(t, err) + // Run analyze to update count estimate + _, err = sqlDB.ExecContext(ctx, "analyze") + require.NoError(t, err) + }) + // Run analyze to update count estimate + _, err = sqlDB.ExecContext(ctx, "analyze") + require.NoError(t, err) + + // Refresh again, should get newR2 + resp10, err := iam.ListUsersRefresh(ctx, []byte("some hash"), 1, filterFunc, resp9.ListToken, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.Equal(t, resp10.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp10.CompleteListing) + require.Equal(t, resp10.EstimatedItemCount, 10) + require.Empty(t, resp10.DeletedIds) + require.Len(t, resp10.Items, 1) + require.Empty(t, cmp.Diff(resp10.Items[0], newR2, cmpIgnoreUnexportedOpts)) + + // Refresh again, should get newR1 + resp11, err := iam.ListUsersRefreshPage(ctx, []byte("some hash"), 1, filterFunc, resp10.ListToken, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.Equal(t, resp11.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp11.CompleteListing) + require.Equal(t, resp11.EstimatedItemCount, 10) + require.Empty(t, resp11.DeletedIds) + require.Len(t, resp11.Items, 1) + require.Empty(t, cmp.Diff(resp11.Items[0], newR1, cmpIgnoreUnexportedOpts)) + + // Refresh again, should get no results + resp12, err := iam.ListUsersRefresh(ctx, []byte("some hash"), 1, filterFunc, resp11.ListToken, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.Equal(t, resp12.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp12.CompleteListing) + require.Equal(t, resp12.EstimatedItemCount, 10) + require.Empty(t, resp12.DeletedIds) + require.Empty(t, resp12.Items) + }) + + t.Run("simple pagination with aggressive filtering", func(t *testing.T) { + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return r.GetPublicId() == allResources[1].GetPublicId() || + r.GetPublicId() == allResources[len(allResources)-1].GetPublicId(), nil + } + resp, err := iam.ListUsers(ctx, []byte("some hash"), 1, filterFunc, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.NotNil(t, resp.ListToken) + require.Equal(t, resp.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp.CompleteListing) + require.Equal(t, resp.EstimatedItemCount, 8) + require.Empty(t, resp.DeletedIds) + require.Len(t, resp.Items, 1) + require.Empty(t, cmp.Diff(resp.Items[0], allResources[1], cmpIgnoreUnexportedOpts)) + + resp2, err := iam.ListUsersPage(ctx, []byte("some hash"), 1, filterFunc, resp.ListToken, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.NotNil(t, resp2.ListToken) + require.Equal(t, resp2.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp2.CompleteListing) + require.Equal(t, resp2.EstimatedItemCount, 8) + require.Empty(t, resp2.DeletedIds) + require.Len(t, resp2.Items, 1) + require.Empty(t, cmp.Diff(resp2.Items[0], allResources[len(allResources)-1], cmpIgnoreUnexportedOpts)) + + // request a refresh, nothing should be returned + resp3, err := iam.ListUsersRefresh(ctx, []byte("some hash"), 1, filterFunc, resp.ListToken, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.Equal(t, resp3.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp3.CompleteListing) + require.Equal(t, resp3.EstimatedItemCount, 8) + require.Empty(t, resp3.DeletedIds) + require.Empty(t, resp3.Items) + + // Create some new tokens + newR1 := iam.TestUser(t, iamRepo, org.GetPublicId()) + newR2 := iam.TestUser(t, iamRepo, org.GetPublicId()) + newR3 := iam.TestUser(t, iamRepo, org.GetPublicId()) + newR4 := iam.TestUser(t, iamRepo, org.GetPublicId()) + // Run analyze to update count estimate + _, err = sqlDB.ExecContext(ctx, "analyze") + require.NoError(t, err) + t.Cleanup(func() { + _, err = repo.DeleteUser(ctx, newR1.GetPublicId()) + require.NoError(t, err) + _, err = repo.DeleteUser(ctx, newR2.GetPublicId()) + require.NoError(t, err) + _, err = repo.DeleteUser(ctx, newR3.GetPublicId()) + require.NoError(t, err) + _, err = repo.DeleteUser(ctx, newR4.GetPublicId()) + require.NoError(t, err) + // Run analyze to update count estimate + _, err = sqlDB.ExecContext(ctx, "analyze") + require.NoError(t, err) + }) + + filterFunc = func(_ context.Context, r *iam.User) (bool, error) { + return r.GetPublicId() == newR3.GetPublicId() || + r.GetPublicId() == newR1.GetPublicId(), nil + } + // Refresh again, should get newR3 + resp4, err := iam.ListUsersRefresh(ctx, []byte("some hash"), 1, filterFunc, resp3.ListToken, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.Equal(t, resp4.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp4.CompleteListing) + require.Equal(t, resp4.EstimatedItemCount, 12) + require.Empty(t, resp4.DeletedIds) + require.Len(t, resp4.Items, 1) + require.Empty(t, cmp.Diff(resp4.Items[0], newR3, cmpIgnoreUnexportedOpts)) + + // Refresh again, should get newR1 + resp5, err := iam.ListUsersRefreshPage(ctx, []byte("some hash"), 1, filterFunc, resp4.ListToken, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.Equal(t, resp5.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp5.CompleteListing) + require.Equal(t, resp5.EstimatedItemCount, 12) + require.Empty(t, resp5.DeletedIds) + require.Len(t, resp5.Items, 1) + require.Empty(t, cmp.Diff(resp5.Items[0], newR1, cmpIgnoreUnexportedOpts)) + }) + + t.Run("simple pagination with deletion", func(t *testing.T) { + filterFunc := func(_ context.Context, r *iam.User) (bool, error) { + return true, nil + } + deletedUserId := allResources[0].GetPublicId() + _, err := repo.DeleteUser(ctx, deletedUserId) + require.NoError(t, err) + allResources = allResources[1:] + + // Run analyze to update count estimate + _, err = sqlDB.ExecContext(ctx, "analyze") + require.NoError(t, err) + + resp, err := iam.ListUsers(ctx, []byte("some hash"), 1, filterFunc, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.NotNil(t, resp.ListToken) + require.Equal(t, resp.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp.CompleteListing) + require.Equal(t, resp.EstimatedItemCount, 7) + require.Empty(t, resp.DeletedIds) + require.Len(t, resp.Items, 1) + require.Empty(t, cmp.Diff(resp.Items[0], allResources[0], cmpIgnoreUnexportedOpts)) + + // request remaining results + resp2, err := iam.ListUsersPage(ctx, []byte("some hash"), 6, filterFunc, resp.ListToken, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.Equal(t, resp2.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp2.CompleteListing) + require.Equal(t, resp2.EstimatedItemCount, 7) + require.Empty(t, resp2.DeletedIds) + require.Len(t, resp2.Items, 6) + require.Empty(t, cmp.Diff(resp2.Items, allResources[1:], cmpIgnoreUnexportedOpts)) + + deletedUserId = allResources[0].GetPublicId() + _, err = repo.DeleteUser(ctx, deletedUserId) + require.NoError(t, err) + allResources = allResources[1:] + + // Run analyze to update count estimate + _, err = sqlDB.ExecContext(ctx, "analyze") + require.NoError(t, err) + + // request a refresh, nothing should be returned except the deleted id + resp3, err := iam.ListUsersRefresh(ctx, []byte("some hash"), 1, filterFunc, resp2.ListToken, repo, []string{org.GetPublicId(), "global"}) + require.NoError(t, err) + require.Equal(t, resp3.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp3.CompleteListing) + require.Equal(t, resp3.EstimatedItemCount, 6) + require.Contains(t, resp3.DeletedIds, deletedUserId) + require.Empty(t, resp3.Items) + }) +} diff --git a/internal/iam/service_list_page.go b/internal/iam/service_list_page.go index efbc6ed81a..280b1a0a58 100644 --- a/internal/iam/service_list_page.go +++ b/internal/iam/service_list_page.go @@ -68,3 +68,59 @@ func ListRolesPage( return pagination.ListPage(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedRoleCount, tok) } + +// ListUsersPage lists up to page size users, filtering out entries that +// do not pass the filter item function. It will automatically request +// more users from the database, at page size chunks, to fill the page. +// It will start its paging based on the information in the token. +// It returns a new list token used to continue pagination or refresh items. +// Users are ordered by create time descending (most recently created first). +func ListUsersPage( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[*User], + tok *listtoken.Token, + repo *Repository, + withScopeIds []string, +) (*pagination.ListResponse[*User], error) { + const op = "iam.ListUsersPage" + + switch { + case len(grantsHash) == 0: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing grants hash") + case pageSize < 1: + return nil, errors.New(ctx, errors.InvalidParameter, op, "page size must be at least 1") + case filterItemFn == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing filter item callback") + case tok == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing token") + case repo == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing repo") + case withScopeIds == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing scope ids") + case tok.ResourceType != resource.User: + return nil, errors.New(ctx, errors.InvalidParameter, op, "token did not have a user resource type") + } + if _, ok := tok.Subtype.(*listtoken.PaginationToken); !ok { + return nil, errors.New(ctx, errors.InvalidParameter, op, "token did not have a pagination token component") + } + + listItemsFn := func(ctx context.Context, lastPageItem *User, limit int) ([]*User, time.Time, error) { + opts := []Option{ + WithLimit(limit), + } + if lastPageItem != nil { + opts = append(opts, WithStartPageAfterItem(lastPageItem)) + } else { + lastItem, err := tok.LastItem(ctx) + if err != nil { + return nil, time.Time{}, err + } + opts = append(opts, WithStartPageAfterItem(lastItem)) + } + return repo.ListUsers(ctx, withScopeIds, opts...) + } + + return pagination.ListPage(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedUserCount, tok) +} diff --git a/internal/iam/service_list_refresh.go b/internal/iam/service_list_refresh.go index 6d738ad82d..a3766acd6e 100644 --- a/internal/iam/service_list_refresh.go +++ b/internal/iam/service_list_refresh.go @@ -74,3 +74,64 @@ func ListRolesRefresh( return pagination.ListRefresh(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedRoleCount, listDeletedIdsFn, tok) } + +// ListUsersRefresh lists up to page size users, filtering out entries that +// do not pass the filter item function. It will automatically request +// more users from the database, at page size chunks, to fill the page. +// It will start its paging based on the information in the token. +// It returns a new list token used to continue pagination or refresh items. +// Users are ordered by update time descending (most recently updated first). +// Users may contain items that were already returned during the initial +// pagination phase. It also returns a list of any users deleted since the +// start of the initial pagination phase or last response. +func ListUsersRefresh( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[*User], + tok *listtoken.Token, + repo *Repository, + withScopeIds []string, +) (*pagination.ListResponse[*User], error) { + const op = "iam.ListUsersRefresh" + + switch { + case len(grantsHash) == 0: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing grants hash") + case pageSize < 1: + return nil, errors.New(ctx, errors.InvalidParameter, op, "page size must be at least 1") + case filterItemFn == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing filter item callback") + case tok == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing token") + case repo == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing repo") + case withScopeIds == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing scope ids") + case tok.ResourceType != resource.User: + return nil, errors.New(ctx, errors.InvalidParameter, op, "token did not have a user resource type") + } + rt, ok := tok.Subtype.(*listtoken.StartRefreshToken) + if !ok { + return nil, errors.New(ctx, errors.InvalidParameter, op, "token did not have a start-refresh token component") + } + + listItemsFn := func(ctx context.Context, lastPageItem *User, limit int) ([]*User, time.Time, error) { + opts := []Option{ + WithLimit(limit), + } + if lastPageItem != nil { + opts = append(opts, WithStartPageAfterItem(lastPageItem)) + } + // Add the database read timeout to account for any creations missed due to concurrent + // transactions in the initial pagination phase. + return repo.listUsersRefresh(ctx, rt.PreviousPhaseUpperBound.Add(-globals.RefreshReadLookbackDuration), withScopeIds, opts...) + } + listDeletedIdsFn := func(ctx context.Context, since time.Time) ([]string, time.Time, error) { + // Add the database read timeout to account for any deletions missed due to concurrent + // transactions in previous requests. + return repo.listUserDeletedIds(ctx, since.Add(-globals.RefreshReadLookbackDuration)) + } + + return pagination.ListRefresh(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedUserCount, listDeletedIdsFn, tok) +} diff --git a/internal/iam/service_list_refresh_page.go b/internal/iam/service_list_refresh_page.go index 0a474cae0a..9ad27554c9 100644 --- a/internal/iam/service_list_refresh_page.go +++ b/internal/iam/service_list_refresh_page.go @@ -81,3 +81,71 @@ func ListRolesRefreshPage( return pagination.ListRefreshPage(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedRoleCount, listDeletedIdsFn, tok) } + +// ListUsersRefreshPage lists up to page size users, filtering out entries that +// do not pass the filter item function. It will automatically request +// more users from the database, at page size chunks, to fill the page. +// It will start its paging based on the information in the token. +// It returns a new list token used to continue pagination or refresh items. +// Users are ordered by update time descending (most recently updated first). +// Users may contain items that were already returned during the initial +// pagination phase. It also returns a list of any users deleted since the +// last response. +func ListUsersRefreshPage( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[*User], + tok *listtoken.Token, + repo *Repository, + withScopeIds []string, +) (*pagination.ListResponse[*User], error) { + const op = "iam.ListUsersRefreshPage" + + switch { + case len(grantsHash) == 0: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing grants hash") + case pageSize < 1: + return nil, errors.New(ctx, errors.InvalidParameter, op, "page size must be at least 1") + case filterItemFn == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing filter item callback") + case tok == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing token") + case repo == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing repo") + case withScopeIds == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing scope ids") + case tok.ResourceType != resource.User: + return nil, errors.New(ctx, errors.InvalidParameter, op, "token did not have a user resource type") + } + rt, ok := tok.Subtype.(*listtoken.RefreshToken) + if !ok { + return nil, errors.New(ctx, errors.InvalidParameter, op, "token did not have a refresh token component") + } + + listItemsFn := func(ctx context.Context, lastPageItem *User, limit int) ([]*User, time.Time, error) { + opts := []Option{ + WithLimit(limit), + } + if lastPageItem != nil { + opts = append(opts, WithStartPageAfterItem(lastPageItem)) + } else { + lastItem, err := tok.LastItem(ctx) + if err != nil { + return nil, time.Time{}, err + } + opts = append(opts, WithStartPageAfterItem(lastItem)) + } + // Add the database read timeout to account for any creations missed due to concurrent + // transactions in the original list pagination phase. + return repo.listUsersRefresh(ctx, rt.PhaseLowerBound.Add(-globals.RefreshReadLookbackDuration), withScopeIds, opts...) + } + + listDeletedIdsFn := func(ctx context.Context, since time.Time) ([]string, time.Time, error) { + // Add the database read timeout to account for any deletes missed due to concurrent + // transactions in the original list pagination phase. + return repo.listUserDeletedIds(ctx, since.Add(-globals.RefreshReadLookbackDuration)) + } + + return pagination.ListRefreshPage(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedUserCount, listDeletedIdsFn, tok) +} diff --git a/internal/iam/testing.go b/internal/iam/testing.go index 92d865b266..d83003ee95 100644 --- a/internal/iam/testing.go +++ b/internal/iam/testing.go @@ -177,6 +177,10 @@ func TestUser(t testing.TB, repo *Repository, scopeId string, opt ...Option) *Us if len(opts.withAccountIds) > 0 { _, err := repo.AddUserAccounts(ctx, user.PublicId, user.Version, opts.withAccountIds) require.NoError(err) + // now that we have updated user accounts, we need to re-fetch the user + // to get the updated version and update time + user, err = repo.lookupUser(ctx, user.GetPublicId()) + require.NoError(err) } return user } diff --git a/internal/iam/user.go b/internal/iam/user.go index 45e2658e0f..88f4ba11de 100644 --- a/internal/iam/user.go +++ b/internal/iam/user.go @@ -7,6 +7,7 @@ import ( "context" "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/db/timestamp" "github.com/hashicorp/boundary/internal/errors" "github.com/hashicorp/boundary/internal/iam/store" "github.com/hashicorp/boundary/internal/types/action" @@ -119,6 +120,13 @@ type userAccountInfo struct { tableName string `gorm:"-"` } +// allocUserAccountInfo will allocate an empty userAccountInfo +func allocUserAccountInfo() *userAccountInfo { + return &userAccountInfo{ + User: &store.User{}, + } +} + func (u *userAccountInfo) shallowConversion() *User { return &User{ User: u.User, @@ -143,3 +151,13 @@ func (u *userAccountInfo) SetTableName(n string) { u.tableName = n } } + +type deletedUser struct { + PublicId string `gorm:"primary_key"` + DeleteTime *timestamp.Timestamp +} + +// TableName returns the tablename to override the default gorm table name +func (u *deletedUser) TableName() string { + return "iam_user_deleted" +} diff --git a/internal/proto/controller/api/services/v1/user_service.proto b/internal/proto/controller/api/services/v1/user_service.proto index cd393accdc..56fd560a26 100644 --- a/internal/proto/controller/api/services/v1/user_service.proto +++ b/internal/proto/controller/api/services/v1/user_service.proto @@ -129,10 +129,47 @@ message ListUsersRequest { string scope_id = 1; // @gotags: `class:"public" eventstream:"observation"` bool recursive = 20 [json_name = "recursive"]; // @gotags: `class:"public" eventstream:"observation"` string filter = 30 [json_name = "filter"]; // @gotags: `class:"sensitive"` + + // An opaque token used to continue an existing iteration or + // request updated items. If not specified, pagination + // will start from the beginning. + string list_token = 40 [json_name = "list_token"]; // @gotags: `class:"public"` + + // The maximum size of a page in this iteration. + // If unset, the default page size configured will be used. + // If the page_size is greater than the default page configured, + // an error will be returned. + uint32 page_size = 50 [json_name = "page_size"]; // @gotags: `class:"public"` } message ListUsersResponse { repeated resources.users.v1.User items = 1; + + // The type of response, either "delta" or "complete". + // Delta signifies that this is part of a paginated result + // or an update to a previously completed pagination. + // Complete signifies that it is the last page. + string response_type = 2 [json_name = "response_type"]; // @gotags: `class:"public"` + + // An opaque token used to continue an existing pagination or + // request updated items. Use this token in the next list request + // to request the next page. + string list_token = 3 [json_name = "list_token"]; // @gotags: `class:"public"` + + // The name of the field which the items are sorted by. + string sort_by = 4 [json_name = "sort_by"]; // @gotags: `class:"public"` + + // The direction of the sort, either "asc" or "desc". + string sort_dir = 5 [json_name = "sort_dir"]; // @gotags: `class:"public"` + + // A list of item IDs that have been removed since they were returned + // as part of a pagination. They should be dropped from any client cache. + // This may contain items that are not known to the cache, if they were + // created and deleted between listings. + repeated string removed_ids = 6 [json_name = "removed_ids"]; // @gotags: `class:"public"` + + // An estimate at the total items available. This may change during pagination. + uint32 est_item_count = 7 [json_name = "est_item_count"]; // @gotags: `class:"public"` } message CreateUserRequest {