diff --git a/internal/credential/query.go b/internal/credential/query.go new file mode 100644 index 000000000..9aa590e9b --- /dev/null +++ b/internal/credential/query.go @@ -0,0 +1,363 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package credential + +const ( + estimateCountStoresQuery = ` +select sum(reltuples::bigint) as estimate from pg_class where oid in ( + 'credential_vault_store'::regclass, + 'credential_static_store'::regclass +) +` + + listDeletedIdsQuery = ` +select public_id + from credential_vault_store_deleted + where delete_time >= @since + union +select public_id + from credential_static_store_deleted + where delete_time >= @since +` + + listStoresTemplate = ` +with stores as ( + select public_id + from credential_store + where %s -- search condition for project IDs is constructed + order by create_time desc, public_id asc + limit %d +), +vault_stores as ( + select * + from credential_vault_store + where public_id in (select public_id from stores) + and delete_time is null +), +vault_tokens as ( + select store_id, + token_hmac, + status + from credential_vault_token + where store_id in (select public_id from stores) + and status = 'current' +), +vault_client_certs as ( + select store_id, + certificate, + certificate_key_hmac + from credential_vault_client_certificate + where store_id in (select public_id from stores) +), +static_stores as ( + select * + from credential_static_store + where public_id in (select public_id from stores) +), +final as ( + select store.public_id, + store.project_id, + store.name, + store.description, + store.create_time, + store.update_time, + store.version, + store.delete_time, + store.vault_address, + store.namespace, + store.ca_cert, + store.tls_server_name, + store.tls_skip_verify, + store.worker_filter, + token.token_hmac as token_hmac, + coalesce(token.status, 'expired') as token_status, + cert.certificate as client_cert, + cert.certificate_key_hmac as client_cert_key_hmac, + 'vault' as subtype + from vault_stores store + left join vault_tokens token on store.public_id = token.store_id + left join vault_client_certs cert on store.public_id = cert.store_id + union + select public_id, + project_id, + name, + description, + create_time, + update_time, + version, + null as delete_time, -- Add to make union uniform + null as vault_address, -- Add to make union uniform + null as namespace, -- Add to make union uniform + null as ca_cert, -- Add to make union uniform + null as tls_server_name, -- Add to make union uniform + null as tls_skip_verify, -- Add to make union uniform + null as worker_filter, -- Add to make union uniform + null as token_hmac, -- Add to make union uniform + null as token_status, -- Add to make union uniform + null as client_cert, -- Add to make union uniform + null as client_cert_key_hmac, -- Add to make union uniform + 'static' as subtype + from static_stores +) + select * + from final +order by create_time desc, public_id asc; +` + + listStoresPageTemplate = ` +with stores as ( + select public_id + from credential_store + where (create_time, public_id) < (@last_item_create_time, @last_item_id) and + %s -- search condition for project IDs is constructed + order by create_time desc, public_id asc + limit %d +), +vault_stores as ( + select * + from credential_vault_store + where public_id in (select public_id from stores) + and delete_time is null +), +vault_tokens as ( + select store_id, + token_hmac, + status + from credential_vault_token + where store_id in (select public_id from stores) + and status = 'current' +), +vault_client_certs as ( + select store_id, + certificate, + certificate_key_hmac + from credential_vault_client_certificate + where store_id in (select public_id from stores) +), +static_stores as ( + select * + from credential_static_store + where public_id in (select public_id from stores) +), +final as ( + select store.public_id, + store.project_id, + store.name, + store.description, + store.create_time, + store.update_time, + store.version, + store.delete_time, + store.vault_address, + store.namespace, + store.ca_cert, + store.tls_server_name, + store.tls_skip_verify, + store.worker_filter, + token.token_hmac as token_hmac, + coalesce(token.status, 'expired') as token_status, + cert.certificate as client_cert, + cert.certificate_key_hmac as client_cert_key_hmac, + 'vault' as subtype + from vault_stores store + left join vault_tokens token on store.public_id = token.store_id + left join vault_client_certs cert on store.public_id = cert.store_id + union + select public_id, + project_id, + name, + description, + create_time, + update_time, + version, + null as delete_time, -- Add to make union uniform + null as vault_address, -- Add to make union uniform + null as namespace, -- Add to make union uniform + null as ca_cert, -- Add to make union uniform + null as tls_server_name, -- Add to make union uniform + null as tls_skip_verify, -- Add to make union uniform + null as worker_filter, -- Add to make union uniform + null as token_hmac, -- Add to make union uniform + null as token_status, -- Add to make union uniform + null as client_cert, -- Add to make union uniform + null as client_cert_key_hmac, -- Add to make union uniform + 'static' as subtype + from static_stores +) + select * + from final +order by create_time desc, public_id asc; +` + + listStoresRefreshTemplate = ` +with stores as ( + select public_id + from credential_store + where update_time > @updated_after_time and + %s -- search condition for project IDs is constructed + order by update_time desc, public_id asc + limit %d +), +vault_stores as ( + select * + from credential_vault_store + where public_id in (select public_id from stores) + and delete_time is null +), +vault_tokens as ( + select store_id, + token_hmac, + status + from credential_vault_token + where store_id in (select public_id from stores) + and status = 'current' +), +vault_client_certs as ( + select store_id, + certificate, + certificate_key_hmac + from credential_vault_client_certificate + where store_id in (select public_id from stores) +), +static_stores as ( + select * + from credential_static_store + where public_id in (select public_id from stores) +), +final as ( + select store.public_id, + store.project_id, + store.name, + store.description, + store.create_time, + store.update_time, + store.version, + store.delete_time, + store.vault_address, + store.namespace, + store.ca_cert, + store.tls_server_name, + store.tls_skip_verify, + store.worker_filter, + token.token_hmac as token_hmac, + coalesce(token.status, 'expired') as token_status, + cert.certificate as client_cert, + cert.certificate_key_hmac as client_cert_key_hmac, + 'vault' as subtype + from vault_stores store + left join vault_tokens token on store.public_id = token.store_id + left join vault_client_certs cert on store.public_id = cert.store_id + union + select public_id, + project_id, + name, + description, + create_time, + update_time, + version, + null as delete_time, -- Add to make union uniform + null as vault_address, -- Add to make union uniform + null as namespace, -- Add to make union uniform + null as ca_cert, -- Add to make union uniform + null as tls_server_name, -- Add to make union uniform + null as tls_skip_verify, -- Add to make union uniform + null as worker_filter, -- Add to make union uniform + null as token_hmac, -- Add to make union uniform + null as token_status, -- Add to make union uniform + null as client_cert, -- Add to make union uniform + null as client_cert_key_hmac, -- Add to make union uniform + 'static' as subtype + from static_stores +) + select * + from final +order by update_time desc, public_id asc; +` + + listStoresRefreshPageTemplate = ` +with stores as ( + select public_id + from credential_store + where update_time > @updated_after_time and + (update_time, public_id) < (@last_item_update_time, @last_item_id) and + %s -- search condition for project IDs is constructed + order by update_time desc, public_id asc + limit %d +), +vault_stores as ( + select * + from credential_vault_store + where public_id in (select public_id from stores) + and delete_time is null +), +vault_tokens as ( + select store_id, + token_hmac, + status + from credential_vault_token + where store_id in (select public_id from stores) + and status = 'current' +), +vault_client_certs as ( + select store_id, + certificate, + certificate_key_hmac + from credential_vault_client_certificate + where store_id in (select public_id from stores) +), +static_stores as ( + select * + from credential_static_store + where public_id in (select public_id from stores) +), +final as ( + select store.public_id, + store.project_id, + store.name, + store.description, + store.create_time, + store.update_time, + store.version, + store.delete_time, + store.vault_address, + store.namespace, + store.ca_cert, + store.tls_server_name, + store.tls_skip_verify, + store.worker_filter, + token.token_hmac as token_hmac, + coalesce(token.status, 'expired') as token_status, + cert.certificate as client_cert, + cert.certificate_key_hmac as client_cert_key_hmac, + 'vault' as subtype + from vault_stores store + left join vault_tokens token on store.public_id = token.store_id + left join vault_client_certs cert on store.public_id = cert.store_id + union + select public_id, + project_id, + name, + description, + create_time, + update_time, + version, + null as delete_time, -- Add to make union uniform + null as vault_address, -- Add to make union uniform + null as namespace, -- Add to make union uniform + null as ca_cert, -- Add to make union uniform + null as tls_server_name, -- Add to make union uniform + null as tls_skip_verify, -- Add to make union uniform + null as worker_filter, -- Add to make union uniform + null as token_hmac, -- Add to make union uniform + null as token_status, -- Add to make union uniform + null as client_cert, -- Add to make union uniform + null as client_cert_key_hmac, -- Add to make union uniform + 'static' as subtype + from static_stores +) + select * + from final +order by update_time desc, public_id asc; +` +) diff --git a/internal/credential/registry.go b/internal/credential/registry.go new file mode 100644 index 000000000..66c0c9aab --- /dev/null +++ b/internal/credential/registry.go @@ -0,0 +1,68 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package credential + +import ( + "context" + "fmt" + "sync" + + "github.com/hashicorp/boundary/globals" +) + +// newStoreFunc is a function that creates a Store from the provided result. +type newStoreFunc func(context.Context, *StoreListQueryResult) (Store, error) + +// StoreSubtypeHooks defines the interface expected to be implemented +// by credential store subtype hooks. +type StoreSubtypeHooks interface { + NewStore(context.Context, *StoreListQueryResult) (Store, error) +} + +type registry struct { + m map[globals.Subtype]StoreSubtypeHooks + + sync.RWMutex +} + +func (r *registry) set(s globals.Subtype, subtype StoreSubtypeHooks) { + r.Lock() + defer r.Unlock() + + _, previouslySet := r.m[s] + if previouslySet { + panic(fmt.Sprintf("credential store subtype %s already registered", s)) + } + + r.m[s] = subtype +} + +func (r *registry) get(s globals.Subtype) (StoreSubtypeHooks, bool) { + r.RLock() + defer r.RUnlock() + + subtype, ok := r.m[s] + if ok { + return subtype, ok + } + return nil, ok +} + +func (r *registry) newFunc(s globals.Subtype) (newStoreFunc, bool) { + subtype, ok := r.get(s) + if !ok { + return nil, ok + } + return subtype.NewStore, ok +} + +var subtypeRegistry = registry{ + m: make(map[globals.Subtype]StoreSubtypeHooks), +} + +// RegisterStoreSubtype registers repository hooks for a provided store sub type. +// RegisterStoreSubtype panics if the subtype has already been registered. +func RegisterStoreSubtype(s globals.Subtype, hooks StoreSubtypeHooks) { + subtypeRegistry.set(s, hooks) +} diff --git a/internal/credential/repository_store.go b/internal/credential/repository_store.go new file mode 100644 index 000000000..0ef3c2e64 --- /dev/null +++ b/internal/credential/repository_store.go @@ -0,0 +1,186 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package credential + +import ( + "context" + "database/sql" + "fmt" + "strconv" + "strings" + "time" + + "github.com/hashicorp/boundary/internal/db" + "github.com/hashicorp/boundary/internal/db/timestamp" + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/pagination" + "github.com/hashicorp/boundary/internal/util" +) + +// StoreRepository coordinates calls across different subtype services +// to gather information about all credential stores. +type StoreRepository struct { + reader db.Reader + writer db.Writer +} + +// NewStoreRepository returns a new credential store repository. +func NewStoreRepository(ctx context.Context, reader db.Reader, writer db.Writer) (*StoreRepository, error) { + const op = "credential.NewStoreRepository" + switch { + case util.IsNil(reader): + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing DB reader") + case util.IsNil(writer): + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing DB writer") + } + return &StoreRepository{ + reader: reader, + writer: writer, + }, nil +} + +// List lists credential stores across all subtypes. +func (s *StoreRepository) List(ctx context.Context, projectIds []string, afterItem pagination.Item, limit int) ([]Store, time.Time, error) { + const op = "credential.(*StoreRepository).list" + switch { + case len(projectIds) == 0: + return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "missing project ids") + case limit < 1: + return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "missing limit") + } + + var inClauses []string + var args []any + for i, projectId := range projectIds { + arg := "project_id_" + strconv.Itoa(i) + inClauses = append(inClauses, "@"+arg) + args = append(args, sql.Named(arg, projectId)) + } + inClause := strings.Join(inClauses, ", ") + whereClause := "project_id in (" + inClause + ")" + + query := fmt.Sprintf(listStoresTemplate, whereClause, limit) + if afterItem != nil { + query = fmt.Sprintf(listStoresPageTemplate, whereClause, limit) + args = append(args, + sql.Named("last_item_create_time", afterItem.GetCreateTime()), + sql.Named("last_item_id", afterItem.GetPublicId()), + ) + } + + return s.queryStores(ctx, query, args) +} + +// ListRefresh lists credential stores across all subtypes. +func (s *StoreRepository) ListRefresh(ctx context.Context, projectIds []string, updatedAfter time.Time, afterItem pagination.Item, limit int) ([]Store, time.Time, error) { + const op = "credential.(*StoreRepository).list" + switch { + case len(projectIds) == 0: + return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "missing project ids") + case updatedAfter.IsZero(): + return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "missing updated after time") + case limit < 1: + return nil, time.Time{}, errors.New(ctx, errors.InvalidParameter, op, "missing limit") + } + + var inClauses []string + var args []any + for i, projectId := range projectIds { + arg := "project_id_" + strconv.Itoa(i) + inClauses = append(inClauses, "@"+arg) + args = append(args, sql.Named(arg, projectId)) + } + inClause := strings.Join(inClauses, ", ") + whereClause := "project_id in (" + inClause + ")" + + query := fmt.Sprintf(listStoresRefreshTemplate, whereClause, limit) + args = append(args, + sql.Named("updated_after_time", timestamp.New(updatedAfter)), + ) + if afterItem != nil { + query = fmt.Sprintf(listStoresRefreshPageTemplate, whereClause, limit) + args = append(args, + sql.Named("last_item_update_time", afterItem.GetUpdateTime()), + sql.Named("last_item_id", afterItem.GetPublicId()), + ) + } + + return s.queryStores(ctx, query, args) +} + +// EstimatedCount estimates the total number of credential stores. +func (s *StoreRepository) EstimatedCount(ctx context.Context) (int, error) { + const op = "credential.(*StoreRepository).EstimatedCount" + rows, err := s.reader.Query(ctx, estimateCountStoresQuery, nil) + if err != nil { + return 0, errors.Wrap(ctx, err, op, errors.WithMsg("failed to query total credential stores")) + } + var count int + for rows.Next() { + if err := s.reader.ScanRows(ctx, rows, &count); err != nil { + return 0, errors.Wrap(ctx, err, op, errors.WithMsg("failed to query total credential stores")) + } + } + return count, nil +} + +// ListDeletedIds lists the deleted IDs of all credential stores since the time specified. +func (s *StoreRepository) ListDeletedIds(ctx context.Context, since time.Time) ([]string, time.Time, error) { + const op = "credential.(*StoreRepository).ListDeletedIds" + var deletedStoreIDs []string + var transactionTimestamp time.Time + if _, err := s.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(r db.Reader, w db.Writer) error { + rows, err := s.writer.Query(ctx, listDeletedIdsQuery, []any{sql.Named("since", since)}) + if err != nil { + return errors.Wrap(ctx, err, op) + } + defer rows.Close() + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return errors.Wrap(ctx, err, op) + } + deletedStoreIDs = append(deletedStoreIDs, id) + } + transactionTimestamp, err = r.Now(ctx) + return err + }); err != nil { + return nil, time.Time{}, err + } + return deletedStoreIDs, transactionTimestamp, nil +} + +func (s *StoreRepository) queryStores(ctx context.Context, query string, args []any) ([]Store, time.Time, error) { + const op = "credential.(*StoreRepository).queryStores" + + var stores []Store + var transactionTimestamp time.Time + if _, err := s.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(r db.Reader, w db.Writer) error { + rows, err := r.Query(ctx, query, args) + if err != nil { + return err + } + defer rows.Close() + var foundStores []*StoreListQueryResult + for rows.Next() { + if err := r.ScanRows(ctx, rows, &foundStores); err != nil { + return err + } + } + + for _, s := range foundStores { + store, err := s.toStore(ctx) + if err != nil { + return err + } + stores = append(stores, store) + } + transactionTimestamp, err = r.Now(ctx) + return err + }); err != nil { + return nil, time.Time{}, errors.Wrap(ctx, err, op) + } + + return stores, transactionTimestamp, nil +} diff --git a/internal/credential/repository_store_test.go b/internal/credential/repository_store_test.go new file mode 100644 index 000000000..007aac2e1 --- /dev/null +++ b/internal/credential/repository_store_test.go @@ -0,0 +1,280 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package credential_test + +import ( + "context" + "slices" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/boundary/internal/credential" + "github.com/hashicorp/boundary/internal/credential/static" + sstore "github.com/hashicorp/boundary/internal/credential/static/store" + "github.com/hashicorp/boundary/internal/credential/vault" + vstore "github.com/hashicorp/boundary/internal/credential/vault/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/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestStoreRepository_List(t *testing.T) { + t.Parallel() + assert, require := assert.New(t), require.New(t) + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + _, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + + stores := []credential.Store{ + vault.TestCredentialStore(t, conn, wrapper, prj.GetPublicId(), "http://some-addr", "some-token", "some-accessor"), + static.TestCredentialStore(t, conn, wrapper, prj.GetPublicId()), + vault.TestCredentialStore(t, conn, wrapper, prj.GetPublicId(), "http://some-addr", "some-token2", "some-accessor"), + static.TestCredentialStore(t, conn, wrapper, prj.GetPublicId()), + static.TestCredentialStore(t, conn, wrapper, prj.GetPublicId()), + } + + // since we sort descending, we need to reverse the slice + slices.Reverse(stores) + + repo, err := credential.NewStoreRepository(ctx, rw, rw) + require.NoError(err) + + cmpOpts := []cmp.Option{ + cmpopts.IgnoreUnexported( + vault.CredentialStore{}, + vstore.CredentialStore{}, + static.CredentialStore{}, + sstore.CredentialStore{}, + timestamp.Timestamp{}, + timestamppb.Timestamp{}, + ), + } + + t.Run("validation", func(t *testing.T) { + t.Parallel() + t.Run("missing project ids", func(t *testing.T) { + t.Parallel() + _, _, err := repo.List(ctx, nil, nil, 1) + require.ErrorContains(err, "missing project ids") + }) + t.Run("invalid limit", func(t *testing.T) { + t.Parallel() + _, _, err := repo.List(ctx, []string{prj.PublicId}, nil, 0) + require.ErrorContains(err, "missing limit") + }) + }) + + t.Run("success-without-after-item", func(t *testing.T) { + t.Parallel() + resp, ttime, err := repo.List(ctx, []string{prj.PublicId}, nil, 10) + require.NoError(err) + // Transaction timestamp should be within ~10 seconds of now + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + require.Empty(cmp.Diff(resp, stores, cmpOpts...)) + }) + t.Run("success-with-after-item", func(t *testing.T) { + t.Parallel() + resp, ttime, err := repo.List(ctx, []string{prj.PublicId}, stores[0], 10) + require.NoError(err) + // Transaction timestamp should be within ~10 seconds of now + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + require.Empty(cmp.Diff(resp, stores[1:], cmpOpts...)) + }) +} + +func TestStoreRepository_ListRefresh(t *testing.T) { + t.Parallel() + assert, require := assert.New(t), require.New(t) + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + _, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + fiveDaysAgo := time.Now().AddDate(0, 0, -5) + + stores := []credential.Store{ + vault.TestCredentialStore(t, conn, wrapper, prj.GetPublicId(), "http://some-addr", "some-token", "some-accessor"), + static.TestCredentialStore(t, conn, wrapper, prj.GetPublicId()), + vault.TestCredentialStore(t, conn, wrapper, prj.GetPublicId(), "http://some-addr", "some-token2", "some-accessor"), + static.TestCredentialStore(t, conn, wrapper, prj.GetPublicId()), + static.TestCredentialStore(t, conn, wrapper, prj.GetPublicId()), + } + + // since we sort descending, we need to reverse the slice + slices.Reverse(stores) + + repo, err := credential.NewStoreRepository(ctx, rw, rw) + require.NoError(err) + + cmpOpts := []cmp.Option{ + cmpopts.IgnoreUnexported( + vault.CredentialStore{}, + vstore.CredentialStore{}, + static.CredentialStore{}, + sstore.CredentialStore{}, + timestamp.Timestamp{}, + timestamppb.Timestamp{}, + ), + } + + t.Run("validation", func(t *testing.T) { + t.Parallel() + t.Run("missing updated after", func(t *testing.T) { + t.Parallel() + _, _, err := repo.ListRefresh(ctx, []string{prj.PublicId}, time.Time{}, nil, 1) + require.ErrorContains(err, "missing updated after time") + }) + t.Run("missing project ids", func(t *testing.T) { + t.Parallel() + _, _, err := repo.ListRefresh(ctx, nil, fiveDaysAgo, nil, 1) + require.ErrorContains(err, "missing project ids") + }) + t.Run("invalid limit", func(t *testing.T) { + t.Parallel() + _, _, err := repo.ListRefresh(ctx, []string{prj.PublicId}, fiveDaysAgo, nil, 0) + require.ErrorContains(err, "missing limit") + }) + }) + + t.Run("success-without-after-item", func(t *testing.T) { + t.Parallel() + resp, ttime, err := repo.ListRefresh(ctx, []string{prj.PublicId}, fiveDaysAgo, nil, 10) + require.NoError(err) + // Transaction timestamp should be within ~10 seconds of now + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + require.Empty(cmp.Diff(resp, stores, cmpOpts...)) + }) + t.Run("success-with-after-item", func(t *testing.T) { + t.Parallel() + resp, ttime, err := repo.ListRefresh(ctx, []string{prj.PublicId}, fiveDaysAgo, stores[0], 10) + require.NoError(err) + // Transaction timestamp should be within ~10 seconds of now + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + require.Empty(cmp.Diff(resp, stores[1:], cmpOpts...)) + }) + t.Run("success-without-after-item-recent-updated-after", func(t *testing.T) { + t.Parallel() + resp, ttime, err := repo.ListRefresh(ctx, []string{prj.PublicId}, stores[len(stores)-1].GetUpdateTime().AsTime(), nil, 10) + require.NoError(err) + // Transaction timestamp should be within ~10 seconds of now + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + require.Empty(cmp.Diff(resp, stores[:len(stores)-1], cmpOpts...)) + }) + t.Run("success-with-after-item-recent-updated-after", func(t *testing.T) { + t.Parallel() + resp, ttime, err := repo.ListRefresh(ctx, []string{prj.PublicId}, stores[len(stores)-1].GetUpdateTime().AsTime(), stores[0], 10) + require.NoError(err) + // Transaction timestamp should be within ~10 seconds of now + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) + require.Empty(cmp.Diff(resp, stores[1:len(stores)-1], cmpOpts...)) + }) +} + +func TestStoreRepository_EstimatedCount(t *testing.T) { + t.Parallel() + assert, require := assert.New(t), require.New(t) + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + sqlDb, err := conn.SqlDB(ctx) + require.NoError(err) + rw := db.New(conn) + wrapper := db.TestWrapper(t) + kmsCache := kms.TestKms(t, conn, wrapper) + staticRepo, err := static.NewRepository(ctx, rw, rw, kmsCache) + require.NoError(err) + _, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + + repo, err := credential.NewStoreRepository(ctx, rw, rw) + require.NoError(err) + + // Check total entries at start, expect 0 + numItems, err := repo.EstimatedCount(ctx) + require.NoError(err) + assert.Equal(0, numItems) + + // Create some stores + staticStores := static.TestCredentialStores(t, conn, wrapper, prj.PublicId, 2) + _ = vault.TestCredentialStores(t, conn, wrapper, prj.PublicId, 2) + // Run analyze to update postgres meta tables + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(err) + + numItems, err = repo.EstimatedCount(ctx) + require.NoError(err) + assert.Equal(4, numItems) + + // Delete a store + _, err = staticRepo.DeleteCredentialStore(ctx, staticStores[0].GetPublicId()) + require.NoError(err) + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(err) + + numItems, err = repo.EstimatedCount(ctx) + require.NoError(err) + assert.Equal(3, numItems) +} + +func TestRepository_ListDeletedStoreIds(t *testing.T) { + t.Parallel() + require, assert := require.New(t), assert.New(t) + ctx := context.Background() + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + kms := kms.TestKms(t, conn, wrapper) + _, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + // Note: we're not testing vault credential stores, because they're deleted + // asynchronously. + staticStore := static.TestCredentialStores(t, conn, wrapper, prj.GetPublicId(), 1)[0] + + staticRepo, err := static.NewRepository(ctx, rw, rw, kms) + require.NoError(err) + repo, err := credential.NewStoreRepository(ctx, rw, rw) + require.NoError(err) + + // Expect no entries at the start + deletedIds, ttime, err := repo.ListDeletedIds(ctx, time.Now().AddDate(-1, 0, 0)) + require.NoError(err) + require.Empty(deletedIds) + // 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))) + + _, err = staticRepo.DeleteCredentialStore(ctx, staticStore.GetPublicId()) + require.NoError(err) + + // Expect one entry + deletedIds, ttime, err = repo.ListDeletedIds(ctx, time.Now().AddDate(-1, 0, 0)) + require.NoError(err) + assert.Empty( + cmp.Diff( + []string{staticStore.GetPublicId()}, + deletedIds, + cmpopts.SortSlices(func(i, j string) bool { return i < j }), + ), + ) + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(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(err) + require.Empty(deletedIds) + assert.True(time.Now().Before(ttime.Add(10 * time.Second))) + assert.True(time.Now().After(ttime.Add(-10 * time.Second))) +} diff --git a/internal/credential/service_list_stores.go b/internal/credential/service_list_stores.go new file mode 100644 index 000000000..52c5397c6 --- /dev/null +++ b/internal/credential/service_list_stores.go @@ -0,0 +1,47 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package credential + +import ( + "context" + "time" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/pagination" +) + +// ListStores lists up to page size credential stores, filtering out entries that +// do not pass the filter item function. It will automatically request +// more credential stores from the database, at page size chunks, to fill the page. +// It returns a new list token used to continue pagination or refresh items. +// Credential stores are ordered by create time descending (most recently created first). +func ListStores( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[Store], + repo *StoreRepository, + projectIds []string, +) (*pagination.ListResponse[Store], error) { + const op = "credential.ListStores" + + 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 projectIds == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing project ids") + case repo == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing repo") + } + + listItemsFn := func(ctx context.Context, lastPageItem Store, limit int) ([]Store, time.Time, error) { + return repo.List(ctx, projectIds, lastPageItem, limit) + } + + return pagination.List(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.EstimatedCount) +} diff --git a/internal/credential/service_list_stores_ext_test.go b/internal/credential/service_list_stores_ext_test.go new file mode 100644 index 000000000..c5b080170 --- /dev/null +++ b/internal/credential/service_list_stores_ext_test.go @@ -0,0 +1,625 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package credential_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/credential" + "github.com/hashicorp/boundary/internal/credential/static" + sstore "github.com/hashicorp/boundary/internal/credential/static/store" + "github.com/hashicorp/boundary/internal/credential/vault" + vstore "github.com/hashicorp/boundary/internal/credential/vault/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" +) + +type fakeReader struct { + db.Reader +} +type fakeWriter struct { + db.Writer +} + +func TestNewStoreService(t *testing.T) { + t.Parallel() + ctx := context.Background() + t.Run("success", func(t *testing.T) { + t.Parallel() + got, err := credential.NewStoreRepository(ctx, &fakeReader{}, &fakeWriter{}) + require.NoError(t, err) + require.NotNil(t, got) + }) + t.Run("nil-reader", func(t *testing.T) { + t.Parallel() + _, err := credential.NewStoreRepository(ctx, nil, &fakeWriter{}) + require.Error(t, err) + }) + t.Run("nil-interface-reader", func(t *testing.T) { + t.Parallel() + _, err := credential.NewStoreRepository(ctx, (*fakeReader)(nil), &fakeWriter{}) + require.Error(t, err) + }) + t.Run("nil-writer", func(t *testing.T) { + t.Parallel() + _, err := credential.NewStoreRepository(ctx, &fakeReader{}, nil) + require.Error(t, err) + }) + t.Run("nil-interface-writer", func(t *testing.T) { + t.Parallel() + _, err := credential.NewStoreRepository(ctx, &fakeReader{}, (*fakeWriter)(nil)) + require.Error(t, err) + }) +} + +func TestStoreService_List(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) + wrapper := db.TestWrapper(t) + kms := kms.TestKms(t, conn, wrapper) + _, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper)) + fiveDaysAgo := time.Now().AddDate(0, 0, -5) + stores := []credential.Store{ + vault.TestCredentialStore(t, conn, wrapper, prj.PublicId, "http://some-addr", "some-token", "some-accessor"), + static.TestCredentialStore(t, conn, wrapper, prj.PublicId), + vault.TestCredentialStore(t, conn, wrapper, prj.PublicId, "http://some-addr", "some-token2", "some-accessor"), + static.TestCredentialStore(t, conn, wrapper, prj.PublicId), + static.TestCredentialStore(t, conn, wrapper, prj.PublicId), + } + + // since we sort descending, we need to reverse the slice + slices.Reverse(stores) + + staticRepo, err := static.NewRepository(ctx, rw, rw, kms) + require.NoError(t, err) + repo, err := credential.NewStoreRepository(ctx, rw, rw) + require.NoError(t, err) + + // Run analyze to update count estimate + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + + cmpOpts := []cmp.Option{ + cmpopts.IgnoreUnexported( + vault.CredentialStore{}, + vstore.CredentialStore{}, + static.CredentialStore{}, + sstore.CredentialStore{}, + timestamp.Timestamp{}, + timestamppb.Timestamp{}, + ), + } + + t.Run("List validation", func(t *testing.T) { + t.Parallel() + t.Run("missing grants hash", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, s credential.Store) (bool, error) { + return true, nil + } + _, err := credential.ListStores(ctx, nil, 1, filterFunc, repo, []string{prj.PublicId}) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, s credential.Store) (bool, error) { + return true, nil + } + _, err := credential.ListStores(ctx, []byte("some hash"), 0, filterFunc, repo, []string{prj.PublicId}) + 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, s credential.Store) (bool, error) { + return true, nil + } + _, err := credential.ListStores(ctx, []byte("some hash"), -1, filterFunc, repo, []string{prj.PublicId}) + require.ErrorContains(t, err, "page size must be at least 1") + }) + t.Run("nil filter func", func(t *testing.T) { + t.Parallel() + _, err := credential.ListStores(ctx, []byte("some hash"), 1, nil, repo, []string{prj.PublicId}) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("missing repo", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, s credential.Store) (bool, error) { + return true, nil + } + _, err := credential.ListStores(ctx, []byte("some hash"), 1, filterFunc, nil, []string{prj.PublicId}) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing public Ids", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, s credential.Store) (bool, error) { + return true, nil + } + _, err := credential.ListStores(ctx, []byte("some hash"), 1, filterFunc, repo, nil) + require.ErrorContains(t, err, "missing project 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, s credential.Store) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.CredentialLibrary, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresPage(ctx, nil, 1, filterFunc, tok, repo, []string{prj.PublicId}) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, s credential.Store) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.CredentialLibrary, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresPage(ctx, []byte("some hash"), 0, filterFunc, tok, repo, []string{prj.PublicId}) + 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, s credential.Store) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.CredentialLibrary, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresPage(ctx, []byte("some hash"), -1, filterFunc, tok, repo, []string{prj.PublicId}) + 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.CredentialLibrary, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresPage(ctx, []byte("some hash"), 1, nil, tok, repo, []string{prj.PublicId}) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("nil token", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, s credential.Store) (bool, error) { + return true, nil + } + _, err = credential.ListStoresPage(ctx, []byte("some hash"), 1, filterFunc, nil, repo, []string{prj.PublicId}) + require.ErrorContains(t, err, "missing token") + }) + t.Run("wrong token type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, s credential.Store) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.CredentialLibrary, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, []string{prj.PublicId}) + require.ErrorContains(t, err, "token did not have a pagination token component") + }) + t.Run("missing repo", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, s credential.Store) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.CredentialLibrary, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresPage(ctx, []byte("some hash"), 1, filterFunc, tok, nil, []string{prj.PublicId}) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing project ids", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, s credential.Store) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.CredentialLibrary, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, nil) + require.ErrorContains(t, err, "missing project 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, s credential.Store) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.CredentialLibrary, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresRefresh(ctx, nil, 1, filterFunc, tok, repo, []string{prj.PublicId}) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, s credential.Store) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.CredentialLibrary, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresRefresh(ctx, []byte("some hash"), 0, filterFunc, tok, repo, []string{prj.PublicId}) + 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, s credential.Store) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.CredentialLibrary, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresRefresh(ctx, []byte("some hash"), -1, filterFunc, tok, repo, []string{prj.PublicId}) + 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.CredentialLibrary, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresRefresh(ctx, []byte("some hash"), 1, nil, tok, repo, []string{prj.PublicId}) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("nil token", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, s credential.Store) (bool, error) { + return true, nil + } + _, err = credential.ListStoresRefresh(ctx, []byte("some hash"), 1, filterFunc, nil, repo, []string{prj.PublicId}) + require.ErrorContains(t, err, "missing token") + }) + t.Run("missing repo", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, s credential.Store) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.CredentialLibrary, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresRefresh(ctx, []byte("some hash"), 1, filterFunc, tok, nil, []string{prj.PublicId}) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing project ids", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, s credential.Store) (bool, error) { + return true, nil + } + tok, err := listtoken.NewStartRefresh(ctx, fiveDaysAgo, resource.CredentialLibrary, []byte("some hash"), fiveDaysAgo, fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresRefresh(ctx, []byte("some hash"), 1, filterFunc, tok, repo, nil) + require.ErrorContains(t, err, "missing project 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, s credential.Store) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.CredentialLibrary, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresRefreshPage(ctx, nil, 1, filterFunc, tok, repo, []string{prj.PublicId}) + require.ErrorContains(t, err, "missing grants hash") + }) + t.Run("zero page size", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, s credential.Store) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.CredentialLibrary, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresRefreshPage(ctx, []byte("some hash"), 0, filterFunc, tok, repo, []string{prj.PublicId}) + 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, s credential.Store) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.CredentialLibrary, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresRefreshPage(ctx, []byte("some hash"), -1, filterFunc, tok, repo, []string{prj.PublicId}) + 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.CredentialLibrary, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresRefreshPage(ctx, []byte("some hash"), 1, nil, tok, repo, []string{prj.PublicId}) + require.ErrorContains(t, err, "missing filter item callback") + }) + t.Run("nil token", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, s credential.Store) (bool, error) { + return true, nil + } + _, err = credential.ListStoresRefreshPage(ctx, []byte("some hash"), 1, filterFunc, nil, repo, []string{prj.PublicId}) + require.ErrorContains(t, err, "missing token") + }) + t.Run("wrong token type", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, s credential.Store) (bool, error) { + return true, nil + } + tok, err := listtoken.NewPagination(ctx, fiveDaysAgo, resource.CredentialLibrary, []byte("some hash"), "some-id", fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresRefreshPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, []string{prj.PublicId}) + require.ErrorContains(t, err, "token did not have a refresh token component") + }) + t.Run("missing repo", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, s credential.Store) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.CredentialLibrary, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresRefreshPage(ctx, []byte("some hash"), 1, filterFunc, tok, nil, []string{prj.PublicId}) + require.ErrorContains(t, err, "missing repo") + }) + t.Run("missing project ids", func(t *testing.T) { + t.Parallel() + filterFunc := func(_ context.Context, s credential.Store) (bool, error) { + return true, nil + } + tok, err := listtoken.NewRefresh(ctx, fiveDaysAgo, resource.CredentialLibrary, []byte("some hash"), fiveDaysAgo, fiveDaysAgo, fiveDaysAgo, "some other id", fiveDaysAgo) + require.NoError(t, err) + _, err = credential.ListStoresRefreshPage(ctx, []byte("some hash"), 1, filterFunc, tok, repo, nil) + require.ErrorContains(t, err, "missing project ids") + }) + }) + + t.Run("simple pagination", func(t *testing.T) { + filterFunc := func(context.Context, credential.Store) (bool, error) { + return true, nil + } + resp, err := credential.ListStores(ctx, []byte("some hash"), 1, filterFunc, repo, []string{prj.PublicId}) + 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], stores[0], cmpOpts...)) + + resp2, err := credential.ListStoresPage(ctx, []byte("some hash"), 1, filterFunc, resp.ListToken, repo, []string{prj.PublicId}) + 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], stores[1], cmpOpts...)) + + resp3, err := credential.ListStoresPage(ctx, []byte("some hash"), 1, filterFunc, resp2.ListToken, repo, []string{prj.PublicId}) + 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], stores[2], cmpOpts...)) + + resp4, err := credential.ListStoresPage(ctx, []byte("some hash"), 1, filterFunc, resp3.ListToken, repo, []string{prj.PublicId}) + 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], stores[3], cmpOpts...)) + + resp5, err := credential.ListStoresPage(ctx, []byte("some hash"), 1, filterFunc, resp4.ListToken, repo, []string{prj.PublicId}) + 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], stores[4], cmpOpts...)) + + // Finished initial pagination phase, request refresh + // Expect no results. + resp6, err := credential.ListStoresRefresh(ctx, []byte("some hash"), 1, filterFunc, resp5.ListToken, repo, []string{prj.PublicId}) + 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 credential stores + newStore1 := static.TestCredentialStore(t, conn, wrapper, prj.PublicId) + newStore2 := static.TestCredentialStore(t, conn, wrapper, prj.PublicId) + t.Cleanup(func() { + _, err := staticRepo.DeleteCredentialStore(ctx, newStore1.PublicId) + require.NoError(t, err) + _, err = staticRepo.DeleteCredentialStore(ctx, newStore2.PublicId) + 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 newStore2 + resp7, err := credential.ListStoresRefresh(ctx, []byte("some hash"), 1, filterFunc, resp6.ListToken, repo, []string{prj.PublicId}) + 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], newStore2, cmpOpts...)) + + // Refresh again, should get newStore1 + resp8, err := credential.ListStoresRefreshPage(ctx, []byte("some hash"), 1, filterFunc, resp7.ListToken, repo, []string{prj.PublicId}) + 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], newStore1, cmpOpts...)) + + // Refresh again, should get no results + resp9, err := credential.ListStoresRefresh(ctx, []byte("some hash"), 1, filterFunc, resp8.ListToken, repo, []string{prj.PublicId}) + require.NoError(t, err) + require.Equal(t, resp9.ListToken.GrantsHash, []byte("some hash")) + require.True(t, resp9.CompleteListing) + require.Equal(t, resp9.EstimatedItemCount, 7) + require.Empty(t, resp9.DeletedIds) + require.Empty(t, resp9.Items) + }) + + t.Run("simple pagination with aggressive filtering", func(t *testing.T) { + filterFunc := func(ctx context.Context, s credential.Store) (bool, error) { + return s.GetPublicId() == stores[1].GetPublicId() || + s.GetPublicId() == stores[len(stores)-1].GetPublicId(), nil + } + resp, err := credential.ListStores(ctx, []byte("some hash"), 1, filterFunc, repo, []string{prj.PublicId}) + 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], stores[1], cmpOpts...)) + + resp2, err := credential.ListStoresPage(ctx, []byte("some hash"), 1, filterFunc, resp.ListToken, repo, []string{prj.PublicId}) + 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], stores[len(stores)-1], cmpOpts...)) + + // request a refresh, nothing should be returned + resp3, err := credential.ListStoresRefresh(ctx, []byte("some hash"), 1, filterFunc, resp.ListToken, repo, []string{prj.PublicId}) + 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 credential stores + newStore1 := static.TestCredentialStore(t, conn, wrapper, prj.PublicId) + newStore2 := static.TestCredentialStore(t, conn, wrapper, prj.PublicId) + newStore3 := static.TestCredentialStore(t, conn, wrapper, prj.PublicId) + newStore4 := static.TestCredentialStore(t, conn, wrapper, prj.PublicId) + t.Cleanup(func() { + _, err := staticRepo.DeleteCredentialStore(ctx, newStore1.PublicId) + require.NoError(t, err) + _, err = staticRepo.DeleteCredentialStore(ctx, newStore2.PublicId) + require.NoError(t, err) + _, err = staticRepo.DeleteCredentialStore(ctx, newStore3.PublicId) + require.NoError(t, err) + _, err = staticRepo.DeleteCredentialStore(ctx, newStore4.PublicId) + require.NoError(t, err) + // Run analyze to update count estimate + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + }) + // Run analyze to update count estimate + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + + filterFunc = func(ctx context.Context, s credential.Store) (bool, error) { + return s.GetPublicId() == newStore1.GetPublicId() || + s.GetPublicId() == newStore3.GetPublicId(), nil + } + // Refresh again, should get newStore3 + resp4, err := credential.ListStoresRefresh(ctx, []byte("some hash"), 1, filterFunc, resp3.ListToken, repo, []string{prj.PublicId}) + 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], newStore3, cmpOpts...)) + + // Refresh again, should get newStore1 + resp5, err := credential.ListStoresRefreshPage(ctx, []byte("some hash"), 1, filterFunc, resp4.ListToken, repo, []string{prj.PublicId}) + 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], newStore1, cmpOpts...)) + }) + + t.Run("simple pagination with deletion", func(t *testing.T) { + filterFunc := func(ctx context.Context, s credential.Store) (bool, error) { + return true, nil + } + deletedStoreId := stores[0].GetPublicId() + _, err := staticRepo.DeleteCredentialStore(ctx, deletedStoreId) + require.NoError(t, err) + stores = stores[1:] + + // Run analyze to update count estimate + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + + resp, err := credential.ListStores(ctx, []byte("some hash"), 1, filterFunc, repo, []string{prj.PublicId}) + 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], stores[0], cmpOpts...)) + + // request remaining results + resp2, err := credential.ListStoresPage(ctx, []byte("some hash"), 3, filterFunc, resp.ListToken, repo, []string{prj.PublicId}) + 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, stores[1:], cmpOpts...)) + + deletedStoreId = stores[0].GetPublicId() + _, err = staticRepo.DeleteCredentialStore(ctx, deletedStoreId) + require.NoError(t, err) + stores = stores[1:] + + // Run analyze to update count estimate + _, err = sqlDb.ExecContext(ctx, "analyze") + require.NoError(t, err) + + resp3, err := credential.ListStoresRefresh(ctx, []byte("some hash"), 1, filterFunc, resp2.ListToken, repo, []string{prj.PublicId}) + 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, deletedStoreId) + require.Empty(t, resp3.Items) + }) +} diff --git a/internal/credential/service_list_stores_page.go b/internal/credential/service_list_stores_page.go new file mode 100644 index 000000000..e18c69b85 --- /dev/null +++ b/internal/credential/service_list_stores_page.go @@ -0,0 +1,60 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package credential + +import ( + "context" + "time" + + "github.com/hashicorp/boundary/internal/errors" + "github.com/hashicorp/boundary/internal/listtoken" + "github.com/hashicorp/boundary/internal/pagination" +) + +// ListStoresPage lists up to page size credential stores, filtering out entries that +// do not pass the filter item function. It will automatically request +// more credential stores 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. +// Credential stores are ordered by create time descending (most recently created first). +func ListStoresPage( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[Store], + tok *listtoken.Token, + repo *StoreRepository, + projectIds []string, +) (*pagination.ListResponse[Store], error) { + const op = "credential.ListStoresPage" + + 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") + } + 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 Store, limit int) ([]Store, time.Time, error) { + if lastPageItem != nil { + return repo.List(ctx, projectIds, lastPageItem, limit) + } + lastItem, err := tok.LastItem(ctx) + if err != nil { + return nil, time.Time{}, err + } + return repo.List(ctx, projectIds, lastItem, limit) + } + + return pagination.ListPage(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.EstimatedCount, tok) +} diff --git a/internal/credential/service_list_stores_refresh.go b/internal/credential/service_list_stores_refresh.go new file mode 100644 index 000000000..7cd600a3b --- /dev/null +++ b/internal/credential/service_list_stores_refresh.go @@ -0,0 +1,68 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package credential + +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" +) + +// ListStoresRefresh lists up to page size credential stores, filtering out entries that +// do not pass the filter item function. It will automatically request +// more credential stores 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. +// Credential stores are ordered by update time descending (most recently updated first). +// Credential stores may contain items that were already returned during the initial +// pagination phase. It also returns a list of any credential stores deleted since the +// start of the initial pagination phase or last response. +func ListStoresRefresh( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[Store], + tok *listtoken.Token, + repo *StoreRepository, + projectIds []string, +) (*pagination.ListResponse[Store], error) { + const op = "credential.ListStoresRefresh" + + 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 projectIds == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing project ids") + case repo == nil: + return nil, errors.New(ctx, errors.InvalidParameter, op, "missing repo") + } + 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 Store, limit int) ([]Store, time.Time, error) { + // Add the database read timeout to account for any creations missed due to concurrent + // transactions in the initial pagination phase. + return repo.ListRefresh(ctx, projectIds, rt.PreviousPhaseUpperBound.Add(-globals.RefreshReadLookbackDuration), lastPageItem, limit) + } + 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. + since = since.Add(-globals.RefreshReadLookbackDuration) + return repo.ListDeletedIds(ctx, since) + } + + return pagination.ListRefresh(ctx, grantsHash, pageSize, filterItemFn, listItemsFn, repo.EstimatedCount, listDeletedIDsFn, tok) +} diff --git a/internal/credential/service_list_stores_refresh_page.go b/internal/credential/service_list_stores_refresh_page.go new file mode 100644 index 000000000..0046e0043 --- /dev/null +++ b/internal/credential/service_list_stores_refresh_page.go @@ -0,0 +1,73 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package credential + +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" +) + +// ListStoresRefreshPage lists up to page size credential stores, filtering out entries that +// do not pass the filter item function. It will automatically request +// more credential stores 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. +// Credential stores are ordered by update time descending (most recently updated first). +// Credential stores may contain items that were already returned during the initial +// pagination phase. It also returns a list of any credential stores deleted since the +// last response. +func ListStoresRefreshPage( + ctx context.Context, + grantsHash []byte, + pageSize int, + filterItemFn pagination.ListFilterFunc[Store], + tok *listtoken.Token, + repo *StoreRepository, + projectIds []string, +) (*pagination.ListResponse[Store], error) { + const op = "credential.ListStoresRefreshPage" + + 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") + } + 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 Store, limit int) ([]Store, time.Time, error) { + // Add the database read timeout to account for any creations missed due to concurrent + // transactions in the initial pagination phase. + since := rt.PhaseLowerBound.Add(-globals.RefreshReadLookbackDuration) + if lastPageItem != nil { + return repo.ListRefresh(ctx, projectIds, since, lastPageItem, limit) + } + lastItem, err := tok.LastItem(ctx) + if err != nil { + return nil, time.Time{}, err + } + return repo.ListRefresh(ctx, projectIds, since, lastItem, limit) + } + 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/credential/store.go b/internal/credential/store.go new file mode 100644 index 000000000..c802fcc39 --- /dev/null +++ b/internal/credential/store.go @@ -0,0 +1,72 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package credential + +import ( + "context" + "fmt" + + "github.com/hashicorp/boundary/globals" + "github.com/hashicorp/boundary/internal/db/timestamp" + "github.com/hashicorp/boundary/internal/errors" +) + +// StoreListQueryResult describes the result from the +// credential store list query used to list all credential +// store subtypes. +type StoreListQueryResult struct { + // PublicId is a surrogate key suitable for use in a public API. + PublicId string `gorm:"primary_key"` + // The Project Id of the owning project and must be set. + ProjectId string + // Optional name of the credential store. + Name string + // Optional description of the credential store. + Description string + // Create time of the credential store. + CreateTime *timestamp.Timestamp + // Update time of the credential store. + UpdateTime *timestamp.Timestamp + // Version of the credential store. + Version uint32 + // Optional delete time of the credential store. + DeleteTime *timestamp.Timestamp + // Optional Vault address of the credential store. + VaultAddress string + // Optional namespace of the credential store. + Namespace string + // Optional CA cert of the credential store. + CaCert []byte + // Optional TLS server name of the credential store. + TlsServerName string + // Optionally specifices whether to skip TLS verification of the credential store. + TlsSkipVerify bool + // Optional worker filter of the credential store. + WorkerFilter string + // Optional token HMAC of the credential store. + TokenHmac []byte + // Optional token status of the credential store. + TokenStatus string + // Optional client certificate of the credential store. + ClientCert []byte + // Optional client cert key HMAC of the credential store. + ClientCertKeyHmac []byte + // The subtype of the credential store. + Subtype string +} + +func (s *StoreListQueryResult) toStore(ctx context.Context) (Store, error) { + const op = "credential.(*StoreListQueryResult).toStore" + + newFn, ok := subtypeRegistry.newFunc(globals.Subtype(s.Subtype)) + if !ok { + return nil, errors.New(ctx, + errors.InvalidParameter, + op, + fmt.Sprintf("%s is an unknown credential store subtype of %s", s.PublicId, s.Subtype), + ) + } + + return newFn(ctx, s) +} diff --git a/internal/proto/controller/storage/credential/store/v1/credential.proto b/internal/proto/controller/storage/credential/store/v1/credential.proto index 3cddf7e36..c354a30aa 100644 --- a/internal/proto/controller/storage/credential/store/v1/credential.proto +++ b/internal/proto/controller/storage/credential/store/v1/credential.proto @@ -6,6 +6,8 @@ syntax = "proto3"; // Package store provides protobufs for storing types in the credential package. package controller.storage.credential.store.v1; +import "controller/storage/timestamp/v1/timestamp.proto"; + option go_package = "github.com/hashicorp/boundary/internal/credential/store;store"; message Store { @@ -58,3 +60,64 @@ message Dynamic { // @inject_tag: `gorm:"not_null"` string library_id = 2; } + +message StoreListQueryResult { + // public_id is a surrogate key suitable for use in a public API. + // @inject_tag: `gorm:"primary_key"` + string public_id = 1; + + // The project_id of the owning project and must be set. + // @inject_tag: `gorm:"not_null"` + string project_id = 2; + + // Optional name of the credential store. + string name = 3; + + // Optional description of the credential store. + string description = 4; + + // Create time of the credential store. + timestamp.v1.Timestamp create_time = 5; + + // Update time of the credential store. + timestamp.v1.Timestamp update_time = 6; + + // Version of the credential store. + uint32 version = 7; + + // Optional delete time of the credential store. + timestamp.v1.Timestamp delete_time = 8; + + // Optional Vault address of the credential store. + string vault_address = 9; + + // Optional namespace of the credential store. + string namespace = 10; + + // Optional CA cert of the credential store. + bytes ca_cert = 11; + + // Optional TLS server name of the credential store. + string tls_server_name = 12; + + // Optionally specifices whether to skip TLS verification of the credential store. + bool tls_skip_verify = 13; + + // Optional worker filter of the credential store. + string worker_filter = 14; + + // Optional token HMAC of the credential store. + bytes token_hmac = 15; + + // Optional token status of the credential store. + string token_status = 16; + + // Optional client certificate of the credential store. + bytes client_cert = 17; + + // Optional client cert key HMAC of the credential store. + bytes client_cert_key_hmac = 18; + + // The subtype of the credential store. + string subtype = 19; +}