diff --git a/src/common/rbac.ts b/src/common/rbac.ts index 7d02e3be51..afb9fe8030 100644 --- a/src/common/rbac.ts +++ b/src/common/rbac.ts @@ -4,42 +4,53 @@ export type KubeResource = "namespaces" | "nodes" | "events" | "resourcequotas" | "services" | "limitranges" | "secrets" | "configmaps" | "ingresses" | "networkpolicies" | "persistentvolumeclaims" | "persistentvolumes" | "storageclasses" | "pods" | "daemonsets" | "deployments" | "statefulsets" | "replicasets" | "jobs" | "cronjobs" | - "endpoints" | "customresourcedefinitions" | "horizontalpodautoscalers" | "podsecuritypolicies" | "poddisruptionbudgets"; + "endpoints" | "customresourcedefinitions" | "horizontalpodautoscalers" | "podsecuritypolicies" | "poddisruptionbudgets" | + "role" | "rolebinding" | "clusterrolebinding" | "serviceaccount"; -export interface KubeApiResource { - kind: string; // resource type (e.g. "Namespace") +export interface KubeApiResource extends KubeApiResourceData { apiName: KubeResource; // valid api resource name (e.g. "namespaces") +} + +export interface KubeApiResourceData { + kind: string; // resource type (e.g. "Namespace") group?: string; // api-group } +export const apiResources: Record = { + "clusterrolebinding": { kind: "ClusterRoleBinding", group: "rbac.authorization.k8s.io" }, + "configmaps": { kind: "ConfigMap" }, + "cronjobs": { kind: "CronJob", group: "batch" }, + "customresourcedefinitions": { kind: "CustomResourceDefinition", group: "apiextensions.k8s.io" }, + "daemonsets": { kind: "DaemonSet", group: "apps" }, + "deployments": { kind: "Deployment", group: "apps" }, + "endpoints": { kind: "Endpoint" }, + "events": { kind: "Event" }, + "horizontalpodautoscalers": { kind: "HorizontalPodAutoscaler" }, + "ingresses": { kind: "Ingress", group: "networking.k8s.io" }, + "jobs": { kind: "Job", group: "batch" }, + "namespaces": { kind: "Namespace" }, + "limitranges": { kind: "LimitRange" }, + "networkpolicies": { kind: "NetworkPolicy", group: "networking.k8s.io" }, + "nodes": { kind: "Node" }, + "persistentvolumes": { kind: "PersistentVolume" }, + "persistentvolumeclaims": { kind: "PersistentVolumeClaim" }, + "pods": { kind: "Pod" }, + "poddisruptionbudgets": { kind: "PodDisruptionBudget", group: "policy" }, + "podsecuritypolicies": { kind: "PodSecurityPolicy" }, + "resourcequotas": { kind: "ResourceQuota" }, + "replicasets": { kind: "ReplicaSet", group: "apps" }, + "role": { kind: "Role", group: "rbac.authorization.k8s.io" }, + "rolebinding": { kind: "RoleBinding", group: "rbac.authorization.k8s.io" }, + "secrets": { kind: "Secret" }, + "serviceaccount": { kind: "ServicAccount", group: "core" }, + "services": { kind: "Service" }, + "statefulsets": { kind: "StatefulSet", group: "apps" }, + "storageclasses": { kind: "StorageClass", group: "storage.k8s.io" }, +}; + // TODO: auto-populate all resources dynamically (see: kubectl api-resources -o=wide -v=7) -export const apiResources: KubeApiResource[] = [ - { kind: "ConfigMap", apiName: "configmaps" }, - { kind: "CronJob", apiName: "cronjobs", group: "batch" }, - { kind: "CustomResourceDefinition", apiName: "customresourcedefinitions", group: "apiextensions.k8s.io" }, - { kind: "DaemonSet", apiName: "daemonsets", group: "apps" }, - { kind: "Deployment", apiName: "deployments", group: "apps" }, - { kind: "Endpoint", apiName: "endpoints" }, - { kind: "Event", apiName: "events" }, - { kind: "HorizontalPodAutoscaler", apiName: "horizontalpodautoscalers" }, - { kind: "Ingress", apiName: "ingresses", group: "networking.k8s.io" }, - { kind: "Job", apiName: "jobs", group: "batch" }, - { kind: "Namespace", apiName: "namespaces" }, - { kind: "LimitRange", apiName: "limitranges" }, - { kind: "NetworkPolicy", apiName: "networkpolicies", group: "networking.k8s.io" }, - { kind: "Node", apiName: "nodes" }, - { kind: "PersistentVolume", apiName: "persistentvolumes" }, - { kind: "PersistentVolumeClaim", apiName: "persistentvolumeclaims" }, - { kind: "Pod", apiName: "pods" }, - { kind: "PodDisruptionBudget", apiName: "poddisruptionbudgets", group: "policy" }, - { kind: "PodSecurityPolicy", apiName: "podsecuritypolicies" }, - { kind: "ResourceQuota", apiName: "resourcequotas" }, - { kind: "ReplicaSet", apiName: "replicasets", group: "apps" }, - { kind: "Secret", apiName: "secrets" }, - { kind: "Service", apiName: "services" }, - { kind: "StatefulSet", apiName: "statefulsets", group: "apps" }, - { kind: "StorageClass", apiName: "storageclasses", group: "storage.k8s.io" }, -]; +export const apiResourceList: KubeApiResource[] = Object.entries(apiResources) + .map(([apiName, data]) => ({ apiName: apiName as KubeResource, ...data })); export function isAllowedResource(resources: KubeResource | KubeResource[]) { if (!Array.isArray(resources)) { diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 13c74a285e..75da12efcc 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -11,7 +11,7 @@ import { Kubectl } from "./kubectl"; import { KubeconfigManager } from "./kubeconfig-manager"; import { loadConfig } from "../common/kube-helpers"; import request, { RequestPromiseOptions } from "request-promise-native"; -import { apiResources, KubeApiResource } from "../common/rbac"; +import { apiResourceList, apiResources, KubeApiResource, KubeResource } from "../common/rbac"; import logger from "./logger"; import { VersionDetector } from "./cluster-detectors/version-detector"; import { detectorRegistry } from "./cluster-detectors/detector-registry"; @@ -693,7 +693,7 @@ export class Cluster implements ClusterModel, ClusterState { if (!this.allowedNamespaces.length) { return []; } - const resources = apiResources.filter((resource) => this.resourceAccessStatuses.get(resource) === undefined); + const resources = apiResourceList.filter((resource) => this.resourceAccessStatuses.get(resource) === undefined); const apiLimit = plimit(5); // 5 concurrent api requests const requests = []; @@ -715,7 +715,7 @@ export class Cluster implements ClusterModel, ClusterState { } await Promise.all(requests); - return apiResources + return apiResourceList .filter((resource) => this.resourceAccessStatuses.get(resource)) .map(apiResource => apiResource.apiName); } catch (error) { @@ -724,7 +724,11 @@ export class Cluster implements ClusterModel, ClusterState { } isAllowedResource(kind: string): boolean { - const apiResource = apiResources.find(resource => resource.kind === kind || resource.apiName === kind); + if (apiResources[kind as KubeResource]) { + return this.allowedResources.includes(kind); + } + + const apiResource = apiResourceList.find(resource => resource.kind === kind); if (apiResource) { return this.allowedResources.includes(apiResource.apiName); diff --git a/src/renderer/api/json-api.ts b/src/renderer/api/json-api.ts index 14923f063f..343aecd4c9 100644 --- a/src/renderer/api/json-api.ts +++ b/src/renderer/api/json-api.ts @@ -135,7 +135,6 @@ export class JsonApi { } if (status >= 200 && status < 300) { - console.log(data, res); this.onData.emit(data, res); this.writeLog({ ...log, data }); @@ -145,13 +144,14 @@ export class JsonApi { if (log.method === "GET" && res.status === 403) { this.writeLog({ ...log, error: data }); throw data; - } else { - const error = new JsonApiErrorParsed(data, this.parseError(data, res)); - - this.onError.emit(error, res); - this.writeLog({ ...log, error }); - throw error; } + + const error = new JsonApiErrorParsed(data, this.parseError(data, res)); + + this.onError.emit(error, res); + this.writeLog({ ...log, error }); + + throw error; } protected parseError(error: JsonApiError | string, res: Response): string[] { diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index 4ad0dbf480..7ff83f1ecf 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -12,6 +12,7 @@ import byline from "byline"; import { IKubeWatchEvent } from "./kube-watch-api"; import { ReadableWebToNodeStream } from "../utils/readableStream"; import { KubeJsonApi, KubeJsonApiData } from "./kube-json-api"; +import { noop } from "../utils"; export interface IKubeApiOptions { /** @@ -320,25 +321,30 @@ export class KubeApi { async list({ namespace = "", reqInit }: KubeApiListOptions = {}, query?: IKubeApiQueryParams): Promise { await this.checkPreferredVersion(); - const res = await this.request.get(this.getUrl({ namespace }), { query }, reqInit); + const url = this.getUrl({ namespace }); + const res = await this.request.get(url, { query }, reqInit); const parsed = this.parseResponse(res, namespace); - if (!parsed || !Array.isArray(parsed)) { + if (Array.isArray(parsed)) { + return parsed; + } + + if (!parsed) { return null; } - return parsed; + throw new Error(`GET multiple request to ${url} returned not an array: ${JSON.stringify(parsed)}`); } async get({ name = "", namespace = "default" } = {}, query?: IKubeApiQueryParams): Promise { await this.checkPreferredVersion(); - const res = await this.request.get(this.getUrl({ namespace, name }), { query }); - + const url = this.getUrl({ namespace, name }); + const res = await this.request.get(url, { query }); const parsed = this.parseResponse(res); if (Array.isArray(parsed)) { - return null; + throw new Error(`GET single request to ${url} returned an array: ${JSON.stringify(parsed)}`); } return parsed; @@ -346,8 +352,8 @@ export class KubeApi { async create({ name = "", namespace = "default" } = {}, data?: Partial): Promise { await this.checkPreferredVersion(); - const apiUrl = this.getUrl({ namespace }); + const apiUrl = this.getUrl({ namespace }); const res = await this.request.post(apiUrl, { data: merge({ kind: this.kind, @@ -361,7 +367,7 @@ export class KubeApi { const parsed = this.parseResponse(res); if (Array.isArray(parsed)) { - return null; + throw new Error(`POST request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`); } return parsed; @@ -375,7 +381,7 @@ export class KubeApi { const parsed = this.parseResponse(res); if (Array.isArray(parsed)) { - return null; + throw new Error(`PUT request to ${apiUrl} returned an array: ${JSON.stringify(parsed)}`); } return parsed; @@ -399,7 +405,7 @@ export class KubeApi { watch(opts: KubeApiWatchOptions = { namespace: "" }): () => void { let errorReceived = false; let timedRetry: NodeJS.Timeout; - const { abortController: { abort, signal } = new AbortController(), namespace, callback } = opts; + const { abortController: { abort, signal } = new AbortController(), namespace, callback = noop } = opts; signal.addEventListener("abort", () => { clearTimeout(timedRetry); @@ -411,7 +417,7 @@ export class KubeApi { responsePromise .then(response => { if (!response.ok) { - return callback?.(null, response); + return callback(null, response); } const nodeStream = new ReadableWebToNodeStream(response.body); @@ -427,22 +433,18 @@ export class KubeApi { }); }); - const stream = byline(nodeStream); - - stream.on("data", (line) => { + byline(nodeStream).on("data", (line) => { try { const event: IKubeWatchEvent = JSON.parse(line); if (event.type === "ERROR" && event.object.kind === "Status") { errorReceived = true; - callback(null, new KubeStatus(event.object as any)); - return; + return callback(null, new KubeStatus(event.object as any)); } this.modifyWatchEvent(event); - - callback?.(event, null); + callback(event, null); } catch (ignore) { // ignore parse errors } @@ -451,7 +453,7 @@ export class KubeApi { .catch(error => { if (error instanceof DOMException) return; // AbortController rejects, we can ignore it - callback?.(null, error); + callback(null, error); }); return abort; diff --git a/src/renderer/components/+user-management/user-management.tsx b/src/renderer/components/+user-management/user-management.tsx index e851d50424..f1772e2610 100644 --- a/src/renderer/components/+user-management/user-management.tsx +++ b/src/renderer/components/+user-management/user-management.tsx @@ -13,29 +13,36 @@ import { isAllowedResource } from "../../../common/rbac"; @observer export class UserManagement extends React.Component { static get tabRoutes() { - const tabRoutes: TabLayoutRoute[] = []; const query = namespaceUrlParam.toObjectParam(); + const tabRoutes: TabLayoutRoute[] = []; - tabRoutes.push( - { + if (isAllowedResource("serviceaccount")) { + tabRoutes.push({ title: "Service Accounts", component: ServiceAccounts, url: serviceAccountsURL({ query }), routePath: serviceAccountsRoute.path.toString(), - }, - { + }); + } + + if (isAllowedResource("rolebinding") || isAllowedResource("clusterrolebinding")) { + // TODO: seperate out these two pages + tabRoutes.push({ title: "Role Bindings", component: RoleBindings, url: roleBindingsURL({ query }), routePath: roleBindingsRoute.path.toString(), - }, - { + }); + } + + if (isAllowedResource("role")) { + tabRoutes.push({ title: "Roles", component: Roles, url: rolesURL({ query }), routePath: rolesRoute.path.toString(), - }, - ); + }); + } if (isAllowedResource("podsecuritypolicies")) { tabRoutes.push({ diff --git a/src/renderer/components/layout/sidebar.tsx b/src/renderer/components/layout/sidebar.tsx index f47ba1702e..bcfc49c610 100644 --- a/src/renderer/components/layout/sidebar.tsx +++ b/src/renderer/components/layout/sidebar.tsx @@ -230,6 +230,7 @@ export class Sidebar extends React.Component { } diff --git a/src/renderer/kube-object.store.ts b/src/renderer/kube-object.store.ts index 9959559fa1..43186b4728 100644 --- a/src/renderer/kube-object.store.ts +++ b/src/renderer/kube-object.store.ts @@ -280,7 +280,7 @@ export abstract class KubeObjectStore extends ItemSt subscribe(apis = this.getSubscribeApis()) { const abortController = new AbortController(); - // This waits for + // This waits for the context and namespaces to be ready or fails fast if the disposer is called Promise.race([rejectPromiseBy(abortController.signal), Promise.all([this.contextReady, this.namespacesReady])]) .then(() => { if (this.context.cluster.isGlobalWatchEnabled && this.loadedNamespaces.length === 0) { @@ -317,10 +317,11 @@ export abstract class KubeObjectStore extends ItemSt if (error.status === 404) { // api has gone, let's not retry return; - } else { // not sure what to do, best to retry - clearTimeout(timedRetry); - timedRetry = setTimeout(watch, 5000); } + + // not sure what to do, best to retry + clearTimeout(timedRetry); + timedRetry = setTimeout(watch, 5000); } else if (error instanceof KubeStatus && error.code === 410) { clearTimeout(timedRetry); // resourceVersion has gone, let's try to reload diff --git a/src/renderer/utils/rbac.ts b/src/renderer/utils/rbac.ts index 36737ccf3a..ef7522e8b4 100644 --- a/src/renderer/utils/rbac.ts +++ b/src/renderer/utils/rbac.ts @@ -26,4 +26,8 @@ export const ResourceNames: Record = { "podsecuritypolicies": "Pod Security Policies", "poddisruptionbudgets": "Pod Disruption Budgets", "limitranges": "Limit Ranges", + "role": "Roles", + "rolebinding": "Role Bindings", + "clusterrolebinding": "Cluster Role Bindings", + "serviceaccount": "Service Accounts" };