From 52b1c1b4c7fa72a9cb1c0dd55c1f71a52d1e64cd Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Wed, 3 Jan 2024 14:35:54 -0500 Subject: [PATCH] Managed group list pagination (#4208) * wip * managed groups pagination * address PR comments * missed one change --- internal/auth/ldap/managed_group.go | 11 + internal/auth/ldap/query.go | 3 + .../auth/ldap/repository_managed_group.go | 139 +++- .../repository_managed_group_members_test.go | 5 +- .../ldap/repository_managed_group_test.go | 295 ++++++- .../auth/ldap/service_list_managed_groups.go | 62 ++ .../service_list_managed_groups_ext_test.go | 626 ++++++++++++++ .../ldap/service_list_managed_groups_page.go | 79 ++ .../service_list_managed_groups_refresh.go | 81 ++ ...ervice_list_managed_groups_refresh_page.go | 91 +++ internal/auth/oidc/managed_group.go | 11 + internal/auth/oidc/options_test.go | 31 + internal/auth/oidc/query.go | 3 + .../auth/oidc/repository_managed_group.go | 134 ++- .../repository_managed_group_members_test.go | 6 +- .../oidc/repository_managed_group_test.go | 337 +++++++- internal/auth/oidc/service_callback.go | 2 +- internal/auth/oidc/service_callback_test.go | 5 +- .../auth/oidc/service_list_managed_groups.go | 62 ++ .../service_list_managed_groups_ext_test.go | 632 ++++++++++++++ .../oidc/service_list_managed_groups_page.go | 79 ++ .../service_list_managed_groups_refresh.go | 81 ++ ...ervice_list_managed_groups_refresh_page.go | 91 +++ internal/daemon/controller/handler.go | 2 +- .../managed_groups/managed_group_service.go | 265 ++++-- .../managed_group_service_test.go | 769 +++++++++++++++++- .../80/15_managed_group_table_updates.up.sql | 22 + .../tests/pagination/managed_group.sql | 15 + internal/gen/controller.swagger.json | 43 + .../api/services/managed_group_service.pb.go | 326 +++++--- .../services/v1/managed_group_service.proto | 29 + 31 files changed, 4059 insertions(+), 278 deletions(-) create mode 100644 internal/auth/ldap/service_list_managed_groups.go create mode 100644 internal/auth/ldap/service_list_managed_groups_ext_test.go create mode 100644 internal/auth/ldap/service_list_managed_groups_page.go create mode 100644 internal/auth/ldap/service_list_managed_groups_refresh.go create mode 100644 internal/auth/ldap/service_list_managed_groups_refresh_page.go create mode 100644 internal/auth/oidc/service_list_managed_groups.go create mode 100644 internal/auth/oidc/service_list_managed_groups_ext_test.go create mode 100644 internal/auth/oidc/service_list_managed_groups_page.go create mode 100644 internal/auth/oidc/service_list_managed_groups_refresh.go create mode 100644 internal/auth/oidc/service_list_managed_groups_refresh_page.go create mode 100644 internal/db/schema/migrations/oss/postgres/80/15_managed_group_table_updates.up.sql create mode 100644 internal/db/sqltest/tests/pagination/managed_group.sql diff --git a/internal/auth/ldap/managed_group.go b/internal/auth/ldap/managed_group.go index fab71a643..a77d9a618 100644 --- a/internal/auth/ldap/managed_group.go +++ b/internal/auth/ldap/managed_group.go @@ -8,6 +8,7 @@ import ( "encoding/json" "github.com/hashicorp/boundary/internal/auth/ldap/store" + "github.com/hashicorp/boundary/internal/db/timestamp" "github.com/hashicorp/boundary/internal/errors" "github.com/hashicorp/boundary/internal/oplog" "github.com/hashicorp/boundary/internal/types/resource" @@ -110,3 +111,13 @@ func (mg *ManagedGroup) oplog(ctx context.Context, opType oplog.OpType, authMeth } return metadata, nil } + +type deletedManagedGroup struct { + PublicId string `gorm:"primary_key"` + DeleteTime *timestamp.Timestamp +} + +// TableName returns the tablename to override the default gorm table name +func (s *deletedManagedGroup) TableName() string { + return "auth_ldap_managed_group_deleted" +} diff --git a/internal/auth/ldap/query.go b/internal/auth/ldap/query.go index e23de6d43..e8c5c61f8 100644 --- a/internal/auth/ldap/query.go +++ b/internal/auth/ldap/query.go @@ -6,5 +6,8 @@ package ldap const ( estimateCountAccounts = ` select sum(reltuples::bigint) as estimate from pg_class where oid in ('auth_ldap_account'::regclass) +` + estimateCountManagedGroups = ` +select sum(reltuples::bigint) as estimate from pg_class where oid in ('auth_ldap_managed_group'::regclass) ` ) diff --git a/internal/auth/ldap/repository_managed_group.go b/internal/auth/ldap/repository_managed_group.go index 42dec5113..567951f56 100644 --- a/internal/auth/ldap/repository_managed_group.go +++ b/internal/auth/ldap/repository_managed_group.go @@ -5,10 +5,13 @@ package ldap import ( "context" + "database/sql" "fmt" "strings" + "time" "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" @@ -97,27 +100,103 @@ func (r *Repository) LookupManagedGroup(ctx context.Context, withPublicId string return a, nil } -// ListManagedGroups in an auth method and supports WithLimit option. -func (r *Repository) ListManagedGroups(ctx context.Context, withAuthMethodId string, opt ...Option) ([]*ManagedGroup, error) { +// ListManagedGroups returns a slice of managed groups in the auth method. +// Supported options: +// - WithLimit which overrides the limit set in the Repository object +// - WithStartPageAfterItem which sets where to start listing from +func (r *Repository) ListManagedGroups(ctx context.Context, withAuthMethodId string, opt ...Option) ([]*ManagedGroup, time.Time, error) { const op = "ldap.(Repository).ListManagedGroups" if withAuthMethodId == "" { - return nil, errors.New(ctx, errors.InvalidParameter, op, "missing auth method id") + return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "missing auth method id") } opts, err := getOpts(opt...) if err != nil { - return nil, errors.Wrap(ctx, err, op) + return nil, time.Time{}, errors.Wrap(ctx, err, op) } + limit := r.defaultLimit if opts.withLimit != 0 { // non-zero signals an override of the default limit for the repo. limit = opts.withLimit } - var mgs []*ManagedGroup - err = r.reader.SearchWhere(ctx, &mgs, "auth_method_id = ?", []any{withAuthMethodId}, db.WithLimit(limit)) - if err != nil { - return nil, errors.Wrap(ctx, err, op) + + var args []any + whereClause := "auth_method_id = @auth_method_id" + args = append(args, sql.Named("auth_method_id", withAuthMethodId)) + + 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()), + ) } - return mgs, nil + + dbOpts := []db.Option{db.WithLimit(limit), db.WithOrder("create_time desc, public_id desc")} + return r.queryManagedGroups(ctx, whereClause, args, dbOpts...) +} + +// ListManagedGroupsRefresh returns a slice of managed groups in the auth method. +// Supported options: +// - WithLimit which overrides the limit set in the Repository object +// - WithStartPageAfterItem which sets where to start listing from +func (r *Repository) ListManagedGroupsRefresh(ctx context.Context, withAuthMethodId string, updatedAfter time.Time, opt ...Option) ([]*ManagedGroup, time.Time, error) { + const op = "ldap.(Repository).ListManagedGroupsRefresh" + switch { + case withAuthMethodId == "": + return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "missing auth method id") + case updatedAfter.IsZero(): + return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "missing updated after time") + } + + opts, err := getOpts(opt...) + if err != nil { + return nil, time.Time{}, errors.Wrap(ctx, err, op) + } + + limit := r.defaultLimit + if opts.withLimit != 0 { + // non-zero signals an override of the default limit for the repo. + limit = opts.withLimit + } + + var args []any + whereClause := "update_time > @updated_after_time and auth_method_id = @auth_method_id" + args = append(args, + sql.Named("updated_after_time", timestamp.New(updatedAfter)), + sql.Named("auth_method_id", withAuthMethodId), + ) + + 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()), + ) + } + + dbOpts := []db.Option{db.WithLimit(limit), db.WithOrder("update_time desc, public_id desc")} + return r.queryManagedGroups(ctx, whereClause, args, dbOpts...) +} + +func (r *Repository) queryManagedGroups(ctx context.Context, whereClause string, args []any, opt ...db.Option) ([]*ManagedGroup, time.Time, error) { + const op = "ldap.(Repository).queryManagedGroups" + + var mgs []*ManagedGroup + var transactionTimestamp time.Time + if _, err := r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(rd db.Reader, w db.Writer) error { + var inMgs []*ManagedGroup + if err := rd.SearchWhere(ctx, &inMgs, whereClause, args, opt...); err != nil { + return errors.Wrap(ctx, err, op) + } + mgs = inMgs + var err error + transactionTimestamp, err = rd.Now(ctx) + return err + }); err != nil { + return nil, time.Time{}, errors.Wrap(ctx, err, op) + } + return mgs, transactionTimestamp, nil } // DeleteManagedGroup deletes the managed group for the provided id from the @@ -262,3 +341,45 @@ func (r *Repository) UpdateManagedGroup(ctx context.Context, scopeId string, mg return returnedManagedGroup, rowsUpdated, nil } + +// listDeletedManagedGroupIds lists the public IDs of any managed groups deleted since the timestamp provided, +// and the timestamp of the transaction within which the managed groups were listed. +func (r *Repository) listDeletedManagedGroupIds(ctx context.Context, since time.Time) ([]string, time.Time, error) { + const op = "ldap.(Repository).listDeletedManagedGroupIds" + var deletedManagedGroups []*deletedManagedGroup + 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, &deletedManagedGroups, "delete_time >= ?", []any{since}); err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("failed to query deleted managed groups")) + } + 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 managedGroupIds []string + for _, a := range deletedManagedGroups { + managedGroupIds = append(managedGroupIds, a.PublicId) + } + return managedGroupIds, transactionTimestamp, nil +} + +// estimatedManagedGroupCount returns an estimate of the total number of managed groups. +func (r *Repository) estimatedManagedGroupCount(ctx context.Context) (int, error) { + const op = "ldap.(Repository).estimatedManagedGroupCount" + rows, err := r.reader.Query(ctx, estimateCountManagedGroups, nil) + if err != nil { + return 0, errors.Wrap(ctx, err, op, errors.WithMsg("failed to query ldap managed group counts")) + } + 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 ldap managed group counts")) + } + } + return count, nil +} diff --git a/internal/auth/ldap/repository_managed_group_members_test.go b/internal/auth/ldap/repository_managed_group_members_test.go index 05f1d78d9..c7377eb9b 100644 --- a/internal/auth/ldap/repository_managed_group_members_test.go +++ b/internal/auth/ldap/repository_managed_group_members_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "math/rand" "testing" + "time" "github.com/hashicorp/boundary/internal/auth/ldap" "github.com/hashicorp/boundary/internal/auth/oidc/store" @@ -124,8 +125,10 @@ func Test_ManagedGroupMemberships(t *testing.T) { // We are intentionally carrying things over between tests to be // more realistic but that means we need correct versions, so update // them first. - currMgs, err := repo.ListManagedGroups(testCtx, testAuthMethod.PublicId) + currMgs, ttime, err := repo.ListManagedGroups(testCtx, testAuthMethod.PublicId) require.NoError(err) + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) require.Len(currMgs, 101) currVersionMap := make(map[string]uint32, len(currMgs)) for _, currMg := range currMgs { diff --git a/internal/auth/ldap/repository_managed_group_test.go b/internal/auth/ldap/repository_managed_group_test.go index adb794757..7f251f92b 100644 --- a/internal/auth/ldap/repository_managed_group_test.go +++ b/internal/auth/ldap/repository_managed_group_test.go @@ -6,23 +6,27 @@ package ldap import ( "context" "fmt" - "sort" + "slices" "strings" "testing" "time" "github.com/DATA-DOG/go-sqlmock" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/boundary/globals" "github.com/hashicorp/boundary/internal/auth" "github.com/hashicorp/boundary/internal/auth/ldap/store" "github.com/hashicorp/boundary/internal/db" dbassert "github.com/hashicorp/boundary/internal/db/assert" + "github.com/hashicorp/boundary/internal/db/timestamp" "github.com/hashicorp/boundary/internal/errors" "github.com/hashicorp/boundary/internal/iam" "github.com/hashicorp/boundary/internal/kms" "github.com/hashicorp/boundary/internal/oplog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" ) func TestRepository_CreateManagedGroup(t *testing.T) { @@ -593,7 +597,11 @@ func TestRepository_DeleteManagedGroup(t *testing.T) { } func TestRepository_ListManagedGroups(t *testing.T) { - t.Parallel() + oldReadTimeout := globals.RefreshReadLookbackDuration + globals.RefreshReadLookbackDuration = 0 + t.Cleanup(func() { + globals.RefreshReadLookbackDuration = oldReadTimeout + }) testConn, _ := db.TestSetup(t, "postgres") testRw := db.New(testConn) testRootWrapper := db.TestWrapper(t) @@ -614,18 +622,24 @@ func TestRepository_ListManagedGroups(t *testing.T) { TestManagedGroup(t, testConn, authMethod1, testGrpNames), TestManagedGroup(t, testConn, authMethod1, testGrpNames), } - sort.Slice(mgs1, func(i, j int) bool { - return strings.Compare(mgs1[i].PublicId, mgs1[j].PublicId) < 0 - }) mgs2 := []*ManagedGroup{ TestManagedGroup(t, testConn, authMethod2, testGrpNames), TestManagedGroup(t, testConn, authMethod2, testGrpNames), TestManagedGroup(t, testConn, authMethod2, testGrpNames), } - sort.Slice(mgs2, func(i, j int) bool { - return strings.Compare(mgs2[i].PublicId, mgs2[j].PublicId) < 0 - }) + + slices.Reverse(mgs1) + slices.Reverse(mgs2) + + cmpOpts := []cmp.Option{ + cmpopts.IgnoreUnexported( + ManagedGroup{}, + store.ManagedGroup{}, + timestamp.Timestamp{}, + timestamppb.Timestamp{}, + ), + } testRepo, err := NewRepository(testCtx, testRw, testRw, testKms) assert.NoError(t, err) @@ -688,7 +702,7 @@ func TestRepository_ListManagedGroups(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { assert, require := assert.New(t), require.New(t) - got, err := tc.repo.ListManagedGroups(tc.ctx, tc.in, tc.opts...) + got, ttime, err := tc.repo.ListManagedGroups(tc.ctx, tc.in, tc.opts...) if tc.wantErrMatch != nil { require.Error(err) assert.Truef(errors.Match(tc.wantErrMatch, err), "Unexpected error %s", err) @@ -698,17 +712,43 @@ func TestRepository_ListManagedGroups(t *testing.T) { return } require.NoError(err) - - sort.Slice(got, func(i, j int) bool { - return strings.Compare(got[i].PublicId, got[j].PublicId) < 0 - }) + // 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))) + require.Empty(cmp.Diff(got, tc.want, cmpOpts...)) assert.EqualValues(tc.want, got) }) } + t.Run("validation", func(t *testing.T) { + t.Parallel() + t.Run("missing auth method id", func(t *testing.T) { + t.Parallel() + _, _, err := testRepo.ListManagedGroups(testCtx, "", WithLimit(testCtx, 1)) + require.ErrorContains(t, err, "missing auth method id") + }) + }) + + t.Run("success-without-after-item", func(t *testing.T) { + t.Parallel() + resp, ttime, err := testRepo.ListManagedGroups(testCtx, authMethod1.PublicId, WithLimit(testCtx, 10)) + require.NoError(t, err) + // 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))) + require.Empty(t, cmp.Diff(resp, mgs1, cmpOpts...)) + }) + t.Run("success-with-after-item", func(t *testing.T) { + t.Parallel() + resp, ttime, err := testRepo.ListManagedGroups(testCtx, authMethod1.PublicId, WithStartPageAfterItem(testCtx, mgs1[0]), WithLimit(testCtx, 10)) + require.NoError(t, err) + // 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))) + require.Empty(t, cmp.Diff(resp, mgs1[1:], cmpOpts...)) + }) } func TestRepository_ListManagedGroups_Limits(t *testing.T) { - t.Parallel() testConn, _ := db.TestSetup(t, "postgres") testRw := db.New(testConn) testRootWrapper := db.TestWrapper(t) @@ -776,9 +816,11 @@ func TestRepository_ListManagedGroups_Limits(t *testing.T) { repo, err := NewRepository(testCtx, testRw, testRw, testKms, tc.repoOpts...) assert.NoError(err) require.NotNil(repo) - got, err := repo.ListManagedGroups(context.Background(), am.GetPublicId(), tc.listOpts...) + got, ttime, err := repo.ListManagedGroups(context.Background(), am.GetPublicId(), tc.listOpts...) require.NoError(err) assert.Len(got, tc.wantLen) + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) }) } } @@ -1225,3 +1267,226 @@ func TestRepository_UpdateManagedGroup(t *testing.T) { }) } } + +func TestRepository_ListManagedGroupsRefresh(t *testing.T) { + oldReadTimeout := globals.RefreshReadLookbackDuration + globals.RefreshReadLookbackDuration = 0 + t.Cleanup(func() { + globals.RefreshReadLookbackDuration = oldReadTimeout + }) + testConn, _ := db.TestSetup(t, "postgres") + testRw := db.New(testConn) + testRootWrapper := db.TestWrapper(t) + + ctx := context.Background() + testKms := kms.TestKms(t, testConn, testRootWrapper) + iamRepo := iam.TestRepo(t, testConn, testRootWrapper) + org, _ := iam.TestScopes(t, iamRepo) + + orgDbWrapper, err := testKms.GetWrapper(ctx, org.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) + authMethod1 := TestAuthMethod(t, testConn, orgDbWrapper, org.PublicId, []string{"ldaps://ldap1"}) + authMethod2 := TestAuthMethod(t, testConn, orgDbWrapper, org.PublicId, []string{"ldaps://ldap2"}) + + mgs1 := []*ManagedGroup{ + TestManagedGroup(t, testConn, authMethod1, testGrpNames), + TestManagedGroup(t, testConn, authMethod1, testGrpNames), + TestManagedGroup(t, testConn, authMethod1, testGrpNames), + } + + mgs2 := []*ManagedGroup{ + TestManagedGroup(t, testConn, authMethod2, testGrpNames), + TestManagedGroup(t, testConn, authMethod2, testGrpNames), + TestManagedGroup(t, testConn, authMethod2, testGrpNames), + } + + slices.Reverse(mgs1) + slices.Reverse(mgs2) + + fiveDaysAgo := time.Now().AddDate(0, 0, -5) + + cmpOpts := []cmp.Option{ + cmpopts.IgnoreUnexported( + ManagedGroup{}, + store.ManagedGroup{}, + timestamp.Timestamp{}, + timestamppb.Timestamp{}, + ), + cmpopts.SortSlices(func(i, j string) bool { return i < j }), + } + + repo, err := NewRepository(ctx, testRw, testRw, testKms) + require.NotNil(t, repo) + assert.NoError(t, err) + + t.Run("validation", func(t *testing.T) { + t.Parallel() + t.Run("missing updated after", func(t *testing.T) { + t.Parallel() + _, _, err := repo.ListManagedGroupsRefresh(ctx, authMethod1.PublicId, time.Time{}, WithLimit(ctx, 1)) + require.ErrorContains(t, err, "missing updated after time") + }) + t.Run("missing auth method id", func(t *testing.T) { + t.Parallel() + _, _, err := repo.ListManagedGroupsRefresh(ctx, "", fiveDaysAgo, WithLimit(ctx, 1)) + require.ErrorContains(t, err, "missing auth method id") + }) + }) + + t.Run("success-without-after-item", func(t *testing.T) { + t.Parallel() + resp, ttime, err := repo.ListManagedGroupsRefresh(ctx, authMethod1.PublicId, fiveDaysAgo, WithLimit(ctx, 10)) + require.NoError(t, err) + // 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))) + require.Empty(t, cmp.Diff(resp, mgs1, cmpOpts...)) + }) + t.Run("success-with-after-item", func(t *testing.T) { + t.Parallel() + resp, ttime, err := repo.ListManagedGroupsRefresh(ctx, authMethod1.PublicId, fiveDaysAgo, WithStartPageAfterItem(ctx, mgs1[0]), WithLimit(ctx, 10)) + require.NoError(t, err) + // 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))) + require.Empty(t, cmp.Diff(resp, mgs1[1:], cmpOpts...)) + }) + t.Run("success-without-after-item-recent-updated-after", func(t *testing.T) { + t.Parallel() + resp, ttime, err := repo.ListManagedGroupsRefresh(ctx, authMethod1.PublicId, mgs1[len(mgs1)-1].GetUpdateTime().AsTime(), WithLimit(ctx, 10)) + require.NoError(t, err) + // 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))) + require.Empty(t, cmp.Diff(resp, mgs1[:len(mgs1)-1], cmpOpts...)) + }) + t.Run("success-with-after-item-recent-updated-after", func(t *testing.T) { + t.Parallel() + resp, ttime, err := repo.ListManagedGroupsRefresh(ctx, authMethod1.PublicId, mgs1[len(mgs1)-1].GetUpdateTime().AsTime(), WithStartPageAfterItem(ctx, mgs1[0]), WithLimit(ctx, 10)) + require.NoError(t, err) + // 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))) + require.Empty(t, cmp.Diff(resp, mgs1[1:len(mgs1)-1], cmpOpts...)) + }) +} + +func TestRepository_estimatedCountManagedGroups(t *testing.T) { + oldReadTimeout := globals.RefreshReadLookbackDuration + globals.RefreshReadLookbackDuration = 0 + t.Cleanup(func() { + globals.RefreshReadLookbackDuration = oldReadTimeout + }) + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + testWrapper := db.TestWrapper(t) + + ctx := context.Background() + testKms := kms.TestKms(t, conn, testWrapper) + iamRepo := iam.TestRepo(t, conn, testWrapper) + org, _ := iam.TestScopes(t, iamRepo) + + databaseWrapper, err := testKms.GetWrapper(ctx, org.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) + + repo, err := NewRepository(ctx, rw, rw, testKms) + assert.NoError(t, err) + + sqlDb, err := conn.SqlDB(ctx) + require.NoError(t, err) + + // Check total entries at start, expect 0 + numItems, err := repo.estimatedManagedGroupCount(ctx) + require.NoError(t, err) + assert.Equal(t, 0, numItems) + + // create managed group and check count, expect 1 + authMethod1 := TestAuthMethod(t, conn, databaseWrapper, org.PublicId, []string{"ldaps://ldap1"}) + mg := TestManagedGroup(t, conn, authMethod1, testGrpNames) + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + + numItems, err = repo.estimatedManagedGroupCount(ctx) + require.NoError(t, err) + assert.Equal(t, 1, numItems) + + // Delete managed group and check count, expect 0 again + _, err = repo.DeleteManagedGroup(ctx, org.GetPublicId(), mg.GetPublicId()) + require.NoError(t, err) + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + + numItems, err = repo.estimatedManagedGroupCount(ctx) + require.NoError(t, err) + assert.Equal(t, 0, numItems) +} + +func TestRepository_listDeletedIdsManagedGroups(t *testing.T) { + oldReadTimeout := globals.RefreshReadLookbackDuration + globals.RefreshReadLookbackDuration = 0 + t.Cleanup(func() { + globals.RefreshReadLookbackDuration = oldReadTimeout + }) + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + testWrapper := db.TestWrapper(t) + + ctx := context.Background() + testKms := kms.TestKms(t, conn, testWrapper) + iamRepo := iam.TestRepo(t, conn, testWrapper) + org, _ := iam.TestScopes(t, iamRepo) + + databaseWrapper, err := testKms.GetWrapper(ctx, org.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) + + repo, err := NewRepository(ctx, rw, rw, testKms) + assert.NoError(t, err) + + sqlDb, err := conn.SqlDB(ctx) + require.NoError(t, err) + + // Check total entries at start, expect 0 + numItems, err := repo.estimatedManagedGroupCount(ctx) + require.NoError(t, err) + assert.Equal(t, 0, numItems) + + // create managed group and check count, expect 1 + authMethod1 := TestAuthMethod(t, conn, databaseWrapper, org.PublicId, []string{"ldaps://ldap1"}) + mg := TestManagedGroup(t, conn, authMethod1, testGrpNames) + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + + deletedIds, ttime, err := repo.listDeletedManagedGroupIds(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 mg and check count, expect 1 entry + _, err = repo.DeleteManagedGroup(ctx, org.GetPublicId(), mg.GetPublicId()) + require.NoError(t, err) + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + + deletedIds, ttime, err = repo.listDeletedManagedGroupIds(ctx, time.Now().AddDate(-1, 0, 0)) + require.NoError(t, err) + assert.Empty( + t, + cmp.Diff( + []string{mg.GetPublicId()}, + deletedIds, + cmpopts.SortSlices(func(i, j string) bool { return i < j }), + ), + ) + // 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.listDeletedManagedGroupIds(ctx, time.Now()) + require.NoError(t, err) + require.Empty(t, deletedIds) + 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/auth/ldap/service_list_managed_groups.go b/internal/auth/ldap/service_list_managed_groups.go new file mode 100644 index 000000000..17b450b6b --- /dev/null +++ b/internal/auth/ldap/service_list_managed_groups.go @@ -0,0 +1,62 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package ldap + +import ( + "context" + "time" + + "github.com/hashicorp/boundary/internal/auth" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/pagination" +) + +// ListManagedGroups lists up to page size ldap managed groups, filtering out entries that +// do not pass the filter item function. It will automatically request +// more managed groups from the database, at page size chunks, to fill the page. +// It returns a new list token used to continue pagination or refresh items. +// Managed groups are ordered by create time descending (most recently created first). +func ListManagedGroups( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[auth.ManagedGroup], + repo *Repository, + authMethodId string, +) (*pagination.ListResponse[auth.ManagedGroup], error) { + const op = "ldap.ListManagedGroups" + + 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 authMethodId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing auth method ID") + } + + listItemsFn := func(ctx context.Context, lastPageItem auth.ManagedGroup, limit int) ([]auth.ManagedGroup, time.Time, error) { + opts := []Option{ + WithLimit(ctx, limit), + } + if lastPageItem != nil { + opts = append(opts, WithStartPageAfterItem(ctx, lastPageItem)) + } + ldapManagedGroups, listTime, err := repo.ListManagedGroups(ctx, authMethodId, opts...) + if err != nil { + return nil, time.Time{}, err + } + var managedGroups []auth.ManagedGroup + for _, managedGroup := range ldapManagedGroups { + managedGroups = append(managedGroups, managedGroup) + } + return managedGroups, listTime, nil + } + + return pagination.List(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedManagedGroupCount) +} diff --git a/internal/auth/ldap/service_list_managed_groups_ext_test.go b/internal/auth/ldap/service_list_managed_groups_ext_test.go new file mode 100644 index 000000000..4af612dc5 --- /dev/null +++ b/internal/auth/ldap/service_list_managed_groups_ext_test.go @@ -0,0 +1,626 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package ldap_test + +import ( + "context" + "slices" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/boundary/globals" + "github.com/hashicorp/boundary/internal/auth" + "github.com/hashicorp/boundary/internal/auth/ldap" + "github.com/hashicorp/boundary/internal/auth/ldap/store" + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/db/timestamp" + "github.com/hashicorp/boundary/internal/iam" + "github.com/hashicorp/boundary/internal/kms" + "github.com/hashicorp/boundary/internal/listtoken" + "github.com/hashicorp/boundary/internal/types/resource" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" +) + +var testGrpNames = []string{"test-admin", "test-users"} + +func TestService_ListManagedGroups(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) + fiveDaysAgo := time.Now().AddDate(0, 0, -5) + rw := db.New(conn) + + testKms := kms.TestKms(t, conn, wrapper) + iamRepo := iam.TestRepo(t, conn, wrapper) + org, _ := iam.TestScopes(t, iamRepo) + + orgDbWrapper, err := testKms.GetWrapper(ctx, org.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) + + repo, err := ldap.NewRepository(context.Background(), rw, rw, testKms) + require.NoError(t, err) + + authMethod := ldap.TestAuthMethod(t, conn, orgDbWrapper, org.PublicId, []string{"ldaps://ldap1"}) + + ldapMgs := []*ldap.ManagedGroup{ + ldap.TestManagedGroup(t, conn, authMethod, testGrpNames), + ldap.TestManagedGroup(t, conn, authMethod, testGrpNames), + ldap.TestManagedGroup(t, conn, authMethod, testGrpNames), + ldap.TestManagedGroup(t, conn, authMethod, testGrpNames), + ldap.TestManagedGroup(t, conn, authMethod, testGrpNames), + } + + var mgs []auth.ManagedGroup + for _, mg := range ldapMgs { + mgs = append(mgs, mg) + } + // since we sort by create time descending, we need to reverse the slice + slices.Reverse(mgs) + + // Run analyze to update host estimate + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + + cmpOpts := []cmp.Option{ + cmpopts.IgnoreUnexported( + ldap.ManagedGroup{}, + store.ManagedGroup{}, + timestamp.Timestamp{}, + timestamppb.Timestamp{}, + ), + } + + t.Run("ListManagedGroups validation", func(t *testing.T) { + t.Parallel() + t.Run("missing grants hash", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + _, err := ldap.ListManagedGroups(ctx, nil, 1, filterFunc, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + _, err := ldap.ListManagedGroups(ctx, []byte("some hash"), 0, filterFunc, repo, authMethod.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, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + _, err := ldap.ListManagedGroups(ctx, []byte("some hash"), -1, filterFunc, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "page size must be at least 1") + }) + t.Run("nil filter func", func(t *testing.T) { + t.Parallel() + _, err := ldap.ListManagedGroups(ctx, []byte("some hash"), 1, nil, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("nil repo", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + _, err := ldap.ListManagedGroups(ctx, []byte("some hash"), 1, filterFunc, nil, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing auth method ID", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + _, err := ldap.ListManagedGroups(ctx, []byte("some hash"), 1, filterFunc, repo, "") + require.ErrorContains(t, err, "missing auth method ID") + }) + }) + t.Run("ListManagedGroupsPage validation", func(t *testing.T) { + t.Parallel() + t.Run("missing grants hash", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsPage(ctx, nil, 1, filterFunc, tok, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsPage(ctx, []byte("some hash"), 0, filterFunc, tok, repo, authMethod.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, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsPage(ctx, []byte("some hash"), -1, filterFunc, tok, repo, authMethod.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.ManagedGroup, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsPage(ctx, []byte("some hash"), 1, nil, tok, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("nil token", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + _, err = ldap.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, nil, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing token") + }) + t.Run("wrong token type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, authMethod.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, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, tok, nil, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing auth method ID", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, "") + require.ErrorContains(t, err, "missing auth method ID") + }) + t.Run("wrong token resource type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.Target, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "token did not have a managed group resource type") + }) + }) + t.Run("ListManagedGroupsRefresh validation", func(t *testing.T) { + t.Parallel() + t.Run("missing grants hash", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsRefresh(ctx, nil, 1, filterFunc, tok, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsRefresh(ctx, []byte("some hash"), 0, filterFunc, tok, repo, authMethod.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, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsRefresh(ctx, []byte("some hash"), -1, filterFunc, tok, repo, authMethod.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.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, nil, tok, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("nil token", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + _, err = ldap.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, nil, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing token") + }) + t.Run("nil repo", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, tok, nil, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing auth method ID", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, tok, repo, "") + require.ErrorContains(t, err, "missing auth method ID") + }) + t.Run("wrong token resource type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.Target, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, tok, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "token did not have a managed group resource type") + }) + }) + t.Run("ListManagedGroupsRefreshPage validation", func(t *testing.T) { + t.Parallel() + t.Run("missing grants hash", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsRefreshPage(ctx, nil, 1, filterFunc, tok, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), 0, filterFunc, tok, repo, authMethod.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, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), -1, filterFunc, tok, repo, authMethod.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.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), 1, nil, tok, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("nil token", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + _, err = ldap.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), 1, filterFunc, nil, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing token") + }) + t.Run("wrong token type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, authMethod.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, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), 1, filterFunc, tok, nil, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing credential store id", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = ldap.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, "") + require.ErrorContains(t, err, "missing auth method ID") + }) + t.Run("wrong token resource type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (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 = ldap.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "token did not have a managed group resource type") + }) + }) + + t.Run("simple pagination", func(t *testing.T) { + filterFunc := func(context.Context, auth.ManagedGroup) (bool, error) { + return true, nil + } + resp, err := ldap.ListManagedGroups(ctx, []byte("some hash"), 1, filterFunc, repo, authMethod.GetPublicId()) + 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, 5) + require.Empty(t, resp.DeletedIds) + require.Len(t, resp.Items, 1) + require.Empty(t, cmp.Diff(resp.Items[0], mgs[0], cmpOpts...)) + + resp2, err := ldap.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, resp.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp2.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp2.CompleteListing) + require.Equal(t, resp2.EstimatedItemCount, 5) + require.Empty(t, resp2.DeletedIds) + require.Len(t, resp2.Items, 1) + require.Empty(t, cmp.Diff(resp2.Items[0], mgs[1], cmpOpts...)) + + resp3, err := ldap.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, resp2.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp3.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp3.CompleteListing) + require.Equal(t, resp3.EstimatedItemCount, 5) + require.Empty(t, resp3.DeletedIds) + require.Len(t, resp3.Items, 1) + require.Empty(t, cmp.Diff(resp3.Items[0], mgs[2], cmpOpts...)) + + resp4, err := ldap.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, resp3.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp4.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp4.CompleteListing) + require.Equal(t, resp4.EstimatedItemCount, 5) + require.Empty(t, resp4.DeletedIds) + require.Len(t, resp4.Items, 1) + require.Empty(t, cmp.Diff(resp4.Items[0], mgs[3], cmpOpts...)) + + resp5, err := ldap.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, resp4.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp5.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp5.CompleteListing) + require.Equal(t, resp5.EstimatedItemCount, 5) + require.Empty(t, resp5.DeletedIds) + require.Len(t, resp5.Items, 1) + require.Empty(t, cmp.Diff(resp5.Items[0], mgs[4], cmpOpts...)) + + // Finished initial pagination phase, request refresh + // Expect no results. + resp6, err := ldap.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, resp5.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp6.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp6.CompleteListing) + require.Equal(t, resp6.EstimatedItemCount, 5) + require.Empty(t, resp6.DeletedIds) + require.Empty(t, resp6.Items) + + // Create some new mgs + mg1 := ldap.TestManagedGroup(t, conn, authMethod, testGrpNames) + mg2 := ldap.TestManagedGroup(t, conn, authMethod, testGrpNames) + t.Cleanup(func() { + repo.DeleteManagedGroup(ctx, org.GetPublicId(), mg1.GetPublicId()) + require.NoError(t, err) + repo.DeleteManagedGroup(ctx, org.GetPublicId(), mg2.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 mg2 + resp7, err := ldap.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, resp6.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp7.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp7.CompleteListing) + require.Equal(t, resp7.EstimatedItemCount, 7) + require.Empty(t, resp7.DeletedIds) + require.Len(t, resp7.Items, 1) + require.Empty(t, cmp.Diff(resp7.Items[0], mg2, cmpOpts...)) + + // Refresh again, should get mg1 + resp8, err := ldap.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), 1, filterFunc, resp7.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp8.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp8.CompleteListing) + require.Equal(t, resp8.EstimatedItemCount, 7) + require.Empty(t, resp8.DeletedIds) + require.Len(t, resp8.Items, 1) + require.Empty(t, cmp.Diff(resp8.Items[0], mg1, cmpOpts...)) + + // Refresh again, should get no results + resp9, err := ldap.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, resp8.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp9.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp9.CompleteListing) + require.Equal(t, resp9.EstimatedItemCount, 7) + require.Empty(t, resp9.DeletedIds) + require.Empty(t, resp9.Items) + }) + + t.Run("simple pagination with aggressive filtering", func(t *testing.T) { + filterFunc := func(ctx context.Context, mg auth.ManagedGroup) (bool, error) { + return mg.GetPublicId() == mgs[1].GetPublicId() || + mg.GetPublicId() == mgs[len(mgs)-1].GetPublicId(), nil + } + resp, err := ldap.ListManagedGroups(ctx, []byte("some hash"), 1, filterFunc, repo, authMethod.GetPublicId()) + 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, 5) + require.Empty(t, resp.DeletedIds) + require.Len(t, resp.Items, 1) + require.Empty(t, cmp.Diff(resp.Items[0], mgs[1], cmpOpts...)) + + resp2, err := ldap.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, resp.ListToken, repo, authMethod.GetPublicId()) + 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, 5) + require.Empty(t, resp2.DeletedIds) + require.Len(t, resp2.Items, 1) + require.Empty(t, cmp.Diff(resp2.Items[0], mgs[len(mgs)-1], cmpOpts...)) + + // request a refresh, nothing should be returned + resp3, err := ldap.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, resp.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp3.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp3.CompleteListing) + require.Equal(t, resp3.EstimatedItemCount, 5) + require.Empty(t, resp3.DeletedIds) + require.Empty(t, resp3.Items) + + // Create some new mgs + mg1 := ldap.TestManagedGroup(t, conn, authMethod, testGrpNames) + mg2 := ldap.TestManagedGroup(t, conn, authMethod, testGrpNames) + mg3 := ldap.TestManagedGroup(t, conn, authMethod, testGrpNames) + t.Cleanup(func() { + repo.DeleteManagedGroup(ctx, org.GetPublicId(), mg1.GetPublicId()) + require.NoError(t, err) + repo.DeleteManagedGroup(ctx, org.GetPublicId(), mg2.GetPublicId()) + require.NoError(t, err) + repo.DeleteManagedGroup(ctx, org.GetPublicId(), mg3.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) + + filterFunc = func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return mg.GetPublicId() == mg3.GetPublicId() || + mg.GetPublicId() == mg1.GetPublicId(), nil + } + // Refresh again, should get mg3 + resp4, err := ldap.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, resp3.ListToken, repo, authMethod.GetPublicId()) + 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], mg3, cmpOpts...)) + + // Refresh again, should get mg1 + resp5, err := ldap.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), 1, filterFunc, resp4.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp5.ListToken.GrantsHash, []byte("some hash")) + require.True(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], mg1, cmpOpts...)) + }) + + t.Run("simple pagination with deletion", func(t *testing.T) { + filterFunc := func(context.Context, auth.ManagedGroup) (bool, error) { + return true, nil + } + deletedMGId := mgs[0].GetPublicId() + repo.DeleteManagedGroup(ctx, org.GetPublicId(), deletedMGId) + require.NoError(t, err) + mgs = mgs[1:] + + // Run analyze to update count estimate + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + + resp, err := ldap.ListManagedGroups(ctx, []byte("some hash"), 1, filterFunc, repo, authMethod.GetPublicId()) + 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, 4) + require.Empty(t, resp.DeletedIds) + require.Len(t, resp.Items, 1) + require.Empty(t, cmp.Diff(resp.Items[0], mgs[0], cmpOpts...)) + + // request remaining results + resp2, err := ldap.ListManagedGroupsPage(ctx, []byte("some hash"), 3, filterFunc, resp.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp2.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp2.CompleteListing) + require.Equal(t, resp2.EstimatedItemCount, 4) + require.Empty(t, resp2.DeletedIds) + require.Len(t, resp2.Items, 3) + require.Empty(t, cmp.Diff(resp2.Items, mgs[1:], cmpOpts...)) + + deletedMGId = mgs[0].GetPublicId() + repo.DeleteManagedGroup(ctx, org.GetPublicId(), deletedMGId) + require.NoError(t, err) + mgs = mgs[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 := ldap.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, resp2.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp3.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp3.CompleteListing) + require.Equal(t, resp3.EstimatedItemCount, 3) + require.Contains(t, resp3.DeletedIds, deletedMGId) + require.Empty(t, resp3.Items) + }) +} diff --git a/internal/auth/ldap/service_list_managed_groups_page.go b/internal/auth/ldap/service_list_managed_groups_page.go new file mode 100644 index 000000000..b62a6fd79 --- /dev/null +++ b/internal/auth/ldap/service_list_managed_groups_page.go @@ -0,0 +1,79 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package ldap + +import ( + "context" + "time" + + "github.com/hashicorp/boundary/internal/auth" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/listtoken" + "github.com/hashicorp/boundary/internal/pagination" + "github.com/hashicorp/boundary/internal/types/resource" +) + +// ListManagedGroupsPage lists up to page size ldap managed groups, filtering out entries that +// do not pass the filter item function. It will automatically request +// more ldap managed groups 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. +// Managed groups are ordered by create time descending (most recently created first). +func ListManagedGroupsPage( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[auth.ManagedGroup], + tok *listtoken.Token, + repo *Repository, + authMethodId string, +) (*pagination.ListResponse[auth.ManagedGroup], error) { + const op = "ldap.ListManagedGroupsPage" + + 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 authMethodId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing auth method ID") + case tok.ResourceType != resource.ManagedGroup: + return nil, errors.New(ctx, errors.InvalidParameter, op, "token did not have a managed group 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 auth.ManagedGroup, limit int) ([]auth.ManagedGroup, time.Time, error) { + opts := []Option{ + WithLimit(ctx, limit), + } + if lastPageItem != nil { + opts = append(opts, WithStartPageAfterItem(ctx, lastPageItem)) + } else { + lastItem, err := tok.LastItem(ctx) + if err != nil { + return nil, time.Time{}, err + } + opts = append(opts, WithStartPageAfterItem(ctx, lastItem)) + } + ldapManagedGroups, listTime, err := repo.ListManagedGroups(ctx, authMethodId, opts...) + if err != nil { + return nil, time.Time{}, err + } + var accts []auth.ManagedGroup + for _, acct := range ldapManagedGroups { + accts = append(accts, acct) + } + return accts, listTime, nil + } + + return pagination.ListPage(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedManagedGroupCount, tok) +} diff --git a/internal/auth/ldap/service_list_managed_groups_refresh.go b/internal/auth/ldap/service_list_managed_groups_refresh.go new file mode 100644 index 000000000..98e0c4699 --- /dev/null +++ b/internal/auth/ldap/service_list_managed_groups_refresh.go @@ -0,0 +1,81 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package ldap + +import ( + "context" + "time" + + "github.com/hashicorp/boundary/globals" + "github.com/hashicorp/boundary/internal/auth" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/listtoken" + "github.com/hashicorp/boundary/internal/pagination" + "github.com/hashicorp/boundary/internal/types/resource" +) + +// ListManagedGroupsRefresh lists ldap managed groups according to the page size +// and list token, filtering out entries that do not +// pass the filter item fn. It returns a new list token +// based on the old one, the grants hash, and the returned +// ldap managed groups. +func ListManagedGroupsRefresh( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[auth.ManagedGroup], + tok *listtoken.Token, + repo *Repository, + authMethodId string, +) (*pagination.ListResponse[auth.ManagedGroup], error) { + const op = "ldap.ListManagedGroupsRefresh" + + 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 authMethodId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing auth method ID") + case tok.ResourceType != resource.ManagedGroup: + return nil, errors.New(ctx, errors.InvalidParameter, op, "token did not have a managed group 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 auth.ManagedGroup, limit int) ([]auth.ManagedGroup, time.Time, error) { + opts := []Option{ + WithLimit(ctx, limit), + } + if lastPageItem != nil { + opts = append(opts, WithStartPageAfterItem(ctx, lastPageItem)) + } + // Add the database read timeout to managed group for any creations missed due to concurrent + // transactions in the initial pagination phase. + ldapManagedGroups, listTime, err := repo.ListManagedGroupsRefresh(ctx, authMethodId, rt.PreviousPhaseUpperBound.Add(-globals.RefreshReadLookbackDuration), opts...) + if err != nil { + return nil, time.Time{}, err + } + var managedGroups []auth.ManagedGroup + for _, managedGroup := range ldapManagedGroups { + managedGroups = append(managedGroups, managedGroup) + } + return managedGroups, listTime, nil + } + listDeletedIdsFn := func(ctx context.Context, since time.Time) ([]string, time.Time, error) { + // Add the database read timeout to managed group for any deletes missed due to concurrent + // transactions in the original list pagination phase. + return repo.listDeletedManagedGroupIds(ctx, since.Add(-globals.RefreshReadLookbackDuration)) + } + + return pagination.ListRefresh(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedManagedGroupCount, listDeletedIdsFn, tok) +} diff --git a/internal/auth/ldap/service_list_managed_groups_refresh_page.go b/internal/auth/ldap/service_list_managed_groups_refresh_page.go new file mode 100644 index 000000000..818f19b6c --- /dev/null +++ b/internal/auth/ldap/service_list_managed_groups_refresh_page.go @@ -0,0 +1,91 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package ldap + +import ( + "context" + "time" + + "github.com/hashicorp/boundary/globals" + "github.com/hashicorp/boundary/internal/auth" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/listtoken" + "github.com/hashicorp/boundary/internal/pagination" + "github.com/hashicorp/boundary/internal/types/resource" +) + +// ListManagedGroupsRefreshPage lists up to page size managed groups, filtering out entries that +// do not pass the filter item function. It will automatically request +// more managed groups 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. +// Managed groups are ordered by update time descending (most recently updated first). +// Managed groups may contain items that were already returned during the initial +// pagination phase. It also returns a list of any managed groups deleted since the +// last response. +func ListManagedGroupsRefreshPage( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[auth.ManagedGroup], + tok *listtoken.Token, + repo *Repository, + authMethodId string, +) (*pagination.ListResponse[auth.ManagedGroup], error) { + const op = "ldap.ListManagedGroupsRefreshPage" + + 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 authMethodId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing auth method ID") + case tok.ResourceType != resource.ManagedGroup: + return nil, errors.New(ctx, errors.InvalidParameter, op, "token did not have a managed group 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 auth.ManagedGroup, limit int) ([]auth.ManagedGroup, time.Time, error) { + opts := []Option{ + WithLimit(ctx, limit), + } + if lastPageItem != nil { + opts = append(opts, WithStartPageAfterItem(ctx, lastPageItem)) + } else { + lastItem, err := tok.LastItem(ctx) + if err != nil { + return nil, time.Time{}, err + } + opts = append(opts, WithStartPageAfterItem(ctx, lastItem)) + } + // Add the database read timeout to managed group for any creations missed due to concurrent + // transactions in the original list pagination phase. + ldapManagedGroups, listTime, err := repo.ListManagedGroupsRefresh(ctx, authMethodId, rt.PhaseLowerBound.Add(-globals.RefreshReadLookbackDuration), opts...) + if err != nil { + return nil, time.Time{}, err + } + var managedGroups []auth.ManagedGroup + for _, managedGroup := range ldapManagedGroups { + managedGroups = append(managedGroups, managedGroup) + } + return managedGroups, listTime, nil + } + listDeletedIdsFn := func(ctx context.Context, since time.Time) ([]string, time.Time, error) { + // Add the database read timeout to managed group for any deletes missed due to concurrent + // transactions in the original list pagination phase. + return repo.listDeletedManagedGroupIds(ctx, since.Add(-globals.RefreshReadLookbackDuration)) + } + + return pagination.ListRefreshPage(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedManagedGroupCount, listDeletedIdsFn, tok) +} diff --git a/internal/auth/oidc/managed_group.go b/internal/auth/oidc/managed_group.go index b6b004711..ae1bb8ae7 100644 --- a/internal/auth/oidc/managed_group.go +++ b/internal/auth/oidc/managed_group.go @@ -7,6 +7,7 @@ import ( "context" "github.com/hashicorp/boundary/internal/auth/oidc/store" + "github.com/hashicorp/boundary/internal/db/timestamp" "github.com/hashicorp/boundary/internal/errors" "github.com/hashicorp/boundary/internal/oplog" "github.com/hashicorp/boundary/internal/types/resource" @@ -107,3 +108,13 @@ func (mg *ManagedGroup) oplog(op oplog.OpType, authMethodScopeId string) oplog.M } return metadata } + +type deletedManagedGroup struct { + PublicId string `gorm:"primary_key"` + DeleteTime *timestamp.Timestamp +} + +// TableName returns the tablename to override the default gorm table name +func (s *deletedManagedGroup) TableName() string { + return "auth_oidc_managed_group_deleted" +} diff --git a/internal/auth/oidc/options_test.go b/internal/auth/oidc/options_test.go index 5e8bbe601..00f15d89c 100644 --- a/internal/auth/oidc/options_test.go +++ b/internal/auth/oidc/options_test.go @@ -7,12 +7,34 @@ import ( "crypto/x509" "net/url" "testing" + "time" "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/db/timestamp" + "github.com/hashicorp/boundary/internal/pagination" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +type fakeItem struct { + pagination.Item + publicId string + createTime time.Time + updateTime time.Time +} + +func (p *fakeItem) GetPublicId() string { + return p.publicId +} + +func (p *fakeItem) GetCreateTime() *timestamp.Timestamp { + return timestamp.New(p.createTime) +} + +func (p *fakeItem) GetUpdateTime() *timestamp.Timestamp { + return timestamp.New(p.updateTime) +} + // Test_GetOpts provides unit tests for GetOpts and all the options func Test_GetOpts(t *testing.T) { t.Parallel() @@ -206,4 +228,13 @@ func Test_GetOpts(t *testing.T) { opts := getOpts(WithReader(r)) assert.Equal(r, opts.withReader) }) + t.Run("WithStartPageAfterItem", func(t *testing.T) { + assert := assert.New(t) + updateTime := time.Now() + createTime := time.Now() + opts := getOpts(WithStartPageAfterItem(&fakeItem{nil, "s_1", createTime, updateTime})) + assert.Equal(opts.withStartPageAfterItem.GetPublicId(), "s_1") + assert.Equal(opts.withStartPageAfterItem.GetUpdateTime(), timestamp.New(updateTime)) + assert.Equal(opts.withStartPageAfterItem.GetCreateTime(), timestamp.New(createTime)) + }) } diff --git a/internal/auth/oidc/query.go b/internal/auth/oidc/query.go index bc95dc004..97db53e4d 100644 --- a/internal/auth/oidc/query.go +++ b/internal/auth/oidc/query.go @@ -19,4 +19,7 @@ const ( estimateCountAccounts = ` select sum(reltuples::bigint) as estimate from pg_class where oid in ('auth_oidc_account'::regclass) ` + estimateCountManagedGroups = ` + select sum(reltuples::bigint) as estimate from pg_class where oid in ('auth_oidc_managed_group'::regclass) + ` ) diff --git a/internal/auth/oidc/repository_managed_group.go b/internal/auth/oidc/repository_managed_group.go index d712c3f3c..3af3703f3 100644 --- a/internal/auth/oidc/repository_managed_group.go +++ b/internal/auth/oidc/repository_managed_group.go @@ -5,10 +5,13 @@ package oidc import ( "context" + "database/sql" "fmt" "strings" + "time" "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" @@ -96,24 +99,97 @@ func (r *Repository) LookupManagedGroup(ctx context.Context, withPublicId string return a, nil } -// ListManagedGroups in an auth method and supports WithLimit option. -func (r *Repository) ListManagedGroups(ctx context.Context, withAuthMethodId string, opt ...Option) ([]*ManagedGroup, error) { +// ListManagedGroups returns a slice of managed groups in an auth method +// Supported options: +// - WithLimit which overrides the limit set in the Repository object +// - WithStartPageAfterItem which sets where to start listing from +func (r *Repository) ListManagedGroups(ctx context.Context, withAuthMethodId string, opt ...Option) ([]*ManagedGroup, time.Time, error) { const op = "oidc.(Repository).ListManagedGroups" if withAuthMethodId == "" { - return nil, errors.New(ctx, errors.InvalidParameter, op, "missing auth method id") + return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "missing auth method id") } opts := getOpts(opt...) + limit := r.defaultLimit if opts.withLimit != 0 { // non-zero signals an override of the default limit for the repo. limit = opts.withLimit } - var mgs []*ManagedGroup - err := r.reader.SearchWhere(ctx, &mgs, "auth_method_id = ?", []any{withAuthMethodId}, db.WithLimit(limit)) - if err != nil { - return nil, errors.Wrap(ctx, err, op) + + var args []any + whereClause := "auth_method_id = @auth_method_id" + args = append(args, sql.Named("auth_method_id", withAuthMethodId)) + + 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()), + ) } - return mgs, nil + + dbOpts := []db.Option{db.WithLimit(limit), db.WithOrder("create_time desc, public_id desc")} + return r.queryManagedGroups(ctx, whereClause, args, dbOpts...) +} + +// ListManagedGroupsRefresh returns a slice of managed groups in the auth method. +// Supported options: +// - WithLimit which overrides the limit set in the Repository object +// - WithStartPageAfterItem which sets where to start listing from +func (r *Repository) ListManagedGroupsRefresh(ctx context.Context, withAuthMethodId string, updatedAfter time.Time, opt ...Option) ([]*ManagedGroup, time.Time, error) { + const op = "oidc.(Repository).ListManagedGroupsRefresh" + switch { + case withAuthMethodId == "": + return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "missing auth method id") + case updatedAfter.IsZero(): + return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "missing updated after time") + } + + opts := getOpts(opt...) + + limit := r.defaultLimit + if opts.withLimit != 0 { + // non-zero signals an override of the default limit for the repo. + limit = opts.withLimit + } + + var args []any + whereClause := "update_time > @updated_after_time and auth_method_id = @auth_method_id" + args = append(args, + sql.Named("updated_after_time", timestamp.New(updatedAfter)), + sql.Named("auth_method_id", withAuthMethodId), + ) + + 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()), + ) + } + + dbOpts := []db.Option{db.WithLimit(limit), db.WithOrder("update_time desc, public_id desc")} + return r.queryManagedGroups(ctx, whereClause, args, dbOpts...) +} + +func (r *Repository) queryManagedGroups(ctx context.Context, whereClause string, args []any, opt ...db.Option) ([]*ManagedGroup, time.Time, error) { + const op = "oidc.(Repository).queryManagedGroups" + + var mgs []*ManagedGroup + var transactionTimestamp time.Time + if _, err := r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(rd db.Reader, w db.Writer) error { + var inMgs []*ManagedGroup + if err := rd.SearchWhere(ctx, &inMgs, whereClause, args, opt...); err != nil { + return errors.Wrap(ctx, err, op) + } + mgs = inMgs + var err error + transactionTimestamp, err = rd.Now(ctx) + return err + }); err != nil { + return nil, time.Time{}, errors.Wrap(ctx, err, op) + } + return mgs, transactionTimestamp, nil } // DeleteManagedGroup deletes the managed group for the provided id from the @@ -252,3 +328,45 @@ func (r *Repository) UpdateManagedGroup(ctx context.Context, scopeId string, mg return returnedManagedGroup, rowsUpdated, nil } + +// listDeletedManagedGroupIds lists the public IDs of any managed groups deleted since the timestamp provided, +// and the timestamp of the transaction within which the managed groups were listed. +func (r *Repository) listDeletedManagedGroupIds(ctx context.Context, since time.Time) ([]string, time.Time, error) { + const op = "oidc.(Repository).listDeletedManagedGroupIds" + var deletedManagedGroups []*deletedManagedGroup + 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, &deletedManagedGroups, "delete_time >= ?", []any{since}); err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("failed to query deleted managed groups")) + } + 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 accountIds []string + for _, a := range deletedManagedGroups { + accountIds = append(accountIds, a.PublicId) + } + return accountIds, transactionTimestamp, nil +} + +// estimatedManagedGroupCount returns an estimate of the total number of managed groups. +func (r *Repository) estimatedManagedGroupCount(ctx context.Context) (int, error) { + const op = "oidc.(Repository).estimatedManagedGroupCount" + rows, err := r.reader.Query(ctx, estimateCountManagedGroups, nil) + if err != nil { + return 0, errors.Wrap(ctx, err, op, errors.WithMsg("failed to query ldap managed group counts")) + } + 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 ldap managed group counts")) + } + } + return count, nil +} diff --git a/internal/auth/oidc/repository_managed_group_members_test.go b/internal/auth/oidc/repository_managed_group_members_test.go index a1ea09b9d..7b54d394f 100644 --- a/internal/auth/oidc/repository_managed_group_members_test.go +++ b/internal/auth/oidc/repository_managed_group_members_test.go @@ -8,6 +8,7 @@ import ( "math/rand" "strings" "testing" + "time" "github.com/hashicorp/boundary/internal/auth/oidc" "github.com/hashicorp/boundary/internal/auth/oidc/store" @@ -205,8 +206,11 @@ func Test_ManagedGroupMemberships(t *testing.T) { // We are intentionally carrying things over between tests to be // more realistic but that means we need correct versions, so update // them first. - currMgs, err := repo.ListManagedGroups(ctx, authMethod.PublicId) + currMgs, ttime, err := repo.ListManagedGroups(ctx, authMethod.PublicId) require.NoError(err) + // 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))) require.Len(currMgs, 100) currVersionMap := make(map[string]uint32, len(currMgs)) for _, currMg := range currMgs { diff --git a/internal/auth/oidc/repository_managed_group_test.go b/internal/auth/oidc/repository_managed_group_test.go index f96cfdf2d..41237f6b4 100644 --- a/internal/auth/oidc/repository_managed_group_test.go +++ b/internal/auth/oidc/repository_managed_group_test.go @@ -6,21 +6,25 @@ package oidc import ( "context" "fmt" - "sort" + "slices" "strings" "testing" "time" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/boundary/globals" "github.com/hashicorp/boundary/internal/auth/oidc/store" "github.com/hashicorp/boundary/internal/db" dbassert "github.com/hashicorp/boundary/internal/db/assert" + "github.com/hashicorp/boundary/internal/db/timestamp" "github.com/hashicorp/boundary/internal/errors" "github.com/hashicorp/boundary/internal/iam" "github.com/hashicorp/boundary/internal/kms" "github.com/hashicorp/boundary/internal/oplog" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" ) func TestRepository_CreateManagedGroup(t *testing.T) { @@ -356,6 +360,12 @@ func TestRepository_DeleteManagedGroup(t *testing.T) { } func TestRepository_ListManagedGroups(t *testing.T) { + oldReadTimeout := globals.RefreshReadLookbackDuration + globals.RefreshReadLookbackDuration = 0 + t.Cleanup(func() { + globals.RefreshReadLookbackDuration = oldReadTimeout + }) + conn, _ := db.TestSetup(t, "postgres") rw := db.New(conn) wrapper := db.TestWrapper(t) @@ -367,6 +377,11 @@ func TestRepository_ListManagedGroups(t *testing.T) { databaseWrapper, err := kmsCache.GetWrapper(ctx, org.PublicId, kms.KeyPurposeDatabase) require.NoError(t, err) + + repo, err := NewRepository(ctx, rw, rw, kmsCache) + assert.NoError(t, err) + require.NotNil(t, repo) + authMethod1 := TestAuthMethod( t, conn, databaseWrapper, org.PublicId, ActivePrivateState, "alice-rp", "fido", @@ -393,18 +408,24 @@ func TestRepository_ListManagedGroups(t *testing.T) { TestManagedGroup(t, conn, authMethod1, TestFakeManagedGroupFilter), TestManagedGroup(t, conn, authMethod1, TestFakeManagedGroupFilter), } - sort.Slice(mgs1, func(i, j int) bool { - return strings.Compare(mgs1[i].PublicId, mgs1[j].PublicId) < 0 - }) mgs2 := []*ManagedGroup{ TestManagedGroup(t, conn, authMethod2, TestFakeManagedGroupFilter), TestManagedGroup(t, conn, authMethod2, TestFakeManagedGroupFilter), TestManagedGroup(t, conn, authMethod2, TestFakeManagedGroupFilter), } - sort.Slice(mgs2, func(i, j int) bool { - return strings.Compare(mgs2[i].PublicId, mgs2[j].PublicId) < 0 - }) + + slices.Reverse(mgs1) + slices.Reverse(mgs2) + + cmpOpts := []cmp.Option{ + cmpopts.IgnoreUnexported( + ManagedGroup{}, + store.ManagedGroup{}, + timestamp.Timestamp{}, + timestamppb.Timestamp{}, + ), + } tests := []struct { name string @@ -439,25 +460,48 @@ func TestRepository_ListManagedGroups(t *testing.T) { for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - assert, require := assert.New(t), require.New(t) - repo, err := NewRepository(ctx, rw, rw, kmsCache) - assert.NoError(err) - require.NotNil(repo) - got, err := repo.ListManagedGroups(context.Background(), tt.in, tt.opts...) + got, ttime, err := repo.ListManagedGroups(context.Background(), tt.in, tt.opts...) if tt.wantIsErr != 0 { - assert.Truef(errors.Match(errors.T(tt.wantIsErr), err), "Unexpected error %s", err) - assert.Equal(tt.wantErrMsg, err.Error()) + assert.Truef(t, errors.Match(errors.T(tt.wantIsErr), err), "Unexpected error %s", err) + assert.Equal(t, tt.wantErrMsg, err.Error()) return } - require.NoError(err) + require.NoError(t, err) + // 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))) - sort.Slice(got, func(i, j int) bool { - return strings.Compare(got[i].PublicId, got[j].PublicId) < 0 - }) - - assert.EqualValues(tt.want, got) + assert.EqualValues(t, tt.want, got) }) } + + t.Run("validation", func(t *testing.T) { + t.Parallel() + t.Run("missing auth method id", func(t *testing.T) { + t.Parallel() + _, _, err := repo.ListManagedGroups(ctx, "", WithLimit(1)) + require.ErrorContains(t, err, "missing auth method id") + }) + }) + + t.Run("success-without-after-item", func(t *testing.T) { + t.Parallel() + resp, ttime, err := repo.ListManagedGroups(ctx, authMethod1.PublicId, WithLimit(10)) + require.NoError(t, err) + // 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))) + require.Empty(t, cmp.Diff(resp, mgs1, cmpOpts...)) + }) + t.Run("success-with-after-item", func(t *testing.T) { + t.Parallel() + resp, ttime, err := repo.ListManagedGroups(ctx, authMethod1.PublicId, WithStartPageAfterItem(mgs1[0]), WithLimit(10)) + require.NoError(t, err) + // 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))) + require.Empty(t, cmp.Diff(resp, mgs1[1:], cmpOpts...)) + }) } func TestRepository_ListManagedGroups_Limits(t *testing.T) { @@ -537,13 +581,132 @@ func TestRepository_ListManagedGroups_Limits(t *testing.T) { repo, err := NewRepository(ctx, rw, rw, kmsCache, tt.repoOpts...) assert.NoError(err) require.NotNil(repo) - got, err := repo.ListManagedGroups(context.Background(), am.GetPublicId(), tt.listOpts...) + got, ttime, err := repo.ListManagedGroups(context.Background(), am.GetPublicId(), tt.listOpts...) require.NoError(err) + // 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))) assert.Len(got, tt.wantLen) }) } } +func TestRepository_ListManagedGroupsRefresh(t *testing.T) { + oldReadTimeout := globals.RefreshReadLookbackDuration + globals.RefreshReadLookbackDuration = 0 + t.Cleanup(func() { + globals.RefreshReadLookbackDuration = oldReadTimeout + }) + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + + ctx := context.Background() + kmsCache := kms.TestKms(t, conn, wrapper) + iamRepo := iam.TestRepo(t, conn, wrapper) + org, _ := iam.TestScopes(t, iamRepo) + + databaseWrapper, err := kmsCache.GetWrapper(ctx, org.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) + + authMethod1 := TestAuthMethod( + t, conn, databaseWrapper, org.PublicId, ActivePrivateState, + "alice-rp", "fido", + WithSigningAlgs(RS256), + WithIssuer(TestConvertToUrls(t, "https://www.alice1.com")[0]), + WithApiUrl(TestConvertToUrls(t, "https://www.alice.com/callback")[0]), + ) + authMethod2 := TestAuthMethod( + t, conn, databaseWrapper, org.PublicId, ActivePrivateState, + "alice-rp", "fido", + WithSigningAlgs(RS256), + WithIssuer(TestConvertToUrls(t, "https://www.alice2.com")[0]), + WithApiUrl(TestConvertToUrls(t, "https://www.alice.com/callback")[0]), + ) + + mgs1 := []*ManagedGroup{ + TestManagedGroup(t, conn, authMethod1, TestFakeManagedGroupFilter), + TestManagedGroup(t, conn, authMethod1, TestFakeManagedGroupFilter), + TestManagedGroup(t, conn, authMethod1, TestFakeManagedGroupFilter), + } + + mgs2 := []*ManagedGroup{ + TestManagedGroup(t, conn, authMethod2, TestFakeManagedGroupFilter), + TestManagedGroup(t, conn, authMethod2, TestFakeManagedGroupFilter), + TestManagedGroup(t, conn, authMethod2, TestFakeManagedGroupFilter), + } + + slices.Reverse(mgs1) + slices.Reverse(mgs2) + + fiveDaysAgo := time.Now().AddDate(0, 0, -5) + + cmpOpts := []cmp.Option{ + cmpopts.IgnoreUnexported( + ManagedGroup{}, + store.ManagedGroup{}, + timestamp.Timestamp{}, + timestamppb.Timestamp{}, + ), + cmpopts.SortSlices(func(i, j string) bool { return i < j }), + } + + repo, err := NewRepository(ctx, rw, rw, kmsCache) + require.NotNil(t, repo) + assert.NoError(t, err) + + t.Run("validation", func(t *testing.T) { + t.Parallel() + t.Run("missing updated after", func(t *testing.T) { + t.Parallel() + _, _, err := repo.ListManagedGroupsRefresh(ctx, authMethod1.PublicId, time.Time{}, WithLimit(1)) + require.ErrorContains(t, err, "missing updated after time") + }) + t.Run("missing auth method id", func(t *testing.T) { + t.Parallel() + _, _, err := repo.ListManagedGroupsRefresh(ctx, "", fiveDaysAgo, WithLimit(1)) + require.ErrorContains(t, err, "missing auth method id") + }) + }) + + t.Run("success-without-after-item", func(t *testing.T) { + t.Parallel() + resp, ttime, err := repo.ListManagedGroupsRefresh(ctx, authMethod1.PublicId, fiveDaysAgo, WithLimit(10)) + require.NoError(t, err) + // 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))) + require.Empty(t, cmp.Diff(resp, mgs1, cmpOpts...)) + }) + t.Run("success-with-after-item", func(t *testing.T) { + t.Parallel() + resp, ttime, err := repo.ListManagedGroupsRefresh(ctx, authMethod1.PublicId, fiveDaysAgo, WithStartPageAfterItem(mgs1[0]), WithLimit(10)) + require.NoError(t, err) + // 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))) + require.Empty(t, cmp.Diff(resp, mgs1[1:], cmpOpts...)) + }) + t.Run("success-without-after-item-recent-updated-after", func(t *testing.T) { + t.Parallel() + resp, ttime, err := repo.ListManagedGroupsRefresh(ctx, authMethod1.PublicId, mgs1[len(mgs1)-1].GetUpdateTime().AsTime(), WithLimit(10)) + require.NoError(t, err) + // 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))) + require.Empty(t, cmp.Diff(resp, mgs1[:len(mgs1)-1], cmpOpts...)) + }) + t.Run("success-with-after-item-recent-updated-after", func(t *testing.T) { + t.Parallel() + resp, ttime, err := repo.ListManagedGroupsRefresh(ctx, authMethod1.PublicId, mgs1[len(mgs1)-1].GetUpdateTime().AsTime(), WithStartPageAfterItem(mgs1[0]), WithLimit(10)) + require.NoError(t, err) + // 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))) + require.Empty(t, cmp.Diff(resp, mgs1[1:len(mgs1)-1], cmpOpts...)) + }) +} + func TestRepository_UpdateManagedGroup(t *testing.T) { ctx := context.Background() conn, _ := db.TestSetup(t, "postgres") @@ -974,3 +1137,135 @@ func TestRepository_UpdateManagedGroup(t *testing.T) { }) } } + +func TestRepository_estimatedCountManagedGroups(t *testing.T) { + oldReadTimeout := globals.RefreshReadLookbackDuration + globals.RefreshReadLookbackDuration = 0 + t.Cleanup(func() { + globals.RefreshReadLookbackDuration = oldReadTimeout + }) + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + + ctx := context.Background() + kmsCache := kms.TestKms(t, conn, wrapper) + iamRepo := iam.TestRepo(t, conn, wrapper) + org, _ := iam.TestScopes(t, iamRepo) + + databaseWrapper, err := kmsCache.GetWrapper(ctx, org.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) + + repo, err := NewRepository(ctx, rw, rw, kmsCache) + assert.NoError(t, err) + + sqlDb, err := conn.SqlDB(ctx) + require.NoError(t, err) + + // Check total entries at start, expect 0 + numItems, err := repo.estimatedManagedGroupCount(ctx) + require.NoError(t, err) + assert.Equal(t, 0, numItems) + + // create managed group and check count, expect 1 + authMethod1 := TestAuthMethod( + t, conn, databaseWrapper, org.PublicId, ActivePrivateState, + "alice-rp", "fido", + WithSigningAlgs(RS256), + WithIssuer(TestConvertToUrls(t, "https://www.alice1.com")[0]), + WithApiUrl(TestConvertToUrls(t, "https://www.alice.com/callback")[0]), + ) + mg := TestManagedGroup(t, conn, authMethod1, TestFakeManagedGroupFilter) + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + + numItems, err = repo.estimatedManagedGroupCount(ctx) + require.NoError(t, err) + assert.Equal(t, 1, numItems) + + // Delete managed group and check count, expect 0 again + _, err = repo.DeleteManagedGroup(ctx, org.GetPublicId(), mg.GetPublicId()) + require.NoError(t, err) + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + + numItems, err = repo.estimatedManagedGroupCount(ctx) + require.NoError(t, err) + assert.Equal(t, 0, numItems) +} + +func TestRepository_listDeletedIdsManagedGroups(t *testing.T) { + oldReadTimeout := globals.RefreshReadLookbackDuration + globals.RefreshReadLookbackDuration = 0 + t.Cleanup(func() { + globals.RefreshReadLookbackDuration = oldReadTimeout + }) + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + + ctx := context.Background() + kmsCache := kms.TestKms(t, conn, wrapper) + iamRepo := iam.TestRepo(t, conn, wrapper) + org, _ := iam.TestScopes(t, iamRepo) + + databaseWrapper, err := kmsCache.GetWrapper(ctx, org.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) + + repo, err := NewRepository(ctx, rw, rw, kmsCache) + assert.NoError(t, err) + + sqlDb, err := conn.SqlDB(ctx) + require.NoError(t, err) + + // Check total entries at start, expect 0 + numItems, err := repo.estimatedManagedGroupCount(ctx) + require.NoError(t, err) + assert.Equal(t, 0, numItems) + + // create managed group and check count, expect 1 + authMethod1 := TestAuthMethod( + t, conn, databaseWrapper, org.PublicId, ActivePrivateState, + "alice-rp", "fido", + WithSigningAlgs(RS256), + WithIssuer(TestConvertToUrls(t, "https://www.alice1.com")[0]), + WithApiUrl(TestConvertToUrls(t, "https://www.alice.com/callback")[0]), + ) + mg := TestManagedGroup(t, conn, authMethod1, TestFakeManagedGroupFilter) + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + + deletedIds, ttime, err := repo.listDeletedManagedGroupIds(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 mg and check count, expect 1 entry + _, err = repo.DeleteManagedGroup(ctx, org.GetPublicId(), mg.GetPublicId()) + require.NoError(t, err) + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + + deletedIds, ttime, err = repo.listDeletedManagedGroupIds(ctx, time.Now().AddDate(-1, 0, 0)) + require.NoError(t, err) + assert.Empty( + t, + cmp.Diff( + []string{mg.GetPublicId()}, + deletedIds, + cmpopts.SortSlices(func(i, j string) bool { return i < j }), + ), + ) + // 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.listDeletedManagedGroupIds(ctx, time.Now()) + require.NoError(t, err) + require.Empty(t, deletedIds) + 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/auth/oidc/service_callback.go b/internal/auth/oidc/service_callback.go index 8d90dd718..2c711d021 100644 --- a/internal/auth/oidc/service_callback.go +++ b/internal/auth/oidc/service_callback.go @@ -193,7 +193,7 @@ func Callback( } // Get the set of all managed groups so we can filter - mgs, err := r.ListManagedGroups(ctx, am.GetPublicId()) + mgs, _, err := r.ListManagedGroups(ctx, am.GetPublicId()) if err != nil { return "", errors.Wrap(ctx, err, op) } diff --git a/internal/auth/oidc/service_callback_test.go b/internal/auth/oidc/service_callback_test.go index d3442ab76..1e7ea322d 100644 --- a/internal/auth/oidc/service_callback_test.go +++ b/internal/auth/oidc/service_callback_test.go @@ -819,8 +819,11 @@ func Test_ManagedGroupFiltering(t *testing.T) { tp.SetExpectedState(state) // Set the filters on the MGs for this test. First we need to get the current versions. - currMgs, err := repo.ListManagedGroups(ctx, testAuthMethod.PublicId) + currMgs, ttime, err := repo.ListManagedGroups(ctx, testAuthMethod.PublicId) require.NoError(err) + // 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))) require.Len(currMgs, 2) currVersionMap := map[string]uint32{ currMgs[0].PublicId: currMgs[0].Version, diff --git a/internal/auth/oidc/service_list_managed_groups.go b/internal/auth/oidc/service_list_managed_groups.go new file mode 100644 index 000000000..01c9e494c --- /dev/null +++ b/internal/auth/oidc/service_list_managed_groups.go @@ -0,0 +1,62 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package oidc + +import ( + "context" + "time" + + "github.com/hashicorp/boundary/internal/auth" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/pagination" +) + +// ListManagedGroups lists up to page size oidc managed groups, filtering out entries that +// do not pass the filter item function. It will automatically request +// more managed groups from the database, at page size chunks, to fill the page. +// It returns a new list token used to continue pagination or refresh items. +// Managed groups are ordered by create time descending (most recently created first). +func ListManagedGroups( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[auth.ManagedGroup], + repo *Repository, + authMethodId string, +) (*pagination.ListResponse[auth.ManagedGroup], error) { + const op = "oidc.ListManagedGroups" + + 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 authMethodId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing auth method ID") + } + + listItemsFn := func(ctx context.Context, lastPageItem auth.ManagedGroup, limit int) ([]auth.ManagedGroup, time.Time, error) { + opts := []Option{ + WithLimit(limit), + } + if lastPageItem != nil { + opts = append(opts, WithStartPageAfterItem(lastPageItem)) + } + oidcAccts, listTime, err := repo.ListManagedGroups(ctx, authMethodId, opts...) + if err != nil { + return nil, time.Time{}, err + } + var managedGroups []auth.ManagedGroup + for _, mg := range oidcAccts { + managedGroups = append(managedGroups, mg) + } + return managedGroups, listTime, nil + } + + return pagination.List(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedManagedGroupCount) +} diff --git a/internal/auth/oidc/service_list_managed_groups_ext_test.go b/internal/auth/oidc/service_list_managed_groups_ext_test.go new file mode 100644 index 000000000..66d88e3b6 --- /dev/null +++ b/internal/auth/oidc/service_list_managed_groups_ext_test.go @@ -0,0 +1,632 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package oidc_test + +import ( + "context" + "slices" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/boundary/globals" + "github.com/hashicorp/boundary/internal/auth" + "github.com/hashicorp/boundary/internal/auth/oidc" + "github.com/hashicorp/boundary/internal/auth/oidc/store" + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/db/timestamp" + "github.com/hashicorp/boundary/internal/iam" + "github.com/hashicorp/boundary/internal/kms" + "github.com/hashicorp/boundary/internal/listtoken" + "github.com/hashicorp/boundary/internal/types/resource" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestService_ListManagedGroups(t *testing.T) { + // Set database read timeout to avoid duplicates in response + oldReadTimeout := globals.RefreshReadLookbackDuration + globals.RefreshReadLookbackDuration = 0 + t.Cleanup(func() { + globals.RefreshReadLookbackDuration = oldReadTimeout + }) + + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + + ctx := context.Background() + sqlDb, err := conn.SqlDB(ctx) + require.NoError(t, err) + kmsCache := kms.TestKms(t, conn, wrapper) + iamRepo := iam.TestRepo(t, conn, wrapper) + org, _ := iam.TestScopes(t, iamRepo) + fiveDaysAgo := time.Now().AddDate(0, 0, -5) + + databaseWrapper, err := kmsCache.GetWrapper(ctx, org.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) + + repo, err := oidc.NewRepository(ctx, rw, rw, kmsCache) + assert.NoError(t, err) + require.NotNil(t, repo) + + authMethod := oidc.TestAuthMethod( + t, conn, databaseWrapper, org.PublicId, oidc.ActivePrivateState, + "alice-rp", "fido", + oidc.WithSigningAlgs(oidc.RS256), + oidc.WithIssuer(oidc.TestConvertToUrls(t, "https://www.alice1.com")[0]), + oidc.WithApiUrl(oidc.TestConvertToUrls(t, "https://www.alice.com/callback")[0]), + ) + + oidcMgs := []*oidc.ManagedGroup{ + oidc.TestManagedGroup(t, conn, authMethod, oidc.TestFakeManagedGroupFilter), + oidc.TestManagedGroup(t, conn, authMethod, oidc.TestFakeManagedGroupFilter), + oidc.TestManagedGroup(t, conn, authMethod, oidc.TestFakeManagedGroupFilter), + oidc.TestManagedGroup(t, conn, authMethod, oidc.TestFakeManagedGroupFilter), + oidc.TestManagedGroup(t, conn, authMethod, oidc.TestFakeManagedGroupFilter), + } + + cmpOpts := []cmp.Option{ + cmpopts.IgnoreUnexported( + oidc.ManagedGroup{}, + store.ManagedGroup{}, + timestamp.Timestamp{}, + timestamppb.Timestamp{}, + ), + } + + var mgs []auth.ManagedGroup + for _, mg := range oidcMgs { + mgs = append(mgs, mg) + } + // since we sort by create time descending, we need to reverse the slice + slices.Reverse(mgs) + + // Run analyze to update host estimate + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + + t.Run("ListManagedGroups validation", func(t *testing.T) { + t.Parallel() + t.Run("missing grants hash", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + _, err := oidc.ListManagedGroups(ctx, nil, 1, filterFunc, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + _, err := oidc.ListManagedGroups(ctx, []byte("some hash"), 0, filterFunc, repo, authMethod.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, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + _, err := oidc.ListManagedGroups(ctx, []byte("some hash"), -1, filterFunc, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "page size must be at least 1") + }) + t.Run("nil filter func", func(t *testing.T) { + t.Parallel() + _, err := oidc.ListManagedGroups(ctx, []byte("some hash"), 1, nil, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("nil repo", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + _, err := oidc.ListManagedGroups(ctx, []byte("some hash"), 1, filterFunc, nil, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing auth method ID", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + _, err := oidc.ListManagedGroups(ctx, []byte("some hash"), 1, filterFunc, repo, "") + require.ErrorContains(t, err, "missing auth method ID") + }) + }) + t.Run("ListManagedGroupsPage validation", func(t *testing.T) { + t.Parallel() + t.Run("missing grants hash", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsPage(ctx, nil, 1, filterFunc, tok, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsPage(ctx, []byte("some hash"), 0, filterFunc, tok, repo, authMethod.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, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsPage(ctx, []byte("some hash"), -1, filterFunc, tok, repo, authMethod.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.ManagedGroup, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsPage(ctx, []byte("some hash"), 1, nil, tok, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("nil token", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + _, err = oidc.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, nil, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing token") + }) + t.Run("wrong token type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, authMethod.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, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, tok, nil, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing auth method ID", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, "") + require.ErrorContains(t, err, "missing auth method ID") + }) + t.Run("wrong token resource type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.Target, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "token did not have a managed group resource type") + }) + }) + t.Run("ListManagedGroupsRefresh validation", func(t *testing.T) { + t.Parallel() + t.Run("missing grants hash", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsRefresh(ctx, nil, 1, filterFunc, tok, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsRefresh(ctx, []byte("some hash"), 0, filterFunc, tok, repo, authMethod.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, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsRefresh(ctx, []byte("some hash"), -1, filterFunc, tok, repo, authMethod.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.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, nil, tok, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("nil token", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + _, err = oidc.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, nil, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing token") + }) + t.Run("nil repo", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, tok, nil, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing auth method ID", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, tok, repo, "") + require.ErrorContains(t, err, "missing auth method ID") + }) + t.Run("wrong token resource type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.Target, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, tok, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "token did not have a managed group resource type") + }) + }) + t.Run("ListManagedGroupsRefreshPage validation", func(t *testing.T) { + t.Parallel() + t.Run("missing grants hash", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsRefreshPage(ctx, nil, 1, filterFunc, tok, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), 0, filterFunc, tok, repo, authMethod.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, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), -1, filterFunc, tok, repo, authMethod.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.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), 1, nil, tok, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("nil token", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + _, err = oidc.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), 1, filterFunc, nil, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing token") + }) + t.Run("wrong token type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, authMethod.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, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), 1, filterFunc, tok, nil, authMethod.GetPublicId()) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing credential store id", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.ManagedGroup, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = oidc.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, "") + require.ErrorContains(t, err, "missing auth method ID") + }) + t.Run("wrong token resource type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, mg auth.ManagedGroup) (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 = oidc.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, authMethod.GetPublicId()) + require.ErrorContains(t, err, "token did not have a managed group resource type") + }) + }) + + t.Run("simple pagination", func(t *testing.T) { + filterFunc := func(context.Context, auth.ManagedGroup) (bool, error) { + return true, nil + } + resp, err := oidc.ListManagedGroups(ctx, []byte("some hash"), 1, filterFunc, repo, authMethod.GetPublicId()) + 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, 5) + require.Empty(t, resp.DeletedIds) + require.Len(t, resp.Items, 1) + require.Empty(t, cmp.Diff(resp.Items[0], mgs[0], cmpOpts...)) + + resp2, err := oidc.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, resp.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp2.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp2.CompleteListing) + require.Equal(t, resp2.EstimatedItemCount, 5) + require.Empty(t, resp2.DeletedIds) + require.Len(t, resp2.Items, 1) + require.Empty(t, cmp.Diff(resp2.Items[0], mgs[1], cmpOpts...)) + + resp3, err := oidc.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, resp2.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp3.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp3.CompleteListing) + require.Equal(t, resp3.EstimatedItemCount, 5) + require.Empty(t, resp3.DeletedIds) + require.Len(t, resp3.Items, 1) + require.Empty(t, cmp.Diff(resp3.Items[0], mgs[2], cmpOpts...)) + + resp4, err := oidc.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, resp3.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp4.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp4.CompleteListing) + require.Equal(t, resp4.EstimatedItemCount, 5) + require.Empty(t, resp4.DeletedIds) + require.Len(t, resp4.Items, 1) + require.Empty(t, cmp.Diff(resp4.Items[0], mgs[3], cmpOpts...)) + + resp5, err := oidc.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, resp4.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp5.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp5.CompleteListing) + require.Equal(t, resp5.EstimatedItemCount, 5) + require.Empty(t, resp5.DeletedIds) + require.Len(t, resp5.Items, 1) + require.Empty(t, cmp.Diff(resp5.Items[0], mgs[4], cmpOpts...)) + + // Finished initial pagination phase, request refresh + // Expect no results. + resp6, err := oidc.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, resp5.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp6.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp6.CompleteListing) + require.Equal(t, resp6.EstimatedItemCount, 5) + require.Empty(t, resp6.DeletedIds) + require.Empty(t, resp6.Items) + + // Create some new mgs + mg1 := oidc.TestManagedGroup(t, conn, authMethod, oidc.TestFakeManagedGroupFilter) + mg2 := oidc.TestManagedGroup(t, conn, authMethod, oidc.TestFakeManagedGroupFilter) + t.Cleanup(func() { + repo.DeleteManagedGroup(ctx, org.GetPublicId(), mg1.GetPublicId()) + require.NoError(t, err) + repo.DeleteManagedGroup(ctx, org.GetPublicId(), mg2.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 mg2 + resp7, err := oidc.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, resp6.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp7.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp7.CompleteListing) + require.Equal(t, resp7.EstimatedItemCount, 7) + require.Empty(t, resp7.DeletedIds) + require.Len(t, resp7.Items, 1) + require.Empty(t, cmp.Diff(resp7.Items[0], mg2, cmpOpts...)) + + // Refresh again, should get mg1 + resp8, err := oidc.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), 1, filterFunc, resp7.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp8.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp8.CompleteListing) + require.Equal(t, resp8.EstimatedItemCount, 7) + require.Empty(t, resp8.DeletedIds) + require.Len(t, resp8.Items, 1) + require.Empty(t, cmp.Diff(resp8.Items[0], mg1, cmpOpts...)) + + // Refresh again, should get no results + resp9, err := oidc.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, resp8.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp9.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp9.CompleteListing) + require.Equal(t, resp9.EstimatedItemCount, 7) + require.Empty(t, resp9.DeletedIds) + require.Empty(t, resp9.Items) + }) + + t.Run("simple pagination with aggressive filtering", func(t *testing.T) { + filterFunc := func(ctx context.Context, mg auth.ManagedGroup) (bool, error) { + return mg.GetPublicId() == mgs[1].GetPublicId() || + mg.GetPublicId() == mgs[len(mgs)-1].GetPublicId(), nil + } + resp, err := oidc.ListManagedGroups(ctx, []byte("some hash"), 1, filterFunc, repo, authMethod.GetPublicId()) + 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, 5) + require.Empty(t, resp.DeletedIds) + require.Len(t, resp.Items, 1) + require.Empty(t, cmp.Diff(resp.Items[0], mgs[1], cmpOpts...)) + + resp2, err := oidc.ListManagedGroupsPage(ctx, []byte("some hash"), 1, filterFunc, resp.ListToken, repo, authMethod.GetPublicId()) + 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, 5) + require.Empty(t, resp2.DeletedIds) + require.Len(t, resp2.Items, 1) + require.Empty(t, cmp.Diff(resp2.Items[0], mgs[len(mgs)-1], cmpOpts...)) + + // request a refresh, nothing should be returned + resp3, err := oidc.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, resp.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp3.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp3.CompleteListing) + require.Equal(t, resp3.EstimatedItemCount, 5) + require.Empty(t, resp3.DeletedIds) + require.Empty(t, resp3.Items) + + // Create some new mgs + mg1 := oidc.TestManagedGroup(t, conn, authMethod, oidc.TestFakeManagedGroupFilter) + mg2 := oidc.TestManagedGroup(t, conn, authMethod, oidc.TestFakeManagedGroupFilter) + mg3 := oidc.TestManagedGroup(t, conn, authMethod, oidc.TestFakeManagedGroupFilter) + t.Cleanup(func() { + repo.DeleteManagedGroup(ctx, org.GetPublicId(), mg1.GetPublicId()) + require.NoError(t, err) + repo.DeleteManagedGroup(ctx, org.GetPublicId(), mg2.GetPublicId()) + require.NoError(t, err) + repo.DeleteManagedGroup(ctx, org.GetPublicId(), mg3.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) + + filterFunc = func(_ context.Context, mg auth.ManagedGroup) (bool, error) { + return mg.GetPublicId() == mg3.GetPublicId() || + mg.GetPublicId() == mg1.GetPublicId(), nil + } + // Refresh again, should get mg3 + resp4, err := oidc.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, resp3.ListToken, repo, authMethod.GetPublicId()) + 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], mg3, cmpOpts...)) + + // Refresh again, should get mg1 + resp5, err := oidc.ListManagedGroupsRefreshPage(ctx, []byte("some hash"), 1, filterFunc, resp4.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp5.ListToken.GrantsHash, []byte("some hash")) + require.True(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], mg1, cmpOpts...)) + }) + + t.Run("simple pagination with deletion", func(t *testing.T) { + filterFunc := func(context.Context, auth.ManagedGroup) (bool, error) { + return true, nil + } + deletedMGId := mgs[0].GetPublicId() + repo.DeleteManagedGroup(ctx, org.GetPublicId(), deletedMGId) + require.NoError(t, err) + mgs = mgs[1:] + + // Run analyze to update count estimate + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + + resp, err := oidc.ListManagedGroups(ctx, []byte("some hash"), 1, filterFunc, repo, authMethod.GetPublicId()) + 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, 4) + require.Empty(t, resp.DeletedIds) + require.Len(t, resp.Items, 1) + require.Empty(t, cmp.Diff(resp.Items[0], mgs[0], cmpOpts...)) + + // request remaining results + resp2, err := oidc.ListManagedGroupsPage(ctx, []byte("some hash"), 3, filterFunc, resp.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp2.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp2.CompleteListing) + require.Equal(t, resp2.EstimatedItemCount, 4) + require.Empty(t, resp2.DeletedIds) + require.Len(t, resp2.Items, 3) + require.Empty(t, cmp.Diff(resp2.Items, mgs[1:], cmpOpts...)) + + deletedMGId = mgs[0].GetPublicId() + repo.DeleteManagedGroup(ctx, org.GetPublicId(), deletedMGId) + require.NoError(t, err) + mgs = mgs[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 := oidc.ListManagedGroupsRefresh(ctx, []byte("some hash"), 1, filterFunc, resp2.ListToken, repo, authMethod.GetPublicId()) + require.NoError(t, err) + require.Equal(t, resp3.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp3.CompleteListing) + require.Equal(t, resp3.EstimatedItemCount, 3) + require.Contains(t, resp3.DeletedIds, deletedMGId) + require.Empty(t, resp3.Items) + }) +} diff --git a/internal/auth/oidc/service_list_managed_groups_page.go b/internal/auth/oidc/service_list_managed_groups_page.go new file mode 100644 index 000000000..a09418448 --- /dev/null +++ b/internal/auth/oidc/service_list_managed_groups_page.go @@ -0,0 +1,79 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package oidc + +import ( + "context" + "time" + + "github.com/hashicorp/boundary/internal/auth" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/listtoken" + "github.com/hashicorp/boundary/internal/pagination" + "github.com/hashicorp/boundary/internal/types/resource" +) + +// ListManagedGroupsPage lists up to page size oidc managed groups, filtering out entries that +// do not pass the filter item function. It will automatically request +// more oidc managed groups 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. +// Managed groups are ordered by create time descending (most recently created first). +func ListManagedGroupsPage( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[auth.ManagedGroup], + tok *listtoken.Token, + repo *Repository, + authMethodId string, +) (*pagination.ListResponse[auth.ManagedGroup], error) { + const op = "oidc.ListManagedGroupsPage" + + 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 authMethodId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing auth method ID") + case tok.ResourceType != resource.ManagedGroup: + return nil, errors.New(ctx, errors.InvalidParameter, op, "token did not have a managed group 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 auth.ManagedGroup, limit int) ([]auth.ManagedGroup, 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)) + } + oidcManagedGroups, listTime, err := repo.ListManagedGroups(ctx, authMethodId, opts...) + if err != nil { + return nil, time.Time{}, err + } + var mgs []auth.ManagedGroup + for _, acct := range oidcManagedGroups { + mgs = append(mgs, acct) + } + return mgs, listTime, nil + } + + return pagination.ListPage(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedManagedGroupCount, tok) +} diff --git a/internal/auth/oidc/service_list_managed_groups_refresh.go b/internal/auth/oidc/service_list_managed_groups_refresh.go new file mode 100644 index 000000000..efa3f43dd --- /dev/null +++ b/internal/auth/oidc/service_list_managed_groups_refresh.go @@ -0,0 +1,81 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package oidc + +import ( + "context" + "time" + + "github.com/hashicorp/boundary/globals" + "github.com/hashicorp/boundary/internal/auth" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/listtoken" + "github.com/hashicorp/boundary/internal/pagination" + "github.com/hashicorp/boundary/internal/types/resource" +) + +// ListManagedGroupsRefresh lists oidc managed groups according to the page size +// and list token, filtering out entries that do not +// pass the filter item fn. It returns a new list token +// based on the old one, the grants hash, and the returned +// oidc managed groups. +func ListManagedGroupsRefresh( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[auth.ManagedGroup], + tok *listtoken.Token, + repo *Repository, + authMethodId string, +) (*pagination.ListResponse[auth.ManagedGroup], error) { + const op = "oidc.ListManagedGroupsRefresh" + + 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 authMethodId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing auth method ID") + case tok.ResourceType != resource.ManagedGroup: + return nil, errors.New(ctx, errors.InvalidParameter, op, "token did not have a managed group 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 auth.ManagedGroup, limit int) ([]auth.ManagedGroup, time.Time, error) { + opts := []Option{ + WithLimit(limit), + } + if lastPageItem != nil { + opts = append(opts, WithStartPageAfterItem(lastPageItem)) + } + // Add the database read timeout to managed group for any creations missed due to concurrent + // transactions in the initial pagination phase. + oidcManagedGroups, listTime, err := repo.ListManagedGroupsRefresh(ctx, authMethodId, rt.PreviousPhaseUpperBound.Add(-globals.RefreshReadLookbackDuration), opts...) + if err != nil { + return nil, time.Time{}, err + } + var managedGroups []auth.ManagedGroup + for _, managedGroup := range oidcManagedGroups { + managedGroups = append(managedGroups, managedGroup) + } + return managedGroups, listTime, nil + } + listDeletedIdsFn := func(ctx context.Context, since time.Time) ([]string, time.Time, error) { + // Add the database read timeout to managed group for any deletes missed due to concurrent + // transactions in the original list pagination phase. + return repo.listDeletedManagedGroupIds(ctx, since.Add(-globals.RefreshReadLookbackDuration)) + } + + return pagination.ListRefresh(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedManagedGroupCount, listDeletedIdsFn, tok) +} diff --git a/internal/auth/oidc/service_list_managed_groups_refresh_page.go b/internal/auth/oidc/service_list_managed_groups_refresh_page.go new file mode 100644 index 000000000..b48412a93 --- /dev/null +++ b/internal/auth/oidc/service_list_managed_groups_refresh_page.go @@ -0,0 +1,91 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package oidc + +import ( + "context" + "time" + + "github.com/hashicorp/boundary/globals" + "github.com/hashicorp/boundary/internal/auth" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/listtoken" + "github.com/hashicorp/boundary/internal/pagination" + "github.com/hashicorp/boundary/internal/types/resource" +) + +// ListManagedGroupsRefreshPage lists up to page size managed groups, filtering out entries that +// do not pass the filter item function. It will automatically request +// more managed groups 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. +// Managed groups are ordered by update time descending (most recently updated first). +// Managed groups may contain items that were already returned during the initial +// pagination phase. It also returns a list of any managed groups deleted since the +// last response. +func ListManagedGroupsRefreshPage( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[auth.ManagedGroup], + tok *listtoken.Token, + repo *Repository, + authMethodId string, +) (*pagination.ListResponse[auth.ManagedGroup], error) { + const op = "oidc.ListManagedGroupsRefreshPage" + + 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 authMethodId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing auth method ID") + case tok.ResourceType != resource.ManagedGroup: + return nil, errors.New(ctx, errors.InvalidParameter, op, "token did not have a managed group 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 auth.ManagedGroup, limit int) ([]auth.ManagedGroup, 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 managed group for any creations missed due to concurrent + // transactions in the original list pagination phase. + oidcManagedGroups, listTime, err := repo.ListManagedGroupsRefresh(ctx, authMethodId, rt.PhaseLowerBound.Add(-globals.RefreshReadLookbackDuration), opts...) + if err != nil { + return nil, time.Time{}, err + } + var managedGroups []auth.ManagedGroup + for _, managedGroup := range oidcManagedGroups { + managedGroups = append(managedGroups, managedGroup) + } + return managedGroups, listTime, nil + } + listDeletedIdsFn := func(ctx context.Context, since time.Time) ([]string, time.Time, error) { + // Add the database read timeout to managed group for any deletes missed due to concurrent + // transactions in the original list pagination phase. + return repo.listDeletedManagedGroupIds(ctx, since.Add(-globals.RefreshReadLookbackDuration)) + } + + return pagination.ListRefreshPage(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedManagedGroupCount, listDeletedIdsFn, tok) +} diff --git a/internal/daemon/controller/handler.go b/internal/daemon/controller/handler.go index 0d97f326e..8b5fcd6f3 100644 --- a/internal/daemon/controller/handler.go +++ b/internal/daemon/controller/handler.go @@ -279,7 +279,7 @@ func (c *Controller) registerGrpcServices(s *grpc.Server) error { services.RegisterSessionServiceServer(s, ss) } if _, ok := currentServices[services.ManagedGroupService_ServiceDesc.ServiceName]; !ok { - mgs, err := managed_groups.NewService(c.baseContext, c.OidcRepoFn, c.LdapRepoFn) + mgs, err := managed_groups.NewService(c.baseContext, c.OidcRepoFn, c.LdapRepoFn, c.conf.RawConfig.Controller.MaxPageSize) if err != nil { return fmt.Errorf("failed to create managed groups handler service: %w", err) } diff --git a/internal/daemon/controller/handlers/managed_groups/managed_group_service.go b/internal/daemon/controller/handlers/managed_groups/managed_group_service.go index 7d9e835eb..dd7c78091 100644 --- a/internal/daemon/controller/handlers/managed_groups/managed_group_service.go +++ b/internal/daemon/controller/handlers/managed_groups/managed_group_service.go @@ -19,6 +19,8 @@ import ( "github.com/hashicorp/boundary/internal/daemon/controller/handlers" "github.com/hashicorp/boundary/internal/errors" pbs "github.com/hashicorp/boundary/internal/gen/controller/api/services" + "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" @@ -93,14 +95,15 @@ func init() { type Service struct { pbs.UnsafeManagedGroupServiceServer - oidcRepoFn common.OidcAuthRepoFactory - ldapRepoFn common.LdapAuthRepoFactory + oidcRepoFn common.OidcAuthRepoFactory + ldapRepoFn common.LdapAuthRepoFactory + maxPageSize uint } var _ pbs.ManagedGroupServiceServer = (*Service)(nil) // NewService returns a managed group service which handles managed group related requests to boundary. -func NewService(ctx context.Context, oidcRepo common.OidcAuthRepoFactory, ldapRepo common.LdapAuthRepoFactory) (Service, error) { +func NewService(ctx context.Context, oidcRepo common.OidcAuthRepoFactory, ldapRepo common.LdapAuthRepoFactory, maxPageSize uint) (Service, error) { const op = "managed_groups.NewService" switch { case oidcRepo == nil: @@ -108,70 +111,187 @@ func NewService(ctx context.Context, oidcRepo common.OidcAuthRepoFactory, ldapRe case ldapRepo == nil: return Service{}, errors.New(ctx, errors.InvalidParameter, op, "missing ldap repository provided") } - return Service{oidcRepoFn: oidcRepo, ldapRepoFn: ldapRepo}, nil + if maxPageSize == 0 { + maxPageSize = uint(globals.DefaultMaxPageSize) + } + return Service{oidcRepoFn: oidcRepo, ldapRepoFn: ldapRepo, maxPageSize: maxPageSize}, nil } // ListManagedGroups implements the interface pbs.ManagedGroupsServiceServer. func (s Service) ListManagedGroups(ctx context.Context, req *pbs.ListManagedGroupsRequest) (*pbs.ListManagedGroupsResponse, error) { + const op = "managed_groups.(Service).ListManagedGroups" if err := validateListRequest(ctx, req); err != nil { - return nil, err + return nil, errors.Wrap(ctx, err, op) } _, authResults := s.parentAndAuthResult(ctx, req.GetAuthMethodId(), action.List) if authResults.Error != nil { return nil, authResults.Error } - ul, err := s.listFromRepo(ctx, req.GetAuthMethodId()) + + authMethodId := req.GetAuthMethodId() + 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()) + } + + var filterItemFn func(ctx context.Context, item auth.ManagedGroup) (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 + } + // TODO: replace the need for this function with some way to convert the `filter` + // to a domain type. This would allow filtering to happen in the domain, and we could + // remove this callback altogether. + filterItemFn = func(ctx context.Context, item auth.ManagedGroup) (bool, error) { + outputOpts, ok := newOutputOpts(ctx, item, authMethodId, authResults) + if !ok { + return false, nil + } + pbItem, err := toProto(ctx, item, outputOpts...) + if err != nil { + return false, err + } + + filterable, err := subtypes.Filterable(ctx, pbItem) + if err != nil { + return false, err + } + return filter.Match(filterable), nil + } + default: + filterItemFn = func(ctx context.Context, item auth.ManagedGroup) (bool, error) { + return true, nil + } + } + + grantsHash, err := authResults.GrantsHash(ctx) if err != nil { return nil, err } - if len(ul) == 0 { - return &pbs.ListManagedGroupsResponse{}, nil + + var listResp *pagination.ListResponse[auth.ManagedGroup] + var sortBy string + switch globals.ResourceInfoFromPrefix(authMethodId).Subtype { + case ldap.Subtype: + repo, err := s.ldapRepoFn() + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + if req.GetListToken() == "" { + sortBy = "created_time" + listResp, err = ldap.ListManagedGroups(ctx, grantsHash, pageSize, filterItemFn, repo, authMethodId) + if err != nil { + return nil, err + } + } else { + listToken, err := handlers.ParseListToken(ctx, req.GetListToken(), resource.ManagedGroup, grantsHash) + if err != nil { + return nil, err + } + switch st := listToken.Subtype.(type) { + case *listtoken.PaginationToken: + sortBy = "created_time" + listResp, err = ldap.ListManagedGroupsPage(ctx, grantsHash, pageSize, filterItemFn, listToken, repo, authMethodId) + if err != nil { + return nil, err + } + case *listtoken.StartRefreshToken: + sortBy = "updated_time" + listResp, err = ldap.ListManagedGroupsRefresh(ctx, grantsHash, pageSize, filterItemFn, listToken, repo, authMethodId) + if err != nil { + return nil, err + } + case *listtoken.RefreshToken: + sortBy = "updated_time" + listResp, err = ldap.ListManagedGroupsRefreshPage(ctx, grantsHash, pageSize, filterItemFn, listToken, repo, authMethodId) + if err != nil { + return nil, err + } + default: + return nil, handlers.ApiErrorWithCodeAndMessage(codes.InvalidArgument, "unexpected list token subtype: %T", st) + } + } + case oidc.Subtype: + repo, err := s.oidcRepoFn() + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + if req.GetListToken() == "" { + sortBy = "created_time" + listResp, err = oidc.ListManagedGroups(ctx, grantsHash, pageSize, filterItemFn, repo, authMethodId) + if err != nil { + return nil, err + } + } else { + listToken, err := handlers.ParseListToken(ctx, req.GetListToken(), resource.ManagedGroup, grantsHash) + if err != nil { + return nil, err + } + switch st := listToken.Subtype.(type) { + case *listtoken.PaginationToken: + sortBy = "created_time" + listResp, err = oidc.ListManagedGroupsPage(ctx, grantsHash, pageSize, filterItemFn, listToken, repo, authMethodId) + if err != nil { + return nil, err + } + case *listtoken.StartRefreshToken: + sortBy = "updated_time" + listResp, err = oidc.ListManagedGroupsRefresh(ctx, grantsHash, pageSize, filterItemFn, listToken, repo, authMethodId) + if err != nil { + return nil, err + } + case *listtoken.RefreshToken: + sortBy = "updated_time" + listResp, err = oidc.ListManagedGroupsRefreshPage(ctx, grantsHash, pageSize, filterItemFn, listToken, repo, authMethodId) + if err != nil { + return nil, err + } + default: + return nil, handlers.ApiErrorWithCodeAndMessage(codes.InvalidArgument, "unexpected list token subtype: %T", st) + } + } + default: + return nil, handlers.ApiErrorWithCodeAndMessage(codes.InvalidArgument, "unknown auth method type for id: %s", authMethodId) } - filter, err := handlers.NewFilter(ctx, req.GetFilter()) - if err != nil { - return nil, err - } - finalItems := make([]*pb.ManagedGroup, 0, len(ul)) - - res := perms.Resource{ - ScopeId: authResults.Scope.Id, - Type: resource.ManagedGroup, - Pin: req.GetAuthMethodId(), - } - for _, mg := range ul { - res.Id = mg.GetPublicId() - authorizedActions := authResults.FetchActionSetForId(ctx, mg.GetPublicId(), IdActions[globals.ResourceInfoFromPrefix(mg.GetPublicId()).Subtype], requestauth.WithResource(&res)).Strings() - if len(authorizedActions) == 0 { + finalItems := make([]*pb.ManagedGroup, 0, len(listResp.Items)) + for _, item := range listResp.Items { + outputOpts, ok := newOutputOpts(ctx, item, authMethodId, authResults) + if !ok { continue } - - 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(authResults.Scope)) - } - if outputFields.Has(globals.AuthorizedActionsField) { - outputOpts = append(outputOpts, handlers.WithAuthorizedActions(authorizedActions)) + pbItem, err := toProto(ctx, item, outputOpts...) + if err != nil { + continue } + finalItems = append(finalItems, pbItem) + } + respType := "delta" + if listResp.CompleteListing { + respType = "complete" + } + resp := &pbs.ListManagedGroupsResponse{ + Items: finalItems, + EstItemCount: uint32(listResp.EstimatedItemCount), + RemovedIds: listResp.DeletedIds, + ResponseType: respType, + SortBy: sortBy, + SortDir: "desc", + } - item, err := toProto(ctx, mg, outputOpts...) + if listResp.ListToken != nil { + resp.ListToken, err = handlers.MarshalListToken(ctx, listResp.ListToken, pbs.ResourceType_RESOURCE_TYPE_MANAGED_GROUP) if err != nil { return nil, err } - - // This comes last so that we can use item fields in the filter after - // the allowed fields are populated above - filterable, err := subtypes.Filterable(ctx, item) - if err != nil { - return nil, err - } - if filter.Match(filterable) { - finalItems = append(finalItems, item) - } } - return &pbs.ListManagedGroupsResponse{Items: finalItems}, nil + + return resp, nil } // GetManagedGroup implements the interface pbs.ManagedGroupServiceServer. @@ -588,39 +708,6 @@ func (s Service) deleteFromRepo(ctx context.Context, scopeId, id string) (bool, return rows > 0, nil } -func (s Service) listFromRepo(ctx context.Context, authMethodId string) ([]auth.ManagedGroup, error) { - const op = "managed_groups.(Service).listFromRepo" - - var outUl []auth.ManagedGroup - switch globals.ResourceInfoFromPrefix(authMethodId).Subtype { - case oidc.Subtype: - oidcRepo, err := s.oidcRepoFn() - if err != nil { - return nil, errors.Wrap(ctx, err, op) - } - oidcl, err := oidcRepo.ListManagedGroups(ctx, authMethodId, oidc.WithLimit(-1)) - if err != nil { - return nil, errors.Wrap(ctx, err, op) - } - for _, a := range oidcl { - outUl = append(outUl, a) - } - case ldap.Subtype: - ldapRepo, err := s.ldapRepoFn() - if err != nil { - return nil, errors.Wrap(ctx, err, op) - } - ldapUl, err := ldapRepo.ListManagedGroups(ctx, authMethodId, ldap.WithLimit(ctx, -1)) - if err != nil { - return nil, errors.Wrap(ctx, err, op) - } - for _, a := range ldapUl { - outUl = append(outUl, a) - } - } - return outUl, nil -} - func (s Service) parentAndAuthResult(ctx context.Context, id string, a action.Type) (auth.AuthMethod, requestauth.VerifyResults) { const op = "managed_groups.(Service)." res := requestauth.VerifyResults{} @@ -908,3 +995,27 @@ func validateListRequest(ctx context.Context, req *pbs.ListManagedGroupsRequest) } return nil } + +func newOutputOpts(ctx context.Context, item auth.ManagedGroup, authMethodId string, authResults requestauth.VerifyResults) ([]handlers.Option, bool) { + res := perms.Resource{ + ScopeId: authResults.Scope.Id, + Type: resource.ManagedGroup, + Pin: authMethodId, + } + res.Id = item.GetPublicId() + authorizedActions := authResults.FetchActionSetForId(ctx, item.GetPublicId(), IdActions[globals.ResourceInfoFromPrefix(item.GetPublicId()).Subtype], requestauth.WithResource(&res)).Strings() + 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(authResults.Scope)) + } + if outputFields.Has(globals.AuthorizedActionsField) { + outputOpts = append(outputOpts, handlers.WithAuthorizedActions(authorizedActions)) + } + return outputOpts, true +} diff --git a/internal/daemon/controller/handlers/managed_groups/managed_group_service_test.go b/internal/daemon/controller/handlers/managed_groups/managed_group_service_test.go index 293b60937..67feb2e29 100644 --- a/internal/daemon/controller/handlers/managed_groups/managed_group_service_test.go +++ b/internal/daemon/controller/handlers/managed_groups/managed_group_service_test.go @@ -7,7 +7,7 @@ import ( "context" "errors" "fmt" - "sort" + "slices" "strconv" "strings" "testing" @@ -16,16 +16,22 @@ import ( "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/boundary/globals" "github.com/hashicorp/boundary/internal/auth/ldap" + ldapstore "github.com/hashicorp/boundary/internal/auth/ldap/store" "github.com/hashicorp/boundary/internal/auth/oidc" + oidcstore "github.com/hashicorp/boundary/internal/auth/oidc/store" "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/common" "github.com/hashicorp/boundary/internal/daemon/controller/handlers" "github.com/hashicorp/boundary/internal/daemon/controller/handlers/managed_groups" "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/action" "github.com/hashicorp/boundary/internal/types/scope" pb "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/managedgroups" @@ -94,7 +100,7 @@ func TestNewService(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - _, err := managed_groups.NewService(ctx, tc.oidcRepo, tc.ldapRepo) + _, err := managed_groups.NewService(ctx, tc.oidcRepo, tc.ldapRepo, 1000) if tc.wantErr { assert.Error(t, err) assert.Contains(t, err.Error(), tc.wantErrContains) @@ -121,7 +127,7 @@ func TestGet(t *testing.T) { return ldap.NewRepository(ctx, rw, rw, kmsCache) } - s, err := managed_groups.NewService(ctx, oidcRepoFn, ldapRepoFn) + s, err := managed_groups.NewService(ctx, oidcRepoFn, ldapRepoFn, 1000) require.NoError(t, err, "Couldn't create new managed groups service.") org, _ := iam.TestScopes(t, iam.TestRepo(t, conn, wrap)) @@ -268,6 +274,8 @@ func TestGet(t *testing.T) { func TestListOidc(t *testing.T) { ctx := context.Background() conn, _ := db.TestSetup(t, "postgres") + sqlDb, err := conn.SqlDB(ctx) + require.NoError(t, err) rw := db.New(conn) wrap := db.TestWrapper(t) kmsCache := kms.TestKms(t, conn, wrap) @@ -333,6 +341,12 @@ func TestListOidc(t *testing.T) { }) } + slices.Reverse(wantSomeManagedGroups) + slices.Reverse(wantOtherManagedGroups) + + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + cases := []struct { name string req *pbs.ListManagedGroupsRequest @@ -343,17 +357,33 @@ func TestListOidc(t *testing.T) { { name: "List Some ManagedGroups", req: &pbs.ListManagedGroupsRequest{AuthMethodId: amSomeManagedGroups.GetPublicId()}, - res: &pbs.ListManagedGroupsResponse{Items: wantSomeManagedGroups}, + res: &pbs.ListManagedGroupsResponse{ + Items: wantSomeManagedGroups, + EstItemCount: 3, + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, }, { name: "List Other ManagedGroups", req: &pbs.ListManagedGroupsRequest{AuthMethodId: amOtherManagedGroups.GetPublicId()}, - res: &pbs.ListManagedGroupsResponse{Items: wantOtherManagedGroups}, + res: &pbs.ListManagedGroupsResponse{ + Items: wantOtherManagedGroups, + EstItemCount: 3, + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, }, { name: "List No ManagedGroups", req: &pbs.ListManagedGroupsRequest{AuthMethodId: amNoManagedGroups.GetPublicId()}, - res: &pbs.ListManagedGroupsResponse{}, + res: &pbs.ListManagedGroupsResponse{ + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, }, { name: "Unfound Auth Method", @@ -366,7 +396,13 @@ func TestListOidc(t *testing.T) { AuthMethodId: amSomeManagedGroups.GetPublicId(), Filter: fmt.Sprintf(`"/item/name"==%q`, wantSomeManagedGroups[1].Name.GetValue()), }, - res: &pbs.ListManagedGroupsResponse{Items: wantSomeManagedGroups[1:2]}, + res: &pbs.ListManagedGroupsResponse{ + Items: wantSomeManagedGroups[1:2], + EstItemCount: 1, + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, skipAnon: true, }, { @@ -375,7 +411,11 @@ func TestListOidc(t *testing.T) { AuthMethodId: amSomeManagedGroups.GetPublicId(), Filter: `"/item/id"=="noManagedGroupmatchesthis"`, }, - res: &pbs.ListManagedGroupsResponse{}, + res: &pbs.ListManagedGroupsResponse{ + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, }, { name: "Filter Bad Format", @@ -386,7 +426,7 @@ func TestListOidc(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 := managed_groups.NewService(ctx, oidcRepoFn, ldapRepoFn) + s, err := managed_groups.NewService(ctx, oidcRepoFn, ldapRepoFn, 1000) require.NoError(err, "Couldn't create new managed group service.") got, gErr := s.ListManagedGroups(auth.DisabledAuthTestContext(iamRepoFn, o.GetPublicId()), tc.req) @@ -397,17 +437,14 @@ func TestListOidc(t *testing.T) { } else { require.NoError(gErr) } - sort.Slice(got.Items, func(i, j int) bool { - return strings.Compare(got.Items[i].GetName().GetValue(), - got.Items[j].GetName().GetValue()) < 0 - }) assert.Empty(cmp.Diff( got, tc.res, - protocmp.Transform(), cmpopts.SortSlices(func(a, b string) bool { return a < b }), + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListManagedGroupsResponse{}, "list_token"), ), "ListManagedGroups() with scope %q got response %q, wanted %q", tc.req, got, tc.res) // Now test with anon @@ -433,6 +470,8 @@ func TestListLdap(t *testing.T) { rw := db.New(conn) wrap := db.TestWrapper(t) kmsCache := kms.TestKms(t, conn, wrap) + sqlDb, err := conn.SqlDB(ctx) + require.NoError(t, err) oidcRepoFn := func() (*oidc.Repository, error) { return oidc.NewRepository(ctx, rw, rw, kmsCache) } @@ -493,6 +532,12 @@ func TestListLdap(t *testing.T) { }) } + slices.Reverse(wantSomeManagedGroups) + slices.Reverse(wantOtherManagedGroups) + + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + cases := []struct { name string req *pbs.ListManagedGroupsRequest @@ -504,17 +549,33 @@ func TestListLdap(t *testing.T) { { name: "List Some ManagedGroups", req: &pbs.ListManagedGroupsRequest{AuthMethodId: amSomeManagedGroups.GetPublicId()}, - res: &pbs.ListManagedGroupsResponse{Items: wantSomeManagedGroups}, + res: &pbs.ListManagedGroupsResponse{ + Items: wantSomeManagedGroups, + EstItemCount: 3, + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, }, { name: "List Other ManagedGroups", req: &pbs.ListManagedGroupsRequest{AuthMethodId: amOtherManagedGroups.GetPublicId()}, - res: &pbs.ListManagedGroupsResponse{Items: wantOtherManagedGroups}, + res: &pbs.ListManagedGroupsResponse{ + Items: wantOtherManagedGroups, + EstItemCount: 3, + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, }, { name: "List No ManagedGroups", req: &pbs.ListManagedGroupsRequest{AuthMethodId: amNoManagedGroups.GetPublicId()}, - res: &pbs.ListManagedGroupsResponse{}, + res: &pbs.ListManagedGroupsResponse{ + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, }, { name: "Unfound Auth Method", @@ -528,7 +589,13 @@ func TestListLdap(t *testing.T) { AuthMethodId: amSomeManagedGroups.GetPublicId(), Filter: fmt.Sprintf(`"/item/name"==%q`, wantSomeManagedGroups[1].Name.GetValue()), }, - res: &pbs.ListManagedGroupsResponse{Items: wantSomeManagedGroups[1:2]}, + res: &pbs.ListManagedGroupsResponse{ + Items: wantSomeManagedGroups[1:2], + EstItemCount: 1, + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, skipAnon: true, }, { @@ -537,7 +604,11 @@ func TestListLdap(t *testing.T) { AuthMethodId: amSomeManagedGroups.GetPublicId(), Filter: `"/item/id"=="noManagedGroupmatchesthis"`, }, - res: &pbs.ListManagedGroupsResponse{}, + res: &pbs.ListManagedGroupsResponse{ + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, }, { name: "Filter Bad Format", @@ -549,7 +620,7 @@ func TestListLdap(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 := managed_groups.NewService(ctx, oidcRepoFn, ldapRepoFn) + s, err := managed_groups.NewService(ctx, oidcRepoFn, ldapRepoFn, 1000) require.NoError(err, "Couldn't create new managed group service.") got, gErr := s.ListManagedGroups(auth.DisabledAuthTestContext(iamRepoFn, o.GetPublicId()), tc.req) @@ -562,17 +633,14 @@ func TestListLdap(t *testing.T) { } else { require.NoError(gErr) } - sort.Slice(got.Items, func(i, j int) bool { - return strings.Compare(got.Items[i].GetName().GetValue(), - got.Items[j].GetName().GetValue()) < 0 - }) assert.Empty(cmp.Diff( got, tc.res, - protocmp.Transform(), cmpopts.SortSlices(func(a, b string) bool { return a < b }), + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListManagedGroupsResponse{}, "list_token"), ), "ListManagedGroups() with scope %q got response %q, wanted %q", tc.req, got, tc.res) // Now test with anon @@ -592,6 +660,645 @@ func TestListLdap(t *testing.T) { } } +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) + rw := db.New(conn) + wrap := db.TestWrapper(t) + kmsCache := kms.TestKms(t, conn, wrap) + oidcRepoFn := func() (*oidc.Repository, error) { + return oidc.NewRepository(ctx, rw, rw, kmsCache) + } + iamRepoFn := func() (*iam.Repository, error) { + return iam.NewRepository(ctx, rw, rw, kmsCache) + } + ldapRepoFn := func() (*ldap.Repository, error) { + return ldap.NewRepository(ctx, rw, rw, kmsCache) + } + + tokenRepoFn := func() (*authtoken.Repository, error) { + return authtoken.NewRepository(ctx, rw, rw, kmsCache) + } + serversRepoFn := func() (*server.Repository, error) { + return server.NewRepository(ctx, rw, rw, kmsCache) + } + + s, err := managed_groups.NewService(ctx, oidcRepoFn, ldapRepoFn, 1000) + require.NoError(t, err) + + iamRepo := iam.TestRepo(t, conn, wrap) + tokenRepo, _ := tokenRepoFn() + oidcRepo, _ := oidcRepoFn() + ldapRepo, _ := ldapRepoFn() + o, pwt := iam.TestScopes(t, iamRepo) + + t.Run("oidc", func(t *testing.T) { + databaseWrapper, err := kmsCache.GetWrapper(ctx, o.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) + authMethod := oidc.TestAuthMethod(t, conn, databaseWrapper, o.PublicId, oidc.ActivePrivateState, "somemgs", "fido", + oidc.WithIssuer(oidc.TestConvertToUrls(t, "https://www.somemgs.com")[0]), oidc.WithSigningAlgs(oidc.RS256), oidc.WithApiUrl(oidc.TestConvertToUrls(t, "https://www.alice.com/callback")[0])) + + var mgs []*pb.ManagedGroup + for i := 0; i < 10; i++ { + mg := oidc.TestManagedGroup(t, conn, authMethod, oidc.TestFakeManagedGroupFilter, oidc.WithName(strconv.Itoa(i))) + mgs = append(mgs, &pb.ManagedGroup{ + Id: mg.GetPublicId(), + AuthMethodId: mg.GetAuthMethodId(), + Name: wrapperspb.String(strconv.Itoa(i)), + CreatedTime: mg.GetCreateTime().GetTimestamp(), + UpdatedTime: mg.GetUpdateTime().GetTimestamp(), + Scope: &scopepb.ScopeInfo{Id: o.GetPublicId(), Type: scope.Org.String(), ParentScopeId: scope.Global.String()}, + Version: 1, + Type: oidc.Subtype.String(), + Attrs: &pb.ManagedGroup_OidcManagedGroupAttributes{ + OidcManagedGroupAttributes: &pb.OidcManagedGroupAttributes{ + Filter: oidc.TestFakeManagedGroupFilter, + }, + }, + AuthorizedActions: oidcAuthorizedActions, + }) + } + + acct := oidc.TestAccount(t, conn, authMethod, "test-login-last") + u := iam.TestUser(t, iamRepo, o.GetPublicId(), iam.WithAccountIds(acct.PublicId)) + + privProjRole := iam.TestRole(t, conn, pwt.GetPublicId()) + iam.TestRoleGrant(t, conn, privProjRole.GetPublicId(), "id=*;type=*;actions=*") + iam.TestUserRole(t, conn, privProjRole.GetPublicId(), u.GetPublicId()) + privOrgRole := iam.TestRole(t, conn, o.GetPublicId()) + iam.TestRoleGrant(t, conn, privOrgRole.GetPublicId(), "id=*;type=*;actions=*") + iam.TestUserRole(t, conn, privOrgRole.GetPublicId(), u.GetPublicId()) + + // Since we sort by created_time descending, we reverse the slice + slices.Reverse(mgs) + + at, _ := tokenRepo.CreateAuthToken(ctx, u, acct.GetPublicId()) + + 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, kmsCache, &requestInfo) + + req := &pbs.ListManagedGroupsRequest{ + AuthMethodId: authMethod.GetPublicId(), + Filter: "", + ListToken: "", + PageSize: 2, + } + + // Run analyze in the DB to update the estimate tables + _, err = sqlDB.ExecContext(ctx, "analyze") + require.NoError(t, err) + + got, err := s.ListManagedGroups(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 2) + + // all comparisons will be done without list token + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListManagedGroupsResponse{ + Items: mgs[0:2], + ResponseType: "delta", + ListToken: "", + SortBy: "created_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: 10, + }, + cmpopts.SortSlices(func(a, b string) bool { + return a < b + }), + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListManagedGroupsResponse{}, "list_token"), + ), + ) + + // second page + req.ListToken = got.ListToken + got, err = s.ListManagedGroups(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 2) + + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListManagedGroupsResponse{ + Items: mgs[2:4], + ResponseType: "delta", + ListToken: "", + SortBy: "created_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: 10, + }, + cmpopts.SortSlices(func(a, b string) bool { + return a < b + }), + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListManagedGroupsResponse{}, "list_token"), + ), + ) + + // remainder of results + req.ListToken = got.ListToken + req.PageSize = 6 + got, err = s.ListManagedGroups(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 6) + + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListManagedGroupsResponse{ + Items: mgs[4:], + ResponseType: "complete", + ListToken: "", + SortBy: "created_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: 10, + }, + cmpopts.SortSlices(func(a, b string) bool { + return a < b + }), + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListManagedGroupsResponse{}, "list_token"), + ), + ) + + // create another managed group + mg := oidc.TestManagedGroup(t, conn, authMethod, oidc.TestFakeManagedGroupFilter, oidc.WithName("new-oidc-mg")) + newMg := &pb.ManagedGroup{ + Id: mg.GetPublicId(), + AuthMethodId: mg.GetAuthMethodId(), + Name: wrapperspb.String("new-oidc-mg"), + CreatedTime: mg.GetCreateTime().GetTimestamp(), + UpdatedTime: mg.GetUpdateTime().GetTimestamp(), + Scope: &scopepb.ScopeInfo{Id: o.GetPublicId(), Type: scope.Org.String(), ParentScopeId: scope.Global.String()}, + Version: 1, + Type: oidc.Subtype.String(), + Attrs: &pb.ManagedGroup_OidcManagedGroupAttributes{ + OidcManagedGroupAttributes: &pb.OidcManagedGroupAttributes{ + Filter: oidc.TestFakeManagedGroupFilter, + }, + }, + AuthorizedActions: oidcAuthorizedActions, + } + // Add to front since it's the latest updated + mgs = append([]*pb.ManagedGroup{newMg}, mgs...) + + // delete different mg + _, err = oidcRepo.DeleteManagedGroup(ctx, o.GetPublicId(), mgs[len(mgs)-1].Id) + require.NoError(t, err) + deletedMg := mgs[len(mgs)-1] + mgs = mgs[:len(mgs)-1] + + // update another mg + mgs[1].Name = wrapperspb.String("new-name") + mgs[1].Version = 2 + m := &oidc.ManagedGroup{ + ManagedGroup: &oidcstore.ManagedGroup{ + PublicId: mgs[1].Id, + AuthMethodId: mgs[1].AuthMethodId, + Name: mgs[1].Name.GetValue(), + }, + } + um, _, err := oidcRepo.UpdateManagedGroup(ctx, o.GetPublicId(), m, 1, []string{"name"}) + require.NoError(t, err) + mgs[1].UpdatedTime = um.GetUpdateTime().GetTimestamp() + mgs[1].Version = um.GetVersion() + // Add to the front since it's most recently updated + mgs = append( + []*pb.ManagedGroup{mgs[1]}, + append( + []*pb.ManagedGroup{mgs[0]}, + mgs[2:]..., + )..., + ) + + // 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 = s.ListManagedGroups(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 1) + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListManagedGroupsResponse{ + Items: []*pb.ManagedGroup{mgs[0]}, + ResponseType: "delta", + ListToken: "", + SortBy: "updated_time", + SortDir: "desc", + RemovedIds: []string{deletedMg.Id}, + EstItemCount: 10, + }, + cmpopts.SortSlices(func(a, b string) bool { + return a < b + }), + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListManagedGroupsResponse{}, "list_token"), + ), + ) + + // Get next page + req.ListToken = got.ListToken + got, err = s.ListManagedGroups(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 1) + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListManagedGroupsResponse{ + Items: []*pb.ManagedGroup{mgs[1]}, + ResponseType: "complete", + ListToken: "", + SortBy: "updated_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: 10, + }, + cmpopts.SortSlices(func(a, b string) bool { + return a < b + }), + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListManagedGroupsResponse{}, "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`, mgs[len(mgs)-2].Id, mgs[len(mgs)-1].Id) + got, err = s.ListManagedGroups(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 1) + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListManagedGroupsResponse{ + Items: []*pb.ManagedGroup{mgs[len(mgs)-2]}, + ResponseType: "delta", + ListToken: "", + SortBy: "created_time", + SortDir: "desc", + // Should be empty again + RemovedIds: nil, + EstItemCount: 10, + }, + cmpopts.SortSlices(func(a, b string) bool { + return a < b + }), + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListManagedGroupsResponse{}, "list_token"), + ), + ) + req.ListToken = got.ListToken + // Get the second page + got, err = s.ListManagedGroups(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 1) + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListManagedGroupsResponse{ + Items: []*pb.ManagedGroup{mgs[len(mgs)-1]}, + ResponseType: "complete", + ListToken: "", + SortBy: "created_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: 10, + }, + cmpopts.SortSlices(func(a, b string) bool { + return a < b + }), + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListManagedGroupsResponse{}, "list_token"), + ), + ) + }) + + t.Run("ldap", func(t *testing.T) { + o, _ := iam.TestScopes(t, iam.TestRepo(t, conn, wrap)) + databaseWrapper, err := kmsCache.GetWrapper(ctx, o.PublicId, kms.KeyPurposeDatabase) + require.NoError(t, err) + authMethod := ldap.TestAuthMethod(t, conn, databaseWrapper, o.PublicId, []string{"ldaps://some-mgs"}) + + testGroups := []string{"admin", "users"} + var mgs []*pb.ManagedGroup + for i := 0; i < 10; i++ { + mg := ldap.TestManagedGroup(t, conn, authMethod, testGroups, ldap.WithName(ctx, strconv.Itoa(i))) + mgs = append(mgs, &pb.ManagedGroup{ + Id: mg.GetPublicId(), + AuthMethodId: mg.GetAuthMethodId(), + Name: wrapperspb.String(strconv.Itoa(i)), + CreatedTime: mg.GetCreateTime().GetTimestamp(), + UpdatedTime: mg.GetUpdateTime().GetTimestamp(), + Scope: &scopepb.ScopeInfo{Id: o.GetPublicId(), Type: scope.Org.String(), ParentScopeId: scope.Global.String()}, + Version: 1, + Type: ldap.Subtype.String(), + Attrs: &pb.ManagedGroup_LdapManagedGroupAttributes{ + LdapManagedGroupAttributes: &pb.LdapManagedGroupAttributes{ + GroupNames: testGroups, + }, + }, + AuthorizedActions: ldapAuthorizedActions, + }) + } + + acct := ldap.TestAccount(t, conn, authMethod, "test-login-last") + u := iam.TestUser(t, iamRepo, o.GetPublicId(), iam.WithAccountIds(acct.PublicId)) + + privProjRole := iam.TestRole(t, conn, pwt.GetPublicId()) + iam.TestRoleGrant(t, conn, privProjRole.GetPublicId(), "id=*;type=*;actions=*") + iam.TestUserRole(t, conn, privProjRole.GetPublicId(), u.GetPublicId()) + privOrgRole := iam.TestRole(t, conn, o.GetPublicId()) + iam.TestRoleGrant(t, conn, privOrgRole.GetPublicId(), "id=*;type=*;actions=*") + iam.TestUserRole(t, conn, privOrgRole.GetPublicId(), u.GetPublicId()) + + // Since we sort by created_time descending, we reverse the slice + slices.Reverse(mgs) + + at, _ := tokenRepo.CreateAuthToken(ctx, u, acct.GetPublicId()) + + 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, kmsCache, &requestInfo) + + req := &pbs.ListManagedGroupsRequest{ + AuthMethodId: authMethod.GetPublicId(), + Filter: "", + ListToken: "", + PageSize: 2, + } + + // Run analyze in the DB to update the estimate tables + _, err = sqlDB.ExecContext(ctx, "analyze") + require.NoError(t, err) + + got, err := s.ListManagedGroups(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 2) + + // all comparisons will be done without list token + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListManagedGroupsResponse{ + Items: mgs[0:2], + ResponseType: "delta", + ListToken: "", + SortBy: "created_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: 10, + }, + cmpopts.SortSlices(func(a, b string) bool { + return a < b + }), + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListManagedGroupsResponse{}, "list_token"), + ), + ) + + // second page + req.ListToken = got.ListToken + got, err = s.ListManagedGroups(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 2) + + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListManagedGroupsResponse{ + Items: mgs[2:4], + ResponseType: "delta", + ListToken: "", + SortBy: "created_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: 10, + }, + cmpopts.SortSlices(func(a, b string) bool { + return a < b + }), + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListManagedGroupsResponse{}, "list_token"), + ), + ) + + // remainder of results + req.ListToken = got.ListToken + req.PageSize = 6 + got, err = s.ListManagedGroups(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 6) + + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListManagedGroupsResponse{ + Items: mgs[4:], + ResponseType: "complete", + ListToken: "", + SortBy: "created_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: 10, + }, + cmpopts.SortSlices(func(a, b string) bool { + return a < b + }), + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListManagedGroupsResponse{}, "list_token"), + ), + ) + + // create another mg + mg := ldap.TestManagedGroup(t, conn, authMethod, testGroups, ldap.WithName(ctx, "new-ldap-name")) + newMg := &pb.ManagedGroup{ + Id: mg.GetPublicId(), + AuthMethodId: mg.GetAuthMethodId(), + Name: wrapperspb.String("new-ldap-name"), + CreatedTime: mg.GetCreateTime().GetTimestamp(), + UpdatedTime: mg.GetUpdateTime().GetTimestamp(), + Scope: &scopepb.ScopeInfo{Id: o.GetPublicId(), Type: scope.Org.String(), ParentScopeId: scope.Global.String()}, + Version: 1, + Type: ldap.Subtype.String(), + Attrs: &pb.ManagedGroup_LdapManagedGroupAttributes{ + LdapManagedGroupAttributes: &pb.LdapManagedGroupAttributes{ + GroupNames: testGroups, + }, + }, + AuthorizedActions: ldapAuthorizedActions, + } + // Add to front since it's the latest updated + mgs = append([]*pb.ManagedGroup{newMg}, mgs...) + + // delete different mg + _, err = ldapRepo.DeleteManagedGroup(ctx, o.GetPublicId(), mgs[len(mgs)-1].Id) + require.NoError(t, err) + deletedMg := mgs[len(mgs)-1] + mgs = mgs[:len(mgs)-1] + + // update another mg + mgs[1].Name = wrapperspb.String("new-name") + mgs[1].Version = 2 + m := &ldap.ManagedGroup{ + ManagedGroup: &ldapstore.ManagedGroup{ + PublicId: mgs[1].Id, + AuthMethodId: mgs[1].AuthMethodId, + Name: mgs[1].Name.GetValue(), + }, + } + um, _, err := ldapRepo.UpdateManagedGroup(ctx, o.GetPublicId(), m, 1, []string{"name"}) + require.NoError(t, err) + mgs[1].UpdatedTime = um.GetUpdateTime().GetTimestamp() + mgs[1].Version = um.GetVersion() + // Add to the front since it's most recently updated + mgs = append( + []*pb.ManagedGroup{mgs[1]}, + append( + []*pb.ManagedGroup{mgs[0]}, + mgs[2:]..., + )..., + ) + + // 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 = s.ListManagedGroups(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 1) + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListManagedGroupsResponse{ + Items: []*pb.ManagedGroup{mgs[0]}, + ResponseType: "delta", + ListToken: "", + SortBy: "updated_time", + SortDir: "desc", + RemovedIds: []string{deletedMg.Id}, + EstItemCount: 10, + }, + cmpopts.SortSlices(func(a, b string) bool { + return a < b + }), + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListManagedGroupsResponse{}, "list_token"), + ), + ) + + // Get next page + req.ListToken = got.ListToken + got, err = s.ListManagedGroups(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 1) + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListManagedGroupsResponse{ + Items: []*pb.ManagedGroup{mgs[1]}, + ResponseType: "complete", + ListToken: "", + SortBy: "updated_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: 10, + }, + cmpopts.SortSlices(func(a, b string) bool { + return a < b + }), + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListManagedGroupsResponse{}, "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`, mgs[len(mgs)-2].Id, mgs[len(mgs)-1].Id) + got, err = s.ListManagedGroups(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 1) + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListManagedGroupsResponse{ + Items: []*pb.ManagedGroup{mgs[len(mgs)-2]}, + ResponseType: "delta", + ListToken: "", + SortBy: "created_time", + SortDir: "desc", + // Should be empty again + RemovedIds: nil, + EstItemCount: 10, + }, + cmpopts.SortSlices(func(a, b string) bool { + return a < b + }), + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListManagedGroupsResponse{}, "list_token"), + ), + ) + req.ListToken = got.ListToken + // Get the second page + got, err = s.ListManagedGroups(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 1) + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListManagedGroupsResponse{ + Items: []*pb.ManagedGroup{mgs[len(mgs)-1]}, + ResponseType: "complete", + ListToken: "", + SortBy: "created_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: 10, + }, + cmpopts.SortSlices(func(a, b string) bool { + return a < b + }), + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListManagedGroupsResponse{}, "list_token"), + ), + ) + }) +} + func TestDelete(t *testing.T) { ctx := context.Background() conn, _ := db.TestSetup(t, "postgres") @@ -624,7 +1331,7 @@ func TestDelete(t *testing.T) { ldapAm := ldap.TestAuthMethod(t, conn, databaseWrapper, o.PublicId, []string{"ldaps://ldap1"}) ldapMg := ldap.TestManagedGroup(t, conn, ldapAm, []string{"admin", "users"}) - s, err := managed_groups.NewService(ctx, oidcRepoFn, ldapRepoFn) + s, err := managed_groups.NewService(ctx, oidcRepoFn, ldapRepoFn, 1000) require.NoError(t, err, "Error when getting new user service.") cases := []struct { @@ -719,7 +1426,7 @@ func TestDelete_twice(t *testing.T) { ) oidcMg := oidc.TestManagedGroup(t, conn, oidcAm, oidc.TestFakeManagedGroupFilter) - s, err := managed_groups.NewService(ctx, oidcRepoFn, ldapRepoFn) + s, err := managed_groups.NewService(ctx, oidcRepoFn, ldapRepoFn, 1000) require.NoError(err, "Error when getting new user service") req := &pbs.DeleteManagedGroupRequest{ Id: oidcMg.GetPublicId(), @@ -747,7 +1454,7 @@ func TestCreateOidc(t *testing.T) { return ldap.NewRepository(ctx, rw, rw, kmsCache) } - s, err := managed_groups.NewService(ctx, oidcRepoFn, ldapRepoFn) + s, err := managed_groups.NewService(ctx, oidcRepoFn, ldapRepoFn, 1000) require.NoError(t, err, "Error when getting new managed group service.") o, _ := iam.TestScopes(t, iam.TestRepo(t, conn, wrap)) @@ -959,7 +1666,7 @@ func TestCreateLdap(t *testing.T) { return ldap.NewRepository(ctx, rw, rw, kmsCache) } - s, err := managed_groups.NewService(ctx, oidcRepoFn, ldapRepoFn) + s, err := managed_groups.NewService(ctx, oidcRepoFn, ldapRepoFn, 1000) require.NoError(t, err, "Error when getting new managed group service.") o, _ := iam.TestScopes(t, iam.TestRepo(t, conn, wrap)) @@ -1183,7 +1890,7 @@ func TestUpdateOidc(t *testing.T) { oidc.WithSigningAlgs(oidc.RS256), oidc.WithApiUrl(oidc.TestConvertToUrls(t, "https://www.alice.com/callback")[0])) - tested, err := managed_groups.NewService(ctx, oidcRepoFn, ldapRepoFn) + tested, err := managed_groups.NewService(ctx, oidcRepoFn, ldapRepoFn, 1000) require.NoError(t, err, "Error when getting new managed_groups service.") defaultScopeInfo := &scopepb.ScopeInfo{Id: o.GetPublicId(), Type: o.GetType(), ParentScopeId: scope.Global.String()} @@ -1573,7 +2280,7 @@ func TestUpdateLdap(t *testing.T) { require.NoError(t, err) am := ldap.TestAuthMethod(t, conn, databaseWrapper, o.PublicId, []string{"ldaps://ldap1"}) - tested, err := managed_groups.NewService(ctx, oidcRepoFn, ldapRepoFn) + tested, err := managed_groups.NewService(ctx, oidcRepoFn, ldapRepoFn, 1000) require.NoError(t, err, "Error when getting new managed_groups service.") testGroups := []string{"test", "admin"} diff --git a/internal/db/schema/migrations/oss/postgres/80/15_managed_group_table_updates.up.sql b/internal/db/schema/migrations/oss/postgres/80/15_managed_group_table_updates.up.sql new file mode 100644 index 000000000..2e58204c3 --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/80/15_managed_group_table_updates.up.sql @@ -0,0 +1,22 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + + -- Add new indexes for the create time and update time queries. + create index auth_ldap_managed_group_create_time_public_id_idx + on auth_ldap_managed_group (create_time desc, public_id desc); + create index auth_ldap_managed_group_update_time_public_id_idx + on auth_ldap_managed_group (update_time desc, public_id desc); + + analyze auth_ldap_managed_group; + + -- Add new indexes for the create time and update time queries. + create index auth_oidc_managed_group_create_time_public_id_idx + on auth_oidc_managed_group (create_time desc, public_id desc); + create index auth_oidc_managed_group_update_time_public_id_idx + on auth_oidc_managed_group (update_time desc, public_id desc); + + analyze auth_oidc_managed_group; + +commit; \ No newline at end of file diff --git a/internal/db/sqltest/tests/pagination/managed_group.sql b/internal/db/sqltest/tests/pagination/managed_group.sql new file mode 100644 index 000000000..5e0c1c275 --- /dev/null +++ b/internal/db/sqltest/tests/pagination/managed_group.sql @@ -0,0 +1,15 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + select plan(4); + + select has_index('auth_ldap_managed_group', 'auth_ldap_managed_group_create_time_public_id_idx', array['create_time', 'public_id']); + select has_index('auth_ldap_managed_group', 'auth_ldap_managed_group_update_time_public_id_idx', array['update_time', 'public_id']); + + select has_index('auth_oidc_managed_group', 'auth_oidc_managed_group_create_time_public_id_idx', array['create_time', 'public_id']); + select has_index('auth_oidc_managed_group', 'auth_oidc_managed_group_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 bd1f41446..c2cb27e26 100644 --- a/internal/gen/controller.swagger.json +++ b/internal/gen/controller.swagger.json @@ -2114,6 +2114,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": [ @@ -8135,6 +8150,34 @@ "type": "object", "$ref": "#/definitions/controller.api.resources.managedgroups.v1.ManagedGroup" } + }, + "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/managed_group_service.pb.go b/internal/gen/controller/api/services/managed_group_service.pb.go index f81ea5b22..b3ce696fb 100644 --- a/internal/gen/controller/api/services/managed_group_service.pb.go +++ b/internal/gen/controller/api/services/managed_group_service.pb.go @@ -129,6 +129,15 @@ type ListManagedGroupsRequest struct { AuthMethodId string `protobuf:"bytes,1,opt,name=auth_method_id,proto3" json:"auth_method_id,omitempty" class:"public" eventstream:"observation"` // @gotags: `class:"public" eventstream:"observation"` Filter string `protobuf:"bytes,30,opt,name=filter,proto3" json:"filter,omitempty" class:"public"` // @gotags: `class:"public"` + // 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 *ListManagedGroupsRequest) Reset() { @@ -177,12 +186,46 @@ func (x *ListManagedGroupsRequest) GetFilter() string { return "" } +func (x *ListManagedGroupsRequest) GetListToken() string { + if x != nil { + return x.ListToken + } + return "" +} + +func (x *ListManagedGroupsRequest) GetPageSize() uint32 { + if x != nil { + return x.PageSize + } + return 0 +} + type ListManagedGroupsResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields Items []*managedgroups.ManagedGroup `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 *ListManagedGroupsResponse) Reset() { @@ -224,6 +267,48 @@ func (x *ListManagedGroupsResponse) GetItems() []*managedgroups.ManagedGroup { return nil } +func (x *ListManagedGroupsResponse) GetResponseType() string { + if x != nil { + return x.ResponseType + } + return "" +} + +func (x *ListManagedGroupsResponse) GetListToken() string { + if x != nil { + return x.ListToken + } + return "" +} + +func (x *ListManagedGroupsResponse) GetSortBy() string { + if x != nil { + return x.SortBy + } + return "" +} + +func (x *ListManagedGroupsResponse) GetSortDir() string { + if x != nil { + return x.SortDir + } + return "" +} + +func (x *ListManagedGroupsResponse) GetRemovedIds() []string { + if x != nil { + return x.RemovedIds + } + return nil +} + +func (x *ListManagedGroupsResponse) GetEstItemCount() uint32 { + if x != nil { + return x.EstItemCount + } + return 0 +} + type CreateManagedGroupRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -552,129 +637,146 @@ var file_controller_api_services_v1_managed_group_service_proto_rawDesc = []byte 0x61, 0x70, 0x69, 0x2e, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x04, 0x69, 0x74, 0x65, - 0x6d, 0x22, 0x5a, 0x0a, 0x18, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, - 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, 0x0a, - 0x0e, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x5f, 0x69, 0x64, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x74, 0x68, - 0x6f, 0x64, 0x5f, 0x69, 0x64, 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, 0x6a, 0x0a, + 0x6d, 0x22, 0x98, 0x01, 0x0a, 0x18, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x26, + 0x0a, 0x0e, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x5f, 0x69, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x74, + 0x68, 0x6f, 0x64, 0x5f, 0x69, 0x64, 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, 0xb0, 0x02, 0x0a, 0x19, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4d, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x37, 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, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, - 0x75, 0x70, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x22, 0x68, 0x0a, 0x19, 0x43, 0x72, 0x65, + 0x75, 0x70, 0x52, 0x05, 0x69, 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, + 0x68, 0x0a, 0x19, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, + 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x4b, 0x0a, 0x04, + 0x69, 0x74, 0x65, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x37, 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, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x67, 0x72, 0x6f, + 0x75, 0x70, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, + 0x6f, 0x75, 0x70, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x7b, 0x0a, 0x1a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x4b, 0x0a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x02, - 0x20, 0x01, 0x28, 0x0b, 0x32, 0x37, 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, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x2e, 0x76, 0x31, - 0x2e, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x04, 0x69, - 0x74, 0x65, 0x6d, 0x22, 0x7b, 0x0a, 0x1a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 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, 0x4b, 0x0a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x37, 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, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x64, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, - 0x22, 0xb6, 0x01, 0x0a, 0x19, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, - 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x4b, - 0x0a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x37, 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, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x67, - 0x72, 0x6f, 0x75, 0x70, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, - 0x47, 0x72, 0x6f, 0x75, 0x70, 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, 0x69, 0x0a, 0x1a, 0x55, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x37, 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, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x2e, 0x76, - 0x31, 0x2e, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x04, - 0x69, 0x74, 0x65, 0x6d, 0x22, 0x2b, 0x0a, 0x19, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, - 0x64, 0x22, 0x1c, 0x0a, 0x1a, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, - 0xae, 0x08, 0x0a, 0x13, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, - 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0xc1, 0x01, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x4d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 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, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 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, 0x47, 0x65, 0x74, - 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x45, 0x92, 0x41, 0x1d, 0x12, 0x1b, 0x47, 0x65, 0x74, 0x73, 0x20, - 0x61, 0x20, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x20, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, - 0x47, 0x72, 0x6f, 0x75, 0x70, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1f, 0x62, 0x04, 0x69, 0x74, - 0x65, 0x6d, 0x12, 0x17, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x2d, - 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x7d, 0x12, 0xd3, 0x01, 0x0a, 0x11, - 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, - 0x73, 0x12, 0x34, 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, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x35, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, + 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, 0x4b, 0x0a, 0x04, 0x69, 0x74, 0x65, + 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x37, 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, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, + 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, + 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0xb6, 0x01, 0x0a, 0x19, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x02, 0x69, 0x64, 0x12, 0x4b, 0x0a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x37, 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, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x64, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 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, + 0x69, 0x0a, 0x1a, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, + 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, + 0x04, 0x69, 0x74, 0x65, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x37, 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, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x67, 0x72, + 0x6f, 0x75, 0x70, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, + 0x72, 0x6f, 0x75, 0x70, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x2b, 0x0a, 0x19, 0x44, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x1c, 0x0a, 0x1a, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xae, 0x08, 0x0a, 0x13, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0xc1, 0x01, + 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, + 0x70, 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, 0x47, + 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 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, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, + 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x45, 0x92, 0x41, 0x1d, 0x12, + 0x1b, 0x47, 0x65, 0x74, 0x73, 0x20, 0x61, 0x20, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x20, 0x4d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x2e, 0x82, 0xd3, 0xe4, 0x93, + 0x02, 0x1f, 0x62, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x12, 0x17, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x64, 0x2d, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x2f, 0x7b, 0x69, 0x64, + 0x7d, 0x12, 0xd3, 0x01, 0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x34, 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, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, - 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x51, - 0x92, 0x41, 0x34, 0x12, 0x32, 0x4c, 0x69, 0x73, 0x74, 0x73, 0x20, 0x61, 0x6c, 0x6c, 0x20, 0x4d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x20, 0x69, 0x6e, 0x20, - 0x61, 0x20, 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x63, 0x20, 0x41, 0x75, 0x74, 0x68, 0x20, - 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x14, 0x12, 0x12, 0x2f, - 0x76, 0x31, 0x2f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x2d, 0x67, 0x72, 0x6f, 0x75, 0x70, - 0x73, 0x12, 0xea, 0x01, 0x0a, 0x12, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 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, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 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, 0x43, 0x72, 0x65, - 0x61, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x65, 0x92, 0x41, 0x3c, 0x12, 0x3a, 0x43, 0x72, - 0x65, 0x61, 0x74, 0x65, 0x73, 0x20, 0x61, 0x20, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x20, 0x4d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x20, 0x69, 0x6e, 0x20, 0x74, - 0x68, 0x65, 0x20, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x64, 0x20, 0x41, 0x75, 0x74, 0x68, - 0x20, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x20, 0x3a, 0x04, - 0x69, 0x74, 0x65, 0x6d, 0x62, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x12, 0x2f, 0x76, 0x31, 0x2f, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x2d, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0xcc, - 0x01, 0x0a, 0x12, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, - 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x35, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, + 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 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, 0x4c, 0x69, 0x73, 0x74, 0x4d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x51, 0x92, 0x41, 0x34, 0x12, 0x32, 0x4c, 0x69, 0x73, 0x74, 0x73, + 0x20, 0x61, 0x6c, 0x6c, 0x20, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, + 0x70, 0x73, 0x20, 0x69, 0x6e, 0x20, 0x61, 0x20, 0x73, 0x70, 0x65, 0x63, 0x69, 0x66, 0x69, 0x63, + 0x20, 0x41, 0x75, 0x74, 0x68, 0x20, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x2e, 0x82, 0xd3, 0xe4, + 0x93, 0x02, 0x14, 0x12, 0x12, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, + 0x2d, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0xea, 0x01, 0x0a, 0x12, 0x43, 0x72, 0x65, 0x61, + 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 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, 0x43, 0x72, 0x65, 0x61, + 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 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, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, - 0x47, 0x72, 0x6f, 0x75, 0x70, 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, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x47, 0x92, 0x41, 0x19, 0x12, 0x17, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x73, 0x20, 0x61, 0x20, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, - 0x70, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x25, 0x3a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x62, 0x04, - 0x69, 0x74, 0x65, 0x6d, 0x32, 0x17, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x64, 0x2d, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x7d, 0x12, 0xc0, 0x01, - 0x0a, 0x12, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, - 0x72, 0x6f, 0x75, 0x70, 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, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, - 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x36, 0x2e, 0x63, 0x6f, + 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, + 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x65, 0x92, + 0x41, 0x3c, 0x12, 0x3a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x73, 0x20, 0x61, 0x20, 0x73, 0x69, + 0x6e, 0x67, 0x6c, 0x65, 0x20, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, + 0x70, 0x20, 0x69, 0x6e, 0x20, 0x74, 0x68, 0x65, 0x20, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, + 0x64, 0x20, 0x41, 0x75, 0x74, 0x68, 0x20, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x2e, 0x82, 0xd3, + 0xe4, 0x93, 0x02, 0x20, 0x3a, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x62, 0x04, 0x69, 0x74, 0x65, 0x6d, + 0x22, 0x12, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x2d, 0x67, 0x72, + 0x6f, 0x75, 0x70, 0x73, 0x12, 0xcc, 0x01, 0x0a, 0x12, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 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, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x3b, 0x92, 0x41, 0x19, 0x12, 0x17, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, - 0x73, 0x20, 0x61, 0x20, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, - 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x19, 0x2a, 0x17, 0x2f, 0x76, 0x31, 0x2f, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x64, 0x2d, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x7d, - 0x42, 0x55, 0xa2, 0xe3, 0x29, 0x04, 0x61, 0x75, 0x74, 0x68, 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, + 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 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, + 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, + 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x47, 0x92, 0x41, 0x19, 0x12, + 0x17, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x73, 0x20, 0x61, 0x20, 0x4d, 0x61, 0x6e, 0x61, 0x67, + 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x25, 0x3a, 0x04, + 0x69, 0x74, 0x65, 0x6d, 0x62, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x32, 0x17, 0x2f, 0x76, 0x31, 0x2f, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x2d, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x2f, 0x7b, + 0x69, 0x64, 0x7d, 0x12, 0xc0, 0x01, 0x0a, 0x12, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 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, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 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, 0x44, + 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, + 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x3b, 0x92, 0x41, 0x19, 0x12, 0x17, + 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x20, 0x61, 0x20, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x19, 0x2a, 0x17, 0x2f, + 0x76, 0x31, 0x2f, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x64, 0x2d, 0x67, 0x72, 0x6f, 0x75, 0x70, + 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x7d, 0x42, 0x55, 0xa2, 0xe3, 0x29, 0x04, 0x61, 0x75, 0x74, 0x68, + 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/proto/controller/api/services/v1/managed_group_service.proto b/internal/proto/controller/api/services/v1/managed_group_service.proto index 8c8d20be9..5881a8fe7 100644 --- a/internal/proto/controller/api/services/v1/managed_group_service.proto +++ b/internal/proto/controller/api/services/v1/managed_group_service.proto @@ -86,10 +86,39 @@ message GetManagedGroupResponse { message ListManagedGroupsRequest { string auth_method_id = 1 [json_name = "auth_method_id"]; // @gotags: `class:"public" eventstream:"observation"` string filter = 30 [json_name = "filter"]; // @gotags: `class:"public"` + // 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 ListManagedGroupsResponse { repeated resources.managedgroups.v1.ManagedGroup 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 CreateManagedGroupRequest {