From 94e17d98dca7c8b78ef1b0b59ac3d88b35b0bc26 Mon Sep 17 00:00:00 2001 From: Deepankumar Date: Thu, 19 Oct 2023 19:32:45 +0000 Subject: [PATCH 01/13] Azure Site Plugin Auth permission added Signed-off-by: Deepankumar --- packages/backend/package.json | 1 + packages/backend/src/index.ts | 3 + packages/backend/src/plugins/azure-sites.ts | 30 ++++++++ packages/backend/src/plugins/permission.ts | 15 ++++ plugins/azure-sites-backend/README.md | 41 ++++++++--- plugins/azure-sites-backend/package.json | 5 ++ .../src/api/AzureSitesApi.ts | 11 +++ .../azure-sites-backend/src/service/router.ts | 71 ++++++++++++++++++- .../src/service/standaloneServer.ts | 12 ++++ plugins/azure-sites-common/package.json | 5 ++ plugins/azure-sites-common/src/index.ts | 2 + plugins/azure-sites-common/src/permissions.ts | 31 ++++++++ plugins/azure-sites-common/src/types.ts | 3 + plugins/azure-sites-common/src/utils.ts | 20 ++++++ plugins/azure-sites/README.md | 2 +- plugins/azure-sites/package.json | 3 + .../src/api/AzureSitesApiBackendClient.ts | 8 +++ .../AzureSitesOverview.tsx | 6 +- .../AzureSitesOverviewTable.test.tsx | 23 ++++++ .../AzureSitesOverviewTable.tsx | 21 ++++-- .../src/hooks/useServiceEntityAnnotations.ts | 2 +- yarn.lock | 10 +++ 22 files changed, 303 insertions(+), 22 deletions(-) create mode 100644 packages/backend/src/plugins/azure-sites.ts create mode 100644 plugins/azure-sites-common/src/permissions.ts create mode 100644 plugins/azure-sites-common/src/utils.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index b3a2fcb60b..f76a40a8f0 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -38,6 +38,7 @@ "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-azure-devops-backend": "workspace:^", "@backstage/plugin-azure-sites-backend": "workspace:^", + "@backstage/plugin-azure-sites-common": "workspace:^", "@backstage/plugin-badges-backend": "workspace:^", "@backstage/plugin-catalog-backend": "workspace:^", "@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "workspace:^", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index bade028597..323fc27005 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -41,6 +41,7 @@ import healthcheck from './plugins/healthcheck'; import { metricsHandler, metricsInit } from './metrics'; import auth from './plugins/auth'; import azureDevOps from './plugins/azure-devops'; +import azureSites from './plugins/azure-sites'; import catalog from './plugins/catalog'; import codeCoverage from './plugins/codecoverage'; import entityFeedback from './plugins/entityFeedback'; @@ -145,6 +146,7 @@ async function main() { const createEnv = makeCreateEnv(config); + const azureSitesEnv = useHotMemoize(module, () => createEnv('azure-sites')); const healthcheckEnv = useHotMemoize(module, () => createEnv('healthcheck')); const catalogEnv = useHotMemoize(module, () => createEnv('catalog')); const codeCoverageEnv = useHotMemoize(module, () => @@ -189,6 +191,7 @@ async function main() { apiRouter.use('/tech-insights', await techInsights(techInsightsEnv)); apiRouter.use('/auth', await auth(authEnv)); apiRouter.use('/azure-devops', await azureDevOps(azureDevOpsEnv)); + apiRouter.use('/azure-sites', await azureSites(azureSitesEnv)); apiRouter.use('/search', await search(searchEnv)); apiRouter.use('/techdocs', await techdocs(techdocsEnv)); apiRouter.use('/todo', await todo(todoEnv)); diff --git a/packages/backend/src/plugins/azure-sites.ts b/packages/backend/src/plugins/azure-sites.ts new file mode 100644 index 0000000000..7e3f53092b --- /dev/null +++ b/packages/backend/src/plugins/azure-sites.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2023 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 { + createRouter, + AzureSitesApi, +} from '@backstage/plugin-azure-sites-backend'; +import { Router } from 'express'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + return await createRouter({ + ...env, + azureSitesApi: AzureSitesApi.fromConfig(env.config), + }); +} diff --git a/packages/backend/src/plugins/permission.ts b/packages/backend/src/plugins/permission.ts index 7192a1ddec..8d8116e627 100644 --- a/packages/backend/src/plugins/permission.ts +++ b/packages/backend/src/plugins/permission.ts @@ -19,6 +19,7 @@ import { createRouter } from '@backstage/plugin-permission-backend'; import { AuthorizeResult, PolicyDecision, + isPermission, } from '@backstage/plugin-permission-common'; import { PermissionPolicy, @@ -30,6 +31,11 @@ import { } from '@backstage/plugin-playlist-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; +import { azureSitesActionPermission } from '@backstage/plugin-azure-sites-common'; +import { + catalogConditions, + createCatalogConditionalDecision, +} from '@backstage/plugin-catalog-backend/alpha'; class ExamplePermissionPolicy implements PermissionPolicy { private playlistPermissionPolicy = new DefaultPlaylistPermissionPolicy(); @@ -42,6 +48,15 @@ class ExamplePermissionPolicy implements PermissionPolicy { return this.playlistPermissionPolicy.handle(request, user); } + if (isPermission(request.permission, azureSitesActionPermission)) { + return createCatalogConditionalDecision( + request.permission, + catalogConditions.isEntityOwner({ + claims: user?.identity.ownershipEntityRefs ?? [], + }), + ); + } + return { result: AuthorizeResult.ALLOW, }; diff --git a/plugins/azure-sites-backend/README.md b/plugins/azure-sites-backend/README.md index 23152f9760..b5f6260a0e 100644 --- a/plugins/azure-sites-backend/README.md +++ b/plugins/azure-sites-backend/README.md @@ -2,7 +2,7 @@ Simple plugin that proxies requests to the Azure Portal API through Azure SDK JavaScript libraries. -_Inspired by [roadie.io AWS Lambda plugin](https://roadie.io/backstage/plugins/aws-lambda/)_ +_Inspired by [roadie.io AWS Lamda plugin](https://roadie.io/backstage/plugins/aws-lambda/)_ ## Setup @@ -36,11 +36,11 @@ Here's how to get the backend plugin up and running: 1. First we need to add the `@backstage/plugin-azure-sites-backend` package to your backend: ```sh - # From your Backstage root directory + # From the Backstage root directory yarn add --cwd packages/backend @backstage/plugin-azure-sites-backend ``` -2. Then we will create a new file named `packages/backend/src/plugins/azure.ts`, and add the following to it: +2. Then we will create a new file named `packages/backend/src/plugins/azure-sites.ts`, and add the following to it: ```ts import { @@ -56,6 +56,7 @@ Here's how to get the backend plugin up and running: return await createRouter({ logger: env.logger, azureSitesApi: AzureSitesApi.fromConfig(env.config), + permissions: env.permissions, }); } ``` @@ -63,9 +64,9 @@ Here's how to get the backend plugin up and running: 3. Next we wire this into the overall backend router, edit `packages/backend/src/index.ts`: ```ts - import azure from './plugins/azure'; + import azureSites from './plugins/azure-sites'; - // Removed for clarity... + // Removed for clairty... async function main() { // ... @@ -76,10 +77,34 @@ Here's how to get the backend plugin up and running: // ... // Insert this line under the other lines that add their routers to apiRouter in the same way - apiRouter.use('/azure-sites', await azure(azureSitesEnv)); + apiRouter.use('/azure-sites', await azureSites(azureSitesEnv)); } ``` -4. Now run `yarn start-backend` from the repo root. +4. Enable permissions and that the below is just an example policy that forbids anyone but the owner of the catalog entity to trigger actions towards a site tied to an entity, edit your `packages/backend/src/plugins/permission.ts` -5. Finally, open `http://localhost:7007/api/azure-sites/health` in a browser, it should return `{"status":"ok"}`. + ```diff + // packages/backend/src/plugins/permission.ts + + import { azureSitesActionPermission } from '@backstage/plugin-azure-sites-common'; + ... + class TestPermissionPolicy implements PermissionPolicy { + - async handle(): Promise { + + async handle(request: PolicyQuery, user?: BackstageIdentityResponse): Promise { + if (isPermission(request.permission, azureSitesActionPermission)) { + return createCatalogConditionalDecision( + request.permission, + catalogConditions.isEntityOwner({ + claims: user?.identity.ownershipEntityRefs ?? [], + }), + ); + } + ... + return { + result: AuthorizeResult.ALLOW, + }; + } + ``` + +5. Now run `yarn start-backend` from the repo root. + +6. Finally, open `http://localhost:7007/api/azure/health` in a browser, it should return `{"status":"ok"}`. diff --git a/plugins/azure-sites-backend/package.json b/plugins/azure-sites-backend/package.json index c4f689129d..4426798ca2 100644 --- a/plugins/azure-sites-backend/package.json +++ b/plugins/azure-sites-backend/package.json @@ -36,8 +36,13 @@ "@azure/arm-resourcegraph": "^4.2.1", "@azure/identity": "^4.0.0", "@backstage/backend-common": "workspace:^", + "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-azure-sites-common": "workspace:^", + "@backstage/plugin-permission-common": "workspace:^", + "@backstage/plugin-permission-node": "workspace:^", "@types/express": "^4.17.6", "express": "^4.17.1", "express-promise-router": "^4.1.0", diff --git a/plugins/azure-sites-backend/src/api/AzureSitesApi.ts b/plugins/azure-sites-backend/src/api/AzureSitesApi.ts index 73e92ea6a0..b8660c5989 100644 --- a/plugins/azure-sites-backend/src/api/AzureSitesApi.ts +++ b/plugins/azure-sites-backend/src/api/AzureSitesApi.ts @@ -95,4 +95,15 @@ export class AzureSitesApi { } return { items: items }; } + + async validateSite(annotationName: string, siteName: string) { + const azureSites = await this.list({ + name: annotationName, + }); + for (const site of azureSites.items) { + if (site.name === siteName) return true; + } + + return false; + } } diff --git a/plugins/azure-sites-backend/src/service/router.ts b/plugins/azure-sites-backend/src/service/router.ts index bea9a15396..c7661239e5 100644 --- a/plugins/azure-sites-backend/src/service/router.ts +++ b/plugins/azure-sites-backend/src/service/router.ts @@ -19,19 +19,32 @@ import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; +import { stringifyEntityRef } from '@backstage/catalog-model'; +import { NotAllowedError } from '@backstage/errors'; +import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; +import { + PermissionEvaluator, + AuthorizeResult, +} from '@backstage/plugin-permission-common'; +import { + azureSitesActionPermission, + AZURE_WEB_SITE_NAME_ANNOTATION, +} from '@backstage/plugin-azure-sites-common'; + import { AzureSitesApi } from '../api'; /** @public */ export interface RouterOptions { logger: Logger; azureSitesApi: AzureSitesApi; + permissions: PermissionEvaluator; } /** @public */ export async function createRouter( options: RouterOptions, ): Promise { - const { logger, azureSitesApi } = options; + const { logger, azureSitesApi, permissions } = options; const router = Router(); router.use(express.json()); @@ -52,7 +65,33 @@ export async function createRouter( '/:subscription/:resourceGroup/:name/start', async (request, response) => { const { subscription, resourceGroup, name } = request.params; - console.log('starting...'); + const token = getBearerTokenFromAuthorizationHeader( + request.header('authorization'), + ); + const entity = request.body.entity; + const resourceRef = stringifyEntityRef(entity); + + const annotationName = + entity.metadata.annotations?.[AZURE_WEB_SITE_NAME_ANNOTATION]; + if (await azureSitesApi.validateSite(annotationName, name)) { + throw new NotAllowedError(); + } + + const decision = ( + await permissions.authorize( + [{ permission: azureSitesActionPermission, resourceRef }], + { + token, + }, + ) + )[0]; + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError('Unauthorized'); + } + + logger.info( + `entity ${entity.metadata.name} - azure site "${name}" is starting...`, + ); response.json( await azureSitesApi.start({ subscription, @@ -66,7 +105,33 @@ export async function createRouter( '/:subscription/:resourceGroup/:name/stop', async (request, response) => { const { subscription, resourceGroup, name } = request.params; - console.log('stopping...'); + const token = getBearerTokenFromAuthorizationHeader( + request.header('authorization'), + ); + const entity = request.body.entity; + const resourceRef = stringifyEntityRef(entity); + + const annotationName = + entity.metadata.annotations?.[AZURE_WEB_SITE_NAME_ANNOTATION]; + if (await azureSitesApi.validateSite(annotationName, name)) { + throw new NotAllowedError(); + } + + const decision = ( + await permissions.authorize( + [{ permission: azureSitesActionPermission, resourceRef }], + { + token, + }, + ) + )[0]; + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError('Unauthorized'); + } + + logger.info( + `entity ${entity.metadata.name} - azure site "${name}" is stopping...`, + ); response.json( await azureSitesApi.stop({ subscription, diff --git a/plugins/azure-sites-backend/src/service/standaloneServer.ts b/plugins/azure-sites-backend/src/service/standaloneServer.ts index 1998bee023..8c64fa7b1b 100644 --- a/plugins/azure-sites-backend/src/service/standaloneServer.ts +++ b/plugins/azure-sites-backend/src/service/standaloneServer.ts @@ -17,11 +17,14 @@ import { createServiceBuilder, loadBackendConfig, + ServerTokenManager, + SingleHostDiscovery, } from '@backstage/backend-common'; import { Server } from 'http'; import { Logger } from 'winston'; import { AzureSitesApi } from '../api'; import { createRouter } from './router'; +import { ServerPermissionClient } from '@backstage/plugin-permission-node'; export interface ServerOptions { port: number; @@ -34,9 +37,18 @@ export async function startStandaloneServer( ): Promise { const logger = options.logger.child({ service: 'azure-backend' }); const config = await loadBackendConfig({ logger, argv: process.argv }); + const discovery = SingleHostDiscovery.fromConfig(config); + const tokenManager = ServerTokenManager.fromConfig(config, { + logger, + }); + const permissions = ServerPermissionClient.fromConfig(config, { + discovery, + tokenManager, + }); logger.debug('Starting application server...'); const router = await createRouter({ logger, + permissions, azureSitesApi: AzureSitesApi.fromConfig(config), }); diff --git a/plugins/azure-sites-common/package.json b/plugins/azure-sites-common/package.json index 05815edd02..0365daa844 100644 --- a/plugins/azure-sites-common/package.json +++ b/plugins/azure-sites-common/package.json @@ -32,6 +32,11 @@ "prepack": "backstage-cli package prepack", "postpack": "backstage-cli package postpack" }, + "dependencies": { + "@backstage/catalog-model": "workspace:^", + "@backstage/plugin-catalog-common": "workspace:^", + "@backstage/plugin-permission-common": "workspace:^" + }, "devDependencies": { "@backstage/cli": "workspace:^" }, diff --git a/plugins/azure-sites-common/src/index.ts b/plugins/azure-sites-common/src/index.ts index db229eae34..999a9c713c 100644 --- a/plugins/azure-sites-common/src/index.ts +++ b/plugins/azure-sites-common/src/index.ts @@ -15,3 +15,5 @@ */ export * from './types'; +export * from './utils'; +export * from './permissions'; diff --git a/plugins/azure-sites-common/src/permissions.ts b/plugins/azure-sites-common/src/permissions.ts new file mode 100644 index 0000000000..03b50f3edc --- /dev/null +++ b/plugins/azure-sites-common/src/permissions.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2023 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 { createPermission } from '@backstage/plugin-permission-common'; +import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common/alpha'; + +/** + * @public + */ +export const azureSitesActionPermission = createPermission({ + name: 'azure.sites.update', + attributes: { action: 'update' }, + resourceType: RESOURCE_TYPE_CATALOG_ENTITY, +}); + +/** + * @public + */ +export const azureSitesPermissions = [azureSitesActionPermission]; diff --git a/plugins/azure-sites-common/src/types.ts b/plugins/azure-sites-common/src/types.ts index 7c8be7ce24..7bab33cef5 100644 --- a/plugins/azure-sites-common/src/types.ts +++ b/plugins/azure-sites-common/src/types.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; + /** @public */ export type AzureSite = { href: string; @@ -45,4 +47,5 @@ export type AzureSiteStartStopRequest = { subscription: string; resourceGroup: string; name: string; + entity?: Entity; }; diff --git a/plugins/azure-sites-common/src/utils.ts b/plugins/azure-sites-common/src/utils.ts new file mode 100644 index 0000000000..017f38a380 --- /dev/null +++ b/plugins/azure-sites-common/src/utils.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2023 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. + */ + +/** + * @public + */ +export const AZURE_WEB_SITE_NAME_ANNOTATION = 'azure.com/microsoft-web-sites'; diff --git a/plugins/azure-sites/README.md b/plugins/azure-sites/README.md index 57bf84ccc5..a19b314b77 100644 --- a/plugins/azure-sites/README.md +++ b/plugins/azure-sites/README.md @@ -14,7 +14,7 @@ The following sections will help you get the Azure plugin setup and running ### Azure Sites Backend -You need to set up the Azure Sites Backend plugin before you move forward with any of these steps if you haven't already. +You need to set up the [Azure Sites Backend plugin](https://github.com/backstage/backstage/tree/master/plugins/azure-sites-backend) before you move forward with any of these steps if you haven't already. ### Entity Annotation diff --git a/plugins/azure-sites/package.json b/plugins/azure-sites/package.json index a4337348f3..c9777e73da 100644 --- a/plugins/azure-sites/package.json +++ b/plugins/azure-sites/package.json @@ -38,6 +38,9 @@ "@backstage/core-plugin-api": "workspace:^", "@backstage/plugin-azure-sites-common": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", + "@backstage/plugin-permission-common": "workspace:^", + "@backstage/plugin-permission-react": "workspace:^", + "@backstage/theme": "workspace:^", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.61", diff --git a/plugins/azure-sites/src/api/AzureSitesApiBackendClient.ts b/plugins/azure-sites/src/api/AzureSitesApiBackendClient.ts index b66a3cbb3f..8c19f4a93d 100644 --- a/plugins/azure-sites/src/api/AzureSitesApiBackendClient.ts +++ b/plugins/azure-sites/src/api/AzureSitesApiBackendClient.ts @@ -39,12 +39,16 @@ export class AzureSitesApiBackendClient implements AzureSitesApi { request.subscription }/${request.resourceGroup}/${request.name}/stop`; const { token: accessToken } = await this.identityApi.getCredentials(); + const entity = request.entity; await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(accessToken && { Authorization: `Bearer ${accessToken}` }), }, + body: JSON.stringify({ + entity, + }), }); } async start(request: AzureSiteStartStopRequest): Promise { @@ -52,12 +56,16 @@ export class AzureSitesApiBackendClient implements AzureSitesApi { request.subscription }/${request.resourceGroup}/${request.name}/start`; const { token: accessToken } = await this.identityApi.getCredentials(); + const entity = request.entity; await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(accessToken && { Authorization: `Bearer ${accessToken}` }), }, + body: JSON.stringify({ + entity, + }), }); } diff --git a/plugins/azure-sites/src/components/AzureSitesOverviewComponent/AzureSitesOverview.tsx b/plugins/azure-sites/src/components/AzureSitesOverviewComponent/AzureSitesOverview.tsx index dbed6cdbd6..71e5639da2 100644 --- a/plugins/azure-sites/src/components/AzureSitesOverviewComponent/AzureSitesOverview.tsx +++ b/plugins/azure-sites/src/components/AzureSitesOverviewComponent/AzureSitesOverview.tsx @@ -17,11 +17,9 @@ import React from 'react'; import { Entity } from '@backstage/catalog-model'; import { useSites } from '../../hooks/useSites'; -import { - AZURE_WEB_SITE_NAME_ANNOTATION, - useServiceEntityAnnotations, -} from '../../hooks/useServiceEntityAnnotations'; import { ErrorBoundary, ResponseErrorPanel } from '@backstage/core-components'; +import { useServiceEntityAnnotations } from '../../hooks/useServiceEntityAnnotations'; +import { AZURE_WEB_SITE_NAME_ANNOTATION } from '@backstage/plugin-azure-sites-common'; import { useEntity, MissingAnnotationEmptyState, diff --git a/plugins/azure-sites/src/components/AzureSitesOverviewTableComponent/AzureSitesOverviewTable.test.tsx b/plugins/azure-sites/src/components/AzureSitesOverviewTableComponent/AzureSitesOverviewTable.test.tsx index 5d1a5ee73b..f161da5ef0 100644 --- a/plugins/azure-sites/src/components/AzureSitesOverviewTableComponent/AzureSitesOverviewTable.test.tsx +++ b/plugins/azure-sites/src/components/AzureSitesOverviewTableComponent/AzureSitesOverviewTable.test.tsx @@ -21,6 +21,11 @@ import { errorApiRef, identityApiRef, } from '@backstage/core-plugin-api'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { + PermissionApi, + permissionApiRef, +} from '@backstage/plugin-permission-react'; import { rest } from 'msw'; import { renderInTestApp, @@ -41,15 +46,33 @@ const identityApiMock = (getCredentials: any) => ({ }); const azureSitesApiMock = {}; +jest.mock('@backstage/plugin-catalog-react', () => ({ + useEntity: () => { + return { loading: false, entity: undefined }; + }, +})); + +jest.mock('@backstage/plugin-catalog-react/alpha', () => ({ + useEntityPermission: () => { + return { loading: false, allowed: true }; + }, +})); + const config = { getString: (_: string) => 'https://test-url', }; +const mockAuthorize = jest + .fn() + .mockImplementation(async () => ({ result: AuthorizeResult.ALLOW })); +const permissionApi: Partial = { authorize: mockAuthorize }; + const apis: [AnyApiRef, Partial][] = [ [errorApiRef, errorApiMock], [configApiRef, config], [azureSiteApiRef, azureSitesApiMock], [identityApiRef, identityApiMock], + [permissionApiRef, permissionApi], ]; describe('AzureSitesOverviewWidget', () => { diff --git a/plugins/azure-sites/src/components/AzureSitesOverviewTableComponent/AzureSitesOverviewTable.tsx b/plugins/azure-sites/src/components/AzureSitesOverviewTableComponent/AzureSitesOverviewTable.tsx index c7f8befedb..2095dc5391 100644 --- a/plugins/azure-sites/src/components/AzureSitesOverviewTableComponent/AzureSitesOverviewTable.tsx +++ b/plugins/azure-sites/src/components/AzureSitesOverviewTableComponent/AzureSitesOverviewTable.tsx @@ -27,7 +27,10 @@ import { Tooltip, } from '@material-ui/core'; import { default as MuiAlert } from '@material-ui/lab/Alert'; -import { AzureSite } from '@backstage/plugin-azure-sites-common'; +import { + AzureSite, + azureSitesActionPermission, +} from '@backstage/plugin-azure-sites-common'; import { Table, TableColumn, Link } from '@backstage/core-components'; import { useTheme } from '@material-ui/core/styles'; import FlashOnIcon from '@material-ui/icons/FlashOn'; @@ -40,6 +43,8 @@ import OpenInNewIcon from '@material-ui/icons/OpenInNew'; import { DateTime } from 'luxon'; import { useApi } from '@backstage/core-plugin-api'; import { azureSiteApiRef } from '../../api'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha'; type States = 'Waiting' | 'Running' | 'Paused' | 'Failed' | 'Stopped'; type Kinds = 'app' | 'functionapp'; @@ -117,6 +122,7 @@ const ActionButtons = ({ onMenuItemClick: Dispatch>; }) => { const azureApi = useApi(azureSiteApiRef); + const { entity } = useEntity(); const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); @@ -132,6 +138,7 @@ const ActionButtons = ({ name: value.name, resourceGroup: value.resourceGroup, subscription: value.subscription, + entity: entity, }); onMenuItemClick('Starting, this may take some time...'); handleClose(); @@ -141,11 +148,15 @@ const ActionButtons = ({ name: value.name, resourceGroup: value.resourceGroup, subscription: value.subscription, + entity: entity, }); onMenuItemClick('Stopping, this may take some time...'); handleClose(); }; + const { loading: loadingPermission, allowed: canDoAction } = + useEntityPermission(azureSitesActionPermission); + return (
- {value.state !== 'Running' && ( - + {value.state !== 'Running' && !loadingPermission && ( +  Start )} - {value.state !== 'Stopped' && ( - + {value.state !== 'Stopped' && !loadingPermission && ( +  Stop diff --git a/plugins/azure-sites/src/hooks/useServiceEntityAnnotations.ts b/plugins/azure-sites/src/hooks/useServiceEntityAnnotations.ts index 08070dc8e4..1695749640 100644 --- a/plugins/azure-sites/src/hooks/useServiceEntityAnnotations.ts +++ b/plugins/azure-sites/src/hooks/useServiceEntityAnnotations.ts @@ -15,8 +15,8 @@ */ import { Entity } from '@backstage/catalog-model'; +import { AZURE_WEB_SITE_NAME_ANNOTATION } from '@backstage/plugin-azure-sites-common'; -export const AZURE_WEB_SITE_NAME_ANNOTATION = 'azure.com/microsoft-web-sites'; export const useServiceEntityAnnotations = (entity: Entity) => { const webSiteName = entity?.metadata.annotations?.[AZURE_WEB_SITE_NAME_ANNOTATION] ?? ''; diff --git a/yarn.lock b/yarn.lock index 3324e9418a..5ed31502a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4969,9 +4969,14 @@ __metadata: "@azure/arm-resourcegraph": ^4.2.1 "@azure/identity": ^4.0.0 "@backstage/backend-common": "workspace:^" + "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-azure-sites-common": "workspace:^" + "@backstage/plugin-permission-common": "workspace:^" + "@backstage/plugin-permission-node": "workspace:^" "@types/express": ^4.17.6 "@types/supertest": ^2.0.8 express: ^4.17.1 @@ -4986,7 +4991,10 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/plugin-azure-sites-common@workspace:plugins/azure-sites-common" dependencies: + "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" + "@backstage/plugin-catalog-common": "workspace:^" + "@backstage/plugin-permission-common": "workspace:^" languageName: unknown linkType: soft @@ -5001,6 +5009,8 @@ __metadata: "@backstage/dev-utils": "workspace:^" "@backstage/plugin-azure-sites-common": "workspace:^" "@backstage/plugin-catalog-react": "workspace:^" + "@backstage/plugin-permission-common": "workspace:^" + "@backstage/plugin-permission-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@material-ui/core": ^4.12.2 "@material-ui/icons": ^4.9.1 From 5a409bb1724dc0b5aa1e07db28d73bc02049f640 Mon Sep 17 00:00:00 2001 From: Deepankumar Loganathan Date: Fri, 20 Oct 2023 23:50:37 +0200 Subject: [PATCH 02/13] changeset and api-report added Signed-off-by: Deepankumar Loganathan --- .changeset/tidy-cooks-mix.md | 7 +++++++ plugins/azure-sites-backend/api-report.md | 5 +++++ plugins/azure-sites-common/api-report.md | 13 +++++++++++++ yarn.lock | 1 + 4 files changed, 26 insertions(+) create mode 100644 .changeset/tidy-cooks-mix.md diff --git a/.changeset/tidy-cooks-mix.md b/.changeset/tidy-cooks-mix.md new file mode 100644 index 0000000000..41a2220547 --- /dev/null +++ b/.changeset/tidy-cooks-mix.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-azure-sites-backend': minor +'@backstage/plugin-azure-sites-common': minor +'@backstage/plugin-azure-sites': minor +--- + +Implemented Permission framework to protect start and stop `Azure sites`. diff --git a/plugins/azure-sites-backend/api-report.md b/plugins/azure-sites-backend/api-report.md index 751ff3f033..b8d8f23c13 100644 --- a/plugins/azure-sites-backend/api-report.md +++ b/plugins/azure-sites-backend/api-report.md @@ -9,6 +9,7 @@ import { AzureSiteStartStopRequest } from '@backstage/plugin-azure-sites-common' import { Config } from '@backstage/config'; import express from 'express'; import { Logger } from 'winston'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; // @public (undocumented) export class AzureSitesApi { @@ -21,6 +22,8 @@ export class AzureSitesApi { start(request: AzureSiteStartStopRequest): Promise; // (undocumented) stop(request: AzureSiteStartStopRequest): Promise; + // (undocumented) + validateSite(annotationName: string, siteName: string): Promise; } // @public (undocumented) @@ -55,6 +58,8 @@ export interface RouterOptions { azureSitesApi: AzureSitesApi; // (undocumented) logger: Logger; + // (undocumented) + permissions: PermissionEvaluator; } // (No @packageDocumentation comment for this package) diff --git a/plugins/azure-sites-common/api-report.md b/plugins/azure-sites-common/api-report.md index 64b4275085..7eb88da1c8 100644 --- a/plugins/azure-sites-common/api-report.md +++ b/plugins/azure-sites-common/api-report.md @@ -3,6 +3,12 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { Entity } from '@backstage/catalog-model'; +import { ResourcePermission } from '@backstage/plugin-permission-common'; + +// @public (undocumented) +export const AZURE_WEB_SITE_NAME_ANNOTATION = 'azure.com/microsoft-web-sites'; + // @public (undocumented) export type AzureSite = { href: string; @@ -29,11 +35,18 @@ export type AzureSiteListResponse = { items: AzureSite[]; }; +// @public (undocumented) +export const azureSitesActionPermission: ResourcePermission<'catalog-entity'>; + +// @public (undocumented) +export const azureSitesPermissions: ResourcePermission<'catalog-entity'>[]; + // @public (undocumented) export type AzureSiteStartStopRequest = { subscription: string; resourceGroup: string; name: string; + entity?: Entity; }; // (No @packageDocumentation comment for this package) diff --git a/yarn.lock b/yarn.lock index 5ed31502a5..8fe6c1c3ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26733,6 +26733,7 @@ __metadata: "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-azure-devops-backend": "workspace:^" "@backstage/plugin-azure-sites-backend": "workspace:^" + "@backstage/plugin-azure-sites-common": "workspace:^" "@backstage/plugin-badges-backend": "workspace:^" "@backstage/plugin-catalog-backend": "workspace:^" "@backstage/plugin-catalog-backend-module-scaffolder-entity-model": "workspace:^" From e9974e1b5f380231cf841f0eb3f16f268cf6b618 Mon Sep 17 00:00:00 2001 From: Deepankumar Loganathan Date: Thu, 2 Nov 2023 14:24:13 +0100 Subject: [PATCH 03/13] typo fixed Signed-off-by: Deepankumar Loganathan --- plugins/azure-sites-backend/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/azure-sites-backend/README.md b/plugins/azure-sites-backend/README.md index b5f6260a0e..f1e688bd79 100644 --- a/plugins/azure-sites-backend/README.md +++ b/plugins/azure-sites-backend/README.md @@ -2,7 +2,7 @@ Simple plugin that proxies requests to the Azure Portal API through Azure SDK JavaScript libraries. -_Inspired by [roadie.io AWS Lamda plugin](https://roadie.io/backstage/plugins/aws-lambda/)_ +_Inspired by [roadie.io AWS Lambda plugin](https://roadie.io/backstage/plugins/aws-lambda/)_ ## Setup From bfa0c5ceaad4ed19d90bfcf301030922d0257b11 Mon Sep 17 00:00:00 2001 From: Deepankumar Loganathan Date: Thu, 9 Nov 2023 13:42:32 +0100 Subject: [PATCH 04/13] passing entityRef instead of full entity Signed-off-by: Deepankumar Loganathan --- plugins/azure-sites-backend/api-report.md | 3 + plugins/azure-sites-backend/package.json | 1 + .../azure-sites-backend/src/service/router.ts | 133 ++++++++++-------- .../src/service/standaloneServer.ts | 3 + plugins/azure-sites-common/api-report.md | 3 +- plugins/azure-sites-common/src/types.ts | 4 +- .../src/api/AzureSitesApiBackendClient.ts | 8 +- .../AzureSitesOverviewTable.tsx | 6 +- yarn.lock | 1 + 9 files changed, 94 insertions(+), 68 deletions(-) diff --git a/plugins/azure-sites-backend/api-report.md b/plugins/azure-sites-backend/api-report.md index b8d8f23c13..04cd22596a 100644 --- a/plugins/azure-sites-backend/api-report.md +++ b/plugins/azure-sites-backend/api-report.md @@ -6,6 +6,7 @@ import { AzureSiteListRequest } from '@backstage/plugin-azure-sites-common'; import { AzureSiteListResponse } from '@backstage/plugin-azure-sites-common'; import { AzureSiteStartStopRequest } from '@backstage/plugin-azure-sites-common'; +import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import express from 'express'; import { Logger } from 'winston'; @@ -57,6 +58,8 @@ export interface RouterOptions { // (undocumented) azureSitesApi: AzureSitesApi; // (undocumented) + catalogApi: CatalogApi; + // (undocumented) logger: Logger; // (undocumented) permissions: PermissionEvaluator; diff --git a/plugins/azure-sites-backend/package.json b/plugins/azure-sites-backend/package.json index 4426798ca2..2599bfc360 100644 --- a/plugins/azure-sites-backend/package.json +++ b/plugins/azure-sites-backend/package.json @@ -36,6 +36,7 @@ "@azure/arm-resourcegraph": "^4.2.1", "@azure/identity": "^4.0.0", "@backstage/backend-common": "workspace:^", + "@backstage/catalog-client": "workspace:^", "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", diff --git a/plugins/azure-sites-backend/src/service/router.ts b/plugins/azure-sites-backend/src/service/router.ts index c7661239e5..fcab14a97f 100644 --- a/plugins/azure-sites-backend/src/service/router.ts +++ b/plugins/azure-sites-backend/src/service/router.ts @@ -30,6 +30,7 @@ import { azureSitesActionPermission, AZURE_WEB_SITE_NAME_ANNOTATION, } from '@backstage/plugin-azure-sites-common'; +import { CatalogApi } from '@backstage/catalog-client'; import { AzureSitesApi } from '../api'; @@ -37,6 +38,7 @@ import { AzureSitesApi } from '../api'; export interface RouterOptions { logger: Logger; azureSitesApi: AzureSitesApi; + catalogApi: CatalogApi; permissions: PermissionEvaluator; } @@ -44,7 +46,7 @@ export interface RouterOptions { export async function createRouter( options: RouterOptions, ): Promise { - const { logger, azureSitesApi, permissions } = options; + const { logger, azureSitesApi, permissions, catalogApi } = options; const router = Router(); router.use(express.json()); @@ -68,37 +70,45 @@ export async function createRouter( const token = getBearerTokenFromAuthorizationHeader( request.header('authorization'), ); - const entity = request.body.entity; - const resourceRef = stringifyEntityRef(entity); + const entityRef = request.body.entityRef; + const entity = await catalogApi.getEntityByRef(entityRef); - const annotationName = - entity.metadata.annotations?.[AZURE_WEB_SITE_NAME_ANNOTATION]; - if (await azureSitesApi.validateSite(annotationName, name)) { + if (entity) { + const annotationName = + entity.metadata?.annotations?.[AZURE_WEB_SITE_NAME_ANNOTATION]; + const resourceRef = stringifyEntityRef(entity); + if ( + annotationName && + (await azureSitesApi.validateSite(annotationName, name)) + ) { + throw new NotAllowedError(); + } + + const decision = ( + await permissions.authorize( + [{ permission: azureSitesActionPermission, resourceRef }], + { + token, + }, + ) + )[0]; + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError('Unauthorized'); + } + + logger.info( + `entity ${entity.metadata.name} - azure site "${name}" is starting...`, + ); + response.json( + await azureSitesApi.start({ + subscription, + resourceGroup, + name, + }), + ); + } else { throw new NotAllowedError(); } - - const decision = ( - await permissions.authorize( - [{ permission: azureSitesActionPermission, resourceRef }], - { - token, - }, - ) - )[0]; - if (decision.result === AuthorizeResult.DENY) { - throw new NotAllowedError('Unauthorized'); - } - - logger.info( - `entity ${entity.metadata.name} - azure site "${name}" is starting...`, - ); - response.json( - await azureSitesApi.start({ - subscription, - resourceGroup, - name, - }), - ); }, ); router.post( @@ -108,37 +118,46 @@ export async function createRouter( const token = getBearerTokenFromAuthorizationHeader( request.header('authorization'), ); - const entity = request.body.entity; - const resourceRef = stringifyEntityRef(entity); - const annotationName = - entity.metadata.annotations?.[AZURE_WEB_SITE_NAME_ANNOTATION]; - if (await azureSitesApi.validateSite(annotationName, name)) { + const entityRef = request.body.entityRef; + const entity = await catalogApi.getEntityByRef(entityRef); + + if (entity) { + const annotationName = + entity.metadata.annotations?.[AZURE_WEB_SITE_NAME_ANNOTATION]; + const resourceRef = stringifyEntityRef(entity); + if ( + annotationName && + (await azureSitesApi.validateSite(annotationName, name)) + ) { + throw new NotAllowedError(); + } + + const decision = ( + await permissions.authorize( + [{ permission: azureSitesActionPermission, resourceRef }], + { + token, + }, + ) + )[0]; + if (decision.result === AuthorizeResult.DENY) { + throw new NotAllowedError('Unauthorized'); + } + + logger.info( + `entity ${entity.metadata.name} - azure site "${name}" is stopping...`, + ); + response.json( + await azureSitesApi.stop({ + subscription, + resourceGroup, + name, + }), + ); + } else { throw new NotAllowedError(); } - - const decision = ( - await permissions.authorize( - [{ permission: azureSitesActionPermission, resourceRef }], - { - token, - }, - ) - )[0]; - if (decision.result === AuthorizeResult.DENY) { - throw new NotAllowedError('Unauthorized'); - } - - logger.info( - `entity ${entity.metadata.name} - azure site "${name}" is stopping...`, - ); - response.json( - await azureSitesApi.stop({ - subscription, - resourceGroup, - name, - }), - ); }, ); router.use(errorHandler()); diff --git a/plugins/azure-sites-backend/src/service/standaloneServer.ts b/plugins/azure-sites-backend/src/service/standaloneServer.ts index 8c64fa7b1b..202c1a128d 100644 --- a/plugins/azure-sites-backend/src/service/standaloneServer.ts +++ b/plugins/azure-sites-backend/src/service/standaloneServer.ts @@ -25,6 +25,7 @@ import { Logger } from 'winston'; import { AzureSitesApi } from '../api'; import { createRouter } from './router'; import { ServerPermissionClient } from '@backstage/plugin-permission-node'; +import { CatalogClient } from '@backstage/catalog-client'; export interface ServerOptions { port: number; @@ -45,11 +46,13 @@ export async function startStandaloneServer( discovery, tokenManager, }); + const catalogApi = new CatalogClient({ discoveryApi: discovery }); logger.debug('Starting application server...'); const router = await createRouter({ logger, permissions, azureSitesApi: AzureSitesApi.fromConfig(config), + catalogApi, }); let service = createServiceBuilder(module) diff --git a/plugins/azure-sites-common/api-report.md b/plugins/azure-sites-common/api-report.md index 7eb88da1c8..31ada5b0ce 100644 --- a/plugins/azure-sites-common/api-report.md +++ b/plugins/azure-sites-common/api-report.md @@ -3,7 +3,6 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { Entity } from '@backstage/catalog-model'; import { ResourcePermission } from '@backstage/plugin-permission-common'; // @public (undocumented) @@ -46,7 +45,7 @@ export type AzureSiteStartStopRequest = { subscription: string; resourceGroup: string; name: string; - entity?: Entity; + entityRef?: string; }; // (No @packageDocumentation comment for this package) diff --git a/plugins/azure-sites-common/src/types.ts b/plugins/azure-sites-common/src/types.ts index 7bab33cef5..dc540599cb 100644 --- a/plugins/azure-sites-common/src/types.ts +++ b/plugins/azure-sites-common/src/types.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; - /** @public */ export type AzureSite = { href: string; @@ -47,5 +45,5 @@ export type AzureSiteStartStopRequest = { subscription: string; resourceGroup: string; name: string; - entity?: Entity; + entityRef?: string; }; diff --git a/plugins/azure-sites/src/api/AzureSitesApiBackendClient.ts b/plugins/azure-sites/src/api/AzureSitesApiBackendClient.ts index 8c19f4a93d..b482e80d92 100644 --- a/plugins/azure-sites/src/api/AzureSitesApiBackendClient.ts +++ b/plugins/azure-sites/src/api/AzureSitesApiBackendClient.ts @@ -39,7 +39,7 @@ export class AzureSitesApiBackendClient implements AzureSitesApi { request.subscription }/${request.resourceGroup}/${request.name}/stop`; const { token: accessToken } = await this.identityApi.getCredentials(); - const entity = request.entity; + const entityRef = request.entityRef; await fetch(url, { method: 'POST', headers: { @@ -47,7 +47,7 @@ export class AzureSitesApiBackendClient implements AzureSitesApi { ...(accessToken && { Authorization: `Bearer ${accessToken}` }), }, body: JSON.stringify({ - entity, + entityRef, }), }); } @@ -56,7 +56,7 @@ export class AzureSitesApiBackendClient implements AzureSitesApi { request.subscription }/${request.resourceGroup}/${request.name}/start`; const { token: accessToken } = await this.identityApi.getCredentials(); - const entity = request.entity; + const entityRef = request.entityRef; await fetch(url, { method: 'POST', headers: { @@ -64,7 +64,7 @@ export class AzureSitesApiBackendClient implements AzureSitesApi { ...(accessToken && { Authorization: `Bearer ${accessToken}` }), }, body: JSON.stringify({ - entity, + entityRef, }), }); } diff --git a/plugins/azure-sites/src/components/AzureSitesOverviewTableComponent/AzureSitesOverviewTable.tsx b/plugins/azure-sites/src/components/AzureSitesOverviewTableComponent/AzureSitesOverviewTable.tsx index 2095dc5391..2f891ec3d2 100644 --- a/plugins/azure-sites/src/components/AzureSitesOverviewTableComponent/AzureSitesOverviewTable.tsx +++ b/plugins/azure-sites/src/components/AzureSitesOverviewTableComponent/AzureSitesOverviewTable.tsx @@ -45,6 +45,7 @@ import { useApi } from '@backstage/core-plugin-api'; import { azureSiteApiRef } from '../../api'; import { useEntity } from '@backstage/plugin-catalog-react'; import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha'; +import { stringifyEntityRef } from '@backstage/catalog-model'; type States = 'Waiting' | 'Running' | 'Paused' | 'Failed' | 'Stopped'; type Kinds = 'app' | 'functionapp'; @@ -123,6 +124,7 @@ const ActionButtons = ({ }) => { const azureApi = useApi(azureSiteApiRef); const { entity } = useEntity(); + const entityRef = stringifyEntityRef(entity); const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); @@ -138,7 +140,7 @@ const ActionButtons = ({ name: value.name, resourceGroup: value.resourceGroup, subscription: value.subscription, - entity: entity, + entityRef: entityRef, }); onMenuItemClick('Starting, this may take some time...'); handleClose(); @@ -148,7 +150,7 @@ const ActionButtons = ({ name: value.name, resourceGroup: value.resourceGroup, subscription: value.subscription, - entity: entity, + entityRef: entityRef, }); onMenuItemClick('Stopping, this may take some time...'); handleClose(); diff --git a/yarn.lock b/yarn.lock index 8fe6c1c3ee..49042fa725 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4969,6 +4969,7 @@ __metadata: "@azure/arm-resourcegraph": ^4.2.1 "@azure/identity": ^4.0.0 "@backstage/backend-common": "workspace:^" + "@backstage/catalog-client": "workspace:^" "@backstage/catalog-model": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" From 8cc5835c38f892f295e70a72d1a1592d3f7bd3a1 Mon Sep 17 00:00:00 2001 From: Deepankumar Loganathan Date: Thu, 9 Nov 2023 14:07:06 +0100 Subject: [PATCH 05/13] comments & plugin fixed Signed-off-by: Deepankumar Loganathan --- packages/backend/src/plugins/azure-sites.ts | 6 +++++- packages/backend/src/plugins/permission.ts | 15 --------------- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/packages/backend/src/plugins/azure-sites.ts b/packages/backend/src/plugins/azure-sites.ts index 7e3f53092b..54c7746cb2 100644 --- a/packages/backend/src/plugins/azure-sites.ts +++ b/packages/backend/src/plugins/azure-sites.ts @@ -19,12 +19,16 @@ import { } from '@backstage/plugin-azure-sites-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; +import { CatalogClient } from '@backstage/catalog-client'; export default async function createPlugin( env: PluginEnvironment, ): Promise { + const catalogApi = new CatalogClient({ discoveryApi: env.discovery }); return await createRouter({ - ...env, + logger: env.logger, + permissions: env.permissions, azureSitesApi: AzureSitesApi.fromConfig(env.config), + catalogApi, }); } diff --git a/packages/backend/src/plugins/permission.ts b/packages/backend/src/plugins/permission.ts index 8d8116e627..7192a1ddec 100644 --- a/packages/backend/src/plugins/permission.ts +++ b/packages/backend/src/plugins/permission.ts @@ -19,7 +19,6 @@ import { createRouter } from '@backstage/plugin-permission-backend'; import { AuthorizeResult, PolicyDecision, - isPermission, } from '@backstage/plugin-permission-common'; import { PermissionPolicy, @@ -31,11 +30,6 @@ import { } from '@backstage/plugin-playlist-backend'; import { Router } from 'express'; import { PluginEnvironment } from '../types'; -import { azureSitesActionPermission } from '@backstage/plugin-azure-sites-common'; -import { - catalogConditions, - createCatalogConditionalDecision, -} from '@backstage/plugin-catalog-backend/alpha'; class ExamplePermissionPolicy implements PermissionPolicy { private playlistPermissionPolicy = new DefaultPlaylistPermissionPolicy(); @@ -48,15 +42,6 @@ class ExamplePermissionPolicy implements PermissionPolicy { return this.playlistPermissionPolicy.handle(request, user); } - if (isPermission(request.permission, azureSitesActionPermission)) { - return createCatalogConditionalDecision( - request.permission, - catalogConditions.isEntityOwner({ - claims: user?.identity.ownershipEntityRefs ?? [], - }), - ); - } - return { result: AuthorizeResult.ALLOW, }; From 9f9063c990ef3a3a0002d57d6eafeff6e22621fb Mon Sep 17 00:00:00 2001 From: Deepankumar Loganathan Date: Thu, 9 Nov 2023 19:49:54 +0100 Subject: [PATCH 06/13] test cases fixed Signed-off-by: Deepankumar Loganathan --- .../AzureSitesOverviewTable.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugins/azure-sites/src/components/AzureSitesOverviewTableComponent/AzureSitesOverviewTable.test.tsx b/plugins/azure-sites/src/components/AzureSitesOverviewTableComponent/AzureSitesOverviewTable.test.tsx index f161da5ef0..7676169551 100644 --- a/plugins/azure-sites/src/components/AzureSitesOverviewTableComponent/AzureSitesOverviewTable.test.tsx +++ b/plugins/azure-sites/src/components/AzureSitesOverviewTableComponent/AzureSitesOverviewTable.test.tsx @@ -58,6 +58,12 @@ jest.mock('@backstage/plugin-catalog-react/alpha', () => ({ }, })); +jest.mock('@backstage/catalog-model', () => ({ + stringifyEntityRef: () => { + return {}; + }, +})); + const config = { getString: (_: string) => 'https://test-url', }; From c709fcb5e47e4d473c2a564ad18c8bbfe5754716 Mon Sep 17 00:00:00 2001 From: Deepankumar Loganathan Date: Mon, 13 Nov 2023 23:10:54 +0100 Subject: [PATCH 07/13] azure-sites-backend permission as optional Signed-off-by: Deepankumar Loganathan --- plugins/azure-sites-backend/api-report.md | 2 +- .../azure-sites-backend/src/service/router.ts | 42 ++++++++++--------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/plugins/azure-sites-backend/api-report.md b/plugins/azure-sites-backend/api-report.md index 04cd22596a..900a14fbc2 100644 --- a/plugins/azure-sites-backend/api-report.md +++ b/plugins/azure-sites-backend/api-report.md @@ -62,7 +62,7 @@ export interface RouterOptions { // (undocumented) logger: Logger; // (undocumented) - permissions: PermissionEvaluator; + permissions?: PermissionEvaluator; } // (No @packageDocumentation comment for this package) diff --git a/plugins/azure-sites-backend/src/service/router.ts b/plugins/azure-sites-backend/src/service/router.ts index fcab14a97f..0a05009f95 100644 --- a/plugins/azure-sites-backend/src/service/router.ts +++ b/plugins/azure-sites-backend/src/service/router.ts @@ -39,7 +39,7 @@ export interface RouterOptions { logger: Logger; azureSitesApi: AzureSitesApi; catalogApi: CatalogApi; - permissions: PermissionEvaluator; + permissions?: PermissionEvaluator; } /** @public */ @@ -84,15 +84,17 @@ export async function createRouter( throw new NotAllowedError(); } - const decision = ( - await permissions.authorize( - [{ permission: azureSitesActionPermission, resourceRef }], - { - token, - }, - ) - )[0]; - if (decision.result === AuthorizeResult.DENY) { + const decision = permissions + ? ( + await permissions.authorize( + [{ permission: azureSitesActionPermission, resourceRef }], + { + token, + }, + ) + )[0] + : undefined; + if (decision && decision.result === AuthorizeResult.DENY) { throw new NotAllowedError('Unauthorized'); } @@ -133,15 +135,17 @@ export async function createRouter( throw new NotAllowedError(); } - const decision = ( - await permissions.authorize( - [{ permission: azureSitesActionPermission, resourceRef }], - { - token, - }, - ) - )[0]; - if (decision.result === AuthorizeResult.DENY) { + const decision = permissions + ? ( + await permissions.authorize( + [{ permission: azureSitesActionPermission, resourceRef }], + { + token, + }, + ) + )[0] + : undefined; + if (decision && decision.result === AuthorizeResult.DENY) { throw new NotAllowedError('Unauthorized'); } From 6c08802adbf84db5820687b152908c12abf8852d Mon Sep 17 00:00:00 2001 From: Deepankumar Loganathan Date: Wed, 15 Nov 2023 16:28:10 +0100 Subject: [PATCH 08/13] fixed: validateSite logic Signed-off-by: Deepankumar Loganathan --- .../azure-sites-backend/src/service/router.ts | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/plugins/azure-sites-backend/src/service/router.ts b/plugins/azure-sites-backend/src/service/router.ts index 0a05009f95..7fbd4d77af 100644 --- a/plugins/azure-sites-backend/src/service/router.ts +++ b/plugins/azure-sites-backend/src/service/router.ts @@ -19,8 +19,7 @@ import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; -import { stringifyEntityRef } from '@backstage/catalog-model'; -import { NotAllowedError } from '@backstage/errors'; +import { InputError, NotAllowedError, NotFoundError } from '@backstage/errors'; import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; import { PermissionEvaluator, @@ -71,23 +70,30 @@ export async function createRouter( request.header('authorization'), ); const entityRef = request.body.entityRef; + if (typeof entityRef !== 'string') { + throw new InputError('Invalid entityRef, not a string'); + } const entity = await catalogApi.getEntityByRef(entityRef); if (entity) { const annotationName = entity.metadata?.annotations?.[AZURE_WEB_SITE_NAME_ANNOTATION]; - const resourceRef = stringifyEntityRef(entity); if ( annotationName && - (await azureSitesApi.validateSite(annotationName, name)) + !(await azureSitesApi.validateSite(annotationName, name)) ) { - throw new NotAllowedError(); + throw new NotFoundError(); } const decision = permissions ? ( await permissions.authorize( - [{ permission: azureSitesActionPermission, resourceRef }], + [ + { + permission: azureSitesActionPermission, + resourceRef: entityRef, + }, + ], { token, }, @@ -109,7 +115,7 @@ export async function createRouter( }), ); } else { - throw new NotAllowedError(); + throw new NotFoundError(); } }, ); @@ -122,23 +128,31 @@ export async function createRouter( ); const entityRef = request.body.entityRef; + if (typeof entityRef !== 'string') { + throw new InputError('Invalid entityRef, not a string'); + } const entity = await catalogApi.getEntityByRef(entityRef); if (entity) { const annotationName = entity.metadata.annotations?.[AZURE_WEB_SITE_NAME_ANNOTATION]; - const resourceRef = stringifyEntityRef(entity); + if ( annotationName && - (await azureSitesApi.validateSite(annotationName, name)) + !(await azureSitesApi.validateSite(annotationName, name)) ) { - throw new NotAllowedError(); + throw new NotFoundError(); } const decision = permissions ? ( await permissions.authorize( - [{ permission: azureSitesActionPermission, resourceRef }], + [ + { + permission: azureSitesActionPermission, + resourceRef: entityRef, + }, + ], { token, }, @@ -160,7 +174,7 @@ export async function createRouter( }), ); } else { - throw new NotAllowedError(); + throw new NotFoundError(); } }, ); From ab094515a602f9e34948b0cdbefc51ff0b0a1aac Mon Sep 17 00:00:00 2001 From: Deepankumar Loganathan Date: Mon, 20 Nov 2023 21:50:52 +0100 Subject: [PATCH 09/13] token passed in getEntityByRef Signed-off-by: Deepankumar Loganathan --- plugins/azure-sites-backend/src/service/router.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/azure-sites-backend/src/service/router.ts b/plugins/azure-sites-backend/src/service/router.ts index 7fbd4d77af..67a4152eba 100644 --- a/plugins/azure-sites-backend/src/service/router.ts +++ b/plugins/azure-sites-backend/src/service/router.ts @@ -73,7 +73,7 @@ export async function createRouter( if (typeof entityRef !== 'string') { throw new InputError('Invalid entityRef, not a string'); } - const entity = await catalogApi.getEntityByRef(entityRef); + const entity = await catalogApi.getEntityByRef(entityRef, { token }); if (entity) { const annotationName = @@ -131,7 +131,7 @@ export async function createRouter( if (typeof entityRef !== 'string') { throw new InputError('Invalid entityRef, not a string'); } - const entity = await catalogApi.getEntityByRef(entityRef); + const entity = await catalogApi.getEntityByRef(entityRef, { token }); if (entity) { const annotationName = From 22f20949628650271f48d3f357ac45ad32704547 Mon Sep 17 00:00:00 2001 From: Deepankumar Loganathan Date: Tue, 28 Nov 2023 21:56:47 +0100 Subject: [PATCH 10/13] updated azure sites backend types Signed-off-by: Deepankumar Loganathan --- plugins/azure-sites-backend/README.md | 2 +- plugins/azure-sites-backend/src/service/router.ts | 4 ++-- plugins/azure-sites-common/api-report.md | 9 ++++++++- plugins/azure-sites-common/src/types.ts | 9 ++++++++- plugins/azure-sites/api-report.md | 10 +++++----- plugins/azure-sites/src/api/AzureSitesApi.ts | 6 +++--- .../azure-sites/src/api/AzureSitesApiBackendClient.ts | 6 +++--- 7 files changed, 30 insertions(+), 16 deletions(-) diff --git a/plugins/azure-sites-backend/README.md b/plugins/azure-sites-backend/README.md index f1e688bd79..5017be8a8f 100644 --- a/plugins/azure-sites-backend/README.md +++ b/plugins/azure-sites-backend/README.md @@ -66,7 +66,7 @@ Here's how to get the backend plugin up and running: ```ts import azureSites from './plugins/azure-sites'; - // Removed for clairty... + // Removed for clarity... async function main() { // ... diff --git a/plugins/azure-sites-backend/src/service/router.ts b/plugins/azure-sites-backend/src/service/router.ts index 67a4152eba..662332344b 100644 --- a/plugins/azure-sites-backend/src/service/router.ts +++ b/plugins/azure-sites-backend/src/service/router.ts @@ -77,7 +77,7 @@ export async function createRouter( if (entity) { const annotationName = - entity.metadata?.annotations?.[AZURE_WEB_SITE_NAME_ANNOTATION]; + entity.metadata.annotations?.[AZURE_WEB_SITE_NAME_ANNOTATION]; if ( annotationName && !(await azureSitesApi.validateSite(annotationName, name)) @@ -141,7 +141,7 @@ export async function createRouter( annotationName && !(await azureSitesApi.validateSite(annotationName, name)) ) { - throw new NotFoundError(); + throw new NotFoundError('annotation mismatched!'); } const decision = permissions diff --git a/plugins/azure-sites-common/api-report.md b/plugins/azure-sites-common/api-report.md index 31ada5b0ce..e3f582107e 100644 --- a/plugins/azure-sites-common/api-report.md +++ b/plugins/azure-sites-common/api-report.md @@ -24,6 +24,14 @@ export type AzureSite = { tags: {}; }; +// @public (undocumented) +export type AzureSiteBackendRequest = { + subscription: string; + resourceGroup: string; + name: string; + entityRef: string; +}; + // @public (undocumented) export type AzureSiteListRequest = { name: string; @@ -45,7 +53,6 @@ export type AzureSiteStartStopRequest = { subscription: string; resourceGroup: string; name: string; - entityRef?: string; }; // (No @packageDocumentation comment for this package) diff --git a/plugins/azure-sites-common/src/types.ts b/plugins/azure-sites-common/src/types.ts index dc540599cb..4273085aab 100644 --- a/plugins/azure-sites-common/src/types.ts +++ b/plugins/azure-sites-common/src/types.ts @@ -45,5 +45,12 @@ export type AzureSiteStartStopRequest = { subscription: string; resourceGroup: string; name: string; - entityRef?: string; +}; + +/** @public */ +export type AzureSiteBackendRequest = { + subscription: string; + resourceGroup: string; + name: string; + entityRef: string; }; diff --git a/plugins/azure-sites/api-report.md b/plugins/azure-sites/api-report.md index abc10ec723..92e785caa2 100644 --- a/plugins/azure-sites/api-report.md +++ b/plugins/azure-sites/api-report.md @@ -6,9 +6,9 @@ /// import { ApiRef } from '@backstage/core-plugin-api'; +import { AzureSiteBackendRequest } from '@backstage/plugin-azure-sites-common'; import { AzureSiteListRequest } from '@backstage/plugin-azure-sites-common'; import { AzureSiteListResponse } from '@backstage/plugin-azure-sites-common'; -import { AzureSiteStartStopRequest } from '@backstage/plugin-azure-sites-common'; import { BackstagePlugin } from '@backstage/core-plugin-api'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; @@ -23,8 +23,8 @@ export const azureSiteApiRef: ApiRef; // @public (undocumented) export type AzureSitesApi = { list: (request: AzureSiteListRequest) => Promise; - start: (request: AzureSiteStartStopRequest) => Promise; - stop: (request: AzureSiteStartStopRequest) => Promise; + start: (request: AzureSiteBackendRequest) => Promise; + stop: (request: AzureSiteBackendRequest) => Promise; }; // @public (undocumented) @@ -36,9 +36,9 @@ export class AzureSitesApiBackendClient implements AzureSitesApi { // (undocumented) list(request: AzureSiteListRequest): Promise; // (undocumented) - start(request: AzureSiteStartStopRequest): Promise; + start(request: AzureSiteBackendRequest): Promise; // (undocumented) - stop(request: AzureSiteStartStopRequest): Promise; + stop(request: AzureSiteBackendRequest): Promise; } // @public (undocumented) diff --git a/plugins/azure-sites/src/api/AzureSitesApi.ts b/plugins/azure-sites/src/api/AzureSitesApi.ts index 87a3b46a8d..067b2836a4 100644 --- a/plugins/azure-sites/src/api/AzureSitesApi.ts +++ b/plugins/azure-sites/src/api/AzureSitesApi.ts @@ -18,7 +18,7 @@ import { createApiRef } from '@backstage/core-plugin-api'; import { AzureSiteListRequest, AzureSiteListResponse, - AzureSiteStartStopRequest, + AzureSiteBackendRequest, } from '@backstage/plugin-azure-sites-common'; /** @public */ @@ -29,6 +29,6 @@ export const azureSiteApiRef = createApiRef({ /** @public */ export type AzureSitesApi = { list: (request: AzureSiteListRequest) => Promise; - start: (request: AzureSiteStartStopRequest) => Promise; - stop: (request: AzureSiteStartStopRequest) => Promise; + start: (request: AzureSiteBackendRequest) => Promise; + stop: (request: AzureSiteBackendRequest) => Promise; }; diff --git a/plugins/azure-sites/src/api/AzureSitesApiBackendClient.ts b/plugins/azure-sites/src/api/AzureSitesApiBackendClient.ts index b482e80d92..59158df41a 100644 --- a/plugins/azure-sites/src/api/AzureSitesApiBackendClient.ts +++ b/plugins/azure-sites/src/api/AzureSitesApiBackendClient.ts @@ -18,7 +18,7 @@ import { AzureSitesApi } from './AzureSitesApi'; import { AzureSiteListRequest, AzureSiteListResponse, - AzureSiteStartStopRequest, + AzureSiteBackendRequest, } from '@backstage/plugin-azure-sites-common'; import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; @@ -34,7 +34,7 @@ export class AzureSitesApiBackendClient implements AzureSitesApi { this.identityApi = options.identityApi; } - async stop(request: AzureSiteStartStopRequest): Promise { + async stop(request: AzureSiteBackendRequest): Promise { const url = `${await this.discoveryApi.getBaseUrl('azure-sites')}/${ request.subscription }/${request.resourceGroup}/${request.name}/stop`; @@ -51,7 +51,7 @@ export class AzureSitesApiBackendClient implements AzureSitesApi { }), }); } - async start(request: AzureSiteStartStopRequest): Promise { + async start(request: AzureSiteBackendRequest): Promise { const url = `${await this.discoveryApi.getBaseUrl('azure-sites')}/${ request.subscription }/${request.resourceGroup}/${request.name}/start`; From a0b718b39846d0921ab547be906cd6cb335f92c0 Mon Sep 17 00:00:00 2001 From: Deepankumar Loganathan Date: Sat, 23 Dec 2023 11:23:25 +0100 Subject: [PATCH 11/13] updated yarn lock Signed-off-by: Deepankumar Loganathan --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index 49042fa725..46dfdcaacf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5013,6 +5013,7 @@ __metadata: "@backstage/plugin-permission-common": "workspace:^" "@backstage/plugin-permission-react": "workspace:^" "@backstage/test-utils": "workspace:^" + "@backstage/theme": "workspace:^" "@material-ui/core": ^4.12.2 "@material-ui/icons": ^4.9.1 "@material-ui/lab": 4.0.0-alpha.61 From 848c06e108b153c697fa5444f1474411c84ac33a Mon Sep 17 00:00:00 2001 From: Deepankumar Loganathan Date: Tue, 16 Jan 2024 15:33:19 +0100 Subject: [PATCH 12/13] updated changeset with example Signed-off-by: Deepankumar Loganathan --- .changeset/tidy-cooks-mix.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.changeset/tidy-cooks-mix.md b/.changeset/tidy-cooks-mix.md index 41a2220547..97e95331f2 100644 --- a/.changeset/tidy-cooks-mix.md +++ b/.changeset/tidy-cooks-mix.md @@ -4,4 +4,29 @@ '@backstage/plugin-azure-sites': minor --- -Implemented Permission framework to protect start and stop `Azure sites`. +`Azure Site` Start and Stop action is now protected with `Permission framework`. Also `catalogApi` is required in `createRouter` when adding this plugin. + +The below example illustrate that the action is forbids anyone but the owner of the catalog entity to trigger actions towards a site tied to an entity. + +```typescript + // packages/backend/src/plugins/permission.ts + import { azureSitesActionPermission } from '@backstage/plugin-azure-sites-common'; + ... + class TestPermissionPolicy implements PermissionPolicy { + async handle(request: PolicyQuery, user?: BackstageIdentityResponse): Promise { + if (isPermission(request.permission, azureSitesActionPermission)) { + return createCatalogConditionalDecision( + request.permission, + catalogConditions.isEntityOwner({ + claims: user?.identity.ownershipEntityRefs ?? [], + }), + ); + } + ... + return { + result: AuthorizeResult.ALLOW, + }; + } + ... + } +``` From 28610f4aee9ce90609b79c04463ce1ed03c75ad0 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 24 Jan 2024 10:48:35 +0100 Subject: [PATCH 13/13] chore: fixing changesets and fixing up release Signed-off-by: blam --- .changeset/tidy-cooks-mix.md | 10 +++++----- .changeset/tidy-cooks-mixed.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) create mode 100644 .changeset/tidy-cooks-mixed.md diff --git a/.changeset/tidy-cooks-mix.md b/.changeset/tidy-cooks-mix.md index 97e95331f2..873de64a45 100644 --- a/.changeset/tidy-cooks-mix.md +++ b/.changeset/tidy-cooks-mix.md @@ -1,12 +1,12 @@ --- -'@backstage/plugin-azure-sites-backend': minor -'@backstage/plugin-azure-sites-common': minor -'@backstage/plugin-azure-sites': minor +'@backstage/plugin-azure-sites-backend': patch +'@backstage/plugin-azure-sites-common': patch +'@backstage/plugin-azure-sites': patch --- -`Azure Site` Start and Stop action is now protected with `Permission framework`. Also `catalogApi` is required in `createRouter` when adding this plugin. +Azure Sites `start` and `stop` action is now protected with the Permissions framework. -The below example illustrate that the action is forbids anyone but the owner of the catalog entity to trigger actions towards a site tied to an entity. +The below example describes an action that forbids anyone but the owner of the catalog entity to trigger actions towards a site tied to an entity. ```typescript // packages/backend/src/plugins/permission.ts diff --git a/.changeset/tidy-cooks-mixed.md b/.changeset/tidy-cooks-mixed.md new file mode 100644 index 0000000000..a72f1ee0df --- /dev/null +++ b/.changeset/tidy-cooks-mixed.md @@ -0,0 +1,31 @@ +--- +'@backstage/plugin-azure-sites-backend': minor +--- + +**BREAKING**: `catalogApi` and `permissionsApi` are now a requirement to be passed through to the `createRouter` function. + +You can fix the typescript issues by passing through the required dependencies like the below `diff` shows: + +```diff + import { + createRouter, + AzureSitesApi, + } from '@backstage/plugin-azure-sites-backend'; + import { Router } from 'express'; + import { PluginEnvironment } from '../types'; + + export default async function createPlugin( + env: PluginEnvironment, + ): Promise { ++ const catalogClient = new CatalogClient({ ++ discoveryApi: env.discovery, ++ }); + + return await createRouter({ + logger: env.logger, + azureSitesApi: AzureSitesApi.fromConfig(env.config), ++ catalogApi: catalogClient, ++ permissionsApi: env.permissions, + }); + } +```