Merge pull request #27499 from dzemanov/add-kubernetes-permission

feat(kubernetes): introduce kubernetes permission
This commit is contained in:
Ben Lambert
2025-02-04 13:10:16 +01:00
committed by GitHub
25 changed files with 594 additions and 134 deletions
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/plugin-kubernetes-backend': patch
'@backstage/plugin-kubernetes-cluster': patch
'@backstage/plugin-kubernetes-common': patch
'@backstage/plugin-kubernetes': patch
---
The `/clusters` endpoint is now protected by the `kubernetes.clusters.read` permission.
The `/services/:serviceId` endpoint is now protected by the `kubernetes.resources.read` permission.
The `/resources` endpoints are now protected by the `kubernetes.resources.read` permission.
+18 -2
View File
@@ -80,11 +80,13 @@ The default value is `false`.
#### Internationalization
To customize or translate the **Delete Pod** text, use the following approach:
To customize or translate text in some of the components, use the following approach:
```js
import { createTranslationMessages } from '@backstage/core-plugin-api/alpha';
import { kubernetesReactTranslationRef } from '@backstage/plugin-kubernetes-react/alpha';
import { kubernetesTranslationRef } from '@backstage/plugin-kubernetes/alpha';
import { kubernetesClusterTranslationRef } from '@backstage/plugin-kubernetes-cluster/alpha';
const app = createApp({
__experimentalTranslations: {
@@ -94,7 +96,21 @@ const app = createApp({
messages: {
"podDrawer.buttons.delete": 'Restart Pod'
}
})
}),
createTranslationMessages({
ref: kubernetesTranslationRef,
messages: {
'kubernetesContentPage.permissionAlert.title': 'Insufficient permissions',
'kubernetesContentPage.permissionAlert.message': 'You do not have permissions to view Kubernetes objects.',
},
}),
createTranslationMessages({
ref: kubernetesClusterTranslationRef,
messages: {
'kubernetesClusterContentPage.permissionAlert.title': 'Insufficient permissions',
'kubernetesClusterContentPage.permissionAlert.message': 'You do not have permissions to view Kubernetes objects.',
},
}),
]
},
...
+18
View File
@@ -0,0 +1,18 @@
---
id: permissions
title: Permissions
description: Configuring permissions for Kubernetes plugin
---
The Kubernetes plugin integrates with the permission framework. Administrators can define PermissionPolicies
to restrict access to the `/clusters`, `/services/:serviceId`, `/resources` and `/proxy` endpoints.
This feature assumes your Backstage instance has enabled the [permissions framework](https://backstage.io/docs/permissions/getting-started).
### Available permissions
| Name | Policy | Description |
| ------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
| kubernetes.clusters.read | read | Allows the user to read Kubernetes clusters information under `/clusters` |
| kubernetes.resources.read | read | Allows the user to read Kubernetes resources information under `/services/:serviceId` and `/resources` |
| kubernetes.proxy | | Allows the user to make arbitrary requests to the [REST API](https://kubernetes.io/docs/reference/using-api/api-concepts/) under `/proxy` |
@@ -0,0 +1,52 @@
/*
* Copyright 2024 The Backstage Authors
*
* 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.
*/
import type {
HttpAuthService,
PermissionsService,
} from '@backstage/backend-plugin-api';
import { NotAllowedError } from '@backstage/errors';
import {
AuthorizeResult,
type BasicPermission,
} from '@backstage/plugin-permission-common';
import express from 'express';
export async function requirePermission(
permissionApi: PermissionsService,
permissionRequired: BasicPermission,
httpAuth: HttpAuthService,
req: express.Request,
) {
const decision = (
await permissionApi.authorize(
[
{
permission: permissionRequired,
},
],
{
credentials: await httpAuth.credentials(req),
},
)
)[0];
if (decision.result === AuthorizeResult.ALLOW) {
return;
}
throw new NotAllowedError('Unauthorized');
}
@@ -18,17 +18,48 @@ import request from 'supertest';
import {
mockCredentials,
mockServices,
type ServiceMock,
startTestBackend,
} from '@backstage/backend-test-utils';
import { kubernetesObjectsProviderExtensionPoint } from '@backstage/plugin-kubernetes-node';
import { createBackendModule } from '@backstage/backend-plugin-api';
import {
createBackendModule,
type PermissionsService,
} from '@backstage/backend-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { ExtendedHttpServer } from '@backstage/backend-defaults/rootHttpRouter';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
describe('resourcesRoutes', () => {
let app: ExtendedHttpServer;
const permissionsMock: ServiceMock<PermissionsService> =
mockServices.permissions.mock({
authorize: jest.fn(),
authorizeConditional: jest.fn(),
});
beforeAll(async () => {
const startPermissionDeniedTestServer = async () => {
permissionsMock.authorize.mockResolvedValue([
{ result: AuthorizeResult.DENY },
]);
const { server } = await startTestBackend({
features: [
mockServices.rootConfig.factory({
data: {
kubernetes: {
serviceLocatorMethod: { type: 'multiTenant' },
clusterLocatorMethods: [],
},
},
}),
permissionsMock.factory,
import('@backstage/plugin-kubernetes-backend'),
],
});
return server;
};
beforeEach(async () => {
const objectsProviderMock = {
getKubernetesObjectsByEntity: jest.fn().mockImplementation(args => {
if (args.entity.metadata.name === 'inject500') {
@@ -109,6 +140,8 @@ describe('resourcesRoutes', () => {
},
}),
import('@backstage/plugin-kubernetes-backend'),
import('@backstage/plugin-permission-backend'),
import('@backstage/plugin-permission-backend-module-allow-all-policy'),
createBackendModule({
pluginId: 'kubernetes',
moduleId: 'test-objects-provider',
@@ -127,6 +160,10 @@ describe('resourcesRoutes', () => {
app = server;
});
afterEach(() => {
app.stop();
});
describe('POST /resources/workloads/query', () => {
// eslint-disable-next-line jest/expect-expect
it('200 happy path', async () => {
@@ -269,6 +306,13 @@ describe('resourcesRoutes', () => {
response: { statusCode: 401 },
});
});
it('403 when permission blocks endpoint', async () => {
app = await startPermissionDeniedTestServer();
const response = await request(app).post(
'/api/kubernetes/resources/workloads/query',
);
expect(response.status).toEqual(403);
});
// eslint-disable-next-line jest/expect-expect
it('500 handle gracefully', async () => {
await request(app)
@@ -548,6 +592,13 @@ describe('resourcesRoutes', () => {
response: { statusCode: 401 },
});
});
it('403 when permission blocks endpoint', async () => {
app = await startPermissionDeniedTestServer();
const response = await request(app).post(
'/api/kubernetes/resources/custom/query',
);
expect(response.status).toEqual(403);
});
// eslint-disable-next-line jest/expect-expect
it('500 handle gracefully', async () => {
await request(app)
@@ -23,6 +23,9 @@ import { InputError } from '@backstage/errors';
import express, { Request } from 'express';
import { KubernetesObjectsProvider } from '@backstage/plugin-kubernetes-node';
import { AuthService, HttpAuthService } from '@backstage/backend-plugin-api';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import { requirePermission } from '../auth/requirePermission';
import { kubernetesResourcesReadPermission } from '@backstage/plugin-kubernetes-common';
export const addResourceRoutesToRouter = (
router: express.Router,
@@ -30,6 +33,7 @@ export const addResourceRoutesToRouter = (
objectsProvider: KubernetesObjectsProvider,
auth: AuthService,
httpAuth: HttpAuthService,
permissionApi: PermissionEvaluator,
) => {
const getEntityByReq = async (req: Request<any>) => {
const rawEntityRef = req.body.entityRef;
@@ -62,6 +66,12 @@ export const addResourceRoutesToRouter = (
};
router.post('/resources/workloads/query', async (req, res) => {
await requirePermission(
permissionApi,
kubernetesResourcesReadPermission,
httpAuth,
req,
);
const entity = await getEntityByReq(req);
const response = await objectsProvider.getKubernetesObjectsByEntity(
{
@@ -74,6 +84,12 @@ export const addResourceRoutesToRouter = (
});
router.post('/resources/custom/query', async (req, res) => {
await requirePermission(
permissionApi,
kubernetesResourcesReadPermission,
httpAuth,
req,
);
const entity = await getEntityByReq(req);
if (!req.body.customResources) {
@@ -92,6 +92,19 @@ describe('API integration tests', () => {
});
},
});
const startPermissionDeniedTestServer = async () => {
permissionsMock.authorize.mockResolvedValue([
{ result: AuthorizeResult.DENY },
]);
const { server } = await startTestBackend({
features: [
minimalValidConfigService,
permissionsMock.factory,
import('@backstage/plugin-kubernetes-backend'),
],
});
return server;
};
beforeEach(async () => {
jest.resetAllMocks();
@@ -305,6 +318,12 @@ describe('API integration tests', () => {
items: [expect.objectContaining({ title: 'cluster-title' })],
});
});
it('returns 403 response when permission blocks endpoint', async () => {
app = await startPermissionDeniedTestServer();
const response = await request(app).get('/api/kubernetes/clusters');
expect(response.status).toEqual(403);
});
});
describe('post /services/:serviceId', () => {
@@ -504,6 +523,14 @@ describe('API integration tests', () => {
}),
);
});
it('returns 403 response when permission blocks endpoint', async () => {
app = await startPermissionDeniedTestServer();
const response = await request(app).post(
'/api/kubernetes/services/test-service',
);
expect(response.status).toEqual(403);
});
});
describe('/proxy', () => {
@@ -571,18 +598,7 @@ metadata:
});
it('returns 403 response when permission blocks endpoint', async () => {
permissionsMock.authorize.mockResolvedValue([
{ result: AuthorizeResult.DENY },
]);
const { server } = await startTestBackend({
features: [
minimalValidConfigService,
permissionsMock.factory,
import('@backstage/plugin-kubernetes-backend'),
],
});
app = server;
app = await startPermissionDeniedTestServer();
const proxyEndpointRequest = request(app)
.post('/api/kubernetes/proxy/api/v1/namespaces')
@@ -779,6 +795,8 @@ metadata:
expect(response.body).toMatchObject({
permissions: [
{ type: 'basic', name: 'kubernetes.proxy', attributes: {} },
{ type: 'basic', name: 'kubernetes.resources.read', attributes: {} },
{ type: 'basic', name: 'kubernetes.clusters.read', attributes: {} },
],
rules: [],
});
@@ -18,7 +18,9 @@ import { Config } from '@backstage/config';
import {
ANNOTATION_KUBERNETES_AUTH_PROVIDER,
ANNOTATION_KUBERNETES_OIDC_TOKEN_PROVIDER,
kubernetesClustersReadPermission,
kubernetesPermissions,
kubernetesResourcesReadPermission,
} from '@backstage/plugin-kubernetes-common';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node';
@@ -73,6 +75,7 @@ import {
} from './KubernetesFanOutHandler';
import { KubernetesClientBasedFetcher } from './KubernetesFetcher';
import { KubernetesProxy } from './KubernetesProxy';
import { requirePermission } from '../auth/requirePermission';
/**
* @deprecated Please migrate to the new backend system as this will be removed in the future.
@@ -393,6 +396,12 @@ export class KubernetesBuilder {
);
// @deprecated
router.post('/services/:serviceId', async (req, res) => {
await requirePermission(
permissionApi,
kubernetesResourcesReadPermission,
httpAuth,
req,
);
const serviceId = req.params.serviceId;
const requestBody: ObjectsByEntityRequest = req.body;
try {
@@ -413,6 +422,12 @@ export class KubernetesBuilder {
});
router.get('/clusters', async (req, res) => {
await requirePermission(
permissionApi,
kubernetesClustersReadPermission,
httpAuth,
req,
);
const credentials = await httpAuth.credentials(req);
const clusterDetails = await this.fetchClusterDetails(clusterSupplier, {
credentials,
@@ -447,6 +462,7 @@ export class KubernetesBuilder {
objectsProvider,
authService,
httpAuth,
permissionApi,
);
return router;
+17 -3
View File
@@ -10,9 +10,7 @@
]
},
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
"access": "public"
},
"keywords": [
"backstage",
@@ -26,8 +24,23 @@
},
"license": "Apache-2.0",
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.ts",
"./package.json": "./package.json"
},
"main": "src/index.ts",
"types": "src/index.ts",
"typesVersions": {
"*": {
"alpha": [
"src/alpha.ts"
],
"package.json": [
"package.json"
]
}
},
"files": [
"dist"
],
@@ -47,6 +60,7 @@
"@backstage/plugin-catalog-react": "workspace:^",
"@backstage/plugin-kubernetes-common": "workspace:^",
"@backstage/plugin-kubernetes-react": "workspace:^",
"@backstage/plugin-permission-react": "workspace:^",
"@kubernetes-models/apimachinery": "^2.0.0",
"@kubernetes-models/base": "^5.0.0",
"@material-ui/core": "^4.12.2",
@@ -0,0 +1,18 @@
## API Report File for "@backstage/plugin-kubernetes-cluster"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
// @alpha (undocumented)
export const kubernetesClusterTranslationRef: TranslationRef<
'kubernetes-cluster',
{
readonly 'kubernetesClusterContentPage.permissionAlert.message': "To view Kubernetes objects, contact your portal administrator to give you the 'kubernetes.clusters.read' permission.";
readonly 'kubernetesClusterContentPage.permissionAlert.title': 'Permission required';
}
>;
// (No @packageDocumentation comment for this package)
```
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2025 The Backstage Authors
*
* 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.
*/
export { kubernetesClusterTranslationRef } from './translation';
@@ -24,6 +24,10 @@ import {
useKubernetesClusterError,
} from '../KubernetesClusterErrorContext/KubernetesClusterErrorContext';
import { WarningPanel } from '@backstage/core-components';
import { kubernetesClustersReadPermission } from '@backstage/plugin-kubernetes-common';
import { RequirePermission } from '@backstage/plugin-permission-react';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { kubernetesClusterTranslationRef } from '../../translation';
const ContentGrid = () => {
const { error } = useKubernetesClusterError();
@@ -57,9 +61,21 @@ const ContentGrid = () => {
* @public
*/
export const KubernetesClusterContent = () => {
const { t } = useTranslationRef(kubernetesClusterTranslationRef);
return (
<KubernetesClusterErrorProvider>
<ContentGrid />
</KubernetesClusterErrorProvider>
<RequirePermission
permission={kubernetesClustersReadPermission}
errorPage={
<WarningPanel
title={t('kubernetesClusterContentPage.permissionAlert.title')}
message={t('kubernetesClusterContentPage.permissionAlert.message')}
/>
}
>
<KubernetesClusterErrorProvider>
<ContentGrid />
</KubernetesClusterErrorProvider>
</RequirePermission>
);
};
@@ -0,0 +1,30 @@
/*
* Copyright 2025 The Backstage Authors
*
* 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.
*/
import { createTranslationRef } from '@backstage/core-plugin-api/alpha';
/** @alpha */
export const kubernetesClusterTranslationRef = createTranslationRef({
id: 'kubernetes-cluster',
messages: {
kubernetesClusterContentPage: {
permissionAlert: {
title: 'Permission required',
message:
"To view Kubernetes objects, contact your portal administrator to give you the 'kubernetes.clusters.read' permission.",
},
},
},
});
+6
View File
@@ -317,6 +317,9 @@ export interface JobsFetchResponse {
type: 'jobs';
}
// @public
export const kubernetesClustersReadPermission: BasicPermission;
// @public (undocumented)
export type KubernetesErrorTypes =
| 'BAD_REQUEST'
@@ -347,6 +350,9 @@ export interface KubernetesRequestBody {
entity: Entity;
}
// @public
export const kubernetesResourcesReadPermission: BasicPermission;
// @public (undocumented)
export interface LimitRangeFetchResponse {
// (undocumented)
+2
View File
@@ -25,6 +25,8 @@ export * from './catalog-entity-constants';
export * from './certificate-authority-constants';
export {
kubernetesProxyPermission,
kubernetesClustersReadPermission,
kubernetesResourcesReadPermission,
kubernetesPermissions,
} from './permissions';
export * from './error-detection';
+25 -1
View File
@@ -24,8 +24,32 @@ export const kubernetesProxyPermission = createPermission({
attributes: {},
});
/** This permission is used to check access to the /resources and /services/:serviceId endpoints
* @public
*/
export const kubernetesResourcesReadPermission = createPermission({
name: 'kubernetes.resources.read',
attributes: {
action: 'read',
},
});
/** This permission is used to check access to the /clusters endpoint
* @public
*/
export const kubernetesClustersReadPermission = createPermission({
name: 'kubernetes.clusters.read',
attributes: {
action: 'read',
},
});
/**
* List of all Kubernetes permissions.
* @public
*/
export const kubernetesPermissions = [kubernetesProxyPermission];
export const kubernetesPermissions = [
kubernetesProxyPermission,
kubernetesResourcesReadPermission,
kubernetesClustersReadPermission,
];
+22 -6
View File
@@ -36,7 +36,8 @@ import fixture4 from '../src/__fixtures__/2-cronjobs.json';
import fixture5 from '../src/__fixtures__/1-rollouts.json';
import fixture6 from '../src/__fixtures__/3-ingresses.json';
import fixture7 from '../src/__fixtures__/2-statefulsets.json';
import { TestApiProvider } from '@backstage/test-utils';
import { mockApis, TestApiProvider } from '@backstage/test-utils';
import { permissionApiRef } from '@backstage/plugin-permission-react';
import { StructuredMetadataTable } from '@backstage/core-components';
const mockEntity: Entity = {
@@ -149,7 +150,10 @@ createDevApp()
title: 'Fixture 1',
element: (
<TestApiProvider
apis={[[kubernetesApiRef, new MockKubernetesClient(fixture1)]]}
apis={[
[kubernetesApiRef, new MockKubernetesClient(fixture1)],
[permissionApiRef, mockApis.permission()],
]}
>
<EntityProvider entity={mockEntity}>
<EntityKubernetesContent />
@@ -162,7 +166,10 @@ createDevApp()
title: 'Fixture 2',
element: (
<TestApiProvider
apis={[[kubernetesApiRef, new MockKubernetesClient(fixture2)]]}
apis={[
[kubernetesApiRef, new MockKubernetesClient(fixture2)],
[permissionApiRef, mockApis.permission()],
]}
>
<EntityProvider entity={mockEntity}>
<EntityKubernetesContent />
@@ -175,7 +182,10 @@ createDevApp()
title: 'Fixture 3',
element: (
<TestApiProvider
apis={[[kubernetesApiRef, new MockKubernetesClient(fixture3)]]}
apis={[
[kubernetesApiRef, new MockKubernetesClient(fixture3)],
[permissionApiRef, mockApis.permission()],
]}
>
<EntityProvider entity={mockEntity}>
<EntityKubernetesContent />
@@ -188,7 +198,10 @@ createDevApp()
title: 'Fixture 4',
element: (
<TestApiProvider
apis={[[kubernetesApiRef, new MockKubernetesClient(fixture4)]]}
apis={[
[kubernetesApiRef, new MockKubernetesClient(fixture4)],
[permissionApiRef, mockApis.permission()],
]}
>
<EntityProvider entity={mockEntity}>
<EntityKubernetesContent />
@@ -201,7 +214,10 @@ createDevApp()
title: 'Fixture 5',
element: (
<TestApiProvider
apis={[[kubernetesApiRef, new MockKubernetesClient(fixture5)]]}
apis={[
[kubernetesApiRef, new MockKubernetesClient(fixture5)],
[permissionApiRef, mockApis.permission()],
]}
>
<EntityProvider entity={mockEntity}>
<EntityKubernetesContent />
+1
View File
@@ -66,6 +66,7 @@
"@backstage/plugin-catalog-react": "workspace:^",
"@backstage/plugin-kubernetes-common": "workspace:^",
"@backstage/plugin-kubernetes-react": "workspace:^",
"@backstage/plugin-permission-react": "workspace:^",
"@kubernetes-models/apimachinery": "^2.0.0",
"@kubernetes-models/base": "^5.0.0",
"@kubernetes/client-node": "1.0.0-rc7",
+10
View File
@@ -13,6 +13,7 @@ import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
// @public (undocumented)
const _default: FrontendPlugin<
@@ -162,5 +163,14 @@ const _default: FrontendPlugin<
>;
export default _default;
// @alpha (undocumented)
export const kubernetesTranslationRef: TranslationRef<
'kubernetes',
{
readonly 'kubernetesContentPage.permissionAlert.message': "To view Kubernetes objects, contact your portal administrator to give you the 'kubernetes.clusters.read' and 'kubernetes.resources.read' permission.";
readonly 'kubernetesContentPage.permissionAlert.title': 'Permission required';
}
>;
// (No @packageDocumentation comment for this package)
```
@@ -16,11 +16,16 @@
import React from 'react';
import { screen } from '@testing-library/react';
import { renderInTestApp } from '@backstage/test-utils';
import {
mockApis,
renderInTestApp,
TestApiProvider,
} from '@backstage/test-utils';
import { KubernetesContent } from './KubernetesContent';
import { useKubernetesObjects } from '@backstage/plugin-kubernetes-react';
import * as oneDeployment from './__fixtures__/1-deployments.json';
import * as twoDeployments from './__fixtures__/2-deployments.json';
import { permissionApiRef } from '@backstage/plugin-permission-react';
jest.mock('@backstage/plugin-kubernetes-react', () => ({
...jest.requireActual('@backstage/plugin-kubernetes-react'),
@@ -39,15 +44,17 @@ describe('KubernetesContent', () => {
error: undefined,
});
await renderInTestApp(
<KubernetesContent
entity={
{
metadata: {
name: 'some-entity',
},
} as any
}
/>,
<TestApiProvider apis={[[permissionApiRef, mockApis.permission()]]}>
<KubernetesContent
entity={
{
metadata: {
name: 'some-entity',
},
} as any
}
/>
</TestApiProvider>,
);
expect(screen.getByText('Your Clusters')).toBeInTheDocument();
// TODO add a prompt for the user to configure their clusters
@@ -81,15 +88,17 @@ describe('KubernetesContent', () => {
error: undefined,
});
await renderInTestApp(
<KubernetesContent
entity={
{
metadata: {
name: 'some-entity',
},
} as any
}
/>,
<TestApiProvider apis={[[permissionApiRef, mockApis.permission()]]}>
<KubernetesContent
entity={
{
metadata: {
name: 'some-entity',
},
} as any
}
/>
</TestApiProvider>,
);
expect(screen.getByText('cluster-1')).toBeInTheDocument();
@@ -145,15 +154,17 @@ describe('KubernetesContent', () => {
error: undefined,
});
await renderInTestApp(
<KubernetesContent
entity={
{
metadata: {
name: 'some-entity',
},
} as any
}
/>,
<TestApiProvider apis={[[permissionApiRef, mockApis.permission()]]}>
<KubernetesContent
entity={
{
metadata: {
name: 'some-entity',
},
} as any
}
/>
</TestApiProvider>,
);
expect(screen.getAllByText('Cluster')).toHaveLength(2);
expect(screen.getByText('cluster-a')).toBeInTheDocument();
+81 -76
View File
@@ -35,6 +35,7 @@ import {
Page,
Progress,
} from '@backstage/core-components';
import { RequireKubernetesPermissions } from './RequireKubernetesPermissions';
type KubernetesContentProps = {
entity: Entity;
@@ -62,89 +63,93 @@ export const KubernetesContent = ({
: new Map<string, DetectedError[]>();
return (
<DetectedErrorsContext.Provider value={[...detectedErrors.values()].flat()}>
<Page themeId="tool">
<Content>
{kubernetesObjects === undefined && error === undefined && (
<Progress />
)}
<Page themeId="tool">
<Content>
<RequireKubernetesPermissions>
<DetectedErrorsContext.Provider
value={[...detectedErrors.values()].flat()}
>
{kubernetesObjects === undefined && error === undefined && (
<Progress />
)}
{/* errors retrieved from the kubernetes clusters */}
{clustersWithErrors.length > 0 && (
<Grid container spacing={3} direction="column">
<Grid item>
<ErrorPanel
entityName={entity.metadata.name}
clustersWithErrors={clustersWithErrors}
/>
{/* errors retrieved from the kubernetes clusters */}
{clustersWithErrors.length > 0 && (
<Grid container spacing={3} direction="column">
<Grid item>
<ErrorPanel
entityName={entity.metadata.name}
clustersWithErrors={clustersWithErrors}
/>
</Grid>
</Grid>
</Grid>
)}
)}
{/* other errors */}
{error !== undefined && (
<Grid container spacing={3} direction="column">
<Grid item>
<ErrorPanel
entityName={entity.metadata.name}
errorMessage={error}
/>
{/* other errors */}
{error !== undefined && (
<Grid container spacing={3} direction="column">
<Grid item>
<ErrorPanel
entityName={entity.metadata.name}
errorMessage={error}
/>
</Grid>
</Grid>
</Grid>
)}
)}
{kubernetesObjects && (
<Grid container spacing={3} direction="column">
<Grid item>
<ErrorReporting
detectedErrors={detectedErrors}
clusters={clusters}
/>
</Grid>
<Grid item>
<Typography variant="h3">Your Clusters</Typography>
</Grid>
<Grid item container>
{kubernetesObjects?.items.length <= 0 && (
<Grid
container
justifyContent="space-around"
direction="row"
alignItems="center"
spacing={2}
>
<Grid item xs={8}>
<EmptyState
missing="data"
title="No Kubernetes resources"
description={`No resources on any known clusters for ${entity.metadata.name}`}
/>
</Grid>
</Grid>
)}
{kubernetesObjects?.items.length > 0 &&
kubernetesObjects?.items.map((item, i) => {
const podsWithErrors = new Set<string>(
detectedErrors
.get(item.cluster.name)
?.filter(de => de.sourceRef.kind === 'Pod')
.map(de => de.sourceRef.name),
);
return (
<Grid item key={i} xs={12}>
<Cluster
clusterObjects={item}
podsWithErrors={podsWithErrors}
{kubernetesObjects && (
<Grid container spacing={3} direction="column">
<Grid item>
<ErrorReporting
detectedErrors={detectedErrors}
clusters={clusters}
/>
</Grid>
<Grid item>
<Typography variant="h3">Your Clusters</Typography>
</Grid>
<Grid item container>
{kubernetesObjects?.items.length <= 0 && (
<Grid
container
justifyContent="space-around"
direction="row"
alignItems="center"
spacing={2}
>
<Grid item xs={8}>
<EmptyState
missing="data"
title="No Kubernetes resources"
description={`No resources on any known clusters for ${entity.metadata.name}`}
/>
</Grid>
);
})}
</Grid>
)}
{kubernetesObjects?.items.length > 0 &&
kubernetesObjects?.items.map((item, i) => {
const podsWithErrors = new Set<string>(
detectedErrors
.get(item.cluster.name)
?.filter(de => de.sourceRef.kind === 'Pod')
.map(de => de.sourceRef.name),
);
return (
<Grid item key={i} xs={12}>
<Cluster
clusterObjects={item}
podsWithErrors={podsWithErrors}
/>
</Grid>
);
})}
</Grid>
</Grid>
</Grid>
)}
</Content>
</Page>
</DetectedErrorsContext.Provider>
)}
</DetectedErrorsContext.Provider>
</RequireKubernetesPermissions>
</Content>
</Page>
);
};
@@ -0,0 +1,61 @@
/*
* Copyright 2025 The Backstage Authors
*
* 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.
*/
import React, { ReactNode } from 'react';
import {
kubernetesClustersReadPermission,
kubernetesResourcesReadPermission,
} from '@backstage/plugin-kubernetes-common';
import { usePermission } from '@backstage/plugin-permission-react';
import { WarningPanel } from '@backstage/core-components';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { kubernetesTranslationRef } from './alpha/translation';
export type RequireKubernetesPermissionProps = {
children: ReactNode;
};
export function RequireKubernetesPermissions(
props: RequireKubernetesPermissionProps,
): JSX.Element | null {
const kubernetesClustersPermissionResult = usePermission({
permission: kubernetesClustersReadPermission,
});
const kubernetesResourcesPermissionResult = usePermission({
permission: kubernetesResourcesReadPermission,
});
const { t } = useTranslationRef(kubernetesTranslationRef);
if (
kubernetesClustersPermissionResult.loading ||
kubernetesResourcesPermissionResult.loading
) {
return null;
}
if (
kubernetesClustersPermissionResult.allowed &&
kubernetesResourcesPermissionResult.allowed
) {
return <>{props.children}</>;
}
return (
<WarningPanel
title={t('kubernetesContentPage.permissionAlert.title')}
message={t('kubernetesContentPage.permissionAlert.message')}
/>
);
}
+1 -1
View File
@@ -13,5 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { kubernetesTranslationRef } from './translation';
export { default } from './plugin';
@@ -0,0 +1,30 @@
/*
* Copyright 2025 The Backstage Authors
*
* 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.
*/
import { createTranslationRef } from '@backstage/core-plugin-api/alpha';
/** @alpha */
export const kubernetesTranslationRef = createTranslationRef({
id: 'kubernetes',
messages: {
kubernetesContentPage: {
permissionAlert: {
title: 'Permission required',
message:
"To view Kubernetes objects, contact your portal administrator to give you the 'kubernetes.clusters.read' and 'kubernetes.resources.read' permission.",
},
},
},
});
+2
View File
@@ -6875,6 +6875,7 @@ __metadata:
"@backstage/plugin-catalog-react": "workspace:^"
"@backstage/plugin-kubernetes-common": "workspace:^"
"@backstage/plugin-kubernetes-react": "workspace:^"
"@backstage/plugin-permission-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@kubernetes-models/apimachinery": ^2.0.0
"@kubernetes-models/base": ^5.0.0
@@ -7003,6 +7004,7 @@ __metadata:
"@backstage/plugin-catalog-react": "workspace:^"
"@backstage/plugin-kubernetes-common": "workspace:^"
"@backstage/plugin-kubernetes-react": "workspace:^"
"@backstage/plugin-permission-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@kubernetes-models/apimachinery": ^2.0.0
"@kubernetes-models/base": ^5.0.0