Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/cmd/console/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ func run() error {
if err = rbacevents.StartInformer(ctx, kube, store); err != nil {
return err
}
rbacHandler := rbacevents.NewHandler(store, rbacevents.NewAPIAuth(restCfg), rbacevents.NewSSARAccess(restCfg))
rbacSSAR := rbacevents.NewSSARAccess(restCfg)
rbacSSAR.StartCleanup(ctx)
rbacHandler := rbacevents.NewHandler(store, rbacevents.NewAPIAuth(restCfg), rbacSSAR)

infCfg := informers.RESTConfig(restCfg)
infDyn, err := dynamic.NewForConfig(infCfg)
Expand Down
1 change: 1 addition & 0 deletions backend/internal/aggregate/argo.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ func placementFromAppSet(obj map[string]any) string {
}

func (e *Engine) createArgoStatusMap(search searchapi.ResultBucket, clusters []Cluster) map[string]StatusMap {
e.appStatusByName = map[string]map[string]AppHealthSync{}
out := map[string]StatusMap{}
ids := map[string]*statusIDs{}
sorted := make([]string, 0, len(clusters))
Expand Down
5 changes: 2 additions & 3 deletions backend/internal/aggregate/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,8 @@ func (e *Engine) searchLoop(ctx context.Context) {
}

func (e *Engine) applications() []App {
e.mu.Lock()
defer e.mu.Unlock()
e.withListCache(e.rebuildSubscriptionLocked)
e.mu.RLock()
defer e.mu.RUnlock()
items := getApplicationsHelper(e.cache, cacheKeys)
if items == nil {
return []App{}
Expand Down
14 changes: 10 additions & 4 deletions backend/internal/aggregate/engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ func (c *countingLister) count(key string) int {
return c.n[key]
}

func TestApplicationsRebuildsOnlySubscriptions(t *testing.T) {
func TestApplicationsReadOnly(t *testing.T) {
cl := &countingLister{inner: MapLister{
"app.k8s.io/v1beta1|Application": {
uObj("app.k8s.io/v1beta1", "Application", "sub-app", "ns", nil),
Expand All @@ -130,15 +130,21 @@ func TestApplicationsRebuildsOnlySubscriptions(t *testing.T) {
"cluster.open-cluster-management.io/v1|ManagedCluster": {localCluster()},
}}
e := NewEngine(cl, nil, nil)
e.mu.Lock()
e.rebuildLocalLocked()
e.mu.Unlock()
cl.mu.Lock()
cl.n = map[string]int{}
cl.mu.Unlock()
e.cache[cacheLocalArgo].Resources = []App{
{Object: map[string]any{"metadata": map[string]any{"name": "cached-argo"}}},
}
apps := e.applications()
if cl.count("argoproj.io/v1alpha1|Application") != 0 {
t.Fatalf("listed local argo %d", cl.count("argoproj.io/v1alpha1|Application"))
t.Fatalf("applications() should not list anything, got argo %d", cl.count("argoproj.io/v1alpha1|Application"))
}
if cl.count("app.k8s.io/v1beta1|Application") != 1 {
t.Fatalf("listed subscription apps %d", cl.count("app.k8s.io/v1beta1|Application"))
if cl.count("app.k8s.io/v1beta1|Application") != 0 {
t.Fatalf("applications() should not list anything, got subscription %d", cl.count("app.k8s.io/v1beta1|Application"))
}
found := false
for _, a := range apps {
Expand Down
3 changes: 3 additions & 0 deletions backend/internal/aggregate/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ func testHandler(t *testing.T, lister Lister) *Handler {
eng := NewEngine(lister, nil, nil)
zero := 0
eng.PreLimit = &zero
eng.mu.Lock()
eng.rebuildLocalLocked()
eng.mu.Unlock()
h := NewHandler(eng, nil, AllowAll{})
h.Authn = testAuthOK
return h
Expand Down
55 changes: 52 additions & 3 deletions backend/internal/aggregate/rbac.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"sort"
"sync"
"time"
Expand Down Expand Up @@ -55,8 +56,11 @@ type cacheEntry struct {
}

type tokenState struct {
last time.Time
entries map[ssarKey]cacheEntry
last time.Time
entries map[ssarKey]cacheEntry
client kubernetes.Interface
clientErr error
clientWait chan struct{}
}

// SSARAccess ports Node getAuthorizedResources / canAccess.
Expand Down Expand Up @@ -148,6 +152,51 @@ func (a *SSARAccess) canAccessRemote(ctx context.Context, token string, clusters
return false, nil
}

func (a *SSARAccess) clientFor(token, th string) (kubernetes.Interface, error) {
a.mu.Lock()
st := a.byToken[th]
if st == nil {
st = &tokenState{entries: map[ssarKey]cacheEntry{}}
a.byToken[th] = st
}
if st.client != nil || st.clientErr != nil {
c, err := st.client, st.clientErr
a.mu.Unlock()
return c, err
}
if st.clientWait != nil {
wait := st.clientWait
a.mu.Unlock()
<-wait
a.mu.Lock()
st = a.byToken[th]
if st == nil {
a.mu.Unlock()
return nil, errors.New("token state evicted during client creation")
}
c, err := st.client, st.clientErr
a.mu.Unlock()
return c, err
}
st.clientWait = make(chan struct{})
a.mu.Unlock()

client, err := a.newClient(token)

a.mu.Lock()
st = a.byToken[th]
if st == nil {
st = &tokenState{entries: map[ssarKey]cacheEntry{}}
a.byToken[th] = st
}
st.client = client
st.clientErr = err
close(st.clientWait)
st.clientWait = nil
a.mu.Unlock()
return client, err
}

func (a *SSARAccess) ssar(ctx context.Context, token string, obj map[string]any, verb, name, namespace string) (bool, error) {
kind := kindOf(obj)
group := apiGroup(apiVersionOf(obj))
Expand All @@ -165,7 +214,7 @@ func (a *SSARAccess) ssar(ctx context.Context, token string, obj map[string]any,
}
a.mu.Unlock()

client, err := a.newClient(token)
client, err := a.clientFor(token, th)
if err != nil {
return false, err
}
Expand Down
3 changes: 3 additions & 0 deletions backend/internal/aggregate/transform_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ func TestPaginationPerPageAllAndBreakpoint(t *testing.T) {
eng := NewEngine(lister, nil, nil)
limit := 500
eng.PreLimit = &limit
eng.mu.Lock()
eng.rebuildLocalLocked()
eng.mu.Unlock()
h2 := NewHandler(eng, nil, AllowAll{})
h2.Authn = testAuthOK
resp2 := postAggregate(t, h2, "/aggregate/applications", RequestListView{Page: 1, PerPage: 10, Search: "zzz"})
Expand Down
36 changes: 32 additions & 4 deletions backend/internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"

authv1 "k8s.io/api/authentication/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
Expand Down Expand Up @@ -158,22 +159,49 @@ func UserRESTConfig(base *rest.Config, userToken string) *rest.Config {
return c
}

// tokenValidationClient caches the HTTP client used by ValidateUserTokenStatus
// so that a new transport (with its TLS session state and connection pool) is not
// allocated on every request.
var (
tokenClientMu sync.Mutex
tokenClientHost string
tokenClient *http.Client
)

func tokenValidationClient(base *rest.Config) (*http.Client, string, error) {
tokenClientMu.Lock()
defer tokenClientMu.Unlock()
host := strings.TrimRight(base.Host, "/")
if tokenClient != nil && tokenClientHost == host {
return tokenClient, host, nil
}
cfg := rest.CopyConfig(base)
cfg.BearerToken = ""
cfg.BearerTokenFile = ""
c, err := rest.HTTPClientFor(cfg)
if err != nil {
return nil, "", err
}
tokenClient = c
tokenClientHost = host
return c, host, nil
}

// ValidateUserTokenStatus probes GET /api with the user token and returns the HTTP status.
func ValidateUserTokenStatus(ctx context.Context, base *rest.Config, token string) (int, error) {
if base == nil {
return 0, errors.New("rest config is required")
}
cfg := UserRESTConfig(base, token)
httpClient, err := rest.HTTPClientFor(cfg)
client, host, err := tokenValidationClient(base)
if err != nil {
return 0, err
}
host := strings.TrimRight(cfg.Host, "/")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, host+"/api", nil)
if err != nil {
return 0, err
}
resp, err := httpClient.Do(req)
req.Header.Set("Authorization", bearerSchemePrefix+token)
resp, err := client.Do(req)
if err != nil {
return 0, err
}
Expand Down
53 changes: 52 additions & 1 deletion backend/internal/events/rbac/access.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package rbac

import (
"context"
"sort"
"sync"
"time"

Expand All @@ -16,7 +17,11 @@ import (
"github.com/stolostron/console/backend/internal/auth"
)

const accessCacheTTL = 60 * time.Second
const (
accessCacheTTL = 60 * time.Second
accessCleanupEvery = 90 * time.Second
accessCacheMaxSize = 5000
)

// AccessChecker decides whether a user token may see a ClusterRole.
type AccessChecker interface {
Expand Down Expand Up @@ -109,3 +114,49 @@ func (a *SSARAccess) ssar(ctx context.Context, userToken, verb, name string) (bo
a.mu.Unlock()
return allowed, nil
}

// StartCleanup expires SSAR cache entries periodically.
func (a *SSARAccess) StartCleanup(ctx context.Context) {
if a == nil {
return
}
go func() {
tick := time.NewTicker(accessCleanupEvery)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
a.cleanup(time.Now())
}
}
}()
}

func (a *SSARAccess) cleanup(now time.Time) {
a.mu.Lock()
defer a.mu.Unlock()
for k, e := range a.cache {
if !e.expiry.After(now) {
delete(a.cache, k)
}
}
if len(a.cache) <= accessCacheMaxSize {
return
}
// Evict oldest entries when the cache exceeds the size limit.
type pair struct {
key cacheKey
expiry time.Time
}
all := make([]pair, 0, len(a.cache))
for k, e := range a.cache {
all = append(all, pair{k, e.expiry})
}
sort.Slice(all, func(i, j int) bool { return all[i].expiry.Before(all[j].expiry) })
extra := len(all) - accessCacheMaxSize
for i := 0; i < extra; i++ {
delete(a.cache, all[i].key)
}
}
6 changes: 3 additions & 3 deletions backend/internal/informers/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,9 @@ func (c *InformerCache) runSpec(ctx context.Context, dyn dynamic.Interface, mapp
} else {
applog.Logger().Warn("informer GVR resolve failed; retrying",
"kind", st.spec.Kind, "apiVersion", st.spec.APIVersion, "error", err)
}
if inv, ok := mapper.(CacheInvalidator); ok {
inv.Invalidate()
if inv, ok := mapper.(CacheInvalidator); ok {
inv.Invalidate()
}
}
if !waitRetry(ctx) {
return
Expand Down
26 changes: 26 additions & 0 deletions backend/internal/informers/factory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,32 @@ func TestStaleDiscoveryCacheInvalidatedOnRetry(t *testing.T) {
t.Fatal("expected mapper.Invalidate() to be called on stale discovery error")
}

type unavailableMapper struct {
invalidated atomic.Bool
}

func (m *unavailableMapper) ServerResourcesForGroupVersion(string) (*metav1.APIResourceList, error) {
return nil, apierrors.NewNotFound(schema.GroupResource{Group: "tower.ansible.com", Resource: "ansiblejobs"}, "")
}

func (m *unavailableMapper) Invalidate() {
m.invalidated.Store(true)
}

func TestUnavailableCRDDoesNotInvalidateCache(t *testing.T) {
mapper := &unavailableMapper{}

ctx, cancel := context.WithCancel(context.Background())
_ = StartSpecs(ctx, nil, mapper, []WatchSpec{watch("AnsibleJob", "tower.ansible.com/v1alpha1")})

time.Sleep(200 * time.Millisecond)
cancel()

if mapper.invalidated.Load() {
t.Fatal("Invalidate() should not be called for unavailable CRDs")
}
}

func TestStartConcurrencyLimitsLists(t *testing.T) {
orig := startConcurrency
startConcurrency = 2
Expand Down
1 change: 1 addition & 0 deletions backend/internal/informers/specs.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ func DefaultWatchSpecs() []WatchSpec {
watch("Secret", "v1").fields("metadata.name", "auto-import-secret"),
watch("Secret", "v1").labels("argocd.argoproj.io/secret-type", "repository"),
watch("PolicyReport", "wgpolicyk8s.io/v1alpha2"),
watch("ClusterRole", "rbac.authorization.k8s.io/v1").labels("rbac.open-cluster-management.io/filter", "vm-clusterroles"),
watch("HostedCluster", "hypershift.openshift.io/v1beta1"),
watch("NodePool", "hypershift.openshift.io/v1beta1"),
watch("AgentMachine", "capi-provider.agent-install.openshift.io/v1alpha1"),
Expand Down
12 changes: 6 additions & 6 deletions backend/internal/informers/specs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import (

func TestDefaultWatchSpecsCount(t *testing.T) {
specs := DefaultWatchSpecs()
if len(specs) != 68 {
t.Fatalf("got %d specs, want 68", len(specs))
if len(specs) != 69 {
t.Fatalf("got %d specs, want 69", len(specs))
}
var polled, cacheOnly, withSel int
for _, s := range specs {
Expand All @@ -29,8 +29,8 @@ func TestDefaultWatchSpecsCount(t *testing.T) {
if cacheOnly != 2 {
t.Fatalf("cacheOnly=%d want 2 (Authentication, MultiClusterHub)", cacheOnly)
}
if withSel != 12 {
t.Fatalf("selector specs=%d want 12", withSel)
if withSel != 13 {
t.Fatalf("selector specs=%d want 13", withSel)
}
}

Expand Down Expand Up @@ -92,8 +92,8 @@ func TestDefaultWatchSpecsShouldForwardCount(t *testing.T) {
skip++
}
}
if forward != 64 {
t.Fatalf("forward=%d want 64", forward)
if forward != 65 {
t.Fatalf("forward=%d want 65", forward)
}
if skip != 4 {
t.Fatalf("skip=%d want 4 (2 polled + 2 cacheOnly)", skip)
Expand Down
Loading