diff --git a/.changeset/tidy-cooks-mix.md b/.changeset/tidy-cooks-mix.md new file mode 100644 index 0000000000..873de64a45 --- /dev/null +++ b/.changeset/tidy-cooks-mix.md @@ -0,0 +1,32 @@ +--- +'@backstage/plugin-azure-sites-backend': patch +'@backstage/plugin-azure-sites-common': patch +'@backstage/plugin-azure-sites': patch +--- + +Azure Sites `start` and `stop` action is now protected with the Permissions framework. + +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 + 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, + }; + } + ... + } +``` 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, + }); + } +``` 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..54c7746cb2 --- /dev/null +++ b/packages/backend/src/plugins/azure-sites.ts @@ -0,0 +1,34 @@ +/* + * 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'; +import { CatalogClient } from '@backstage/catalog-client'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + const catalogApi = new CatalogClient({ discoveryApi: env.discovery }); + return await createRouter({ + logger: env.logger, + permissions: env.permissions, + azureSitesApi: AzureSitesApi.fromConfig(env.config), + catalogApi, + }); +} diff --git a/plugins/azure-sites-backend/README.md b/plugins/azure-sites-backend/README.md index 23152f9760..5017be8a8f 100644 --- a/plugins/azure-sites-backend/README.md +++ b/plugins/azure-sites-backend/README.md @@ -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,7 +64,7 @@ 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... @@ -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/api-report.md b/plugins/azure-sites-backend/api-report.md index 751ff3f033..900a14fbc2 100644 --- a/plugins/azure-sites-backend/api-report.md +++ b/plugins/azure-sites-backend/api-report.md @@ -6,9 +6,11 @@ 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'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; // @public (undocumented) export class AzureSitesApi { @@ -21,6 +23,8 @@ export class AzureSitesApi { start(request: AzureSiteStartStopRequest): Promise; // (undocumented) stop(request: AzureSiteStartStopRequest): Promise; + // (undocumented) + validateSite(annotationName: string, siteName: string): Promise; } // @public (undocumented) @@ -54,7 +58,11 @@ export interface RouterOptions { // (undocumented) azureSitesApi: AzureSitesApi; // (undocumented) + catalogApi: CatalogApi; + // (undocumented) logger: Logger; + // (undocumented) + permissions?: PermissionEvaluator; } // (No @packageDocumentation comment for this package) diff --git a/plugins/azure-sites-backend/package.json b/plugins/azure-sites-backend/package.json index c4f689129d..2599bfc360 100644 --- a/plugins/azure-sites-backend/package.json +++ b/plugins/azure-sites-backend/package.json @@ -36,8 +36,14 @@ "@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:^", + "@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..662332344b 100644 --- a/plugins/azure-sites-backend/src/service/router.ts +++ b/plugins/azure-sites-backend/src/service/router.ts @@ -19,19 +19,33 @@ import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; +import { InputError, NotAllowedError, NotFoundError } 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 { CatalogApi } from '@backstage/catalog-client'; + import { AzureSitesApi } from '../api'; /** @public */ export interface RouterOptions { logger: Logger; azureSitesApi: AzureSitesApi; + catalogApi: CatalogApi; + permissions?: PermissionEvaluator; } /** @public */ export async function createRouter( options: RouterOptions, ): Promise { - const { logger, azureSitesApi } = options; + const { logger, azureSitesApi, permissions, catalogApi } = options; const router = Router(); router.use(express.json()); @@ -52,28 +66,116 @@ export async function createRouter( '/:subscription/:resourceGroup/:name/start', async (request, response) => { const { subscription, resourceGroup, name } = request.params; - console.log('starting...'); - response.json( - await azureSitesApi.start({ - subscription, - resourceGroup, - name, - }), + const token = getBearerTokenFromAuthorizationHeader( + 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, { token }); + + if (entity) { + const annotationName = + entity.metadata.annotations?.[AZURE_WEB_SITE_NAME_ANNOTATION]; + if ( + annotationName && + !(await azureSitesApi.validateSite(annotationName, name)) + ) { + throw new NotFoundError(); + } + + const decision = permissions + ? ( + await permissions.authorize( + [ + { + permission: azureSitesActionPermission, + resourceRef: entityRef, + }, + ], + { + token, + }, + ) + )[0] + : undefined; + if (decision && 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 NotFoundError(); + } }, ); router.post( '/:subscription/:resourceGroup/:name/stop', async (request, response) => { const { subscription, resourceGroup, name } = request.params; - console.log('stopping...'); - response.json( - await azureSitesApi.stop({ - subscription, - resourceGroup, - name, - }), + const token = getBearerTokenFromAuthorizationHeader( + 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, { token }); + + if (entity) { + const annotationName = + entity.metadata.annotations?.[AZURE_WEB_SITE_NAME_ANNOTATION]; + + if ( + annotationName && + !(await azureSitesApi.validateSite(annotationName, name)) + ) { + throw new NotFoundError('annotation mismatched!'); + } + + const decision = permissions + ? ( + await permissions.authorize( + [ + { + permission: azureSitesActionPermission, + resourceRef: entityRef, + }, + ], + { + token, + }, + ) + )[0] + : undefined; + if (decision && 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 NotFoundError(); + } }, ); router.use(errorHandler()); diff --git a/plugins/azure-sites-backend/src/service/standaloneServer.ts b/plugins/azure-sites-backend/src/service/standaloneServer.ts index 1998bee023..202c1a128d 100644 --- a/plugins/azure-sites-backend/src/service/standaloneServer.ts +++ b/plugins/azure-sites-backend/src/service/standaloneServer.ts @@ -17,11 +17,15 @@ 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'; +import { CatalogClient } from '@backstage/catalog-client'; export interface ServerOptions { port: number; @@ -34,10 +38,21 @@ 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, + }); + 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 64b4275085..e3f582107e 100644 --- a/plugins/azure-sites-common/api-report.md +++ b/plugins/azure-sites-common/api-report.md @@ -3,6 +3,11 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +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; @@ -19,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; @@ -29,6 +42,12 @@ 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; 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..4273085aab 100644 --- a/plugins/azure-sites-common/src/types.ts +++ b/plugins/azure-sites-common/src/types.ts @@ -46,3 +46,11 @@ export type AzureSiteStartStopRequest = { resourceGroup: string; name: string; }; + +/** @public */ +export type AzureSiteBackendRequest = { + subscription: string; + resourceGroup: string; + name: string; + entityRef: string; +}; 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/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/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/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 b66a3cbb3f..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,30 +34,38 @@ 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`; const { token: accessToken } = await this.identityApi.getCredentials(); + const entityRef = request.entityRef; await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(accessToken && { Authorization: `Bearer ${accessToken}` }), }, + body: JSON.stringify({ + entityRef, + }), }); } - 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`; const { token: accessToken } = await this.identityApi.getCredentials(); + const entityRef = request.entityRef; await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(accessToken && { Authorization: `Bearer ${accessToken}` }), }, + body: JSON.stringify({ + entityRef, + }), }); } 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..7676169551 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,39 @@ 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 }; + }, +})); + +jest.mock('@backstage/catalog-model', () => ({ + stringifyEntityRef: () => { + return {}; + }, +})); + 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..2f891ec3d2 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,9 @@ 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'; +import { stringifyEntityRef } from '@backstage/catalog-model'; type States = 'Waiting' | 'Running' | 'Paused' | 'Failed' | 'Stopped'; type Kinds = 'app' | 'functionapp'; @@ -117,6 +123,8 @@ const ActionButtons = ({ onMenuItemClick: Dispatch>; }) => { const azureApi = useApi(azureSiteApiRef); + const { entity } = useEntity(); + const entityRef = stringifyEntityRef(entity); const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); @@ -132,6 +140,7 @@ const ActionButtons = ({ name: value.name, resourceGroup: value.resourceGroup, subscription: value.subscription, + entityRef: entityRef, }); onMenuItemClick('Starting, this may take some time...'); handleClose(); @@ -141,11 +150,15 @@ const ActionButtons = ({ name: value.name, resourceGroup: value.resourceGroup, subscription: value.subscription, + entityRef: entityRef, }); 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..46dfdcaacf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4969,9 +4969,15 @@ __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:^" + "@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 +4992,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,7 +5010,10 @@ __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:^" + "@backstage/theme": "workspace:^" "@material-ui/core": ^4.12.2 "@material-ui/icons": ^4.9.1 "@material-ui/lab": 4.0.0-alpha.61 @@ -26723,6 +26735,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:^"