diff --git a/plugins/ocm-backend/dev/index.ts b/plugins/ocm-backend/dev/index.ts index cb7c0d4449..2a0de9cb2f 100644 --- a/plugins/ocm-backend/dev/index.ts +++ b/plugins/ocm-backend/dev/index.ts @@ -109,10 +109,24 @@ export async function startStandaloneServer( }, }, }); + const createEnv = makeCreateEnv(config); const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); + const discovery = HostDiscovery.fromConfig(config); + const tokenManager = ServerTokenManager.fromConfig(config, { + logger, + }); + const permissions = ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager, + }); - const ocmRouterOptions: RouterOptions = { logger, config }; + const ocmRouterOptions: RouterOptions = { + logger, + config, + permissions, + discovery, + }; const service = createServiceBuilder(module) .setPort(options.port) .enableCors({ diff --git a/plugins/ocm-backend/package.json b/plugins/ocm-backend/package.json index 29fd556f20..55f69222cc 100644 --- a/plugins/ocm-backend/package.json +++ b/plugins/ocm-backend/package.json @@ -42,15 +42,18 @@ "configSchema": "config.d.ts", "dependencies": { "@backstage/backend-common": "^0.21.6", + "@backstage/backend-dynamic-feature-service": "^0.2.8", "@backstage/backend-plugin-api": "^0.6.16", "@backstage/backend-tasks": "^0.5.21", "@backstage/catalog-client": "^1.6.3", "@backstage/catalog-model": "^1.4.5", "@backstage/config": "^1.2.0", + "@backstage/errors": "^1.2.4", "@backstage/plugin-catalog-node": "^1.11.0", "@backstage/plugin-kubernetes-common": "^0.7.5", + "@backstage/plugin-permission-common": "0.7.13", + "@backstage/plugin-permission-node": "0.7.27", "@janus-idp/backstage-plugin-ocm-common": "2.3.0", - "@backstage/backend-dynamic-feature-service": "^0.2.8", "@kubernetes/client-node": "^0.20.0", "express": "^4.18.2", "express-promise-router": "^4.1.1", @@ -62,8 +65,6 @@ "@janus-idp/cli": "1.8.1", "@backstage/plugin-auth-node": "0.4.11", "@backstage/plugin-catalog-backend": "1.21.0", - "@backstage/plugin-permission-common": "0.7.13", - "@backstage/plugin-permission-node": "0.7.27", "@types/express": "4.17.20", "@types/supertest": "2.0.16", "msw": "1.3.2", diff --git a/plugins/ocm-backend/src/service/router.test.ts b/plugins/ocm-backend/src/service/router.test.ts index a84f20b54a..aeb1ad6234 100644 --- a/plugins/ocm-backend/src/service/router.test.ts +++ b/plugins/ocm-backend/src/service/router.test.ts @@ -1,4 +1,5 @@ import { ConfigReader } from '@backstage/config'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; import express from 'express'; import { setupServer } from 'msw/node'; @@ -26,11 +27,33 @@ const logger = createLogger({ transports: [new transports.Console({ silent: true })], }); +const mockedAuthorize = jest.fn().mockImplementation(async () => [ + { + result: AuthorizeResult.ALLOW, + }, +]); + +const mockedAuthorizeConditional = jest.fn().mockImplementation(async () => [ + { + result: AuthorizeResult.ALLOW, + }, +]); + +const mockPermissionEvaluator = { + authorize: mockedAuthorize, + authorizeConditional: mockedAuthorizeConditional, +}; + +const mockDiscovery = { + getBaseUrl: jest.fn(), + getExternalBaseUrl: jest.fn(), +}; + describe('createRouter', () => { let app: express.Express; beforeAll(async () => { - jest.resetAllMocks(); + jest.clearAllMocks(); const router = await createRouter({ logger: logger, config: new ConfigReader({ @@ -46,11 +69,28 @@ describe('createRouter', () => { }, }, }), + permissions: mockPermissionEvaluator, + discovery: mockDiscovery, }); app = express().use(router); }); describe('GET /status', () => { + it('should deny access when getting all clusters', async () => { + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + + const result = await request(app).get('/status'); + + expect(mockedAuthorize).toHaveBeenCalled(); + + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: 'Unauthorized', + }); + }); it('should get all clusters', async () => { const result = await request(app).get('/status'); @@ -177,6 +217,21 @@ describe('createRouter', () => { }); describe('GET /status/:hubName/:clusterName', () => { + it('should deny access when getting all clusters', async () => { + mockedAuthorize.mockImplementationOnce(async () => [ + { result: AuthorizeResult.DENY }, + ]); + + const result = await request(app).get('/status/foo/cluster1'); + + expect(mockedAuthorize).toHaveBeenCalled(); + + expect(result.statusCode).toBe(403); + expect(result.body.error).toEqual({ + name: 'NotAllowedError', + message: 'Unauthorized', + }); + }); it('should correctly parse a cluster', async () => { const result = await request(app).get('/status/foo/cluster1'); diff --git a/plugins/ocm-backend/src/service/router.ts b/plugins/ocm-backend/src/service/router.ts index d8cdf88693..b8f67edc28 100644 --- a/plugins/ocm-backend/src/service/router.ts +++ b/plugins/ocm-backend/src/service/router.ts @@ -14,20 +14,37 @@ * limitations under the License. */ -import { errorHandler, loggerToWinstonLogger } from '@backstage/backend-common'; +import { + createLegacyAuthAdapters, + errorHandler, + loggerToWinstonLogger, + PluginEndpointDiscovery, +} from '@backstage/backend-common'; import { coreServices, createBackendPlugin, + HttpAuthService, + PermissionsService, } from '@backstage/backend-plugin-api'; import { Config } from '@backstage/config'; +import { NotAllowedError } from '@backstage/errors'; +import { + AuthorizeResult, + BasicPermission, +} from '@backstage/plugin-permission-common'; +import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; import express from 'express'; import Router from 'express-promise-router'; +import { Request } from 'express-serve-static-core'; import { Logger } from 'winston'; import { Cluster, ClusterOverview, + ocmClusterReadPermission, + ocmEntityPermissions, + ocmEntityReadPermission, } from '@janus-idp/backstage-plugin-ocm-common'; import { readOcmConfigs } from '../helpers/config'; @@ -52,11 +69,25 @@ import { ManagedClusterInfo } from '../types'; export interface RouterOptions { logger: Logger; config: Config; + discovery: PluginEndpointDiscovery; + permissions: PermissionsService; + httpAuth?: HttpAuthService; } -const buildRouter = (config: Config, logger: Logger) => { +const buildRouter = ( + config: Config, + logger: Logger, + httpAuth: HttpAuthService, + permissions: PermissionsService, +) => { const router = Router(); + + const permissionsIntegrationRouter = createPermissionIntegrationRouter({ + permissions: ocmEntityPermissions, + }); + router.use(express.json()); + router.use(permissionsIntegrationRouter); const clients = Object.fromEntries( readOcmConfigs(config).map(provider => [ @@ -68,43 +99,63 @@ const buildRouter = (config: Config, logger: Logger) => { ]), ); - router.get( - '/status/:providerId/:clusterName', - async ({ params: { clusterName, providerId } }, response) => { - logger.debug( - `Incoming status request for ${clusterName} cluster on ${providerId} hub`, - ); - - if (!clients.hasOwnProperty(providerId)) { - throw Object.assign(new Error('Hub not found'), { - statusCode: 404, - name: 'HubNotFound', - }); - } - - const normalizedClusterName = translateResourceToOCM( - clusterName, - clients[providerId].hubResourceName, - ); - - const mc = await getManagedCluster( - clients[providerId].client, - normalizedClusterName, - ); - const mci = await getManagedClusterInfo( - clients[providerId].client, - normalizedClusterName, - ); - - response.send({ - name: clusterName, - ...parseManagedCluster(mc), - ...parseUpdateInfo(mci), - } as Cluster); - }, - ); + const authorize = async (request: Request, permission: BasicPermission) => { + const decision = ( + await permissions.authorize([{ permission: permission }], { + credentials: await httpAuth.credentials(request), + }) + )[0]; + + return decision; + }; + + router.get('/status/:providerId/:clusterName', async (request, response) => { + const decision = await authorize(request, ocmEntityReadPermission); + + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError('Unauthorized'); + } + + const { clusterName, providerId } = request.params; + logger.debug( + `Incoming status request for ${clusterName} cluster on ${providerId} hub`, + ); + + if (!clients.hasOwnProperty(providerId)) { + throw Object.assign(new Error('Hub not found'), { + statusCode: 404, + name: 'HubNotFound', + }); + } + + const normalizedClusterName = translateResourceToOCM( + clusterName, + clients[providerId].hubResourceName, + ); + + const mc = await getManagedCluster( + clients[providerId].client, + normalizedClusterName, + ); + const mci = await getManagedClusterInfo( + clients[providerId].client, + normalizedClusterName, + ); + + response.send({ + name: clusterName, + ...parseManagedCluster(mc), + ...parseUpdateInfo(mci), + } as Cluster); + }); + + router.get('/status', async (request, response) => { + const decision = await authorize(request, ocmClusterReadPermission); + + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError('Unauthorized'); + } - router.get('/status', async (_, response) => { logger.debug(`Incoming status request for all clusters`); const allClusters = await Promise.all( @@ -144,8 +195,11 @@ export async function createRouter( ): Promise { const { logger } = options; const { config } = options; + const { permissions } = options; + + const { httpAuth } = createLegacyAuthAdapters(options); - return buildRouter(config, logger); + return buildRouter(config, logger, httpAuth, permissions); } export const ocmPlugin = createBackendPlugin({ @@ -156,9 +210,18 @@ export const ocmPlugin = createBackendPlugin({ logger: coreServices.logger, config: coreServices.rootConfig, http: coreServices.httpRouter, + httpAuth: coreServices.httpAuth, + permissions: coreServices.permissions, }, - async init({ config, logger, http }) { - http.use(buildRouter(config, loggerToWinstonLogger(logger))); + async init({ config, logger, http, httpAuth, permissions }) { + http.use( + buildRouter( + config, + loggerToWinstonLogger(logger), + httpAuth, + permissions, + ), + ); }, }); }, diff --git a/plugins/ocm-common/package.json b/plugins/ocm-common/package.json index 1097a17a15..904721c356 100644 --- a/plugins/ocm-common/package.json +++ b/plugins/ocm-common/package.json @@ -35,5 +35,8 @@ "plugin" ], "homepage": "https://janus-idp.io/", - "bugs": "https://github.com/janus-idp/backstage-plugins/issues" + "bugs": "https://github.com/janus-idp/backstage-plugins/issues", + "dependencies": { + "@backstage/plugin-permission-common": "^0.7.13" + } } diff --git a/plugins/ocm-common/src/index.ts b/plugins/ocm-common/src/index.ts index 5e0716b5e7..1054e1f6e7 100644 --- a/plugins/ocm-common/src/index.ts +++ b/plugins/ocm-common/src/index.ts @@ -1,60 +1,11 @@ -/***/ /** * Common functionalities for the Open Cluster Management plugin. * * @packageDocumentation */ -export type ClusterStatus = { - available: boolean; - reason?: string; -}; - -export type ClusterBase = { - name: string; -}; - -export type ClusterUpdate = { - available?: boolean; - version?: string; - url?: string; -}; - -export type ClusterNodesStatus = { - status: string; - type: string; -}; - -export type ClusterDetails = { - consoleUrl?: string; - kubernetesVersion?: string; - oauthUrl?: string; - openshiftId?: string; - openshiftVersion?: string; - platform?: string; - region?: string; - allocatableResources?: { - cpuCores?: number; - memorySize?: string; - numberOfPods?: number; - }; - availableResources?: { - cpuCores?: number; - memorySize?: string; - numberOfPods?: number; - }; - update?: ClusterUpdate; - status: ClusterStatus; -}; - -export type Cluster = ClusterBase & ClusterDetails; -export type ClusterOverview = ClusterBase & { - status: ClusterStatus; - update: ClusterUpdate; - platform: string; - openshiftVersion: string; - nodes: Array; -}; +export * from './types'; +export * from './permissions'; export const ANNOTATION_CLUSTER_ID = 'janus-idp.io/ocm-cluster-id'; export const ANNOTATION_PROVIDER_ID = 'janus-idp.io/ocm-provider-id'; diff --git a/plugins/ocm-common/src/permissions.ts b/plugins/ocm-common/src/permissions.ts new file mode 100644 index 0000000000..58b18469da --- /dev/null +++ b/plugins/ocm-common/src/permissions.ts @@ -0,0 +1,20 @@ +import { createPermission } from '@backstage/plugin-permission-common'; + +export const ocmClusterReadPermission = createPermission({ + name: 'ocm.cluster.read', + attributes: { + action: 'read', + }, +}); + +export const ocmEntityReadPermission = createPermission({ + name: 'ocm.entity.read', + attributes: { + action: 'read', + }, +}); + +export const ocmEntityPermissions = [ + ocmClusterReadPermission, + ocmEntityReadPermission, +]; diff --git a/plugins/ocm-common/src/types.ts b/plugins/ocm-common/src/types.ts new file mode 100644 index 0000000000..f2510f165a --- /dev/null +++ b/plugins/ocm-common/src/types.ts @@ -0,0 +1,50 @@ +export type ClusterStatus = { + available: boolean; + reason?: string; +}; + +export type ClusterBase = { + name: string; +}; + +export type ClusterUpdate = { + available?: boolean; + version?: string; + url?: string; +}; + +export type ClusterNodesStatus = { + status: string; + type: string; +}; + +export type ClusterDetails = { + consoleUrl?: string; + kubernetesVersion?: string; + oauthUrl?: string; + openshiftId?: string; + openshiftVersion?: string; + platform?: string; + region?: string; + allocatableResources?: { + cpuCores?: number; + memorySize?: string; + numberOfPods?: number; + }; + availableResources?: { + cpuCores?: number; + memorySize?: string; + numberOfPods?: number; + }; + update?: ClusterUpdate; + status: ClusterStatus; +}; + +export type Cluster = ClusterBase & ClusterDetails; +export type ClusterOverview = ClusterBase & { + status: ClusterStatus; + update: ClusterUpdate; + platform: string; + openshiftVersion: string; + nodes: Array; +}; diff --git a/plugins/ocm/README.md b/plugins/ocm/README.md index 6d1f2fc9d4..ebf3ea2766 100644 --- a/plugins/ocm/README.md +++ b/plugins/ocm/README.md @@ -155,6 +155,8 @@ For more information about the default owner configuration, see [upstream string return await createRouter({ logger: env.logger, config: env.config, + permissions: env.permissions, + discovery: env.discovery, }); } ``` diff --git a/plugins/ocm/package.json b/plugins/ocm/package.json index 8ec36b248c..fe4f6f6806 100644 --- a/plugins/ocm/package.json +++ b/plugins/ocm/package.json @@ -31,6 +31,7 @@ "@backstage/errors": "^1.2.4", "@backstage/plugin-catalog-react": "^1.11.2", "@backstage/plugin-home": "^0.7.2", + "@backstage/plugin-permission-react": "^0.4.22", "@backstage/plugin-search-react": "^1.7.9", "@backstage/theme": "^0.5.2", "@janus-idp/backstage-plugin-ocm-common": "2.3.0", diff --git a/plugins/ocm/src/components/ClusterAvailableResourcesCard/ClusterAvailableResourcesCard.test.tsx b/plugins/ocm/src/components/ClusterAvailableResourcesCard/ClusterAvailableResourcesCard.test.tsx index 8792cc9d3d..34b00e956e 100644 --- a/plugins/ocm/src/components/ClusterAvailableResourcesCard/ClusterAvailableResourcesCard.test.tsx +++ b/plugins/ocm/src/components/ClusterAvailableResourcesCard/ClusterAvailableResourcesCard.test.tsx @@ -11,6 +11,17 @@ jest.mock('../ClusterContext/', () => ({ useCluster: jest.fn().mockReturnValue({}), })); +jest.mock('@backstage/plugin-permission-react', () => ({ + RequirePermission: jest + .fn() + .mockImplementation(({ permission, children }) => ( +
+ {`${permission}`} + {children} +
+ )), +})); + describe('ClusterAvailableResourceCard', () => { afterAll(() => { jest.resetAllMocks(); diff --git a/plugins/ocm/src/components/ClusterAvailableResourcesCard/ClusterAvailableResourcesCard.tsx b/plugins/ocm/src/components/ClusterAvailableResourcesCard/ClusterAvailableResourcesCard.tsx index 9f8a1c573e..0366877418 100644 --- a/plugins/ocm/src/components/ClusterAvailableResourcesCard/ClusterAvailableResourcesCard.tsx +++ b/plugins/ocm/src/components/ClusterAvailableResourcesCard/ClusterAvailableResourcesCard.tsx @@ -1,5 +1,9 @@ import React from 'react'; +import { RequirePermission } from '@backstage/plugin-permission-react'; + +import { ocmEntityReadPermission } from '@janus-idp/backstage-plugin-ocm-common'; + import { useCluster } from '../ClusterContext'; import { TableCardFromData } from '../TableCardFromData'; @@ -20,10 +24,12 @@ export const ClusterAvailableResourceCard = (): any => { ['numberOfPods', 'Number of pods'], ]); return ( - + + + ); }; diff --git a/plugins/ocm/src/components/ClusterInfoCard/ClusterInfoCard.test.tsx b/plugins/ocm/src/components/ClusterInfoCard/ClusterInfoCard.test.tsx index 666857bd9a..eb4f3b38ab 100644 --- a/plugins/ocm/src/components/ClusterInfoCard/ClusterInfoCard.test.tsx +++ b/plugins/ocm/src/components/ClusterInfoCard/ClusterInfoCard.test.tsx @@ -10,6 +10,17 @@ jest.mock('../ClusterContext/', () => ({ useCluster: jest.fn().mockReturnValue({}), })); +jest.mock('@backstage/plugin-permission-react', () => ({ + RequirePermission: jest + .fn() + .mockImplementation(({ permission, children }) => ( +
+ {`${permission}`} + {children} +
+ )), +})); + jest.mock('../common', () => ({ Status: () => 'Ready', Update: () => '4.10.26', diff --git a/plugins/ocm/src/components/ClusterInfoCard/ClusterInfoCard.tsx b/plugins/ocm/src/components/ClusterInfoCard/ClusterInfoCard.tsx index fc438852d2..1c8d1ed023 100644 --- a/plugins/ocm/src/components/ClusterInfoCard/ClusterInfoCard.tsx +++ b/plugins/ocm/src/components/ClusterInfoCard/ClusterInfoCard.tsx @@ -1,5 +1,9 @@ import React from 'react'; +import { RequirePermission } from '@backstage/plugin-permission-react'; + +import { ocmEntityReadPermission } from '@janus-idp/backstage-plugin-ocm-common'; + import { useCluster } from '../ClusterContext'; import { Status, Update } from '../common'; import { TableCardFromData } from '../TableCardFromData'; @@ -24,6 +28,8 @@ export const ClusterInfoCard = () => { ['platform', 'Platform'], ]); return ( - + + + ); }; diff --git a/plugins/ocm/src/components/ClusterStatusPage/ClusterStatusPage.tsx b/plugins/ocm/src/components/ClusterStatusPage/ClusterStatusPage.tsx index 788c83cf79..78cfc44ccf 100644 --- a/plugins/ocm/src/components/ClusterStatusPage/ClusterStatusPage.tsx +++ b/plugins/ocm/src/components/ClusterStatusPage/ClusterStatusPage.tsx @@ -16,6 +16,7 @@ import { import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef, EntityRefLink } from '@backstage/plugin-catalog-react'; import { HomePageCompanyLogo } from '@backstage/plugin-home'; +import { RequirePermission } from '@backstage/plugin-permission-react'; import { SearchContextProvider } from '@backstage/plugin-search-react'; import { Chip, CircularProgress, Grid, makeStyles } from '@material-ui/core'; @@ -23,6 +24,7 @@ import { Chip, CircularProgress, Grid, makeStyles } from '@material-ui/core'; import { ClusterNodesStatus, ClusterOverview, + ocmClusterReadPermission, } from '@janus-idp/backstage-plugin-ocm-common'; import { OcmApiRef } from '../../api'; @@ -180,17 +182,21 @@ export const ClusterStatusPage = ({ logo }: { logo?: React.ReactNode }) => { return ( - -
- - - {logo && } - - + + +
+ + + {logo && ( + + )} + + + - - - + + + ); }; diff --git a/yarn.lock b/yarn.lock index 385db1a896..37612825ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3451,6 +3451,18 @@ "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" history "^5.0.0" +"@backstage/core-plugin-api@^1.9.2": + version "1.9.2" + resolved "https://registry.yarnpkg.com/@backstage/core-plugin-api/-/core-plugin-api-1.9.2.tgz#1a75865e567708829f5a8056ad23ea94233f4b7f" + integrity sha512-VbMzgbp5c14B+xi5qFDXEd/LMsrM9D9IpU9tLPSaN2fn9FWhxmeHILNaiLHO2mdLd6RxLopKKbKWduBYbqyu5Q== + dependencies: + "@backstage/config" "^1.2.0" + "@backstage/errors" "^1.2.4" + "@backstage/types" "^1.1.1" + "@backstage/version-bridge" "^1.0.8" + "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + history "^5.0.0" + "@backstage/dev-utils@1.0.30": version "1.0.30" resolved "https://registry.yarnpkg.com/@backstage/dev-utils/-/dev-utils-1.0.30.tgz#3d11bd7999fea004e9e8e164979e6c3aec6998f4" @@ -4245,6 +4257,17 @@ "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" swr "^2.0.0" +"@backstage/plugin-permission-react@^0.4.22": + version "0.4.22" + resolved "https://registry.yarnpkg.com/@backstage/plugin-permission-react/-/plugin-permission-react-0.4.22.tgz#7a6d60a7ada0748ca7c23ccba64b1afc7b33045c" + integrity sha512-FPGbx3jasbC/PoKTud7qYgprMop1MejmgqoV3CtWFnWlhICjxEcTTl+guK5EkYWxjIiJPRFrUjEuDqQ42Fsiqg== + dependencies: + "@backstage/config" "^1.2.0" + "@backstage/core-plugin-api" "^1.9.2" + "@backstage/plugin-permission-common" "^0.7.13" + "@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0" + swr "^2.0.0" + "@backstage/plugin-proxy-backend@^0.4.14": version "0.4.14" resolved "https://registry.yarnpkg.com/@backstage/plugin-proxy-backend/-/plugin-proxy-backend-0.4.14.tgz#9ccf8d58376f509153a51074ac557a23be51a6dc" @@ -4920,6 +4943,13 @@ dependencies: "@types/react" "^16.13.1 || ^17.0.0" +"@backstage/version-bridge@^1.0.8": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@backstage/version-bridge/-/version-bridge-1.0.8.tgz#c6664708bcd20744e7b2c440a03f1e44f7c4a2a1" + integrity sha512-f4u5YEq/+TLe/W4UnsiD8u15qcuyFx2tuO/RDrJ2c/ulm4TuSeEcupMs7b9oa2Pge5IQAISadz0em1c+VDIB+g== + dependencies: + "@types/react" "^16.13.1 || ^17.0.0" + "@balena/dockerignore@^1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@balena/dockerignore/-/dockerignore-1.0.2.tgz#9ffe4726915251e8eb69f44ef3547e0da2c03e0d"