Skip to content

Commit

Permalink
feat: dispatch permissions to frontend in experimental contexts mode …
Browse files Browse the repository at this point in the history
…(#10383)

* feat: dispatch resources permissions (backend)
Signed-off-by: Philippe Martin <[email protected]>

* feat: provide getContextsPermissions to main world
Signed-off-by: Philippe Martin <[email protected]>

* feat: store for contexts permissions
Signed-off-by: Philippe Martin <[email protected]>

* refactor: use KubernetesResourceName instead of string
Signed-off-by: Philippe Martin <[email protected]>

* refactor: revert "refactor: use KubernetesResourceName instead of string"

This reverts commit 885552c6ef0b2a2e3f6ee6e1efc437a0f25ef7f8.

Signed-off-by: Philippe Martin <[email protected]>

* feat: use string type for resource name
Signed-off-by: Philippe Martin <[email protected]>

* fix: permissions store is inactive when non-experimental
Signed-off-by: Philippe Martin <[email protected]>

* fix: use map pattern + fix undefined
Signed-off-by: Philippe Martin <[email protected]>

* feat: add doc on permission.reason field
Signed-off-by: Philippe Martin <[email protected]>

* fix: remove use of any
Signed-off-by: Philippe Martin <[email protected]>

* fix: move Object.defineProperty into beforeAll
Signed-off-by: Philippe Martin <[email protected]>

* fix: do not make Window properties writable in tests
Signed-off-by: Philippe Martin <[email protected]>

---------

Signed-off-by: Philippe Martin <[email protected]>
  • Loading branch information
feloy authored Dec 19, 2024
1 parent 38be954 commit 0c48de3
Show file tree
Hide file tree
Showing 9 changed files with 398 additions and 2 deletions.
35 changes: 35 additions & 0 deletions packages/api/src/kubernetes-contexts-permissions.ts
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 5 additions & 0 deletions packages/main/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -2592,6 +2593,10 @@ export class PluginSystem {
return kubernetesClient.getContextsHealths();
});

this.ipcHandle('kubernetes:getContextsPermissions', async (_listener): Promise<ContextPermission[]> => {
return kubernetesClient.getContextsPermissions();
});

const kubernetesExecCallbackMap = new Map<
number,
{ onStdIn: (data: string) => void; onResize: (columns: number, rows: number) => void }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, Map<string, ContextResourcePermission>>());
const dispatcher = new ContextsStatesDispatcher(manager, apiSender);
const updateHealthStatesSpy = vi.spyOn(dispatcher, 'updateHealthStates');
const updatePermissionsSpy = vi.spyOn(dispatcher, 'updatePermissions');
Expand All @@ -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<string, Map<string, ContextResourcePermission>>());
const dispatcher = new ContextsStatesDispatcher(manager, apiSender);
const updateHealthStatesSpy = vi.spyOn(dispatcher, 'updateHealthStates');
const updatePermissionsSpy = vi.spyOn(dispatcher, 'updatePermissions');
Expand Down Expand Up @@ -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<string, Map<string, ContextResourcePermission>>([
[
'context1',
new Map<string, ContextResourcePermission>([
[
'resource1',
{
attrs: {},
permitted: true,
reason: 'ok',
},
],
[
'resource2',
{
attrs: {},
permitted: false,
reason: 'nok',
},
],
]),
],
[
'context2',
new Map<string, ContextResourcePermission>([
[
'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');
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
}));
});
}
}
8 changes: 8 additions & 0 deletions packages/main/src/plugin/kubernetes/kubernetes-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
}
}
5 changes: 5 additions & 0 deletions packages/preload/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1876,6 +1877,10 @@ export function initExposure(): void {
return ipcInvoke('kubernetes:getContextsHealths');
});

contextBridge.exposeInMainWorld('kubernetesGetContextsPermissions', async (): Promise<ContextPermission[]> => {
return ipcInvoke('kubernetes:getContextsPermissions');
});

contextBridge.exposeInMainWorld('kubernetesGetClusters', async (): Promise<Cluster[]> => {
return ipcInvoke('kubernetes-client:getClusters');
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, () => Promise<void>>();
const eventEmitter = {
receive: (message: string, callback: () => Promise<void>) => {
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);
});
Loading

0 comments on commit 0c48de3

Please sign in to comment.