diff --git a/.changeset/real-foxes-film.md b/.changeset/real-foxes-film.md new file mode 100644 index 0000000000..22f1ecead2 --- /dev/null +++ b/.changeset/real-foxes-film.md @@ -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. diff --git a/docs/features/kubernetes/configuration.md b/docs/features/kubernetes/configuration.md index 1a921103b1..c323d5118a 100644 --- a/docs/features/kubernetes/configuration.md +++ b/docs/features/kubernetes/configuration.md @@ -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.', + }, + }), ] }, ... diff --git a/docs/features/kubernetes/permissions.md b/docs/features/kubernetes/permissions.md new file mode 100644 index 0000000000..5f1475d4b3 --- /dev/null +++ b/docs/features/kubernetes/permissions.md @@ -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` | diff --git a/plugins/kubernetes-backend/src/auth/requirePermission.ts b/plugins/kubernetes-backend/src/auth/requirePermission.ts new file mode 100644 index 0000000000..9934ed653d --- /dev/null +++ b/plugins/kubernetes-backend/src/auth/requirePermission.ts @@ -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'); +} diff --git a/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts b/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts index 7ace17e805..462312a404 100644 --- a/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts +++ b/plugins/kubernetes-backend/src/routes/resourceRoutes.test.ts @@ -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 = + 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) diff --git a/plugins/kubernetes-backend/src/routes/resourcesRoutes.ts b/plugins/kubernetes-backend/src/routes/resourcesRoutes.ts index 838e039d27..d500d3cb8b 100644 --- a/plugins/kubernetes-backend/src/routes/resourcesRoutes.ts +++ b/plugins/kubernetes-backend/src/routes/resourcesRoutes.ts @@ -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) => { 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) { diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts index b38bf9121a..3046232bad 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.test.ts @@ -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: [], }); diff --git a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts index 6a4e9cc296..2d2c592f53 100644 --- a/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts +++ b/plugins/kubernetes-backend/src/service/KubernetesBuilder.ts @@ -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; diff --git a/plugins/kubernetes-cluster/package.json b/plugins/kubernetes-cluster/package.json index 6ef4d85356..445850a681 100644 --- a/plugins/kubernetes-cluster/package.json +++ b/plugins/kubernetes-cluster/package.json @@ -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", diff --git a/plugins/kubernetes-cluster/report-alpha.api.md b/plugins/kubernetes-cluster/report-alpha.api.md new file mode 100644 index 0000000000..13314d6c83 --- /dev/null +++ b/plugins/kubernetes-cluster/report-alpha.api.md @@ -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) +``` diff --git a/plugins/kubernetes-cluster/src/alpha.ts b/plugins/kubernetes-cluster/src/alpha.ts new file mode 100644 index 0000000000..e49b7ac9c9 --- /dev/null +++ b/plugins/kubernetes-cluster/src/alpha.ts @@ -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'; diff --git a/plugins/kubernetes-cluster/src/components/KubernetesClusterContent/KubernetesClusterContent.tsx b/plugins/kubernetes-cluster/src/components/KubernetesClusterContent/KubernetesClusterContent.tsx index c46ac4c8e2..2a824d329a 100644 --- a/plugins/kubernetes-cluster/src/components/KubernetesClusterContent/KubernetesClusterContent.tsx +++ b/plugins/kubernetes-cluster/src/components/KubernetesClusterContent/KubernetesClusterContent.tsx @@ -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 ( - - - + + } + > + + + + ); }; diff --git a/plugins/kubernetes-cluster/src/translation.ts b/plugins/kubernetes-cluster/src/translation.ts new file mode 100644 index 0000000000..ba99c6744e --- /dev/null +++ b/plugins/kubernetes-cluster/src/translation.ts @@ -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.", + }, + }, + }, +}); diff --git a/plugins/kubernetes-common/report.api.md b/plugins/kubernetes-common/report.api.md index 8cdac7ed45..cc99e5063d 100644 --- a/plugins/kubernetes-common/report.api.md +++ b/plugins/kubernetes-common/report.api.md @@ -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) diff --git a/plugins/kubernetes-common/src/index.ts b/plugins/kubernetes-common/src/index.ts index faaaaab92c..8378ac8199 100644 --- a/plugins/kubernetes-common/src/index.ts +++ b/plugins/kubernetes-common/src/index.ts @@ -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'; diff --git a/plugins/kubernetes-common/src/permissions.ts b/plugins/kubernetes-common/src/permissions.ts index d3ccbe8171..718251567c 100644 --- a/plugins/kubernetes-common/src/permissions.ts +++ b/plugins/kubernetes-common/src/permissions.ts @@ -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, +]; diff --git a/plugins/kubernetes/dev/index.tsx b/plugins/kubernetes/dev/index.tsx index 91b97cbfc5..e5581ac2ef 100644 --- a/plugins/kubernetes/dev/index.tsx +++ b/plugins/kubernetes/dev/index.tsx @@ -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: ( @@ -162,7 +166,10 @@ createDevApp() title: 'Fixture 2', element: ( @@ -175,7 +182,10 @@ createDevApp() title: 'Fixture 3', element: ( @@ -188,7 +198,10 @@ createDevApp() title: 'Fixture 4', element: ( @@ -201,7 +214,10 @@ createDevApp() title: 'Fixture 5', element: ( diff --git a/plugins/kubernetes/package.json b/plugins/kubernetes/package.json index cf99896620..c733cc00b3 100644 --- a/plugins/kubernetes/package.json +++ b/plugins/kubernetes/package.json @@ -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", diff --git a/plugins/kubernetes/report-alpha.api.md b/plugins/kubernetes/report-alpha.api.md index 4f779df258..19f0554291 100644 --- a/plugins/kubernetes/report-alpha.api.md +++ b/plugins/kubernetes/report-alpha.api.md @@ -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) ``` diff --git a/plugins/kubernetes/src/KubernetesContent.test.tsx b/plugins/kubernetes/src/KubernetesContent.test.tsx index 7c21c6f5a7..daeb184275 100644 --- a/plugins/kubernetes/src/KubernetesContent.test.tsx +++ b/plugins/kubernetes/src/KubernetesContent.test.tsx @@ -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( - , + + + , ); 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( - , + + + , ); expect(screen.getByText('cluster-1')).toBeInTheDocument(); @@ -145,15 +154,17 @@ describe('KubernetesContent', () => { error: undefined, }); await renderInTestApp( - , + + + , ); expect(screen.getAllByText('Cluster')).toHaveLength(2); expect(screen.getByText('cluster-a')).toBeInTheDocument(); diff --git a/plugins/kubernetes/src/KubernetesContent.tsx b/plugins/kubernetes/src/KubernetesContent.tsx index acd1ecc521..f980e87f0b 100644 --- a/plugins/kubernetes/src/KubernetesContent.tsx +++ b/plugins/kubernetes/src/KubernetesContent.tsx @@ -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(); return ( - - - - {kubernetesObjects === undefined && error === undefined && ( - - )} + + + + + {kubernetesObjects === undefined && error === undefined && ( + + )} - {/* errors retrieved from the kubernetes clusters */} - {clustersWithErrors.length > 0 && ( - - - + {/* errors retrieved from the kubernetes clusters */} + {clustersWithErrors.length > 0 && ( + + + + - - )} + )} - {/* other errors */} - {error !== undefined && ( - - - + {/* other errors */} + {error !== undefined && ( + + + + - - )} + )} - {kubernetesObjects && ( - - - - - - Your Clusters - - - {kubernetesObjects?.items.length <= 0 && ( - - - - - - )} - {kubernetesObjects?.items.length > 0 && - kubernetesObjects?.items.map((item, i) => { - const podsWithErrors = new Set( - detectedErrors - .get(item.cluster.name) - ?.filter(de => de.sourceRef.kind === 'Pod') - .map(de => de.sourceRef.name), - ); - - return ( - - + + + + + Your Clusters + + + {kubernetesObjects?.items.length <= 0 && ( + + + - ); - })} + + )} + {kubernetesObjects?.items.length > 0 && + kubernetesObjects?.items.map((item, i) => { + const podsWithErrors = new Set( + detectedErrors + .get(item.cluster.name) + ?.filter(de => de.sourceRef.kind === 'Pod') + .map(de => de.sourceRef.name), + ); + + return ( + + + + ); + })} + - - )} - - - + )} + + + + ); }; diff --git a/plugins/kubernetes/src/RequireKubernetesPermissions.tsx b/plugins/kubernetes/src/RequireKubernetesPermissions.tsx new file mode 100644 index 0000000000..efc83e8ef8 --- /dev/null +++ b/plugins/kubernetes/src/RequireKubernetesPermissions.tsx @@ -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 ( + + ); +} diff --git a/plugins/kubernetes/src/alpha/index.ts b/plugins/kubernetes/src/alpha/index.ts index 2f137f09ee..23888f968f 100644 --- a/plugins/kubernetes/src/alpha/index.ts +++ b/plugins/kubernetes/src/alpha/index.ts @@ -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'; diff --git a/plugins/kubernetes/src/alpha/translation.ts b/plugins/kubernetes/src/alpha/translation.ts new file mode 100644 index 0000000000..7f9704af84 --- /dev/null +++ b/plugins/kubernetes/src/alpha/translation.ts @@ -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.", + }, + }, + }, +}); diff --git a/yarn.lock b/yarn.lock index 35366c00b0..7c163ea686 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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