mirror of
https://github.com/gravitational/teleport.git
synced 2026-11-01 21:52:02 +08:00
- Adds (SortCache) Clear to allow resetting an existing cache - Converts (SortCache) Ascend and (SortCache) Descend to return an iter.Seq[T] instead of taking an iteration function - Removes (SortCache) AscendPaginated and (SortCache) DescendPaginated Leveraging iterates aligns the API with the direction that Go is heading in regards to iteration. Removing of the paginated functions does put the onus on callers to paginate, however, in the only cases these were used there are now fewer allocations. AscendPaginated was unused and DescendPaginated was only used in two places - both of which would benefit from migrating away from using streams in favor of iter.Seq. The new Clear API makes it easier for callers to reset an existing cache. Without this change the only way to reset a cache would be to replace the entire cache with a new one. This places the burden on callers to apply locking and handle concurrent read/write/deletes and swapping out the cache entirely. That all is eliminated by Clear handling the internal locking to reset the cache state.
436 lines
15 KiB
Go
436 lines
15 KiB
Go
/*
|
|
* Teleport
|
|
* Copyright (C) 2024 Gravitational, Inc.
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*/
|
|
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gravitational/trace"
|
|
"github.com/jonboulle/clockwork"
|
|
|
|
"github.com/gravitational/teleport/api/client/proto"
|
|
apidefaults "github.com/gravitational/teleport/api/defaults"
|
|
"github.com/gravitational/teleport/api/types"
|
|
"github.com/gravitational/teleport/lib/utils"
|
|
logutils "github.com/gravitational/teleport/lib/utils/log"
|
|
"github.com/gravitational/teleport/lib/utils/sortcache"
|
|
)
|
|
|
|
const (
|
|
// accessRequestID is the name of the sort index used for sorting request by ID (equivalent to proto.AccessRequestSort_DEFAULT since
|
|
// access requests currently default to being sorted by ID in the backend).
|
|
accessRequestID = "ID"
|
|
// accessRequestCreated is the name of the sort index used for sorting requests by creation time (this is typically the sort order
|
|
// used in user interfaces, since most users that want to view requests want to see the most recent requests specifically).
|
|
accessRequestCreated = "Created"
|
|
// accessRequestState is the name of the sort index used for sorting requests by their current state (pending, approved, etc).
|
|
accessRequestState = "State"
|
|
// accessRequestUser is the name of the sort index used for sorting requests by the person who created the request.
|
|
accessRequestUser = "User"
|
|
)
|
|
|
|
// AccessRequestCacheConfig holds the configuration parameters for an [AccessRequestCache].
|
|
type AccessRequestCacheConfig struct {
|
|
// Clock is a clock for time-related operation.
|
|
Clock clockwork.Clock
|
|
// Events is an event system client.
|
|
Events types.Events
|
|
// Getter is an access request getter client.
|
|
Getter AccessRequestGetter
|
|
// MaxRetryPeriod is the maximum retry period on failed watches.
|
|
MaxRetryPeriod time.Duration
|
|
}
|
|
|
|
// CheckAndSetDefaults valides the config and provides reasonable defaults for optional fields.
|
|
func (c *AccessRequestCacheConfig) CheckAndSetDefaults() error {
|
|
if c.Clock == nil {
|
|
c.Clock = clockwork.NewRealClock()
|
|
}
|
|
|
|
if c.Events == nil {
|
|
return trace.BadParameter("access request cache config missing event system client")
|
|
}
|
|
|
|
if c.Getter == nil {
|
|
return trace.BadParameter("access request cache config missing access request getter")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AccessRequestCache is a custom cache for access requests that offers custom sort indexes not
|
|
// supported by the standard backend implementation. As with all caches, the state observed during
|
|
// reads may be slightly outdated. There is a builtin fallback that always routes requests for a
|
|
// single specific access request (specified by ID) to the real backend, to avoid outdated single-resource
|
|
// reads. Usecases that need perfectly up to date information (e.g. loading an access request in order
|
|
// to generate a certificate) should always load the desired request by ID for this reason.
|
|
type AccessRequestCache struct {
|
|
rw sync.RWMutex
|
|
cfg AccessRequestCacheConfig
|
|
primaryCache *sortcache.SortCache[*types.AccessRequestV3]
|
|
ttlCache *utils.FnCache
|
|
initC chan struct{}
|
|
initOnce sync.Once
|
|
closeContext context.Context
|
|
cancel context.CancelFunc
|
|
// onInit is a callback used in tests to detect
|
|
// individual initializations.
|
|
onInit func()
|
|
}
|
|
|
|
// NewAccessRequestCache sets up a new [AccessRequestCache] instance based on the supplied
|
|
// configuration. The cache is initialized asychronously in the background, so while it is
|
|
// safe to read from it immediately, performance is better after the cache properly initializes.
|
|
func NewAccessRequestCache(cfg AccessRequestCacheConfig) (*AccessRequestCache, error) {
|
|
if err := cfg.CheckAndSetDefaults(); err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
ttlCache, err := utils.NewFnCache(utils.FnCacheConfig{
|
|
Context: ctx,
|
|
TTL: 15 * time.Second,
|
|
Clock: cfg.Clock,
|
|
})
|
|
if err != nil {
|
|
cancel()
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
c := &AccessRequestCache{
|
|
cfg: cfg,
|
|
ttlCache: ttlCache,
|
|
initC: make(chan struct{}),
|
|
closeContext: ctx,
|
|
cancel: cancel,
|
|
}
|
|
|
|
if _, err := newResourceWatcher(ctx, c, ResourceWatcherConfig{
|
|
Component: "access-request-cache",
|
|
Client: cfg.Events,
|
|
MaxRetryPeriod: cfg.MaxRetryPeriod,
|
|
}); err != nil {
|
|
cancel()
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
return c, nil
|
|
}
|
|
|
|
// ListAccessRequests is an access request getter with pagination and sorting options.
|
|
func (c *AccessRequestCache) ListAccessRequests(ctx context.Context, req *proto.ListAccessRequestsRequest) (*proto.ListAccessRequestsResponse, error) {
|
|
rsp, err := c.ListMatchingAccessRequests(ctx, req, func(_ *types.AccessRequestV3) bool {
|
|
return true
|
|
})
|
|
|
|
return rsp, trace.Wrap(err)
|
|
}
|
|
|
|
// ListMatchingAccessRequests is equivalent to ListAccessRequests except that it adds the ability to provide an arbitrary matcher function. This method
|
|
// should be preferred when using custom filtering (e.g. access-controls), since the paginations keys used by the access request cache are non-standard.
|
|
func (c *AccessRequestCache) ListMatchingAccessRequests(ctx context.Context, req *proto.ListAccessRequestsRequest, match func(*types.AccessRequestV3) bool) (*proto.ListAccessRequestsResponse, error) {
|
|
const maxPageSize = 16_000
|
|
|
|
if req.Filter == nil {
|
|
req.Filter = &types.AccessRequestFilter{}
|
|
}
|
|
|
|
if req.Filter.ID != "" {
|
|
// important special case: single-request lookups must always be forwarded to the real backend to avoid race conditions whereby
|
|
// stale cache state causes spurious errors due to users trying to utilize an access request immediately after it gets approved.
|
|
rsp, err := c.cfg.Getter.ListAccessRequests(ctx, req)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
// fallback doesn't apply the match function, so we need to apply it manually here.
|
|
matched := rsp.AccessRequests[:0]
|
|
for _, req := range rsp.AccessRequests {
|
|
if !match(req) {
|
|
continue
|
|
}
|
|
matched = append(matched, req)
|
|
}
|
|
rsp.AccessRequests = matched
|
|
return rsp, nil
|
|
}
|
|
|
|
if req.Limit == 0 {
|
|
req.Limit = apidefaults.DefaultChunkSize
|
|
}
|
|
|
|
if req.Limit > maxPageSize {
|
|
return nil, trace.BadParameter("page size of %d is too large", req.Limit)
|
|
}
|
|
|
|
cache, err := c.read(ctx)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
var index string
|
|
switch req.Sort {
|
|
case proto.AccessRequestSort_DEFAULT:
|
|
index = accessRequestID
|
|
case proto.AccessRequestSort_CREATED:
|
|
index = accessRequestCreated
|
|
case proto.AccessRequestSort_STATE:
|
|
index = accessRequestState
|
|
case proto.AccessRequestSort_USER:
|
|
index = accessRequestUser
|
|
default:
|
|
return nil, trace.BadParameter("unsupported access request sort index '%v'", req.Sort)
|
|
}
|
|
|
|
if !cache.HasIndex(index) {
|
|
// this case would be a fairly trivial programming error, but its best to give it
|
|
// a friendly error message.
|
|
return nil, trace.Errorf("access request cache was not configured with sort index %q (this is a bug)", index)
|
|
}
|
|
|
|
accessRequests := cache.Ascend
|
|
if req.Descending {
|
|
accessRequests = cache.Descend
|
|
}
|
|
|
|
limit := int(req.Limit)
|
|
|
|
// perform the traversal until we've seen all items or fill the page
|
|
var rsp proto.ListAccessRequestsResponse
|
|
now := time.Now()
|
|
var expired int
|
|
for r := range accessRequests(index, req.StartKey, "") {
|
|
if len(rsp.AccessRequests) == limit {
|
|
rsp.NextKey = cache.KeyOf(index, r)
|
|
break
|
|
}
|
|
|
|
if !r.Expiry().IsZero() && now.After(r.Expiry()) {
|
|
expired++
|
|
// skip requests that appear expired. some backends can take up to 48 hours to expired items
|
|
// and access requests showing up past their expiry time is particularly confusing.
|
|
continue
|
|
}
|
|
if !req.Filter.Match(r) || !match(r) {
|
|
continue
|
|
}
|
|
|
|
c := r.Copy()
|
|
cr, ok := c.(*types.AccessRequestV3)
|
|
if !ok {
|
|
slog.WarnContext(ctx, "clone returned unexpected type (this is a bug)", "expected", logutils.TypeAttr(r), "got", logutils.TypeAttr(c))
|
|
continue
|
|
}
|
|
|
|
rsp.AccessRequests = append(rsp.AccessRequests, cr)
|
|
}
|
|
|
|
if expired > 0 {
|
|
// this is a debug-level log since some amount of delay between expiry and backend cleanup is expected, but
|
|
// very large and/or disproportionate numbers of stale access requests might be a symptom of a deeper issue.
|
|
slog.DebugContext(ctx, "omitting expired access requests from cache read", "count", expired)
|
|
}
|
|
|
|
return &rsp, nil
|
|
}
|
|
|
|
// fetch configures a sortcache and inserts all currently extant access requests into it. this method is used both
|
|
// as the means of setting up the initial primary cache state, and for creating temporary cache states to read from
|
|
// when the primary is unhealthy.
|
|
func (c *AccessRequestCache) fetch(ctx context.Context) (*sortcache.SortCache[*types.AccessRequestV3], error) {
|
|
cache := sortcache.New(sortcache.Config[*types.AccessRequestV3]{
|
|
Indexes: map[string]func(*types.AccessRequestV3) string{
|
|
accessRequestID: func(req *types.AccessRequestV3) string {
|
|
// since accessRequestID is equivalent to the DEFAULT sort index (i.e. the sort index of the backend),
|
|
// it is preferable to keep its format equivalent to the format of the NextKey/StartKey values
|
|
// expected by the backend implementation of ListResources.
|
|
return req.GetName()
|
|
},
|
|
accessRequestCreated: func(req *types.AccessRequestV3) string {
|
|
return fmt.Sprintf("%s/%s", req.GetCreationTime().Format(time.RFC3339), req.GetName())
|
|
},
|
|
accessRequestState: func(req *types.AccessRequestV3) string {
|
|
return fmt.Sprintf("%s/%s", req.GetState().String(), req.GetName())
|
|
},
|
|
accessRequestUser: func(req *types.AccessRequestV3) string {
|
|
return fmt.Sprintf("%s/%s", req.GetUser(), req.GetName())
|
|
},
|
|
},
|
|
})
|
|
|
|
var req proto.ListAccessRequestsRequest
|
|
for {
|
|
rsp, err := c.cfg.Getter.ListAccessRequests(ctx, &req)
|
|
if err != nil {
|
|
return nil, trace.Wrap(err)
|
|
}
|
|
|
|
for _, r := range rsp.AccessRequests {
|
|
if evicted := cache.Put(r); evicted != 0 {
|
|
// this warning, if it appears, means that we configured our indexes incorrectly and one access request is overwriting another.
|
|
// the most likely explanation is that one of our indexes is missing the request id suffix we typically use.
|
|
slog.WarnContext(ctx, "conflict during access request fetch (this is a bug and may result in missing requests)", "id", r.GetName(), "evicted", evicted)
|
|
}
|
|
}
|
|
|
|
if rsp.NextKey == "" {
|
|
break
|
|
}
|
|
req.StartKey = rsp.NextKey
|
|
}
|
|
|
|
return cache, nil
|
|
}
|
|
|
|
// read gets a read-only view into a valid cache state. it prefers reading from the primary cache, but will fallback
|
|
// to a periodically reloaded temporary state when the primary state is unhealthy.
|
|
func (c *AccessRequestCache) read(ctx context.Context) (*sortcache.SortCache[*types.AccessRequestV3], error) {
|
|
c.rw.RLock()
|
|
primary := c.primaryCache
|
|
c.rw.RUnlock()
|
|
|
|
// primary cache state is healthy, so use that. note that we don't protect access to the sortcache itself
|
|
// via our rw lock. sortcaches have their own internal locking. we just use our lock to protect the *pointer*
|
|
// to the sortcache.
|
|
if primary != nil {
|
|
return primary, nil
|
|
}
|
|
|
|
temp, err := utils.FnCacheGet(ctx, c.ttlCache, "access-request-cache", func(ctx context.Context) (*sortcache.SortCache[*types.AccessRequestV3], error) {
|
|
return c.fetch(ctx)
|
|
})
|
|
|
|
// primary may have been concurrently loaded. if it was, prefer using that.
|
|
c.rw.RLock()
|
|
primary = c.primaryCache
|
|
c.rw.RUnlock()
|
|
|
|
if primary != nil {
|
|
return primary, nil
|
|
}
|
|
|
|
return temp, trace.Wrap(err)
|
|
}
|
|
|
|
// --- the below methods implement the resourceCollector interface ---
|
|
|
|
// resourceKinds is part of the resourceCollector interface and is used to configure the event watcher
|
|
// that monitors for access request modifications.
|
|
func (c *AccessRequestCache) resourceKinds() []types.WatchKind {
|
|
return []types.WatchKind{
|
|
{
|
|
Kind: types.KindAccessRequest,
|
|
},
|
|
}
|
|
}
|
|
|
|
// getResourcesAndUpdateCurrent is part of the resourceCollector interface and is called one the
|
|
// event stream for the cache has been initialized to trigger setup of the initial primary cache state.
|
|
func (c *AccessRequestCache) getResourcesAndUpdateCurrent(ctx context.Context) error {
|
|
cache, err := c.fetch(ctx)
|
|
if err != nil {
|
|
return trace.Wrap(err)
|
|
}
|
|
|
|
c.rw.Lock()
|
|
defer c.rw.Unlock()
|
|
c.primaryCache = cache
|
|
c.initOnce.Do(func() {
|
|
close(c.initC)
|
|
})
|
|
if c.onInit != nil {
|
|
c.onInit()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SetInitCallback is used in tests that care about cache inits.
|
|
func (c *AccessRequestCache) SetInitCallback(cb func()) {
|
|
c.rw.Lock()
|
|
defer c.rw.Unlock()
|
|
c.onInit = cb
|
|
}
|
|
|
|
// processEventsAndUpdateCurrent is part of the resourceCollector interface and is used to update the
|
|
// primary cache state when modification events occur.
|
|
func (c *AccessRequestCache) processEventsAndUpdateCurrent(ctx context.Context, events []types.Event) {
|
|
c.rw.RLock()
|
|
cache := c.primaryCache
|
|
c.rw.RUnlock()
|
|
|
|
for _, event := range events {
|
|
switch event.Type {
|
|
case types.OpPut:
|
|
req, ok := event.Resource.(*types.AccessRequestV3)
|
|
if !ok {
|
|
slog.WarnContext(ctx, "unexpected resource type in event", "expected", logutils.TypeAttr(req), "got", logutils.TypeAttr(event.Resource))
|
|
continue
|
|
}
|
|
if evicted := cache.Put(req); evicted > 1 {
|
|
// this warning, if it appears, means that we configured our indexes incorrectly and one access request is overwriting another.
|
|
// the most likely explanation is that one of our indexes is missing the request id suffix we typically use.
|
|
slog.WarnContext(ctx, "request put event resulted in multiple cache evictions (this is a bug)", "id", req.GetName(), "evicted", evicted)
|
|
}
|
|
case types.OpDelete:
|
|
cache.Delete(accessRequestID, event.Resource.GetName())
|
|
default:
|
|
slog.WarnContext(ctx, "unexpected event variant", "op", logutils.StringerAttr(event.Type), "resource", logutils.TypeAttr(event.Resource))
|
|
}
|
|
}
|
|
}
|
|
|
|
// notifyStale is part of the resourceCollector interface and is used to inform
|
|
// the access request cache that its view is outdated (presumably due to issues with
|
|
// the event stream).
|
|
func (c *AccessRequestCache) notifyStale() {
|
|
c.rw.Lock()
|
|
defer c.rw.Unlock()
|
|
if c.primaryCache == nil {
|
|
return
|
|
}
|
|
c.primaryCache = nil
|
|
c.initC = make(chan struct{})
|
|
c.initOnce = sync.Once{}
|
|
}
|
|
|
|
// initializationChan is part of the resourceCollector interface and gets the channel
|
|
// used to signal that the accessRequestCache has been initialized.
|
|
func (c *AccessRequestCache) initializationChan() <-chan struct{} {
|
|
c.rw.RLock()
|
|
defer c.rw.RUnlock()
|
|
return c.initC
|
|
}
|
|
|
|
// InitializationChan is part of the resourceCollector interface and gets the channel
|
|
// used to signal that the accessRequestCache has been initialized.
|
|
func (c *AccessRequestCache) InitializationChan() <-chan struct{} {
|
|
return c.initializationChan()
|
|
}
|
|
|
|
// Close terminates the background process that keeps the access request cache up to
|
|
// date, and terminates any inflight load operations.
|
|
func (c *AccessRequestCache) Close() error {
|
|
c.cancel()
|
|
return nil
|
|
}
|