From aa931e064297b716c98e6a590cbe165cd24ccf9a Mon Sep 17 00:00:00 2001 From: Michael Milton Date: Thu, 7 Dec 2023 17:01:15 -0500 Subject: [PATCH] Auth token pagination (#4113) * Add pagination support for auth tokens * change analyze count estimate comment * adjust queries for efficiency and security * even more efficient queries --- internal/authtoken/authtoken.go | 47 +- internal/authtoken/options.go | 10 + internal/authtoken/query.go | 182 ++++++ internal/authtoken/repository.go | 171 ++++- internal/authtoken/repository_test.go | 223 ++++++- internal/authtoken/service_list.go | 53 ++ internal/authtoken/service_list_ext_test.go | 587 ++++++++++++++++++ internal/authtoken/service_list_page.go | 67 ++ internal/authtoken/service_list_refresh.go | 73 +++ .../authtoken/service_list_refresh_page.go | 80 +++ internal/daemon/controller/handler.go | 2 +- .../handlers/authtokens/authtoken_service.go | 192 ++++-- .../authtokens/authtoken_service_test.go | 392 +++++++++++- .../04_auth_token_base_table_updates.up.sql | 14 + .../sqltest/tests/pagination/auth_token.sql | 12 + internal/gen/controller.swagger.json | 43 ++ .../api/services/authtokens_service.pb.go | 205 ++++-- .../api/services/v1/authtokens_service.proto | 29 + 18 files changed, 2214 insertions(+), 168 deletions(-) create mode 100644 internal/authtoken/query.go create mode 100644 internal/authtoken/service_list.go create mode 100644 internal/authtoken/service_list_ext_test.go create mode 100644 internal/authtoken/service_list_page.go create mode 100644 internal/authtoken/service_list_refresh.go create mode 100644 internal/authtoken/service_list_refresh_page.go create mode 100644 internal/db/schema/migrations/oss/postgres/79/04_auth_token_base_table_updates.up.sql create mode 100644 internal/db/sqltest/tests/pagination/auth_token.sql diff --git a/internal/authtoken/authtoken.go b/internal/authtoken/authtoken.go index af85e80256..fc9fa7cd3d 100644 --- a/internal/authtoken/authtoken.go +++ b/internal/authtoken/authtoken.go @@ -12,9 +12,11 @@ import ( "github.com/hashicorp/boundary/globals" "github.com/hashicorp/boundary/internal/authtoken/store" "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/db/timestamp" "github.com/hashicorp/boundary/internal/errors" "github.com/hashicorp/boundary/internal/gen/controller/tokens" "github.com/hashicorp/boundary/internal/kms" + "github.com/hashicorp/boundary/internal/types/resource" wrapping "github.com/hashicorp/go-kms-wrapping/v2" "github.com/hashicorp/go-kms-wrapping/v2/extras/structwrapping" "github.com/hashicorp/go-secure-stdlib/base62" @@ -56,8 +58,8 @@ type AuthToken struct { tableName string `gorm:"-"` } -func (s *AuthToken) clone() *AuthToken { - cp := proto.Clone(s.AuthToken) +func (at *AuthToken) clone() *AuthToken { + cp := proto.Clone(at.AuthToken) return &AuthToken{ AuthToken: cp.(*store.AuthToken), } @@ -167,3 +169,44 @@ func EncryptToken(ctx context.Context, kmsCache *kms.Kms, scopeId, publicId, tok return globals.ServiceTokenV1 + encoded, nil } + +// GetResourceType returns the resource type of the AuthToken +func (at AuthToken) GetResourceType() resource.Type { + return resource.AuthToken +} + +func (at AuthToken) GetUpdateTime() *timestamp.Timestamp { + return at.UpdateTime +} + +func (at AuthToken) GetCreateTime() *timestamp.Timestamp { + return at.CreateTime +} + +// GetDescription returns an empty string so that +// AuthToken will satisfy resource requirements +func (at AuthToken) GetDescription() string { + return "" +} + +// GetName returns an empty string so that +// AuthToken will satisfy resource requirements +func (at AuthToken) GetName() string { + return "" +} + +// GetVersion returns 0 so that +// AuthToken will satisfy resource requirements +func (at AuthToken) GetVersion() uint32 { + return 0 +} + +type deletedAuthToken struct { + PublicId string `gorm:"primary_key"` + DeleteTime *timestamp.Timestamp +} + +// TableName returns the tablename to override the default gorm table name +func (s *deletedAuthToken) TableName() string { + return "auth_token_deleted" +} diff --git a/internal/authtoken/options.go b/internal/authtoken/options.go index 2eb3a4ac25..123001b3f7 100644 --- a/internal/authtoken/options.go +++ b/internal/authtoken/options.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/boundary/internal/auth/password" "github.com/hashicorp/boundary/internal/db" "github.com/hashicorp/boundary/internal/iam" + "github.com/hashicorp/boundary/internal/pagination" ) var ( @@ -38,6 +39,7 @@ type options struct { withPublicId string withPasswordOptions []password.Option withIamOptions []iam.Option + withStartPageAfterItem pagination.Item } func getDefaultOptions() options { @@ -116,3 +118,11 @@ func WithIamOptions(with ...iam.Option) Option { o.withIamOptions = with } } + +// WithStartPageAfterItem is used to paginate over the results. +// The next page will start after the provided item. +func WithStartPageAfterItem(item pagination.Item) Option { + return func(o *options) { + o.withStartPageAfterItem = item + } +} diff --git a/internal/authtoken/query.go b/internal/authtoken/query.go new file mode 100644 index 0000000000..1b416b893c --- /dev/null +++ b/internal/authtoken/query.go @@ -0,0 +1,182 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package authtoken + +const ( + estimateCountAuthTokens = ` +select reltuples::bigint as estimate from pg_class where oid in ('auth_token'::regclass) +` + listAuthTokensTemplate = ` +with auth_tokens as ( + select public_id, + auth_account_id, + create_time, + update_time, + approximate_last_access_time, + expiration_time, + status + from auth_token + order by create_time desc, public_id asc + limit %d +), +auth_accounts as ( + select public_id, + auth_method_id, + scope_id, + iam_user_id, + iam_user_scope_id + from auth_account + where %s + and public_id in (select auth_account_id from auth_tokens) +), +final as ( + select at.public_id, + at.auth_account_id, + aa.auth_method_id, + aa.scope_id, + aa.iam_user_id, + aa.iam_user_scope_id, + at.create_time, + at.update_time, + at.approximate_last_access_time, + at.expiration_time, + at.status + from auth_tokens at + join auth_accounts aa on aa.public_id = at.auth_account_id +) + select * + from final +order by create_time desc, public_id asc; +` + listAuthTokensPageTemplate = ` +with auth_tokens as ( + select public_id, + auth_account_id, + create_time, + update_time, + approximate_last_access_time, + expiration_time, + status + from auth_token + where (create_time, public_id) < (@last_item_create_time, @last_item_id) + order by create_time desc, public_id asc + limit %d +), +auth_accounts as ( + select public_id, + auth_method_id, + scope_id, + iam_user_id, + iam_user_scope_id + from auth_account + where %s + and public_id in (select auth_account_id from auth_tokens) +), +final as ( + select at.public_id, + at.auth_account_id, + aa.auth_method_id, + aa.scope_id, + aa.iam_user_id, + aa.iam_user_scope_id, + at.create_time, + at.update_time, + at.approximate_last_access_time, + at.expiration_time, + at.status + from auth_tokens at + join auth_accounts aa on aa.public_id = at.auth_account_id +) + select * + from final +order by create_time desc, public_id asc; +` + refreshAuthTokensTemplate = ` +with auth_tokens as ( + select public_id, + auth_account_id, + create_time, + update_time, + approximate_last_access_time, + expiration_time, + status + from auth_token + where update_time > @updated_after_time + order by update_time desc, public_id asc + limit %d +), +auth_accounts as ( + select public_id, + auth_method_id, + scope_id, + iam_user_id, + iam_user_scope_id + from auth_account + where %s + and public_id in (select auth_account_id from auth_tokens) +), +final as ( + select at.public_id, + at.auth_account_id, + aa.auth_method_id, + aa.scope_id, + aa.iam_user_id, + aa.iam_user_scope_id, + at.create_time, + at.update_time, + at.approximate_last_access_time, + at.expiration_time, + at.status + from auth_tokens at + join auth_accounts aa on aa.public_id = at.auth_account_id +) + select * + from final +order by update_time desc, public_id asc; +` + refreshAuthTokensPageTemplate = ` +with auth_tokens as ( + select public_id, + auth_account_id, + create_time, + update_time, + approximate_last_access_time, + expiration_time, + status + from auth_token + where update_time > @updated_after_time + and (update_time, public_id) < (@last_item_update_time, @last_item_id) + order by update_time desc, public_id asc + limit %d +), +auth_accounts as ( + select public_id, + auth_method_id, + scope_id, + iam_user_id, + iam_user_scope_id + from auth_account + where %s + and public_id in (select auth_account_id from auth_tokens) +), +final as ( + select at.public_id, + at.auth_account_id, + aa.auth_method_id, + aa.scope_id, + aa.iam_user_id, + aa.iam_user_scope_id, + at.create_time, + at.update_time, + at.approximate_last_access_time, + at.expiration_time, + at.status + from auth_tokens at + join auth_accounts aa on aa.public_id = at.auth_account_id +) + select * + from final +order by update_time desc, public_id asc; +` +) diff --git a/internal/authtoken/repository.go b/internal/authtoken/repository.go index 0728df0867..cbeeb163ca 100644 --- a/internal/authtoken/repository.go +++ b/internal/authtoken/repository.go @@ -5,7 +5,10 @@ package authtoken import ( "context" + "database/sql" "fmt" + "strconv" + "strings" "time" "github.com/golang/protobuf/ptypes" @@ -290,29 +293,163 @@ func (r *Repository) ValidateToken(ctx context.Context, id, token string, opt .. return retAT, nil } -// ListAuthTokens lists auth tokens in the given scopes and supports the -// WithLimit option. -func (r *Repository) ListAuthTokens(ctx context.Context, withScopeIds []string, opt ...Option) ([]*AuthToken, error) { - const op = "authtoken.(Repository).ListAuthTokens" +// listAuthTokens lists auth tokens in the given scopes and supports the +// WithLimit and WithStartPageAfterItem options. +func (r *Repository) listAuthTokens(ctx context.Context, withScopeIds []string, opt ...Option) ([]*AuthToken, time.Time, error) { + const op = "authtoken.(Repository).listAuthTokens" if len(withScopeIds) == 0 { - return nil, errors.New(ctx, errors.InvalidParameter, op, "missing scope id") + return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "missing scope id") } opts := getOpts(opt...) - // use the view, to bring in the required account columns. Just don't forget - // to convert them before returning them - var atvs []*authTokenView - if err := r.reader.SearchWhere(ctx, &atvs, "auth_account_id in (select public_id from auth_account where scope_id in (?))", []any{withScopeIds}, db.WithLimit(opts.withLimit)); err != nil { - return nil, errors.Wrap(ctx, err, op) + limit := r.limit + if opts.withLimit != 0 { + // non-zero signals an override of the default limit for the repo. + limit = opts.withLimit + } + + var args []any + var inClauses []string + for i, scopeId := range withScopeIds { + arg := "scope_id_" + strconv.Itoa(i) + inClauses = append(inClauses, "@"+arg) + args = append(args, sql.Named(arg, scopeId)) + } + inClause := strings.Join(inClauses, ", ") + whereClause := "scope_id in (" + inClause + ")" + + query := fmt.Sprintf(listAuthTokensTemplate, limit, whereClause) + if opts.withStartPageAfterItem != nil { + query = fmt.Sprintf(listAuthTokensPageTemplate, limit, whereClause) + args = append(args, + sql.Named("last_item_create_time", opts.withStartPageAfterItem.GetCreateTime()), + sql.Named("last_item_id", opts.withStartPageAfterItem.GetPublicId()), + ) + } + + return r.queryAuthTokens(ctx, query, args, limit) +} + +// listAuthTokensRefresh lists auth tokens in the given scopes and supports the +// WithLimit and WithStartPageAfterItem options. +func (r *Repository) listAuthTokensRefresh(ctx context.Context, updatedAfter time.Time, withScopeIds []string, opt ...Option) ([]*AuthToken, time.Time, error) { + const op = "authtoken.(Repository).listAuthTokensRefresh" + + switch { + case updatedAfter.IsZero(): + return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "missing updated after time") + + case len(withScopeIds) == 0: + return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "missing scope id") + } + + opts := getOpts(opt...) + + limit := r.limit + if opts.withLimit != 0 { + // non-zero signals an override of the default limit for the repo. + limit = opts.withLimit + } + + var args []any + var inClauses []string + for i, scopeId := range withScopeIds { + arg := "scope_id_" + strconv.Itoa(i) + inClauses = append(inClauses, "@"+arg) + args = append(args, sql.Named(arg, scopeId)) + } + inClause := strings.Join(inClauses, ", ") + whereClause := "scope_id in (" + inClause + ")" + + query := fmt.Sprintf(refreshAuthTokensTemplate, limit, whereClause) + args = append(args, + sql.Named("updated_after_time", timestamp.New(updatedAfter)), + ) + if opts.withStartPageAfterItem != nil { + query = fmt.Sprintf(refreshAuthTokensPageTemplate, limit, whereClause) + args = append(args, + sql.Named("last_item_update_time", opts.withStartPageAfterItem.GetUpdateTime()), + sql.Named("last_item_id", opts.withStartPageAfterItem.GetPublicId()), + ) } - authTokens := make([]*AuthToken, 0, len(atvs)) - for _, atv := range atvs { - atv.Token = "" - atv.CtToken = nil - atv.KeyId = "" - authTokens = append(authTokens, atv.toAuthToken()) + + return r.queryAuthTokens(ctx, query, args, limit) +} + +func (r *Repository) queryAuthTokens(ctx context.Context, query string, args []any, limit int) ([]*AuthToken, time.Time, error) { + const op = "authtoken.(Repository).queryAuthTokens" + + var transactionTimestamp time.Time + var authTokens []*AuthToken + if _, err := r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(rd db.Reader, w db.Writer) error { + rows, err := rd.Query(ctx, query, args) + if err != nil { + return err + } + defer rows.Close() + + // use the view, to bring in the required account columns. Just don't forget + // to convert them before returning them + var atvs []*authTokenView + for rows.Next() { + var atv authTokenView + if err := rd.ScanRows(ctx, rows, &atv); err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("scan row failed")) + } + atvs = append(atvs, &atv) + } + + authTokens = make([]*AuthToken, 0, len(atvs)) + for _, atv := range atvs { + authTokens = append(authTokens, atv.toAuthToken()) + } + transactionTimestamp, err = rd.Now(ctx) + return err + }); err != nil { + return nil, time.Time{}, err + } + return authTokens, transactionTimestamp, nil +} + +// listDeletedIds lists the public IDs of any auth tokens deleted since the timestamp provided. +func (r *Repository) listDeletedIds(ctx context.Context, since time.Time) ([]string, time.Time, error) { + const op = "authtoken.(Repository).listDeletedIds" + var deletedAuthTokens []*deletedAuthToken + 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, &deletedAuthTokens, "delete_time >= ?", []any{since}); err != nil { + return errors.Wrap(ctx, err, op, errors.WithMsg("failed to query deleted auth tokens")) + } + 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 atIds []string + for _, at := range deletedAuthTokens { + atIds = append(atIds, at.PublicId) + } + return atIds, transactionTimestamp, nil +} + +// estimatedCount returns an estimate of the total number of items in the auth tokens table. +func (r *Repository) estimatedCount(ctx context.Context) (int, error) { + const op = "authtoken.(Repository).estimatedCount" + rows, err := r.reader.Query(ctx, estimateCountAuthTokens, nil) + if err != nil { + return 0, errors.Wrap(ctx, err, op, errors.WithMsg("failed to query total auth tokens")) + } + var count int + for rows.Next() { + if err := r.reader.ScanRows(ctx, rows, &count); err != nil { + return 0, errors.Wrap(ctx, err, op, errors.WithMsg("failed to query total auth tokens")) + } } - return authTokens, nil + return count, nil } // DeleteAuthToken deletes the token with the provided id from the repository returning a count of the diff --git a/internal/authtoken/repository_test.go b/internal/authtoken/repository_test.go index 4c80d09295..b74b5e2c5f 100644 --- a/internal/authtoken/repository_test.go +++ b/internal/authtoken/repository_test.go @@ -657,24 +657,28 @@ func TestRepository_ListAuthTokens(t *testing.T) { emptyOrg, _ := iam.TestScopes(t, repo) tests := []struct { - name string - orgId string - want []*AuthToken + name string + orgId string + want []*AuthToken + wantTTime time.Time }{ { - name: "populated", - orgId: org.GetPublicId(), - want: []*AuthToken{at1, at2, at3}, + name: "populated", + orgId: org.GetPublicId(), + want: []*AuthToken{at1, at2, at3}, + wantTTime: time.Now(), }, { - name: "empty", - orgId: emptyOrg.GetPublicId(), - want: []*AuthToken{}, + name: "empty", + orgId: emptyOrg.GetPublicId(), + want: []*AuthToken{}, + wantTTime: time.Now(), }, { - name: "empty-org-id", - orgId: "", - want: []*AuthToken{}, + name: "empty-org-id", + orgId: "", + want: []*AuthToken{}, + wantTTime: time.Now(), }, } @@ -685,13 +689,109 @@ func TestRepository_ListAuthTokens(t *testing.T) { repo, err := NewRepository(ctx, rw, rw, kms) require.NoError(err) require.NotNil(repo) - got, err := repo.ListAuthTokens(ctx, []string{tt.orgId}) + got, ttime, err := repo.listAuthTokens(ctx, []string{tt.orgId}) assert.NoError(err) sort.Slice(tt.want, func(i, j int) bool { return tt.want[i].PublicId < tt.want[j].PublicId }) sort.Slice(got, func(i, j int) bool { return got[i].PublicId < got[j].PublicId }) assert.Empty(cmp.Diff(tt.want, got, protocmp.Transform()), "row count") + // Transaction timestamp should be within ~10 seconds of now + assert.True(tt.wantTTime.Before(ttime.Add(10 * time.Second))) + assert.True(tt.wantTTime.After(ttime.Add(-10 * time.Second))) }) } + t.Run("withStartPageAfter", func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + + for i := 0; i < 7; i++ { + at := TestAuthToken(t, conn, kms, org.GetPublicId()) + at.Token = "" + at.KeyId = "" + } + + repo, err := NewRepository(ctx, rw, rw, kms) + require.NoError(err) + page1, ttime, err := repo.listAuthTokens(ctx, []string{org.GetPublicId()}, WithLimit(2)) + require.NoError(err) + require.Len(page1, 2) + // Transaction timestamp should be within ~10 seconds of now + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + page2, ttime, err := repo.listAuthTokens(ctx, []string{org.GetPublicId()}, WithLimit(2), WithStartPageAfterItem(page1[1])) + require.NoError(err) + require.Len(page2, 2) + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + for _, item := range page1 { + assert.NotEqual(item.GetPublicId(), page2[0].GetPublicId()) + assert.NotEqual(item.GetPublicId(), page2[1].GetPublicId()) + } + page3, ttime, err := repo.listAuthTokens(ctx, []string{org.GetPublicId()}, WithLimit(2), WithStartPageAfterItem(page2[1])) + require.NoError(err) + require.Len(page3, 2) + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + for _, item := range page2 { + assert.NotEqual(item.GetPublicId(), page3[0].GetPublicId()) + assert.NotEqual(item.GetPublicId(), page3[1].GetPublicId()) + } + page4, ttime, err := repo.listAuthTokens(ctx, []string{org.GetPublicId()}, WithLimit(2), WithStartPageAfterItem(page3[1])) + require.NoError(err) + require.Len(page4, 2) + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + for _, item := range page3 { + assert.NotEqual(item.GetPublicId(), page4[0].GetPublicId()) + assert.NotEqual(item.GetPublicId(), page4[1].GetPublicId()) + } + page5, ttime, err := repo.listAuthTokens(ctx, []string{org.GetPublicId()}, WithLimit(2), WithStartPageAfterItem(page4[1])) + require.NoError(err) + require.Len(page5, 2) + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + for _, item := range page4 { + assert.NotEqual(item.GetPublicId(), page5[0].GetPublicId()) + assert.NotEqual(item.GetPublicId(), page5[1].GetPublicId()) + } + page6, ttime, err := repo.listAuthTokens(ctx, []string{org.GetPublicId()}, WithLimit(2), WithStartPageAfterItem(page5[1])) + require.NoError(err) + require.Empty(page6) + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + + // Create 2 new auth tokens + newAt1 := TestAuthToken(t, conn, kms, org.GetPublicId()) + newAt1.Token = "" + newAt1.KeyId = "" + newAt2 := TestAuthToken(t, conn, kms, org.GetPublicId()) + newAt2.Token = "" + newAt2.KeyId = "" + + // since it will return newest to oldest, we get page1[1] first + page7, ttime, err := repo.listAuthTokensRefresh( + ctx, + time.Now().Add(-1*time.Second), + []string{org.GetPublicId()}, + WithLimit(1), + ) + require.NoError(err) + require.Len(page7, 1) + require.Equal(page7[0].GetPublicId(), newAt2.GetPublicId()) + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + + page8, ttime, err := repo.listAuthTokensRefresh( + context.Background(), + time.Now().Add(-1*time.Second), + []string{org.GetPublicId()}, + WithLimit(1), + WithStartPageAfterItem(page7[0]), + ) + require.NoError(err) + require.Len(page8, 1) + require.Equal(page8[0].GetPublicId(), newAt1.GetPublicId()) + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + }) } func TestRepository_ListAuthTokens_Multiple_Scopes(t *testing.T) { @@ -715,9 +815,11 @@ func TestRepository_ListAuthTokens_Multiple_Scopes(t *testing.T) { total++ } - got, err := repo.ListAuthTokens(ctx, []string{"global", org.GetPublicId(), proj.GetPublicId()}) + got, ttime, err := repo.listAuthTokens(ctx, []string{"global", org.GetPublicId(), proj.GetPublicId()}) require.NoError(t, err) - assert.Equal(t, total, len(got)) + assert.Equal(t, total, len(got)) // Transaction timestamp should be within ~10 seconds of now + assert.True(t, time.Now().Before(ttime.Add(10*time.Second))) + assert.True(t, time.Now().After(ttime.Add(-10*time.Second))) } func Test_IssuePendingToken(t *testing.T) { @@ -880,3 +982,94 @@ func Test_CloseExpiredPendingTokens(t *testing.T) { }) } } + +func Test_listDeletedIds(t *testing.T) { + t.Parallel() + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + kms := kms.TestKms(t, conn, wrapper) + iamRepo := iam.TestRepo(t, conn, wrapper) + org, _ := iam.TestScopes(t, iamRepo) + at := TestAuthToken(t, conn, kms, org.GetPublicId()) + at.Token = "" + at.KeyId = "" + repo, err := NewRepository(ctx, rw, rw, kms) + require.NoError(t, err) + + // Expect no entries at the start + deletedIds, ttime, err := repo.listDeletedIds(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 an auth token + _, err = repo.DeleteAuthToken(ctx, at.GetPublicId()) + require.NoError(t, err) + + // Expect a single entry + deletedIds, ttime, err = repo.listDeletedIds(ctx, time.Now().AddDate(-1, 0, 0)) + require.NoError(t, err) + require.Equal(t, []string{at.GetPublicId()}, deletedIds) + // Transaction timestamp should be within ~10 seconds of now + assert.True(t, time.Now().Before(ttime.Add(10*time.Second))) + assert.True(t, time.Now().After(ttime.Add(-10*time.Second))) + + // Try again with the time set to now, expect no entries + deletedIds, ttime, err = repo.listDeletedIds(ctx, time.Now()) + require.NoError(t, err) + require.Empty(t, deletedIds) + // Transaction timestamp should be within ~10 seconds of now + assert.True(t, time.Now().Before(ttime.Add(10*time.Second))) + assert.True(t, time.Now().After(ttime.Add(-10*time.Second))) +} + +func Test_estimatedCount(t *testing.T) { + t.Parallel() + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + sqlDb, err := conn.SqlDB(ctx) + require.NoError(t, err) + rw := db.New(conn) + wrapper := db.TestWrapper(t) + kms := kms.TestKms(t, conn, wrapper) + repo, err := NewRepository(ctx, rw, rw, kms) + require.NoError(t, err) + + // Run analyze to update estimate + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + + // Check total entries at start, expect 0 + numItems, err := repo.estimatedCount(ctx) + require.NoError(t, err) + assert.Equal(t, 0, numItems) + + iamRepo := iam.TestRepo(t, conn, wrapper) + org, _ := iam.TestScopes(t, iamRepo) + // Create an auth token, expect 1 entry + at := TestAuthToken(t, conn, kms, org.GetPublicId()) + at.Token = "" + at.KeyId = "" + + // Run analyze to update estimate + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + numItems, err = repo.estimatedCount(ctx) + require.NoError(t, err) + assert.Equal(t, 1, numItems) + + // Delete the auth token, expect 0 again + _, err = repo.DeleteAuthToken(ctx, at.GetPublicId()) + require.NoError(t, err) + + // Run analyze to update estimate + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + numItems, err = repo.estimatedCount(ctx) + require.NoError(t, err) + assert.Equal(t, 0, numItems) +} diff --git a/internal/authtoken/service_list.go b/internal/authtoken/service_list.go new file mode 100644 index 0000000000..10e8c2911f --- /dev/null +++ b/internal/authtoken/service_list.go @@ -0,0 +1,53 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package authtoken + +import ( + "context" + "time" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/pagination" +) + +// List lists up to page size auth tokens, filtering out entries that +// do not pass the filter item function. It will automatically request +// more auth tokens from the database, at page size chunks, to fill the page. +// It returns a new list token used to continue pagination or refresh items. +// Auth tokens are ordered by create time descending (most recently created first). +func List( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[*AuthToken], + repo *Repository, + withScopeIds []string, +) (*pagination.ListResponse[*AuthToken], error) { + const op = "authtoken.List" + + switch { + case len(grantsHash) == 0: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing grants hash") + case pageSize < 1: + return nil, errors.New(ctx, errors.InvalidParameter, op, "page size must be at least 1") + case filterItemFn == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing filter item callback") + case repo == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing repo") + case withScopeIds == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing scope ids") + } + + listItemsFn := func(ctx context.Context, lastPageItem *AuthToken, limit int) ([]*AuthToken, time.Time, error) { + opts := []Option{ + WithLimit(limit), + } + if lastPageItem != nil { + opts = append(opts, WithStartPageAfterItem(lastPageItem)) + } + return repo.listAuthTokens(ctx, withScopeIds, opts...) + } + + return pagination.List(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedCount) +} diff --git a/internal/authtoken/service_list_ext_test.go b/internal/authtoken/service_list_ext_test.go new file mode 100644 index 0000000000..710b5fddc5 --- /dev/null +++ b/internal/authtoken/service_list_ext_test.go @@ -0,0 +1,587 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package authtoken_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/authtoken" + "github.com/hashicorp/boundary/internal/authtoken/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" +) + +func TestService_List(t *testing.T) { + fiveDaysAgo := time.Now() + // Set database read timeout to avoid duplicates in response + oldReadTimeout := globals.RefreshReadLookbackDuration + globals.RefreshReadLookbackDuration = 0 + t.Cleanup(func() { + globals.RefreshReadLookbackDuration = oldReadTimeout + }) + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + sqlDB, err := conn.SqlDB(context.Background()) + require.NoError(t, err) + rw := db.New(conn) + wrapper := db.TestWrapper(t) + kms := kms.TestKms(t, conn, wrapper) + iamRepo := iam.TestRepo(t, conn, wrapper) + org, _ := iam.TestScopes(t, iamRepo) + + var allTokens []*authtoken.AuthToken + for i := 0; i < 5; i++ { + at := authtoken.TestAuthToken(t, conn, kms, org.GetPublicId()) + at.Token = "" + at.KeyId = "" + allTokens = append(allTokens, at) + } + + repo, err := authtoken.NewRepository(ctx, rw, rw, kms) + require.NoError(t, err) + + // Reverse since we read items in descending order (newest first) + slices.Reverse(allTokens) + + // Run analyze to update postgres estimates + _, err = sqlDB.ExecContext(ctx, "analyze") + require.NoError(t, err) + + cmpIgnoreUnexportedOpts := cmpopts.IgnoreUnexported(authtoken.AuthToken{}, store.AuthToken{}, timestamp.Timestamp{}, timestamppb.Timestamp{}) + cmpIgnoreFieldsOpts := cmpopts.IgnoreFields(authtoken.AuthToken{}, "Token", "KeyId") + + t.Run("List validation", func(t *testing.T) { + t.Parallel() + t.Run("missing grants hash", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + _, err := authtoken.List(ctx, nil, 1, filterFunc, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + _, err := authtoken.List(ctx, []byte("some hash"), 0, filterFunc, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "page size must be at least 1") + }) + t.Run("negative page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + _, err := authtoken.List(ctx, []byte("some hash"), -1, filterFunc, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "page size must be at least 1") + }) + t.Run("nil filter func", func(t *testing.T) { + t.Parallel() + _, err := authtoken.List(ctx, []byte("some hash"), 1, nil, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("nil repo", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + _, err := authtoken.List(ctx, []byte("some hash"), 1, filterFunc, nil, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing scope ids", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + _, err := authtoken.List(ctx, []byte("some hash"), 1, filterFunc, repo, nil) + require.ErrorContains(t, err, "missing scope ids") + }) + }) + t.Run("ListPage validation", func(t *testing.T) { + t.Parallel() + t.Run("missing grants hash", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListPage(ctx, nil, 1, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListPage(ctx, []byte("some hash"), 0, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "page size must be at least 1") + }) + t.Run("negative page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListPage(ctx, []byte("some hash"), -1, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "page size must be at least 1") + }) + t.Run("nil filter func", func(t *testing.T) { + t.Parallel() + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListPage(ctx, []byte("some hash"), 1, nil, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("nil token", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + _, err = authtoken.ListPage(ctx, []byte("some hash"), 1, filterFunc, nil, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing token") + }) + t.Run("wrong token type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "token did not have a pagination token component") + }) + t.Run("nil repo", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListPage(ctx, []byte("some hash"), 1, filterFunc, tok, nil, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing scope ids", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, nil) + require.ErrorContains(t, err, "missing scope ids") + }) + }) + t.Run("ListRefresh validation", func(t *testing.T) { + t.Parallel() + t.Run("missing grants hash", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListRefresh(ctx, nil, 1, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListRefresh(ctx, []byte("some hash"), 0, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "page size must be at least 1") + }) + t.Run("negative page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListRefresh(ctx, []byte("some hash"), -1, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "page size must be at least 1") + }) + t.Run("nil filter func", func(t *testing.T) { + t.Parallel() + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListRefresh(ctx, []byte("some hash"), 1, nil, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("nil token", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + _, err = authtoken.ListRefresh(ctx, []byte("some hash"), 1, filterFunc, nil, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing token") + }) + t.Run("wrong token type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListRefresh(ctx, []byte("some hash"), 1, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "token did not have a start-refresh token component") + }) + t.Run("nil repo", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListRefresh(ctx, []byte("some hash"), 1, filterFunc, tok, nil, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing scope ids", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListRefresh(ctx, []byte("some hash"), 1, filterFunc, tok, repo, nil) + require.ErrorContains(t, err, "missing scope ids") + }) + }) + t.Run("ListRefreshPage validation", func(t *testing.T) { + t.Parallel() + t.Run("missing grants hash", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListRefreshPage(ctx, nil, 1, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListRefreshPage(ctx, []byte("some hash"), 0, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "page size must be at least 1") + }) + t.Run("negative page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListRefreshPage(ctx, []byte("some hash"), -1, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "page size must be at least 1") + }) + t.Run("nil filter func", func(t *testing.T) { + t.Parallel() + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListRefreshPage(ctx, []byte("some hash"), 1, nil, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("nil token", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + _, err = authtoken.ListRefreshPage(ctx, []byte("some hash"), 1, filterFunc, nil, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing token") + }) + t.Run("wrong token type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListRefreshPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "token did not have a refresh token component") + }) + t.Run("nil repo", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListRefreshPage(ctx, []byte("some hash"), 1, filterFunc, tok, nil, []string{org.GetPublicId()}) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing scope ids", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.AuthToken, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = authtoken.ListRefreshPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, nil) + require.ErrorContains(t, err, "missing scope ids") + }) + }) + + t.Run("simple pagination", func(t *testing.T) { + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + resp, err := authtoken.List(ctx, []byte("some hash"), 1, filterFunc, repo, []string{org.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], allTokens[0], cmpIgnoreUnexportedOpts, cmpIgnoreFieldsOpts)) + + resp2, err := authtoken.ListPage(ctx, []byte("some hash"), 1, filterFunc, resp.ListToken, repo, []string{org.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], allTokens[1], cmpIgnoreUnexportedOpts, cmpIgnoreFieldsOpts)) + + resp3, err := authtoken.ListPage(ctx, []byte("some hash"), 1, filterFunc, resp2.ListToken, repo, []string{org.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], allTokens[2], cmpIgnoreUnexportedOpts, cmpIgnoreFieldsOpts)) + + resp4, err := authtoken.ListPage(ctx, []byte("some hash"), 1, filterFunc, resp3.ListToken, repo, []string{org.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], allTokens[3], cmpIgnoreUnexportedOpts, cmpIgnoreFieldsOpts)) + + resp5, err := authtoken.ListPage(ctx, []byte("some hash"), 1, filterFunc, resp4.ListToken, repo, []string{org.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], allTokens[4], cmpIgnoreUnexportedOpts, cmpIgnoreFieldsOpts)) + + // Finished initial pagination phase, request refresh + // Expect no results. + resp6, err := authtoken.ListRefresh(ctx, []byte("some hash"), 1, filterFunc, resp5.ListToken, repo, []string{org.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 auth tokens + newAt1 := authtoken.TestAuthToken(t, conn, kms, org.GetPublicId()) + newAt2 := authtoken.TestAuthToken(t, conn, kms, org.GetPublicId()) + t.Cleanup(func() { + _, err = repo.DeleteAuthToken(ctx, newAt1.GetPublicId()) + require.NoError(t, err) + _, err = repo.DeleteAuthToken(ctx, newAt2.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 newAt2 + resp7, err := authtoken.ListRefresh(ctx, []byte("some hash"), 1, filterFunc, resp6.ListToken, repo, []string{org.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], newAt2, cmpIgnoreUnexportedOpts, cmpIgnoreFieldsOpts)) + + // Refresh again, should get newAt1 + resp8, err := authtoken.ListRefreshPage(ctx, []byte("some hash"), 1, filterFunc, resp7.ListToken, repo, []string{org.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], newAt1, cmpIgnoreUnexportedOpts, cmpIgnoreFieldsOpts)) + + // Refresh again, should get no results + resp9, err := authtoken.ListRefresh(ctx, []byte("some hash"), 1, filterFunc, resp8.ListToken, repo, []string{org.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(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return at.GetPublicId() == allTokens[1].GetPublicId() || + at.GetPublicId() == allTokens[len(allTokens)-1].GetPublicId(), nil + } + resp, err := authtoken.List(ctx, []byte("some hash"), 1, filterFunc, repo, []string{org.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], allTokens[1], cmpIgnoreUnexportedOpts, cmpIgnoreFieldsOpts)) + + resp2, err := authtoken.ListPage(ctx, []byte("some hash"), 1, filterFunc, resp.ListToken, repo, []string{org.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], allTokens[len(allTokens)-1], cmpIgnoreUnexportedOpts, cmpIgnoreFieldsOpts)) + + // request a refresh, nothing should be returned + resp3, err := authtoken.ListRefresh(ctx, []byte("some hash"), 1, filterFunc, resp.ListToken, repo, []string{org.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 tokens + newAt1 := authtoken.TestAuthToken(t, conn, kms, org.GetPublicId()) + newAt2 := authtoken.TestAuthToken(t, conn, kms, org.GetPublicId()) + newAt3 := authtoken.TestAuthToken(t, conn, kms, org.GetPublicId()) + newAt4 := authtoken.TestAuthToken(t, conn, kms, org.GetPublicId()) + // Run analyze to update count estimate + _, err = sqlDB.ExecContext(ctx, "analyze") + require.NoError(t, err) + t.Cleanup(func() { + _, err = repo.DeleteAuthToken(ctx, newAt1.GetPublicId()) + require.NoError(t, err) + _, err = repo.DeleteAuthToken(ctx, newAt2.GetPublicId()) + require.NoError(t, err) + _, err = repo.DeleteAuthToken(ctx, newAt3.GetPublicId()) + require.NoError(t, err) + _, err = repo.DeleteAuthToken(ctx, newAt4.GetPublicId()) + require.NoError(t, err) + // Run analyze to update count estimate + _, err = sqlDB.ExecContext(ctx, "analyze") + require.NoError(t, err) + }) + + filterFunc = func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return at.GetPublicId() == newAt3.GetPublicId() || + at.GetPublicId() == newAt1.GetPublicId(), nil + } + // Refresh again, should get newAt3 + resp4, err := authtoken.ListRefresh(ctx, []byte("some hash"), 1, filterFunc, resp3.ListToken, repo, []string{org.GetPublicId()}) + require.NoError(t, err) + require.Equal(t, resp4.ListToken.GrantsHash, []byte("some hash")) + require.False(t, resp4.CompleteListing) + require.Equal(t, resp4.EstimatedItemCount, 9) + require.Empty(t, resp4.DeletedIds) + require.Len(t, resp4.Items, 1) + require.Empty(t, cmp.Diff(resp4.Items[0], newAt3, cmpIgnoreUnexportedOpts, cmpIgnoreFieldsOpts)) + + // Refresh again, should get newAt1 + resp5, err := authtoken.ListRefreshPage(ctx, []byte("some hash"), 1, filterFunc, resp4.ListToken, repo, []string{org.GetPublicId()}) + require.NoError(t, err) + require.Equal(t, resp5.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp5.CompleteListing) + require.Equal(t, resp5.EstimatedItemCount, 9) + require.Empty(t, resp5.DeletedIds) + require.Len(t, resp5.Items, 1) + require.Empty(t, cmp.Diff(resp5.Items[0], newAt1, cmpIgnoreUnexportedOpts, cmpIgnoreFieldsOpts)) + }) + + t.Run("simple pagination with deletion", func(t *testing.T) { + filterFunc := func(_ context.Context, at *authtoken.AuthToken) (bool, error) { + return true, nil + } + deletedAuthTokenId := allTokens[0].GetPublicId() + _, err := repo.DeleteAuthToken(ctx, deletedAuthTokenId) + require.NoError(t, err) + allTokens = allTokens[1:] + + // Run analyze to update count estimate + _, err = sqlDB.ExecContext(ctx, "analyze") + require.NoError(t, err) + + resp, err := authtoken.List(ctx, []byte("some hash"), 1, filterFunc, repo, []string{org.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], allTokens[0], cmpIgnoreUnexportedOpts, cmpIgnoreFieldsOpts)) + + // request remaining results + resp2, err := authtoken.ListPage(ctx, []byte("some hash"), 3, filterFunc, resp.ListToken, repo, []string{org.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, allTokens[1:], cmpIgnoreUnexportedOpts, cmpIgnoreFieldsOpts)) + + deletedAuthTokenId = allTokens[0].GetPublicId() + _, err = repo.DeleteAuthToken(ctx, deletedAuthTokenId) + require.NoError(t, err) + allTokens = allTokens[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 := authtoken.ListRefresh(ctx, []byte("some hash"), 1, filterFunc, resp2.ListToken, repo, []string{org.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, deletedAuthTokenId) + require.Empty(t, resp3.Items) + }) +} diff --git a/internal/authtoken/service_list_page.go b/internal/authtoken/service_list_page.go new file mode 100644 index 0000000000..1c63d52415 --- /dev/null +++ b/internal/authtoken/service_list_page.go @@ -0,0 +1,67 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package authtoken + +import ( + "context" + "time" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/listtoken" + "github.com/hashicorp/boundary/internal/pagination" +) + +// ListPage lists up to page size auth tokens, filtering out entries that +// do not pass the filter item function. It will automatically request +// more auth tokens 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. +// Auth tokens are ordered by create time descending (most recently created first). +func ListPage( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[*AuthToken], + tok *listtoken.Token, + repo *Repository, + withScopeIds []string, +) (*pagination.ListResponse[*AuthToken], error) { + const op = "authtoken.ListPage" + + switch { + case len(grantsHash) == 0: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing grants hash") + case pageSize < 1: + return nil, errors.New(ctx, errors.InvalidParameter, op, "page size must be at least 1") + case filterItemFn == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing filter item callback") + case tok == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing token") + case repo == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing repo") + case withScopeIds == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing scope ids") + } + 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 *AuthToken, limit int) ([]*AuthToken, time.Time, error) { + opts := []Option{ + WithLimit(limit), + } + if lastPageItem != nil { + opts = append(opts, WithStartPageAfterItem(lastPageItem)) + } else { + lastItem, err := tok.LastItem(ctx) + if err != nil { + return nil, time.Time{}, err + } + opts = append(opts, WithStartPageAfterItem(lastItem)) + } + return repo.listAuthTokens(ctx, withScopeIds, opts...) + } + + return pagination.ListPage(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedCount, tok) +} diff --git a/internal/authtoken/service_list_refresh.go b/internal/authtoken/service_list_refresh.go new file mode 100644 index 0000000000..d70c4327d3 --- /dev/null +++ b/internal/authtoken/service_list_refresh.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package authtoken + +import ( + "context" + "time" + + "github.com/hashicorp/boundary/globals" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/listtoken" + "github.com/hashicorp/boundary/internal/pagination" +) + +// ListRefresh lists up to page size auth tokens, filtering out entries that +// do not pass the filter item function. It will automatically request +// more auth tokens 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. +// Auth tokens are ordered by update time descending (most recently updated first). +// Auth tokens may contain items that were already returned during the initial +// pagination phase. It also returns a list of any auth tokens deleted since the +// start of the initial pagination phase or last response. +func ListRefresh( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[*AuthToken], + tok *listtoken.Token, + repo *Repository, + withScopeIds []string, +) (*pagination.ListResponse[*AuthToken], error) { + const op = "authtoken.ListRefresh" + + switch { + case len(grantsHash) == 0: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing grants hash") + case pageSize < 1: + return nil, errors.New(ctx, errors.InvalidParameter, op, "page size must be at least 1") + case filterItemFn == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing filter item callback") + case tok == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing token") + case repo == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing repo") + case withScopeIds == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing scope ids") + } + 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 *AuthToken, limit int) ([]*AuthToken, time.Time, error) { + opts := []Option{ + WithLimit(limit), + } + if lastPageItem != nil { + opts = append(opts, WithStartPageAfterItem(lastPageItem)) + } + // Add the database read timeout to account for any creations missed due to concurrent + // transactions in the initial pagination phase. + return repo.listAuthTokensRefresh(ctx, rt.PreviousPhaseUpperBound.Add(-globals.RefreshReadLookbackDuration), withScopeIds, opts...) + } + listDeletedIdsFn := func(ctx context.Context, since time.Time) ([]string, time.Time, error) { + // Add the database read timeout to account for any deletions missed due to concurrent + // transactions in previous requests. + return repo.listDeletedIds(ctx, since.Add(-globals.RefreshReadLookbackDuration)) + } + + return pagination.ListRefresh(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedCount, listDeletedIdsFn, tok) +} diff --git a/internal/authtoken/service_list_refresh_page.go b/internal/authtoken/service_list_refresh_page.go new file mode 100644 index 0000000000..177dca1bd3 --- /dev/null +++ b/internal/authtoken/service_list_refresh_page.go @@ -0,0 +1,80 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package authtoken + +import ( + "context" + "time" + + "github.com/hashicorp/boundary/globals" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/listtoken" + "github.com/hashicorp/boundary/internal/pagination" +) + +// ListRefreshPage lists up to page size auth tokens, filtering out entries that +// do not pass the filter item function. It will automatically request +// more auth tokens 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. +// Auth tokens are ordered by update time descending (most recently updated first). +// Auth tokens may contain items that were already returned during the initial +// pagination phase. It also returns a list of any auth tokens deleted since the +// last response. +func ListRefreshPage( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[*AuthToken], + tok *listtoken.Token, + repo *Repository, + withScopeIds []string, +) (*pagination.ListResponse[*AuthToken], error) { + const op = "authtoken.ListRefreshPage" + + switch { + case len(grantsHash) == 0: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing grants hash") + case pageSize < 1: + return nil, errors.New(ctx, errors.InvalidParameter, op, "page size must be at least 1") + case filterItemFn == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing filter item callback") + case tok == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing token") + case repo == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing repo") + case withScopeIds == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing scope ids") + } + 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 *AuthToken, limit int) ([]*AuthToken, time.Time, error) { + opts := []Option{ + WithLimit(limit), + } + if lastPageItem != nil { + opts = append(opts, WithStartPageAfterItem(lastPageItem)) + } else { + lastItem, err := tok.LastItem(ctx) + if err != nil { + return nil, time.Time{}, err + } + opts = append(opts, WithStartPageAfterItem(lastItem)) + } + // Add the database read timeout to account for any creations missed due to concurrent + // transactions in the original list pagination phase. + return repo.listAuthTokensRefresh(ctx, rt.PhaseLowerBound.Add(-globals.RefreshReadLookbackDuration), withScopeIds, opts...) + } + + listDeletedIdsFn := func(ctx context.Context, since time.Time) ([]string, time.Time, error) { + // Add the database read timeout to account for any deletes missed due to concurrent + // transactions in the original list pagination phase. + return repo.listDeletedIds(ctx, since.Add(-globals.RefreshReadLookbackDuration)) + } + + return pagination.ListRefreshPage(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.estimatedCount, listDeletedIdsFn, tok) +} diff --git a/internal/daemon/controller/handler.go b/internal/daemon/controller/handler.go index de5b8437e1..c4af59cb7c 100644 --- a/internal/daemon/controller/handler.go +++ b/internal/daemon/controller/handler.go @@ -171,7 +171,7 @@ func (c *Controller) registerGrpcServices(s *grpc.Server) error { services.RegisterAuthMethodServiceServer(s, authMethods) } if _, ok := currentServices[services.AuthTokenService_ServiceDesc.ServiceName]; !ok { - authtoks, err := authtokens.NewService(c.baseContext, c.AuthTokenRepoFn, c.IamRepoFn) + authtoks, err := authtokens.NewService(c.baseContext, c.AuthTokenRepoFn, c.IamRepoFn, c.conf.RawConfig.Controller.MaxPageSize) if err != nil { return fmt.Errorf("failed to create auth token handler service: %w", err) } diff --git a/internal/daemon/controller/handlers/authtokens/authtoken_service.go b/internal/daemon/controller/handlers/authtokens/authtoken_service.go index 853fc4d792..12489b539c 100644 --- a/internal/daemon/controller/handlers/authtokens/authtoken_service.go +++ b/internal/daemon/controller/handlers/authtokens/authtoken_service.go @@ -15,12 +15,15 @@ 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" "github.com/hashicorp/boundary/internal/types/resource" "github.com/hashicorp/boundary/internal/types/scope" pb "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/authtokens" + "github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/scopes" "google.golang.org/grpc/codes" ) @@ -51,14 +54,15 @@ func init() { type Service struct { pbs.UnsafeAuthTokenServiceServer - repoFn common.AuthTokenRepoFactory - iamRepoFn common.IamRepoFactory + repoFn common.AuthTokenRepoFactory + iamRepoFn common.IamRepoFactory + maxPageSize uint } var _ pbs.AuthTokenServiceServer = (*Service)(nil) // NewService returns a user service which handles user related requests to boundary. -func NewService(ctx context.Context, repo common.AuthTokenRepoFactory, iamRepoFn common.IamRepoFactory) (Service, error) { +func NewService(ctx context.Context, repo common.AuthTokenRepoFactory, iamRepoFn common.IamRepoFactory, maxPageSize uint) (Service, error) { const op = "authtoken.NewService" if repo == nil { return Service{}, errors.New(ctx, errors.InvalidParameter, op, "missing auth token repository") @@ -66,14 +70,20 @@ func NewService(ctx context.Context, repo common.AuthTokenRepoFactory, iamRepoFn if iamRepoFn == nil { return Service{}, errors.New(ctx, errors.InvalidParameter, op, "missing iam repository") } - return Service{repoFn: repo, iamRepoFn: iamRepoFn}, nil + if maxPageSize == 0 { + maxPageSize = uint(globals.DefaultMaxPageSize) + } + return Service{repoFn: repo, iamRepoFn: iamRepoFn, maxPageSize: maxPageSize}, nil } // ListAuthTokens implements the interface pbs.AuthTokenServiceServer. func (s Service) ListAuthTokens(ctx context.Context, req *pbs.ListAuthTokensRequest) (*pbs.ListAuthTokensResponse, error) { + const op = "authtokens.(Service).ListAuthTokens" + if err := validateListRequest(ctx, req); err != nil { - return nil, err + return nil, errors.Wrap(ctx, err, op) } + authResults := s.authResult(ctx, req.GetScopeId(), action.List) if authResults.Error != nil { // If it's forbidden, and it's a recursive request, and they're @@ -84,69 +94,131 @@ func (s Service) ListAuthTokens(ctx context.Context, req *pbs.ListAuthTokensRequ req.GetRecursive() && authResults.AuthenticationFinished { } else { - return nil, authResults.Error + return nil, errors.Wrap(ctx, authResults.Error, op) } } scopeIds, scopeInfoMap, err := scopeids.GetListingScopeIds( ctx, s.iamRepoFn, authResults, req.GetScopeId(), resource.AuthToken, req.GetRecursive()) if err != nil { - return nil, err + return nil, errors.Wrap(ctx, err, op) } // If no scopes match, return an empty response if len(scopeIds) == 0 { return &pbs.ListAuthTokensResponse{}, nil } - ul, err := s.listFromRepo(ctx, scopeIds) - if err != nil { - return nil, err + pageSize := int(s.maxPageSize) + // Use the requested page size only if it is smaller than + // the configured max. + if req.GetPageSize() != 0 && uint(req.GetPageSize()) < s.maxPageSize { + pageSize = int(req.GetPageSize()) } - if len(ul) == 0 { - return &pbs.ListAuthTokensResponse{}, nil + + var filterItemFn func(ctx context.Context, item *authtoken.AuthToken) (bool, error) + switch { + case req.GetFilter() != "": + // Only use a filter if we need to + filter, err := handlers.NewFilter(ctx, req.GetFilter()) + if err != nil { + return nil, err + } + filterItemFn = func(ctx context.Context, item *authtoken.AuthToken) (bool, error) { + outputOpts, ok := newOutputOpts(ctx, item, scopeInfoMap, authResults) + if !ok { + return false, nil + } + pbItem, err := toProto(ctx, item, outputOpts...) + if err != nil { + return false, err + } + return filter.Match(pbItem), nil + } + default: + filterItemFn = func(ctx context.Context, item *authtoken.AuthToken) (bool, error) { + return true, nil + } } - filter, err := handlers.NewFilter(ctx, req.GetFilter()) + grantsHash, err := authResults.GrantsHash(ctx) if err != nil { - return nil, err + return nil, errors.Wrap(ctx, err, op) } - finalItems := make([]*pb.AuthToken, 0, len(ul)) - res := perms.Resource{ - Type: resource.AuthToken, + + repo, err := s.repoFn() + if err != nil { + return nil, errors.Wrap(ctx, err, op) } - for _, at := range ul { - res.Id = at.GetPublicId() - res.ScopeId = at.GetScopeId() - authorizedActions := authResults.FetchActionSetForId(ctx, at.GetPublicId(), IdActions, auth.WithResource(&res)) - if len(authorizedActions) == 0 { - continue - } - if authorizedActions.OnlySelf() && at.GetIamUserId() != authResults.UserId { - continue + var listResp *pagination.ListResponse[*authtoken.AuthToken] + var sortBy string + if req.GetListToken() == "" { + sortBy = "created_time" + listResp, err = authtoken.List(ctx, grantsHash, pageSize, filterItemFn, repo, scopeIds) + if err != nil { + return nil, err } - - outputFields := authResults.FetchOutputFields(res, action.List).SelfOrDefaults(authResults.UserId) - outputOpts := make([]handlers.Option, 0, 3) - outputOpts = append(outputOpts, handlers.WithOutputFields(outputFields)) - if outputFields.Has(globals.ScopeField) { - outputOpts = append(outputOpts, handlers.WithScope(scopeInfoMap[at.GetScopeId()])) + } else { + listToken, err := handlers.ParseListToken(ctx, req.GetListToken(), resource.AuthToken, grantsHash) + if err != nil { + return nil, err } - if outputFields.Has(globals.AuthorizedActionsField) { - outputOpts = append(outputOpts, handlers.WithAuthorizedActions(authorizedActions.Strings())) + switch st := listToken.Subtype.(type) { + case *listtoken.PaginationToken: + sortBy = "created_time" + listResp, err = authtoken.ListPage(ctx, grantsHash, pageSize, filterItemFn, listToken, repo, scopeIds) + if err != nil { + return nil, err + } + case *listtoken.StartRefreshToken: + sortBy = "updated_time" + listResp, err = authtoken.ListRefresh(ctx, grantsHash, pageSize, filterItemFn, listToken, repo, scopeIds) + if err != nil { + return nil, err + } + case *listtoken.RefreshToken: + sortBy = "updated_time" + listResp, err = authtoken.ListRefreshPage(ctx, grantsHash, pageSize, filterItemFn, listToken, repo, scopeIds) + if err != nil { + return nil, err + } + default: + return nil, handlers.ApiErrorWithCodeAndMessage(codes.InvalidArgument, "unexpected list token subtype: %T", st) } + } - item, err := toProto(ctx, at, outputOpts...) + finalItems := make([]*pb.AuthToken, 0, len(listResp.Items)) + for _, item := range listResp.Items { + outputOpts, ok := newOutputOpts(ctx, item, scopeInfoMap, authResults) + if !ok { + continue + } + item, err := toProto(ctx, item, outputOpts...) if err != nil { - return nil, err + return nil, errors.Wrap(ctx, err, op) } + finalItems = append(finalItems, item) + } + respType := "delta" + if listResp.CompleteListing { + respType = "complete" + } + resp := &pbs.ListAuthTokensResponse{ + Items: finalItems, + EstItemCount: uint32(listResp.EstimatedItemCount), + RemovedIds: listResp.DeletedIds, + ResponseType: respType, + SortBy: sortBy, + SortDir: "desc", + } - if filter.Match(item) { - finalItems = append(finalItems, item) + if listResp.ListToken != nil { + resp.ListToken, err = handlers.MarshalListToken(ctx, listResp.ListToken, pbs.ResourceType_RESOURCE_TYPE_AUTH_TOKEN) + if err != nil { + return nil, err } } - - return &pbs.ListAuthTokensResponse{Items: finalItems}, nil + return resp, nil } // GetAuthToken implements the interface pbs.AuthTokenServiceServer. @@ -266,19 +338,6 @@ func (s Service) deleteFromRepo(ctx context.Context, id string) (bool, error) { return rows > 0, nil } -func (s Service) listFromRepo(ctx context.Context, scopeIds []string) ([]*authtoken.AuthToken, error) { - repo, err := s.repoFn() - _ = repo - if err != nil { - return nil, err - } - ul, err := repo.ListAuthTokens(ctx, scopeIds, authtoken.WithLimit(-1)) - if err != nil { - return nil, err - } - return ul, nil -} - func (s Service) authResult(ctx context.Context, id string, a action.Type) auth.VerifyResults { res := auth.VerifyResults{} @@ -395,3 +454,30 @@ func validateListRequest(ctx context.Context, req *pbs.ListAuthTokensRequest) er } return nil } + +func newOutputOpts(ctx context.Context, item *authtoken.AuthToken, scopeInfoMap map[string]*scopes.ScopeInfo, authResults auth.VerifyResults) ([]handlers.Option, bool) { + res := perms.Resource{ + Type: resource.AuthToken, + } + res.Id = item.GetPublicId() + res.ScopeId = item.GetScopeId() + authorizedActions := authResults.FetchActionSetForId(ctx, item.GetPublicId(), IdActions, auth.WithResource(&res)) + if len(authorizedActions) == 0 { + return nil, false + } + + if authorizedActions.OnlySelf() && item.GetIamUserId() != authResults.UserId { + return nil, false + } + + outputFields := authResults.FetchOutputFields(res, action.List).SelfOrDefaults(authResults.UserId) + outputOpts := make([]handlers.Option, 0, 3) + outputOpts = append(outputOpts, handlers.WithOutputFields(outputFields)) + if outputFields.Has(globals.ScopeField) { + outputOpts = append(outputOpts, handlers.WithScope(scopeInfoMap[item.GetScopeId()])) + } + if outputFields.Has(globals.AuthorizedActionsField) { + outputOpts = append(outputOpts, handlers.WithAuthorizedActions(authorizedActions.Strings())) + } + return outputOpts, true +} diff --git a/internal/daemon/controller/handlers/authtokens/authtoken_service_test.go b/internal/daemon/controller/handlers/authtokens/authtoken_service_test.go index 393a2223aa..9629bbafab 100644 --- a/internal/daemon/controller/handlers/authtokens/authtoken_service_test.go +++ b/internal/daemon/controller/handlers/authtokens/authtoken_service_test.go @@ -8,11 +8,13 @@ import ( "errors" "fmt" "net/http/httptest" + "slices" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/boundary/globals" + "github.com/hashicorp/boundary/internal/auth/password" "github.com/hashicorp/boundary/internal/authtoken" "github.com/hashicorp/boundary/internal/daemon/controller/auth" "github.com/hashicorp/boundary/internal/daemon/controller/handlers" @@ -34,7 +36,10 @@ import ( "github.com/stretchr/testify/require" ) -var testAuthorizedActions = []string{"no-op", "read", "read:self", "delete", "delete:self"} +var ( + fullAuthorizedActions = []string{"no-op", "read", "read:self", "delete", "delete:self"} + selfAuthorizedActions = []string{"read:self", "delete:self"} +) func TestGetSelf(t *testing.T) { ctx := context.Background() @@ -53,7 +58,7 @@ func TestGetSelf(t *testing.T) { return server.NewRepository(ctx, rw, rw, kms) } - a, err := authtokens.NewService(ctx, tokenRepoFn, iamRepoFn) + a, err := authtokens.NewService(ctx, tokenRepoFn, iamRepoFn, 1000) require.NoError(t, err, "Couldn't create new auth token service.") o, _ := iam.TestScopes(t, iam.TestRepo(t, conn, wrap)) @@ -132,7 +137,7 @@ func TestGet(t *testing.T) { return authtoken.NewRepository(ctx, rw, rw, kms) } - s, err := authtokens.NewService(ctx, repoFn, iamRepoFn) + s, err := authtokens.NewService(ctx, repoFn, iamRepoFn, 1000) require.NoError(t, err, "Couldn't create new auth token service.") org, _ := iam.TestScopes(t, iam.TestRepo(t, conn, wrap)) @@ -149,7 +154,7 @@ func TestGet(t *testing.T) { ApproximateLastUsedTime: at.GetApproximateLastAccessTime().GetTimestamp(), ExpirationTime: at.GetExpirationTime().GetTimestamp(), Scope: &scopes.ScopeInfo{Id: org.GetPublicId(), Type: scope.Org.String(), ParentScopeId: scope.Global.String()}, - AuthorizedActions: testAuthorizedActions, + AuthorizedActions: fullAuthorizedActions, } cases := []struct { @@ -232,19 +237,22 @@ func TestList_Self(t *testing.T) { cases := []struct { name string requester *authtoken.AuthToken + req *pbs.ListAuthTokensRequest count int }{ { name: "First token sees only self", + req: &pbs.ListAuthTokensRequest{ScopeId: o.GetPublicId()}, requester: at, }, { name: "Second token sees only self", + req: &pbs.ListAuthTokensRequest{ScopeId: o.GetPublicId()}, requester: otherAt, }, } - a, err := authtokens.NewService(testCtx, tokenRepoFn, iamRepoFn) + a, err := authtokens.NewService(testCtx, tokenRepoFn, iamRepoFn, 1000) require.NoError(t, err) for _, tc := range cases { @@ -261,7 +269,7 @@ func TestList_Self(t *testing.T) { } ctx := auth.NewVerifierContext(testCtx, iamRepoFn, tokenRepoFn, serversRepoFn, kms, &requestInfo) - got, err := a.ListAuthTokens(ctx, &pbs.ListAuthTokensRequest{ScopeId: o.GetPublicId()}) + got, err := a.ListAuthTokens(ctx, tc.req) require.NoError(err) require.Len(got.Items, 1) assert.Equal(got.Items[0].GetId(), tc.requester.GetPublicId()) @@ -273,6 +281,8 @@ func TestList_Self(t *testing.T) { func TestList(t *testing.T) { conn, _ := db.TestSetup(t, "postgres") + sqlDB, err := conn.SqlDB(context.Background()) + require.NoError(t, err) rw := db.New(conn) wrap := db.TestWrapper(t) kms := kms.TestKms(t, conn, wrap) @@ -287,9 +297,10 @@ func TestList(t *testing.T) { orgNoTokens, _ := iam.TestScopes(t, iamRepo) var globalTokens []*pb.AuthToken + var allTokens []*pb.AuthToken for i := 0; i < 3; i++ { at := authtoken.TestAuthToken(t, conn, kms, scope.Global.String()) - globalTokens = append(globalTokens, &pb.AuthToken{ + pbat := &pb.AuthToken{ Id: at.GetPublicId(), ScopeId: at.GetScopeId(), UserId: at.GetIamUserId(), @@ -300,15 +311,17 @@ func TestList(t *testing.T) { ApproximateLastUsedTime: at.GetApproximateLastAccessTime().GetTimestamp(), ExpirationTime: at.GetExpirationTime().GetTimestamp(), Scope: &scopes.ScopeInfo{Id: scope.Global.String(), Type: scope.Global.String(), Name: scope.Global.String(), Description: "Global Scope"}, - AuthorizedActions: testAuthorizedActions, - }) + AuthorizedActions: fullAuthorizedActions, + } + globalTokens = append(globalTokens, pbat) + allTokens = append(allTokens, pbat) } orgWithSomeTokens, _ := iam.TestScopes(t, iamRepo) var wantSomeTokens []*pb.AuthToken for i := 0; i < 3; i++ { at := authtoken.TestAuthToken(t, conn, kms, orgWithSomeTokens.GetPublicId()) - wantSomeTokens = append(wantSomeTokens, &pb.AuthToken{ + pbat := &pb.AuthToken{ Id: at.GetPublicId(), ScopeId: at.GetScopeId(), UserId: at.GetIamUserId(), @@ -319,15 +332,17 @@ func TestList(t *testing.T) { ApproximateLastUsedTime: at.GetApproximateLastAccessTime().GetTimestamp(), ExpirationTime: at.GetExpirationTime().GetTimestamp(), Scope: &scopes.ScopeInfo{Id: orgWithSomeTokens.GetPublicId(), Type: scope.Org.String(), ParentScopeId: scope.Global.String()}, - AuthorizedActions: testAuthorizedActions, - }) + AuthorizedActions: fullAuthorizedActions, + } + wantSomeTokens = append(wantSomeTokens, pbat) + allTokens = append(allTokens, pbat) } orgWithOtherTokens, _ := iam.TestScopes(t, iamRepo) var wantOtherTokens []*pb.AuthToken for i := 0; i < 3; i++ { at := authtoken.TestAuthToken(t, conn, kms, orgWithOtherTokens.GetPublicId()) - wantOtherTokens = append(wantOtherTokens, &pb.AuthToken{ + pbat := &pb.AuthToken{ Id: at.GetPublicId(), ScopeId: at.GetScopeId(), UserId: at.GetIamUserId(), @@ -338,12 +353,21 @@ func TestList(t *testing.T) { ApproximateLastUsedTime: at.GetApproximateLastAccessTime().GetTimestamp(), ExpirationTime: at.GetExpirationTime().GetTimestamp(), Scope: &scopes.ScopeInfo{Id: orgWithOtherTokens.GetPublicId(), Type: scope.Org.String(), ParentScopeId: scope.Global.String()}, - AuthorizedActions: testAuthorizedActions, - }) + AuthorizedActions: fullAuthorizedActions, + } + wantOtherTokens = append(wantOtherTokens, pbat) + allTokens = append(allTokens, pbat) } - allTokens := append(globalTokens, wantSomeTokens...) - allTokens = append(allTokens, wantOtherTokens...) + // Reverse slices since response is ordered by created_time descending (newest first) + slices.Reverse(globalTokens) + slices.Reverse(wantSomeTokens) + slices.Reverse(wantOtherTokens) + slices.Reverse(allTokens) + + // Run analyze to update postgres estimates + _, err = sqlDB.ExecContext(context.Background(), "analyze") + require.NoError(t, err) cases := []struct { name string @@ -354,17 +378,33 @@ func TestList(t *testing.T) { { name: "List Some Tokens", req: &pbs.ListAuthTokensRequest{ScopeId: orgWithSomeTokens.GetPublicId()}, - res: &pbs.ListAuthTokensResponse{Items: wantSomeTokens}, + res: &pbs.ListAuthTokensResponse{ + Items: wantSomeTokens, + EstItemCount: 3, + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, }, { name: "List Other Tokens", req: &pbs.ListAuthTokensRequest{ScopeId: orgWithOtherTokens.GetPublicId()}, - res: &pbs.ListAuthTokensResponse{Items: wantOtherTokens}, + res: &pbs.ListAuthTokensResponse{ + Items: wantOtherTokens, + EstItemCount: 3, + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, }, { name: "List No Token", req: &pbs.ListAuthTokensRequest{ScopeId: orgNoTokens.GetPublicId()}, - res: &pbs.ListAuthTokensResponse{}, + res: &pbs.ListAuthTokensResponse{ + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, }, // TODO: When an org doesn't exist, we should return a 404 instead of an empty list. { @@ -375,7 +415,24 @@ func TestList(t *testing.T) { { name: "List Recursively", req: &pbs.ListAuthTokensRequest{ScopeId: "global", Recursive: true}, - res: &pbs.ListAuthTokensResponse{Items: allTokens}, + res: &pbs.ListAuthTokensResponse{ + Items: allTokens, + EstItemCount: 9, + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, + }, + { + name: "Paginate listing", + req: &pbs.ListAuthTokensRequest{ScopeId: "global", Recursive: true, PageSize: 2}, + res: &pbs.ListAuthTokensResponse{ + Items: allTokens[:2], + EstItemCount: 9, + ResponseType: "delta", + SortBy: "created_time", + SortDir: "desc", + }, }, { name: "Filter to Some Tokens", @@ -383,12 +440,22 @@ func TestList(t *testing.T) { ScopeId: "global", Recursive: true, Filter: fmt.Sprintf(`"/item/scope/id"==%q`, orgWithSomeTokens.GetPublicId()), }, - res: &pbs.ListAuthTokensResponse{Items: wantSomeTokens}, + res: &pbs.ListAuthTokensResponse{ + Items: wantSomeTokens, + EstItemCount: 3, + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, }, { name: "Filter All Tokens", req: &pbs.ListAuthTokensRequest{ScopeId: orgWithOtherTokens.GetPublicId(), Filter: `"/item/scope/id"=="thisdoesntmatch"`}, - res: &pbs.ListAuthTokensResponse{}, + res: &pbs.ListAuthTokensResponse{ + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + }, }, { name: "Filter Bad Format", @@ -398,7 +465,7 @@ func TestList(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - s, err := authtokens.NewService(context.Background(), repoFn, iamRepoFn) + s, err := authtokens.NewService(context.Background(), repoFn, iamRepoFn, 1000) assert, require := assert.New(t), require.New(t) require.NoError(err, "Couldn't create new user service.") @@ -419,7 +486,8 @@ func TestList(t *testing.T) { cmpopts.SortSlices(func(a, b string) bool { return a < b }), - ), "ListAuthTokens() with scope %q got response %q, wanted %q", tc.req.GetScopeId(), got, tc.res) + protocmp.IgnoreFields(&pbs.ListAuthTokensResponse{}, "list_token"), + )) // Now check anon listing got, gErr = s.ListAuthTokens(auth.DisabledAuthTestContext(iamRepoFn, tc.req.GetScopeId(), auth.WithUserId(globals.AnonymousUserId)), tc.req) @@ -435,6 +503,274 @@ func TestList(t *testing.T) { } } +func authTokenToProto(at *authtoken.AuthToken, scope *scopes.ScopeInfo, authorizedActions []string) *pb.AuthToken { + return &pb.AuthToken{ + Id: at.GetPublicId(), + ScopeId: at.GetScopeId(), + UserId: at.GetIamUserId(), + AuthMethodId: at.GetAuthMethodId(), + AccountId: at.GetAuthAccountId(), + CreatedTime: at.GetCreateTime().GetTimestamp(), + UpdatedTime: at.GetUpdateTime().GetTimestamp(), + ApproximateLastUsedTime: at.GetApproximateLastAccessTime().GetTimestamp(), + ExpirationTime: at.GetExpirationTime().GetTimestamp(), + Scope: scope, + AuthorizedActions: authorizedActions, + } +} + +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) + kms := kms.TestKms(t, conn, wrap) + iamRepoFn := func() (*iam.Repository, error) { + return iam.TestRepo(t, conn, wrap), nil + } + tokenRepoFn := func() (*authtoken.Repository, error) { + return authtoken.NewRepository(ctx, rw, rw, kms) + } + serversRepoFn := func() (*server.Repository, error) { + return server.NewRepository(ctx, rw, rw, kms) + } + iamRepo := iam.TestRepo(t, conn, wrap) + tokenRepo, _ := tokenRepoFn() + orgWithTokens, pwt := iam.TestScopes(t, iamRepo) + + authMethod := password.TestAuthMethods(t, conn, orgWithTokens.GetPublicId(), 1)[0] + // auth account is only used to join auth method to user. + // We don't do anything else with the auth account in the test setup. + acct := password.TestAccount(t, conn, authMethod.GetPublicId(), "test_user") + + u := iam.TestUser(t, iamRepo, orgWithTokens.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()) + + var allTokens []*pb.AuthToken + for i := 0; i < 9; i++ { + at, _ := tokenRepo.CreateAuthToken(ctx, u, acct.GetPublicId()) + atp := authTokenToProto(at, &scopes.ScopeInfo{Id: orgWithTokens.GetPublicId(), Type: scope.Org.String(), ParentScopeId: scope.Global.String()}, selfAuthorizedActions) + allTokens = append(allTokens, atp) + } + + a, err := authtokens.NewService(ctx, tokenRepoFn, iamRepoFn, 1000) + require.NoError(t, err, "Couldn't create new user service.") + + masterToken, _ := tokenRepo.CreateAuthToken(ctx, u, acct.GetPublicId()) + mtp := authTokenToProto(masterToken, &scopes.ScopeInfo{Id: orgWithTokens.GetPublicId(), Type: scope.Org.String(), ParentScopeId: scope.Global.String()}, selfAuthorizedActions) + allTokens = append(allTokens, mtp) + + slices.Reverse(allTokens) + + // Run analyze to update postgres estimates + _, err = sqlDB.ExecContext(context.Background(), "analyze") + require.NoError(t, err) + + requestInfo := authpb.RequestInfo{ + TokenFormat: uint32(auth.AuthTokenTypeBearer), + Token: masterToken.GetToken(), + PublicId: masterToken.GetPublicId(), + } + requestContext := context.WithValue(ctx, requests.ContextRequestInformationKey, &requests.RequestContext{}) + ctx = auth.NewVerifierContext(requestContext, iamRepoFn, tokenRepoFn, serversRepoFn, kms, &requestInfo) + + // Start paginating, recursively + req := &pbs.ListAuthTokensRequest{ + ScopeId: "global", + Recursive: true, + Filter: "", + ListToken: "", + PageSize: 2, + } + got, err := a.ListAuthTokens(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 2) + // Compare without comparing the list token + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListAuthTokensResponse{ + Items: allTokens[0:2], + ResponseType: "delta", + SortBy: "created_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: 10, + }, + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListAuthTokensResponse{}, "list_token"), + ), + ) + + // Request second page + req.ListToken = got.ListToken + got, err = a.ListAuthTokens(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 2) + // Compare without comparing the list token + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListAuthTokensResponse{ + Items: allTokens[2:4], + ResponseType: "delta", + SortBy: "created_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: 10, + }, + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListAuthTokensResponse{}, "list_token"), + ), + ) + + // Request rest of results + req.ListToken = got.ListToken + req.PageSize = 10 + got, err = a.ListAuthTokens(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 6) + // Compare without comparing the list token + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListAuthTokensResponse{ + Items: allTokens[4:], + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: 10, + }, + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListAuthTokensResponse{}, "list_token"), + ), + ) + + // Create two auth tokens in lieu of updating + at1, _ := tokenRepo.CreateAuthToken(ctx, u, acct.GetPublicId()) + atp1 := authTokenToProto(at1, &scopes.ScopeInfo{Id: orgWithTokens.GetPublicId(), Type: scope.Org.String(), ParentScopeId: scope.Global.String()}, selfAuthorizedActions) + // Add to the front since it's most recently updated + allTokens = append([]*pb.AuthToken{atp1}, allTokens...) + + at2, _ := tokenRepo.CreateAuthToken(ctx, u, acct.GetPublicId()) + atp2 := authTokenToProto(at2, &scopes.ScopeInfo{Id: orgWithTokens.GetPublicId(), Type: scope.Org.String(), ParentScopeId: scope.Global.String()}, selfAuthorizedActions) + // Add to the front since it's most recently updated + allTokens = append([]*pb.AuthToken{atp2}, allTokens...) + + // Delete one of the other auth tokens + _, err = tokenRepo.DeleteAuthToken(ctx, allTokens[len(allTokens)-1].Id) + require.NoError(t, err) + deletedAuthToken := allTokens[len(allTokens)-1] + allTokens = allTokens[:len(allTokens)-1] + + // Run analyze to update postgres estimates + _, err = sqlDB.ExecContext(ctx, "analyze") + require.NoError(t, err) + + // Request updated results + req.ListToken = got.ListToken + req.PageSize = 1 + got, err = a.ListAuthTokens(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 1) + // Compare without comparing the list token + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListAuthTokensResponse{ + Items: []*pb.AuthToken{allTokens[0]}, + ResponseType: "delta", + SortBy: "updated_time", + SortDir: "desc", + // Should contain the deleted auth token + RemovedIds: []string{deletedAuthToken.Id}, + EstItemCount: 11, + }, + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListAuthTokensResponse{}, "list_token"), + ), + ) + + // Get next page + req.ListToken = got.ListToken + got, err = a.ListAuthTokens(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 1) + // Compare without comparing the list token + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListAuthTokensResponse{ + Items: []*pb.AuthToken{allTokens[1]}, + ResponseType: "complete", + SortBy: "updated_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: 11, + }, + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListAuthTokensResponse{}, "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`, allTokens[len(allTokens)-2].Id, allTokens[len(allTokens)-1].Id) + got, err = a.ListAuthTokens(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 1) + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListAuthTokensResponse{ + Items: []*pb.AuthToken{allTokens[len(allTokens)-2]}, + ResponseType: "delta", + SortBy: "created_time", + SortDir: "desc", + // Should be empty again + RemovedIds: nil, + EstItemCount: 11, + }, + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListAuthTokensResponse{}, "list_token"), + ), + ) + req.ListToken = got.ListToken + // Get the second page + got, err = a.ListAuthTokens(ctx, req) + require.NoError(t, err) + require.Len(t, got.GetItems(), 1) + assert.Empty(t, + cmp.Diff( + got, + &pbs.ListAuthTokensResponse{ + Items: []*pb.AuthToken{allTokens[len(allTokens)-1]}, + ResponseType: "complete", + SortBy: "created_time", + SortDir: "desc", + RemovedIds: nil, + EstItemCount: 11, + }, + protocmp.Transform(), + protocmp.IgnoreFields(&pbs.ListAuthTokensResponse{}, "list_token"), + ), + ) +} + func TestDeleteSelf(t *testing.T) { testCtx := context.Background() conn, _ := db.TestSetup(t, "postgres") @@ -454,7 +790,7 @@ func TestDeleteSelf(t *testing.T) { return server.NewRepository(testCtx, rw, rw, kms) } - a, err := authtokens.NewService(testCtx, tokenRepoFn, iamRepoFn) + a, err := authtokens.NewService(testCtx, tokenRepoFn, iamRepoFn, 1000) require.NoError(t, err, "Couldn't create new auth token service.") o, _ := iam.TestScopes(t, iam.TestRepo(t, conn, wrap)) @@ -544,7 +880,7 @@ func TestDelete(t *testing.T) { org, _ := iam.TestScopes(t, iamRepo) at := authtoken.TestAuthToken(t, conn, kms, org.GetPublicId()) - s, err := authtokens.NewService(ctx, repoFn, iamRepoFn) + s, err := authtokens.NewService(ctx, repoFn, iamRepoFn, 1000) require.NoError(t, err, "Error when getting new user service.") cases := []struct { @@ -609,7 +945,7 @@ func TestDelete_twice(t *testing.T) { org, _ := iam.TestScopes(t, iamRepo) at := authtoken.TestAuthToken(t, conn, kms, org.GetPublicId()) - s, err := authtokens.NewService(ctx, repoFn, iamRepoFn) + s, err := authtokens.NewService(ctx, repoFn, iamRepoFn, 1000) require.NoError(err, "Error when getting new user service") req := &pbs.DeleteAuthTokenRequest{ Id: at.GetPublicId(), diff --git a/internal/db/schema/migrations/oss/postgres/79/04_auth_token_base_table_updates.up.sql b/internal/db/schema/migrations/oss/postgres/79/04_auth_token_base_table_updates.up.sql new file mode 100644 index 0000000000..2d52fef59c --- /dev/null +++ b/internal/db/schema/migrations/oss/postgres/79/04_auth_token_base_table_updates.up.sql @@ -0,0 +1,14 @@ +-- 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_token_create_time_public_id_idx + on auth_token (create_time desc, public_id asc); + create index auth_token_update_time_public_id_idx + on auth_token (update_time desc, public_id asc); + + analyze auth_token; + +commit; \ No newline at end of file diff --git a/internal/db/sqltest/tests/pagination/auth_token.sql b/internal/db/sqltest/tests/pagination/auth_token.sql new file mode 100644 index 0000000000..778e40a84d --- /dev/null +++ b/internal/db/sqltest/tests/pagination/auth_token.sql @@ -0,0 +1,12 @@ +-- Copyright (c) HashiCorp, Inc. +-- SPDX-License-Identifier: BUSL-1.1 + +begin; + select plan(2); + + select has_index('auth_token', 'auth_token_create_time_public_id_idx', array['create_time', 'public_id']); + select has_index('auth_token', 'auth_token_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 8d44f06e16..f7e2b9007d 100644 --- a/internal/gen/controller.swagger.json +++ b/internal/gen/controller.swagger.json @@ -585,6 +585,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": [ @@ -7551,6 +7566,34 @@ "type": "object", "$ref": "#/definitions/controller.api.resources.authtokens.v1.AuthToken" } + }, + "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/authtokens_service.pb.go b/internal/gen/controller/api/services/authtokens_service.pb.go index d150d04b93..6d875d8eb3 100644 --- a/internal/gen/controller/api/services/authtokens_service.pb.go +++ b/internal/gen/controller/api/services/authtokens_service.pb.go @@ -128,6 +128,15 @@ type ListAuthTokensRequest struct { ScopeId string `protobuf:"bytes,1,opt,name=scope_id,proto3" json:"scope_id,omitempty" class:"public"` // @gotags: `class:"public"` Recursive bool `protobuf:"varint,20,opt,name=recursive,proto3" json:"recursive,omitempty" class:"public"` // @gotags: `class:"public"` 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 *ListAuthTokensRequest) Reset() { @@ -183,12 +192,46 @@ func (x *ListAuthTokensRequest) GetFilter() string { return "" } +func (x *ListAuthTokensRequest) GetListToken() string { + if x != nil { + return x.ListToken + } + return "" +} + +func (x *ListAuthTokensRequest) GetPageSize() uint32 { + if x != nil { + return x.PageSize + } + return 0 +} + type ListAuthTokensResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields Items []*authtokens.AuthToken `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 *ListAuthTokensResponse) Reset() { @@ -230,6 +273,48 @@ func (x *ListAuthTokensResponse) GetItems() []*authtokens.AuthToken { return nil } +func (x *ListAuthTokensResponse) GetResponseType() string { + if x != nil { + return x.ResponseType + } + return "" +} + +func (x *ListAuthTokensResponse) GetListToken() string { + if x != nil { + return x.ListToken + } + return "" +} + +func (x *ListAuthTokensResponse) GetSortBy() string { + if x != nil { + return x.SortBy + } + return "" +} + +func (x *ListAuthTokensResponse) GetSortDir() string { + if x != nil { + return x.SortDir + } + return "" +} + +func (x *ListAuthTokensResponse) GetRemovedIds() []string { + if x != nil { + return x.RemovedIds + } + return nil +} + +func (x *ListAuthTokensResponse) GetEstItemCount() uint32 { + if x != nil { + return x.EstItemCount + } + return 0 +} + type DeleteAuthTokenRequest struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -339,65 +424,81 @@ var file_controller_api_services_v1_authtokens_service_proto_rawDesc = []byte{ 0x20, 0x01, 0x28, 0x0b, 0x32, 0x31, 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, 0x61, 0x75, 0x74, 0x68, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, - 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0x69, 0x0a, - 0x15, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x5f, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x5f, - 0x69, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x63, 0x75, 0x72, 0x73, 0x69, 0x76, 0x65, 0x18, - 0x14, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x72, 0x65, 0x63, 0x75, 0x72, 0x73, 0x69, 0x76, 0x65, - 0x12, 0x16, 0x0a, 0x06, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x18, 0x1e, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x22, 0x61, 0x0a, 0x16, 0x4c, 0x69, 0x73, 0x74, + 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x22, 0xa7, 0x01, + 0x0a, 0x15, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x73, 0x63, 0x6f, 0x70, 0x65, + 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x63, 0x6f, 0x70, 0x65, + 0x5f, 0x69, 0x64, 0x12, 0x1c, 0x0a, 0x09, 0x72, 0x65, 0x63, 0x75, 0x72, 0x73, 0x69, 0x76, 0x65, + 0x18, 0x14, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x72, 0x65, 0x63, 0x75, 0x72, 0x73, 0x69, 0x76, + 0x65, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x18, 0x1e, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x12, 0x1e, 0x0a, 0x0a, 0x6c, 0x69, 0x73, + 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x28, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x6c, + 0x69, 0x73, 0x74, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x1c, 0x0a, 0x09, 0x70, 0x61, 0x67, + 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x32, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x70, 0x61, + 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x22, 0xa7, 0x02, 0x0a, 0x16, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x47, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x31, 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, 0x61, 0x75, 0x74, 0x68, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x54, - 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x22, 0x28, 0x0a, 0x16, 0x44, + 0x6f, 0x6b, 0x65, 0x6e, 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, 0x28, 0x0a, 0x16, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x75, 0x74, 0x68, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x19, 0x0a, 0x17, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x19, 0x0a, 0x17, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, - 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x32, 0xac, 0x04, 0x0a, 0x10, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x65, - 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0xb3, 0x01, 0x0a, 0x0c, 0x47, 0x65, 0x74, 0x41, 0x75, 0x74, - 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2f, 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, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 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, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x40, 0x92, 0x41, 0x1b, 0x12, 0x19, - 0x47, 0x65, 0x74, 0x73, 0x20, 0x61, 0x20, 0x73, 0x69, 0x6e, 0x67, 0x6c, 0x65, 0x20, 0x41, 0x75, - 0x74, 0x68, 0x20, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1c, 0x62, - 0x04, 0x69, 0x74, 0x65, 0x6d, 0x12, 0x14, 0x2f, 0x76, 0x31, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x2d, - 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x7d, 0x12, 0xab, 0x01, 0x0a, 0x0e, - 0x4c, 0x69, 0x73, 0x74, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x31, - 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, - 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 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, 0x4c, - 0x69, 0x73, 0x74, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x32, 0x92, 0x41, 0x18, 0x12, 0x16, 0x4c, 0x69, 0x73, 0x74, - 0x73, 0x20, 0x61, 0x6c, 0x6c, 0x20, 0x41, 0x75, 0x74, 0x68, 0x20, 0x54, 0x6f, 0x6b, 0x65, 0x6e, - 0x73, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x11, 0x12, 0x0f, 0x2f, 0x76, 0x31, 0x2f, 0x61, 0x75, - 0x74, 0x68, 0x2d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, 0xb3, 0x01, 0x0a, 0x0f, 0x44, 0x65, - 0x6c, 0x65, 0x74, 0x65, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x32, 0x2e, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xac, 0x04, 0x0a, 0x10, 0x41, 0x75, 0x74, 0x68, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0xb3, 0x01, 0x0a, 0x0c, + 0x47, 0x65, 0x74, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2f, 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, 0x41, 0x75, 0x74, + 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 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, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 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, 0x44, - 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x37, 0x92, 0x41, 0x18, 0x12, 0x16, 0x44, 0x65, 0x6c, - 0x65, 0x74, 0x65, 0x73, 0x20, 0x61, 0x6e, 0x20, 0x41, 0x75, 0x74, 0x68, 0x20, 0x54, 0x6f, 0x6b, - 0x65, 0x6e, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x16, 0x2a, 0x14, 0x2f, 0x76, 0x31, 0x2f, 0x61, - 0x75, 0x74, 0x68, 0x2d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x7d, 0x42, - 0x4d, 0x5a, 0x4b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, - 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x61, 0x72, 0x79, - 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x63, 0x6f, - 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x73, 0x65, 0x72, - 0x76, 0x69, 0x63, 0x65, 0x73, 0x3b, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x62, 0x06, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x75, + 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, + 0x40, 0x92, 0x41, 0x1b, 0x12, 0x19, 0x47, 0x65, 0x74, 0x73, 0x20, 0x61, 0x20, 0x73, 0x69, 0x6e, + 0x67, 0x6c, 0x65, 0x20, 0x41, 0x75, 0x74, 0x68, 0x20, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x2e, 0x82, + 0xd3, 0xe4, 0x93, 0x02, 0x1c, 0x62, 0x04, 0x69, 0x74, 0x65, 0x6d, 0x12, 0x14, 0x2f, 0x76, 0x31, + 0x2f, 0x61, 0x75, 0x74, 0x68, 0x2d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x2f, 0x7b, 0x69, 0x64, + 0x7d, 0x12, 0xab, 0x01, 0x0a, 0x0e, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 0x73, 0x12, 0x31, 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, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 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, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, + 0x65, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x32, 0x92, 0x41, 0x18, + 0x12, 0x16, 0x4c, 0x69, 0x73, 0x74, 0x73, 0x20, 0x61, 0x6c, 0x6c, 0x20, 0x41, 0x75, 0x74, 0x68, + 0x20, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x11, 0x12, 0x0f, + 0x2f, 0x76, 0x31, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x2d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x12, + 0xb3, 0x01, 0x0a, 0x0f, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, + 0x6b, 0x65, 0x6e, 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, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, + 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, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x41, 0x75, 0x74, 0x68, 0x54, + 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x37, 0x92, 0x41, + 0x18, 0x12, 0x16, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x73, 0x20, 0x61, 0x6e, 0x20, 0x41, 0x75, + 0x74, 0x68, 0x20, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x2e, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x16, 0x2a, + 0x14, 0x2f, 0x76, 0x31, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x2d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, + 0x2f, 0x7b, 0x69, 0x64, 0x7d, 0x42, 0x4d, 0x5a, 0x4b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, + 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x62, 0x6f, + 0x75, 0x6e, 0x64, 0x61, 0x72, 0x79, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, + 0x67, 0x65, 0x6e, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2f, 0x61, + 0x70, 0x69, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x73, 0x3b, 0x73, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x73, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( diff --git a/internal/proto/controller/api/services/v1/authtokens_service.proto b/internal/proto/controller/api/services/v1/authtokens_service.proto index 0560cd9e79..1b3ae27192 100644 --- a/internal/proto/controller/api/services/v1/authtokens_service.proto +++ b/internal/proto/controller/api/services/v1/authtokens_service.proto @@ -52,10 +52,39 @@ message ListAuthTokensRequest { string scope_id = 1 [json_name = "scope_id"]; // @gotags: `class:"public"` bool recursive = 20 [json_name = "recursive"]; // @gotags: `class:"public"` 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 ListAuthTokensResponse { repeated resources.authtokens.v1.AuthToken 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 DeleteAuthTokenRequest {