1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Hide UserManagement sidebar based on access control

- Also throw errors in kube-api methods if an array is returned by the
  API but a single item was expected or vice-versa

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2021-02-26 10:36:07 -05:00
parent 4e99b4910f
commit ca152e1cc5
8 changed files with 103 additions and 73 deletions

View File

@ -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<KubeResource, KubeApiResourceData> = {
"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)) {

View File

@ -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);

View File

@ -135,7 +135,6 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
}
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<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
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[] {

View File

@ -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<T extends KubeObject> {
/**
@ -320,25 +321,30 @@ export class KubeApi<T extends KubeObject = any> {
async list({ namespace = "", reqInit }: KubeApiListOptions = {}, query?: IKubeApiQueryParams): Promise<T[] | null> {
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<T | null> {
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<T extends KubeObject = any> {
async create({ name = "", namespace = "default" } = {}, data?: Partial<T>): Promise<T | null> {
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<T extends KubeObject = any> {
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<T extends KubeObject = any> {
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<T extends KubeObject = any> {
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<T extends KubeObject = any> {
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<T extends KubeObject = any> {
});
});
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<T extends KubeObject = any> {
.catch(error => {
if (error instanceof DOMException) return; // AbortController rejects, we can ignore it
callback?.(null, error);
callback(null, error);
});
return abort;

View File

@ -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({

View File

@ -230,6 +230,7 @@ export class Sidebar extends React.Component<Props> {
<SidebarNavItem
id="users"
isActive={isActiveRoute(usersManagementRoute)}
isHidden={UserManagement.tabRoutes.length === 0}
url={usersManagementURL({ query })}
subMenus={UserManagement.tabRoutes}
icon={<Icon material="security"/>}

View File

@ -280,7 +280,7 @@ export abstract class KubeObjectStore<T extends KubeObject = any> 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<T extends KubeObject = any> 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

View File

@ -26,4 +26,8 @@ export const ResourceNames: Record<KubeResource, string> = {
"podsecuritypolicies": "Pod Security Policies",
"poddisruptionbudgets": "Pod Disruption Budgets",
"limitranges": "Limit Ranges",
"role": "Roles",
"rolebinding": "Role Bindings",
"clusterrolebinding": "Cluster Role Bindings",
"serviceaccount": "Service Accounts"
};