From 6a69242bdea41ef802f2cb33627179d4a20c4767 Mon Sep 17 00:00:00 2001 From: Super User Date: Tue, 4 Aug 2026 13:35:13 +0200 Subject: [PATCH 1/8] ACM-39327: bound non-admin SSE memory and RBAC checks Avoid O(N) SelfSubjectAccessReviews and inflate-before-filter on the /events stream so restricted users no longer OOM the console backend under large inventory. Signed-off-by: Enrique Mingorance Cano --- backend/src/lib/compression.ts | 13 +- backend/src/lib/server-side-events.ts | 51 +++- backend/src/routes/events.ts | 263 ++++++++++++++++++-- backend/test/lib/server-side-events.test.ts | 139 +++++++++++ backend/test/routes/events.test.ts | 222 ++++++++++++++++- 5 files changed, 651 insertions(+), 37 deletions(-) create mode 100644 backend/test/lib/server-side-events.test.ts diff --git a/backend/src/lib/compression.ts b/backend/src/lib/compression.ts index cdf8113d271..1491eb26ecd 100644 --- a/backend/src/lib/compression.ts +++ b/backend/src/lib/compression.ts @@ -243,11 +243,18 @@ export async function inflateResource(buffer: Buffer, dictionary: Dictionary): P } export async function inflateEvent(event: ServerSideEvent): Promise { - const { id, data } = event - const { type, object } = data as WatchEvent + const { id, name, namespace, data } = event + if (!data || typeof data !== 'object') return event + const watchEvent = data as WatchEvent & { meta?: unknown } + const { type, object } = watchEvent return !object ? event - : { id, data: { type, object: Buffer.isBuffer(object) ? await inflateResource(object, getEventDict()) : object } } + : { + id, + name, + namespace, + data: { type, object: Buffer.isBuffer(object) ? await inflateResource(object, getEventDict()) : object }, + } } export async function inflateApps(apps: ICompressedResource[]): Promise { diff --git a/backend/src/lib/server-side-events.ts b/backend/src/lib/server-side-events.ts index 73840733644..b703ec9d919 100644 --- a/backend/src/lib/server-side-events.ts +++ b/backend/src/lib/server-side-events.ts @@ -4,7 +4,6 @@ import { constants } from 'node:http2' import type { Transform } from 'node:stream' import { clearInterval } from 'node:timers' import type { Zlib } from 'node:zlib' -import { batchPromiseAll } from './batch-promise-all' import { getEncodeStream, inflateEvent } from './compression' import { setCookie } from './cookies' import { logger } from './logger' @@ -35,6 +34,15 @@ export interface ServerSideEvent { namespace?: string data?: DataT } + +/** Lightweight resource identity for RBAC filtering without inflating compressed objects. */ +export interface EventResourceMeta { + kind: string + apiVersion: string + name?: string + namespace?: string +} + export interface WatchEvent { type: 'ADDED' | 'DELETED' | 'MODIFIED' | 'EOP' object: { @@ -46,6 +54,24 @@ export interface WatchEvent { resourceVersion: string } } + meta?: EventResourceMeta +} + +/** Resolve kind/apiVersion/name/namespace from meta or an already-inflated object. */ +export function getEventResourceMeta(event: ServerSideEvent): EventResourceMeta | undefined { + const data = event.data as (WatchEvent & { type?: string }) | undefined + if (!data || typeof data !== 'object') return undefined + if (data.meta?.kind) return data.meta + const object = data.object as WatchEvent['object'] | Buffer | undefined + if (object && !Buffer.isBuffer(object) && typeof object === 'object' && object.kind) { + return { + kind: object.kind, + apiVersion: object.apiVersion, + name: object.metadata?.name, + namespace: object.metadata?.namespace, + } + } + return undefined } export interface ServerSideEventClient { @@ -130,15 +156,18 @@ export class ServerSideEvents { if (!client) return if (client.events && !client.events[event.name]) return if (client.namespaces && !client.namespaces[event.namespace]) return - event = await inflateEvent(event) + // Filter before inflate so denied events never materialize full resource JSON in memory. if (this.eventFilter) { client.eventQueue.push( this.eventFilter(client.token, event) - .then((shouldSendEvent) => (shouldSendEvent ? event : undefined)) + .then(async (shouldSendEvent) => { + if (!shouldSendEvent) return undefined + return inflateEvent(event) + }) .catch((): undefined => undefined) ) } else { - client.eventQueue.push(Promise.resolve(event)) + client.eventQueue.push(inflateEvent(event)) } void this.processClient(clientID) } @@ -311,10 +340,10 @@ export class ServerSideEvents { // SORT EVENTS INTO SMALLER PACKETS // SO THAT BROWSER PAGE LOADS QUICKER - // uncompress and split events into packets + // Classify using meta / inflated object identity — do not inflate the whole cache up front. const values = Object.values(this.events) const compressed = sizeOf(values) - let parts = await batchPromiseAll(values, (event) => inflateEvent(event)) + let parts: ServerSideEvent[] = [...values] // mock a large environment if (process.env.MOCK_CLUSTERS) { @@ -343,9 +372,9 @@ export class ServerSideEvents { const other: ServerSideEvent[] = [] const remainder: ServerSideEvent[] = [] parts.forEach((event) => { - const data = event.data as WatchEvent + const meta = getEventResourceMeta(event) // see frontend/src/components/LoadPluginData.tsx for what pages are fast loaded - switch (data.object.kind) { + switch (meta?.kind) { case 'ManagedCluster': case 'HostedCluster': case 'ClusterDeployment': @@ -384,9 +413,9 @@ export class ServerSideEvents { // sort events alphabetically so that browser list fills from top to bottom const compareFn = (propName: 'name' | 'namespace') => (a: ServerSideEvent, b: ServerSideEvent) => { - const adata = a.data as WatchEvent - const bdata = b.data as WatchEvent - return adata.object.metadata[propName].localeCompare(bdata.object.metadata[propName]) + const aVal = getEventResourceMeta(a)?.[propName] ?? '' + const bVal = getEventResourceMeta(b)?.[propName] ?? '' + return aVal.localeCompare(bVal) } clusters.sort(compareFn('name')) infos.sort(compareFn('namespace')) diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 6071e9e985f..96120df7565 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -1,5 +1,6 @@ /* Copyright Contributors to the Open Cluster Management project */ +import { createHash } from 'node:crypto' import get from 'get-value' import got, { CancelError, HTTPError, TimeoutError } from 'got' import { Http2ServerRequest, Http2ServerResponse } from 'node:http2' @@ -10,7 +11,12 @@ import { batchPromiseAll } from '../lib/batch-promise-all' import { createDictionary, deflateResource, inflateResource } from '../lib/compression' import { jsonPost } from '../lib/json-request' import { logger } from '../lib/logger' -import { type ServerSideEvent, ServerSideEvents } from '../lib/server-side-events' +import { + type EventResourceMeta, + type ServerSideEvent, + ServerSideEvents, + getEventResourceMeta, +} from '../lib/server-side-events' import { getCACertificate, getServiceAccountToken } from '../lib/serviceAccountToken' import { getAuthenticatedToken } from '../lib/token' import type { IResource } from '../resources/resource' @@ -167,11 +173,36 @@ export function getEventDict() { const accessCache: Record }>> = {} +interface SubjectRulesStatus { + incomplete: boolean + resourceRules: Array<{ + verbs?: string[] + apiGroups?: string[] + resources?: string[] + resourceNames?: string[] + }> +} + +type KindGetAccess = + | { type: 'deny-all' } + | { type: 'allow-all' } + | { type: 'allow-names'; names: Set } + | { type: 'incomplete' } + +const subjectRulesCache: Record }> = {} +const kindGetAccessCache: Record }> = {} + /** Clear all cached RBAC access checks. Used for test isolation. */ export function resetAccessCache() { for (const key in accessCache) { delete accessCache[key] } + for (const key in subjectRulesCache) { + delete subjectRulesCache[key] + } + for (const key in kindGetAccessCache) { + delete kindGetAccessCache[key] + } } export function getAccessCache() { @@ -181,6 +212,22 @@ export function getAccessCache() { export const ACCESS_CACHE_TTL = 60 * 1000 // 60 seconds export const ACCESS_CACHE_CLEANUP_INTERVAL = 90 * 1000 // 90 seconds export const ACCESS_CACHE_MAX_TOKENS = 1000 // Maximum number of token entries to keep +export const ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN = 2000 // Cap RBAC keys retained per token + +/** Hash bearer tokens so the access cache does not retain full JWTs as object keys. */ +export function hashAccessToken(token: string): string { + return createHash('sha256').update(token).digest('hex') +} + +function enforceAccessCacheEntryCap(tokenCache: Record }>) { + const keys = Object.keys(tokenCache) + if (keys.length <= ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN) return + keys.sort((a, b) => tokenCache[a].time - tokenCache[b].time) + const toRemove = keys.length - ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN + for (let i = 0; i < toRemove; i++) { + delete tokenCache[keys[i]] + } +} let accessCacheCleanupTimer: NodeJS.Timeout | undefined @@ -204,10 +251,22 @@ export function cleanupAccessCache() { if (Object.keys(tokenCache).length === 0) { delete accessCache[token] } else { + enforceAccessCacheEntryCap(tokenCache) tokenStats.push({ token, newestTime }) } } + for (const key in subjectRulesCache) { + if (subjectRulesCache[key].time < cutoffTime) { + delete subjectRulesCache[key] + } + } + for (const key in kindGetAccessCache) { + if (kindGetAccessCache[key].time < cutoffTime) { + delete kindGetAccessCache[key] + } + } + if (tokenStats.length > ACCESS_CACHE_MAX_TOKENS) { tokenStats.sort((a, b) => a.newestTime - b.newestTime) const tokensToRemove = tokenStats.length - ACCESS_CACHE_MAX_TOKENS @@ -826,8 +885,18 @@ export async function cacheResource(resource: IResource, forwardEventsToClients existing = latestExisting } const compressed = deflateResource(resource, eventDict) + const meta: EventResourceMeta = { + kind: resource.kind, + apiVersion: resource.apiVersion, + name: resource.metadata?.name, + namespace: resource.metadata?.namespace, + } const eventID = forwardEventsToClients - ? compressed.then((compressed) => ServerSideEvents.pushEvent({ data: { type: 'MODIFIED', object: compressed } })) + ? compressed.then((compressed) => + ServerSideEvents.pushEvent({ + data: { type: 'MODIFIED', object: compressed, meta }, + }) + ) : NO_BROADCAST_EVENT_ID cache[uid] = { compressed, eventID } @@ -870,6 +939,12 @@ async function deleteResource(resource: IResource, forwardEventsToClients = true apiVersion: resource.apiVersion, metadata: { name: resource.metadata.name, namespace: resource.metadata.namespace }, }, + meta: { + kind: resource.kind, + apiVersion: resource.apiVersion, + name: resource.metadata.name, + namespace: resource.metadata.namespace, + }, }, }) // after deletion has been broadcast to current clients, no need to retain @@ -903,14 +978,30 @@ function eventFilter(token: string, serverSideEvent: ServerSideEvent { if (allowed) return true - return canListNamespacedScopedKind(resource, token).then((allowed) => { - if (allowed) return true - return canGetResource(resource, token) - }) + // After cluster list is denied, use one SelfSubjectRulesReview per token/kind. + // Do NOT fall through to namespaced list SSAR first — that is O(namespaces) and + // OOMs/hangs restricted users when MOCK_CLUSTERS (or real inventory) is large. + return resolveKindGetAccess(resource.kind, resource.apiVersion, token).then((access) => + applyKindGetAccess(access, resource, token, () => + // Authorizer could not enumerate rules; keep prior namespaced-list then get fallback. + canListNamespacedScopedKind(resource, token).then((nsAllowed) => { + if (nsAllowed) return true + return canAccess(resource, 'get', token) + }) + ) + ) }) } default: @@ -919,11 +1010,17 @@ function eventFilter(token: string, serverSideEvent: ServerSideEvent { +function canListClusterScopedKind( + resource: { kind: string; apiVersion: string; metadata?: { name?: string; namespace?: string } }, + token: string +): Promise { return canAccess({ kind: resource.kind, apiVersion: resource.apiVersion }, 'list', token) } -function canListNamespacedScopedKind(resource: IResource, token: string): Promise { +function canListNamespacedScopedKind( + resource: { kind: string; apiVersion: string; metadata?: { name?: string; namespace?: string } }, + token: string +): Promise { if (!resource.metadata?.namespace) return Promise.resolve(false) return canAccess( { @@ -936,8 +1033,127 @@ function canListNamespacedScopedKind(resource: IResource, token: string): Promis ) } -function canGetResource(resource: IResource, token: string): Promise { - return canAccess(resource, 'get', token) +/** Used by SSE eventFilter after list checks fail; prefers SelfSubjectRulesReview over N SSARs. */ +export function canGetResource( + resource: { kind: string; apiVersion: string; metadata?: { name?: string; namespace?: string } }, + token: string +): Promise { + return resolveKindGetAccess(resource.kind, resource.apiVersion, token).then((access) => + applyKindGetAccess(access, resource, token) + ) +} + +function applyKindGetAccess( + access: KindGetAccess, + resource: { kind: string; apiVersion: string; metadata?: { name?: string; namespace?: string } }, + token: string, + onIncomplete?: () => Promise +): Promise { + switch (access.type) { + case 'deny-all': + return Promise.resolve(false) + case 'allow-all': + return Promise.resolve(true) + case 'allow-names': + return Promise.resolve(resource.metadata?.name ? access.names.has(resource.metadata.name) : false) + case 'incomplete': + return onIncomplete ? onIncomplete() : canAccess(resource, 'get', token) + } +} + +/** + * One SelfSubjectRulesReview per token (namespace "default"). + * ClusterRole bindings appear here; avoids an SSRR storm across MOCK_CLUSTERS namespaces. + */ +function getSubjectRules(token: string): Promise { + const cacheKey = hashAccessToken(token) + const existing = subjectRulesCache[cacheKey] + if (existing && existing.time > Date.now() - ACCESS_CACHE_TTL) { + return existing.promise + } + + const promise = jsonPost<{ + status?: { + incomplete?: boolean + resourceRules?: SubjectRulesStatus['resourceRules'] + } + }>( + process.env.CLUSTER_API_URL + '/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', + { + apiVersion: 'authorization.k8s.io/v1', + kind: 'SelfSubjectRulesReview', + metadata: {}, + // Namespace is required by the API; ClusterRole bindings are included for any namespace. + spec: { namespace: 'default' }, + }, + token + ) + .then((result) => ({ + incomplete: result.body?.status?.incomplete ?? false, + resourceRules: result.body?.status?.resourceRules ?? [], + })) + .catch((err: unknown) => { + logger.warn({ msg: 'SelfSubjectRulesReview failed; falling back to per-object SSAR', error: err }) + return { incomplete: true, resourceRules: [] as SubjectRulesStatus['resourceRules'] } + }) + + subjectRulesCache[cacheKey] = { time: Date.now(), promise } + return promise +} + +function evaluateKindGetAccess(rules: SubjectRulesStatus, kind: string, apiVersion: string): KindGetAccess { + const group = apiVersion.includes('/') ? apiVersion.split('/')[0] : '' + const resourcePlural = pluralize(kind.toLowerCase()) + const accessVerbs = new Set(['get', 'list', 'watch']) + + let allowAll = false + const names = new Set() + + for (const rule of rules.resourceRules) { + const verbs = rule.verbs ?? [] + if (!verbs.includes('*') && !verbs.some((verb) => accessVerbs.has(verb))) continue + + const groups = rule.apiGroups ?? [] + if (!groups.includes('*') && !groups.includes(group)) continue + + const resources = rule.resources ?? [] + if (!resources.includes('*') && !resources.includes(resourcePlural)) continue + + const resourceNames = rule.resourceNames + if (!resourceNames || resourceNames.length === 0 || resourceNames.includes('*')) { + allowAll = true + break + } + for (const name of resourceNames) names.add(name) + } + + switch (true) { + case allowAll: + return { type: 'allow-all' } + case names.size > 0: + return { type: 'allow-names', names } + // OpenShift often sets incomplete=true even when the user has no bindings and resourceRules + // is empty. Treat empty rules as deny-all so we do not fall back to O(N) namespaced SSARs. + case rules.resourceRules.length === 0: + return { type: 'deny-all' } + // Non-empty but incomplete: authorizer may have omitted grants for this kind — fall back. + case rules.incomplete: + return { type: 'incomplete' } + default: + return { type: 'deny-all' } + } +} + +function resolveKindGetAccess(kind: string, apiVersion: string, token: string): Promise { + const cacheKey = `${hashAccessToken(token)}:${kind}:${apiVersion}` + const existing = kindGetAccessCache[cacheKey] + if (existing && existing.time > Date.now() - ACCESS_CACHE_TTL) { + return existing.promise + } + + const promise = getSubjectRules(token).then((rules) => evaluateKindGetAccess(rules, kind, apiVersion)) + kindGetAccessCache[cacheKey] = { time: Date.now(), promise } + return promise } export function canAccess( @@ -946,10 +1162,10 @@ export function canAccess( token: string ): Promise { // Cache is cleaned up periodically by cleanupAccessCache() to prevent unbounded memory growth - - const key = `${resource.kind}:${resource.metadata?.namespace}:${resource.metadata?.name}` - if (!accessCache[token]) accessCache[token] = {} - const existing = accessCache[token][key] + const tokenKey = hashAccessToken(token) + const key = `${verb}:${resource.kind}:${resource.metadata?.namespace}:${resource.metadata?.name}` + if (!accessCache[tokenKey]) accessCache[tokenKey] = {} + const existing = accessCache[tokenKey][key] if (existing && existing.time > Date.now() - ACCESS_CACHE_TTL) { return existing.promise } @@ -973,23 +1189,30 @@ export function canAccess( }, token ).then((result) => { + const allowed = result.body.status.allowed if (process.env.LOG_ACCESS === 'true') { logger.debug({ msg: 'access', - allowed: result.body.status.allowed, + allowed, verb, resource: pluralize(resource.kind.toLowerCase()), name: resource.metadata?.name, namespace: resource.metadata?.namespace, }) } - return result.body.status.allowed + // Replace in-flight promise with a settled boolean promise to drop large closures. + const entry = accessCache[tokenKey]?.[key] + if (entry && entry.promise === promise) { + entry.promise = Promise.resolve(allowed) + } + return allowed }) - accessCache[token][key] = { + accessCache[tokenKey][key] = { time: Date.now(), promise, } + enforceAccessCacheEntryCap(accessCache[tokenKey]) return promise } @@ -1014,4 +1237,4 @@ function pruneResources(option: IWatchOptions, items: IResource[]) { } return resource }) -} +} \ No newline at end of file diff --git a/backend/test/lib/server-side-events.test.ts b/backend/test/lib/server-side-events.test.ts new file mode 100644 index 00000000000..0373af8b355 --- /dev/null +++ b/backend/test/lib/server-side-events.test.ts @@ -0,0 +1,139 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +import { Writable } from 'node:stream' +import * as compression from '../../src/lib/compression' +import { getEventResourceMeta, ServerSideEvents, type ServerSideEvent } from '../../src/lib/server-side-events' + +describe('getEventResourceMeta', () => { + it('prefers meta over object when both are present', () => { + const event: ServerSideEvent = { + data: { + type: 'MODIFIED', + meta: { + kind: 'ManagedCluster', + apiVersion: 'cluster.open-cluster-management.io/v1', + name: 'from-meta', + }, + object: { + kind: 'ManagedCluster', + apiVersion: 'cluster.open-cluster-management.io/v1', + metadata: { name: 'from-object', namespace: '', resourceVersion: '1' }, + }, + }, + } + expect(getEventResourceMeta(event)?.name).toBe('from-meta') + }) + + it('reads identity from inflated object when meta is missing', () => { + const event: ServerSideEvent = { + data: { + type: 'MODIFIED', + object: { + kind: 'Policy', + apiVersion: 'policy.open-cluster-management.io/v1', + metadata: { name: 'p1', namespace: 'ns1', resourceVersion: '1' }, + }, + }, + } + expect(getEventResourceMeta(event)).toEqual({ + kind: 'Policy', + apiVersion: 'policy.open-cluster-management.io/v1', + name: 'p1', + namespace: 'ns1', + }) + }) + + it('returns undefined for compressed object without meta', () => { + const event: ServerSideEvent = { + data: { + type: 'MODIFIED', + object: Buffer.from('compressed'), + }, + } + expect(getEventResourceMeta(event)).toBeUndefined() + }) +}) + +describe('ServerSideEvents filter-before-inflate (ACM-39327)', () => { + beforeEach(() => { + ServerSideEvents.reset() + jest.restoreAllMocks() + }) + + afterEach(() => { + ServerSideEvents.eventFilter = undefined as unknown as typeof ServerSideEvents.eventFilter + ServerSideEvents.reset() + }) + + it('does not inflate denied events', async () => { + const inflateSpy = jest.spyOn(compression, 'inflateEvent') + ServerSideEvents.eventFilter = async () => false + + const writableStream = new Writable({ + write(_chunk, _encoding, callback) { + callback() + }, + }) + const clients = ServerSideEvents.getClients() + clients['deny-client'] = { + token: 'token', + writableStream, + compressionStream: undefined as unknown as (typeof clients)[string]['compressionStream'], + eventQueue: [], + } + + await ServerSideEvents.pushEvent({ + data: { + type: 'MODIFIED', + meta: { + kind: 'ManagedCluster', + apiVersion: 'cluster.open-cluster-management.io/v1', + name: 'cluster-1', + }, + object: Buffer.from('should-not-inflate'), + }, + }) + + // Allow the async eventQueue filter/processClient work to settle. + await new Promise((resolve) => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) + + expect(inflateSpy).not.toHaveBeenCalled() + }) + + it('inflates events only after the filter allows them', async () => { + const inflateSpy = jest.spyOn(compression, 'inflateEvent').mockImplementation(async (event) => event) + ServerSideEvents.eventFilter = async () => true + + const writableStream = new Writable({ + write(_chunk, _encoding, callback) { + callback() + }, + }) + const clients = ServerSideEvents.getClients() + clients['allow-client'] = { + token: 'token', + writableStream, + compressionStream: undefined as unknown as (typeof clients)[string]['compressionStream'], + eventQueue: [], + } + + const event: ServerSideEvent = { + data: { + type: 'MODIFIED', + meta: { + kind: 'ManagedCluster', + apiVersion: 'cluster.open-cluster-management.io/v1', + name: 'cluster-1', + }, + object: { kind: 'ManagedCluster', apiVersion: 'v1', metadata: { name: 'cluster-1', namespace: '', resourceVersion: '1' } }, + }, + } + await ServerSideEvents.pushEvent(event) + + await new Promise((resolve) => setImmediate(resolve)) + await new Promise((resolve) => setImmediate(resolve)) + + expect(inflateSpy).toHaveBeenCalled() + }) +}) diff --git a/backend/test/routes/events.test.ts b/backend/test/routes/events.test.ts index 215ea9834d4..de93537e3e7 100644 --- a/backend/test/routes/events.test.ts +++ b/backend/test/routes/events.test.ts @@ -16,11 +16,14 @@ import { listAndWatch, stopWatching, canAccess, + canGetResource, resetAccessCache, getAccessCache, cleanupAccessCache, + hashAccessToken, ACCESS_CACHE_TTL, ACCESS_CACHE_MAX_TOKENS, + ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN, } from '../../src/routes/events' import * as serviceAccountTokenModule from '../../src/lib/serviceAccountToken' import type { IArgoApplication, IResource } from '../../src/resources/resource' @@ -1425,7 +1428,7 @@ describe('events Route', () => { nock.cleanAll() }) - it('should cache RBAC access check results', async () => { + it('should cache RBAC access check results under hashed token keys', async () => { const mockToken = 'test-token-123' const resource = { kind: 'Pod', apiVersion: 'v1', metadata: { namespace: 'default', name: 'test-pod' } } @@ -1438,14 +1441,39 @@ describe('events Route', () => { expect(result1).toBe(true) expect(result1).toBe(result2) + expect(getAccessCache()[mockToken]).toBeUndefined() + expect(getAccessCache()[hashAccessToken(mockToken)]['get:Pod:default:test-pod']).toBeDefined() + }) + + it('should use distinct cache keys per verb', async () => { + const mockToken = 'test-token-verb' + const resource = { kind: 'Pod', apiVersion: 'v1', metadata: { namespace: 'default', name: 'test-pod' } } + + nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: false } }) + + expect(await canAccess(resource, 'get', mockToken)).toBe(true) + expect(await canAccess(resource, 'list', mockToken)).toBe(false) + + const tokenCache = getAccessCache()[hashAccessToken(mockToken)] + expect(tokenCache['get:Pod:default:test-pod']).toBeDefined() + expect(tokenCache['list:Pod:default:test-pod']).toBeDefined() }) it('should respect TTL and refetch after expiry', async () => { const cache = getAccessCache() const mockToken = 'test-token-ttl' + const tokenKey = hashAccessToken(mockToken) - cache[mockToken] = { - 'Secret:default:credentials': { time: Date.now() - ACCESS_CACHE_TTL - 1000, promise: Promise.resolve(true) }, + cache[tokenKey] = { + 'get:Secret:default:credentials': { + time: Date.now() - ACCESS_CACHE_TTL - 1000, + promise: Promise.resolve(true), + }, } nock(process.env.CLUSTER_API_URL || '') @@ -1494,5 +1522,193 @@ describe('events Route', () => { expect(cache['token-0']).toBeDefined() expect(cache[`token-${tokenCount - 1}`]).toBeUndefined() }) + + it('should enforce maximum entries per token', async () => { + const mockToken = 'test-token-entry-cap' + + nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .times(ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN + 50) + .reply(200, { status: { allowed: false } }) + + for (let i = 0; i < ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN + 50; i++) { + await canAccess( + { kind: 'Pod', apiVersion: 'v1', metadata: { namespace: 'default', name: `pod-${i}` } }, + 'get', + mockToken + ) + } + + const tokenCache = getAccessCache()[hashAccessToken(mockToken)] + expect(Object.keys(tokenCache).length).toBeLessThanOrEqual(ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN) + }) + }) + + /** + * ACM-39327: restricted users must not trigger O(N) SelfSubjectAccessReviews when the SSE + * filter falls through after cluster-scoped list is denied. One SelfSubjectRulesReview per + * token/kind short-circuits deny-all / allow-names without per-object or per-namespace SSARs. + */ + describe('SelfSubjectRulesReview short-circuit (ACM-39327)', () => { + const managedCluster = (name: string) => ({ + kind: 'ManagedCluster', + apiVersion: 'cluster.open-cluster-management.io/v1', + metadata: { name }, + }) + + beforeEach(() => { + resetAccessCache() + jest.clearAllMocks() + process.env.CLUSTER_API_URL = 'https://api.test-cluster.com:6443' + }) + + afterEach(() => { + resetAccessCache() + delete process.env.CLUSTER_API_URL + nock.cleanAll() + }) + + it('should deny all gets from complete empty rules without per-object SSAR', async () => { + nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') + .reply(200, { status: { incomplete: false, resourceRules: [] } }) + + const ssarScope = nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + + expect(await canGetResource(managedCluster('cluster-1'), 'none-user-token')).toBe(false) + expect(ssarScope.isDone()).toBe(false) + }) + + it('should use a single rules review for many gets of the same kind', async () => { + let rulesCalls = 0 + nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') + .reply(200, () => { + rulesCalls++ + return { status: { incomplete: false, resourceRules: [] } } + }) + + const ssarScope = nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .times(1) + .reply(200, { status: { allowed: true } }) + + const results = await Promise.all( + Array.from({ length: 500 }, (_, i) => canGetResource(managedCluster(`cluster-${i}`), 'scale-none-token')) + ) + + expect(results.every((allowed) => allowed === false)).toBe(true) + expect(rulesCalls).toBe(1) + // Regression guard: must not fall back to per-object SSAR for deny-all. + expect(ssarScope.isDone()).toBe(false) + }) + + it('should use a single rules review across many namespaces of the same kind', async () => { + let rulesCalls = 0 + nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') + .reply(200, () => { + rulesCalls++ + return { status: { incomplete: true, resourceRules: [] } } + }) + + const results = await Promise.all( + Array.from({ length: 200 }, (_, i) => + canGetResource( + { + kind: 'ManagedClusterInfo', + apiVersion: 'internal.open-cluster-management.io/v1beta1', + metadata: { name: `cluster-${i}`, namespace: `cluster-${i}` }, + }, + 'namespaced-none-token' + ) + ) + ) + + expect(results.every((allowed) => allowed === false)).toBe(true) + expect(rulesCalls).toBe(1) + }) + + it('should allow only named resources from resourceNames rules', async () => { + nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') + .reply(200, { + status: { + incomplete: false, + resourceRules: [ + { + verbs: ['get'], + apiGroups: ['cluster.open-cluster-management.io'], + resources: ['managedclusters'], + resourceNames: ['allowed-cluster'], + }, + ], + }, + }) + + expect(await canGetResource(managedCluster('allowed-cluster'), 'partial-user-token')).toBe(true) + expect(await canGetResource(managedCluster('other-cluster'), 'partial-user-token')).toBe(false) + }) + + it('should allow all resources when rules grant unrestricted get/list/watch', async () => { + nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') + .reply(200, { + status: { + incomplete: false, + resourceRules: [ + { + verbs: ['get', 'list', 'watch'], + apiGroups: ['cluster.open-cluster-management.io'], + resources: ['managedclusters'], + }, + ], + }, + }) + + const ssarScope = nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: false } }) + + expect(await canGetResource(managedCluster('any-cluster'), 'viewer-token')).toBe(true) + expect(ssarScope.isDone()).toBe(false) + }) + + it('should fall back to SSAR when rules review is incomplete with non-empty rules', async () => { + nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') + .reply(200, { + status: { + incomplete: true, + resourceRules: [ + { + verbs: ['get'], + apiGroups: [''], + resources: ['pods'], + }, + ], + }, + }) + nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + + expect(await canGetResource(managedCluster('cluster-1'), 'incomplete-user-token')).toBe(true) + }) + + it('should deny-all when rules are empty even if incomplete is true (OpenShift none user)', async () => { + nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') + .reply(200, { status: { incomplete: true, resourceRules: [] } }) + + const ssarScope = nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + + expect(await canGetResource(managedCluster('cluster-1'), 'openshift-none-token')).toBe(false) + expect(ssarScope.isDone()).toBe(false) + }) }) }) From c35ff3f46de3364135bf416e075c31dfc3f5c530 Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Tue, 4 Aug 2026 13:37:59 +0200 Subject: [PATCH 2/8] ACM-39327: apply formatting from lint-staged Signed-off-by: Enrique Mingorance Cano --- backend/src/routes/events.ts | 7 ++----- backend/test/lib/server-side-events.test.ts | 12 ++++++++---- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 96120df7565..2508b273a26 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -184,10 +184,7 @@ interface SubjectRulesStatus { } type KindGetAccess = - | { type: 'deny-all' } - | { type: 'allow-all' } - | { type: 'allow-names'; names: Set } - | { type: 'incomplete' } + { type: 'deny-all' } | { type: 'allow-all' } | { type: 'allow-names'; names: Set } | { type: 'incomplete' } const subjectRulesCache: Record }> = {} const kindGetAccessCache: Record }> = {} @@ -1237,4 +1234,4 @@ function pruneResources(option: IWatchOptions, items: IResource[]) { } return resource }) -} \ No newline at end of file +} diff --git a/backend/test/lib/server-side-events.test.ts b/backend/test/lib/server-side-events.test.ts index 0373af8b355..41d7962329b 100644 --- a/backend/test/lib/server-side-events.test.ts +++ b/backend/test/lib/server-side-events.test.ts @@ -61,7 +61,7 @@ describe('ServerSideEvents filter-before-inflate (ACM-39327)', () => { }) afterEach(() => { - ServerSideEvents.eventFilter = undefined as unknown as typeof ServerSideEvents.eventFilter + ServerSideEvents.eventFilter = undefined ServerSideEvents.reset() }) @@ -78,7 +78,7 @@ describe('ServerSideEvents filter-before-inflate (ACM-39327)', () => { clients['deny-client'] = { token: 'token', writableStream, - compressionStream: undefined as unknown as (typeof clients)[string]['compressionStream'], + compressionStream: undefined, eventQueue: [], } @@ -114,7 +114,7 @@ describe('ServerSideEvents filter-before-inflate (ACM-39327)', () => { clients['allow-client'] = { token: 'token', writableStream, - compressionStream: undefined as unknown as (typeof clients)[string]['compressionStream'], + compressionStream: undefined, eventQueue: [], } @@ -126,7 +126,11 @@ describe('ServerSideEvents filter-before-inflate (ACM-39327)', () => { apiVersion: 'cluster.open-cluster-management.io/v1', name: 'cluster-1', }, - object: { kind: 'ManagedCluster', apiVersion: 'v1', metadata: { name: 'cluster-1', namespace: '', resourceVersion: '1' } }, + object: { + kind: 'ManagedCluster', + apiVersion: 'v1', + metadata: { name: 'cluster-1', namespace: '', resourceVersion: '1' }, + }, }, } await ServerSideEvents.pushEvent(event) From b42bb19c72e0487ec390b6a9841275279594e9ff Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Tue, 4 Aug 2026 15:21:36 +0200 Subject: [PATCH 3/8] server-side-event async removal Signed-off-by: Enrique Mingorance Cano --- backend/src/lib/server-side-events.ts | 11 ++++++----- backend/test/lib/server-side-events.test.ts | 6 +++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/backend/src/lib/server-side-events.ts b/backend/src/lib/server-side-events.ts index b703ec9d919..3cb776f63e6 100644 --- a/backend/src/lib/server-side-events.ts +++ b/backend/src/lib/server-side-events.ts @@ -151,16 +151,16 @@ export class ServerSideEvents { } } - private static async sendEvent(clientID: string, event: ServerSideEvent): Promise { + private static sendEvent(clientID: string, event: ServerSideEvent): Promise { const client = this.clients[clientID] - if (!client) return - if (client.events && !client.events[event.name]) return - if (client.namespaces && !client.namespaces[event.namespace]) return + if (!client) return Promise.resolve() + if (client.events && !client.events[event.name]) return Promise.resolve() + if (client.namespaces && !client.namespaces[event.namespace]) return Promise.resolve() // Filter before inflate so denied events never materialize full resource JSON in memory. if (this.eventFilter) { client.eventQueue.push( this.eventFilter(client.token, event) - .then(async (shouldSendEvent) => { + .then((shouldSendEvent) => { if (!shouldSendEvent) return undefined return inflateEvent(event) }) @@ -170,6 +170,7 @@ export class ServerSideEvents { client.eventQueue.push(inflateEvent(event)) } void this.processClient(clientID) + return Promise.resolve() } private static async processClient(clientID: string): Promise { diff --git a/backend/test/lib/server-side-events.test.ts b/backend/test/lib/server-side-events.test.ts index 41d7962329b..794928bcaca 100644 --- a/backend/test/lib/server-side-events.test.ts +++ b/backend/test/lib/server-side-events.test.ts @@ -67,7 +67,7 @@ describe('ServerSideEvents filter-before-inflate (ACM-39327)', () => { it('does not inflate denied events', async () => { const inflateSpy = jest.spyOn(compression, 'inflateEvent') - ServerSideEvents.eventFilter = async () => false + ServerSideEvents.eventFilter = () => Promise.resolve(false) const writableStream = new Writable({ write(_chunk, _encoding, callback) { @@ -102,8 +102,8 @@ describe('ServerSideEvents filter-before-inflate (ACM-39327)', () => { }) it('inflates events only after the filter allows them', async () => { - const inflateSpy = jest.spyOn(compression, 'inflateEvent').mockImplementation(async (event) => event) - ServerSideEvents.eventFilter = async () => true + const inflateSpy = jest.spyOn(compression, 'inflateEvent').mockImplementation((event) => Promise.resolve(event)) + ServerSideEvents.eventFilter = () => Promise.resolve(true) const writableStream = new Writable({ write(_chunk, _encoding, callback) { From 2baff1b34c485c55fd33a37dfa8f6b5e6f57c9e0 Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Thu, 20 Aug 2026 09:39:05 +0200 Subject: [PATCH 4/8] ACM-39327: namespaced SelfSubjectRulesReview for SSE RBAC Use one SelfSubjectRulesReview per token+namespace instead of a single `default` namespace review. Add an explicit cluster-scoped kind list and confirm non-deny cluster-scoped results with SSAR so RoleBindings in `default` cannot impersonate cluster access. Handle `evaluationError` and `unavailable` rules-review states and update tests. Signed-off-by: Enrique Mingorance Cano --- backend/src/routes/events.ts | 205 ++++++++---- backend/test/routes/events.test.ts | 514 ++++++++++++++++++++++++----- 2 files changed, 574 insertions(+), 145 deletions(-) diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 2508b273a26..1491c3e73b5 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -175,6 +175,10 @@ const accessCache: Record } | { type: 'incomplete' } +type AccessResource = { kind: string; apiVersion: string; metadata?: { name?: string; namespace?: string } } + +/** SSRR requires a namespace; cluster-scoped kinds are reviewed in this probe namespace only. */ +const CLUSTER_SCOPED_RULES_NAMESPACE = 'default' + +/** + * Kinds watched by the console that are cluster-scoped in Kubernetes (not inferred from metadata.namespace). + * Keep aligned with cluster-scoped entries in the definitions list below. + */ +const CLUSTER_SCOPED_KINDS = new Set([ + 'AgentServiceConfig', + 'Authentication', + 'CertificateSigningRequest', + 'ClusterCurator', + 'ClusterImageSet', + 'ClusterManagementAddOn', + 'ClusterRole', + 'ClusterVersion', + 'DiscoveredCluster', + 'DiscoveryConfig', + 'Group', + 'Infrastructure', + 'ManagedCluster', + 'ManagedClusterSet', + 'ManagedClusterSetBinding', + 'MultiClusterEngine', + 'Namespace', + 'Placement', + 'PlacementDecision', + 'Search', + 'StorageClass', + 'User', +]) + +function isClusterScopedKind(kind: string): boolean { + return CLUSTER_SCOPED_KINDS.has(kind) +} + const subjectRulesCache: Record }> = {} const kindGetAccessCache: Record }> = {} @@ -987,18 +1029,8 @@ function eventFilter(token: string, serverSideEvent: ServerSideEvent { if (allowed) return true - // After cluster list is denied, use one SelfSubjectRulesReview per token/kind. - // Do NOT fall through to namespaced list SSAR first — that is O(namespaces) and - // OOMs/hangs restricted users when MOCK_CLUSTERS (or real inventory) is large. - return resolveKindGetAccess(resource.kind, resource.apiVersion, token).then((access) => - applyKindGetAccess(access, resource, token, () => - // Authorizer could not enumerate rules; keep prior namespaced-list then get fallback. - canListNamespacedScopedKind(resource, token).then((nsAllowed) => { - if (nsAllowed) return true - return canAccess(resource, 'get', token) - }) - ) - ) + // After cluster list is denied, use SelfSubjectRulesReview instead of O(N) SSARs. + return canGetResource(resource, token) }) } default: @@ -1007,17 +1039,11 @@ function eventFilter(token: string, serverSideEvent: ServerSideEvent { +function canListClusterScopedKind(resource: AccessResource, token: string): Promise { return canAccess({ kind: resource.kind, apiVersion: resource.apiVersion }, 'list', token) } -function canListNamespacedScopedKind( - resource: { kind: string; apiVersion: string; metadata?: { name?: string; namespace?: string } }, - token: string -): Promise { +function canListNamespacedScopedKind(resource: AccessResource, token: string): Promise { if (!resource.metadata?.namespace) return Promise.resolve(false) return canAccess( { @@ -1030,19 +1056,47 @@ function canListNamespacedScopedKind( ) } -/** Used by SSE eventFilter after list checks fail; prefers SelfSubjectRulesReview over N SSARs. */ -export function canGetResource( - resource: { kind: string; apiVersion: string; metadata?: { name?: string; namespace?: string } }, - token: string -): Promise { - return resolveKindGetAccess(resource.kind, resource.apiVersion, token).then((access) => - applyKindGetAccess(access, resource, token) - ) +function apiGroupFromVersion(apiVersion: string): string { + return apiVersion.includes('/') ? apiVersion.split('/')[0] : '' +} + +function resourcePluralName(kind: string): string { + return pluralize(kind.toLowerCase()) +} + +function rulesNamespaceFor(resource: AccessResource): string { + if (isClusterScopedKind(resource.kind)) { + return CLUSTER_SCOPED_RULES_NAMESPACE + } + return resource.metadata?.namespace || CLUSTER_SCOPED_RULES_NAMESPACE +} + +/** + * Used by SSE eventFilter after cluster-scoped list is denied. + * Namespaced resources are reviewed in the resource's namespace (cached per token+namespace). + * Cluster-scoped resources use a probe-namespace review only as a negative/named-binding cache; + * unrestricted grants from that probe are confirmed with SSAR so RoleBindings in `default` + * cannot impersonate cluster-scoped access. + */ +export function canGetResource(resource: AccessResource, token: string): Promise { + return resolveKindGetAccess(resource, token).then((access) => { + // Probe-namespace SSRR cannot distinguish RoleBindings from ClusterRoleBindings. + // Any non-deny cluster-scoped result must be confirmed with SSAR (not only allow-all/allow-names). + if (isClusterScopedKind(resource.kind) && access.type !== 'deny-all') { + return canAccess(resource, 'get', token) + } + return applyKindGetAccess(access, resource, token, () => + canListNamespacedScopedKind(resource, token).then((nsAllowed) => { + if (nsAllowed) return true + return canAccess(resource, 'get', token) + }) + ) + }) } function applyKindGetAccess( access: KindGetAccess, - resource: { kind: string; apiVersion: string; metadata?: { name?: string; namespace?: string } }, + resource: AccessResource, token: string, onIncomplete?: () => Promise ): Promise { @@ -1059,11 +1113,11 @@ function applyKindGetAccess( } /** - * One SelfSubjectRulesReview per token (namespace "default"). - * ClusterRole bindings appear here; avoids an SSRR storm across MOCK_CLUSTERS namespaces. + * One SelfSubjectRulesReview per token+namespace. + * ClusterRoleBindings appear in every namespace; RoleBindings appear only in their namespace. */ -function getSubjectRules(token: string): Promise { - const cacheKey = hashAccessToken(token) +function getSubjectRules(token: string, namespace: string): Promise { + const cacheKey = `${hashAccessToken(token)}:${namespace}` const existing = subjectRulesCache[cacheKey] if (existing && existing.time > Date.now() - ACCESS_CACHE_TTL) { return existing.promise @@ -1072,6 +1126,7 @@ function getSubjectRules(token: string): Promise { const promise = jsonPost<{ status?: { incomplete?: boolean + evaluationError?: string resourceRules?: SubjectRulesStatus['resourceRules'] } }>( @@ -1080,29 +1135,45 @@ function getSubjectRules(token: string): Promise { apiVersion: 'authorization.k8s.io/v1', kind: 'SelfSubjectRulesReview', metadata: {}, - // Namespace is required by the API; ClusterRole bindings are included for any namespace. - spec: { namespace: 'default' }, + spec: { namespace }, }, token ) - .then((result) => ({ - incomplete: result.body?.status?.incomplete ?? false, - resourceRules: result.body?.status?.resourceRules ?? [], - })) + .then((result) => { + // jsonPost resolves on HTTP errors; treat non-2xx as review unavailable (SSAR fallback). + if (result.statusCode < 200 || result.statusCode >= 300) { + throw new Error(`SelfSubjectRulesReview failed with status ${result.statusCode}`) + } + return { + incomplete: result.body?.status?.incomplete ?? false, + evaluationError: result.body?.status?.evaluationError, + resourceRules: result.body?.status?.resourceRules ?? [], + } + }) .catch((err: unknown) => { logger.warn({ msg: 'SelfSubjectRulesReview failed; falling back to per-object SSAR', error: err }) - return { incomplete: true, resourceRules: [] as SubjectRulesStatus['resourceRules'] } + // Do not retain a failed review under ACCESS_CACHE_TTL; next call should retry SSRR. + delete subjectRulesCache[cacheKey] + return { + incomplete: true, + unavailable: true, + resourceRules: [] as SubjectRulesStatus['resourceRules'], + } }) subjectRulesCache[cacheKey] = { time: Date.now(), promise } return promise } -function evaluateKindGetAccess(rules: SubjectRulesStatus, kind: string, apiVersion: string): KindGetAccess { - const group = apiVersion.includes('/') ? apiVersion.split('/')[0] : '' - const resourcePlural = pluralize(kind.toLowerCase()) +function evaluateKindGetAccess(rules: SubjectRulesStatus, group: string, resourcePlural: string): KindGetAccess { const accessVerbs = new Set(['get', 'list', 'watch']) + // Authorizer reported partial rule enumeration; empty rules still deny-all (none-user fast path). + if (rules.evaluationError) { + if (rules.resourceRules.length === 0) return { type: 'deny-all' } + return { type: 'incomplete' } + } + let allowAll = false const names = new Set() @@ -1124,40 +1195,35 @@ function evaluateKindGetAccess(rules: SubjectRulesStatus, kind: string, apiVersi for (const name of resourceNames) names.add(name) } - switch (true) { - case allowAll: - return { type: 'allow-all' } - case names.size > 0: - return { type: 'allow-names', names } - // OpenShift often sets incomplete=true even when the user has no bindings and resourceRules - // is empty. Treat empty rules as deny-all so we do not fall back to O(N) namespaced SSARs. - case rules.resourceRules.length === 0: - return { type: 'deny-all' } - // Non-empty but incomplete: authorizer may have omitted grants for this kind — fall back. - case rules.incomplete: - return { type: 'incomplete' } - default: - return { type: 'deny-all' } - } + if (allowAll) return { type: 'allow-all' } + if (names.size > 0) return { type: 'allow-names', names } + // The review request failed; defer to the per-object SSAR fallback. + if (rules.unavailable === true) return { type: 'incomplete' } + // OpenShift often sets incomplete=true even when the user has no bindings and resourceRules + // is empty. Treat empty rules as deny-all so we do not fall back to O(N) namespaced SSARs. + if (rules.resourceRules.length === 0) return { type: 'deny-all' } + // Non-empty but incomplete: authorizer may have omitted grants for this kind — fall back. + if (rules.incomplete) return { type: 'incomplete' } + return { type: 'deny-all' } } -function resolveKindGetAccess(kind: string, apiVersion: string, token: string): Promise { - const cacheKey = `${hashAccessToken(token)}:${kind}:${apiVersion}` +function resolveKindGetAccess(resource: AccessResource, token: string): Promise { + const group = apiGroupFromVersion(resource.apiVersion) + const plural = resourcePluralName(resource.kind) + const namespace = rulesNamespaceFor(resource) + // Permission checks are by API group, not version; keep cache keys version-free. + const cacheKey = `${hashAccessToken(token)}:${namespace}:${group}:${plural}` const existing = kindGetAccessCache[cacheKey] if (existing && existing.time > Date.now() - ACCESS_CACHE_TTL) { return existing.promise } - const promise = getSubjectRules(token).then((rules) => evaluateKindGetAccess(rules, kind, apiVersion)) + const promise = getSubjectRules(token, namespace).then((rules) => evaluateKindGetAccess(rules, group, plural)) kindGetAccessCache[cacheKey] = { time: Date.now(), promise } return promise } -export function canAccess( - resource: { kind: string; apiVersion: string; metadata?: { name?: string; namespace?: string } }, - verb: 'get' | 'list' | 'create', - token: string -): Promise { +export function canAccess(resource: AccessResource, verb: 'get' | 'list' | 'create', token: string): Promise { // Cache is cleaned up periodically by cleanupAccessCache() to prevent unbounded memory growth const tokenKey = hashAccessToken(token) const key = `${verb}:${resource.kind}:${resource.metadata?.namespace}:${resource.metadata?.name}` @@ -1167,6 +1233,7 @@ export function canAccess( return existing.promise } + const resourceName = resourcePluralName(resource.kind) const promise = jsonPost<{ status: { allowed: boolean } }>( process.env.CLUSTER_API_URL + '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', { @@ -1175,11 +1242,11 @@ export function canAccess( metadata: {}, spec: { resourceAttributes: { - group: resource.apiVersion.includes('/') ? resource.apiVersion.split('/')[0] : '', + group: apiGroupFromVersion(resource.apiVersion), name: resource.metadata?.name, namespace: resource.metadata?.namespace ?? (resource.kind === 'Namespace' ? resource.metadata?.name : undefined), - resource: pluralize(resource.kind.toLowerCase()), + resource: resourceName, verb, }, }, @@ -1192,7 +1259,7 @@ export function canAccess( msg: 'access', allowed, verb, - resource: pluralize(resource.kind.toLowerCase()), + resource: resourceName, name: resource.metadata?.name, namespace: resource.metadata?.namespace, }) diff --git a/backend/test/routes/events.test.ts b/backend/test/routes/events.test.ts index de93537e3e7..397d5860e9c 100644 --- a/backend/test/routes/events.test.ts +++ b/backend/test/routes/events.test.ts @@ -1546,15 +1546,83 @@ describe('events Route', () => { /** * ACM-39327: restricted users must not trigger O(N) SelfSubjectAccessReviews when the SSE - * filter falls through after cluster-scoped list is denied. One SelfSubjectRulesReview per - * token/kind short-circuits deny-all / allow-names without per-object or per-namespace SSARs. + * filter falls through after cluster-scoped list is denied. SelfSubjectRulesReview is + * namespaced: cache one review per token+namespace, never treat `default` as global allow. */ describe('SelfSubjectRulesReview short-circuit (ACM-39327)', () => { + const apiUrl = () => process.env.CLUSTER_API_URL || '' const managedCluster = (name: string) => ({ kind: 'ManagedCluster', apiVersion: 'cluster.open-cluster-management.io/v1', metadata: { name }, }) + const secret = (namespace: string, name: string) => ({ + kind: 'Secret', + apiVersion: 'v1', + metadata: { namespace, name }, + }) + const managedClusterInfo = (cluster: string) => ({ + kind: 'ManagedClusterInfo', + apiVersion: 'internal.open-cluster-management.io/v1beta1', + metadata: { name: cluster, namespace: cluster }, + }) + + const emptyRules = { incomplete: false, resourceRules: [] as unknown[] } + const secretGetInNamespace = { + incomplete: false, + resourceRules: [{ verbs: ['get'], apiGroups: [''], resources: ['secrets'] }], + } + const clusterAdminRules = { + incomplete: false, + resourceRules: [{ verbs: ['*'], apiGroups: ['*'], resources: ['*'] }], + } + + function rulesReviewNamespace(body: unknown): string { + let parsed = body + if (typeof body === 'string') { + try { + parsed = JSON.parse(body) as unknown + } catch { + return '' + } + } + return (parsed as { spec?: { namespace?: string } })?.spec?.namespace || '' + } + + function nockRulesReview(replyFn: (namespace: string) => { incomplete?: boolean; resourceRules?: unknown[] }) { + return nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') + .reply(200, (_uri: string, requestBody: unknown) => ({ status: replyFn(rulesReviewNamespace(requestBody)) })) + } + + function parseSsarResourceAttributes(body: unknown) { + const parsed = + typeof body === 'string' + ? (JSON.parse(body) as { spec?: { resourceAttributes?: Record } }) + : body + return (parsed as { spec?: { resourceAttributes?: Record } })?.spec?.resourceAttributes + } + + function nockSsarGet(matcher: (attrs: Record) => boolean, allowed: boolean) { + return nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', (body: unknown) => { + const attrs = parseSsarResourceAttributes(body) + return attrs?.verb === 'get' && matcher(attrs ?? {}) + }) + .reply(200, { status: { allowed } }) + } + + const namedManagedClusterRule = (name: string) => ({ + incomplete: false, + resourceRules: [ + { + verbs: ['get'], + apiGroups: ['cluster.open-cluster-management.io'], + resources: ['managedclusters'], + resourceNames: [name], + }, + ], + }) beforeEach(() => { resetAccessCache() @@ -1569,11 +1637,9 @@ describe('events Route', () => { }) it('should deny all gets from complete empty rules without per-object SSAR', async () => { - nock(process.env.CLUSTER_API_URL || '') - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') - .reply(200, { status: { incomplete: false, resourceRules: [] } }) + nockRulesReview(() => emptyRules) - const ssarScope = nock(process.env.CLUSTER_API_URL || '') + const ssarScope = nock(apiUrl()) .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') .reply(200, { status: { allowed: true } }) @@ -1581,16 +1647,14 @@ describe('events Route', () => { expect(ssarScope.isDone()).toBe(false) }) - it('should use a single rules review for many gets of the same kind', async () => { + it('should use a single rules review for many cluster-scoped gets of the same kind', async () => { let rulesCalls = 0 - nock(process.env.CLUSTER_API_URL || '') - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') - .reply(200, () => { - rulesCalls++ - return { status: { incomplete: false, resourceRules: [] } } - }) + nockRulesReview(() => { + rulesCalls++ + return emptyRules + }) - const ssarScope = nock(process.env.CLUSTER_API_URL || '') + const ssarScope = nock(apiUrl()) .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') .times(1) .reply(200, { status: { allowed: true } }) @@ -1601,18 +1665,15 @@ describe('events Route', () => { expect(results.every((allowed) => allowed === false)).toBe(true) expect(rulesCalls).toBe(1) - // Regression guard: must not fall back to per-object SSAR for deny-all. expect(ssarScope.isDone()).toBe(false) }) - it('should use a single rules review across many namespaces of the same kind', async () => { + it('should use a single rules review for many namespaced gets in the same namespace', async () => { let rulesCalls = 0 - nock(process.env.CLUSTER_API_URL || '') - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') - .reply(200, () => { - rulesCalls++ - return { status: { incomplete: true, resourceRules: [] } } - }) + nockRulesReview(() => { + rulesCalls++ + return emptyRules + }) const results = await Promise.all( Array.from({ length: 200 }, (_, i) => @@ -1620,9 +1681,9 @@ describe('events Route', () => { { kind: 'ManagedClusterInfo', apiVersion: 'internal.open-cluster-management.io/v1beta1', - metadata: { name: `cluster-${i}`, namespace: `cluster-${i}` }, + metadata: { name: `info-${i}`, namespace: 'acm39327-mc-01' }, }, - 'namespaced-none-token' + 'same-ns-none-token' ) ) ) @@ -1631,84 +1692,385 @@ describe('events Route', () => { expect(rulesCalls).toBe(1) }) - it('should allow only named resources from resourceNames rules', async () => { - nock(process.env.CLUSTER_API_URL || '') - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') - .reply(200, { - status: { - incomplete: false, - resourceRules: [ - { - verbs: ['get'], - apiGroups: ['cluster.open-cluster-management.io'], - resources: ['managedclusters'], - resourceNames: ['allowed-cluster'], - }, - ], - }, + it('should issue one rules review per namespace for a none user, without per-object SSAR', async () => { + const namespaces = new Set() + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { + namespaces.add(rulesReviewNamespace(body)) + return true + }) + .times(50) + .reply(200, { status: emptyRules }) + + const ssarScope = nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + + const results = await Promise.all( + Array.from({ length: 50 }, (_, i) => + canGetResource(managedClusterInfo(`cluster-${i}`), 'namespaced-none-token') + ) + ) + + expect(results.every((allowed) => allowed === false)).toBe(true) + expect(namespaces.size).toBe(50) + expect(ssarScope.isDone()).toBe(false) + }) + + it('must not treat get secrets in default as access to Credentials in other namespaces', async () => { + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { + return rulesReviewNamespace(body) === 'default' + }) + .reply(200, { status: secretGetInNamespace }) + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { + return rulesReviewNamespace(body) !== 'default' }) + .times(2) + .reply(200, { status: emptyRules }) + + expect(await canGetResource(secret('default', 'default-cred'), 'user1-token')).toBe(true) + expect(await canGetResource(secret('kube-system', 'other-cred'), 'user1-token')).toBe(false) + expect(await canGetResource(secret('acm39327-mc-01', 'cluster-cred'), 'user1-token')).toBe(false) + }) + + it('should allow namespaced cluster resources when the user is admin in that cluster namespace', async () => { + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { + return rulesReviewNamespace(body) === 'acm39327-mc-01' + }) + .reply(200, { status: clusterAdminRules }) + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { + return rulesReviewNamespace(body) !== 'acm39327-mc-01' + }) + .times(2) + .reply(200, { status: emptyRules }) + + expect(await canGetResource(managedClusterInfo('acm39327-mc-01'), 'cluster-admin-token')).toBe(true) + expect(await canGetResource(managedClusterInfo('other-cluster'), 'cluster-admin-token')).toBe(false) + expect(await canGetResource(managedCluster('acm39327-mc-01'), 'cluster-admin-token')).toBe(false) + }) + + it('should confirm named cluster-scoped resources with SSAR before allowing access', async () => { + nockRulesReview(() => namedManagedClusterRule('allowed-cluster')) + const ssarScope = nockSsarGet( + (attrs) => + attrs.group === 'cluster.open-cluster-management.io' && + attrs.resource === 'managedclusters' && + attrs.name === 'allowed-cluster', + true + ) expect(await canGetResource(managedCluster('allowed-cluster'), 'partial-user-token')).toBe(true) + expect(ssarScope.isDone()).toBe(true) + }) + + it('should deny non-matching names from cluster-scoped allow-names without trusting SSRR alone', async () => { + nockRulesReview(() => namedManagedClusterRule('allowed-cluster')) + const ssarScope = nockSsarGet( + (attrs) => + attrs.group === 'cluster.open-cluster-management.io' && + attrs.resource === 'managedclusters' && + attrs.name === 'other-cluster', + false + ) + expect(await canGetResource(managedCluster('other-cluster'), 'partial-user-token')).toBe(false) + expect(ssarScope.isDone()).toBe(true) }) - it('should allow all resources when rules grant unrestricted get/list/watch', async () => { - nock(process.env.CLUSTER_API_URL || '') - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') - .reply(200, { - status: { - incomplete: false, - resourceRules: [ - { - verbs: ['get', 'list', 'watch'], - apiGroups: ['cluster.open-cluster-management.io'], - resources: ['managedclusters'], - }, - ], - }, - }) + it('should allow namespaced resources when rules grant unrestricted get/list/watch in that namespace', async () => { + nockRulesReview(() => ({ + incomplete: false, + resourceRules: [{ verbs: ['get', 'list', 'watch'], apiGroups: [''], resources: ['secrets'] }], + })) - const ssarScope = nock(process.env.CLUSTER_API_URL || '') + const ssarScope = nock(apiUrl()) .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') .reply(200, { status: { allowed: false } }) - expect(await canGetResource(managedCluster('any-cluster'), 'viewer-token')).toBe(true) + expect(await canGetResource(secret('default', 'any-secret'), 'viewer-token')).toBe(true) expect(ssarScope.isDone()).toBe(false) }) - it('should fall back to SSAR when rules review is incomplete with non-empty rules', async () => { - nock(process.env.CLUSTER_API_URL || '') - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') - .reply(200, { - status: { - incomplete: true, - resourceRules: [ - { - verbs: ['get'], - apiGroups: [''], - resources: ['pods'], - }, - ], + it('should confirm cluster-scoped allow-all with SSAR so default RoleBindings are not treated as cluster access', async () => { + nockRulesReview(() => ({ + incomplete: false, + resourceRules: [ + { + verbs: ['get', 'list', 'watch'], + apiGroups: ['cluster.open-cluster-management.io'], + resources: ['managedclusters'], }, - }) - nock(process.env.CLUSTER_API_URL || '') + ], + })) + nock(apiUrl()) .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') - .reply(200, { status: { allowed: true } }) + .reply(200, { status: { allowed: false } }) + + expect(await canGetResource(managedCluster('any-cluster'), 'default-role-token')).toBe(false) + }) + + it('should reuse kind access across API versions of the same group', async () => { + let rulesCalls = 0 + nockRulesReview(() => { + rulesCalls++ + return emptyRules + }) + + expect( + await canGetResource( + { kind: 'Placement', apiVersion: 'cluster.open-cluster-management.io/v1beta1', metadata: { name: 'p1' } }, + 'version-token' + ) + ).toBe(false) + expect( + await canGetResource( + { kind: 'Placement', apiVersion: 'cluster.open-cluster-management.io/v1alpha1', metadata: { name: 'p2' } }, + 'version-token' + ) + ).toBe(false) + expect(rulesCalls).toBe(1) + }) + + it('should confirm cluster-scoped incomplete rules with SSAR instead of trusting SSRR alone', async () => { + nockRulesReview(() => ({ + incomplete: true, + resourceRules: [{ verbs: ['get'], apiGroups: [''], resources: ['pods'] }], + })) + const ssarScope = nockSsarGet( + (attrs) => + attrs.group === 'cluster.open-cluster-management.io' && + attrs.resource === 'managedclusters' && + attrs.name === 'cluster-1', + true + ) expect(await canGetResource(managedCluster('cluster-1'), 'incomplete-user-token')).toBe(true) + expect(ssarScope.isDone()).toBe(true) }) it('should deny-all when rules are empty even if incomplete is true (OpenShift none user)', async () => { - nock(process.env.CLUSTER_API_URL || '') - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') - .reply(200, { status: { incomplete: true, resourceRules: [] } }) + nockRulesReview(() => ({ incomplete: true, resourceRules: [] })) - const ssarScope = nock(process.env.CLUSTER_API_URL || '') + const ssarScope = nock(apiUrl()) .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') .reply(200, { status: { allowed: true } }) expect(await canGetResource(managedCluster('cluster-1'), 'openshift-none-token')).toBe(false) expect(ssarScope.isDone()).toBe(false) }) + + it('should fall back to SSAR when SelfSubjectRulesReview request fails', async () => { + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') + .reply(500, { message: 'internal error' }) + + const ssarScope = nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + + expect(await canGetResource(managedCluster('cluster-1'), 'ssrr-fail-token')).toBe(true) + expect(ssarScope.isDone()).toBe(true) + }) + }) + + describe('cluster-scoped SSRR middle-ground security (ACM-39327)', () => { + const apiUrl = () => process.env.CLUSTER_API_URL || '' + const managedCluster = (name: string) => ({ + kind: 'ManagedCluster', + apiVersion: 'cluster.open-cluster-management.io/v1', + metadata: { name }, + }) + + function rulesReviewNamespace(body: unknown): string { + let parsed = body + if (typeof body === 'string') { + try { + parsed = JSON.parse(body) as unknown + } catch { + return '' + } + } + return (parsed as { spec?: { namespace?: string } })?.spec?.namespace || '' + } + + function nockRulesReview(replyFn: (namespace: string) => { incomplete?: boolean; resourceRules?: unknown[] }) { + return nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') + .reply(200, (_uri: string, requestBody: unknown) => ({ status: replyFn(rulesReviewNamespace(requestBody)) })) + } + + function nockRulesReviewStatus( + replyFn: (namespace: string) => { + incomplete?: boolean + evaluationError?: string + resourceRules?: unknown[] + } + ) { + return nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') + .reply(200, (_uri: string, requestBody: unknown) => ({ status: replyFn(rulesReviewNamespace(requestBody)) })) + } + + function parseSsarResourceAttributes(body: unknown) { + const parsed = + typeof body === 'string' + ? (JSON.parse(body) as { spec?: { resourceAttributes?: Record } }) + : body + return (parsed as { spec?: { resourceAttributes?: Record } })?.spec?.resourceAttributes + } + + function nockSsarGet(matcher: (attrs: Record) => boolean, allowed: boolean) { + return nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', (body: unknown) => { + const attrs = parseSsarResourceAttributes(body) + return attrs?.verb === 'get' && matcher(attrs ?? {}) + }) + .reply(200, { status: { allowed } }) + } + + const namedManagedClusterRule = (name: string) => ({ + incomplete: false, + resourceRules: [ + { + verbs: ['get'], + apiGroups: ['cluster.open-cluster-management.io'], + resources: ['managedclusters'], + resourceNames: [name], + }, + ], + }) + + beforeEach(() => { + resetAccessCache() + jest.clearAllMocks() + process.env.CLUSTER_API_URL = 'https://api.test-cluster.com:6443' + }) + + afterEach(() => { + resetAccessCache() + delete process.env.CLUSTER_API_URL + nock.cleanAll() + }) + + it('should deny allow-names from a default RoleBinding when SSAR get is false', async () => { + nockRulesReview(() => namedManagedClusterRule('acm39327-mc-02')) + const ssarScope = nockSsarGet( + (attrs) => + attrs.group === 'cluster.open-cluster-management.io' && + attrs.resource === 'managedclusters' && + attrs.name === 'acm39327-mc-02', + false + ) + + expect(await canGetResource(managedCluster('acm39327-mc-02'), 'user1-token')).toBe(false) + expect(ssarScope.isDone()).toBe(true) + }) + + it('should allow allow-names only when SSAR get confirms a real ClusterRoleBinding grant', async () => { + nockRulesReview(() => namedManagedClusterRule('allowed-cluster')) + const ssarScope = nockSsarGet( + (attrs) => + attrs.group === 'cluster.open-cluster-management.io' && + attrs.resource === 'managedclusters' && + attrs.name === 'allowed-cluster', + true + ) + + expect(await canGetResource(managedCluster('allowed-cluster'), 'clusterrole-user-token')).toBe(true) + expect(ssarScope.isDone()).toBe(true) + }) + + it('should confirm cluster-scoped allow-all with SSAR and deny when SSAR rejects', async () => { + nockRulesReview(() => ({ + incomplete: false, + resourceRules: [ + { + verbs: ['get', 'list', 'watch'], + apiGroups: ['cluster.open-cluster-management.io'], + resources: ['managedclusters'], + }, + ], + })) + const ssarScope = nockSsarGet( + (attrs) => attrs.group === 'cluster.open-cluster-management.io' && attrs.resource === 'managedclusters', + false + ) + + expect(await canGetResource(managedCluster('any-cluster'), 'default-role-token')).toBe(false) + expect(ssarScope.isDone()).toBe(true) + }) + + it('should not trust allow-names on ManagedCluster when metadata.namespace is set without SSAR confirmation', async () => { + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { + return rulesReviewNamespace(body) === 'default' + }) + .reply(200, { status: namedManagedClusterRule('acm39327-mc-02') }) + const ssarScope = nockSsarGet( + (attrs) => + attrs.group === 'cluster.open-cluster-management.io' && + attrs.resource === 'managedclusters' && + attrs.name === 'acm39327-mc-02', + false + ) + + expect( + await canGetResource( + { + kind: 'ManagedCluster', + apiVersion: 'cluster.open-cluster-management.io/v1', + metadata: { name: 'acm39327-mc-02', namespace: 'default' }, + }, + 'user1-token' + ) + ).toBe(false) + expect(ssarScope.isDone()).toBe(true) + }) + + it('should treat SSRR evaluationError as incomplete and confirm cluster-scoped access with SSAR', async () => { + nockRulesReviewStatus(() => ({ + incomplete: false, + evaluationError: 'webhook authorizer does not support user rule resolution', + resourceRules: [{ verbs: ['get'], apiGroups: [''], resources: ['pods'] }], + })) + const ssarScope = nockSsarGet( + (attrs) => + attrs.group === 'cluster.open-cluster-management.io' && + attrs.resource === 'managedclusters' && + attrs.name === 'cluster-1', + true + ) + + expect(await canGetResource(managedCluster('cluster-1'), 'evaluation-error-token')).toBe(true) + expect(ssarScope.isDone()).toBe(true) + }) + + it('should confirm any non-deny-all cluster-scoped SSRR result with SSAR, not applyKindGetAccess alone', async () => { + nockRulesReview(() => ({ + incomplete: true, + resourceRules: [ + { + verbs: ['get'], + apiGroups: ['cluster.open-cluster-management.io'], + resources: ['managedclusters'], + resourceNames: ['cluster-1'], + }, + ], + })) + const ssarScope = nockSsarGet( + (attrs) => + attrs.group === 'cluster.open-cluster-management.io' && + attrs.resource === 'managedclusters' && + attrs.name === 'cluster-1', + false + ) + + expect(await canGetResource(managedCluster('cluster-1'), 'incomplete-named-token')).toBe(false) + expect(ssarScope.isDone()).toBe(true) + }) }) }) From 9c5efd366316221f2c11c172f75cc4cdea61effd Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Thu, 20 Aug 2026 11:13:21 +0200 Subject: [PATCH 5/8] sonar report fixes Signed-off-by: Enrique Mingorance Cano --- backend/src/routes/events.ts | 116 ++++++++++------- backend/test/routes/events.test.ts | 193 +++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+), 46 deletions(-) diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 1491c3e73b5..c6fc371a87d 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -268,51 +268,61 @@ function enforceAccessCacheEntryCap(tokenCache: Record(cache: Record, cutoffTime: number) { + for (const key in cache) { + if (cache[key].time < cutoffTime) { + delete cache[key] + } + } +} + +/** Prune one token's SSAR entries; returns newest remaining time, or undefined if the token was removed. */ +function pruneAccessCacheToken( + token: string, + tokenCache: Record }>, + cutoffTime: number +): number | undefined { + let newestTime = 0 + + for (const key in tokenCache) { + if (tokenCache[key].time < cutoffTime) { + delete tokenCache[key] + } else if (tokenCache[key].time > newestTime) { + newestTime = tokenCache[key].time + } + } + + if (Object.keys(tokenCache).length === 0) { + delete accessCache[token] + return undefined + } + + enforceAccessCacheEntryCap(tokenCache) + return newestTime +} + let accessCacheCleanupTimer: NodeJS.Timeout | undefined export function cleanupAccessCache() { - const now = Date.now() - const cutoffTime = now - ACCESS_CACHE_TTL + const cutoffTime = Date.now() - ACCESS_CACHE_TTL const tokenStats: Array<{ token: string; newestTime: number }> = [] for (const token in accessCache) { - const tokenCache = accessCache[token] - let newestTime = 0 - - for (const key in tokenCache) { - if (tokenCache[key].time < cutoffTime) { - delete tokenCache[key] - } else if (tokenCache[key].time > newestTime) { - newestTime = tokenCache[key].time - } - } - - if (Object.keys(tokenCache).length === 0) { - delete accessCache[token] - } else { - enforceAccessCacheEntryCap(tokenCache) + const newestTime = pruneAccessCacheToken(token, accessCache[token], cutoffTime) + if (newestTime !== undefined) { tokenStats.push({ token, newestTime }) } } - for (const key in subjectRulesCache) { - if (subjectRulesCache[key].time < cutoffTime) { - delete subjectRulesCache[key] - } - } - for (const key in kindGetAccessCache) { - if (kindGetAccessCache[key].time < cutoffTime) { - delete kindGetAccessCache[key] - } - } + expireTimedEntries(subjectRulesCache, cutoffTime) + expireTimedEntries(kindGetAccessCache, cutoffTime) - if (tokenStats.length > ACCESS_CACHE_MAX_TOKENS) { - tokenStats.sort((a, b) => a.newestTime - b.newestTime) - const tokensToRemove = tokenStats.length - ACCESS_CACHE_MAX_TOKENS + if (tokenStats.length <= ACCESS_CACHE_MAX_TOKENS) return - for (let i = 0; i < tokensToRemove; i++) { - delete accessCache[tokenStats[i].token] - } + tokenStats.sort((a, b) => a.newestTime - b.newestTime) + const tokensToRemove = tokenStats.length - ACCESS_CACHE_MAX_TOKENS + for (let i = 0; i < tokensToRemove; i++) { + delete accessCache[tokenStats[i].token] } } @@ -1165,6 +1175,28 @@ function getSubjectRules(token: string, namespace: string): Promise +): { allowAll: true } | { names: string[] } | null { + const verbs = rule.verbs ?? [] + if (!verbs.includes('*') && !verbs.some((verb) => accessVerbs.has(verb))) return null + + const groups = rule.apiGroups ?? [] + if (!groups.includes('*') && !groups.includes(group)) return null + + const resources = rule.resources ?? [] + if (!resources.includes('*') && !resources.includes(resourcePlural)) return null + + const resourceNames = rule.resourceNames + if (!resourceNames || resourceNames.length === 0 || resourceNames.includes('*')) { + return { allowAll: true } + } + return { names: resourceNames } +} + function evaluateKindGetAccess(rules: SubjectRulesStatus, group: string, resourcePlural: string): KindGetAccess { const accessVerbs = new Set(['get', 'list', 'watch']) @@ -1178,21 +1210,13 @@ function evaluateKindGetAccess(rules: SubjectRulesStatus, group: string, resourc const names = new Set() for (const rule of rules.resourceRules) { - const verbs = rule.verbs ?? [] - if (!verbs.includes('*') && !verbs.some((verb) => accessVerbs.has(verb))) continue - - const groups = rule.apiGroups ?? [] - if (!groups.includes('*') && !groups.includes(group)) continue - - const resources = rule.resources ?? [] - if (!resources.includes('*') && !resources.includes(resourcePlural)) continue - - const resourceNames = rule.resourceNames - if (!resourceNames || resourceNames.length === 0 || resourceNames.includes('*')) { + const match = ruleGrantsKindAccess(rule, group, resourcePlural, accessVerbs) + if (!match) continue + if ('allowAll' in match) { allowAll = true break } - for (const name of resourceNames) names.add(name) + for (const name of match.names) names.add(name) } if (allowAll) return { type: 'allow-all' } @@ -1266,7 +1290,7 @@ export function canAccess(resource: AccessResource, verb: 'get' | 'list' | 'crea } // Replace in-flight promise with a settled boolean promise to drop large closures. const entry = accessCache[tokenKey]?.[key] - if (entry && entry.promise === promise) { + if (entry?.promise === promise) { entry.promise = Promise.resolve(allowed) } return allowed diff --git a/backend/test/routes/events.test.ts b/backend/test/routes/events.test.ts index 397d5860e9c..95b586afdb9 100644 --- a/backend/test/routes/events.test.ts +++ b/backend/test/routes/events.test.ts @@ -1464,6 +1464,29 @@ describe('events Route', () => { expect(tokenCache['list:Pod:default:test-pod']).toBeDefined() }) + it('should log access checks when LOG_ACCESS is enabled', async () => { + const { logger } = await import('../../src/lib/logger') + const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => {}) + process.env.LOG_ACCESS = 'true' + + nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + + await canAccess( + { kind: 'Pod', apiVersion: 'v1', metadata: { namespace: 'default', name: 'logged-pod' } }, + 'get', + 'log-access-token' + ) + + expect(debugSpy).toHaveBeenCalledWith( + expect.objectContaining({ msg: 'access', allowed: true, verb: 'get', resource: 'pods' }) + ) + + debugSpy.mockRestore() + delete process.env.LOG_ACCESS + }) + it('should respect TTL and refetch after expiry', async () => { const cache = getAccessCache() const mockToken = 'test-token-ttl' @@ -1505,6 +1528,37 @@ describe('events Route', () => { expect(cache['token2']).toBeUndefined() }) + it('should expire subject rules and kind access caches during cleanup', async () => { + let rulesCalls = 0 + nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') + .twice() + .reply(200, () => { + rulesCalls++ + return { status: { incomplete: false, resourceRules: [] } } + }) + + const start = 1_000_000 + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(start) + + await canGetResource( + { kind: 'ManagedCluster', apiVersion: 'cluster.open-cluster-management.io/v1', metadata: { name: 'c1' } }, + 'cache-expiry-token' + ) + + nowSpy.mockReturnValue(start + ACCESS_CACHE_TTL + 1) + cleanupAccessCache() + + nowSpy.mockReturnValue(start + ACCESS_CACHE_TTL + 2) + await canGetResource( + { kind: 'ManagedCluster', apiVersion: 'cluster.open-cluster-management.io/v1', metadata: { name: 'c2' } }, + 'cache-expiry-token' + ) + + nowSpy.mockRestore() + expect(rulesCalls).toBe(2) + }) + it('should enforce maximum token limit with LRU eviction', () => { const cache = getAccessCache() const now = Date.now() @@ -1875,6 +1929,145 @@ describe('events Route', () => { expect(await canGetResource(managedCluster('cluster-1'), 'ssrr-fail-token')).toBe(true) expect(ssarScope.isDone()).toBe(true) }) + + it('should reuse subject rules cache across kinds in the same namespace', async () => { + let rulesCalls = 0 + nockRulesReview(() => { + rulesCalls++ + return emptyRules + }) + + await canGetResource(managedCluster('cluster-1'), 'shared-rules-token') + await canGetResource( + { kind: 'Placement', apiVersion: 'cluster.open-cluster-management.io/v1beta1', metadata: { name: 'p1' } }, + 'shared-rules-token' + ) + + expect(rulesCalls).toBe(1) + }) + + it('should deny-all when evaluationError is set with empty resourceRules', async () => { + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') + .reply(200, { + status: { + incomplete: false, + evaluationError: 'authorizer unavailable', + resourceRules: [], + }, + }) + + const ssarScope = nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + + expect(await canGetResource(managedCluster('cluster-1'), 'eval-error-empty-token')).toBe(false) + expect(ssarScope.isDone()).toBe(false) + }) + + it('should deny-all when rules do not grant access to the requested kind', async () => { + nockRulesReview(() => ({ + incomplete: false, + resourceRules: [{ verbs: ['get'], apiGroups: [''], resources: ['pods'] }], + })) + + const ssarScope = nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + + expect(await canGetResource(managedCluster('cluster-1'), 'unrelated-rules-token')).toBe(false) + expect(ssarScope.isDone()).toBe(false) + }) + + it('should fall back to namespaced list SSAR for incomplete namespaced resources', async () => { + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { + return rulesReviewNamespace(body) === 'acm39327-mc-01' + }) + .reply(200, { + status: { + incomplete: true, + resourceRules: [{ verbs: ['get'], apiGroups: [''], resources: ['secrets'] }], + }, + }) + + const listScope = nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', (body: unknown) => { + const parsed = + typeof body === 'string' + ? (JSON.parse(body) as { spec?: { resourceAttributes?: { verb?: string; namespace?: string } } }) + : body + const attrs = (parsed as { spec?: { resourceAttributes?: { verb?: string; namespace?: string } } })?.spec + ?.resourceAttributes + return attrs?.verb === 'list' && attrs?.namespace === 'acm39327-mc-01' + }) + .reply(200, { status: { allowed: true } }) + + expect(await canGetResource(managedClusterInfo('acm39327-mc-01'), 'namespaced-incomplete-token')).toBe(true) + expect(listScope.isDone()).toBe(true) + }) + + it('should fall back to get SSAR when namespaced incomplete rules deny list access', async () => { + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { + return rulesReviewNamespace(body) === 'acm39327-mc-01' + }) + .reply(200, { + status: { + incomplete: true, + resourceRules: [{ verbs: ['get'], apiGroups: [''], resources: ['secrets'] }], + }, + }) + + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', (body: unknown) => { + const parsed = + typeof body === 'string' + ? (JSON.parse(body) as { spec?: { resourceAttributes?: { verb?: string; namespace?: string } } }) + : body + const attrs = (parsed as { spec?: { resourceAttributes?: { verb?: string; namespace?: string } } })?.spec + ?.resourceAttributes + return attrs?.verb === 'list' && attrs?.namespace === 'acm39327-mc-01' + }) + .reply(200, { status: { allowed: false } }) + + const getScope = nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', (body: unknown) => { + const parsed = + typeof body === 'string' + ? (JSON.parse(body) as { spec?: { resourceAttributes?: { verb?: string; namespace?: string; name?: string } } }) + : body + const attrs = (parsed as { spec?: { resourceAttributes?: { verb?: string; namespace?: string; name?: string } } }) + ?.spec?.resourceAttributes + return ( + attrs?.verb === 'get' && + attrs?.namespace === 'acm39327-mc-01' && + attrs?.name === 'acm39327-mc-01' + ) + }) + .reply(200, { status: { allowed: true } }) + + expect(await canGetResource(managedClusterInfo('acm39327-mc-01'), 'namespaced-incomplete-get-token')).toBe(true) + expect(getScope.isDone()).toBe(true) + }) + + it('should deny allow-names when the resource has no name', async () => { + nockRulesReview(() => ({ + incomplete: false, + resourceRules: [ + { + verbs: ['get'], + apiGroups: [''], + resources: ['secrets'], + resourceNames: ['named-secret'], + }, + ], + })) + + expect(await canGetResource({ kind: 'Secret', apiVersion: 'v1', metadata: { namespace: 'default' } }, 'named-only-token')).toBe( + false + ) + }) }) describe('cluster-scoped SSRR middle-ground security (ACM-39327)', () => { From 2ced7d5b6fbfc39b4baf2820c2cd6f67c9b7a68f Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Thu, 20 Aug 2026 12:27:58 +0200 Subject: [PATCH 6/8] prettier error fixed Signed-off-by: Enrique Mingorance Cano --- backend/test/routes/events.test.ts | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/backend/test/routes/events.test.ts b/backend/test/routes/events.test.ts index 95b586afdb9..22b9677b18e 100644 --- a/backend/test/routes/events.test.ts +++ b/backend/test/routes/events.test.ts @@ -1654,7 +1654,8 @@ describe('events Route', () => { typeof body === 'string' ? (JSON.parse(body) as { spec?: { resourceAttributes?: Record } }) : body - return (parsed as { spec?: { resourceAttributes?: Record } })?.spec?.resourceAttributes + return (parsed as { spec?: { resourceAttributes?: Record } })?.spec + ?.resourceAttributes } function nockSsarGet(matcher: (attrs: Record) => boolean, allowed: boolean) { @@ -2035,15 +2036,14 @@ describe('events Route', () => { .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', (body: unknown) => { const parsed = typeof body === 'string' - ? (JSON.parse(body) as { spec?: { resourceAttributes?: { verb?: string; namespace?: string; name?: string } } }) + ? (JSON.parse(body) as { + spec?: { resourceAttributes?: { verb?: string; namespace?: string; name?: string } } + }) : body - const attrs = (parsed as { spec?: { resourceAttributes?: { verb?: string; namespace?: string; name?: string } } }) - ?.spec?.resourceAttributes - return ( - attrs?.verb === 'get' && - attrs?.namespace === 'acm39327-mc-01' && - attrs?.name === 'acm39327-mc-01' - ) + const attrs = ( + parsed as { spec?: { resourceAttributes?: { verb?: string; namespace?: string; name?: string } } } + )?.spec?.resourceAttributes + return attrs?.verb === 'get' && attrs?.namespace === 'acm39327-mc-01' && attrs?.name === 'acm39327-mc-01' }) .reply(200, { status: { allowed: true } }) @@ -2064,9 +2064,12 @@ describe('events Route', () => { ], })) - expect(await canGetResource({ kind: 'Secret', apiVersion: 'v1', metadata: { namespace: 'default' } }, 'named-only-token')).toBe( - false - ) + expect( + await canGetResource( + { kind: 'Secret', apiVersion: 'v1', metadata: { namespace: 'default' } }, + 'named-only-token' + ) + ).toBe(false) }) }) @@ -2113,7 +2116,8 @@ describe('events Route', () => { typeof body === 'string' ? (JSON.parse(body) as { spec?: { resourceAttributes?: Record } }) : body - return (parsed as { spec?: { resourceAttributes?: Record } })?.spec?.resourceAttributes + return (parsed as { spec?: { resourceAttributes?: Record } })?.spec + ?.resourceAttributes } function nockSsarGet(matcher: (attrs: Record) => boolean, allowed: boolean) { From cd7932b470d93c86dff2884037c99376554da12e Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Tue, 1 Sep 2026 13:37:40 +0200 Subject: [PATCH 7/8] ACM-39327: extract SSE RBAC modules and derive cluster-scoped kinds Split access cache and SelfSubjectRulesReview logic out of events.ts so watch definitions own cluster scope, SSAR cache keys include API group, and failed rules reviews can retry instead of pinning incomplete results. Signed-off-by: Enrique Mingorance Cano Co-authored-by: Cursor --- backend/src/resources/watch-options.ts | 6 + backend/src/routes/events.ts | 488 +------------ backend/src/routes/eventsAccess.ts | 308 ++++++++ backend/src/routes/eventsCache.ts | 188 +++++ backend/test/routes/events.test.ts | 865 ----------------------- backend/test/routes/eventsAccess.test.ts | 698 ++++++++++++++++++ backend/test/routes/eventsCache.test.ts | 250 +++++++ 7 files changed, 1489 insertions(+), 1314 deletions(-) create mode 100644 backend/src/routes/eventsAccess.ts create mode 100644 backend/src/routes/eventsCache.ts create mode 100644 backend/test/routes/eventsAccess.test.ts create mode 100644 backend/test/routes/eventsCache.test.ts diff --git a/backend/src/resources/watch-options.ts b/backend/src/resources/watch-options.ts index 45ac79f88f3..188c96e80fd 100644 --- a/backend/src/resources/watch-options.ts +++ b/backend/src/resources/watch-options.ts @@ -16,4 +16,10 @@ export interface IWatchOptions { * Defaults to true when omitted. */ forwardEventsToClients?: boolean + /** + * True when the Kubernetes resource is cluster-scoped. + * Used by SSE RBAC to decide whether SelfSubjectRulesReview should probe `default` + * (cluster-scoped) or the resource namespace (namespaced). + */ + clusterScoped?: boolean } diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index c6fc371a87d..6d7d68d926d 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -1,6 +1,5 @@ /* Copyright Contributors to the Open Cluster Management project */ -import { createHash } from 'node:crypto' import get from 'get-value' import got, { CancelError, HTTPError, TimeoutError } from 'got' import { Http2ServerRequest, Http2ServerResponse } from 'node:http2' @@ -9,7 +8,6 @@ import { pipeline } from 'node:stream/promises' import { Transform } from 'node:stream' import { batchPromiseAll } from '../lib/batch-promise-all' import { createDictionary, deflateResource, inflateResource } from '../lib/compression' -import { jsonPost } from '../lib/json-request' import { logger } from '../lib/logger' import { type EventResourceMeta, @@ -23,6 +21,25 @@ import type { IResource } from '../resources/resource' import type { IWatchOptions } from '../resources/watch-options' import { polledAggregation } from './aggregator' import { getAppDict, type ICompressedResource, type ITransformedResource } from './aggregators/applications' +import { + canAccess, + canGetResource, + canListClusterScopedKind, + canListNamespacedScopedKind, + configureClusterScopedKinds, +} from './eventsAccess' +import { startAccessCacheCleanup, stopAccessCacheCleanup } from './eventsCache' + +export { + ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN, + ACCESS_CACHE_MAX_TOKENS, + ACCESS_CACHE_TTL, + cleanupAccessCache, + getAccessCache, + hashAccessToken, + resetAccessCache, +} from './eventsCache' +export { canAccess, canGetResource } from './eventsAccess' export async function events(req: Http2ServerRequest, res: Http2ServerResponse): Promise { const token = await getAuthenticatedToken(req, res) @@ -171,189 +188,11 @@ export function getEventDict() { return eventDict } -const accessCache: Record }>> = {} - -interface SubjectRulesStatus { - incomplete: boolean - /** True when the SelfSubjectRulesReview request itself failed. */ - unavailable?: boolean - /** Set when an authorizer could not fully enumerate rules; partial lists must not be trusted as complete. */ - evaluationError?: string - resourceRules: Array<{ - verbs?: string[] - apiGroups?: string[] - resources?: string[] - resourceNames?: string[] - }> -} - -type KindGetAccess = - { type: 'deny-all' } | { type: 'allow-all' } | { type: 'allow-names'; names: Set } | { type: 'incomplete' } - -type AccessResource = { kind: string; apiVersion: string; metadata?: { name?: string; namespace?: string } } - -/** SSRR requires a namespace; cluster-scoped kinds are reviewed in this probe namespace only. */ -const CLUSTER_SCOPED_RULES_NAMESPACE = 'default' - -/** - * Kinds watched by the console that are cluster-scoped in Kubernetes (not inferred from metadata.namespace). - * Keep aligned with cluster-scoped entries in the definitions list below. - */ -const CLUSTER_SCOPED_KINDS = new Set([ - 'AgentServiceConfig', - 'Authentication', - 'CertificateSigningRequest', - 'ClusterCurator', - 'ClusterImageSet', - 'ClusterManagementAddOn', - 'ClusterRole', - 'ClusterVersion', - 'DiscoveredCluster', - 'DiscoveryConfig', - 'Group', - 'Infrastructure', - 'ManagedCluster', - 'ManagedClusterSet', - 'ManagedClusterSetBinding', - 'MultiClusterEngine', - 'Namespace', - 'Placement', - 'PlacementDecision', - 'Search', - 'StorageClass', - 'User', -]) - -function isClusterScopedKind(kind: string): boolean { - return CLUSTER_SCOPED_KINDS.has(kind) -} - -const subjectRulesCache: Record }> = {} -const kindGetAccessCache: Record }> = {} - -/** Clear all cached RBAC access checks. Used for test isolation. */ -export function resetAccessCache() { - for (const key in accessCache) { - delete accessCache[key] - } - for (const key in subjectRulesCache) { - delete subjectRulesCache[key] - } - for (const key in kindGetAccessCache) { - delete kindGetAccessCache[key] - } -} - -export function getAccessCache() { - return accessCache -} - -export const ACCESS_CACHE_TTL = 60 * 1000 // 60 seconds -export const ACCESS_CACHE_CLEANUP_INTERVAL = 90 * 1000 // 90 seconds -export const ACCESS_CACHE_MAX_TOKENS = 1000 // Maximum number of token entries to keep -export const ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN = 2000 // Cap RBAC keys retained per token - -/** Hash bearer tokens so the access cache does not retain full JWTs as object keys. */ -export function hashAccessToken(token: string): string { - return createHash('sha256').update(token).digest('hex') -} - -function enforceAccessCacheEntryCap(tokenCache: Record }>) { - const keys = Object.keys(tokenCache) - if (keys.length <= ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN) return - keys.sort((a, b) => tokenCache[a].time - tokenCache[b].time) - const toRemove = keys.length - ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN - for (let i = 0; i < toRemove; i++) { - delete tokenCache[keys[i]] - } -} - -function expireTimedEntries(cache: Record, cutoffTime: number) { - for (const key in cache) { - if (cache[key].time < cutoffTime) { - delete cache[key] - } - } -} - -/** Prune one token's SSAR entries; returns newest remaining time, or undefined if the token was removed. */ -function pruneAccessCacheToken( - token: string, - tokenCache: Record }>, - cutoffTime: number -): number | undefined { - let newestTime = 0 - - for (const key in tokenCache) { - if (tokenCache[key].time < cutoffTime) { - delete tokenCache[key] - } else if (tokenCache[key].time > newestTime) { - newestTime = tokenCache[key].time - } - } - - if (Object.keys(tokenCache).length === 0) { - delete accessCache[token] - return undefined - } - - enforceAccessCacheEntryCap(tokenCache) - return newestTime -} - -let accessCacheCleanupTimer: NodeJS.Timeout | undefined - -export function cleanupAccessCache() { - const cutoffTime = Date.now() - ACCESS_CACHE_TTL - const tokenStats: Array<{ token: string; newestTime: number }> = [] - - for (const token in accessCache) { - const newestTime = pruneAccessCacheToken(token, accessCache[token], cutoffTime) - if (newestTime !== undefined) { - tokenStats.push({ token, newestTime }) - } - } - - expireTimedEntries(subjectRulesCache, cutoffTime) - expireTimedEntries(kindGetAccessCache, cutoffTime) - - if (tokenStats.length <= ACCESS_CACHE_MAX_TOKENS) return - - tokenStats.sort((a, b) => a.newestTime - b.newestTime) - const tokensToRemove = tokenStats.length - ACCESS_CACHE_MAX_TOKENS - for (let i = 0; i < tokensToRemove; i++) { - delete accessCache[tokenStats[i].token] - } -} - -function startAccessCacheCleanup() { - if (accessCacheCleanupTimer) return - - accessCacheCleanupTimer = setInterval(() => { - try { - cleanupAccessCache() - } catch (err: unknown) { - logger.error({ msg: 'accessCache cleanup failed', error: err }) - } - }, ACCESS_CACHE_CLEANUP_INTERVAL) - - accessCacheCleanupTimer.unref() - logger.info({ msg: 'accessCache cleanup started', interval: ACCESS_CACHE_CLEANUP_INTERVAL }) -} - -function stopAccessCacheCleanup() { - if (accessCacheCleanupTimer) { - clearInterval(accessCacheCleanupTimer) - accessCacheCleanupTimer = undefined - logger.info({ msg: 'accessCache cleanup stopped' }) - } -} - const definitions: IWatchOptions[] = [ - { kind: 'ClusterManagementAddOn', apiVersion: 'addon.open-cluster-management.io/v1alpha1' }, + { kind: 'ClusterManagementAddOn', apiVersion: 'addon.open-cluster-management.io/v1alpha1', clusterScoped: true }, { kind: 'ManagedClusterAddOn', apiVersion: 'addon.open-cluster-management.io/v1alpha1' }, { kind: 'Agent', apiVersion: 'agent-install.openshift.io/v1beta1' }, - { kind: 'AgentServiceConfig', apiVersion: 'agent-install.openshift.io/v1beta1' }, + { kind: 'AgentServiceConfig', apiVersion: 'agent-install.openshift.io/v1beta1', clusterScoped: true }, { kind: 'InfraEnv', apiVersion: 'agent-install.openshift.io/v1beta1' }, { kind: 'NMStateConfig', apiVersion: 'agent-install.openshift.io/v1beta1' }, { kind: 'Application', apiVersion: 'app.k8s.io/v1beta1' }, @@ -365,35 +204,36 @@ const definitions: IWatchOptions[] = [ { kind: 'Application', apiVersion: 'argoproj.io/v1alpha1', isPolled: true }, { kind: 'ApplicationSet', apiVersion: 'argoproj.io/v1alpha1', isPolled: true }, { kind: 'ArgoCD', apiVersion: 'argoproj.io/v1alpha1' }, - { kind: 'Authentication', apiVersion: 'config.openshift.io/v1', forwardEventsToClients: false }, - { kind: 'Infrastructure', apiVersion: 'config.openshift.io/v1' }, + { kind: 'Authentication', apiVersion: 'config.openshift.io/v1', forwardEventsToClients: false, clusterScoped: true }, + { kind: 'Infrastructure', apiVersion: 'config.openshift.io/v1', clusterScoped: true }, { kind: 'CertificateSigningRequest', apiVersion: 'certificates.k8s.io/v1', labelSelector: { 'open-cluster-management.io/cluster-name': '' }, + clusterScoped: true, }, - { kind: 'ManagedCluster', apiVersion: 'cluster.open-cluster-management.io/v1' }, + { kind: 'ManagedCluster', apiVersion: 'cluster.open-cluster-management.io/v1', clusterScoped: true }, { kind: 'Placement', apiVersion: 'cluster.open-cluster-management.io/v1beta1' }, { kind: 'PlacementDecision', apiVersion: 'cluster.open-cluster-management.io/v1beta1' }, { kind: 'ManagedClusterSetBinding', apiVersion: 'cluster.open-cluster-management.io/v1beta2' }, - { kind: 'ManagedClusterSet', apiVersion: 'cluster.open-cluster-management.io/v1beta2' }, + { kind: 'ManagedClusterSet', apiVersion: 'cluster.open-cluster-management.io/v1beta2', clusterScoped: true }, { kind: 'ClusterCurator', apiVersion: 'cluster.open-cluster-management.io/v1beta1' }, { kind: 'Subscription', apiVersion: 'operators.coreos.com/v1alpha1' }, - { kind: 'ClusterExtension', apiVersion: 'olm.operatorframework.io/v1' }, + { kind: 'ClusterExtension', apiVersion: 'olm.operatorframework.io/v1', clusterScoped: true }, { kind: 'DiscoveredCluster', apiVersion: 'discovery.open-cluster-management.io/v1' }, { kind: 'DiscoveryConfig', apiVersion: 'discovery.open-cluster-management.io/v1' }, { kind: 'AgentClusterInstall', apiVersion: 'extensions.hive.openshift.io/v1beta1' }, { kind: 'ClusterClaim', apiVersion: 'hive.openshift.io/v1' }, { kind: 'ClusterDeployment', apiVersion: 'hive.openshift.io/v1' }, - { kind: 'ClusterImageSet', apiVersion: 'hive.openshift.io/v1' }, + { kind: 'ClusterImageSet', apiVersion: 'hive.openshift.io/v1', clusterScoped: true }, { kind: 'ClusterPool', apiVersion: 'hive.openshift.io/v1' }, { kind: 'ClusterProvision', apiVersion: 'hive.openshift.io/v1' }, { kind: 'MachinePool', apiVersion: 'hive.openshift.io/v1' }, { kind: 'ManagedClusterInfo', apiVersion: 'internal.open-cluster-management.io/v1beta1' }, { kind: 'BareMetalHost', apiVersion: 'metal3.io/v1alpha1' }, - { kind: 'MultiClusterEngine', apiVersion: 'multicluster.openshift.io/v1' }, - { kind: 'ClusterVersion', apiVersion: 'config.openshift.io/v1' }, - { kind: 'StorageClass', apiVersion: 'storage.k8s.io/v1' }, + { kind: 'MultiClusterEngine', apiVersion: 'multicluster.openshift.io/v1', clusterScoped: true }, + { kind: 'ClusterVersion', apiVersion: 'config.openshift.io/v1', clusterScoped: true }, + { kind: 'StorageClass', apiVersion: 'storage.k8s.io/v1', clusterScoped: true }, { kind: 'PlacementBinding', apiVersion: 'policy.open-cluster-management.io/v1' }, { kind: 'Policy', apiVersion: 'policy.open-cluster-management.io/v1' }, { kind: 'PolicyAutomation', apiVersion: 'policy.open-cluster-management.io/v1beta1' }, @@ -411,7 +251,7 @@ const definitions: IWatchOptions[] = [ fieldSelector: { 'metadata.namespace': 'openshift-config-managed', 'metadata.name': 'console-public' }, }, { kind: 'ConfigMap', apiVersion: 'v1', fieldSelector: { 'metadata.name': 'console-search-config' } }, - { kind: 'Namespace', apiVersion: 'v1' }, + { kind: 'Namespace', apiVersion: 'v1', clusterScoped: true }, { kind: 'Secret', apiVersion: 'v1', labelSelector: { 'cluster.open-cluster-management.io/credentials': '' } }, // **Need to look for creds with: 'cluster.open-cluster-management.io/type': 'ans', for edit scenarios { kind: 'Secret', apiVersion: 'v1', labelSelector: { 'cluster.open-cluster-management.io/type': 'ans' } }, @@ -435,12 +275,13 @@ const definitions: IWatchOptions[] = [ fieldSelector: { 'metadata.name': 'grafana-dashboard-acm-openshift-virtualization-single-vm-view' }, }, { kind: 'MulticlusterRoleAssignment', apiVersion: 'rbac.open-cluster-management.io/v1beta1' }, - { kind: 'User', apiVersion: 'user.openshift.io/v1' }, - { kind: 'Group', apiVersion: 'user.openshift.io/v1' }, + { kind: 'User', apiVersion: 'user.openshift.io/v1', clusterScoped: true }, + { kind: 'Group', apiVersion: 'user.openshift.io/v1', clusterScoped: true }, { kind: 'ClusterRole', apiVersion: 'rbac.authorization.k8s.io/v1', labelSelector: { 'rbac.open-cluster-management.io/filter': 'vm-clusterroles' }, + clusterScoped: true, }, { kind: 'Service', @@ -449,6 +290,10 @@ const definitions: IWatchOptions[] = [ }, ] +configureClusterScopedKinds( + definitions.filter((definition) => definition.clusterScoped).map((definition) => definition.kind) +) + export function startWatching(): void { ServerSideEvents.eventFilter = eventFilter startAccessCacheCleanup() @@ -1049,261 +894,6 @@ function eventFilter(token: string, serverSideEvent: ServerSideEvent { - return canAccess({ kind: resource.kind, apiVersion: resource.apiVersion }, 'list', token) -} - -function canListNamespacedScopedKind(resource: AccessResource, token: string): Promise { - if (!resource.metadata?.namespace) return Promise.resolve(false) - return canAccess( - { - kind: resource.kind, - apiVersion: resource.apiVersion, - metadata: { namespace: resource.metadata.namespace }, - }, - 'list', - token - ) -} - -function apiGroupFromVersion(apiVersion: string): string { - return apiVersion.includes('/') ? apiVersion.split('/')[0] : '' -} - -function resourcePluralName(kind: string): string { - return pluralize(kind.toLowerCase()) -} - -function rulesNamespaceFor(resource: AccessResource): string { - if (isClusterScopedKind(resource.kind)) { - return CLUSTER_SCOPED_RULES_NAMESPACE - } - return resource.metadata?.namespace || CLUSTER_SCOPED_RULES_NAMESPACE -} - -/** - * Used by SSE eventFilter after cluster-scoped list is denied. - * Namespaced resources are reviewed in the resource's namespace (cached per token+namespace). - * Cluster-scoped resources use a probe-namespace review only as a negative/named-binding cache; - * unrestricted grants from that probe are confirmed with SSAR so RoleBindings in `default` - * cannot impersonate cluster-scoped access. - */ -export function canGetResource(resource: AccessResource, token: string): Promise { - return resolveKindGetAccess(resource, token).then((access) => { - // Probe-namespace SSRR cannot distinguish RoleBindings from ClusterRoleBindings. - // Any non-deny cluster-scoped result must be confirmed with SSAR (not only allow-all/allow-names). - if (isClusterScopedKind(resource.kind) && access.type !== 'deny-all') { - return canAccess(resource, 'get', token) - } - return applyKindGetAccess(access, resource, token, () => - canListNamespacedScopedKind(resource, token).then((nsAllowed) => { - if (nsAllowed) return true - return canAccess(resource, 'get', token) - }) - ) - }) -} - -function applyKindGetAccess( - access: KindGetAccess, - resource: AccessResource, - token: string, - onIncomplete?: () => Promise -): Promise { - switch (access.type) { - case 'deny-all': - return Promise.resolve(false) - case 'allow-all': - return Promise.resolve(true) - case 'allow-names': - return Promise.resolve(resource.metadata?.name ? access.names.has(resource.metadata.name) : false) - case 'incomplete': - return onIncomplete ? onIncomplete() : canAccess(resource, 'get', token) - } -} - -/** - * One SelfSubjectRulesReview per token+namespace. - * ClusterRoleBindings appear in every namespace; RoleBindings appear only in their namespace. - */ -function getSubjectRules(token: string, namespace: string): Promise { - const cacheKey = `${hashAccessToken(token)}:${namespace}` - const existing = subjectRulesCache[cacheKey] - if (existing && existing.time > Date.now() - ACCESS_CACHE_TTL) { - return existing.promise - } - - const promise = jsonPost<{ - status?: { - incomplete?: boolean - evaluationError?: string - resourceRules?: SubjectRulesStatus['resourceRules'] - } - }>( - process.env.CLUSTER_API_URL + '/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', - { - apiVersion: 'authorization.k8s.io/v1', - kind: 'SelfSubjectRulesReview', - metadata: {}, - spec: { namespace }, - }, - token - ) - .then((result) => { - // jsonPost resolves on HTTP errors; treat non-2xx as review unavailable (SSAR fallback). - if (result.statusCode < 200 || result.statusCode >= 300) { - throw new Error(`SelfSubjectRulesReview failed with status ${result.statusCode}`) - } - return { - incomplete: result.body?.status?.incomplete ?? false, - evaluationError: result.body?.status?.evaluationError, - resourceRules: result.body?.status?.resourceRules ?? [], - } - }) - .catch((err: unknown) => { - logger.warn({ msg: 'SelfSubjectRulesReview failed; falling back to per-object SSAR', error: err }) - // Do not retain a failed review under ACCESS_CACHE_TTL; next call should retry SSRR. - delete subjectRulesCache[cacheKey] - return { - incomplete: true, - unavailable: true, - resourceRules: [] as SubjectRulesStatus['resourceRules'], - } - }) - - subjectRulesCache[cacheKey] = { time: Date.now(), promise } - return promise -} - -function ruleGrantsKindAccess( - rule: SubjectRulesStatus['resourceRules'][number], - group: string, - resourcePlural: string, - accessVerbs: Set -): { allowAll: true } | { names: string[] } | null { - const verbs = rule.verbs ?? [] - if (!verbs.includes('*') && !verbs.some((verb) => accessVerbs.has(verb))) return null - - const groups = rule.apiGroups ?? [] - if (!groups.includes('*') && !groups.includes(group)) return null - - const resources = rule.resources ?? [] - if (!resources.includes('*') && !resources.includes(resourcePlural)) return null - - const resourceNames = rule.resourceNames - if (!resourceNames || resourceNames.length === 0 || resourceNames.includes('*')) { - return { allowAll: true } - } - return { names: resourceNames } -} - -function evaluateKindGetAccess(rules: SubjectRulesStatus, group: string, resourcePlural: string): KindGetAccess { - const accessVerbs = new Set(['get', 'list', 'watch']) - - // Authorizer reported partial rule enumeration; empty rules still deny-all (none-user fast path). - if (rules.evaluationError) { - if (rules.resourceRules.length === 0) return { type: 'deny-all' } - return { type: 'incomplete' } - } - - let allowAll = false - const names = new Set() - - for (const rule of rules.resourceRules) { - const match = ruleGrantsKindAccess(rule, group, resourcePlural, accessVerbs) - if (!match) continue - if ('allowAll' in match) { - allowAll = true - break - } - for (const name of match.names) names.add(name) - } - - if (allowAll) return { type: 'allow-all' } - if (names.size > 0) return { type: 'allow-names', names } - // The review request failed; defer to the per-object SSAR fallback. - if (rules.unavailable === true) return { type: 'incomplete' } - // OpenShift often sets incomplete=true even when the user has no bindings and resourceRules - // is empty. Treat empty rules as deny-all so we do not fall back to O(N) namespaced SSARs. - if (rules.resourceRules.length === 0) return { type: 'deny-all' } - // Non-empty but incomplete: authorizer may have omitted grants for this kind — fall back. - if (rules.incomplete) return { type: 'incomplete' } - return { type: 'deny-all' } -} - -function resolveKindGetAccess(resource: AccessResource, token: string): Promise { - const group = apiGroupFromVersion(resource.apiVersion) - const plural = resourcePluralName(resource.kind) - const namespace = rulesNamespaceFor(resource) - // Permission checks are by API group, not version; keep cache keys version-free. - const cacheKey = `${hashAccessToken(token)}:${namespace}:${group}:${plural}` - const existing = kindGetAccessCache[cacheKey] - if (existing && existing.time > Date.now() - ACCESS_CACHE_TTL) { - return existing.promise - } - - const promise = getSubjectRules(token, namespace).then((rules) => evaluateKindGetAccess(rules, group, plural)) - kindGetAccessCache[cacheKey] = { time: Date.now(), promise } - return promise -} - -export function canAccess(resource: AccessResource, verb: 'get' | 'list' | 'create', token: string): Promise { - // Cache is cleaned up periodically by cleanupAccessCache() to prevent unbounded memory growth - const tokenKey = hashAccessToken(token) - const key = `${verb}:${resource.kind}:${resource.metadata?.namespace}:${resource.metadata?.name}` - if (!accessCache[tokenKey]) accessCache[tokenKey] = {} - const existing = accessCache[tokenKey][key] - if (existing && existing.time > Date.now() - ACCESS_CACHE_TTL) { - return existing.promise - } - - const resourceName = resourcePluralName(resource.kind) - const promise = jsonPost<{ status: { allowed: boolean } }>( - process.env.CLUSTER_API_URL + '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', - { - apiVersion: 'authorization.k8s.io/v1', - kind: 'SelfSubjectAccessReview', - metadata: {}, - spec: { - resourceAttributes: { - group: apiGroupFromVersion(resource.apiVersion), - name: resource.metadata?.name, - namespace: - resource.metadata?.namespace ?? (resource.kind === 'Namespace' ? resource.metadata?.name : undefined), - resource: resourceName, - verb, - }, - }, - }, - token - ).then((result) => { - const allowed = result.body.status.allowed - if (process.env.LOG_ACCESS === 'true') { - logger.debug({ - msg: 'access', - allowed, - verb, - resource: resourceName, - name: resource.metadata?.name, - namespace: resource.metadata?.namespace, - }) - } - // Replace in-flight promise with a settled boolean promise to drop large closures. - const entry = accessCache[tokenKey]?.[key] - if (entry?.promise === promise) { - entry.promise = Promise.resolve(allowed) - } - return allowed - }) - - accessCache[tokenKey][key] = { - time: Date.now(), - promise, - } - enforceAccessCacheEntryCap(accessCache[tokenKey]) - return promise -} - let stopping = false export function stopWatching(): void { stopping = true diff --git a/backend/src/routes/eventsAccess.ts b/backend/src/routes/eventsAccess.ts new file mode 100644 index 00000000000..e0cf346762a --- /dev/null +++ b/backend/src/routes/eventsAccess.ts @@ -0,0 +1,308 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +import pluralize from 'pluralize' +import { jsonPost } from '../lib/json-request' +import { logger } from '../lib/logger' +import { + deleteTimedCacheEntry, + getKindGetAccessCacheStore, + getSubjectRulesCacheStore, + getSsarCacheEntry, + getTimedCacheEntry, + hashAccessToken, + replaceSsarCachePromise, + setSsarCacheEntry, + setTimedCacheEntry, +} from './eventsCache' + +export interface SubjectRulesStatus { + incomplete: boolean + /** True when the SelfSubjectRulesReview request itself failed. */ + unavailable?: boolean + /** Set when an authorizer could not fully enumerate rules; partial lists must not be trusted as complete. */ + evaluationError?: string + resourceRules: Array<{ + verbs?: string[] + apiGroups?: string[] + resources?: string[] + resourceNames?: string[] + }> +} + +export type KindGetAccess = + { type: 'deny-all' } | { type: 'allow-all' } | { type: 'allow-names'; names: Set } | { type: 'incomplete' } + +export type AccessResource = { kind: string; apiVersion: string; metadata?: { name?: string; namespace?: string } } + +/** SSRR requires a namespace; cluster-scoped kinds are reviewed in this probe namespace only. */ +const CLUSTER_SCOPED_RULES_NAMESPACE = 'default' + +let clusterScopedKinds = new Set() + +/** Replace the cluster-scoped kind lookup. Derived from watch definitions in events.ts. */ +export function configureClusterScopedKinds(kinds: Iterable): void { + clusterScopedKinds = new Set(kinds) +} + +export function isClusterScopedKind(kind: string): boolean { + return clusterScopedKinds.has(kind) +} + +const subjectRulesCache = getSubjectRulesCacheStore() +const kindGetAccessCache = getKindGetAccessCacheStore() + +export function canListClusterScopedKind(resource: AccessResource, token: string): Promise { + return canAccess({ kind: resource.kind, apiVersion: resource.apiVersion }, 'list', token) +} + +export function canListNamespacedScopedKind(resource: AccessResource, token: string): Promise { + if (!resource.metadata?.namespace) return Promise.resolve(false) + return canAccess( + { + kind: resource.kind, + apiVersion: resource.apiVersion, + metadata: { namespace: resource.metadata.namespace }, + }, + 'list', + token + ) +} + +function apiGroupFromVersion(apiVersion: string): string { + return apiVersion.includes('/') ? apiVersion.split('/')[0] : '' +} + +function resourcePluralName(kind: string): string { + return pluralize(kind.toLowerCase()) +} + +function rulesNamespaceFor(resource: AccessResource): string { + if (isClusterScopedKind(resource.kind)) { + return CLUSTER_SCOPED_RULES_NAMESPACE + } + return resource.metadata?.namespace || CLUSTER_SCOPED_RULES_NAMESPACE +} + +/** + * Used by SSE eventFilter after cluster-scoped list is denied. + * Namespaced resources are reviewed in the resource's namespace (cached per token+namespace). + * Cluster-scoped resources use a probe-namespace review only as a negative/named-binding cache; + * unrestricted grants from that probe are confirmed with SSAR so RoleBindings in `default` + * cannot impersonate cluster-scoped access. + */ +export function canGetResource(resource: AccessResource, token: string): Promise { + return resolveKindGetAccess(resource, token).then((access) => { + // Probe-namespace SSRR cannot distinguish RoleBindings from ClusterRoleBindings. + // Any non-deny cluster-scoped result must be confirmed with SSAR (not only allow-all/allow-names). + if (isClusterScopedKind(resource.kind) && access.type !== 'deny-all') { + return canAccess(resource, 'get', token) + } + return applyKindGetAccess(access, resource, token, () => + canListNamespacedScopedKind(resource, token).then((nsAllowed) => { + if (nsAllowed) return true + return canAccess(resource, 'get', token) + }) + ) + }) +} + +function applyKindGetAccess( + access: KindGetAccess, + resource: AccessResource, + token: string, + onIncomplete?: () => Promise +): Promise { + switch (access.type) { + case 'deny-all': + return Promise.resolve(false) + case 'allow-all': + return Promise.resolve(true) + case 'allow-names': + return Promise.resolve(resource.metadata?.name ? access.names.has(resource.metadata.name) : false) + case 'incomplete': + return onIncomplete ? onIncomplete() : canAccess(resource, 'get', token) + } +} + +/** + * One SelfSubjectRulesReview per token+namespace. + * ClusterRoleBindings appear in every namespace; RoleBindings appear only in their namespace. + */ +function getSubjectRules(token: string, namespace: string): Promise { + const cacheKey = `${hashAccessToken(token)}:${namespace}` + const existing = getTimedCacheEntry(cacheKey, subjectRulesCache) + if (existing) { + return existing.promise + } + + const promise = jsonPost<{ + status?: { + incomplete?: boolean + evaluationError?: string + resourceRules?: SubjectRulesStatus['resourceRules'] + } + }>( + process.env.CLUSTER_API_URL + '/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', + { + apiVersion: 'authorization.k8s.io/v1', + kind: 'SelfSubjectRulesReview', + metadata: {}, + spec: { namespace }, + }, + token + ) + .then((result) => { + // jsonPost resolves on HTTP errors; treat non-2xx as review unavailable (SSAR fallback). + if (result.statusCode < 200 || result.statusCode >= 300) { + throw new Error(`SelfSubjectRulesReview failed with status ${result.statusCode}`) + } + return { + incomplete: result.body?.status?.incomplete ?? false, + evaluationError: result.body?.status?.evaluationError, + resourceRules: result.body?.status?.resourceRules ?? [], + } + }) + .catch((err: unknown) => { + logger.warn({ msg: 'SelfSubjectRulesReview failed; falling back to per-object SSAR', error: err }) + // Do not retain a failed review under ACCESS_CACHE_TTL; next call should retry SSRR. + deleteTimedCacheEntry(cacheKey, subjectRulesCache) + return { + incomplete: true, + unavailable: true, + resourceRules: [] as SubjectRulesStatus['resourceRules'], + } + }) + + setTimedCacheEntry(cacheKey, subjectRulesCache, { time: Date.now(), promise }) + return promise +} + +function ruleGrantsKindAccess( + rule: SubjectRulesStatus['resourceRules'][number], + group: string, + resourcePlural: string, + accessVerbs: Set +): { allowAll: true } | { names: string[] } | null { + const verbs = rule.verbs ?? [] + if (!verbs.includes('*') && !verbs.some((verb) => accessVerbs.has(verb))) return null + + const groups = rule.apiGroups ?? [] + if (!groups.includes('*') && !groups.includes(group)) return null + + const resources = rule.resources ?? [] + if (!resources.includes('*') && !resources.includes(resourcePlural)) return null + + const resourceNames = rule.resourceNames + if (!resourceNames || resourceNames.length === 0 || resourceNames.includes('*')) { + return { allowAll: true } + } + return { names: resourceNames } +} + +function evaluateKindGetAccess(rules: SubjectRulesStatus, group: string, resourcePlural: string): KindGetAccess { + const accessVerbs = new Set(['get', 'list', 'watch']) + + // Authorizer reported partial rule enumeration; empty rules still deny-all (none-user fast path). + if (rules.evaluationError) { + if (rules.resourceRules.length === 0) return { type: 'deny-all' } + return { type: 'incomplete' } + } + + let allowAll = false + const names = new Set() + + for (const rule of rules.resourceRules) { + const match = ruleGrantsKindAccess(rule, group, resourcePlural, accessVerbs) + if (!match) continue + if ('allowAll' in match) { + allowAll = true + break + } + for (const name of match.names) names.add(name) + } + + if (allowAll) return { type: 'allow-all' } + if (names.size > 0) return { type: 'allow-names', names } + // The review request failed; defer to the per-object SSAR fallback. + if (rules.unavailable === true) return { type: 'incomplete' } + // OpenShift often sets incomplete=true even when the user has no bindings and resourceRules + // is empty. Treat empty rules as deny-all so we do not fall back to O(N) namespaced SSARs. + if (rules.resourceRules.length === 0) return { type: 'deny-all' } + // Non-empty but incomplete: authorizer may have omitted grants for this kind — fall back. + if (rules.incomplete) return { type: 'incomplete' } + return { type: 'deny-all' } +} + +function resolveKindGetAccess(resource: AccessResource, token: string): Promise { + const group = apiGroupFromVersion(resource.apiVersion) + const plural = resourcePluralName(resource.kind) + const namespace = rulesNamespaceFor(resource) + // Permission checks are by API group, not version; keep cache keys version-free. + const cacheKey = `${hashAccessToken(token)}:${namespace}:${group}:${plural}` + const existing = getTimedCacheEntry(cacheKey, kindGetAccessCache) + if (existing) { + return existing.promise + } + + const promise = getSubjectRules(token, namespace).then((rules) => { + // Do not retain a decision derived from an unavailable review; allow SSRR retry. + if (rules.unavailable) deleteTimedCacheEntry(cacheKey, kindGetAccessCache) + return evaluateKindGetAccess(rules, group, plural) + }) + setTimedCacheEntry(cacheKey, kindGetAccessCache, { time: Date.now(), promise }) + return promise +} + +export function canAccess(resource: AccessResource, verb: 'get' | 'list' | 'create', token: string): Promise { + // Cache is cleaned up periodically by cleanupAccessCache() to prevent unbounded memory growth + const tokenKey = hashAccessToken(token) + const group = apiGroupFromVersion(resource.apiVersion) + // Include API group: Application/Subscription exist in more than one group with different RBAC. + const key = `${verb}:${group}:${resource.kind}:${resource.metadata?.namespace}:${resource.metadata?.name}` + const existing = getSsarCacheEntry(tokenKey, key) + if (existing) { + return existing.promise + } + + const resourceName = resourcePluralName(resource.kind) + const promise = jsonPost<{ status: { allowed: boolean } }>( + process.env.CLUSTER_API_URL + '/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', + { + apiVersion: 'authorization.k8s.io/v1', + kind: 'SelfSubjectAccessReview', + metadata: {}, + spec: { + resourceAttributes: { + group, + name: resource.metadata?.name, + namespace: + resource.metadata?.namespace ?? (resource.kind === 'Namespace' ? resource.metadata?.name : undefined), + resource: resourceName, + verb, + }, + }, + }, + token + ).then((result) => { + const allowed = result.body.status.allowed + if (process.env.LOG_ACCESS === 'true') { + logger.debug({ + msg: 'access', + allowed, + verb, + resource: resourceName, + name: resource.metadata?.name, + namespace: resource.metadata?.namespace, + }) + } + // Replace in-flight promise with a settled boolean promise to drop large closures. + replaceSsarCachePromise(tokenKey, key, promise, allowed) + return allowed + }) + + setSsarCacheEntry(tokenKey, key, { + time: Date.now(), + promise, + }) + return promise +} diff --git a/backend/src/routes/eventsCache.ts b/backend/src/routes/eventsCache.ts new file mode 100644 index 00000000000..5f33762bc5f --- /dev/null +++ b/backend/src/routes/eventsCache.ts @@ -0,0 +1,188 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +import { createHash } from 'node:crypto' +import { logger } from '../lib/logger' + +export const ACCESS_CACHE_TTL = 60 * 1000 // 60 seconds +export const ACCESS_CACHE_CLEANUP_INTERVAL = 90 * 1000 // 90 seconds +export const ACCESS_CACHE_MAX_TOKENS = 1000 // Maximum number of token entries to keep +export const ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN = 2000 // Cap RBAC keys retained per token + +export interface TimedCacheEntry { + time: number + promise: Promise +} + +const accessCache: Record>> = {} +const subjectRulesCache: Record> = {} +const kindGetAccessCache: Record> = {} + +let accessCacheCleanupTimer: NodeJS.Timeout | undefined + +/** Hash bearer tokens so the access cache does not retain full JWTs as object keys. */ +export function hashAccessToken(token: string): string { + return createHash('sha256').update(token).digest('hex') +} + +/** Clear all cached RBAC access checks. Used for test isolation. */ +export function resetAccessCache() { + for (const key in accessCache) { + delete accessCache[key] + } + for (const key in subjectRulesCache) { + delete subjectRulesCache[key] + } + for (const key in kindGetAccessCache) { + delete kindGetAccessCache[key] + } +} + +export function getAccessCache() { + return accessCache +} + +function enforceAccessCacheEntryCap(tokenCache: Record>) { + const keys = Object.keys(tokenCache) + if (keys.length <= ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN) return + keys.sort((a, b) => tokenCache[a].time - tokenCache[b].time) + const toRemove = keys.length - ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN + for (let i = 0; i < toRemove; i++) { + delete tokenCache[keys[i]] + } +} + +function expireTimedEntries(cache: Record, cutoffTime: number) { + for (const key in cache) { + if (cache[key].time < cutoffTime) { + delete cache[key] + } + } +} + +/** Prune one token's SSAR entries; returns newest remaining time, or undefined if the token was removed. */ +function pruneAccessCacheToken( + token: string, + tokenCache: Record>, + cutoffTime: number +): number | undefined { + let newestTime = 0 + + for (const key in tokenCache) { + if (tokenCache[key].time < cutoffTime) { + delete tokenCache[key] + } else if (tokenCache[key].time > newestTime) { + newestTime = tokenCache[key].time + } + } + + if (Object.keys(tokenCache).length === 0) { + delete accessCache[token] + return undefined + } + + enforceAccessCacheEntryCap(tokenCache) + return newestTime +} + +export function cleanupAccessCache() { + const cutoffTime = Date.now() - ACCESS_CACHE_TTL + const tokenStats: Array<{ token: string; newestTime: number }> = [] + + for (const token in accessCache) { + const newestTime = pruneAccessCacheToken(token, accessCache[token], cutoffTime) + if (newestTime !== undefined) { + tokenStats.push({ token, newestTime }) + } + } + + expireTimedEntries(subjectRulesCache, cutoffTime) + expireTimedEntries(kindGetAccessCache, cutoffTime) + + if (tokenStats.length <= ACCESS_CACHE_MAX_TOKENS) return + + tokenStats.sort((a, b) => a.newestTime - b.newestTime) + const tokensToRemove = tokenStats.length - ACCESS_CACHE_MAX_TOKENS + for (let i = 0; i < tokensToRemove; i++) { + delete accessCache[tokenStats[i].token] + } +} + +export function startAccessCacheCleanup() { + if (accessCacheCleanupTimer) return + + accessCacheCleanupTimer = setInterval(() => { + try { + cleanupAccessCache() + } catch (err: unknown) { + logger.error({ msg: 'accessCache cleanup failed', error: err }) + } + }, ACCESS_CACHE_CLEANUP_INTERVAL) + + accessCacheCleanupTimer.unref() + logger.info({ msg: 'accessCache cleanup started', interval: ACCESS_CACHE_CLEANUP_INTERVAL }) +} + +export function stopAccessCacheCleanup() { + if (accessCacheCleanupTimer) { + clearInterval(accessCacheCleanupTimer) + accessCacheCleanupTimer = undefined + logger.info({ msg: 'accessCache cleanup stopped' }) + } +} + +export function getSsarCacheEntry(tokenKey: string, key: string): TimedCacheEntry | undefined { + const existing = accessCache[tokenKey]?.[key] + if (existing && existing.time > Date.now() - ACCESS_CACHE_TTL) { + return existing + } + return undefined +} + +export function setSsarCacheEntry(tokenKey: string, key: string, entry: TimedCacheEntry) { + if (!accessCache[tokenKey]) accessCache[tokenKey] = {} + accessCache[tokenKey][key] = entry + enforceAccessCacheEntryCap(accessCache[tokenKey]) +} + +export function replaceSsarCachePromise( + tokenKey: string, + key: string, + inFlightPromise: Promise, + allowed: boolean +) { + const entry = accessCache[tokenKey]?.[key] + if (entry?.promise === inFlightPromise) { + entry.promise = Promise.resolve(allowed) + } +} + +export function getTimedCacheEntry( + cacheKey: string, + cache: Record> +): TimedCacheEntry | undefined { + const existing = cache[cacheKey] + if (existing && existing.time > Date.now() - ACCESS_CACHE_TTL) { + return existing as TimedCacheEntry + } + return undefined +} + +export function setTimedCacheEntry( + cacheKey: string, + cache: Record>, + entry: TimedCacheEntry +) { + cache[cacheKey] = entry +} + +export function deleteTimedCacheEntry(cacheKey: string, cache: Record>) { + delete cache[cacheKey] +} + +export function getSubjectRulesCacheStore() { + return subjectRulesCache +} + +export function getKindGetAccessCacheStore() { + return kindGetAccessCache +} diff --git a/backend/test/routes/events.test.ts b/backend/test/routes/events.test.ts index 22b9677b18e..f02d20614ad 100644 --- a/backend/test/routes/events.test.ts +++ b/backend/test/routes/events.test.ts @@ -15,15 +15,6 @@ import { createWatchEventProcessor, listAndWatch, stopWatching, - canAccess, - canGetResource, - resetAccessCache, - getAccessCache, - cleanupAccessCache, - hashAccessToken, - ACCESS_CACHE_TTL, - ACCESS_CACHE_MAX_TOKENS, - ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN, } from '../../src/routes/events' import * as serviceAccountTokenModule from '../../src/lib/serviceAccountToken' import type { IArgoApplication, IResource } from '../../src/resources/resource' @@ -1414,860 +1405,4 @@ describe('events Route', () => { expect(listCallCount).toBe(1) // Only counting second list call }) }) - - describe('Access Cache Cleanup', () => { - beforeEach(() => { - resetAccessCache() - jest.clearAllMocks() - process.env.CLUSTER_API_URL = 'https://api.test-cluster.com:6443' - }) - - afterEach(() => { - resetAccessCache() - delete process.env.CLUSTER_API_URL - nock.cleanAll() - }) - - it('should cache RBAC access check results under hashed token keys', async () => { - const mockToken = 'test-token-123' - const resource = { kind: 'Pod', apiVersion: 'v1', metadata: { namespace: 'default', name: 'test-pod' } } - - nock(process.env.CLUSTER_API_URL || '') - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') - .reply(200, { status: { allowed: true } }) - - const result1 = await canAccess(resource, 'get', mockToken) - const result2 = await canAccess(resource, 'get', mockToken) - - expect(result1).toBe(true) - expect(result1).toBe(result2) - expect(getAccessCache()[mockToken]).toBeUndefined() - expect(getAccessCache()[hashAccessToken(mockToken)]['get:Pod:default:test-pod']).toBeDefined() - }) - - it('should use distinct cache keys per verb', async () => { - const mockToken = 'test-token-verb' - const resource = { kind: 'Pod', apiVersion: 'v1', metadata: { namespace: 'default', name: 'test-pod' } } - - nock(process.env.CLUSTER_API_URL || '') - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') - .reply(200, { status: { allowed: true } }) - nock(process.env.CLUSTER_API_URL || '') - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') - .reply(200, { status: { allowed: false } }) - - expect(await canAccess(resource, 'get', mockToken)).toBe(true) - expect(await canAccess(resource, 'list', mockToken)).toBe(false) - - const tokenCache = getAccessCache()[hashAccessToken(mockToken)] - expect(tokenCache['get:Pod:default:test-pod']).toBeDefined() - expect(tokenCache['list:Pod:default:test-pod']).toBeDefined() - }) - - it('should log access checks when LOG_ACCESS is enabled', async () => { - const { logger } = await import('../../src/lib/logger') - const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => {}) - process.env.LOG_ACCESS = 'true' - - nock(process.env.CLUSTER_API_URL || '') - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') - .reply(200, { status: { allowed: true } }) - - await canAccess( - { kind: 'Pod', apiVersion: 'v1', metadata: { namespace: 'default', name: 'logged-pod' } }, - 'get', - 'log-access-token' - ) - - expect(debugSpy).toHaveBeenCalledWith( - expect.objectContaining({ msg: 'access', allowed: true, verb: 'get', resource: 'pods' }) - ) - - debugSpy.mockRestore() - delete process.env.LOG_ACCESS - }) - - it('should respect TTL and refetch after expiry', async () => { - const cache = getAccessCache() - const mockToken = 'test-token-ttl' - const tokenKey = hashAccessToken(mockToken) - - cache[tokenKey] = { - 'get:Secret:default:credentials': { - time: Date.now() - ACCESS_CACHE_TTL - 1000, - promise: Promise.resolve(true), - }, - } - - nock(process.env.CLUSTER_API_URL || '') - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') - .reply(200, { status: { allowed: false } }) - - const result = await canAccess( - { kind: 'Secret', apiVersion: 'v1', metadata: { namespace: 'default', name: 'credentials' } }, - 'get', - mockToken - ) - expect(result).toBe(false) - }) - - it('should remove stale cache entries during cleanup', () => { - const cache = getAccessCache() - const now = Date.now() - - cache['token1'] = { - stale: { time: now - ACCESS_CACHE_TTL - 1000, promise: Promise.resolve(true) }, - fresh: { time: now - 30000, promise: Promise.resolve(true) }, - } - cache['token2'] = { 'stale-only': { time: now - ACCESS_CACHE_TTL - 5000, promise: Promise.resolve(false) } } - - cleanupAccessCache() - - expect(cache['token1']['stale']).toBeUndefined() - expect(cache['token1']['fresh']).toBeDefined() - expect(cache['token2']).toBeUndefined() - }) - - it('should expire subject rules and kind access caches during cleanup', async () => { - let rulesCalls = 0 - nock(process.env.CLUSTER_API_URL || '') - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') - .twice() - .reply(200, () => { - rulesCalls++ - return { status: { incomplete: false, resourceRules: [] } } - }) - - const start = 1_000_000 - const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(start) - - await canGetResource( - { kind: 'ManagedCluster', apiVersion: 'cluster.open-cluster-management.io/v1', metadata: { name: 'c1' } }, - 'cache-expiry-token' - ) - - nowSpy.mockReturnValue(start + ACCESS_CACHE_TTL + 1) - cleanupAccessCache() - - nowSpy.mockReturnValue(start + ACCESS_CACHE_TTL + 2) - await canGetResource( - { kind: 'ManagedCluster', apiVersion: 'cluster.open-cluster-management.io/v1', metadata: { name: 'c2' } }, - 'cache-expiry-token' - ) - - nowSpy.mockRestore() - expect(rulesCalls).toBe(2) - }) - - it('should enforce maximum token limit with LRU eviction', () => { - const cache = getAccessCache() - const now = Date.now() - const tokenCount = ACCESS_CACHE_MAX_TOKENS + 100 - - for (let i = 0; i < tokenCount; i++) { - cache[`token-${i}`] = { - 'Pod:default:test': { time: now - (i / tokenCount) * 50 * 1000, promise: Promise.resolve(true) }, - } - } - - cleanupAccessCache() - - expect(Object.keys(cache).length).toBe(ACCESS_CACHE_MAX_TOKENS) - expect(cache['token-0']).toBeDefined() - expect(cache[`token-${tokenCount - 1}`]).toBeUndefined() - }) - - it('should enforce maximum entries per token', async () => { - const mockToken = 'test-token-entry-cap' - - nock(process.env.CLUSTER_API_URL || '') - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') - .times(ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN + 50) - .reply(200, { status: { allowed: false } }) - - for (let i = 0; i < ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN + 50; i++) { - await canAccess( - { kind: 'Pod', apiVersion: 'v1', metadata: { namespace: 'default', name: `pod-${i}` } }, - 'get', - mockToken - ) - } - - const tokenCache = getAccessCache()[hashAccessToken(mockToken)] - expect(Object.keys(tokenCache).length).toBeLessThanOrEqual(ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN) - }) - }) - - /** - * ACM-39327: restricted users must not trigger O(N) SelfSubjectAccessReviews when the SSE - * filter falls through after cluster-scoped list is denied. SelfSubjectRulesReview is - * namespaced: cache one review per token+namespace, never treat `default` as global allow. - */ - describe('SelfSubjectRulesReview short-circuit (ACM-39327)', () => { - const apiUrl = () => process.env.CLUSTER_API_URL || '' - const managedCluster = (name: string) => ({ - kind: 'ManagedCluster', - apiVersion: 'cluster.open-cluster-management.io/v1', - metadata: { name }, - }) - const secret = (namespace: string, name: string) => ({ - kind: 'Secret', - apiVersion: 'v1', - metadata: { namespace, name }, - }) - const managedClusterInfo = (cluster: string) => ({ - kind: 'ManagedClusterInfo', - apiVersion: 'internal.open-cluster-management.io/v1beta1', - metadata: { name: cluster, namespace: cluster }, - }) - - const emptyRules = { incomplete: false, resourceRules: [] as unknown[] } - const secretGetInNamespace = { - incomplete: false, - resourceRules: [{ verbs: ['get'], apiGroups: [''], resources: ['secrets'] }], - } - const clusterAdminRules = { - incomplete: false, - resourceRules: [{ verbs: ['*'], apiGroups: ['*'], resources: ['*'] }], - } - - function rulesReviewNamespace(body: unknown): string { - let parsed = body - if (typeof body === 'string') { - try { - parsed = JSON.parse(body) as unknown - } catch { - return '' - } - } - return (parsed as { spec?: { namespace?: string } })?.spec?.namespace || '' - } - - function nockRulesReview(replyFn: (namespace: string) => { incomplete?: boolean; resourceRules?: unknown[] }) { - return nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') - .reply(200, (_uri: string, requestBody: unknown) => ({ status: replyFn(rulesReviewNamespace(requestBody)) })) - } - - function parseSsarResourceAttributes(body: unknown) { - const parsed = - typeof body === 'string' - ? (JSON.parse(body) as { spec?: { resourceAttributes?: Record } }) - : body - return (parsed as { spec?: { resourceAttributes?: Record } })?.spec - ?.resourceAttributes - } - - function nockSsarGet(matcher: (attrs: Record) => boolean, allowed: boolean) { - return nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', (body: unknown) => { - const attrs = parseSsarResourceAttributes(body) - return attrs?.verb === 'get' && matcher(attrs ?? {}) - }) - .reply(200, { status: { allowed } }) - } - - const namedManagedClusterRule = (name: string) => ({ - incomplete: false, - resourceRules: [ - { - verbs: ['get'], - apiGroups: ['cluster.open-cluster-management.io'], - resources: ['managedclusters'], - resourceNames: [name], - }, - ], - }) - - beforeEach(() => { - resetAccessCache() - jest.clearAllMocks() - process.env.CLUSTER_API_URL = 'https://api.test-cluster.com:6443' - }) - - afterEach(() => { - resetAccessCache() - delete process.env.CLUSTER_API_URL - nock.cleanAll() - }) - - it('should deny all gets from complete empty rules without per-object SSAR', async () => { - nockRulesReview(() => emptyRules) - - const ssarScope = nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') - .reply(200, { status: { allowed: true } }) - - expect(await canGetResource(managedCluster('cluster-1'), 'none-user-token')).toBe(false) - expect(ssarScope.isDone()).toBe(false) - }) - - it('should use a single rules review for many cluster-scoped gets of the same kind', async () => { - let rulesCalls = 0 - nockRulesReview(() => { - rulesCalls++ - return emptyRules - }) - - const ssarScope = nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') - .times(1) - .reply(200, { status: { allowed: true } }) - - const results = await Promise.all( - Array.from({ length: 500 }, (_, i) => canGetResource(managedCluster(`cluster-${i}`), 'scale-none-token')) - ) - - expect(results.every((allowed) => allowed === false)).toBe(true) - expect(rulesCalls).toBe(1) - expect(ssarScope.isDone()).toBe(false) - }) - - it('should use a single rules review for many namespaced gets in the same namespace', async () => { - let rulesCalls = 0 - nockRulesReview(() => { - rulesCalls++ - return emptyRules - }) - - const results = await Promise.all( - Array.from({ length: 200 }, (_, i) => - canGetResource( - { - kind: 'ManagedClusterInfo', - apiVersion: 'internal.open-cluster-management.io/v1beta1', - metadata: { name: `info-${i}`, namespace: 'acm39327-mc-01' }, - }, - 'same-ns-none-token' - ) - ) - ) - - expect(results.every((allowed) => allowed === false)).toBe(true) - expect(rulesCalls).toBe(1) - }) - - it('should issue one rules review per namespace for a none user, without per-object SSAR', async () => { - const namespaces = new Set() - nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { - namespaces.add(rulesReviewNamespace(body)) - return true - }) - .times(50) - .reply(200, { status: emptyRules }) - - const ssarScope = nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') - .reply(200, { status: { allowed: true } }) - - const results = await Promise.all( - Array.from({ length: 50 }, (_, i) => - canGetResource(managedClusterInfo(`cluster-${i}`), 'namespaced-none-token') - ) - ) - - expect(results.every((allowed) => allowed === false)).toBe(true) - expect(namespaces.size).toBe(50) - expect(ssarScope.isDone()).toBe(false) - }) - - it('must not treat get secrets in default as access to Credentials in other namespaces', async () => { - nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { - return rulesReviewNamespace(body) === 'default' - }) - .reply(200, { status: secretGetInNamespace }) - nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { - return rulesReviewNamespace(body) !== 'default' - }) - .times(2) - .reply(200, { status: emptyRules }) - - expect(await canGetResource(secret('default', 'default-cred'), 'user1-token')).toBe(true) - expect(await canGetResource(secret('kube-system', 'other-cred'), 'user1-token')).toBe(false) - expect(await canGetResource(secret('acm39327-mc-01', 'cluster-cred'), 'user1-token')).toBe(false) - }) - - it('should allow namespaced cluster resources when the user is admin in that cluster namespace', async () => { - nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { - return rulesReviewNamespace(body) === 'acm39327-mc-01' - }) - .reply(200, { status: clusterAdminRules }) - nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { - return rulesReviewNamespace(body) !== 'acm39327-mc-01' - }) - .times(2) - .reply(200, { status: emptyRules }) - - expect(await canGetResource(managedClusterInfo('acm39327-mc-01'), 'cluster-admin-token')).toBe(true) - expect(await canGetResource(managedClusterInfo('other-cluster'), 'cluster-admin-token')).toBe(false) - expect(await canGetResource(managedCluster('acm39327-mc-01'), 'cluster-admin-token')).toBe(false) - }) - - it('should confirm named cluster-scoped resources with SSAR before allowing access', async () => { - nockRulesReview(() => namedManagedClusterRule('allowed-cluster')) - const ssarScope = nockSsarGet( - (attrs) => - attrs.group === 'cluster.open-cluster-management.io' && - attrs.resource === 'managedclusters' && - attrs.name === 'allowed-cluster', - true - ) - - expect(await canGetResource(managedCluster('allowed-cluster'), 'partial-user-token')).toBe(true) - expect(ssarScope.isDone()).toBe(true) - }) - - it('should deny non-matching names from cluster-scoped allow-names without trusting SSRR alone', async () => { - nockRulesReview(() => namedManagedClusterRule('allowed-cluster')) - const ssarScope = nockSsarGet( - (attrs) => - attrs.group === 'cluster.open-cluster-management.io' && - attrs.resource === 'managedclusters' && - attrs.name === 'other-cluster', - false - ) - - expect(await canGetResource(managedCluster('other-cluster'), 'partial-user-token')).toBe(false) - expect(ssarScope.isDone()).toBe(true) - }) - - it('should allow namespaced resources when rules grant unrestricted get/list/watch in that namespace', async () => { - nockRulesReview(() => ({ - incomplete: false, - resourceRules: [{ verbs: ['get', 'list', 'watch'], apiGroups: [''], resources: ['secrets'] }], - })) - - const ssarScope = nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') - .reply(200, { status: { allowed: false } }) - - expect(await canGetResource(secret('default', 'any-secret'), 'viewer-token')).toBe(true) - expect(ssarScope.isDone()).toBe(false) - }) - - it('should confirm cluster-scoped allow-all with SSAR so default RoleBindings are not treated as cluster access', async () => { - nockRulesReview(() => ({ - incomplete: false, - resourceRules: [ - { - verbs: ['get', 'list', 'watch'], - apiGroups: ['cluster.open-cluster-management.io'], - resources: ['managedclusters'], - }, - ], - })) - nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') - .reply(200, { status: { allowed: false } }) - - expect(await canGetResource(managedCluster('any-cluster'), 'default-role-token')).toBe(false) - }) - - it('should reuse kind access across API versions of the same group', async () => { - let rulesCalls = 0 - nockRulesReview(() => { - rulesCalls++ - return emptyRules - }) - - expect( - await canGetResource( - { kind: 'Placement', apiVersion: 'cluster.open-cluster-management.io/v1beta1', metadata: { name: 'p1' } }, - 'version-token' - ) - ).toBe(false) - expect( - await canGetResource( - { kind: 'Placement', apiVersion: 'cluster.open-cluster-management.io/v1alpha1', metadata: { name: 'p2' } }, - 'version-token' - ) - ).toBe(false) - expect(rulesCalls).toBe(1) - }) - - it('should confirm cluster-scoped incomplete rules with SSAR instead of trusting SSRR alone', async () => { - nockRulesReview(() => ({ - incomplete: true, - resourceRules: [{ verbs: ['get'], apiGroups: [''], resources: ['pods'] }], - })) - const ssarScope = nockSsarGet( - (attrs) => - attrs.group === 'cluster.open-cluster-management.io' && - attrs.resource === 'managedclusters' && - attrs.name === 'cluster-1', - true - ) - - expect(await canGetResource(managedCluster('cluster-1'), 'incomplete-user-token')).toBe(true) - expect(ssarScope.isDone()).toBe(true) - }) - - it('should deny-all when rules are empty even if incomplete is true (OpenShift none user)', async () => { - nockRulesReview(() => ({ incomplete: true, resourceRules: [] })) - - const ssarScope = nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') - .reply(200, { status: { allowed: true } }) - - expect(await canGetResource(managedCluster('cluster-1'), 'openshift-none-token')).toBe(false) - expect(ssarScope.isDone()).toBe(false) - }) - - it('should fall back to SSAR when SelfSubjectRulesReview request fails', async () => { - nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') - .reply(500, { message: 'internal error' }) - - const ssarScope = nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') - .reply(200, { status: { allowed: true } }) - - expect(await canGetResource(managedCluster('cluster-1'), 'ssrr-fail-token')).toBe(true) - expect(ssarScope.isDone()).toBe(true) - }) - - it('should reuse subject rules cache across kinds in the same namespace', async () => { - let rulesCalls = 0 - nockRulesReview(() => { - rulesCalls++ - return emptyRules - }) - - await canGetResource(managedCluster('cluster-1'), 'shared-rules-token') - await canGetResource( - { kind: 'Placement', apiVersion: 'cluster.open-cluster-management.io/v1beta1', metadata: { name: 'p1' } }, - 'shared-rules-token' - ) - - expect(rulesCalls).toBe(1) - }) - - it('should deny-all when evaluationError is set with empty resourceRules', async () => { - nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') - .reply(200, { - status: { - incomplete: false, - evaluationError: 'authorizer unavailable', - resourceRules: [], - }, - }) - - const ssarScope = nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') - .reply(200, { status: { allowed: true } }) - - expect(await canGetResource(managedCluster('cluster-1'), 'eval-error-empty-token')).toBe(false) - expect(ssarScope.isDone()).toBe(false) - }) - - it('should deny-all when rules do not grant access to the requested kind', async () => { - nockRulesReview(() => ({ - incomplete: false, - resourceRules: [{ verbs: ['get'], apiGroups: [''], resources: ['pods'] }], - })) - - const ssarScope = nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') - .reply(200, { status: { allowed: true } }) - - expect(await canGetResource(managedCluster('cluster-1'), 'unrelated-rules-token')).toBe(false) - expect(ssarScope.isDone()).toBe(false) - }) - - it('should fall back to namespaced list SSAR for incomplete namespaced resources', async () => { - nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { - return rulesReviewNamespace(body) === 'acm39327-mc-01' - }) - .reply(200, { - status: { - incomplete: true, - resourceRules: [{ verbs: ['get'], apiGroups: [''], resources: ['secrets'] }], - }, - }) - - const listScope = nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', (body: unknown) => { - const parsed = - typeof body === 'string' - ? (JSON.parse(body) as { spec?: { resourceAttributes?: { verb?: string; namespace?: string } } }) - : body - const attrs = (parsed as { spec?: { resourceAttributes?: { verb?: string; namespace?: string } } })?.spec - ?.resourceAttributes - return attrs?.verb === 'list' && attrs?.namespace === 'acm39327-mc-01' - }) - .reply(200, { status: { allowed: true } }) - - expect(await canGetResource(managedClusterInfo('acm39327-mc-01'), 'namespaced-incomplete-token')).toBe(true) - expect(listScope.isDone()).toBe(true) - }) - - it('should fall back to get SSAR when namespaced incomplete rules deny list access', async () => { - nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { - return rulesReviewNamespace(body) === 'acm39327-mc-01' - }) - .reply(200, { - status: { - incomplete: true, - resourceRules: [{ verbs: ['get'], apiGroups: [''], resources: ['secrets'] }], - }, - }) - - nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', (body: unknown) => { - const parsed = - typeof body === 'string' - ? (JSON.parse(body) as { spec?: { resourceAttributes?: { verb?: string; namespace?: string } } }) - : body - const attrs = (parsed as { spec?: { resourceAttributes?: { verb?: string; namespace?: string } } })?.spec - ?.resourceAttributes - return attrs?.verb === 'list' && attrs?.namespace === 'acm39327-mc-01' - }) - .reply(200, { status: { allowed: false } }) - - const getScope = nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', (body: unknown) => { - const parsed = - typeof body === 'string' - ? (JSON.parse(body) as { - spec?: { resourceAttributes?: { verb?: string; namespace?: string; name?: string } } - }) - : body - const attrs = ( - parsed as { spec?: { resourceAttributes?: { verb?: string; namespace?: string; name?: string } } } - )?.spec?.resourceAttributes - return attrs?.verb === 'get' && attrs?.namespace === 'acm39327-mc-01' && attrs?.name === 'acm39327-mc-01' - }) - .reply(200, { status: { allowed: true } }) - - expect(await canGetResource(managedClusterInfo('acm39327-mc-01'), 'namespaced-incomplete-get-token')).toBe(true) - expect(getScope.isDone()).toBe(true) - }) - - it('should deny allow-names when the resource has no name', async () => { - nockRulesReview(() => ({ - incomplete: false, - resourceRules: [ - { - verbs: ['get'], - apiGroups: [''], - resources: ['secrets'], - resourceNames: ['named-secret'], - }, - ], - })) - - expect( - await canGetResource( - { kind: 'Secret', apiVersion: 'v1', metadata: { namespace: 'default' } }, - 'named-only-token' - ) - ).toBe(false) - }) - }) - - describe('cluster-scoped SSRR middle-ground security (ACM-39327)', () => { - const apiUrl = () => process.env.CLUSTER_API_URL || '' - const managedCluster = (name: string) => ({ - kind: 'ManagedCluster', - apiVersion: 'cluster.open-cluster-management.io/v1', - metadata: { name }, - }) - - function rulesReviewNamespace(body: unknown): string { - let parsed = body - if (typeof body === 'string') { - try { - parsed = JSON.parse(body) as unknown - } catch { - return '' - } - } - return (parsed as { spec?: { namespace?: string } })?.spec?.namespace || '' - } - - function nockRulesReview(replyFn: (namespace: string) => { incomplete?: boolean; resourceRules?: unknown[] }) { - return nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') - .reply(200, (_uri: string, requestBody: unknown) => ({ status: replyFn(rulesReviewNamespace(requestBody)) })) - } - - function nockRulesReviewStatus( - replyFn: (namespace: string) => { - incomplete?: boolean - evaluationError?: string - resourceRules?: unknown[] - } - ) { - return nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') - .reply(200, (_uri: string, requestBody: unknown) => ({ status: replyFn(rulesReviewNamespace(requestBody)) })) - } - - function parseSsarResourceAttributes(body: unknown) { - const parsed = - typeof body === 'string' - ? (JSON.parse(body) as { spec?: { resourceAttributes?: Record } }) - : body - return (parsed as { spec?: { resourceAttributes?: Record } })?.spec - ?.resourceAttributes - } - - function nockSsarGet(matcher: (attrs: Record) => boolean, allowed: boolean) { - return nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', (body: unknown) => { - const attrs = parseSsarResourceAttributes(body) - return attrs?.verb === 'get' && matcher(attrs ?? {}) - }) - .reply(200, { status: { allowed } }) - } - - const namedManagedClusterRule = (name: string) => ({ - incomplete: false, - resourceRules: [ - { - verbs: ['get'], - apiGroups: ['cluster.open-cluster-management.io'], - resources: ['managedclusters'], - resourceNames: [name], - }, - ], - }) - - beforeEach(() => { - resetAccessCache() - jest.clearAllMocks() - process.env.CLUSTER_API_URL = 'https://api.test-cluster.com:6443' - }) - - afterEach(() => { - resetAccessCache() - delete process.env.CLUSTER_API_URL - nock.cleanAll() - }) - - it('should deny allow-names from a default RoleBinding when SSAR get is false', async () => { - nockRulesReview(() => namedManagedClusterRule('acm39327-mc-02')) - const ssarScope = nockSsarGet( - (attrs) => - attrs.group === 'cluster.open-cluster-management.io' && - attrs.resource === 'managedclusters' && - attrs.name === 'acm39327-mc-02', - false - ) - - expect(await canGetResource(managedCluster('acm39327-mc-02'), 'user1-token')).toBe(false) - expect(ssarScope.isDone()).toBe(true) - }) - - it('should allow allow-names only when SSAR get confirms a real ClusterRoleBinding grant', async () => { - nockRulesReview(() => namedManagedClusterRule('allowed-cluster')) - const ssarScope = nockSsarGet( - (attrs) => - attrs.group === 'cluster.open-cluster-management.io' && - attrs.resource === 'managedclusters' && - attrs.name === 'allowed-cluster', - true - ) - - expect(await canGetResource(managedCluster('allowed-cluster'), 'clusterrole-user-token')).toBe(true) - expect(ssarScope.isDone()).toBe(true) - }) - - it('should confirm cluster-scoped allow-all with SSAR and deny when SSAR rejects', async () => { - nockRulesReview(() => ({ - incomplete: false, - resourceRules: [ - { - verbs: ['get', 'list', 'watch'], - apiGroups: ['cluster.open-cluster-management.io'], - resources: ['managedclusters'], - }, - ], - })) - const ssarScope = nockSsarGet( - (attrs) => attrs.group === 'cluster.open-cluster-management.io' && attrs.resource === 'managedclusters', - false - ) - - expect(await canGetResource(managedCluster('any-cluster'), 'default-role-token')).toBe(false) - expect(ssarScope.isDone()).toBe(true) - }) - - it('should not trust allow-names on ManagedCluster when metadata.namespace is set without SSAR confirmation', async () => { - nock(apiUrl()) - .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { - return rulesReviewNamespace(body) === 'default' - }) - .reply(200, { status: namedManagedClusterRule('acm39327-mc-02') }) - const ssarScope = nockSsarGet( - (attrs) => - attrs.group === 'cluster.open-cluster-management.io' && - attrs.resource === 'managedclusters' && - attrs.name === 'acm39327-mc-02', - false - ) - - expect( - await canGetResource( - { - kind: 'ManagedCluster', - apiVersion: 'cluster.open-cluster-management.io/v1', - metadata: { name: 'acm39327-mc-02', namespace: 'default' }, - }, - 'user1-token' - ) - ).toBe(false) - expect(ssarScope.isDone()).toBe(true) - }) - - it('should treat SSRR evaluationError as incomplete and confirm cluster-scoped access with SSAR', async () => { - nockRulesReviewStatus(() => ({ - incomplete: false, - evaluationError: 'webhook authorizer does not support user rule resolution', - resourceRules: [{ verbs: ['get'], apiGroups: [''], resources: ['pods'] }], - })) - const ssarScope = nockSsarGet( - (attrs) => - attrs.group === 'cluster.open-cluster-management.io' && - attrs.resource === 'managedclusters' && - attrs.name === 'cluster-1', - true - ) - - expect(await canGetResource(managedCluster('cluster-1'), 'evaluation-error-token')).toBe(true) - expect(ssarScope.isDone()).toBe(true) - }) - - it('should confirm any non-deny-all cluster-scoped SSRR result with SSAR, not applyKindGetAccess alone', async () => { - nockRulesReview(() => ({ - incomplete: true, - resourceRules: [ - { - verbs: ['get'], - apiGroups: ['cluster.open-cluster-management.io'], - resources: ['managedclusters'], - resourceNames: ['cluster-1'], - }, - ], - })) - const ssarScope = nockSsarGet( - (attrs) => - attrs.group === 'cluster.open-cluster-management.io' && - attrs.resource === 'managedclusters' && - attrs.name === 'cluster-1', - false - ) - - expect(await canGetResource(managedCluster('cluster-1'), 'incomplete-named-token')).toBe(false) - expect(ssarScope.isDone()).toBe(true) - }) - }) }) diff --git a/backend/test/routes/eventsAccess.test.ts b/backend/test/routes/eventsAccess.test.ts new file mode 100644 index 00000000000..0b5758d31fb --- /dev/null +++ b/backend/test/routes/eventsAccess.test.ts @@ -0,0 +1,698 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import nock from 'nock' +import { + canAccess, + canGetResource, + canListClusterScopedKind, + canListNamespacedScopedKind, + configureClusterScopedKinds, +} from '../../src/routes/eventsAccess' +import { ACCESS_CACHE_TTL, cleanupAccessCache, resetAccessCache } from '../../src/routes/eventsCache' + +describe('eventsAccess', () => { + const apiUrl = () => process.env.CLUSTER_API_URL || '' + const managedCluster = (name: string) => ({ + kind: 'ManagedCluster', + apiVersion: 'cluster.open-cluster-management.io/v1', + metadata: { name }, + }) + const secret = (namespace: string, name: string) => ({ + kind: 'Secret', + apiVersion: 'v1', + metadata: { namespace, name }, + }) + const managedClusterInfo = (cluster: string) => ({ + kind: 'ManagedClusterInfo', + apiVersion: 'internal.open-cluster-management.io/v1beta1', + metadata: { name: cluster, namespace: cluster }, + }) + + const emptyRules = { incomplete: false, resourceRules: [] as unknown[] } + const secretGetInNamespace = { + incomplete: false, + resourceRules: [{ verbs: ['get'], apiGroups: [''], resources: ['secrets'] }], + } + const clusterAdminRules = { + incomplete: false, + resourceRules: [{ verbs: ['*'], apiGroups: ['*'], resources: ['*'] }], + } + + function rulesReviewNamespace(body: unknown): string { + let parsed = body + if (typeof body === 'string') { + try { + parsed = JSON.parse(body) as unknown + } catch { + return '' + } + } + return (parsed as { spec?: { namespace?: string } })?.spec?.namespace || '' + } + + function nockRulesReview(replyFn: (namespace: string) => { incomplete?: boolean; resourceRules?: unknown[] }) { + return nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') + .reply(200, (_uri: string, requestBody: unknown) => ({ status: replyFn(rulesReviewNamespace(requestBody)) })) + } + + function nockRulesReviewStatus( + replyFn: (namespace: string) => { + incomplete?: boolean + evaluationError?: string + resourceRules?: unknown[] + } + ) { + return nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') + .reply(200, (_uri: string, requestBody: unknown) => ({ status: replyFn(rulesReviewNamespace(requestBody)) })) + } + + function parseSsarResourceAttributes(body: unknown) { + const parsed = + typeof body === 'string' + ? (JSON.parse(body) as { spec?: { resourceAttributes?: Record } }) + : body + return (parsed as { spec?: { resourceAttributes?: Record } })?.spec?.resourceAttributes + } + + function nockSsarGet(matcher: (attrs: Record) => boolean, allowed: boolean) { + return nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', (body: unknown) => { + const attrs = parseSsarResourceAttributes(body) + return attrs?.verb === 'get' && matcher(attrs ?? {}) + }) + .reply(200, { status: { allowed } }) + } + + const namedManagedClusterRule = (name: string) => ({ + incomplete: false, + resourceRules: [ + { + verbs: ['get'], + apiGroups: ['cluster.open-cluster-management.io'], + resources: ['managedclusters'], + resourceNames: [name], + }, + ], + }) + + beforeEach(() => { + resetAccessCache() + configureClusterScopedKinds(['ManagedCluster', 'ClusterExtension', 'Namespace', 'StorageClass']) + process.env.CLUSTER_API_URL = 'https://api.test-cluster.com:6443' + }) + + afterEach(() => { + resetAccessCache() + delete process.env.CLUSTER_API_URL + nock.cleanAll() + }) + + describe('canListClusterScopedKind', () => { + it('should issue a cluster-scoped list SSAR', async () => { + const resource = managedCluster('cluster-1') + const ssarScope = nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', (body: unknown) => { + const parsed = + typeof body === 'string' + ? (JSON.parse(body) as { spec?: { resourceAttributes?: { verb?: string } } }) + : body + return ( + (parsed as { spec?: { resourceAttributes?: { verb?: string } } })?.spec?.resourceAttributes?.verb === 'list' + ) + }) + .reply(200, { status: { allowed: true } }) + + expect(await canListClusterScopedKind(resource, 'list-token')).toBe(true) + expect(ssarScope.isDone()).toBe(true) + }) + }) + + describe('canListNamespacedScopedKind', () => { + it('should return false when the resource has no namespace', async () => { + const ssarScope = nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + + expect(await canListNamespacedScopedKind(managedCluster('cluster-1'), 'list-token')).toBe(false) + expect(ssarScope.isDone()).toBe(false) + }) + + it('should issue a namespaced list SSAR', async () => { + const resource = managedClusterInfo('acm39327-mc-01') + const ssarScope = nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', (body: unknown) => { + const parsed = + typeof body === 'string' + ? (JSON.parse(body) as { spec?: { resourceAttributes?: { namespace?: string; verb?: string } } }) + : body + const attrs = (parsed as { spec?: { resourceAttributes?: { namespace?: string; verb?: string } } })?.spec + ?.resourceAttributes + return attrs?.namespace === 'acm39327-mc-01' && attrs?.verb === 'list' + }) + .reply(200, { status: { allowed: true } }) + + expect(await canListNamespacedScopedKind(resource, 'list-token')).toBe(true) + expect(ssarScope.isDone()).toBe(true) + }) + }) + + /** + * ACM-39327: restricted users must not trigger O(N) SelfSubjectAccessReviews when the SSE + * filter falls through after cluster-scoped list is denied. SelfSubjectRulesReview is + * namespaced: cache one review per token+namespace, never treat `default` as global allow. + */ + describe('SelfSubjectRulesReview short-circuit (ACM-39327)', () => { + it('should deny all gets from complete empty rules without per-object SSAR', async () => { + nockRulesReview(() => emptyRules) + + const ssarScope = nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + + expect(await canGetResource(managedCluster('cluster-1'), 'none-user-token')).toBe(false) + expect(ssarScope.isDone()).toBe(false) + }) + + it('should use a single rules review for many cluster-scoped gets of the same kind', async () => { + let rulesCalls = 0 + nockRulesReview(() => { + rulesCalls++ + return emptyRules + }) + + const ssarScope = nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .times(1) + .reply(200, { status: { allowed: true } }) + + const results = await Promise.all( + Array.from({ length: 500 }, (_, i) => canGetResource(managedCluster(`cluster-${i}`), 'scale-none-token')) + ) + + expect(results.every((allowed) => allowed === false)).toBe(true) + expect(rulesCalls).toBe(1) + expect(ssarScope.isDone()).toBe(false) + }) + + it('should use a single rules review for many namespaced gets in the same namespace', async () => { + let rulesCalls = 0 + nockRulesReview(() => { + rulesCalls++ + return emptyRules + }) + + const results = await Promise.all( + Array.from({ length: 200 }, (_, i) => + canGetResource( + { + kind: 'ManagedClusterInfo', + apiVersion: 'internal.open-cluster-management.io/v1beta1', + metadata: { name: `info-${i}`, namespace: 'acm39327-mc-01' }, + }, + 'same-ns-none-token' + ) + ) + ) + + expect(results.every((allowed) => allowed === false)).toBe(true) + expect(rulesCalls).toBe(1) + }) + + it('should issue one rules review per namespace for a none user, without per-object SSAR', async () => { + const namespaces = new Set() + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { + namespaces.add(rulesReviewNamespace(body)) + return true + }) + .times(50) + .reply(200, { status: emptyRules }) + + const ssarScope = nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + + const results = await Promise.all( + Array.from({ length: 50 }, (_, i) => + canGetResource(managedClusterInfo(`cluster-${i}`), 'namespaced-none-token') + ) + ) + + expect(results.every((allowed) => allowed === false)).toBe(true) + expect(namespaces.size).toBe(50) + expect(ssarScope.isDone()).toBe(false) + }) + + it('must not treat get secrets in default as access to Credentials in other namespaces', async () => { + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { + return rulesReviewNamespace(body) === 'default' + }) + .reply(200, { status: secretGetInNamespace }) + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { + return rulesReviewNamespace(body) !== 'default' + }) + .times(2) + .reply(200, { status: emptyRules }) + + expect(await canGetResource(secret('default', 'default-cred'), 'user1-token')).toBe(true) + expect(await canGetResource(secret('kube-system', 'other-cred'), 'user1-token')).toBe(false) + expect(await canGetResource(secret('acm39327-mc-01', 'cluster-cred'), 'user1-token')).toBe(false) + }) + + it('should allow namespaced cluster resources when the user is admin in that cluster namespace', async () => { + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { + return rulesReviewNamespace(body) === 'acm39327-mc-01' + }) + .reply(200, { status: clusterAdminRules }) + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { + return rulesReviewNamespace(body) !== 'acm39327-mc-01' + }) + .times(2) + .reply(200, { status: emptyRules }) + + expect(await canGetResource(managedClusterInfo('acm39327-mc-01'), 'cluster-admin-token')).toBe(true) + expect(await canGetResource(managedClusterInfo('other-cluster'), 'cluster-admin-token')).toBe(false) + expect(await canGetResource(managedCluster('acm39327-mc-01'), 'cluster-admin-token')).toBe(false) + }) + + it('should confirm named cluster-scoped resources with SSAR before allowing access', async () => { + nockRulesReview(() => namedManagedClusterRule('allowed-cluster')) + const ssarScope = nockSsarGet( + (attrs) => + attrs.group === 'cluster.open-cluster-management.io' && + attrs.resource === 'managedclusters' && + attrs.name === 'allowed-cluster', + true + ) + + expect(await canGetResource(managedCluster('allowed-cluster'), 'partial-user-token')).toBe(true) + expect(ssarScope.isDone()).toBe(true) + }) + + it('should deny non-matching names from cluster-scoped allow-names without trusting SSRR alone', async () => { + nockRulesReview(() => namedManagedClusterRule('allowed-cluster')) + const ssarScope = nockSsarGet( + (attrs) => + attrs.group === 'cluster.open-cluster-management.io' && + attrs.resource === 'managedclusters' && + attrs.name === 'other-cluster', + false + ) + + expect(await canGetResource(managedCluster('other-cluster'), 'partial-user-token')).toBe(false) + expect(ssarScope.isDone()).toBe(true) + }) + + it('should allow namespaced resources when rules grant unrestricted get/list/watch in that namespace', async () => { + nockRulesReview(() => ({ + incomplete: false, + resourceRules: [{ verbs: ['get', 'list', 'watch'], apiGroups: [''], resources: ['secrets'] }], + })) + + const ssarScope = nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: false } }) + + expect(await canGetResource(secret('default', 'any-secret'), 'viewer-token')).toBe(true) + expect(ssarScope.isDone()).toBe(false) + }) + + it('should confirm cluster-scoped allow-all with SSAR so default RoleBindings are not treated as cluster access', async () => { + nockRulesReview(() => ({ + incomplete: false, + resourceRules: [ + { + verbs: ['get', 'list', 'watch'], + apiGroups: ['cluster.open-cluster-management.io'], + resources: ['managedclusters'], + }, + ], + })) + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: false } }) + + expect(await canGetResource(managedCluster('any-cluster'), 'default-role-token')).toBe(false) + }) + + it('should reuse kind access across API versions of the same group', async () => { + let rulesCalls = 0 + nockRulesReview(() => { + rulesCalls++ + return emptyRules + }) + + expect( + await canGetResource( + { kind: 'Placement', apiVersion: 'cluster.open-cluster-management.io/v1beta1', metadata: { name: 'p1' } }, + 'version-token' + ) + ).toBe(false) + expect( + await canGetResource( + { kind: 'Placement', apiVersion: 'cluster.open-cluster-management.io/v1alpha1', metadata: { name: 'p2' } }, + 'version-token' + ) + ).toBe(false) + expect(rulesCalls).toBe(1) + }) + + it('should fall back to SSAR when rules review is incomplete with non-empty rules', async () => { + nockRulesReview(() => ({ + incomplete: true, + resourceRules: [{ verbs: ['get'], apiGroups: [''], resources: ['pods'] }], + })) + const ssarScope = nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + + expect(await canGetResource(managedCluster('cluster-1'), 'incomplete-user-token')).toBe(true) + expect(ssarScope.isDone()).toBe(true) + }) + + it('should deny-all when rules are empty even if incomplete is true (OpenShift none user)', async () => { + nockRulesReview(() => ({ incomplete: true, resourceRules: [] })) + + const ssarScope = nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + + expect(await canGetResource(managedCluster('cluster-1'), 'openshift-none-token')).toBe(false) + expect(ssarScope.isDone()).toBe(false) + }) + + it('should fall back to SSAR when SelfSubjectRulesReview request fails', async () => { + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') + .reply(500, { message: 'internal error' }) + + const ssarScope = nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + + expect(await canGetResource(managedCluster('cluster-1'), 'ssrr-fail-token')).toBe(true) + expect(ssarScope.isDone()).toBe(true) + }) + + it('should expire subject rules and kind access caches during cleanup', async () => { + let rulesCalls = 0 + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') + .twice() + .reply(200, () => { + rulesCalls++ + return { status: { incomplete: false, resourceRules: [] } } + }) + + const start = 1_000_000 + const nowSpy = jest.spyOn(Date, 'now').mockReturnValue(start) + + await canGetResource(managedCluster('c1'), 'cache-expiry-token') + + nowSpy.mockReturnValue(start + ACCESS_CACHE_TTL + 1) + cleanupAccessCache() + + nowSpy.mockReturnValue(start + ACCESS_CACHE_TTL + 2) + await canGetResource(managedCluster('c2'), 'cache-expiry-token') + + nowSpy.mockRestore() + expect(rulesCalls).toBe(2) + }) + }) + + /** + * TDD: middle-ground security — SSRR deny-all short-circuit only; any non-deny cluster-scoped + * result must be confirmed with SSAR. Implementation pending in eventsAccess.ts. + */ + describe('cluster-scoped SSRR middle-ground security (TDD)', () => { + it('should deny allow-names from a default RoleBinding when SSAR get is false (Kevin)', async () => { + nockRulesReview(() => namedManagedClusterRule('acm39327-mc-02')) + const ssarScope = nockSsarGet( + (attrs) => + attrs.group === 'cluster.open-cluster-management.io' && + attrs.resource === 'managedclusters' && + attrs.name === 'acm39327-mc-02', + false + ) + + expect(await canGetResource(managedCluster('acm39327-mc-02'), 'user1-token')).toBe(false) + expect(ssarScope.isDone()).toBe(true) + }) + + it('should allow allow-names only when SSAR get confirms a real ClusterRoleBinding grant', async () => { + nockRulesReview(() => namedManagedClusterRule('allowed-cluster')) + const ssarScope = nockSsarGet( + (attrs) => + attrs.group === 'cluster.open-cluster-management.io' && + attrs.resource === 'managedclusters' && + attrs.name === 'allowed-cluster', + true + ) + + expect(await canGetResource(managedCluster('allowed-cluster'), 'clusterrole-user-token')).toBe(true) + expect(ssarScope.isDone()).toBe(true) + }) + + it('should confirm cluster-scoped allow-all with SSAR and deny when SSAR rejects', async () => { + nockRulesReview(() => ({ + incomplete: false, + resourceRules: [ + { + verbs: ['get', 'list', 'watch'], + apiGroups: ['cluster.open-cluster-management.io'], + resources: ['managedclusters'], + }, + ], + })) + const ssarScope = nockSsarGet( + (attrs) => attrs.group === 'cluster.open-cluster-management.io' && attrs.resource === 'managedclusters', + false + ) + + expect(await canGetResource(managedCluster('any-cluster'), 'default-role-token')).toBe(false) + expect(ssarScope.isDone()).toBe(true) + }) + + it('should not trust allow-names on ManagedCluster when metadata.namespace is set without SSAR confirmation', async () => { + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { + return rulesReviewNamespace(body) === 'default' + }) + .reply(200, { status: namedManagedClusterRule('acm39327-mc-02') }) + const ssarScope = nockSsarGet( + (attrs) => + attrs.group === 'cluster.open-cluster-management.io' && + attrs.resource === 'managedclusters' && + attrs.name === 'acm39327-mc-02', + false + ) + + expect( + await canGetResource( + { + kind: 'ManagedCluster', + apiVersion: 'cluster.open-cluster-management.io/v1', + metadata: { name: 'acm39327-mc-02', namespace: 'default' }, + }, + 'user1-token' + ) + ).toBe(false) + expect(ssarScope.isDone()).toBe(true) + }) + + it('should treat SSRR evaluationError as incomplete and confirm cluster-scoped access with SSAR', async () => { + nockRulesReviewStatus(() => ({ + incomplete: false, + evaluationError: 'webhook authorizer does not support user rule resolution', + resourceRules: [{ verbs: ['get'], apiGroups: [''], resources: ['pods'] }], + })) + const ssarScope = nockSsarGet( + (attrs) => + attrs.group === 'cluster.open-cluster-management.io' && + attrs.resource === 'managedclusters' && + attrs.name === 'cluster-1', + true + ) + + expect(await canGetResource(managedCluster('cluster-1'), 'evaluation-error-token')).toBe(true) + expect(ssarScope.isDone()).toBe(true) + }) + + it('should confirm any non-deny-all cluster-scoped SSRR result with SSAR, not applyKindGetAccess alone', async () => { + nockRulesReview(() => ({ + incomplete: true, + resourceRules: [ + { + verbs: ['get'], + apiGroups: ['cluster.open-cluster-management.io'], + resources: ['managedclusters'], + resourceNames: ['cluster-1'], + }, + ], + })) + const ssarScope = nockSsarGet( + (attrs) => + attrs.group === 'cluster.open-cluster-management.io' && + attrs.resource === 'managedclusters' && + attrs.name === 'cluster-1', + false + ) + + expect(await canGetResource(managedCluster('cluster-1'), 'incomplete-named-token')).toBe(false) + expect(ssarScope.isDone()).toBe(true) + }) + }) + + describe('canAccess', () => { + it('should post a SelfSubjectAccessReview for the requested verb and resource', async () => { + const resource = secret('default', 'test-secret') + const ssarScope = nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', (body: unknown) => { + const parsed = + typeof body === 'string' + ? (JSON.parse(body) as { spec?: { resourceAttributes?: { verb?: string; resource?: string } } }) + : body + const attrs = (parsed as { spec?: { resourceAttributes?: { verb?: string; resource?: string } } })?.spec + ?.resourceAttributes + return attrs?.verb === 'create' && attrs?.resource === 'secrets' + }) + .reply(200, { status: { allowed: true } }) + + expect(await canAccess(resource, 'create', 'create-token')).toBe(true) + expect(ssarScope.isDone()).toBe(true) + }) + + it('should log access checks when LOG_ACCESS is enabled', async () => { + const { logger } = await import('../../src/lib/logger') + const debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => {}) + process.env.LOG_ACCESS = 'true' + + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + + await canAccess( + { kind: 'Pod', apiVersion: 'v1', metadata: { namespace: 'default', name: 'logged-pod' } }, + 'get', + 'log-access-token' + ) + + expect(debugSpy).toHaveBeenCalledWith( + expect.objectContaining({ msg: 'access', allowed: true, verb: 'get', resource: 'pods' }) + ) + + debugSpy.mockRestore() + delete process.env.LOG_ACCESS + }) + + it('should not reuse SSAR results across API groups that share a kind name', async () => { + const appK8s = { + kind: 'Application', + apiVersion: 'app.k8s.io/v1beta1', + metadata: { namespace: 'ns', name: 'app' }, + } + const argoApp = { + kind: 'Application', + apiVersion: 'argoproj.io/v1alpha1', + metadata: { namespace: 'ns', name: 'app' }, + } + + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', (body: unknown) => { + return parseSsarResourceAttributes(body)?.group === 'app.k8s.io' + }) + .reply(200, { status: { allowed: true } }) + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews', (body: unknown) => { + return parseSsarResourceAttributes(body)?.group === 'argoproj.io' + }) + .reply(200, { status: { allowed: false } }) + + expect(await canAccess(appK8s, 'get', 'group-collision-token')).toBe(true) + expect(await canAccess(argoApp, 'get', 'group-collision-token')).toBe(false) + }) + }) + + describe('namespaced vs cluster-scoped kind routing (ACM-39327)', () => { + const placement = (namespace: string, name: string) => ({ + kind: 'Placement', + apiVersion: 'cluster.open-cluster-management.io/v1beta1', + metadata: { namespace, name }, + }) + const clusterExtension = (name: string) => ({ + kind: 'ClusterExtension', + apiVersion: 'olm.operatorframework.io/v1', + metadata: { name }, + }) + const placementAllowAll = { + incomplete: false, + resourceRules: [ + { + verbs: ['get', 'list', 'watch'], + apiGroups: ['cluster.open-cluster-management.io'], + resources: ['placements'], + }, + ], + } + + it('must not treat Placement allow-all in default as access to other namespaces', async () => { + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { + return rulesReviewNamespace(body) === 'default' + }) + .reply(200, { status: placementAllowAll }) + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews', (body: unknown) => { + return rulesReviewNamespace(body) === 'other-ns' + }) + .reply(200, { status: emptyRules }) + + expect(await canGetResource(placement('default', 'p-default'), 'placement-token')).toBe(true) + expect(await canGetResource(placement('other-ns', 'p-other'), 'placement-token')).toBe(false) + }) + + it('should confirm ClusterExtension cluster-scoped grants with SSAR', async () => { + nockRulesReview(() => ({ + incomplete: false, + resourceRules: [ + { + verbs: ['get', 'list', 'watch'], + apiGroups: ['olm.operatorframework.io'], + resources: ['clusterextensions'], + }, + ], + })) + const ssarScope = nockSsarGet( + (attrs) => attrs.group === 'olm.operatorframework.io' && attrs.resource === 'clusterextensions', + true + ) + + expect(await canGetResource(clusterExtension('ext-1'), 'cluster-extension-token')).toBe(true) + expect(ssarScope.isDone()).toBe(true) + }) + + it('should retry SelfSubjectRulesReview after an unavailable review', async () => { + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews') + .reply(500, { message: 'internal error' }) + nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: false } }) + + expect(await canGetResource(managedCluster('cluster-1'), 'ssrr-retry-token')).toBe(false) + + nock(apiUrl()).post('/apis/authorization.k8s.io/v1/selfsubjectrulesreviews').reply(200, { status: emptyRules }) + const ssarScope = nock(apiUrl()) + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + + expect(await canGetResource(managedCluster('cluster-2'), 'ssrr-retry-token')).toBe(false) + expect(ssarScope.isDone()).toBe(false) + }) + }) +}) diff --git a/backend/test/routes/eventsCache.test.ts b/backend/test/routes/eventsCache.test.ts new file mode 100644 index 00000000000..3da9befc8a1 --- /dev/null +++ b/backend/test/routes/eventsCache.test.ts @@ -0,0 +1,250 @@ +/* Copyright Contributors to the Open Cluster Management project */ +import { createHash } from 'node:crypto' +import nock from 'nock' +import { canAccess } from '../../src/routes/eventsAccess' +import { + ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN, + ACCESS_CACHE_MAX_TOKENS, + ACCESS_CACHE_TTL, + cleanupAccessCache, + deleteTimedCacheEntry, + getAccessCache, + getKindGetAccessCacheStore, + getSsarCacheEntry, + getSubjectRulesCacheStore, + getTimedCacheEntry, + hashAccessToken, + replaceSsarCachePromise, + resetAccessCache, + setSsarCacheEntry, + setTimedCacheEntry, + startAccessCacheCleanup, + stopAccessCacheCleanup, +} from '../../src/routes/eventsCache' + +describe('eventsCache', () => { + beforeEach(() => { + resetAccessCache() + process.env.CLUSTER_API_URL = 'https://api.test-cluster.com:6443' + }) + + afterEach(() => { + resetAccessCache() + stopAccessCacheCleanup() + delete process.env.CLUSTER_API_URL + nock.cleanAll() + }) + + describe('hashAccessToken', () => { + it('should return a stable SHA-256 hex digest', () => { + const token = 'test-token-123' + const expected = createHash('sha256').update(token).digest('hex') + expect(hashAccessToken(token)).toBe(expected) + expect(hashAccessToken(token)).toBe(hashAccessToken(token)) + }) + }) + + describe('SSAR cache helpers', () => { + it('should return undefined for expired SSAR cache entries', () => { + const tokenKey = hashAccessToken('expired-token') + setSsarCacheEntry(tokenKey, 'get:Pod:default:pod', { + time: Date.now() - ACCESS_CACHE_TTL - 1000, + promise: Promise.resolve(true), + }) + expect(getSsarCacheEntry(tokenKey, 'get:Pod:default:pod')).toBeUndefined() + }) + + it('should replace in-flight SSAR promises with settled booleans', async () => { + const tokenKey = hashAccessToken('replace-token') + const key = 'get:Pod:default:pod' + const inFlight = Promise.resolve(true) + setSsarCacheEntry(tokenKey, key, { time: Date.now(), promise: inFlight }) + + replaceSsarCachePromise(tokenKey, key, inFlight, true) + const cached = getSsarCacheEntry(tokenKey, key) + if (!cached) throw new Error('expected SSAR cache entry') + expect(await cached.promise).toBe(true) + }) + + it('should enforce per-token entry cap when setting SSAR cache entries', () => { + const tokenKey = hashAccessToken('cap-token') + const now = Date.now() + for (let i = 0; i < ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN + 50; i++) { + setSsarCacheEntry(tokenKey, `get:Pod:default:pod-${i}`, { + time: now - i, + promise: Promise.resolve(false), + }) + } + cleanupAccessCache() + expect(Object.keys(getAccessCache()[tokenKey]).length).toBeLessThanOrEqual(ACCESS_CACHE_MAX_ENTRIES_PER_TOKEN) + }) + }) + + describe('timed cache helpers', () => { + it('should get, set, and delete timed cache entries with TTL', async () => { + const store = getSubjectRulesCacheStore() + const cacheKey = 'token:default' + + setTimedCacheEntry(cacheKey, store, { time: Date.now(), promise: Promise.resolve('rules') }) + const cached = getTimedCacheEntry(cacheKey, store) + if (!cached) throw new Error('expected timed cache entry') + expect(await cached.promise).toBe('rules') + + setTimedCacheEntry(cacheKey, store, { + time: Date.now() - ACCESS_CACHE_TTL - 1000, + promise: Promise.resolve('stale'), + }) + expect(getTimedCacheEntry(cacheKey, store)).toBeUndefined() + + setTimedCacheEntry(cacheKey, store, { time: Date.now(), promise: Promise.resolve('fresh') }) + deleteTimedCacheEntry(cacheKey, store) + expect(getTimedCacheEntry(cacheKey, store)).toBeUndefined() + }) + }) + + describe('resetAccessCache', () => { + it('should clear SSAR, subject rules, and kind-get-access stores', () => { + const tokenKey = hashAccessToken('reset-token') + setSsarCacheEntry(tokenKey, 'get:Pod:default:pod', { time: Date.now(), promise: Promise.resolve(true) }) + setTimedCacheEntry('rules-key', getSubjectRulesCacheStore(), { + time: Date.now(), + promise: Promise.resolve({ incomplete: false, resourceRules: [] }), + }) + setTimedCacheEntry('kind-key', getKindGetAccessCacheStore(), { + time: Date.now(), + promise: Promise.resolve({ type: 'deny-all' }), + }) + + resetAccessCache() + + expect(getAccessCache()[tokenKey]).toBeUndefined() + expect(Object.keys(getSubjectRulesCacheStore()).length).toBe(0) + expect(Object.keys(getKindGetAccessCacheStore()).length).toBe(0) + }) + }) + + describe('cleanupAccessCache', () => { + it('should remove stale SSAR entries during cleanup', () => { + const cache = getAccessCache() + const now = Date.now() + + cache['token1'] = { + stale: { time: now - ACCESS_CACHE_TTL - 1000, promise: Promise.resolve(true) }, + fresh: { time: now - 30000, promise: Promise.resolve(true) }, + } + cache['token2'] = { 'stale-only': { time: now - ACCESS_CACHE_TTL - 5000, promise: Promise.resolve(false) } } + + cleanupAccessCache() + + expect(cache['token1']['stale']).toBeUndefined() + expect(cache['token1']['fresh']).toBeDefined() + expect(cache['token2']).toBeUndefined() + }) + + it('should enforce maximum token limit with LRU eviction', () => { + const cache = getAccessCache() + const now = Date.now() + const tokenCount = ACCESS_CACHE_MAX_TOKENS + 100 + + for (let i = 0; i < tokenCount; i++) { + cache[`token-${i}`] = { + 'Pod:default:test': { time: now - (i / tokenCount) * 50 * 1000, promise: Promise.resolve(true) }, + } + } + + cleanupAccessCache() + + expect(Object.keys(cache).length).toBe(ACCESS_CACHE_MAX_TOKENS) + expect(cache['token-0']).toBeDefined() + expect(cache[`token-${tokenCount - 1}`]).toBeUndefined() + }) + + it('should expire timed caches during cleanup', () => { + const subjectRules = getSubjectRulesCacheStore() + const kindGetAccess = getKindGetAccessCacheStore() + const staleTime = Date.now() - ACCESS_CACHE_TTL - 1000 + + setTimedCacheEntry('stale-rules', subjectRules, { time: staleTime, promise: Promise.resolve('old') }) + setTimedCacheEntry('stale-kind', kindGetAccess, { + time: staleTime, + promise: Promise.resolve({ type: 'deny-all' }), + }) + + cleanupAccessCache() + + expect(getTimedCacheEntry('stale-rules', subjectRules)).toBeUndefined() + expect(getTimedCacheEntry('stale-kind', kindGetAccess)).toBeUndefined() + }) + }) + + describe('startAccessCacheCleanup', () => { + it('should start and stop periodic cleanup without error', () => { + startAccessCacheCleanup() + startAccessCacheCleanup() + stopAccessCacheCleanup() + stopAccessCacheCleanup() + }) + }) + + describe('SSAR caching via canAccess', () => { + it('should cache RBAC access check results under hashed token keys', async () => { + const mockToken = 'test-token-123' + const resource = { kind: 'Pod', apiVersion: 'v1', metadata: { namespace: 'default', name: 'test-pod' } } + + nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + + const result1 = await canAccess(resource, 'get', mockToken) + const result2 = await canAccess(resource, 'get', mockToken) + + expect(result1).toBe(true) + expect(result1).toBe(result2) + expect(getAccessCache()[mockToken]).toBeUndefined() + expect(getAccessCache()[hashAccessToken(mockToken)]['get::Pod:default:test-pod']).toBeDefined() + }) + + it('should use distinct cache keys per verb', async () => { + const mockToken = 'test-token-verb' + const resource = { kind: 'Pod', apiVersion: 'v1', metadata: { namespace: 'default', name: 'test-pod' } } + + nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: true } }) + nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: false } }) + + expect(await canAccess(resource, 'get', mockToken)).toBe(true) + expect(await canAccess(resource, 'list', mockToken)).toBe(false) + + const tokenCache = getAccessCache()[hashAccessToken(mockToken)] + expect(tokenCache['get::Pod:default:test-pod']).toBeDefined() + expect(tokenCache['list::Pod:default:test-pod']).toBeDefined() + }) + + it('should respect TTL and refetch after expiry', async () => { + const cache = getAccessCache() + const mockToken = 'test-token-ttl' + const tokenKey = hashAccessToken(mockToken) + + cache[tokenKey] = { + 'get::Secret:default:credentials': { + time: Date.now() - ACCESS_CACHE_TTL - 1000, + promise: Promise.resolve(true), + }, + } + + nock(process.env.CLUSTER_API_URL || '') + .post('/apis/authorization.k8s.io/v1/selfsubjectaccessreviews') + .reply(200, { status: { allowed: false } }) + + const result = await canAccess( + { kind: 'Secret', apiVersion: 'v1', metadata: { namespace: 'default', name: 'credentials' } }, + 'get', + mockToken + ) + expect(result).toBe(false) + }) + }) +}) From 3c9f8294251f87b8a29102e7435321c25173def0 Mon Sep 17 00:00:00 2001 From: Enrique Mingorance Cano Date: Mon, 14 Sep 2026 17:13:00 +0200 Subject: [PATCH 8/8] CLUSTER_SCOPED_KINDS kevin's proposal Signed-off-by: Enrique Mingorance Cano --- backend/src/routes/events.ts | 116 +---------------------- backend/src/routes/eventsAccess.ts | 10 +- backend/src/routes/eventsDefinitions.ts | 110 +++++++++++++++++++++ backend/test/routes/eventsAccess.test.ts | 2 - 4 files changed, 114 insertions(+), 124 deletions(-) create mode 100644 backend/src/routes/eventsDefinitions.ts diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 1c375224139..0b4eb6c833a 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -21,13 +21,8 @@ import type { IResource } from '../resources/resource' import type { IWatchOptions } from '../resources/watch-options' import { polledAggregation } from './aggregator' import { getAppDict, type ICompressedResource, type ITransformedResource } from './aggregators/applications' -import { - canAccess, - canGetResource, - canListClusterScopedKind, - canListNamespacedScopedKind, - configureClusterScopedKinds, -} from './eventsAccess' +import { canAccess, canGetResource, canListClusterScopedKind, canListNamespacedScopedKind } from './eventsAccess' +import { definitions } from './eventsDefinitions' import { startAccessCacheCleanup, stopAccessCacheCleanup } from './eventsCache' export { @@ -188,113 +183,6 @@ export function getEventDict() { return eventDict } -const definitions: IWatchOptions[] = [ - { kind: 'ClusterManagementAddOn', apiVersion: 'addon.open-cluster-management.io/v1alpha1', clusterScoped: true }, - { kind: 'ManagedClusterAddOn', apiVersion: 'addon.open-cluster-management.io/v1alpha1' }, - { kind: 'Agent', apiVersion: 'agent-install.openshift.io/v1beta1' }, - { kind: 'AgentServiceConfig', apiVersion: 'agent-install.openshift.io/v1beta1', clusterScoped: true }, - { kind: 'InfraEnv', apiVersion: 'agent-install.openshift.io/v1beta1' }, - { kind: 'NMStateConfig', apiVersion: 'agent-install.openshift.io/v1beta1' }, - { kind: 'Application', apiVersion: 'app.k8s.io/v1beta1' }, - { kind: 'Channel', apiVersion: 'apps.open-cluster-management.io/v1' }, - { kind: 'GitOpsCluster', apiVersion: 'apps.open-cluster-management.io/v1beta1' }, - { kind: 'HelmRelease', apiVersion: 'apps.open-cluster-management.io/v1' }, - { kind: 'Subscription', apiVersion: 'apps.open-cluster-management.io/v1' }, - { kind: 'SubscriptionReport', apiVersion: 'apps.open-cluster-management.io/v1alpha1' }, - { kind: 'Application', apiVersion: 'argoproj.io/v1alpha1', isPolled: true }, - { kind: 'ApplicationSet', apiVersion: 'argoproj.io/v1alpha1', isPolled: true }, - { kind: 'ArgoCD', apiVersion: 'argoproj.io/v1alpha1' }, - { kind: 'Authentication', apiVersion: 'config.openshift.io/v1', forwardEventsToClients: false, clusterScoped: true }, - { kind: 'Infrastructure', apiVersion: 'config.openshift.io/v1', clusterScoped: true }, - { - kind: 'CertificateSigningRequest', - apiVersion: 'certificates.k8s.io/v1', - labelSelector: { 'open-cluster-management.io/cluster-name': '' }, - clusterScoped: true, - }, - { kind: 'ManagedCluster', apiVersion: 'cluster.open-cluster-management.io/v1', clusterScoped: true }, - { kind: 'Placement', apiVersion: 'cluster.open-cluster-management.io/v1beta1' }, - { kind: 'PlacementDecision', apiVersion: 'cluster.open-cluster-management.io/v1beta1' }, - { kind: 'ManagedClusterSetBinding', apiVersion: 'cluster.open-cluster-management.io/v1beta2' }, - { kind: 'ManagedClusterSet', apiVersion: 'cluster.open-cluster-management.io/v1beta2', clusterScoped: true }, - { kind: 'ClusterCurator', apiVersion: 'cluster.open-cluster-management.io/v1beta1' }, - { kind: 'Subscription', apiVersion: 'operators.coreos.com/v1alpha1' }, - { kind: 'ClusterExtension', apiVersion: 'olm.operatorframework.io/v1', clusterScoped: true }, - { kind: 'DiscoveredCluster', apiVersion: 'discovery.open-cluster-management.io/v1' }, - { kind: 'DiscoveryConfig', apiVersion: 'discovery.open-cluster-management.io/v1' }, - { kind: 'AgentClusterInstall', apiVersion: 'extensions.hive.openshift.io/v1beta1' }, - { kind: 'ClusterClaim', apiVersion: 'hive.openshift.io/v1' }, - { kind: 'ClusterDeployment', apiVersion: 'hive.openshift.io/v1' }, - { kind: 'ClusterImageSet', apiVersion: 'hive.openshift.io/v1', clusterScoped: true }, - { kind: 'ClusterPool', apiVersion: 'hive.openshift.io/v1' }, - { kind: 'ClusterProvision', apiVersion: 'hive.openshift.io/v1' }, - { kind: 'MachinePool', apiVersion: 'hive.openshift.io/v1' }, - { kind: 'ManagedClusterInfo', apiVersion: 'internal.open-cluster-management.io/v1beta1' }, - { kind: 'BareMetalHost', apiVersion: 'metal3.io/v1alpha1' }, - { kind: 'MultiClusterEngine', apiVersion: 'multicluster.openshift.io/v1', clusterScoped: true }, - { kind: 'ClusterVersion', apiVersion: 'config.openshift.io/v1', clusterScoped: true }, - { kind: 'StorageClass', apiVersion: 'storage.k8s.io/v1', clusterScoped: true }, - { kind: 'PlacementBinding', apiVersion: 'policy.open-cluster-management.io/v1' }, - { kind: 'Policy', apiVersion: 'policy.open-cluster-management.io/v1' }, - { kind: 'PolicyAutomation', apiVersion: 'policy.open-cluster-management.io/v1beta1' }, - { kind: 'PolicySet', apiVersion: 'policy.open-cluster-management.io/v1beta1' }, - { kind: 'SubmarinerConfig', apiVersion: 'submarineraddon.open-cluster-management.io/v1alpha1' }, - { kind: 'AnsibleJob', apiVersion: 'tower.ansible.com/v1alpha1' }, - { kind: 'AnsibleWorkflow', apiVersion: 'tower.ansible.com/v1alpha1' }, - { - kind: 'ConfigMap', - apiVersion: 'v1', - fieldSelector: { 'metadata.name': 'assisted-service' }, - }, - { - kind: 'ConfigMap', - apiVersion: 'v1', - fieldSelector: { 'metadata.namespace': 'openshift-config-managed', 'metadata.name': 'console-public' }, - }, - { kind: 'ConfigMap', apiVersion: 'v1', fieldSelector: { 'metadata.name': 'console-search-config' } }, - { kind: 'Namespace', apiVersion: 'v1', clusterScoped: true }, - { kind: 'Secret', apiVersion: 'v1', labelSelector: { 'cluster.open-cluster-management.io/credentials': '' } }, - // **Need to look for creds with: 'cluster.open-cluster-management.io/type': 'ans', for edit scenarios - { kind: 'Secret', apiVersion: 'v1', labelSelector: { 'cluster.open-cluster-management.io/type': 'ans' } }, - { kind: 'Secret', apiVersion: 'v1', fieldSelector: { 'metadata.name': 'auto-import-secret' } }, - { kind: 'Secret', apiVersion: 'v1', labelSelector: { 'argocd.argoproj.io/secret-type': 'repository' } }, - { kind: 'PolicyReport', apiVersion: 'wgpolicyk8s.io/v1alpha2' }, - { kind: 'HostedCluster', apiVersion: 'hypershift.openshift.io/v1beta1' }, - { kind: 'NodePool', apiVersion: 'hypershift.openshift.io/v1beta1' }, - { kind: 'AgentMachine', apiVersion: 'capi-provider.agent-install.openshift.io/v1alpha1' }, - { kind: 'ConfigMap', apiVersion: 'v1', labelSelector: { 'hypershift.openshift.io/supported-versions': 'true' } }, - { kind: 'Search', apiVersion: 'search.open-cluster-management.io/v1alpha1' }, - // Configmaps that contain Grafana dashboard IDs - { - kind: 'ConfigMap', - apiVersion: 'v1', - fieldSelector: { 'metadata.name': 'grafana-dashboard-acm-openshift-virtualization-clusters-overview' }, - }, - { - kind: 'ConfigMap', - apiVersion: 'v1', - fieldSelector: { 'metadata.name': 'grafana-dashboard-acm-openshift-virtualization-single-vm-view' }, - }, - { kind: 'MulticlusterRoleAssignment', apiVersion: 'rbac.open-cluster-management.io/v1beta1' }, - { kind: 'User', apiVersion: 'user.openshift.io/v1', clusterScoped: true }, - { kind: 'Group', apiVersion: 'user.openshift.io/v1', clusterScoped: true }, - { - kind: 'ClusterRole', - apiVersion: 'rbac.authorization.k8s.io/v1', - labelSelector: { 'rbac.open-cluster-management.io/filter': 'vm-clusterroles' }, - clusterScoped: true, - }, - { - kind: 'Service', - apiVersion: 'v1', - fieldSelector: { 'metadata.name': 'cluster-proxy-addon-user', 'metadata.namespace': 'multicluster-engine' }, - }, -] - -configureClusterScopedKinds( - definitions.filter((definition) => definition.clusterScoped).map((definition) => definition.kind) -) - export function startWatching(): void { ServerSideEvents.eventFilter = eventFilter startAccessCacheCleanup() diff --git a/backend/src/routes/eventsAccess.ts b/backend/src/routes/eventsAccess.ts index e0cf346762a..ab51c4413f1 100644 --- a/backend/src/routes/eventsAccess.ts +++ b/backend/src/routes/eventsAccess.ts @@ -14,6 +14,7 @@ import { setSsarCacheEntry, setTimedCacheEntry, } from './eventsCache' +import { CLUSTER_SCOPED_KINDS } from './eventsDefinitions' export interface SubjectRulesStatus { incomplete: boolean @@ -37,15 +38,8 @@ export type AccessResource = { kind: string; apiVersion: string; metadata?: { na /** SSRR requires a namespace; cluster-scoped kinds are reviewed in this probe namespace only. */ const CLUSTER_SCOPED_RULES_NAMESPACE = 'default' -let clusterScopedKinds = new Set() - -/** Replace the cluster-scoped kind lookup. Derived from watch definitions in events.ts. */ -export function configureClusterScopedKinds(kinds: Iterable): void { - clusterScopedKinds = new Set(kinds) -} - export function isClusterScopedKind(kind: string): boolean { - return clusterScopedKinds.has(kind) + return CLUSTER_SCOPED_KINDS.has(kind) } const subjectRulesCache = getSubjectRulesCacheStore() diff --git a/backend/src/routes/eventsDefinitions.ts b/backend/src/routes/eventsDefinitions.ts new file mode 100644 index 00000000000..34cbe94177b --- /dev/null +++ b/backend/src/routes/eventsDefinitions.ts @@ -0,0 +1,110 @@ +/* Copyright Contributors to the Open Cluster Management project */ + +import type { IWatchOptions } from '../resources/watch-options' + +export const definitions: IWatchOptions[] = [ + { kind: 'ClusterManagementAddOn', apiVersion: 'addon.open-cluster-management.io/v1alpha1', clusterScoped: true }, + { kind: 'ManagedClusterAddOn', apiVersion: 'addon.open-cluster-management.io/v1alpha1' }, + { kind: 'Agent', apiVersion: 'agent-install.openshift.io/v1beta1' }, + { kind: 'AgentServiceConfig', apiVersion: 'agent-install.openshift.io/v1beta1', clusterScoped: true }, + { kind: 'InfraEnv', apiVersion: 'agent-install.openshift.io/v1beta1' }, + { kind: 'NMStateConfig', apiVersion: 'agent-install.openshift.io/v1beta1' }, + { kind: 'Application', apiVersion: 'app.k8s.io/v1beta1' }, + { kind: 'Channel', apiVersion: 'apps.open-cluster-management.io/v1' }, + { kind: 'GitOpsCluster', apiVersion: 'apps.open-cluster-management.io/v1beta1' }, + { kind: 'HelmRelease', apiVersion: 'apps.open-cluster-management.io/v1' }, + { kind: 'Subscription', apiVersion: 'apps.open-cluster-management.io/v1' }, + { kind: 'SubscriptionReport', apiVersion: 'apps.open-cluster-management.io/v1alpha1' }, + { kind: 'Application', apiVersion: 'argoproj.io/v1alpha1', isPolled: true }, + { kind: 'ApplicationSet', apiVersion: 'argoproj.io/v1alpha1', isPolled: true }, + { kind: 'ArgoCD', apiVersion: 'argoproj.io/v1alpha1' }, + { kind: 'Authentication', apiVersion: 'config.openshift.io/v1', forwardEventsToClients: false, clusterScoped: true }, + { kind: 'Infrastructure', apiVersion: 'config.openshift.io/v1', clusterScoped: true }, + { + kind: 'CertificateSigningRequest', + apiVersion: 'certificates.k8s.io/v1', + labelSelector: { 'open-cluster-management.io/cluster-name': '' }, + clusterScoped: true, + }, + { kind: 'ManagedCluster', apiVersion: 'cluster.open-cluster-management.io/v1', clusterScoped: true }, + { kind: 'Placement', apiVersion: 'cluster.open-cluster-management.io/v1beta1' }, + { kind: 'PlacementDecision', apiVersion: 'cluster.open-cluster-management.io/v1beta1' }, + { kind: 'ManagedClusterSetBinding', apiVersion: 'cluster.open-cluster-management.io/v1beta2' }, + { kind: 'ManagedClusterSet', apiVersion: 'cluster.open-cluster-management.io/v1beta2', clusterScoped: true }, + { kind: 'ClusterCurator', apiVersion: 'cluster.open-cluster-management.io/v1beta1' }, + { kind: 'Subscription', apiVersion: 'operators.coreos.com/v1alpha1' }, + { kind: 'ClusterExtension', apiVersion: 'olm.operatorframework.io/v1', clusterScoped: true }, + { kind: 'DiscoveredCluster', apiVersion: 'discovery.open-cluster-management.io/v1' }, + { kind: 'DiscoveryConfig', apiVersion: 'discovery.open-cluster-management.io/v1' }, + { kind: 'AgentClusterInstall', apiVersion: 'extensions.hive.openshift.io/v1beta1' }, + { kind: 'ClusterClaim', apiVersion: 'hive.openshift.io/v1' }, + { kind: 'ClusterDeployment', apiVersion: 'hive.openshift.io/v1' }, + { kind: 'ClusterImageSet', apiVersion: 'hive.openshift.io/v1', clusterScoped: true }, + { kind: 'ClusterPool', apiVersion: 'hive.openshift.io/v1' }, + { kind: 'ClusterProvision', apiVersion: 'hive.openshift.io/v1' }, + { kind: 'MachinePool', apiVersion: 'hive.openshift.io/v1' }, + { kind: 'ManagedClusterInfo', apiVersion: 'internal.open-cluster-management.io/v1beta1' }, + { kind: 'BareMetalHost', apiVersion: 'metal3.io/v1alpha1' }, + { kind: 'MultiClusterEngine', apiVersion: 'multicluster.openshift.io/v1', clusterScoped: true }, + { kind: 'ClusterVersion', apiVersion: 'config.openshift.io/v1', clusterScoped: true }, + { kind: 'StorageClass', apiVersion: 'storage.k8s.io/v1', clusterScoped: true }, + { kind: 'PlacementBinding', apiVersion: 'policy.open-cluster-management.io/v1' }, + { kind: 'Policy', apiVersion: 'policy.open-cluster-management.io/v1' }, + { kind: 'PolicyAutomation', apiVersion: 'policy.open-cluster-management.io/v1beta1' }, + { kind: 'PolicySet', apiVersion: 'policy.open-cluster-management.io/v1beta1' }, + { kind: 'SubmarinerConfig', apiVersion: 'submarineraddon.open-cluster-management.io/v1alpha1' }, + { kind: 'AnsibleJob', apiVersion: 'tower.ansible.com/v1alpha1' }, + { kind: 'AnsibleWorkflow', apiVersion: 'tower.ansible.com/v1alpha1' }, + { + kind: 'ConfigMap', + apiVersion: 'v1', + fieldSelector: { 'metadata.name': 'assisted-service' }, + }, + { + kind: 'ConfigMap', + apiVersion: 'v1', + fieldSelector: { 'metadata.namespace': 'openshift-config-managed', 'metadata.name': 'console-public' }, + }, + { kind: 'ConfigMap', apiVersion: 'v1', fieldSelector: { 'metadata.name': 'console-search-config' } }, + { kind: 'Namespace', apiVersion: 'v1', clusterScoped: true }, + { kind: 'Secret', apiVersion: 'v1', labelSelector: { 'cluster.open-cluster-management.io/credentials': '' } }, + // **Need to look for creds with: 'cluster.open-cluster-management.io/type': 'ans', for edit scenarios + { kind: 'Secret', apiVersion: 'v1', labelSelector: { 'cluster.open-cluster-management.io/type': 'ans' } }, + { kind: 'Secret', apiVersion: 'v1', fieldSelector: { 'metadata.name': 'auto-import-secret' } }, + { kind: 'Secret', apiVersion: 'v1', labelSelector: { 'argocd.argoproj.io/secret-type': 'repository' } }, + { kind: 'PolicyReport', apiVersion: 'wgpolicyk8s.io/v1alpha2' }, + { kind: 'HostedCluster', apiVersion: 'hypershift.openshift.io/v1beta1' }, + { kind: 'NodePool', apiVersion: 'hypershift.openshift.io/v1beta1' }, + { kind: 'AgentMachine', apiVersion: 'capi-provider.agent-install.openshift.io/v1alpha1' }, + { kind: 'ConfigMap', apiVersion: 'v1', labelSelector: { 'hypershift.openshift.io/supported-versions': 'true' } }, + { kind: 'Search', apiVersion: 'search.open-cluster-management.io/v1alpha1' }, + // Configmaps that contain Grafana dashboard IDs + { + kind: 'ConfigMap', + apiVersion: 'v1', + fieldSelector: { 'metadata.name': 'grafana-dashboard-acm-openshift-virtualization-clusters-overview' }, + }, + { + kind: 'ConfigMap', + apiVersion: 'v1', + fieldSelector: { 'metadata.name': 'grafana-dashboard-acm-openshift-virtualization-single-vm-view' }, + }, + { kind: 'MulticlusterRoleAssignment', apiVersion: 'rbac.open-cluster-management.io/v1beta1' }, + { kind: 'User', apiVersion: 'user.openshift.io/v1', clusterScoped: true }, + { kind: 'Group', apiVersion: 'user.openshift.io/v1', clusterScoped: true }, + { + kind: 'ClusterRole', + apiVersion: 'rbac.authorization.k8s.io/v1', + labelSelector: { 'rbac.open-cluster-management.io/filter': 'vm-clusterroles' }, + clusterScoped: true, + }, + { + kind: 'Service', + apiVersion: 'v1', + fieldSelector: { 'metadata.name': 'cluster-proxy-addon-user', 'metadata.namespace': 'multicluster-engine' }, + }, +] + +export const CLUSTER_SCOPED_KINDS = new Set( + definitions.filter((definition) => definition.clusterScoped).map((definition) => definition.kind) +) diff --git a/backend/test/routes/eventsAccess.test.ts b/backend/test/routes/eventsAccess.test.ts index 0b5758d31fb..471ec7221f2 100644 --- a/backend/test/routes/eventsAccess.test.ts +++ b/backend/test/routes/eventsAccess.test.ts @@ -5,7 +5,6 @@ import { canGetResource, canListClusterScopedKind, canListNamespacedScopedKind, - configureClusterScopedKinds, } from '../../src/routes/eventsAccess' import { ACCESS_CACHE_TTL, cleanupAccessCache, resetAccessCache } from '../../src/routes/eventsCache' @@ -98,7 +97,6 @@ describe('eventsAccess', () => { beforeEach(() => { resetAccessCache() - configureClusterScopedKinds(['ManagedCluster', 'ClusterExtension', 'Namespace', 'StorageClass']) process.env.CLUSTER_API_URL = 'https://api.test-cluster.com:6443' })