Merge pull request #27499 from dzemanov/add-kubernetes-permission
feat(kubernetes): introduce kubernetes permission
This commit is contained in:
@@ -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.
|
||||
@@ -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.',
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
...
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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';
|
||||
+19
-3
@@ -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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user