From fe5ed5bb8d16177ff80a5d114cb96b2f8894a279 Mon Sep 17 00:00:00 2001 From: Jeff Mitchell Date: Wed, 28 Aug 2024 21:18:38 -0400 Subject: [PATCH] Add `implicit-scopes` resource type to cache (#5053) This allows getting a list of scope IDs known by the cache via cached targets and sessions. It is not refreshed from the controller. The name contains `implicit` in case we ever want e.g. `scopes`. --- internal/clientcache/cmd/search/search.go | 40 +++++- .../cache/repository_implicit_scopes.go | 114 ++++++++++++++++ .../cache/repository_implicit_scopes_test.go | 124 ++++++++++++++++++ internal/clientcache/internal/cache/search.go | 20 ++- .../internal/daemon/search_handler.go | 30 ++++- 5 files changed, 318 insertions(+), 10 deletions(-) create mode 100644 internal/clientcache/internal/cache/repository_implicit_scopes.go create mode 100644 internal/clientcache/internal/cache/repository_implicit_scopes_test.go diff --git a/internal/clientcache/cmd/search/search.go b/internal/clientcache/cmd/search/search.go index e095671811f..7075a31c7ab 100644 --- a/internal/clientcache/cmd/search/search.go +++ b/internal/clientcache/cmd/search/search.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/boundary/api" "github.com/hashicorp/boundary/api/aliases" + "github.com/hashicorp/boundary/api/scopes" "github.com/hashicorp/boundary/api/sessions" "github.com/hashicorp/boundary/api/targets" cachecmd "github.com/hashicorp/boundary/internal/clientcache/cmd/cache" @@ -34,6 +35,7 @@ var ( "resolvable-aliases", "targets", "sessions", + "implicit-scopes", } errCacheNotRunning = stderrors.New("The cache process is not running.") @@ -181,9 +183,6 @@ func (c *SearchCommand) Run(args []string) int { return base.CommandCliError } default: - if result.Incomplete { - c.UI.Warn("The maximum result set size was reached and the search results are incomplete. Please narrow your search or adjust the -max-result-set-size parameter.") - } switch { case len(result.ResolvableAliases) > 0: c.UI.Output(printAliasListTable(result.ResolvableAliases)) @@ -191,9 +190,17 @@ func (c *SearchCommand) Run(args []string) int { c.UI.Output(printTargetListTable(result.Targets)) case len(result.Sessions) > 0: c.UI.Output(printSessionListTable(result.Sessions)) + case len(result.ImplicitScopes) > 0: + c.UI.Output(printImplicitScopesListTable(result.ImplicitScopes)) default: c.UI.Output("No items found") } + + // Put this at the end or people may not see it as they may not scroll + // all the way up. + if result.Incomplete { + c.UI.Warn("The maximum result set size was reached and the search results are incomplete. Please narrow your search or adjust the -max-result-set-size parameter.") + } } return base.CommandSuccess } @@ -449,6 +456,33 @@ func printSessionListTable(items []*sessions.Session) string { return base.WrapForHelpText(output) } +func printImplicitScopesListTable(items []*scopes.Scope) string { + if len(items) == 0 { + return "No implicit scopes found" + } + var output []string + output = []string{ + "", + "Scope information:", + } + for i, item := range items { + if i > 0 { + output = append(output, "") + } + if item.Id != "" { + output = append(output, + fmt.Sprintf(" ID: %s", item.Id), + ) + } else { + output = append(output, + fmt.Sprintf(" ID: %s", "(not available)"), + ) + } + } + + return base.WrapForHelpText(output) +} + type filterBy struct { flagFilter string flagQuery string diff --git a/internal/clientcache/internal/cache/repository_implicit_scopes.go b/internal/clientcache/internal/cache/repository_implicit_scopes.go new file mode 100644 index 00000000000..2a031839e67 --- /dev/null +++ b/internal/clientcache/internal/cache/repository_implicit_scopes.go @@ -0,0 +1,114 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + "fmt" + "slices" + + "github.com/hashicorp/boundary/api/scopes" + "github.com/hashicorp/boundary/internal/errors" +) + +func (r *Repository) ListImplicitScopes(ctx context.Context, authTokenId string, opt ...Option) (*SearchResult, error) { + const op = "cache.(Repository).ListImplicitScopes" + switch { + case authTokenId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "auth token id is missing") + } + ret, err := r.searchImplicitScopes(ctx, "true", nil, append(opt, withAuthTokenId(authTokenId))...) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + return ret, nil +} + +// QueryImplicitScopes is not supported currently so we return an error message +func (r *Repository) QueryImplicitScopes(ctx context.Context, authTokenId, query string, opt ...Option) (*SearchResult, error) { + const op = "cache.(Repository).QueryImplicitScopes" + + // Internal is used as we have checks at the handler level to ensure this + // can't be used so it's an internal error if we actually call this + // function. + return nil, errors.New(ctx, errors.Internal, op, "querying implicit scopes is not supported") +} + +func (r *Repository) searchImplicitScopes(ctx context.Context, condition string, searchArgs []any, opt ...Option) (*SearchResult, error) { + const op = "cache.(Repository).searchImplicitScopes" + switch { + case condition == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "condition is missing") + } + + opts, err := getOpts(opt...) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + switch { + case opts.withAuthTokenId != "" && opts.withUserId != "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "both user id and auth token id were provided") + case opts.withAuthTokenId == "" && opts.withUserId == "": + return nil, errors.New(ctx, errors.InvalidParameter, op, "neither user id nor auth token id were provided") + + // In these cases we append twice because we're doing a union of two tables + case opts.withAuthTokenId != "": + condition = "where fk_user_id in (select user_id from auth_token where id = ?)" + searchArgs = append(searchArgs, opts.withAuthTokenId, opts.withAuthTokenId) + case opts.withUserId != "": + condition = "where fk_user_id = ?" + searchArgs = append(searchArgs, opts.withUserId, opts.withUserId) + } + + const unionQueryBase = ` + select distinct fk_user_id, scope_id from session + %s + union + select distinct fk_user_id, scope_id from target + %s +` + unionQuery := fmt.Sprintf(unionQueryBase, condition, condition) + + rows, err := r.rw.Query(ctx, unionQuery, searchArgs) + if err != nil { + return nil, errors.Wrap(ctx, err, op) + } + defer rows.Close() + + type ScopeIdsResult struct { + FkUserId string `gorm:"primaryKey"` + ScopeId string `gorm:"default:null"` + } + + var scopeIdsResults []ScopeIdsResult + for rows.Next() { + var res ScopeIdsResult + if err := r.rw.ScanRows(ctx, rows, &res); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + scopeIdsResults = append(scopeIdsResults, res) + } + if err := rows.Err(); err != nil { + return nil, errors.Wrap(ctx, err, op) + } + + dedupMap := make(map[string]struct{}, len(scopeIdsResults)) + for _, res := range scopeIdsResults { + dedupMap[res.ScopeId] = struct{}{} + } + scopeIds := make([]string, 0, len(dedupMap)) + for k := range dedupMap { + scopeIds = append(scopeIds, k) + } + slices.Sort(scopeIds) + + sr := &SearchResult{ + ImplicitScopes: make([]*scopes.Scope, 0, len(dedupMap)), + } + for _, scopeId := range scopeIds { + sr.ImplicitScopes = append(sr.ImplicitScopes, &scopes.Scope{Id: scopeId}) + } + + return sr, nil +} diff --git a/internal/clientcache/internal/cache/repository_implicit_scopes_test.go b/internal/clientcache/internal/cache/repository_implicit_scopes_test.go new file mode 100644 index 00000000000..f5bc2263372 --- /dev/null +++ b/internal/clientcache/internal/cache/repository_implicit_scopes_test.go @@ -0,0 +1,124 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package cache + +import ( + "context" + "sync" + "testing" + + "github.com/hashicorp/boundary/api/authtokens" + "github.com/hashicorp/boundary/api/scopes" + "github.com/hashicorp/boundary/api/sessions" + "github.com/hashicorp/boundary/api/targets" + cachedb "github.com/hashicorp/boundary/internal/clientcache/internal/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/exp/maps" +) + +func TestRepository_ImplicitScopes(t *testing.T) { + ctx := context.Background() + s, err := cachedb.Open(ctx) + require.NoError(t, err) + + addr := "address" + u1 := &user{ + Id: "u1", + Address: addr, + } + at1 := &authtokens.AuthToken{ + Id: "at_1", + Token: "at_1_token", + UserId: u1.Id, + } + kt1 := KeyringToken{KeyringType: "k1", TokenName: "t1", AuthTokenId: at1.Id} + + u2 := &user{ + Id: "u2", + Address: addr, + } + at2 := &authtokens.AuthToken{ + Id: "at_2", + Token: "at_2_token", + UserId: u2.Id, + } + kt2 := KeyringToken{KeyringType: "k2", TokenName: "t2", AuthTokenId: at2.Id} + atMap := map[ringToken]*authtokens.AuthToken{ + {"k1", "t1"}: at1, + {"k2", "t2"}: at2, + } + r, err := NewRepository(ctx, s, &sync.Map{}, mapBasedAuthTokenKeyringLookup(atMap), sliceBasedAuthTokenBoundaryReader(maps.Values(atMap))) + require.NoError(t, err) + require.NoError(t, r.AddKeyringToken(ctx, addr, kt1)) + require.NoError(t, r.AddKeyringToken(ctx, addr, kt2)) + + var expectedScopes []*scopes.Scope + + ts := []*targets.Target{ + target("1"), + target("2"), + target("3"), + } + require.NoError(t, r.refreshTargets(ctx, u1, map[AuthToken]string{{Id: "id"}: "something"}, + WithTargetRetrievalFunc(testStaticResourceRetrievalFunc(t, [][]*targets.Target{ts}, [][]string{nil})))) + + for _, t := range ts { + expectedScopes = append(expectedScopes, &scopes.Scope{ + Id: t.ScopeId, + }) + } + + ss := []*sessions.Session{ + { + Id: "ttcp_1", + Status: "status1", + Endpoint: "address1", + ScopeId: "p_123", + TargetId: "ttcp_123", + UserId: "u_123", + Type: "tcp", + }, + { + Id: "ttcp_2", + Status: "status2", + Endpoint: "address2", + ScopeId: "p_123", + TargetId: "ttcp_123", + UserId: "u_123", + Type: "tcp", + }, + { + Id: "ttcp_3", + Status: "status3", + Endpoint: "address3", + ScopeId: "p_123", + TargetId: "ttcp_123", + UserId: "u_123", + Type: "tcp", + }, + } + require.NoError(t, r.refreshSessions(ctx, u1, map[AuthToken]string{{Id: "id"}: "something"}, + WithSessionRetrievalFunc(testStaticResourceRetrievalFunc(t, [][]*sessions.Session{ss}, [][]string{nil})))) + + expectedScopes = append(expectedScopes, &scopes.Scope{ + Id: ss[0].ScopeId, + }) + + t.Run("wrong user gets no implicit scopes", func(t *testing.T) { + l, err := r.ListImplicitScopes(ctx, kt2.AuthTokenId) + require.NoError(t, err) + assert.Empty(t, l.ImplicitScopes) + }) + t.Run("correct token gets implicit scopes from listing", func(t *testing.T) { + l, err := r.ListImplicitScopes(ctx, kt1.AuthTokenId) + require.NoError(t, err) + assert.Len(t, l.ImplicitScopes, len(expectedScopes)) + assert.ElementsMatch(t, l.ImplicitScopes, expectedScopes) + }) + t.Run("querying returns error", func(t *testing.T) { + _, err := r.QueryImplicitScopes(ctx, kt1.AuthTokenId, "anything") + require.Error(t, err) + }) +} diff --git a/internal/clientcache/internal/cache/search.go b/internal/clientcache/internal/cache/search.go index a26aa1a9c9d..0b9ae252f8f 100644 --- a/internal/clientcache/internal/cache/search.go +++ b/internal/clientcache/internal/cache/search.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/hashicorp/boundary/api/aliases" + "github.com/hashicorp/boundary/api/scopes" "github.com/hashicorp/boundary/api/sessions" "github.com/hashicorp/boundary/api/targets" "github.com/hashicorp/boundary/internal/errors" @@ -23,11 +24,12 @@ const ( ResolvableAliases SearchableResource = "resolvable-aliases" Targets SearchableResource = "targets" Sessions SearchableResource = "sessions" + ImplicitScopes SearchableResource = "implicit-scopes" ) func (r SearchableResource) Valid() bool { switch r { - case ResolvableAliases, Targets, Sessions: + case ResolvableAliases, Targets, Sessions, ImplicitScopes: return true } return false @@ -41,6 +43,8 @@ func ToSearchableResource(s string) SearchableResource { return Targets case strings.EqualFold(s, string(Sessions)): return Sessions + case strings.EqualFold(s, string(ImplicitScopes)): + return ImplicitScopes } return Unknown } @@ -64,6 +68,7 @@ type SearchResult struct { ResolvableAliases []*aliases.Alias `json:"resolvable_aliases,omitempty"` Targets []*targets.Target `json:"targets,omitempty"` Sessions []*sessions.Session `json:"sessions,omitempty"` + ImplicitScopes []*scopes.Scope `json:"implicit_scopes,omitempty"` // Incomplete is true if the search results are incomplete, that is, we are // returning only a subset based on the max result set size @@ -125,6 +130,19 @@ func NewSearchService(ctx context.Context, repo *Repository) (*SearchService, er in.Sessions = finalResults }, }, + ImplicitScopes: &resourceSearchFns[*scopes.Scope]{ + list: repo.ListImplicitScopes, + query: repo.QueryImplicitScopes, + filter: func(in *SearchResult, e *bexpr.Evaluator) { + finalResults := make([]*scopes.Scope, 0, len(in.ImplicitScopes)) + for _, item := range in.ImplicitScopes { + if m, err := e.Evaluate(filterItem{item}); err == nil && m { + finalResults = append(finalResults, item) + } + } + in.ImplicitScopes = finalResults + }, + }, }, }, nil } diff --git a/internal/clientcache/internal/daemon/search_handler.go b/internal/clientcache/internal/daemon/search_handler.go index f450a76491d..2fbdfbd300d 100644 --- a/internal/clientcache/internal/daemon/search_handler.go +++ b/internal/clientcache/internal/daemon/search_handler.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/boundary/api" "github.com/hashicorp/boundary/api/aliases" + "github.com/hashicorp/boundary/api/scopes" "github.com/hashicorp/boundary/api/sessions" "github.com/hashicorp/boundary/api/targets" "github.com/hashicorp/boundary/internal/clientcache/internal/cache" @@ -26,6 +27,7 @@ type SearchResult struct { ResolvableAliases []*aliases.Alias `json:"resolvable_aliases,omitempty"` Targets []*targets.Target `json:"targets,omitempty"` Sessions []*sessions.Session `json:"sessions,omitempty"` + ImplicitScopes []*scopes.Scope `json:"implicit_scopes,omitempty"` Incomplete bool `json:"incomplete,omitempty"` } @@ -61,6 +63,8 @@ func newSearchHandlerFunc(ctx context.Context, repo *cache.Repository, refreshSe authTokenId := q.Get(authTokenIdKey) maxResultSetSizeStr := q.Get(maxResultSetSizeKey) maxResultSetSizeInt, maxResultSetSizeIntErr := strconv.Atoi(maxResultSetSizeStr) + query := q.Get(queryKey) + filter := q.Get(filterKey) searchableResource := cache.ToSearchableResource(resource) switch { @@ -84,6 +88,18 @@ func newSearchHandlerFunc(ctx context.Context, repo *cache.Repository, refreshSe event.WriteError(ctx, op, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("%s must be greater than or equal to -1", maxResultSetSizeStr))) writeError(w, fmt.Sprintf("%s must be greater than or equal to -1", maxResultSetSizeStr), http.StatusBadRequest) return + case searchableResource == cache.ImplicitScopes && maxResultSetSizeStr != "": + event.WriteError(ctx, op, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("max result set size is not supported for resource %q", resource))) + writeError(w, fmt.Sprintf("max result set size is not supported for resource %q", resource), http.StatusBadRequest) + return + case searchableResource == cache.ImplicitScopes && query != "": + event.WriteError(ctx, op, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("query is not supported for resource %q", resource))) + writeError(w, fmt.Sprintf("query is not supported for resource %q", resource), http.StatusBadRequest) + return + case searchableResource == cache.ImplicitScopes && filter != "": + event.WriteError(ctx, op, errors.New(ctx, errors.InvalidParameter, op, fmt.Sprintf("filter is not supported for resource %q", resource))) + writeError(w, fmt.Sprintf("filter is not supported for resource %q", resource), http.StatusBadRequest) + return } t, err := repo.LookupToken(reqCtx, authTokenId, cache.WithUpdateLastAccessedTime(true)) @@ -118,14 +134,15 @@ func newSearchHandlerFunc(ctx context.Context, repo *cache.Repository, refreshSe // Refresh the resources for the provided user, if possible. This is best // effort, so if there is any problem refreshing, we just log the error // and move on to handling the search request. - if err := refreshService.RefreshForSearch(reqCtx, authTokenId, searchableResource, opts...); err != nil { - // we don't stop the search, we just log that the inline refresh failed - event.WriteError(ctx, op, err, event.WithInfoMsg("when refreshing the resources inline for search", "auth_token_id", authTokenId, "resource", searchableResource)) + switch searchableResource { + case cache.ImplicitScopes: + default: + if err := refreshService.RefreshForSearch(reqCtx, authTokenId, searchableResource, opts...); err != nil { + // we don't stop the search, we just log that the inline refresh failed + event.WriteError(ctx, op, err, event.WithInfoMsg("when refreshing the resources inline for search", "auth_token_id", authTokenId, "resource", searchableResource)) + } } - query := r.URL.Query().Get(queryKey) - filter := r.URL.Query().Get(filterKey) - res, err := s.Search(reqCtx, cache.SearchParams{ AuthTokenId: authTokenId, Resource: searchableResource, @@ -166,6 +183,7 @@ func toApiResult(sr *cache.SearchResult) *SearchResult { ResolvableAliases: sr.ResolvableAliases, Targets: sr.Targets, Sessions: sr.Sessions, + ImplicitScopes: sr.ImplicitScopes, Incomplete: sr.Incomplete, } }