From 0c48de375083812d462831c0d83023fee40b554b Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 19 Dec 2024 15:01:39 +0100 Subject: [PATCH] feat: dispatch permissions to frontend in experimental contexts mode (#10383) * feat: dispatch resources permissions (backend) Signed-off-by: Philippe Martin * feat: provide getContextsPermissions to main world Signed-off-by: Philippe Martin * feat: store for contexts permissions Signed-off-by: Philippe Martin * refactor: use KubernetesResourceName instead of string Signed-off-by: Philippe Martin * refactor: revert "refactor: use KubernetesResourceName instead of string" This reverts commit 885552c6ef0b2a2e3f6ee6e1efc437a0f25ef7f8. Signed-off-by: Philippe Martin * feat: use string type for resource name Signed-off-by: Philippe Martin * fix: permissions store is inactive when non-experimental Signed-off-by: Philippe Martin * fix: use map pattern + fix undefined Signed-off-by: Philippe Martin * feat: add doc on permission.reason field Signed-off-by: Philippe Martin * fix: remove use of any Signed-off-by: Philippe Martin * fix: move Object.defineProperty into beforeAll Signed-off-by: Philippe Martin * fix: do not make Window properties writable in tests Signed-off-by: Philippe Martin --------- Signed-off-by: Philippe Martin --- .../src/kubernetes-contexts-permissions.ts | 35 ++++++ packages/main/src/plugin/index.ts | 5 + .../contexts-states-dispatcher.spec.ts | 106 +++++++++++++++++- .../kubernetes/contexts-states-dispatcher.ts | 14 ++- .../plugin/kubernetes/kubernetes-client.ts | 8 ++ packages/preload/src/index.ts | 5 + ...es-context-permission-experimental.spec.ts | 90 +++++++++++++++ ...ontext-permission-non-experimental.spec.ts | 75 +++++++++++++ .../stores/kubernetes-context-permission.ts | 62 ++++++++++ 9 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/kubernetes-contexts-permissions.ts create mode 100644 packages/renderer/src/stores/kubernetes-context-permission-experimental.spec.ts create mode 100644 packages/renderer/src/stores/kubernetes-context-permission-non-experimental.spec.ts create mode 100644 packages/renderer/src/stores/kubernetes-context-permission.ts diff --git a/packages/api/src/kubernetes-contexts-permissions.ts b/packages/api/src/kubernetes-contexts-permissions.ts new file mode 100644 index 000000000..192ab5ef6 --- /dev/null +++ b/packages/api/src/kubernetes-contexts-permissions.ts @@ -0,0 +1,35 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +export interface ContextPermission { + contextName: string; + // the resource name is a generic string type and not a string literal type, as we want to handle CRDs names + resourceName: string; + // permitted if allowed and not denied + // > When multiple authorization modules are configured, each is checked in sequence. + // > If any authorizer approves or denies a request, that decision is immediately returned + // > and no other authorizer is consulted. If all modules have no opinion on the request, + // > then the request is denied. An overall deny verdict means that the API server rejects + // > the request and responds with an HTTP 403 (Forbidden) status. + // (source: https://kubernetes.io/docs/reference/access-authn-authz/authorization/) + permitted: boolean; + // A free-form and optional text reason for the resource being allowed or denied. + // We cannot rely on having a reason for every request. + // For exemple on Kind cluster, a reason is given only when the access is allowed, no reason is done for denial. + reason?: string; +} diff --git a/packages/main/src/plugin/index.ts b/packages/main/src/plugin/index.ts index cab0f747d..c144a84d2 100644 --- a/packages/main/src/plugin/index.ts +++ b/packages/main/src/plugin/index.ts @@ -88,6 +88,7 @@ import type { ImageInspectInfo } from '/@api/image-inspect-info.js'; import type { ImageSearchOptions, ImageSearchResult, ImageTagsListOptions } from '/@api/image-registry.js'; import type { KubeContext } from '/@api/kubernetes-context.js'; import type { ContextHealth } from '/@api/kubernetes-contexts-healths.js'; +import type { ContextPermission } from '/@api/kubernetes-contexts-permissions.js'; import type { ContextGeneralState, ResourceName } from '/@api/kubernetes-contexts-states.js'; import type { ForwardConfig, ForwardOptions } from '/@api/kubernetes-port-forward-model.js'; import type { ManifestCreateOptions, ManifestInspectInfo, ManifestPushOptions } from '/@api/manifest-info.js'; @@ -2592,6 +2593,10 @@ export class PluginSystem { return kubernetesClient.getContextsHealths(); }); + this.ipcHandle('kubernetes:getContextsPermissions', async (_listener): Promise => { + return kubernetesClient.getContextsPermissions(); + }); + const kubernetesExecCallbackMap = new Map< number, { onStdIn: (data: string) => void; onResize: (columns: number, rows: number) => void } diff --git a/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.spec.ts b/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.spec.ts index 7773d9c56..500f90881 100644 --- a/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.spec.ts +++ b/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.spec.ts @@ -20,6 +20,7 @@ import { expect, test, vi } from 'vitest'; import type { ApiSenderType } from '../api.js'; import type { ContextHealthState } from './context-health-checker.js'; +import type { ContextResourcePermission } from './context-permissions-checker.js'; import type { ContextsManagerExperimental } from './contexts-manager-experimental.js'; import { ContextsStatesDispatcher } from './contexts-states-dispatcher.js'; import type { KubeConfigSingleContext } from './kubeconfig-single-context.js'; @@ -61,7 +62,10 @@ test('ContextsStatesDispatcher should call updatePermissions when onContextPermi getHealthCheckersStates: vi.fn(), getPermissions: vi.fn(), } as unknown as ContextsManagerExperimental; - const apiSender: ApiSenderType = {} as unknown as ApiSenderType; + const apiSender: ApiSenderType = { + send: vi.fn(), + } as unknown as ApiSenderType; + vi.mocked(manager.getPermissions).mockReturnValue(new Map>()); const dispatcher = new ContextsStatesDispatcher(manager, apiSender); const updateHealthStatesSpy = vi.spyOn(dispatcher, 'updateHealthStates'); const updatePermissionsSpy = vi.spyOn(dispatcher, 'updatePermissions'); @@ -87,6 +91,7 @@ test('ContextsStatesDispatcher should call updateHealthStates and updatePermissi const apiSender: ApiSenderType = { send: vi.fn(), } as unknown as ApiSenderType; + vi.mocked(manager.getPermissions).mockReturnValue(new Map>()); const dispatcher = new ContextsStatesDispatcher(manager, apiSender); const updateHealthStatesSpy = vi.spyOn(dispatcher, 'updateHealthStates'); const updatePermissionsSpy = vi.spyOn(dispatcher, 'updatePermissions'); @@ -150,3 +155,102 @@ test('updateHealthStates should call apiSender.send with kubernetes-contexts-hea dispatcher.updateHealthStates(); expect(sendMock).toHaveBeenCalledWith('kubernetes-contexts-healths'); }); + +test('getContextsPermissions should return the values as an array', () => { + const manager: ContextsManagerExperimental = { + getPermissions: vi.fn(), + } as unknown as ContextsManagerExperimental; + const apiSender: ApiSenderType = { + send: vi.fn(), + } as unknown as ApiSenderType; + const dispatcher = new ContextsStatesDispatcher(manager, apiSender); + const value = new Map>([ + [ + 'context1', + new Map([ + [ + 'resource1', + { + attrs: {}, + permitted: true, + reason: 'ok', + }, + ], + [ + 'resource2', + { + attrs: {}, + permitted: false, + reason: 'nok', + }, + ], + ]), + ], + [ + 'context2', + new Map([ + [ + 'resource1', + { + attrs: {}, + permitted: false, + reason: 'nok', + }, + ], + [ + 'resource2', + { + attrs: {}, + permitted: true, + reason: 'ok', + }, + ], + ]), + ], + ]); + vi.mocked(manager.getPermissions).mockReturnValue(value); + const result = dispatcher.getContextsPermissions(); + expect(result).toEqual([ + { + contextName: 'context1', + resourceName: 'resource1', + permitted: true, + reason: 'ok', + }, + { + contextName: 'context1', + resourceName: 'resource2', + permitted: false, + reason: 'nok', + }, + { + contextName: 'context2', + resourceName: 'resource1', + permitted: false, + reason: 'nok', + }, + { + contextName: 'context2', + resourceName: 'resource2', + permitted: true, + reason: 'ok', + }, + ]); +}); + +test('updatePermissions should call apiSender.send with kubernetes-contexts-permissions', () => { + const manager: ContextsManagerExperimental = { + onContextHealthStateChange: vi.fn(), + onContextPermissionResult: vi.fn(), + onContextDelete: vi.fn(), + getHealthCheckersStates: vi.fn(), + getPermissions: vi.fn(), + } as unknown as ContextsManagerExperimental; + const apiSender: ApiSenderType = { + send: vi.fn(), + } as unknown as ApiSenderType; + const dispatcher = new ContextsStatesDispatcher(manager, apiSender); + vi.spyOn(dispatcher, 'getContextsPermissions').mockReturnValue([]); + dispatcher.updatePermissions(); + expect(vi.mocked(apiSender.send)).toHaveBeenCalledWith('kubernetes-contexts-permissions'); +}); diff --git a/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.ts b/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.ts index 6412c2a5c..968fecf3b 100644 --- a/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.ts +++ b/packages/main/src/plugin/kubernetes/contexts-states-dispatcher.ts @@ -17,6 +17,7 @@ ***********************************************************************/ import type { ContextHealth } from '/@api/kubernetes-contexts-healths.js'; +import type { ContextPermission } from '/@api/kubernetes-contexts-permissions.js'; import type { ApiSenderType } from '../api.js'; import type { ContextHealthState } from './context-health-checker.js'; @@ -56,6 +57,17 @@ export class ContextsStatesDispatcher { } updatePermissions(): void { - console.log('current permissions', this.manager.getPermissions()); + this.apiSender.send('kubernetes-contexts-permissions'); + } + + getContextsPermissions(): ContextPermission[] { + return Array.from(this.manager.getPermissions().entries()).flatMap(([contextName, permissions]) => { + return Array.from(permissions.entries()).map(([resourceName, contextResourcePermission]) => ({ + contextName, + resourceName, + permitted: contextResourcePermission.permitted, + reason: contextResourcePermission.reason, + })); + }); } } diff --git a/packages/main/src/plugin/kubernetes/kubernetes-client.ts b/packages/main/src/plugin/kubernetes/kubernetes-client.ts index 4046483cd..062206486 100644 --- a/packages/main/src/plugin/kubernetes/kubernetes-client.ts +++ b/packages/main/src/plugin/kubernetes/kubernetes-client.ts @@ -72,6 +72,7 @@ import type { KubernetesPortForwardService } from '/@/plugin/kubernetes/kubernet import { KubernetesPortForwardServiceProvider } from '/@/plugin/kubernetes/kubernetes-port-forward-service.js'; import type { KubeContext } from '/@api/kubernetes-context.js'; import type { ContextHealth } from '/@api/kubernetes-contexts-healths.js'; +import type { ContextPermission } from '/@api/kubernetes-contexts-permissions.js'; import type { ContextGeneralState, ResourceName } from '/@api/kubernetes-contexts-states.js'; import type { ForwardConfig, ForwardOptions } from '/@api/kubernetes-port-forward-model.js'; import type { V1Route } from '/@api/openshift-types.js'; @@ -1852,4 +1853,11 @@ export class KubernetesClient { } return this.contextsStatesDispatcher?.getContextsHealths(); } + + public getContextsPermissions(): ContextPermission[] { + if (!this.contextsStatesDispatcher) { + throw new Error('contextsStatesDispatcher is undefined. This should not happen in Kubernetes experimental'); + } + return this.contextsStatesDispatcher.getContextsPermissions(); + } } diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index 33381b53b..468d0d82f 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -68,6 +68,7 @@ import type { ImageInspectInfo } from '/@api/image-inspect-info'; import type { ImageSearchOptions, ImageSearchResult, ImageTagsListOptions } from '/@api/image-registry'; import type { KubeContext } from '/@api/kubernetes-context'; import type { ContextHealth } from '/@api/kubernetes-contexts-healths'; +import type { ContextPermission } from '/@api/kubernetes-contexts-permissions'; import type { ContextGeneralState, ResourceName } from '/@api/kubernetes-contexts-states'; import type { ForwardConfig, ForwardOptions } from '/@api/kubernetes-port-forward-model'; import type { ManifestCreateOptions, ManifestInspectInfo, ManifestPushOptions } from '/@api/manifest-info'; @@ -1876,6 +1877,10 @@ export function initExposure(): void { return ipcInvoke('kubernetes:getContextsHealths'); }); + contextBridge.exposeInMainWorld('kubernetesGetContextsPermissions', async (): Promise => { + return ipcInvoke('kubernetes:getContextsPermissions'); + }); + contextBridge.exposeInMainWorld('kubernetesGetClusters', async (): Promise => { return ipcInvoke('kubernetes-client:getClusters'); }); diff --git a/packages/renderer/src/stores/kubernetes-context-permission-experimental.spec.ts b/packages/renderer/src/stores/kubernetes-context-permission-experimental.spec.ts new file mode 100644 index 000000000..acfc3ed03 --- /dev/null +++ b/packages/renderer/src/stores/kubernetes-context-permission-experimental.spec.ts @@ -0,0 +1,90 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { get } from 'svelte/store'; +import { beforeAll, expect, test, vi } from 'vitest'; + +import type { ContextPermission } from '/@api/kubernetes-contexts-permissions'; + +import { kubernetesContextsPermissions, kubernetesContextsPermissionsStore } from './kubernetes-context-permission'; + +const callbacks = new Map Promise>(); +const eventEmitter = { + receive: (message: string, callback: () => Promise) => { + callbacks.set(message, callback); + }, +}; + +beforeAll(() => { + Object.defineProperty(global, 'window', { + value: { + kubernetesGetContextsPermissions: vi.fn(), + getConfigurationValue: vi.fn(), + addEventListener: eventEmitter.receive, + events: { + receive: eventEmitter.receive, + }, + }, + }); +}); + +test('kubernetesContextsPermissions in experimental states mode', async () => { + vi.mocked(window.getConfigurationValue).mockResolvedValue(true); + + const initialValues: ContextPermission[] = []; + const nextValues: ContextPermission[] = [ + { + contextName: 'context1', + resourceName: 'pods', + permitted: true, + }, + { + contextName: 'context2', + resourceName: 'deployments', + permitted: false, + }, + ]; + vi.mocked(window.kubernetesGetContextsPermissions).mockResolvedValue(initialValues); + + kubernetesContextsPermissionsStore.setup(); + + // send 'extensions-already-started' event + const callbackExtensionsStarted = callbacks.get('extensions-already-started'); + expect(callbackExtensionsStarted).toBeDefined(); + await callbackExtensionsStarted!(); + + await vi.waitFor(() => { + const currentValue = get(kubernetesContextsPermissions); + expect(currentValue).toEqual(initialValues); + }, 500); + + // data has been updated in the backend + vi.mocked(window.kubernetesGetContextsPermissions).mockResolvedValue(nextValues); + + // send an event indicating the data is updated + const event = 'kubernetes-contexts-permissions'; + const callback = callbacks.get(event); + expect(callback).toBeDefined(); + await callback!(); + + await vi.waitFor(() => { + // check received data is updated + const currentValue = get(kubernetesContextsPermissions); + expect(currentValue).toEqual(nextValues); + }, 500); +}); diff --git a/packages/renderer/src/stores/kubernetes-context-permission-non-experimental.spec.ts b/packages/renderer/src/stores/kubernetes-context-permission-non-experimental.spec.ts new file mode 100644 index 000000000..7149a739c --- /dev/null +++ b/packages/renderer/src/stores/kubernetes-context-permission-non-experimental.spec.ts @@ -0,0 +1,75 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { get } from 'svelte/store'; +import { beforeAll, expect, test, vi } from 'vitest'; + +import type { ContextPermission } from '/@api/kubernetes-contexts-permissions'; + +import { kubernetesContextsPermissions, kubernetesContextsPermissionsStore } from './kubernetes-context-permission'; + +const callbacks = new Map Promise>(); +const eventEmitter = { + receive: (message: string, callback: () => Promise) => { + callbacks.set(message, callback); + }, +}; + +beforeAll(() => { + Object.defineProperty(global, 'window', { + value: { + kubernetesGetContextsPermissions: vi.fn(), + getConfigurationValue: vi.fn(), + addEventListener: eventEmitter.receive, + events: { + receive: eventEmitter.receive, + }, + }, + }); +}); + +test('kubernetesContextsPermissions in experimental states mode', async () => { + vi.mocked(window.getConfigurationValue).mockResolvedValue(false); + + const initialValues: ContextPermission[] = [ + { + contextName: 'context1', + resourceName: 'pods', + permitted: true, + }, + { + contextName: 'context2', + resourceName: 'deployments', + permitted: false, + }, + ]; + vi.mocked(window.kubernetesGetContextsPermissions).mockResolvedValue(initialValues); + + kubernetesContextsPermissionsStore.setup(); + + // send 'extensions-already-started' event + const callbackExtensionsStarted = callbacks.get('extensions-already-started'); + expect(callbackExtensionsStarted).toBeDefined(); + await callbackExtensionsStarted!(); + + // values are never fetched + await new Promise(resolve => setTimeout(resolve, 500)); + const currentValue = get(kubernetesContextsPermissions); + expect(currentValue).toEqual([]); + expect(vi.mocked(window.kubernetesGetContextsPermissions)).not.toHaveBeenCalled(); +}); diff --git a/packages/renderer/src/stores/kubernetes-context-permission.ts b/packages/renderer/src/stores/kubernetes-context-permission.ts new file mode 100644 index 000000000..01f468ef4 --- /dev/null +++ b/packages/renderer/src/stores/kubernetes-context-permission.ts @@ -0,0 +1,62 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { type Writable, writable } from 'svelte/store'; + +import type { ContextPermission } from '/@api/kubernetes-contexts-permissions'; + +import { EventStore } from './event-store'; + +const windowEvents = ['kubernetes-contexts-permissions', 'extension-stopped', 'extensions-started']; +const windowListeners = ['extensions-already-started']; + +let experimentalStates: boolean | undefined = undefined; +let readyToUpdate = false; + +export async function checkForUpdate(eventName: string): Promise { + // check for update only in experimental states mode + if (experimentalStates === undefined) { + experimentalStates = (await window.getConfigurationValue('kubernetes.statesExperimental')) ?? false; + } + if (experimentalStates === false) { + return false; + } + + if ('extensions-already-started' === eventName) { + readyToUpdate = true; + } + // do not fetch until extensions are all started + return readyToUpdate; +} + +export const kubernetesContextsPermissions: Writable = writable([]); + +// use helper here as window methods are initialized after the store in tests +const listContextsPermissions = (): Promise => { + return window.kubernetesGetContextsPermissions(); +}; + +export const kubernetesContextsPermissionsStore = new EventStore( + 'contexts permissions', + kubernetesContextsPermissions, + checkForUpdate, + windowEvents, + windowListeners, + listContextsPermissions, +); +kubernetesContextsPermissionsStore.setup();