diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index e1474a4332..c2896309f4 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -390,12 +390,6 @@ const scenarios = [ sidebarItemTestId: "sidebar-item-link-for-service-accounts", }, - { - expectedSelector: "h5.title", - parentSidebarItemTestId: "sidebar-item-link-for-user-management", - sidebarItemTestId: "sidebar-item-link-for-roles", - }, - { expectedSelector: "h5.title", parentSidebarItemTestId: "sidebar-item-link-for-user-management", @@ -405,7 +399,7 @@ const scenarios = [ { expectedSelector: "h5.title", parentSidebarItemTestId: "sidebar-item-link-for-user-management", - sidebarItemTestId: "sidebar-item-link-for-role-bindings", + sidebarItemTestId: "sidebar-item-link-for-roles", }, { @@ -417,7 +411,7 @@ const scenarios = [ { expectedSelector: "h5.title", parentSidebarItemTestId: "sidebar-item-link-for-user-management", - sidebarItemTestId: "sidebar-item-link-for-pod-security-policies", + sidebarItemTestId: "sidebar-item-link-for-role-bindings", }, { diff --git a/src/common/cluster/authorization-namespace-review.injectable.ts b/src/common/cluster/authorization-namespace-review.injectable.ts index 2537331fe5..aa78453569 100644 --- a/src/common/cluster/authorization-namespace-review.injectable.ts +++ b/src/common/cluster/authorization-namespace-review.injectable.ts @@ -8,8 +8,15 @@ import { AuthorizationV1Api } from "@kubernetes/client-node"; import { getInjectable } from "@ogre-tools/injectable"; import type { Logger } from "../logger"; import loggerInjectable from "../logger.injectable"; +import type { KubeApiResource } from "../rbac"; -export type RequestNamespaceResources = (namespace: string) => Promise; +/** + * Requests the permissions for actions on the kube cluster + * @param namespace The namespace of the resources + * @param availableResources List of available resources in the cluster to resolve glob values fir api groups + * @returns list of allowed resources names + */ +export type RequestNamespaceResources = (namespace: string, availableResources: KubeApiResource[]) => Promise; /** * @param proxyConfig This config's `currentContext` field must be set, and will be used as the target cluster @@ -25,12 +32,7 @@ const authorizationNamespaceReview = ({ logger }: Dependencies): AuthorizationNa const api = proxyConfig.makeApiClient(AuthorizationV1Api); - /** - * Requests the permissions for actions on the kube cluster - * @param namespace The namespace of the resources - * @returns list of allowed resources - */ - return async (namespace: string): Promise => { + return async (namespace, availableResources) => { try { const { body } = await api.createSelfSubjectRulesReview({ apiVersion: "authorization.k8s.io/v1", @@ -41,12 +43,27 @@ const authorizationNamespaceReview = ({ logger }: Dependencies): AuthorizationNa const resources = new Set(); body.status?.resourceRules.forEach(resourceRule => { - if (resourceRule.verbs.some(verb => ["*", "list"].includes(verb))) { - resourceRule.resources?.forEach(resource => resources.add(resource)); + if (!resourceRule.verbs.some(verb => ["*", "list"].includes(verb)) || !resourceRule.resources) { + return; } - }); - resources.delete("*"); + const apiGroups = resourceRule.apiGroups; + + if (resourceRule.resources.length === 1 && resourceRule.resources[0] === "*" && apiGroups) { + if (apiGroups[0] === "*") { + availableResources.forEach(resource => resources.add(resource.apiName)); + } else { + availableResources.forEach((apiResource)=> { + if (apiGroups.includes(apiResource.group || "")) { + resources.add(apiResource.apiName); + } + }); + } + } else { + resourceRule.resources.forEach(resource => resources.add(resource)); + } + + }); return [...resources]; } catch (error) { diff --git a/src/common/cluster/cluster.ts b/src/common/cluster/cluster.ts index 8d82cf9f53..7f27025190 100644 --- a/src/common/cluster/cluster.ts +++ b/src/common/cluster/cluster.ts @@ -26,6 +26,7 @@ import type { Logger } from "../logger"; import type { BroadcastMessage } from "../ipc/broadcast-message.injectable"; import type { LoadConfigfromFile } from "../kube-helpers/load-config-from-file.injectable"; import type { RequestNamespaceResources } from "./authorization-namespace-review.injectable"; +import type { RequestListApiResources } from "./list-api-resources.injectable"; export interface ClusterDependencies { readonly directoryForKubeConfigs: string; @@ -36,6 +37,7 @@ export interface ClusterDependencies { createKubectl: (clusterVersion: string) => Kubectl; createAuthorizationReview: (config: KubeConfig) => CanI; createAuthorizationNamespaceReview: (config: KubeConfig) => RequestNamespaceResources; + createListApiResources: (cluster: Cluster) => RequestListApiResources; createListNamespaces: (config: KubeConfig) => ListNamespaces; createVersionDetector: (cluster: Cluster) => VersionDetector; broadcastMessage: BroadcastMessage; @@ -477,6 +479,7 @@ export class Cluster implements ClusterModel, ClusterState { const proxyConfig = await this.getProxyKubeconfig(); const canI = this.dependencies.createAuthorizationReview(proxyConfig); const requestNamespaceResources = this.dependencies.createAuthorizationNamespaceReview(proxyConfig); + const listApiResources = this.dependencies.createListApiResources(this); this.isAdmin = await canI({ namespace: "kube-system", @@ -488,7 +491,7 @@ export class Cluster implements ClusterModel, ClusterState { resource: "*", }); this.allowedNamespaces = await this.getAllowedNamespaces(proxyConfig); - this.allowedResources = await this.getAllowedResources(requestNamespaceResources); + this.allowedResources = await this.getAllowedResources(listApiResources, requestNamespaceResources); this.ready = true; } @@ -670,14 +673,23 @@ export class Cluster implements ClusterModel, ClusterState { } } - protected async getAllowedResources(requestNamespaceResources: RequestNamespaceResources) { + protected async getAllowedResources(listApiResources:RequestListApiResources, requestNamespaceResources: RequestNamespaceResources) { try { if (!this.allowedNamespaces.length) { return []; } - const resources = apiResources.filter((resource) => this.resourceAccessStatuses.get(resource) === undefined); - const unknownResources = new Map(resources.map(resource => ([resource.apiName, resource]))); + const unknownResources = new Map(apiResources.map(resource => ([resource.apiName, resource]))); + + const availableResources = await listApiResources(); + const availableResourcesNames = new Set(availableResources.map(apiResource => apiResource.apiName)); + + [...unknownResources.values()].map(unknownResource => { + if (!availableResourcesNames.has(unknownResource.apiName)) { + this.resourceAccessStatuses.set(unknownResource, false); + unknownResources.delete(unknownResource.apiName); + } + }); if (unknownResources.size > 0) { const apiLimit = plimit(5); // 5 concurrent api requests @@ -687,7 +699,7 @@ export class Cluster implements ClusterModel, ClusterState { return; } - const namespaceResources = await requestNamespaceResources(namespace); + const namespaceResources = await requestNamespaceResources(namespace, availableResources); for (const resourceName of namespaceResources) { const unknownResource = unknownResources.get(resourceName); diff --git a/src/common/cluster/list-api-resources.injectable.ts b/src/common/cluster/list-api-resources.injectable.ts new file mode 100644 index 0000000000..ed9d5c9c39 --- /dev/null +++ b/src/common/cluster/list-api-resources.injectable.ts @@ -0,0 +1,91 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { + V1APIGroupList, + V1APIResourceList, + V1APIVersions, +} from "@kubernetes/client-node"; +import { getInjectable } from "@ogre-tools/injectable"; +import type { K8sRequest } from "../../main/k8s-request.injectable"; +import k8SRequestInjectable from "../../main/k8s-request.injectable"; +import type { Logger } from "../logger"; +import loggerInjectable from "../logger.injectable"; +import type { KubeApiResource, KubeResource } from "../rbac"; +import type { Cluster } from "./cluster"; +import plimit from "p-limit"; + +export type RequestListApiResources = () => Promise; + +/** + * @param proxyConfig This config's `currentContext` field must be set, and will be used as the target cluster + */ +export type ListApiResources = (cluster: Cluster) => RequestListApiResources; + +interface Dependencies { + logger: Logger; + k8sRequest: K8sRequest; +} + +const listApiResources = ({ k8sRequest, logger }: Dependencies): ListApiResources => { + return (cluster) => { + const clusterRequest = (path: string) => k8sRequest(cluster, path); + const apiLimit = plimit(5); + + return async () => { + const resources: KubeApiResource[] = []; + + try { + const resourceListGroups:{ group:string;path:string }[] = []; + + await Promise.all( + [ + clusterRequest("/api").then((response:V1APIVersions)=>response.versions.forEach(version => resourceListGroups.push({ group:version, path:`/api/${version}` }))), + clusterRequest("/apis").then((response:V1APIGroupList) => response.groups.forEach(group => { + const preferredVersion = group.preferredVersion?.groupVersion; + + if (preferredVersion) { + resourceListGroups.push({ group:group.name, path:`/apis/${preferredVersion}` }); + } + })), + ], + ); + + await Promise.all( + resourceListGroups.map(({ group, path }) => apiLimit(async () => { + const apiResources:V1APIResourceList = await clusterRequest(path); + + if (apiResources.resources) { + resources.push( + ...apiResources.resources.filter(resource => resource.verbs.includes("list")).map((resource) => ({ + apiName: resource.name as KubeResource, + kind: resource.kind, + group, + })), + ); + } + }), + ), + ); + } catch (error) { + logger.error(`[LIST-API-RESOURCES]: failed to list api resources: ${error}`); + } + + return resources; + }; + }; +}; + +const listApiResourcesInjectable = getInjectable({ + id: "list-api-resources", + instantiate: (di) => { + const k8sRequest = di.inject(k8SRequestInjectable); + const logger = di.inject(loggerInjectable); + + return listApiResources({ k8sRequest, logger }); + }, +}); + +export default listApiResourcesInjectable; diff --git a/src/main/__test__/cluster.test.ts b/src/main/__test__/cluster.test.ts index d6510bbb4a..980b63c767 100644 --- a/src/main/__test__/cluster.test.ts +++ b/src/main/__test__/cluster.test.ts @@ -20,7 +20,8 @@ import directoryForTempInjectable from "../../common/app-paths/directory-for-tem import normalizedPlatformInjectable from "../../common/vars/normalized-platform.injectable"; import kubectlBinaryNameInjectable from "../kubectl/binary-name.injectable"; import kubectlDownloadingNormalizedArchInjectable from "../kubectl/normalized-arch.injectable"; -import { apiResourceRecord } from "../../common/rbac"; +import { apiResourceRecord, apiResources } from "../../common/rbac"; +import listApiResourcesInjectable from "../../common/cluster/list-api-resources.injectable"; console = new Console(process.stdout, process.stderr); // fix mockFS @@ -42,6 +43,7 @@ describe("create clusters", () => { di.override(broadcastMessageInjectable, () => async () => {}); di.override(authorizationReviewInjectable, () => () => () => Promise.resolve(true)); di.override(authorizationNamespaceReviewInjectable, () => () => () => Promise.resolve(Object.keys(apiResourceRecord))); + di.override(listApiResourcesInjectable, () => () => () => Promise.resolve(apiResources)); di.override(listNamespacesInjectable, () => () => () => Promise.resolve([ "default" ])); di.override(createContextHandlerInjectable, () => (cluster) => ({ restartServer: jest.fn(), diff --git a/src/main/create-cluster/create-cluster.injectable.ts b/src/main/create-cluster/create-cluster.injectable.ts index 0f22cd4c51..e1782ec6c9 100644 --- a/src/main/create-cluster/create-cluster.injectable.ts +++ b/src/main/create-cluster/create-cluster.injectable.ts @@ -13,6 +13,7 @@ import { createClusterInjectionToken } from "../../common/cluster/create-cluster import authorizationReviewInjectable from "../../common/cluster/authorization-review.injectable"; import createAuthorizationNamespaceReview from "../../common/cluster/authorization-namespace-review.injectable"; import listNamespacesInjectable from "../../common/cluster/list-namespaces.injectable"; +import createListApiResourcesInjectable from "../../common/cluster/list-api-resources.injectable"; import loggerInjectable from "../../common/logger.injectable"; import detectorRegistryInjectable from "../cluster-detectors/detector-registry.injectable"; import createVersionDetectorInjectable from "../cluster-detectors/create-version-detector.injectable"; @@ -30,6 +31,7 @@ const createClusterInjectable = getInjectable({ createContextHandler: di.inject(createContextHandlerInjectable), createAuthorizationReview: di.inject(authorizationReviewInjectable), createAuthorizationNamespaceReview: di.inject(createAuthorizationNamespaceReview), + createListApiResources: di.inject(createListApiResourcesInjectable), createListNamespaces: di.inject(listNamespacesInjectable), logger: di.inject(loggerInjectable), detectorRegistry: di.inject(detectorRegistryInjectable), diff --git a/src/renderer/create-cluster/create-cluster.injectable.ts b/src/renderer/create-cluster/create-cluster.injectable.ts index 0b5e51d2d8..e0a9f51656 100644 --- a/src/renderer/create-cluster/create-cluster.injectable.ts +++ b/src/renderer/create-cluster/create-cluster.injectable.ts @@ -29,6 +29,7 @@ const createClusterInjectable = getInjectable({ createAuthorizationReview: () => { throw new Error("Tried to access back-end feature in front-end."); }, createAuthorizationNamespaceReview: () => { throw new Error("Tried to access back-end feature in front-end."); }, createListNamespaces: () => { throw new Error("Tried to access back-end feature in front-end."); }, + createListApiResources: ()=> { throw new Error("Tried to access back-end feature in front-end."); }, detectorRegistry: undefined as never, createVersionDetector: () => { throw new Error("Tried to access back-end feature in front-end."); }, };