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

Replace admin check with check for access (#297)

Signed-off-by: Adam Malcontenti-Wilson <adman.com@gmail.com>
This commit is contained in:
Adam Malcontenti-Wilson 2020-05-10 01:25:34 +10:00 committed by GitHub
parent fb287c435c
commit e21e0b577b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 62 additions and 58 deletions

View File

@ -20,7 +20,7 @@ interface Props extends RouteComponentProps<{}> {
export class Storage extends React.Component<Props> { export class Storage extends React.Component<Props> {
static get tabRoutes() { static get tabRoutes() {
const tabRoutes: TabRoute[] = []; const tabRoutes: TabRoute[] = [];
const { isClusterAdmin } = configStore; const { allowedResources } = configStore;
const query = namespaceStore.getContextParams() const query = namespaceStore.getContextParams()
tabRoutes.push({ tabRoutes.push({
@ -30,7 +30,7 @@ export class Storage extends React.Component<Props> {
path: volumeClaimsRoute.path, path: volumeClaimsRoute.path,
}) })
if (isClusterAdmin) { if (allowedResources.includes('persistentvolumes')) {
tabRoutes.push({ tabRoutes.push({
title: <Trans>Persistent Volumes</Trans>, title: <Trans>Persistent Volumes</Trans>,
component: PersistentVolumes, component: PersistentVolumes,
@ -39,7 +39,7 @@ export class Storage extends React.Component<Props> {
}); });
} }
if (isClusterAdmin) { if (allowedResources.includes('storageclasses')) {
tabRoutes.push({ tabRoutes.push({
title: <Trans>Storage Classes</Trans>, title: <Trans>Storage Classes</Trans>,
component: StorageClasses, component: StorageClasses,

View File

@ -21,7 +21,7 @@ interface Props extends RouteComponentProps<{}> {
export class UserManagement extends React.Component<Props> { export class UserManagement extends React.Component<Props> {
static get tabRoutes() { static get tabRoutes() {
const tabRoutes: TabRoute[] = []; const tabRoutes: TabRoute[] = [];
const { isClusterAdmin } = configStore; const { allowedResources } = configStore;
const query = namespaceStore.getContextParams() const query = namespaceStore.getContextParams()
tabRoutes.push( tabRoutes.push(
{ {
@ -43,7 +43,7 @@ export class UserManagement extends React.Component<Props> {
path: rolesRoute.path, path: rolesRoute.path,
}, },
) )
if (isClusterAdmin) { if (allowedResources.includes("podsecuritypolicies")) {
tabRoutes.push({ tabRoutes.push({
title: <Trans>Pod Security Policies</Trans>, title: <Trans>Pod Security Policies</Trans>,
component: PodSecurityPolicies, component: PodSecurityPolicies,

View File

@ -46,7 +46,7 @@ class App extends React.Component {
}; };
render() { render() {
const homeUrl = configStore.isClusterAdmin ? clusterURL() : workloadsURL(); const homeUrl = clusterURL();
return ( return (
<I18nProvider i18n={_i18n}> <I18nProvider i18n={_i18n}>
<Router history={browserHistory}> <Router history={browserHistory}>

View File

@ -71,7 +71,7 @@ export class Sidebar extends React.Component<Props> {
render() { render() {
const { toggle, isPinned, className } = this.props; const { toggle, isPinned, className } = this.props;
const { isClusterAdmin } = configStore; const { isClusterAdmin, allowedResources } = configStore;
const query = namespaceStore.getContextParams(); const query = namespaceStore.getContextParams();
return ( return (
<SidebarContext.Provider value={{ pinned: isPinned }}> <SidebarContext.Provider value={{ pinned: isPinned }}>
@ -91,14 +91,13 @@ export class Sidebar extends React.Component<Props> {
<div className="sidebar-nav flex column box grow-fixed"> <div className="sidebar-nav flex column box grow-fixed">
<SidebarNavItem <SidebarNavItem
id="cluster" id="cluster"
isHidden={!isClusterAdmin}
url={clusterURL()} url={clusterURL()}
text={<Trans>Cluster</Trans>} text={<Trans>Cluster</Trans>}
icon={<Icon svg="kube"/>} icon={<Icon svg="kube"/>}
/> />
<SidebarNavItem <SidebarNavItem
id="nodes" id="nodes"
isHidden={!isClusterAdmin} isHidden={!allowedResources.includes('nodes')}
url={nodesURL()} url={nodesURL()}
text={<Trans>Nodes</Trans>} text={<Trans>Nodes</Trans>}
icon={<Icon svg="nodes"/>} icon={<Icon svg="nodes"/>}
@ -166,7 +165,7 @@ export class Sidebar extends React.Component<Props> {
/> />
<SidebarNavItem <SidebarNavItem
id="custom-resources" id="custom-resources"
isHidden={!isClusterAdmin} isHidden={!allowedResources.includes('customresourcedefinitions')}
url={crdURL()} url={crdURL()}
subMenus={CustomResources.tabRoutes} subMenus={CustomResources.tabRoutes}
routePath={crdRoute.path} routePath={crdRoute.path}

View File

@ -45,6 +45,10 @@ export class ConfigStore {
return this.config.allowedNamespaces || []; return this.config.allowedNamespaces || [];
} }
get allowedResources() {
return this.config.allowedResources;
}
get isClusterAdmin() { get isClusterAdmin() {
return this.config.isClusterAdmin; return this.config.isClusterAdmin;
} }

View File

@ -57,18 +57,15 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
} }
} }
protected async loadItems(namespaces?: string[]): Promise<T[]> { protected async loadItems(allowedNamespaces?: string[]): Promise<T[]> {
if (!configStore.isClusterAdmin && !this.api.isNamespaced) { if (!this.api.isNamespaced || !allowedNamespaces) {
return []
}
if (!namespaces) {
const { limit } = this; const { limit } = this;
const query: IKubeApiQueryParams = limit ? { limit } : {}; const query: IKubeApiQueryParams = limit ? { limit } : {};
return this.api.list({}, query); return this.api.list({}, query);
} }
else { else {
return Promise return Promise
.all(namespaces.map(namespace => this.api.list({ namespace }))) .all(allowedNamespaces.map(namespace => this.api.list({ namespace })))
.then(items => items.flat()) .then(items => items.flat())
} }
} }
@ -155,7 +152,6 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
} }
subscribe(apis = [this.api]) { subscribe(apis = [this.api]) {
apis = apis.filter(api => !configStore.isClusterAdmin ? api.isNamespaced : true);
return KubeApi.watchAll(...apis); return KubeApi.watchAll(...apis);
} }

View File

@ -5,6 +5,7 @@ export interface IConfig extends Partial<IClusterInfo> {
username?: string; username?: string;
token?: string; token?: string;
allowedNamespaces?: string[]; allowedNamespaces?: string[];
allowedResources?: string[];
isClusterAdmin?: boolean; isClusterAdmin?: boolean;
chartsEnabled: boolean; chartsEnabled: boolean;
kubectlAccess?: boolean; // User accessed via kubectl-lens plugin kubectlAccess?: boolean; // User accessed via kubectl-lens plugin

View File

@ -3,7 +3,7 @@ import { FeatureStatusMap } from "./feature"
import * as k8s from "./k8s" import * as k8s from "./k8s"
import { clusterStore } from "../common/cluster-store" import { clusterStore } from "../common/cluster-store"
import logger from "./logger" import logger from "./logger"
import { KubeConfig, CoreV1Api } from "@kubernetes/client-node" import { KubeConfig, CoreV1Api, AuthorizationV1Api, V1ResourceAttributes } from "@kubernetes/client-node"
import * as fm from "./feature-manager"; import * as fm from "./feature-manager";
import { Kubectl } from "./kubectl"; import { Kubectl } from "./kubectl";
import { PromiseIpc } from "electron-promise-ipc" import { PromiseIpc } from "electron-promise-ipc"
@ -137,9 +137,7 @@ export class Cluster implements ClusterInfo {
this.distribution = this.detectKubernetesDistribution(this.version) this.distribution = this.detectKubernetesDistribution(this.version)
this.features = await fm.getFeatures(this.contextHandler) this.features = await fm.getFeatures(this.contextHandler)
this.isAdmin = await this.isClusterAdmin() this.isAdmin = await this.isClusterAdmin()
if (this.isAdmin) {
this.nodes = await this.getNodeCount() this.nodes = await this.getNodeCount()
}
this.kubeCtl = new Kubectl(this.version) this.kubeCtl = new Kubectl(this.version)
this.kubeCtl.ensureKubectl() this.kubeCtl.ensureKubectl()
} }
@ -227,30 +225,29 @@ export class Cluster implements ClusterInfo {
} }
} }
protected async isClusterAdmin(): Promise<boolean> { public async canI(resourceAttributes: V1ResourceAttributes): Promise<boolean> {
const requestOpts: request.RequestPromiseOptions = { const authApi = this.contextHandler.kc.makeApiClient(AuthorizationV1Api)
body: {
kind: "SelfSubjectAccessReview",
apiVersion: "authorization.k8s.io/v1",
spec: {
resourceAttributes: {
namespace: "kube-system",
resource: "*",
verb: "create",
}
}
},
method: "post"
}
try { try {
const response = await this.k8sRequest("/apis/authorization.k8s.io/v1/selfsubjectaccessreviews", requestOpts) const accessReview = await authApi.createSelfSubjectAccessReview({
return response.status.allowed === true apiVersion: "authorization.k8s.io/v1",
kind: "SelfSubjectAccessReview",
spec: { resourceAttributes }
})
return accessReview.body.status.allowed === true
} catch(error) { } catch(error) {
logger.error(`failed to request selfSubjectAccessReview: ${error.message}`) logger.error(`failed to request selfSubjectAccessReview: ${error.message}`)
return false return false
} }
} }
protected async isClusterAdmin(): Promise<boolean> {
return this.canI({
namespace: "kube-system",
resource: "*",
verb: "create",
})
}
protected detectKubernetesDistribution(kubernetesVersion: string): string { protected detectKubernetesDistribution(kubernetesVersion: string): string {
if (kubernetesVersion.includes("gke")) { if (kubernetesVersion.includes("gke")) {
return "gke" return "gke"

View File

@ -5,33 +5,19 @@ import { getAppVersion } from "../../common/app-utils"
import { CoreV1Api, AuthorizationV1Api } from "@kubernetes/client-node" import { CoreV1Api, AuthorizationV1Api } from "@kubernetes/client-node"
import { Cluster } from "../cluster" import { Cluster } from "../cluster"
function selfSubjectAccessReview(authApi: AuthorizationV1Api, namespace: string) {
return authApi.createSelfSubjectAccessReview({
apiVersion: "authorization.k8s.io/v1",
kind: "SelfSubjectAccessReview",
spec: {
resourceAttributes: {
namespace: namespace,
resource: "pods",
verb: "list",
}
}
})
}
async function getAllowedNamespaces(cluster: Cluster) { async function getAllowedNamespaces(cluster: Cluster) {
const api = cluster.contextHandler.kc.makeApiClient(CoreV1Api) const api = cluster.contextHandler.kc.makeApiClient(CoreV1Api)
const authApi = cluster.contextHandler.kc.makeApiClient(AuthorizationV1Api)
try { try {
const namespaceList = await api.listNamespace() const namespaceList = await api.listNamespace()
const nsAccessStatuses = await Promise.all( const nsAccessStatuses = await Promise.all(
namespaceList.body.items.map(ns => { namespaceList.body.items.map(ns => cluster.canI({
return selfSubjectAccessReview(authApi, ns.metadata.name) namespace: ns.metadata.name,
}) resource: "pods",
verb: "list",
}))
) )
return namespaceList.body.items return namespaceList.body.items
.filter((ns, i) => nsAccessStatuses[i].body.status.allowed) .filter((ns, i) => nsAccessStatuses[i])
.map(ns => ns.metadata.name) .map(ns => ns.metadata.name)
} catch(error) { } catch(error) {
const kc = cluster.contextHandler.kc const kc = cluster.contextHandler.kc
@ -44,6 +30,26 @@ async function getAllowedNamespaces(cluster: Cluster) {
} }
} }
async function getAllowedResources(cluster: Cluster) {
// TODO: auto-populate all resources dynamically
const resources = [
"nodes", "persistentvolumes", "storageclasses", "customresourcedefinitions",
"podsecuritypolicies"
]
try {
const resourceAccessStatuses = await Promise.all(
resources.map(resource => cluster.canI({
resource: resource,
verb: "list"
}))
)
return resources
.filter((resource, i) => resourceAccessStatuses[i])
} catch(error) {
return []
}
}
class ConfigRoute extends LensApi { class ConfigRoute extends LensApi {
public async routeConfig(request: LensApiRequest) { public async routeConfig(request: LensApiRequest) {
@ -56,6 +62,7 @@ class ConfigRoute extends LensApi {
kubeVersion: cluster.version, kubeVersion: cluster.version,
chartsEnabled: true, chartsEnabled: true,
isClusterAdmin: cluster.isAdmin, isClusterAdmin: cluster.isAdmin,
allowedResources: await getAllowedResources(cluster),
allowedNamespaces: await getAllowedNamespaces(cluster) allowedNamespaces: await getAllowedNamespaces(cluster)
}; };