internal/credential: add store pagination logic

This commit is contained in:
Johan Brandhorst-Satzkorn committed 2024-01-05 07:43:20 -08:00
1 parent 3197ab763f
commit a7ebb7a3ef
11 files changed
+1905

No files matched your search

+363
View File
@@ -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;
`
)
+68
View File
@@ -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)
}
+186
View File
@@ -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
}
@@ -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)))
}
@@ -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)
}
@@ -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)
})
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
+72
View File
@@ -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)
}
@@ -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;
}