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`.

(cherry picked from commit c0a318c583)
pull/5128/head
Jeff Mitchell 2 years ago committed by Timothy Messier
parent 4d8a7dd39a
commit b04de5ac4b
No known key found for this signature in database
GPG Key ID: EFD2F184F7600572

@ -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

@ -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
}

@ -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)
})
}

@ -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
}

@ -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,
}
}

Loading…
Cancel
Save