Azure Site Plugin Auth permission added

Signed-off-by: Deepankumar <deepan0433@gmail.com>
This commit is contained in:
Deepankumar
2023-10-19 19:32:45 +00:00
committed by blam
parent e5f864e399
commit 94e17d98dc
22 changed files with 303 additions and 22 deletions
+1
View File
@@ -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:^",
+3
View File
@@ -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));
@@ -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<Router> {
return await createRouter({
...env,
azureSitesApi: AzureSitesApi.fromConfig(env.config),
});
}
@@ -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,
};
+33 -8
View File
@@ -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<PolicyDecision> {
+ async handle(request: PolicyQuery, user?: BackstageIdentityResponse): Promise<PolicyDecision> {
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"}`.
+5
View File
@@ -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",
@@ -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;
}
}
@@ -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<express.Router> {
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,
@@ -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<Server> {
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),
});
+5
View File
@@ -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:^"
},
+2
View File
@@ -15,3 +15,5 @@
*/
export * from './types';
export * from './utils';
export * from './permissions';
@@ -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];
+3
View File
@@ -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;
};
+20
View File
@@ -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';
+1 -1
View File
@@ -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
+3
View File
@@ -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",
@@ -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<void> {
@@ -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,
}),
});
}
@@ -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,
@@ -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<PermissionApi> = { authorize: mockAuthorize };
const apis: [AnyApiRef, Partial<unknown>][] = [
[errorApiRef, errorApiMock],
[configApiRef, config],
[azureSiteApiRef, azureSitesApiMock],
[identityApiRef, identityApiMock],
[permissionApiRef, permissionApi],
];
describe('AzureSitesOverviewWidget', () => {
@@ -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<React.SetStateAction<string | null>>;
}) => {
const azureApi = useApi(azureSiteApiRef);
const { entity } = useEntity();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(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 (
<div>
<IconButton
@@ -173,14 +184,14 @@ const ActionButtons = ({
},
}}
>
{value.state !== 'Running' && (
<MenuItem key="start" onClick={start}>
{value.state !== 'Running' && !loadingPermission && (
<MenuItem key="start" onClick={start} disabled={!canDoAction}>
<StartIcon />
&nbsp;Start
</MenuItem>
)}
{value.state !== 'Stopped' && (
<MenuItem key="stop" onClick={stop}>
{value.state !== 'Stopped' && !loadingPermission && (
<MenuItem key="stop" onClick={stop} disabled={!canDoAction}>
<StopIcon />
&nbsp;Stop
</MenuItem>
@@ -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] ?? '';
+10
View File
@@ -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