This commit is contained in:
NetPenguins
2020-09-05 08:21:14 -04:00
311 changed files with 13768 additions and 3837 deletions
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-api-docs",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,16 +20,15 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.20",
"@backstage/core": "^0.1.1-alpha.20",
"@backstage/plugin-catalog": "^0.1.1-alpha.20",
"@backstage/theme": "^0.1.1-alpha.20",
"@backstage/catalog-model": "^0.1.1-alpha.21",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/plugin-catalog": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@kyma-project/asyncapi-react": "^0.11.0",
"@material-icons/font": "^1.0.2",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "6.0.0-beta.0",
@@ -37,14 +36,15 @@
"swagger-ui-react": "^3.31.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/dev-utils": "^0.1.1-alpha.20",
"@backstage/test-utils": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/dev-utils": "^0.1.1-alpha.21",
"@backstage/test-utils": "^0.1.1-alpha.21",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/react": "^16.9",
"@types/swagger-ui-react": "^3.23.3",
"jest-fetch-mock": "^3.0.3"
},
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { Table, TableColumn } from '@backstage/core';
import { Link } from '@material-ui/core';
@@ -16,13 +16,15 @@
import { ApiEntityV1alpha1 } from '@backstage/catalog-model';
import { InfoCard } from '@backstage/core';
import React, { FC } from 'react';
import React from 'react';
import { ApiDefinitionWidget } from '../ApiDefinitionWidget/ApiDefinitionWidget';
export const ApiDefinitionCard: FC<{
type Props = {
title?: string;
apiEntity: ApiEntityV1alpha1;
}> = ({ title, apiEntity }) => {
};
export const ApiDefinitionCard = ({ title, apiEntity }: Props) => {
const type = apiEntity?.spec?.type || '';
const definition = apiEntity?.spec?.definition || '';
@@ -14,15 +14,17 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import React from 'react';
import { AsyncApiDefinitionWidget } from '../AsyncApiDefinitionWidget/AsyncApiDefinitionWidget';
import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget/OpenApiDefinitionWidget';
import { PlainApiDefinitionWidget } from '../PlainApiDefinitionWidget/PlainApiDefinitionWidget';
export const ApiDefinitionWidget: FC<{
type Props = {
type: string;
definition: string;
}> = ({ type, definition }) => {
};
export const ApiDefinitionWidget = ({ type, definition }: Props) => {
switch (type) {
case 'openapi':
return <OpenApiDefinitionWidget definition={definition} />;
@@ -31,6 +33,8 @@ export const ApiDefinitionWidget: FC<{
return <AsyncApiDefinitionWidget definition={definition} />;
default:
return <PlainApiDefinitionWidget definition={definition} />;
return (
<PlainApiDefinitionWidget definition={definition} language={type} />
);
}
};
@@ -29,7 +29,7 @@ import {
import { catalogApiRef } from '@backstage/plugin-catalog';
import { Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { FC, useEffect } from 'react';
import React, { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { ApiDefinitionCard } from '../ApiDefinitionCard/ApiDefinitionCard';
@@ -59,15 +59,18 @@ export const getPageTheme = (entity?: Entity): PageTheme => {
return pageTheme[themeKey] ?? pageTheme.home;
};
const EntityPageTitle: FC<{ title: string; entity: Entity | undefined }> = ({
title,
}) => (
type EntityPageTitleProps = {
title: string;
entity: Entity | undefined;
};
const EntityPageTitle = ({ title }: EntityPageTitleProps) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
</Box>
);
export const ApiEntityPage: FC<{}> = () => {
export const ApiEntityPage = () => {
const { optionalNamespaceAndName } = useParams() as {
optionalNamespaceAndName: string;
};
@@ -15,7 +15,7 @@
*/
import AsyncApi from '@kyma-project/asyncapi-react';
import React, { FC } from 'react';
import React from 'react';
import { makeStyles, fade } from '@material-ui/core/styles';
import '@kyma-project/asyncapi-react/lib/styles/fiori.css';
@@ -135,9 +135,11 @@ const useStyles = makeStyles(theme => ({
},
}));
export const AsyncApiDefinitionWidget: FC<{
type Props = {
definition: any;
}> = ({ definition }) => {
};
export const AsyncApiDefinitionWidget = ({ definition }: Props) => {
const classes = useStyles();
return (
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { FC, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
@@ -65,9 +65,11 @@ const useStyles = makeStyles(theme => ({
},
}));
export const OpenApiDefinitionWidget: FC<{
type Props = {
definition: any;
}> = ({ definition }) => {
};
export const OpenApiDefinitionWidget = ({ definition }: Props) => {
const classes = useStyles();
// Due to a bug in the swagger-ui-react component, the component needs
@@ -15,10 +15,13 @@
*/
import { CodeSnippet } from '@backstage/core';
import React, { FC } from 'react';
import React from 'react';
export const PlainApiDefinitionWidget: FC<{
type Props = {
definition: any;
}> = ({ definition }) => {
return <CodeSnippet text={definition} language="yaml" />;
language: string;
};
export const PlainApiDefinitionWidget = ({ definition, language }: Props) => {
return <CodeSnippet text={definition} language={language} />;
};
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
+32
View File
@@ -0,0 +1,32 @@
# App backend plugin
This backend plugin can be installed to serve static content of a Backstage app.
## Installation
Add both this package and your local frontend app package as dependencies to your backend, for example
```bash
yarn add @backstage/plugin-app-backend example-app
```
By adding the app package as a dependency we ensure that it is built as part of the backend, and that it can be resolved at runtime.
Now add the plugin router to your app, creating it for example like this:
```ts
const router = await createRouter({
logger,
appPackageName: 'example-app',
});
```
And registering it like this:
```ts
createServiceBuilder(module)
...
.addRouter('', router);
```
Be sure to register the app router last, as it serves content for HTML5-mode navigation, i.e. falling back to serving `index.html` for any route that can't be found.
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@backstage/plugin-app-backend",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"scripts": {
"start": "backstage-cli backend:dev",
"build": "backstage-cli backend:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.21",
"@backstage/config-loader": "^0.1.1-alpha.21",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.21",
"@types/supertest": "^2.0.8",
"msw": "^0.19.5",
"supertest": "^4.0.2"
},
"files": [
"dist",
"static"
]
}
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { WorkflowRunsPage } from './WorkflowRunsPage';
export * from './service/router';
+136
View File
@@ -0,0 +1,136 @@
/*
* Copyright 2020 Spotify AB
*
* 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 fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { getVoidLogger } from '@backstage/backend-common';
import { injectEnvConfig } from './config';
jest.mock('fs-extra');
const fsMock = fs as jest.Mocked<typeof fs>;
const readFileMock = (fsMock.readFile as unknown) as jest.MockedFunction<
(name: string) => Promise<string>
>;
const MOCK_DIR = 'mock-dir';
const baseOptions = {
env: {},
staticDir: MOCK_DIR,
logger: getVoidLogger(),
};
describe('injectEnvConfig', () => {
beforeEach(() => {
fsMock.readdir.mockResolvedValue(['main.js']);
});
afterEach(() => {
jest.resetAllMocks();
});
it('should not inject without config', async () => {
await injectEnvConfig(baseOptions);
expect(fsMock.readdir).toHaveBeenCalledTimes(0);
expect(fsMock.readFile).toHaveBeenCalledTimes(0);
expect(fsMock.writeFile).toHaveBeenCalledTimes(0);
});
it('should find the correct file to inject', async () => {
fsMock.readdir.mockResolvedValue([
'before.js',
'not-js.txt',
'main.js',
'after.js',
]);
readFileMock.mockImplementation(async (file: string) => {
if (file.endsWith('main.js')) {
return '"__APP_INJECTED_RUNTIME_CONFIG__"';
}
return 'NO_PLACEHOLDER_HERE';
});
await injectEnvConfig({ ...baseOptions, env: { APP_CONFIG_x: '0' } });
expect(fsMock.readFile).toHaveBeenCalledTimes(2);
expect(fsMock.readFile).toHaveBeenNthCalledWith(
1,
resolvePath(MOCK_DIR, 'before.js'),
'utf8',
);
expect(fsMock.readFile).toHaveBeenNthCalledWith(
2,
resolvePath(MOCK_DIR, 'main.js'),
'utf8',
);
expect(fsMock.writeFile).toHaveBeenCalledTimes(1);
expect(fsMock.writeFile).toHaveBeenCalledWith(
resolvePath(MOCK_DIR, 'main.js'),
'/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":0}"/*__INJECTED_END__*/',
'utf8',
);
// eslint-disable-next-line no-eval
expect(JSON.parse(eval(fsMock.writeFile.mock.calls[0][1]))).toEqual({
x: 0,
});
});
it('should re-inject config', async () => {
fsMock.readdir.mockResolvedValue(['main.js']);
readFileMock.mockResolvedValue(
'JSON.parse("__APP_INJECTED_RUNTIME_CONFIG__")',
);
await injectEnvConfig({
...baseOptions,
env: {
APP_CONFIG_x: '0',
},
});
expect(fsMock.writeFile).toHaveBeenCalledTimes(1);
expect(fsMock.writeFile).toHaveBeenCalledWith(
resolvePath(MOCK_DIR, 'main.js'),
'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":0}"/*__INJECTED_END__*/)',
'utf8',
);
// eslint-disable-next-line no-eval
expect(eval(fsMock.writeFile.mock.calls[0][1])).toEqual({ x: 0 });
readFileMock.mockResolvedValue(fsMock.writeFile.mock.calls[0][1]);
await injectEnvConfig({
...baseOptions,
env: {
APP_CONFIG_x: '1',
APP_CONFIG_y: '2',
},
});
expect(fsMock.writeFile).toHaveBeenCalledTimes(2);
expect(fsMock.writeFile).toHaveBeenLastCalledWith(
resolvePath(MOCK_DIR, 'main.js'),
'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":1,\\"y\\":2}"/*__INJECTED_END__*/)',
'utf8',
);
// eslint-disable-next-line no-eval
expect(eval(fsMock.writeFile.mock.calls[1][1])).toEqual({ x: 1, y: 2 });
});
});
+74
View File
@@ -0,0 +1,74 @@
/*
* Copyright 2020 Spotify AB
*
* 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 fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { readEnvConfig } from '@backstage/config-loader';
import { Logger } from 'winston';
type Options = {
// Environment to read config from
env: { [name: string]: string | undefined };
// Directory of the static JS files to search for file to inject
staticDir: string;
logger: Logger;
};
/**
* Injects config from APP_CONFIG_ env vars, replacing existing
* injected config if it has already been injected.
*/
export async function injectEnvConfig(options: Options) {
const { env, staticDir, logger } = options;
const envConfig = readEnvConfig(env);
if (envConfig.length === 0) {
return;
}
const files = await fs.readdir(staticDir);
const jsFiles = files.filter(file => file.endsWith('.js'));
const [{ data }] = envConfig;
const escapedData = JSON.stringify(data).replace(/("|'|\\)/g, '\\$1');
const injected = `/*__APP_INJECTED_CONFIG_MARKER__*/"${escapedData}"/*__INJECTED_END__*/`;
for (const jsFile of jsFiles) {
const path = resolvePath(staticDir, jsFile);
const content = await fs.readFile(path, 'utf8');
if (content.includes('__APP_INJECTED_RUNTIME_CONFIG__')) {
logger.info(`Injecting env config into ${jsFile}`);
const newContent = content.replace(
'"__APP_INJECTED_RUNTIME_CONFIG__"',
injected,
);
await fs.writeFile(path, newContent, 'utf8');
return;
} else if (content.includes('__APP_INJECTED_CONFIG_MARKER__')) {
logger.info(`Replacing injected env config in ${jsFile}`);
const newContent = content.replace(
/\/\*__APP_INJECTED_CONFIG_MARKER__\*\/.*\/\*__INJECTED_END__\*\//,
injected,
);
await fs.writeFile(path, newContent, 'utf8');
return;
}
}
logger.info('Env config not injected');
}
+33
View File
@@ -0,0 +1,33 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { getRootLogger } from '@backstage/backend-common';
import yn from 'yn';
import { startStandaloneServer } from './service/standaloneServer';
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});
process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
@@ -0,0 +1 @@
!dist
@@ -0,0 +1 @@
this is index.html
@@ -0,0 +1 @@
this is other.html
@@ -0,0 +1 @@
this is main.txt
@@ -0,0 +1,95 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { resolve as resolvePath } from 'path';
import { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import Router from 'express-promise-router';
import request from 'supertest';
import { createRouter } from './router';
jest.mock('../lib/config', () => ({ injectEnvConfig: jest.fn() }));
global.__non_webpack_require__ = {
resolve: () => resolvePath(__dirname, '__fixtures__/app-dir/package.json'),
};
describe('createRouter', () => {
let app: express.Express;
beforeAll(async () => {
const router = await createRouter({
logger: getVoidLogger(),
appPackageName: 'example-app',
});
app = express().use(router);
});
beforeEach(() => {
jest.resetAllMocks();
});
it('returns index.html', async () => {
const response = await request(app).get('/index.html');
expect(response.status).toBe(200);
expect(response.text).toBe('this is index.html\n');
});
it('returns other.html', async () => {
const response = await request(app).get('/other.html');
expect(response.status).toBe(200);
expect(response.text).toBe('this is other.html\n');
});
it('returns index.html if missing', async () => {
const response = await request(app).get('/missing.html');
expect(response.status).toBe(200);
expect(response.text).toBe('this is index.html\n');
});
});
describe('createRouter with static fallback handler', () => {
it('uses static fallback handler', async () => {
const staticFallbackHandler = Router();
staticFallbackHandler.get('/test.txt', (_req, res) => {
res.end('this is test.txt');
});
const router = await createRouter({
logger: getVoidLogger(),
appPackageName: 'example-app',
staticFallbackHandler,
});
const app = express().use(router);
const response1 = await request(app).get('/static/main.txt');
expect(response1.status).toBe(200);
expect(response1.text).toBe('this is main.txt\n');
const response2 = await request(app).get('/static/test.txt');
expect(response2.status).toBe(200);
expect(response2.text).toBe('this is test.txt');
const response3 = await request(app).get('/static/missing.txt');
expect(response3.status).toBe(404);
});
});
+59
View File
@@ -0,0 +1,59 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { resolve as resolvePath } from 'path';
import { notFoundHandler, resolvePackagePath } from '@backstage/backend-common';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { injectEnvConfig } from '../lib/config';
export interface RouterOptions {
logger: Logger;
appPackageName: string;
staticFallbackHandler?: express.Handler;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const appDistDir = resolvePackagePath(options.appPackageName, 'dist');
options.logger.info(`Serving static app content from ${appDistDir}`);
await injectEnvConfig({
env: process.env,
logger: options.logger,
staticDir: resolvePath(appDistDir, 'static'),
});
const router = Router();
// Use a separate router for static content so that a fallback can be provided by backend
const staticRouter = Router();
staticRouter.use(express.static(resolvePath(appDistDir, 'static')));
if (options.staticFallbackHandler) {
staticRouter.use(options.staticFallbackHandler);
}
staticRouter.use(notFoundHandler());
router.use('/static', staticRouter);
router.use(express.static(appDistDir));
router.get('/*', (_req, res) => {
res.sendFile(resolvePath(appDistDir, 'index.html'));
});
return router;
}
@@ -0,0 +1,46 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { createServiceBuilder } from '@backstage/backend-common';
import { Server } from 'http';
import { Logger } from 'winston';
import { createRouter } from './router';
export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}
export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'app-backend' });
logger.debug('Starting application server...');
const router = await createRouter({
logger,
appPackageName: 'example-app',
});
const service = createServiceBuilder(module).addRouter('', router);
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
module.hot?.accept();
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {};
+4 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,10 +20,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.20",
"@backstage/config": "^0.1.1-alpha.20",
"@backstage/backend-common": "^0.1.1-alpha.21",
"@backstage/config": "^0.1.1-alpha.21",
"@types/express": "^4.17.6",
"body-parser": "^1.19.0",
"compression": "^1.7.4",
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
@@ -50,7 +49,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/jwt-decode": "2.2.1",
@@ -15,13 +15,13 @@
*/
import Knex from 'knex';
import path from 'path';
import { utc } from 'moment';
import { resolvePackagePath } from '@backstage/backend-common';
import { AnyJWK, KeyStore, StoredKey } from './types';
const migrationsDir = path.resolve(
require.resolve('@backstage/plugin-auth-backend/package.json'),
'../migrations',
const migrationsDir = resolvePackagePath(
'@backstage/plugin-auth-backend',
'migrations',
);
const TABLE = 'signing_keys';
@@ -0,0 +1,103 @@
/*
* Copyright 2020 Spotify AB
*
* 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 express from 'express';
import { ensuresXRequestedWith, postMessageResponse } from './authFlowHelpers';
import { WebMessageResponse } from './types';
describe('oauth helpers', () => {
describe('postMessageResponse', () => {
const appOrigin = 'http://localhost:3000';
it('should post a message back with payload success', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
response: {
providerInfo: {
accessToken: 'ACCESS_TOKEN',
idToken: 'ID_TOKEN',
expiresInSeconds: 10,
scope: 'email',
},
profile: {
email: 'foo@bar.com',
},
backstageIdentity: {
id: 'a',
idToken: 'a.b.c',
},
},
};
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(3);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
expect.stringContaining(base64Data),
);
});
it('should post a message back with payload error', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
error: new Error('Unknown error occured'),
};
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(3);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
expect.stringContaining(base64Data),
);
});
});
describe('ensuresXRequestedWith', () => {
it('should return false if no header present', () => {
const mockRequest = ({
header: () => jest.fn(),
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
});
it('should return false if header present with incorrect value', () => {
const mockRequest = ({
header: () => 'INVALID',
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
});
it('should return true if header present with correct value', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(true);
});
});
});
@@ -0,0 +1,56 @@
/*
* Copyright 2020 Spotify AB
*
* 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 express from 'express';
import crypto from 'crypto';
import { WebMessageResponse } from './types';
export const postMessageResponse = (
res: express.Response,
appOrigin: string,
response: WebMessageResponse,
) => {
const jsonData = JSON.stringify(response);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
res.setHeader('Content-Type', 'text/html');
res.setHeader('X-Frame-Options', 'sameorigin');
// TODO: Make target app origin configurable globally
const script = `
(window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), '${appOrigin}')
window.close()
`;
const hash = crypto.createHash('sha256').update(script).digest('base64');
res.setHeader('Content-Security-Policy', `script-src 'sha256-${hash}'`);
res.end(`
<html>
<body>
<script>${script}</script>
</body>
</html>
`);
};
export const ensuresXRequestedWith = (req: express.Request) => {
const requiredHeader = req.header('X-Requested-With');
if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') {
return false;
}
return true;
};
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ensuresXRequestedWith, postMessageResponse } from './authFlowHelpers';
@@ -0,0 +1,31 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { AuthResponse } from '../../providers/types';
/**
* Payload sent as a post message after the auth request is complete.
* If successful then has a valid payload with Auth information else contains an error.
*/
export type WebMessageResponse =
| {
type: 'authorization_response';
response: AuthResponse<unknown>;
}
| {
type: 'authorization_response';
error: Error;
};
@@ -15,16 +15,9 @@
*/
import express from 'express';
import {
ensuresXRequestedWith,
postMessageResponse,
THOUSAND_DAYS_MS,
TEN_MINUTES_MS,
verifyNonce,
encodeState,
OAuthProvider,
} from './OAuthProvider';
import { WebMessageResponse, OAuthProviderHandlers } from '../providers/types';
import { THOUSAND_DAYS_MS, TEN_MINUTES_MS, OAuthAdapter } from './OAuthAdapter';
import { encodeState } from './helpers';
import { OAuthHandlers } from './types';
const mockResponseData = {
providerInfo: {
@@ -41,149 +34,8 @@ const mockResponseData = {
},
};
describe('OAuthProvider Utils', () => {
describe('verifyNonce', () => {
it('should throw error if cookie nonce missing', () => {
const state = { nonce: 'NONCE', env: 'development' };
const mockRequest = ({
cookies: {},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Auth response is missing cookie nonce');
});
it('should throw error if state nonce missing', () => {
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCE',
},
query: {},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Invalid state passed via request');
});
it('should throw error if nonce mismatch', () => {
const state = { nonce: 'NONCEB', env: 'development' };
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCEA',
},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Invalid nonce');
});
it('should not throw any error if nonce matches', () => {
const state = { nonce: 'NONCE', env: 'development' };
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCE',
},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).not.toThrow();
});
});
describe('postMessageResponse', () => {
const appOrigin = 'http://localhost:3000';
it('should post a message back with payload success', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
response: {
providerInfo: {
accessToken: 'ACCESS_TOKEN',
idToken: 'ID_TOKEN',
expiresInSeconds: 10,
scope: 'email',
},
profile: {
email: 'foo@bar.com',
},
backstageIdentity: {
id: 'a',
idToken: 'a.b.c',
},
},
};
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(3);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
expect.stringContaining(base64Data),
);
});
it('should post a message back with payload error', () => {
const mockResponse = ({
end: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
} as unknown) as express.Response;
const data: WebMessageResponse = {
type: 'authorization_response',
error: new Error('Unknown error occured'),
};
const jsonData = JSON.stringify(data);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
postMessageResponse(mockResponse, appOrigin, data);
expect(mockResponse.setHeader).toBeCalledTimes(3);
expect(mockResponse.end).toBeCalledTimes(1);
expect(mockResponse.end).toBeCalledWith(
expect.stringContaining(base64Data),
);
});
});
describe('ensuresXRequestedWith', () => {
it('should return false if no header present', () => {
const mockRequest = ({
header: () => jest.fn(),
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
});
it('should return false if header present with incorrect value', () => {
const mockRequest = ({
header: () => 'INVALID',
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
});
it('should return true if header present with correct value', () => {
const mockRequest = ({
header: () => 'XMLHttpRequest',
} as unknown) as express.Request;
expect(ensuresXRequestedWith(mockRequest)).toBe(true);
});
});
});
describe('OAuthProvider', () => {
class MyAuthProvider implements OAuthProviderHandlers {
describe('OAuthAdapter', () => {
class MyAuthProvider implements OAuthHandlers {
async start() {
return {
url: '/url',
@@ -215,7 +67,7 @@ describe('OAuthProvider', () => {
};
it('sets the correct headers in start', async () => {
const oauthProvider = new OAuthProvider(
const oauthProvider = new OAuthAdapter(
providerInstance,
oAuthProviderOptions,
);
@@ -250,7 +102,7 @@ describe('OAuthProvider', () => {
});
it('sets the refresh cookie if refresh is enabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
@@ -284,7 +136,7 @@ describe('OAuthProvider', () => {
});
it('does not set the refresh cookie if refresh is disabled', async () => {
const oauthProvider = new OAuthProvider(providerInstance, {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
});
@@ -309,7 +161,7 @@ describe('OAuthProvider', () => {
});
it('removes refresh cookie when logging out', async () => {
const oauthProvider = new OAuthProvider(providerInstance, {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
@@ -334,7 +186,7 @@ describe('OAuthProvider', () => {
it('gets new access-token when refreshing', async () => {
oAuthProviderOptions.disableRefresh = false;
const oauthProvider = new OAuthProvider(providerInstance, {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: false,
});
@@ -363,7 +215,7 @@ describe('OAuthProvider', () => {
});
it('handles refresh without capabilities', async () => {
const oauthProvider = new OAuthProvider(providerInstance, {
const oauthProvider = new OAuthAdapter(providerInstance, {
...oAuthProviderOptions,
disableRefresh: true,
});
@@ -19,14 +19,14 @@ import crypto from 'crypto';
import { URL } from 'url';
import {
AuthProviderRouteHandlers,
OAuthProviderHandlers,
WebMessageResponse,
BackstageIdentity,
OAuthState,
AuthProviderConfig,
} from '../providers/types';
} from '../../providers/types';
import { InputError } from '@backstage/backend-common';
import { TokenIssuer } from '../identity';
import { TokenIssuer } from '../../identity';
import { verifyNonce, encodeState } from './helpers';
import { postMessageResponse, ensuresXRequestedWith } from '../flow';
import { OAuthHandlers } from './types';
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
export const TEN_MINUTES_MS = 600 * 1000;
@@ -42,99 +42,20 @@ export type Options = {
tokenIssuer: TokenIssuer;
};
const readState = (stateString: string): OAuthState => {
const state = Object.fromEntries(
new URLSearchParams(decodeURIComponent(stateString)),
);
if (
!state.nonce ||
!state.env ||
state.nonce?.length === 0 ||
state.env?.length === 0
) {
throw Error(`Invalid state passed via request`);
}
return {
nonce: state.nonce,
env: state.env,
};
};
export const encodeState = (state: OAuthState): string => {
const searchParams = new URLSearchParams();
searchParams.append('nonce', state.nonce);
searchParams.append('env', state.env);
return encodeURIComponent(searchParams.toString());
};
export const verifyNonce = (req: express.Request, providerId: string) => {
const cookieNonce = req.cookies[`${providerId}-nonce`];
const state: OAuthState = readState(req.query.state?.toString() ?? '');
const stateNonce = state.nonce;
if (!cookieNonce) {
throw new Error('Auth response is missing cookie nonce');
}
if (stateNonce.length === 0) {
throw new Error('Auth response is missing state nonce');
}
if (cookieNonce !== stateNonce) {
throw new Error('Invalid nonce');
}
};
export const postMessageResponse = (
res: express.Response,
appOrigin: string,
response: WebMessageResponse,
) => {
const jsonData = JSON.stringify(response);
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
res.setHeader('Content-Type', 'text/html');
res.setHeader('X-Frame-Options', 'sameorigin');
// TODO: Make target app origin configurable globally
const script = `
(window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), '${appOrigin}')
window.close()
`;
const hash = crypto.createHash('sha256').update(script).digest('base64');
res.setHeader('Content-Security-Policy', `script-src 'sha256-${hash}'`);
res.end(`
<html>
<body>
<script>${script}</script>
</body>
</html>
`);
};
export const ensuresXRequestedWith = (req: express.Request) => {
const requiredHeader = req.header('X-Requested-With');
if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') {
return false;
}
return true;
};
export class OAuthProvider implements AuthProviderRouteHandlers {
export class OAuthAdapter implements AuthProviderRouteHandlers {
static fromConfig(
config: AuthProviderConfig,
providerHandlers: OAuthProviderHandlers,
handlers: OAuthHandlers,
options: Pick<
Options,
'providerId' | 'persistScopes' | 'disableRefresh' | 'tokenIssuer'
>,
): OAuthProvider {
): OAuthAdapter {
const { origin: appOrigin } = new URL(config.appUrl);
const secure = config.baseUrl.startsWith('https://');
const url = new URL(config.baseUrl);
const cookiePath = `${url.pathname}/${options.providerId}`;
return new OAuthProvider(providerHandlers, {
return new OAuthAdapter(handlers, {
...options,
appOrigin,
cookieDomain: url.hostname,
@@ -144,7 +65,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
}
constructor(
private readonly providerHandlers: OAuthProviderHandlers,
private readonly handlers: OAuthHandlers,
private readonly options: Options,
) {}
@@ -173,10 +94,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
state: stateParameter,
};
const { url, status } = await this.providerHandlers.start(
req,
queryParameters,
);
const { url, status } = await this.handlers.start(req, queryParameters);
res.statusCode = status || 302;
res.setHeader('Location', url);
@@ -192,9 +110,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
// verify nonce cookie and state cookie on callback
verifyNonce(req, this.options.providerId);
const { response, refreshToken } = await this.providerHandlers.handler(
req,
);
const { response, refreshToken } = await this.handlers.handler(req);
if (this.options.persistScopes) {
const grantedScopes = this.getScopesFromCookie(
@@ -251,7 +167,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
return;
}
if (!this.providerHandlers.refresh || this.options.disableRefresh) {
if (!this.handlers.refresh || this.options.disableRefresh) {
res.send(
`Refresh token not supported for provider: ${this.options.providerId}`,
);
@@ -270,7 +186,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
const scope = req.query.scope?.toString() ?? '';
// get new access_token
const response = await this.providerHandlers.refresh(refreshToken, scope);
const response = await this.handlers.refresh(refreshToken, scope);
await this.populateIdentity(response.backstageIdentity);
@@ -287,19 +203,6 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
}
}
identifyEnv(req: express.Request): string | undefined {
const reqEnv = req.query.env?.toString();
if (reqEnv) {
return reqEnv;
}
const stateParams = req.query.state?.toString();
if (!stateParams) {
return undefined;
}
const env = readState(stateParams).env;
return env;
}
/**
* If the response from the OAuth provider includes a Backstage identity, we
* make sure it's populated with all the information we can derive from the user ID.
@@ -15,46 +15,32 @@
*/
import express from 'express';
import {
AuthProviderRouteHandlers,
EnvironmentIdentifierFn,
} from '../providers/types';
import { Config } from '@backstage/config';
import { InputError } from '@backstage/backend-common';
import { readState } from './helpers';
import { AuthProviderRouteHandlers } from '../../providers/types';
export type EnvironmentHandlers = {
[key: string]: AuthProviderRouteHandlers;
};
export class OAuthEnvironmentHandler implements AuthProviderRouteHandlers {
static mapConfig(
config: Config,
factoryFunc: (envConfig: Config) => AuthProviderRouteHandlers,
) {
const envs = config.keys();
const handlers = new Map<string, AuthProviderRouteHandlers>();
export class EnvironmentHandler implements AuthProviderRouteHandlers {
constructor(
private readonly providerId: string,
private readonly providers: EnvironmentHandlers,
private readonly envIdentifier: EnvironmentIdentifierFn,
) {}
private getProviderForEnv(
req: express.Request,
res: express.Response,
): AuthProviderRouteHandlers | undefined {
const env: string | undefined = this.envIdentifier(req);
if (!env) {
throw new InputError(`Must specify 'env' query to select environment`);
for (const env of envs) {
const envConfig = config.getConfig(env);
const handler = factoryFunc(envConfig);
handlers.set(env, handler);
}
if (this.providers.hasOwnProperty(env)) {
return this.providers[env];
}
res.status(404).send(
`Missing configuration.
<br>
<br>
For this flow to work you need to supply a valid configuration for the "${env}" environment of the "${this.providerId}" provider.`,
);
return undefined;
return new OAuthEnvironmentHandler(handlers);
}
constructor(
private readonly handlers: Map<string, AuthProviderRouteHandlers>,
) {}
async start(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req, res);
await provider?.start(req, res);
@@ -77,4 +63,40 @@ For this flow to work you need to supply a valid configuration for the "${env}"
const provider = this.getProviderForEnv(req, res);
await provider?.logout?.(req, res);
}
private getRequestFromEnv(req: express.Request): string | undefined {
const reqEnv = req.query.env?.toString();
if (reqEnv) {
return reqEnv;
}
const stateParams = req.query.state?.toString();
if (!stateParams) {
return undefined;
}
const env = readState(stateParams).env;
return env;
}
private getProviderForEnv(
req: express.Request,
res: express.Response,
): AuthProviderRouteHandlers | undefined {
const env: string | undefined = this.getRequestFromEnv(req);
if (!env) {
throw new InputError(`Must specify 'env' query to select environment`);
}
if (!this.handlers.has(env)) {
res.status(404).send(
`Missing configuration.
<br>
<br>
For this flow to work you need to supply a valid configuration for the "${env}" environment of provider.`,
);
return undefined;
}
return this.handlers.get(env);
}
}
@@ -0,0 +1,77 @@
/*
* Copyright 2020 Spotify AB
*
* 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 express from 'express';
import { verifyNonce, encodeState } from './helpers';
describe('OAuthProvider Utils', () => {
describe('verifyNonce', () => {
it('should throw error if cookie nonce missing', () => {
const state = { nonce: 'NONCE', env: 'development' };
const mockRequest = ({
cookies: {},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Auth response is missing cookie nonce');
});
it('should throw error if state nonce missing', () => {
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCE',
},
query: {},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Invalid state passed via request');
});
it('should throw error if nonce mismatch', () => {
const state = { nonce: 'NONCEB', env: 'development' };
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCEA',
},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).toThrowError('Invalid nonce');
});
it('should not throw any error if nonce matches', () => {
const state = { nonce: 'NONCE', env: 'development' };
const mockRequest = ({
cookies: {
'providera-nonce': 'NONCE',
},
query: {
state: encodeState(state),
},
} as unknown) as express.Request;
expect(() => {
verifyNonce(mockRequest, 'providera');
}).not.toThrow();
});
});
});
@@ -0,0 +1,60 @@
/*
* Copyright 2020 Spotify AB
*
* 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 express from 'express';
import { OAuthState } from './types';
export const readState = (stateString: string): OAuthState => {
const state = Object.fromEntries(
new URLSearchParams(decodeURIComponent(stateString)),
);
if (
!state.nonce ||
!state.env ||
state.nonce?.length === 0 ||
state.env?.length === 0
) {
throw Error(`Invalid state passed via request`);
}
return {
nonce: state.nonce,
env: state.env,
};
};
export const encodeState = (state: OAuthState): string => {
const searchParams = new URLSearchParams();
searchParams.append('nonce', state.nonce);
searchParams.append('env', state.env);
return encodeURIComponent(searchParams.toString());
};
export const verifyNonce = (req: express.Request, providerId: string) => {
const cookieNonce = req.cookies[`${providerId}-nonce`];
const state: OAuthState = readState(req.query.state?.toString() ?? '');
const stateNonce = state.nonce;
if (!cookieNonce) {
throw new Error('Auth response is missing cookie nonce');
}
if (stateNonce.length === 0) {
throw new Error('Auth response is missing state nonce');
}
if (cookieNonce !== stateNonce) {
throw new Error('Invalid nonce');
}
};
@@ -0,0 +1,25 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { OAuthEnvironmentHandler } from './OAuthEnvironmentHandler';
export { OAuthAdapter } from './OAuthAdapter';
export type {
OAuthHandlers,
OAuthProviderInfo,
OAuthProviderOptions,
OAuthResponse,
OAuthState,
} from './types';
+111
View File
@@ -0,0 +1,111 @@
/*
* Copyright 2020 Spotify AB
*
* 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 express from 'express';
import { AuthResponse, RedirectInfo } from '../../providers/types';
/**
* Common options for passport.js-based OAuth providers
*/
export type OAuthProviderOptions = {
/**
* Client ID of the auth provider.
*/
clientId: string;
/**
* Client Secret of the auth provider.
*/
clientSecret: string;
/**
* Callback URL to be passed to the auth provider to redirect to after the user signs in.
*/
callbackUrl: string;
};
export type OAuthResponse = AuthResponse<OAuthProviderInfo>;
export type OAuthProviderInfo = {
/**
* An access token issued for the signed in user.
*/
accessToken: string;
/**
* (Optional) Id token issued for the signed in user.
*/
idToken?: string;
/**
* Expiry of the access token in seconds.
*/
expiresInSeconds?: number;
/**
* Scopes granted for the access token.
*/
scope: string;
/**
* A refresh token issued for the signed in user
*/
refreshToken?: string;
};
export type OAuthState = {
/* A type for the serialized value in the `state` parameter of the OAuth authorization flow
*/
nonce: string;
env: string;
};
/**
* Any OAuth provider needs to implement this interface which has provider specific
* handlers for different methods to perform authentication, get access tokens,
* refresh tokens and perform sign out.
*/
export interface OAuthHandlers {
/**
* This method initiates a sign in request with an auth provider.
* @param {express.Request} req
* @param options
*/
start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo>;
/**
* Handles the redirect from the auth provider when the user has signed in.
* @param {express.Request} req
*/
handler(
req: express.Request,
): Promise<{
response: AuthResponse<OAuthProviderInfo>;
refreshToken?: string;
}>;
/**
* (Optional) Given a refresh token and scope fetches a new access token from the auth provider.
* @param {string} refreshToken
* @param {string} scope
*/
refresh?(
refreshToken: string,
scope: string,
): Promise<AuthResponse<OAuthProviderInfo>>;
/**
* (Optional) Sign out of the auth provider.
*/
logout?(): Promise<void>;
}
@@ -17,12 +17,13 @@
import express from 'express';
import passport from 'passport';
import jwtDecoder from 'jwt-decode';
import {
RedirectInfo,
RefreshTokenResponse,
ProfileInfo,
ProviderStrategy,
} from '../providers/types';
import { ProfileInfo, RedirectInfo } from '../../providers/types';
export type PassportDoneCallback<Res, Private = never> = (
err?: Error,
response?: Res,
privateInfo?: Private,
) => void;
export const makeProfileInfo = (
profile: passport.Profile,
@@ -106,6 +107,18 @@ export const executeFrameHandlerStrategy = async <T, PrivateInfo = never>(
);
};
type RefreshTokenResponse = {
/**
* An access token issued for the signed in user.
*/
accessToken: string;
/**
* Optionally, the server can issue a new Refresh Token for the user
*/
refreshToken?: string;
params: any;
};
export const executeRefreshTokenStrategy = async (
providerStrategy: passport.Strategy,
refreshToken: string,
@@ -156,6 +169,10 @@ export const executeRefreshTokenStrategy = async (
});
};
type ProviderStrategy = {
userProfile(accessToken: string, callback: Function): void;
};
export const executeFetchUserProfileStrategy = async (
providerStrategy: passport.Strategy,
accessToken: string,
@@ -0,0 +1,24 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
} from './PassportStrategyHelper';
export type { PassportDoneCallback } from './PassportStrategyHelper';
@@ -17,25 +17,22 @@
import express from 'express';
import passport from 'passport';
import Auth0Strategy from './strategy';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
import { OAuthProvider } from '../../lib/OAuthProvider';
import {
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
} from '../../lib/PassportStrategyHelper';
import {
AuthProviderConfig,
OAuthProviderHandlers,
OAuthResponse,
PassportDoneCallback,
RedirectInfo,
OAuthProviderOptions,
} from '../types';
import { Config } from '@backstage/config';
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
type PrivateInfo = {
refreshToken: string;
@@ -45,7 +42,7 @@ export type Auth0AuthProviderOptions = OAuthProviderOptions & {
domain: string;
};
export class Auth0AuthProvider implements OAuthProviderHandlers {
export class Auth0AuthProvider implements OAuthHandlers {
private readonly _strategy: Auth0Strategy;
constructor(options: Auth0AuthProviderOptions) {
@@ -151,29 +148,28 @@ export class Auth0AuthProvider implements OAuthProviderHandlers {
}
}
export function createAuth0Provider(
config: AuthProviderConfig,
_: string,
envConfig: Config,
_logger: Logger,
tokenIssuer: TokenIssuer,
) {
const providerId = 'auth0';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const domain = envConfig.getString('domain');
const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`;
export const createAuth0Provider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'auth0';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const domain = envConfig.getString('domain');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const provider = new Auth0AuthProvider({
clientId,
clientSecret,
callbackUrl,
domain,
});
const provider = new Auth0AuthProvider({
clientId,
clientSecret,
callbackUrl,
domain,
});
return OAuthProvider.fromConfig(config, provider, {
disableRefresh: true,
providerId,
tokenIssuer,
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: true,
providerId,
tokenIssuer,
});
});
}
@@ -25,16 +25,8 @@ import { createOktaProvider } from './okta';
import { createSamlProvider } from './saml';
import { createAuth0Provider } from './auth0';
import { createMicrosoftProvider } from './microsoft';
import {
AuthProviderConfig,
AuthProviderFactory,
EnvironmentIdentifierFn,
} from './types';
import { AuthProviderConfig, AuthProviderFactory } from './types';
import { Config } from '@backstage/config';
import {
EnvironmentHandlers,
EnvironmentHandler,
} from '../lib/EnvironmentHandler';
const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
@@ -50,9 +42,9 @@ const factories: { [providerId: string]: AuthProviderFactory } = {
export const createAuthProviderRouter = (
providerId: string,
globalConfig: AuthProviderConfig,
providerConfig: Config,
config: Config,
logger: Logger,
issuer: TokenIssuer,
tokenIssuer: TokenIssuer,
) => {
const factory = factories[providerId];
if (!factory) {
@@ -60,28 +52,8 @@ export const createAuthProviderRouter = (
}
const router = Router();
const envs = providerConfig.keys();
const envProviders: EnvironmentHandlers = {};
let envIdentifier: EnvironmentIdentifierFn | undefined;
for (const env of envs) {
const envConfig = providerConfig.getConfig(env);
const provider = factory(globalConfig, env, envConfig, logger, issuer);
if (provider) {
envProviders[env] = provider;
envIdentifier = provider.identifyEnv;
}
}
if (typeof envIdentifier === 'undefined') {
throw Error(`No envIdentifier provided for '${providerId}'`);
}
const handler = new EnvironmentHandler(
providerId,
envProviders,
envIdentifier,
);
const handler = factory({ globalConfig, config, logger, tokenIssuer });
router.get('/start', handler.start.bind(handler));
router.get('/handler/frame', handler.frameHandler.bind(handler));
@@ -20,20 +20,17 @@ import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
makeProfileInfo,
} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
AuthProviderConfig,
RedirectInfo,
OAuthProviderOptions,
OAuthResponse,
PassportDoneCallback,
} from '../types';
import { OAuthProvider } from '../../lib/OAuthProvider';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import passport from 'passport';
import { Config } from '@backstage/config';
export type GithubAuthProviderOptions = OAuthProviderOptions & {
tokenUrl?: string;
@@ -41,7 +38,7 @@ export type GithubAuthProviderOptions = OAuthProviderOptions & {
authorizationUrl?: string;
};
export class GithubAuthProvider implements OAuthProviderHandlers {
export class GithubAuthProvider implements OAuthHandlers {
private readonly _strategy: GithubStrategy;
static transformPassportProfile(rawProfile: any): passport.Profile {
@@ -137,43 +134,42 @@ export class GithubAuthProvider implements OAuthProviderHandlers {
}
}
export function createGithubProvider(
config: AuthProviderConfig,
_: string,
envConfig: Config,
_logger: Logger,
tokenIssuer: TokenIssuer,
) {
const providerId = 'github';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const enterpriseInstanceUrl = envConfig.getOptionalString(
'enterpriseInstanceUrl',
);
const authorizationUrl = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/login/oauth/authorize`
: undefined;
const tokenUrl = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/login/oauth/access_token`
: undefined;
const userProfileUrl = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/api/v3/user`
: undefined;
const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`;
export const createGithubProvider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'github';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const enterpriseInstanceUrl = envConfig.getOptionalString(
'enterpriseInstanceUrl',
);
const authorizationUrl = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/login/oauth/authorize`
: undefined;
const tokenUrl = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/login/oauth/access_token`
: undefined;
const userProfileUrl = enterpriseInstanceUrl
? `${enterpriseInstanceUrl}/api/v3/user`
: undefined;
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const provider = new GithubAuthProvider({
clientId,
clientSecret,
callbackUrl,
tokenUrl,
userProfileUrl,
authorizationUrl,
});
const provider = new GithubAuthProvider({
clientId,
clientSecret,
callbackUrl,
tokenUrl,
userProfileUrl,
authorizationUrl,
});
return OAuthProvider.fromConfig(config, provider, {
disableRefresh: true,
persistScopes: true,
providerId,
tokenIssuer,
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: true,
persistScopes: true,
providerId,
tokenIssuer,
});
});
}
@@ -20,26 +20,23 @@ import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
makeProfileInfo,
} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
AuthProviderConfig,
RedirectInfo,
OAuthProviderOptions,
OAuthResponse,
PassportDoneCallback,
} from '../types';
import { OAuthProvider } from '../../lib/OAuthProvider';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import passport from 'passport';
import { Config } from '@backstage/config';
export type GitlabAuthProviderOptions = OAuthProviderOptions & {
baseUrl: string;
};
export class GitlabAuthProvider implements OAuthProviderHandlers {
export class GitlabAuthProvider implements OAuthHandlers {
private readonly _strategy: GitlabStrategy;
static transformPassportProfile(rawProfile: any): passport.Profile {
@@ -140,30 +137,29 @@ export class GitlabAuthProvider implements OAuthProviderHandlers {
}
}
export function createGitlabProvider(
config: AuthProviderConfig,
_: string,
envConfig: Config,
_logger: Logger,
tokenIssuer: TokenIssuer,
) {
const providerId = 'gitlab';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const audience = envConfig.getString('audience');
const baseUrl = audience || 'https://gitlab.com';
const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`;
export const createGitlabProvider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'gitlab';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const audience = envConfig.getString('audience');
const baseUrl = audience || 'https://gitlab.com';
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const provider = new GitlabAuthProvider({
clientId,
clientSecret,
callbackUrl,
baseUrl,
});
const provider = new GitlabAuthProvider({
clientId,
clientSecret,
callbackUrl,
baseUrl,
});
return OAuthProvider.fromConfig(config, provider, {
disableRefresh: true,
providerId,
tokenIssuer,
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: true,
providerId,
tokenIssuer,
});
});
}
@@ -22,26 +22,23 @@ import {
executeRefreshTokenStrategy,
makeProfileInfo,
executeFetchUserProfileStrategy,
} from '../../lib/PassportStrategyHelper';
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
OAuthProviderHandlers,
RedirectInfo,
AuthProviderConfig,
OAuthAdapter,
OAuthHandlers,
OAuthProviderOptions,
OAuthResponse,
PassportDoneCallback,
} from '../types';
import { OAuthProvider } from '../../lib/OAuthProvider';
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import passport from 'passport';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
import { Config } from '@backstage/config';
type PrivateInfo = {
refreshToken: string;
};
export class GoogleAuthProvider implements OAuthProviderHandlers {
export class GoogleAuthProvider implements OAuthHandlers {
private readonly _strategy: GoogleStrategy;
constructor(options: OAuthProviderOptions) {
@@ -148,27 +145,26 @@ export class GoogleAuthProvider implements OAuthProviderHandlers {
}
}
export function createGoogleProvider(
config: AuthProviderConfig,
_: string,
envConfig: Config,
_logger: Logger,
tokenIssuer: TokenIssuer,
) {
const providerId = 'google';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`;
export const createGoogleProvider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'google';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const provider = new GoogleAuthProvider({
clientId,
clientSecret,
callbackUrl,
});
const provider = new GoogleAuthProvider({
clientId,
clientSecret,
callbackUrl,
});
return OAuthProvider.fromConfig(config, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
});
});
}
@@ -24,21 +24,18 @@ import {
executeRefreshTokenStrategy,
makeProfileInfo,
executeFetchUserProfileStrategy,
} from '../../lib/PassportStrategyHelper';
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
OAuthProviderHandlers,
RedirectInfo,
AuthProviderConfig,
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
PassportDoneCallback,
} from '../types';
import { OAuthProvider } from '../../lib/OAuthProvider';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
import { Config } from '@backstage/config';
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import got from 'got';
@@ -51,7 +48,7 @@ export type MicrosoftAuthProviderOptions = OAuthProviderOptions & {
tokenUrl?: string;
};
export class MicrosoftAuthProvider implements OAuthProviderHandlers {
export class MicrosoftAuthProvider implements OAuthHandlers {
private readonly _strategy: MicrosoftStrategy;
static transformAuthResponse(
@@ -205,34 +202,33 @@ export class MicrosoftAuthProvider implements OAuthProviderHandlers {
}
}
export function createMicrosoftProvider(
config: AuthProviderConfig,
_: string,
envConfig: Config,
_logger: Logger,
tokenIssuer: TokenIssuer,
) {
const providerId = 'microsoft';
export const createMicrosoftProvider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'microsoft';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const tenantID = envConfig.getString('tenantId');
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const tenantID = envConfig.getString('tenantId');
const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`;
const authorizationUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/authorize`;
const tokenUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/token`;
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const authorizationUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/authorize`;
const tokenUrl = `https://login.microsoftonline.com/${tenantID}/oauth2/v2.0/token`;
const provider = new MicrosoftAuthProvider({
clientId,
clientSecret,
callbackUrl,
authorizationUrl,
tokenUrl,
const provider = new MicrosoftAuthProvider({
clientId,
clientSecret,
callbackUrl,
authorizationUrl,
tokenUrl,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
});
});
return OAuthProvider.fromConfig(config, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
});
}
@@ -17,25 +17,22 @@
import express from 'express';
import passport from 'passport';
import { Strategy as OAuth2Strategy } from 'passport-oauth2';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
import { OAuthProvider } from '../../lib/OAuthProvider';
import {
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
} from '../../lib/PassportStrategyHelper';
import {
AuthProviderConfig,
OAuthProviderOptions,
OAuthProviderHandlers,
OAuthResponse,
PassportDoneCallback,
RedirectInfo,
} from '../types';
import { Config } from '@backstage/config';
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
type PrivateInfo = {
refreshToken: string;
@@ -46,7 +43,7 @@ export type OAuth2AuthProviderOptions = OAuthProviderOptions & {
tokenUrl: string;
};
export class OAuth2AuthProvider implements OAuthProviderHandlers {
export class OAuth2AuthProvider implements OAuthHandlers {
private readonly _strategy: OAuth2Strategy;
constructor(options: OAuth2AuthProviderOptions) {
@@ -159,31 +156,30 @@ export class OAuth2AuthProvider implements OAuthProviderHandlers {
}
}
export function createOAuth2Provider(
config: AuthProviderConfig,
_: string,
envConfig: Config,
_logger: Logger,
tokenIssuer: TokenIssuer,
) {
const providerId = 'oauth2';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`;
const authorizationUrl = envConfig.getString('authorizationUrl');
const tokenUrl = envConfig.getString('tokenUrl');
export const createOAuth2Provider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'oauth2';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const authorizationUrl = envConfig.getString('authorizationUrl');
const tokenUrl = envConfig.getString('tokenUrl');
const provider = new OAuth2AuthProvider({
clientId,
clientSecret,
callbackUrl,
authorizationUrl,
tokenUrl,
});
const provider = new OAuth2AuthProvider({
clientId,
clientSecret,
callbackUrl,
authorizationUrl,
tokenUrl,
});
return OAuthProvider.fromConfig(config, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
});
});
}
@@ -14,7 +14,13 @@
* limitations under the License.
*/
import express from 'express';
import { OAuthProvider } from '../../lib/OAuthProvider';
import {
OAuthAdapter,
OAuthProviderOptions,
OAuthHandlers,
OAuthResponse,
OAuthEnvironmentHandler,
} from '../../lib/oauth';
import { Strategy as OktaStrategy } from 'passport-okta-oauth';
import passport from 'passport';
import {
@@ -23,19 +29,10 @@ import {
executeRefreshTokenStrategy,
makeProfileInfo,
executeFetchUserProfileStrategy,
} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
RedirectInfo,
AuthProviderConfig,
OAuthProviderOptions,
OAuthResponse,
PassportDoneCallback,
} from '../types';
import { Logger } from 'winston';
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import { StateStore } from 'passport-oauth2';
import { TokenIssuer } from '../../identity';
import { Config } from '@backstage/config';
type PrivateInfo = {
refreshToken: string;
@@ -45,7 +42,7 @@ export type OktaAuthProviderOptions = OAuthProviderOptions & {
audience: string;
};
export class OktaAuthProvider implements OAuthProviderHandlers {
export class OktaAuthProvider implements OAuthHandlers {
private readonly _strategy: any;
/**
@@ -170,29 +167,28 @@ export class OktaAuthProvider implements OAuthProviderHandlers {
}
}
export function createOktaProvider(
config: AuthProviderConfig,
_: string,
envConfig: Config,
_logger: Logger,
tokenIssuer: TokenIssuer,
) {
const providerId = 'okta';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const audience = envConfig.getString('audience');
const callbackUrl = `${config.baseUrl}/${providerId}/handler/frame`;
export const createOktaProvider: AuthProviderFactory = ({
globalConfig,
config,
tokenIssuer,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const providerId = 'okta';
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const audience = envConfig.getString('audience');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const provider = new OktaAuthProvider({
audience,
clientId,
clientSecret,
callbackUrl,
});
const provider = new OktaAuthProvider({
audience,
clientId,
clientSecret,
callbackUrl,
});
return OAuthProvider.fromConfig(config, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: false,
providerId,
tokenIssuer,
});
});
}
@@ -23,17 +23,15 @@ import {
import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
} from '../../lib/PassportStrategyHelper';
import {
AuthProviderConfig,
AuthProviderRouteHandlers,
PassportDoneCallback,
} from '../../lib/passport';
import {
AuthProviderRouteHandlers,
ProfileInfo,
AuthProviderFactory,
} from '../types';
import { postMessageResponse } from '../../lib/OAuthProvider';
import { Logger } from 'winston';
import { postMessageResponse } from '../../lib/flow';
import { TokenIssuer } from '../../identity';
import { Config } from '@backstage/config';
type SamlInfo = {
userId: string;
@@ -119,15 +117,12 @@ type SAMLProviderOptions = {
tokenIssuer: TokenIssuer;
};
export function createSamlProvider(
_authProviderConfig: AuthProviderConfig,
_env: string,
envConfig: Config,
_logger: Logger,
tokenIssuer: TokenIssuer,
) {
const entryPoint = envConfig.getString('entryPoint');
const issuer = envConfig.getString('issuer');
export const createSamlProvider: AuthProviderFactory = ({
config,
tokenIssuer,
}) => {
const entryPoint = config.getString('entryPoint');
const issuer = config.getString('issuer');
const opts = {
entryPoint,
issuer,
@@ -136,4 +131,4 @@ export function createSamlProvider(
};
return new SamlAuthProvider(opts);
}
};
+15 -169
View File
@@ -18,23 +18,6 @@ import express from 'express';
import { Logger } from 'winston';
import { TokenIssuer } from '../identity';
import { Config } from '@backstage/config';
import { OAuthProvider } from '../lib/OAuthProvider';
import { SamlAuthProvider } from './saml/provider';
export type OAuthProviderOptions = {
/**
* Client ID of the auth provider.
*/
clientId: string;
/**
* Client Secret of the auth provider.
*/
clientSecret: string;
/**
* Callback URL to be passed to the auth provider to redirect to after the user signs in.
*/
callbackUrl: string;
};
export type AuthProviderConfig = {
/**
@@ -49,48 +32,16 @@ export type AuthProviderConfig = {
appUrl: string;
};
/**
* Any OAuth provider needs to implement this interface which has provider specific
* handlers for different methods to perform authentication, get access tokens,
* refresh tokens and perform sign out.
*/
export interface OAuthProviderHandlers {
export type RedirectInfo = {
/**
* This method initiates a sign in request with an auth provider.
* @param {express.Request} req
* @param options
* URL to redirect to
*/
start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo>;
url: string;
/**
* Handles the redirect from the auth provider when the user has signed in.
* @param {express.Request} req
* Status code to use for the redirect
*/
handler(
req: express.Request,
): Promise<{
response: AuthResponse<OAuthProviderInfo>;
refreshToken?: string;
}>;
/**
* (Optional) Given a refresh token and scope fetches a new access token from the auth provider.
* @param {string} refreshToken
* @param {string} scope
*/
refresh?(
refreshToken: string,
scope: string,
): Promise<AuthResponse<OAuthProviderInfo>>;
/**
* (Optional) Sign out of the auth provider.
*/
logout?(): Promise<void>;
}
status?: number;
};
/**
* Any Auth provider needs to implement this interface which handles the routes in the
@@ -157,24 +108,18 @@ export interface AuthProviderRouteHandlers {
* @param {express.Response} res
*/
logout?(req: express.Request, res: express.Response): Promise<void>;
/**
*(Optional) A method to identify the environment Context of the Request
*
*Request
*- contains the environment context information encoded in the request
* @param {express.Request} req
*/
identifyEnv?(req: express.Request): string | undefined;
}
export type AuthProviderFactoryOptions = {
globalConfig: AuthProviderConfig;
config: Config;
logger: Logger;
tokenIssuer: TokenIssuer;
};
export type AuthProviderFactory = (
globalConfig: AuthProviderConfig,
env: string,
envConfig: Config,
logger: Logger,
issuer: TokenIssuer,
) => OAuthProvider | SamlAuthProvider | undefined;
options: AuthProviderFactoryOptions,
) => AuthProviderRouteHandlers;
export type AuthResponse<ProviderInfo> = {
providerInfo: ProviderInfo;
@@ -182,8 +127,6 @@ export type AuthResponse<ProviderInfo> = {
backstageIdentity?: BackstageIdentity;
};
export type OAuthResponse = AuthResponse<OAuthProviderInfo>;
export type BackstageIdentity = {
/**
* The backstage user ID.
@@ -196,67 +139,6 @@ export type BackstageIdentity = {
idToken?: string;
};
export type OAuthProviderInfo = {
/**
* An access token issued for the signed in user.
*/
accessToken: string;
/**
* (Optional) Id token issued for the signed in user.
*/
idToken?: string;
/**
* Expiry of the access token in seconds.
*/
expiresInSeconds?: number;
/**
* Scopes granted for the access token.
*/
scope: string;
/**
* A refresh token issued for the signed in user
*/
refreshToken?: string;
};
export type OAuthPrivateInfo = {
/**
* A refresh token issued for the signed in user.
*/
refreshToken: string;
};
/**
* Payload sent as a post message after the auth request is complete.
* If successful then has a valid payload with Auth information else contains an error.
*/
export type WebMessageResponse =
| {
type: 'authorization_response';
response: AuthResponse<unknown>;
}
| {
type: 'authorization_response';
error: Error;
};
export type PassportDoneCallback<Res, Private = never> = (
err?: Error,
response?: Res,
privateInfo?: Private,
) => void;
export type RedirectInfo = {
/**
* URL to redirect to
*/
url: string;
/**
* Status code to use for the redirect
*/
status?: number;
};
/**
* Used to display login information to user, i.e. sidebar popup.
*
@@ -278,39 +160,3 @@ export type ProfileInfo = {
*/
picture?: string;
};
export type RefreshTokenResponse = {
/**
* An access token issued for the signed in user.
*/
accessToken: string;
/**
* Optionally, the server can issue a new Refresh Token for the user
*/
refreshToken?: string;
params: any;
};
export type ProviderStrategy = {
userProfile(accessToken: string, callback: Function): void;
};
export type SAMLProviderConfig = {
entryPoint: string;
issuer: string;
};
export type SAMLEnvironmentProviderConfig = {
[key: string]: SAMLProviderConfig;
};
export type OAuthState = {
/* A type for the serialized value in the `state` parameter of the OAuth authorization flow
*/
nonce: string;
env: string;
};
export type EnvironmentIdentifierFn = (
req: express.Request,
) => string | undefined;
+8 -3
View File
@@ -17,12 +17,12 @@
import express from 'express';
import Router from 'express-promise-router';
import cookieParser from 'cookie-parser';
import bodyParser from 'body-parser';
import Knex from 'knex';
import { Logger } from 'winston';
import { createAuthProviderRouter } from '../providers';
import { Config } from '@backstage/config';
import { DatabaseKeyStore, TokenFactory, createOidcRouter } from '../identity';
import { NotFoundError } from '@backstage/backend-common';
export interface RouterOptions {
logger: Logger;
@@ -53,8 +53,8 @@ export async function createRouter(
});
router.use(cookieParser());
router.use(bodyParser.urlencoded({ extended: false }));
router.use(bodyParser.json());
router.use(express.urlencoded({ extended: false }));
router.use(express.json());
const providersConfig = options.config.getConfig('auth.providers');
const providers = providersConfig.keys();
@@ -89,5 +89,10 @@ export async function createRouter(
}),
);
router.use('/:provider/', req => {
const { provider } = req.params;
throw new NotFoundError(`No auth provider registered for '${provider}'`);
});
return router;
}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -22,9 +22,9 @@
"mock-data:local": "./scripts/mock-data-local.sh"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.20",
"@backstage/catalog-model": "^0.1.1-alpha.20",
"@backstage/config": "^0.1.1-alpha.20",
"@backstage/backend-common": "^0.1.1-alpha.21",
"@backstage/catalog-model": "^0.1.1-alpha.21",
"@backstage/config": "^0.1.1-alpha.21",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
@@ -41,7 +41,7 @@
"yup": "^0.29.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@types/lodash": "^4.14.151",
"@types/node-fetch": "^2.5.7",
"@types/supertest": "^2.0.8",
@@ -9,6 +9,8 @@ for URL in \
'playback-lib-component.yaml' \
'www-artist-component.yaml' \
'shuffle-api-component.yaml' \
'petstore-api.yaml' \
'streetlights-api.yaml' \
; do \
curl \
--location \
@@ -14,17 +14,16 @@
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import { getVoidLogger, resolvePackagePath } from '@backstage/backend-common';
import { makeValidator } from '@backstage/catalog-model';
import Knex from 'knex';
import path from 'path';
import { Logger } from 'winston';
import { CommonDatabase } from './CommonDatabase';
import { Database } from './types';
const migrationsDir = path.resolve(
require.resolve('@backstage/plugin-catalog-backend/package.json'),
'../migrations',
const migrationsDir = resolvePackagePath(
'@backstage/plugin-catalog-backend',
'migrations',
);
export type CreateDatabaseOptions = {
@@ -0,0 +1,217 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { LocationSpec, Entity } from '@backstage/catalog-model';
import { CatalogRulesEnforcer } from './CatalogRules';
import { ConfigReader } from '@backstage/config';
const entity = {
user: {
kind: 'User',
} as Entity,
group: {
kind: 'Group',
} as Entity,
component: {
kind: 'component',
} as Entity,
location: {
kind: 'Location',
} as Entity,
};
const location: Record<string, LocationSpec> = {
x: {
type: 'github',
target: 'https://github.com/a/b/blob/master/x.yaml',
},
y: {
type: 'github',
target: 'https://github.com/a/b/blob/master/y.yaml',
},
z: {
type: 'file',
target: '/root/z.yaml',
},
};
describe('CatalogRulesEnforcer', () => {
it('should deny by default', () => {
const enforcer = new CatalogRulesEnforcer([]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
});
it('should deny all', () => {
const enforcer = new CatalogRulesEnforcer([{ allow: [] }]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
});
it('should allow all', () => {
const enforcer = new CatalogRulesEnforcer([
{
allow: ['User', 'Group', 'Component', 'Location'].map(kind => ({
kind,
})),
},
]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(true);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(true);
});
it('should deny groups', () => {
const enforcer = new CatalogRulesEnforcer([
{ allow: [{ kind: 'User' }, { kind: 'Component' }] },
]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
});
it('should deny groups from github', () => {
const enforcer = new CatalogRulesEnforcer([
{ allow: [{ kind: 'User' }, { kind: 'Component' }] },
{ allow: [{ kind: 'Group' }], locations: [{ type: 'file' }] },
]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
});
it('should allow groups from files', () => {
const enforcer = new CatalogRulesEnforcer([
{ allow: [{ kind: 'Group' }], locations: [{ type: 'file' }] },
]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
});
it('should not be sensitive to kind case', () => {
const enforcer = new CatalogRulesEnforcer([
{ allow: [{ kind: 'group' }] },
{ allow: [{ kind: 'Component' }] },
]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
});
describe('fromConfig', () => {
it('should allow components by default', () => {
const enforcer = CatalogRulesEnforcer.fromConfig(new ConfigReader({}));
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(true);
});
it('should deny all', () => {
const enforcer = CatalogRulesEnforcer.fromConfig(
new ConfigReader({ catalog: { rules: [] } }),
);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
});
it('should allow all', () => {
const enforcer = CatalogRulesEnforcer.fromConfig(
new ConfigReader({
catalog: {
rules: [{ allow: ['User', 'Group'] }, { allow: ['Component'] }],
},
}),
);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(true);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
});
it('should deny groups', () => {
const enforcer = CatalogRulesEnforcer.fromConfig(
new ConfigReader({
catalog: { rules: [{ allow: ['User'] }, { allow: ['Component'] }] },
}),
);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
});
it('should allow groups from a specific github location', () => {
const enforcer = CatalogRulesEnforcer.fromConfig(
new ConfigReader({
catalog: {
rules: [{ allow: ['user'] }],
locations: [
{
type: 'github',
target: 'https://github.com/a/b/blob/master/x.yaml',
rules: [
{
allow: ['Group'],
},
],
},
],
},
}),
);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
});
it('should not care about location configuration in catalog.rules', () => {
const enforcer = CatalogRulesEnforcer.fromConfig(
new ConfigReader({
catalog: {
rules: [{ allow: ['Group'], locations: [{ type: 'github' }] }],
},
}),
);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
});
});
});
@@ -0,0 +1,177 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { Config } from '@backstage/config';
import { LocationSpec, Entity } from '@backstage/catalog-model';
/**
* A structure for matching entities to a given rule.
*/
type EntityMatcher = {
kind: string;
};
/**
* A structure for matching locations to a given rule.
*/
type LocationMatcher = {
target?: string;
type: string;
};
/**
* Rules to apply to catalog entities
*
* An undefined list of matchers means match all, an empty list of matchers means match none
*/
type CatalogRule = {
allow: EntityMatcher[];
locations?: LocationMatcher[];
};
export class CatalogRulesEnforcer {
/**
* Default rules used by the catalog.
*
* Denies any location from specifying user or group entities.
*/
static readonly defaultRules: CatalogRule[] = [
{
allow: ['Component', 'API', 'Location'].map(kind => ({ kind })),
},
];
/**
* Loads catalog rules from config.
*
* This reads `catalog.rules` and defaults to the default rules if no value is present.
* The value of the config should be a list of config objects, each with a single `allow`
* field which in turn is a list of entity kinds to allow.
*
* If there is no matching rule to allow an ingested entity, it will be rejected by the catalog.
*
* It also reads in rules from `catalog.locations`, where each location can have a list
* of rules for that specific location, specified in a `rules` field.
*
* For example:
*
* ```yaml
* catalog:
* rules:
* - allow: [Component, API]
*
* locations:
* - type: github
* target: https://github.com/org/repo/blob/master/users.yaml
* rules:
* - allow: [User, Group]
* - type: github
* target: https://github.com/org/repo/blob/master/systems.yaml
* rules:
* - allow: [System]
* ```
*/
static fromConfig(config: Config) {
const rules = new Array<CatalogRule>();
if (config.has('catalog.rules')) {
const globalRules = config.getConfigArray('catalog.rules').map(sub => ({
allow: sub.getStringArray('allow').map(kind => ({ kind })),
}));
rules.push(...globalRules);
} else {
rules.push(...CatalogRulesEnforcer.defaultRules);
}
if (config.has('catalog.locations')) {
const locationRules = config
.getConfigArray('catalog.locations')
.flatMap(locConf => {
if (!locConf.has('rules')) {
return [];
}
const type = locConf.getString('type');
const target = locConf.getString('target');
return locConf.getConfigArray('rules').map(ruleConf => ({
allow: ruleConf.getStringArray('allow').map(kind => ({ kind })),
locations: [{ type, target }],
}));
});
rules.push(...locationRules);
}
return new CatalogRulesEnforcer(rules);
}
constructor(private readonly rules: CatalogRule[]) {}
/**
* Checks wether a specific entity/location combination is allowed
* according to the configured rules.
*/
isAllowed(entity: Entity, location: LocationSpec) {
for (const rule of this.rules) {
if (!this.matchLocation(location, rule.locations)) {
continue;
}
if (this.matchEntity(entity, rule.allow)) {
return true;
}
}
return false;
}
private matchLocation(
location: LocationSpec,
matchers?: LocationMatcher[],
): boolean {
if (!matchers) {
return true;
}
for (const matcher of matchers) {
if (matcher.type !== location.type) {
continue;
}
if (matcher.target && matcher.target !== location.target) {
continue;
}
return true;
}
return false;
}
private matchEntity(entity: Entity, matchers?: EntityMatcher[]): boolean {
if (!matchers) {
return true;
}
for (const matcher of matchers) {
if (entity.kind.toLowerCase() !== matcher.kind.toLowerCase()) {
continue;
}
return true;
}
return false;
}
}
@@ -47,6 +47,7 @@ import {
} from './processors/types';
import { YamlProcessor } from './processors/YamlProcessor';
import { LocationReader, ReadLocationResult } from './types';
import { CatalogRulesEnforcer } from './CatalogRules';
// The max amount of nesting depth of generated work items
const MAX_DEPTH = 10;
@@ -63,6 +64,7 @@ type Options = {
export class LocationReaders implements LocationReader {
private readonly logger: Logger;
private readonly processors: LocationProcessor[];
private readonly rulesEnforcer: CatalogRulesEnforcer;
static defaultProcessors(options: {
config?: Config;
@@ -96,6 +98,9 @@ export class LocationReaders implements LocationReader {
}: Options) {
this.logger = logger;
this.processors = processors;
this.rulesEnforcer = config
? CatalogRulesEnforcer.fromConfig(config)
: new CatalogRulesEnforcer(CatalogRulesEnforcer.defaultRules);
}
async read(location: LocationSpec): Promise<ReadLocationResult> {
@@ -112,11 +117,20 @@ export class LocationReaders implements LocationReader {
} else if (item.type === 'data') {
await this.handleData(item, emit);
} else if (item.type === 'entity') {
const entity = await this.handleEntity(item, emit);
output.entities.push({
entity,
location: item.location,
});
if (this.rulesEnforcer.isAllowed(item.entity, item.location)) {
const entity = await this.handleEntity(item, emit);
output.entities.push({
entity,
location: item.location,
});
} else {
output.errors.push({
location: item.location,
error: new Error(
`Entity of kind ${item.entity.kind} is not allowed from location ${item.location.target}:${item.location.type}`,
),
});
}
} else if (item.type === 'error') {
await this.handleError(item, emit);
output.errors.push({
@@ -41,7 +41,15 @@ describe('AzureApiReaderProcessor', () => {
target:
'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster',
url: new URL(
'https://dev.azure.com/org-name/project-name/_apis/sourceProviders/TfsGit/filecontents?repository=repo-name&commitOrBranch=master&path=my-template.yaml&api-version=6.0-preview.1',
'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml&version=master',
),
err: undefined,
},
{
target:
'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml',
url: new URL(
'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml',
),
err: undefined,
},
@@ -83,7 +83,7 @@ export class AzureApiReaderProcessor implements LocationProcessor {
// Converts
// from: https://dev.azure.com/{organization}/{project}/_git/reponame?path={path}&version=GB{commitOrBranch}&_a=contents
// to: https://dev.azure.com/{organization}/{project}/_apis/sourceProviders/{providerName}/filecontents?repository={repository}&commitOrBranch={commitOrBranch}&path={path}&api-version=6.0-preview.1
// to: https://dev.azure.com/{organization}/{project}/_apis/git/repositories/reponame/items?path={path}&version={commitOrBranch}
buildRawUrl(target: string): URL {
try {
const url = new URL(target);
@@ -119,17 +119,19 @@ export class AzureApiReaderProcessor implements LocationProcessor {
userOrOrg,
project,
'_apis',
'sourceProviders',
'TfsGit',
'filecontents',
'git',
'repositories',
repoName,
'items',
].join('/');
url.search = [
`repository=${repoName}`,
`commitOrBranch=${ref}`,
`path=${path}`,
'api-version=6.0-preview.1',
].join('&');
const queryParams = [`path=${path}`];
if (ref) {
queryParams.push(`version=${ref}`);
}
url.search = queryParams.join('&');
url.protocol = 'https';
@@ -15,11 +15,35 @@
*/
import { LocationSpec } from '@backstage/catalog-model';
import fetch from 'node-fetch';
import fetch, { RequestInit, HeadersInit } from 'node-fetch';
import * as result from './results';
import { LocationProcessor, LocationProcessorEmit } from './types';
import { Config } from '@backstage/config';
export class GithubReaderProcessor implements LocationProcessor {
private privateToken: string;
constructor(config?: Config) {
this.privateToken =
config?.getOptionalString('catalog.processors.github.privateToken') ?? '';
}
getRequestOptions(): RequestInit {
const headers: HeadersInit = {
Accept: 'application/vnd.github.v3.raw',
};
if (this.privateToken !== '') {
headers.Authorization = `token ${this.privateToken}`;
}
const requestOptions: RequestInit = {
headers,
};
return requestOptions;
}
async readLocation(
location: LocationSpec,
optional: boolean,
@@ -34,7 +58,7 @@ export class GithubReaderProcessor implements LocationProcessor {
// TODO(freben): Should "hard" errors thrown by this line be treated as
// notFound instead of fatal?
const response = await fetch(url.toString());
const response = await fetch(url.toString(), this.getRequestOptions());
if (response.ok) {
const data = await response.buffer();
+16 -14
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -21,30 +21,32 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.20",
"@backstage/core": "^0.1.1-alpha.20",
"@backstage/plugin-api-docs": "^0.1.1-alpha.20",
"@backstage/plugin-github-actions": "^0.1.1-alpha.20",
"@backstage/plugin-jenkins": "^0.1.1-alpha.20",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.20",
"@backstage/plugin-sentry": "^0.1.1-alpha.20",
"@backstage/plugin-techdocs": "^0.1.1-alpha.20",
"@backstage/theme": "^0.1.1-alpha.20",
"@backstage/catalog-model": "^0.1.1-alpha.21",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/plugin-api-docs": "^0.1.1-alpha.21",
"@backstage/plugin-github-actions": "^0.1.1-alpha.21",
"@backstage/plugin-jenkins": "^0.1.1-alpha.21",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.21",
"@backstage/plugin-sentry": "^0.1.1-alpha.21",
"@backstage/plugin-techdocs": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"moment": "^2.26.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-helmet": "6.1.0",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3",
"swr": "^0.3.0"
"swr": "^0.3.0",
"@types/react": "^16.9"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/dev-utils": "^0.1.1-alpha.20",
"@backstage/test-utils": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/dev-utils": "^0.1.1-alpha.21",
"@backstage/test-utils": "^0.1.1-alpha.21",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/react-hooks": "^3.3.0",
@@ -0,0 +1,46 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { AboutCard } from './AboutCard';
describe('<AboutCard />', () => {
it('renders info and "view source" link', () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
annotations: {
'backstage.io/managed-by-location':
'github:https://github.com/spotify/backstage/blob/master/software.yaml',
},
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
const { getByText } = render(<AboutCard entity={entity} />);
expect(getByText('service')).toBeInTheDocument();
expect(getByText('View Source').closest('a')).toHaveAttribute(
'href',
'https://github.com/spotify/backstage/blob/master/software.yaml',
);
});
});
@@ -0,0 +1,183 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
Grid,
Typography,
makeStyles,
Chip,
IconButton,
Card,
CardContent,
CardHeader,
Divider,
} from '@material-ui/core';
import { Entity } from '@backstage/catalog-model';
import GitHubIcon from '@material-ui/icons/GitHub';
import { IconLinkVertical } from './IconLinkVertical';
import EditIcon from '@material-ui/icons/Edit';
import DocsIcon from '@material-ui/icons/Description';
const useStyles = makeStyles(theme => ({
links: {
margin: theme.spacing(2, 0),
display: 'grid',
gridAutoFlow: 'column',
gridAutoColumns: 'min-content',
gridGap: theme.spacing(3),
},
label: {
color: theme.palette.text.secondary,
textTransform: 'uppercase',
fontSize: '10px',
fontWeight: 'bold',
letterSpacing: 0.5,
overflow: 'hidden',
whiteSpace: 'nowrap',
},
value: {
fontWeight: 'bold',
overflow: 'hidden',
lineHeight: '24px',
wordBreak: 'break-word',
},
description: {
wordBreak: 'break-word',
},
}));
const iconMap: Record<string, React.ReactNode> = {
github: <GitHubIcon />,
};
type CodeLinkInfo = { icon?: React.ReactNode; href?: string };
function getCodeLinkInfo(entity: Entity): CodeLinkInfo {
const location =
entity?.metadata?.annotations?.['backstage.io/managed-by-location'];
if (location) {
// split by first `:`
// e.g. "github:https://github.com/spotify/backstage/blob/master/software.yaml"
const [type, target] = location.split(/:(.+)/);
return { icon: iconMap[type], href: target };
}
return {};
}
type AboutCardProps = {
entity: Entity;
};
export function AboutCard({ entity }: AboutCardProps) {
const classes = useStyles();
const codeLink = getCodeLinkInfo(entity);
return (
<Card>
<CardHeader
title="About"
action={
<IconButton href={codeLink.href || '#'} aria-label="Edit">
<EditIcon />
</IconButton>
}
subheader={
<nav className={classes.links}>
<IconLinkVertical label="View Source" {...codeLink} />
<IconLinkVertical
label="View Techdocs"
icon={<DocsIcon />}
href={`/docs/${''}`}
/>
</nav>
}
/>
<Divider />
<CardContent>
<Grid container>
<AboutField label="Description" gridSizes={{ xs: 12 }}>
<Typography
variant="body2"
paragraph
className={classes.description}
>
{entity?.metadata?.description || 'No description'}
</Typography>
</AboutField>
<AboutField
label="Owner"
value={entity?.spec?.owner as string}
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
/>
<AboutField
label="Type"
value={entity?.spec?.type as string}
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
/>
<AboutField
label="Lifecycle"
value={entity?.spec?.lifecycle as string}
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
/>
<AboutField
label="Tags"
value="No Tags"
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
>
{(entity?.metadata?.tags || []).map(t => (
<Chip key={t} size="small" label={t} />
))}
</AboutField>
</Grid>
</CardContent>
</Card>
);
}
function AboutField({
label,
value,
gridSizes,
children,
}: {
label: string;
value?: string;
gridSizes?: Record<string, number>;
children?: React.ReactNode;
}) {
const classes = useStyles();
// Content is either children or a string prop `value`
const content = React.Children.count(children) ? (
children
) : (
<Typography variant="body2" className={classes.value}>
{value || `unknown`}
</Typography>
);
return (
<Grid item {...gridSizes}>
<Typography variant="subtitle2" className={classes.label}>
{label}
</Typography>
{content}
</Grid>
);
}
@@ -0,0 +1,53 @@
/*
* Copyright 2020 Spotify AB
*
* 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 * as React from 'react';
import { makeStyles, Link } from '@material-ui/core';
import LinkIcon from '@material-ui/icons/Link';
export type IconLinkVerticalProps = {
icon?: React.ReactNode;
href?: string;
label: string;
};
const useIconStyles = makeStyles({
link: {
display: 'grid',
justifyItems: 'center',
gridGap: 4,
textAlign: 'center',
},
label: {
fontSize: '0.7rem',
textTransform: 'uppercase',
fontWeight: 600,
letterSpacing: 1.2,
},
});
export function IconLinkVertical({
icon = <LinkIcon />,
href = '#',
...props
}: IconLinkVerticalProps) {
const classes = useIconStyles();
return (
<Link className={classes.link} href={href} {...props}>
{icon}
<span className={classes.label}>{props.label}</span>
</Link>
);
}
@@ -13,4 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Widget, RecentWorkflowRunsCard } from './Widget';
export { IconLinkVertical } from './IconLinkVertical';
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { WorkflowRunDetailsPage } from './WorkflowRunDetailsPage';
export { AboutCard } from './AboutCard';
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { CatalogPage } from './CatalogPage';
@@ -75,12 +75,7 @@ const columns: TableColumn<Entity>[] = [
<>
{entity.metadata.tags &&
entity.metadata.tags.map(t => (
<Chip
key={t}
label={t}
color="secondary"
style={{ marginBottom: '0px' }}
/>
<Chip key={t} label={t} style={{ marginBottom: '0px' }} />
))}
</>
),
@@ -1,100 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
const mockNavigate = jest.fn();
return {
...actual,
useNavigate: jest.fn(() => mockNavigate),
useParams: jest.fn(),
};
});
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { wrapInTestApp } from '@backstage/test-utils';
import { render, waitFor } from '@testing-library/react';
import * as React from 'react';
import { CatalogApi, catalogApiRef } from '../../api/types';
import { EntityPage, getPageTheme } from './EntityPage';
const {
useParams,
useNavigate,
}: { useParams: jest.Mock; useNavigate: () => jest.Mock } = jest.requireMock(
'react-router-dom',
);
const errorApi = { post: () => {} };
describe('EntityPage', () => {
it('should redirect to catalog page when name is not provided', async () => {
useParams.mockReturnValue({
kind: 'Component',
optionalNamespaceAndName: '',
});
render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[errorApiRef, errorApi],
[
catalogApiRef,
({
async getEntityByName() {},
} as Partial<CatalogApi>) as CatalogApi,
],
])}
>
<EntityPage />
</ApiProvider>,
),
);
await waitFor(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog'));
});
});
describe('getPageTheme', () => {
const defaultPageTheme = getPageTheme();
it.each(['service', 'app', 'library', 'tool', 'documentation', 'website'])(
'should select right theme for predefined type: %p ̰ ',
type => {
const theme = getPageTheme(({
spec: {
type,
},
} as any) as Entity);
expect(theme).toBeDefined();
expect(theme).not.toBe(defaultPageTheme);
},
);
it('should select default theme for unknown/unspecified types', () => {
const theme1 = getPageTheme(({
spec: {
type: 'unknown-type',
},
} as any) as Entity);
const theme2 = getPageTheme(({
spec: {},
} as any) as Entity);
expect(theme1).toBe(defaultPageTheme);
expect(theme2).toBe(defaultPageTheme);
});
});
@@ -1,231 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { Entity } from '@backstage/catalog-model';
import {
Content,
errorApiRef,
Header,
HeaderLabel,
Page,
pageTheme,
PageTheme,
Progress,
useApi,
HeaderTabs,
} from '@backstage/core';
import { Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { FC, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../..';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { EntityPageDocs } from '../EntityPageDocs/EntityDocsPage';
import { EntityPageApi } from '../EntityPageApi/EntityPageApi';
import { EntityPageOverview } from '../EntityPageOverview/EntityPageOverview';
import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
const REDIRECT_DELAY = 1000;
function headerProps(
kind: string,
namespace: string | undefined,
name: string,
entity: Entity | undefined,
): { headerTitle: string; headerType: string } {
return {
headerTitle: `${name}${namespace ? ` in ${namespace}` : ''}`,
headerType: (() => {
let t = kind.toLowerCase();
if (entity && entity.spec && 'type' in entity.spec) {
t += ' — ';
t += (entity.spec as { type: string }).type.toLowerCase();
}
return t;
})(),
};
}
export const getPageTheme = (entity?: Entity): PageTheme => {
const themeKey = entity?.spec?.type?.toString() ?? 'home';
return pageTheme[themeKey] ?? pageTheme.home;
};
const EntityPageTitle: FC<{ title: string; entity: Entity | undefined }> = ({
entity,
title,
}) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
{entity && <FavouriteEntity entity={entity} />}
</Box>
);
export const EntityPage: FC<{}> = () => {
const {
optionalNamespaceAndName,
kind,
selectedTabId = 'overview',
} = useParams() as {
optionalNamespaceAndName: string;
kind: string;
selectedTabId: string;
};
const navigate = useNavigate();
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const errorApi = useApi(errorApiRef);
const catalogApi = useApi(catalogApiRef);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const { value: entity, error, loading } = useAsync(
() => catalogApi.getEntityByName({ kind, namespace, name }),
[catalogApi, kind, namespace, name],
);
useEffect(() => {
if (!error && !loading && !entity) {
errorApi.post(new Error('Entity not found!'));
setTimeout(() => {
navigate('/');
}, REDIRECT_DELAY);
}
}, [errorApi, navigate, error, loading, entity]);
if (!name) {
navigate('/catalog');
return null;
}
const cleanUpAfterRemoval = async () => {
setConfirmationDialogOpen(false);
navigate('/');
};
const showRemovalDialog = () => setConfirmationDialogOpen(true);
// TODO - Replace with proper tabs implementation
const tabs = [
{
id: 'overview',
label: 'Overview',
content: (e: Entity) => <EntityPageOverview entity={e} />,
},
{
id: 'ci',
label: 'CI/CD',
},
{
id: 'tests',
label: 'Tests',
},
{
id: 'api',
label: 'API',
show: (e: Entity) => !!e?.spec?.implementsApis,
content: (e: Entity) => <EntityPageApi entity={e} />,
},
{
id: 'monitoring',
label: 'Monitoring',
},
{
id: 'quality',
label: 'Quality',
},
{
id: 'docs',
label: 'Docs',
show: (e: Entity) =>
!!e.metadata.annotations?.['backstage.io/techdocs-ref'],
content: (e: Entity) => <EntityPageDocs entity={e} />,
},
];
const { headerTitle, headerType } = headerProps(
kind,
namespace,
name,
entity,
);
const selectedTab = tabs.find(tab => tab.id === selectedTabId);
const filteredHeaderTabs = entity
? tabs.filter(tab => (tab.show ? tab.show(entity) : true))
: [];
return (
<Page theme={getPageTheme(entity)}>
<Header
title={<EntityPageTitle title={headerTitle} entity={entity} />}
pageTitleOverride={headerTitle}
type={headerType}
>
{entity && (
<>
<HeaderLabel
label="Owner"
value={entity.spec?.owner || 'unknown'}
/>
<HeaderLabel
label="Lifecycle"
value={entity.spec?.lifecycle || 'unknown'}
/>
<EntityContextMenu onUnregisterEntity={showRemovalDialog} />
</>
)}
</Header>
{loading && <Progress />}
{error && (
<Content>
<Alert severity="error">{error.toString()}</Alert>
</Content>
)}
{entity && (
<>
<HeaderTabs
tabs={filteredHeaderTabs}
onChange={idx => {
navigate(
`/catalog/${kind}/${optionalNamespaceAndName}/${filteredHeaderTabs[idx].id}`,
);
}}
selectedIndex={filteredHeaderTabs.findIndex(
tab => tab.id === selectedTabId,
)}
/>
{selectedTab && selectedTab.content
? selectedTab.content(entity)
: null}
<UnregisterEntityDialog
open={confirmationDialogOpen}
entity={entity}
onConfirm={cleanUpAfterRemoval}
onClose={() => setConfirmationDialogOpen(false)}
/>
</>
)}
</Page>
);
};
@@ -14,19 +14,26 @@
* limitations under the License.
*/
// TODO(shmidt-i): move to the app
import { Entity } from '@backstage/catalog-model';
import { render } from '@testing-library/react';
import React from 'react';
import { EntityMetadataCard } from './EntityMetadataCard';
import { Content } from '@backstage/core';
import {
LatestWorkflowsForBranchCard,
GITHUB_ACTIONS_ANNOTATION,
} from '@backstage/plugin-github-actions';
import { Grid } from '@material-ui/core';
import React, { FC } from 'react';
describe('EntityMetadataCard component', () => {
it('should display entity name if provided', async () => {
const testEntity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'test' },
};
const rendered = await render(<EntityMetadataCard entity={testEntity} />);
expect(await rendered.findByText('test')).toBeInTheDocument();
});
});
export const EntityPageCi: FC<{ entity: Entity }> = ({ entity }) => {
return (
<Content>
<Grid container spacing={3}>
{entity.metadata?.annotations?.[GITHUB_ACTIONS_ANNOTATION] && (
<Grid item sm={12}>
<LatestWorkflowsForBranchCard entity={entity} branch="master" />
</Grid>
)}
</Grid>
</Content>
);
};
@@ -0,0 +1,142 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState, useContext } from 'react';
import { useParams, useNavigate } from 'react-router';
import { EntityContext } from '../../hooks/useEntity';
import {
pageTheme,
PageTheme,
Page,
Header,
HeaderLabel,
Content,
Progress,
} from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
import { Box } from '@material-ui/core';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
import { Alert } from '@material-ui/lab';
import { Tabbed } from './Tabbed';
const getPageTheme = (entity?: Entity): PageTheme => {
const themeKey = entity?.spec?.type?.toString() ?? 'home';
return pageTheme[themeKey] ?? pageTheme.home;
};
const EntityPageTitle = ({
entity,
title,
}: {
title: string;
entity: Entity | undefined;
}) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
{entity && <FavouriteEntity entity={entity} />}
</Box>
);
function headerProps(
kind: string,
namespace: string | undefined,
name: string,
entity: Entity | undefined,
): { headerTitle: string; headerType: string } {
return {
headerTitle: `${name}${namespace ? ` in ${namespace}` : ''}`,
headerType: (() => {
let t = kind.toLowerCase();
if (entity && entity.spec && 'type' in entity.spec) {
t += ' — ';
t += (entity.spec as { type: string }).type.toLowerCase();
}
return t;
})(),
};
}
export const EntityPageLayout = ({
children,
}: {
children?: React.ReactNode;
}) => {
const { optionalNamespaceAndName, kind } = useParams() as {
optionalNamespaceAndName: string;
kind: string;
};
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const { entity, loading, error } = useContext(EntityContext);
const { headerTitle, headerType } = headerProps(
kind,
namespace,
name,
entity!,
);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const navigate = useNavigate();
const cleanUpAfterRemoval = async () => {
setConfirmationDialogOpen(false);
navigate('/');
};
const showRemovalDialog = () => setConfirmationDialogOpen(true);
return (
<Page theme={getPageTheme(entity!)}>
<Header
title={<EntityPageTitle title={headerTitle} entity={entity!} />}
pageTitleOverride={headerTitle}
type={headerType}
>
{entity && (
<>
<HeaderLabel
label="Owner"
value={entity.spec?.owner || 'unknown'}
/>
<HeaderLabel
label="Lifecycle"
value={entity.spec?.lifecycle || 'unknown'}
/>
<EntityContextMenu onUnregisterEntity={showRemovalDialog} />
</>
)}
</Header>
{loading && <Progress />}
{entity && <Tabbed.Layout>{children}</Tabbed.Layout>}
{error && (
<Content>
<Alert severity="error">{error.toString()}</Alert>
</Content>
)}
<UnregisterEntityDialog
open={confirmationDialogOpen}
entity={entity!}
onConfirm={cleanUpAfterRemoval}
onClose={() => setConfirmationDialogOpen(false)}
/>
</Page>
);
};
EntityPageLayout.Content = Tabbed.Content;
@@ -0,0 +1,198 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Tabbed } from './Tabbed';
import { renderInTestApp } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { Routes, Route } from 'react-router';
describe('Tabbed layout', () => {
it('renders simplest case', async () => {
const rendered = await renderInTestApp(
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="*"
element={<div>tabbed-test-content</div>}
/>
</Tabbed.Layout>,
);
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument();
});
it('throws if any other component is a child of Tabbed.Layout', async () => {
await expect(
renderInTestApp(
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="*"
element={<div>tabbed-test-content</div>}
/>
<div>This will cause app to throw</div>
</Tabbed.Layout>,
),
).rejects.toThrow(/This component only accepts/);
});
it('navigates when user clicks different tab', async () => {
const rendered = await renderInTestApp(
<Routes>
<Route
path="/*"
element={
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="/"
element={<div>tabbed-test-content</div>}
/>
<Tabbed.Content
title="tabbed-test-title-2"
path="/some-other-path"
element={<div>tabbed-test-content-2</div>}
/>
</Tabbed.Layout>
}
/>
</Routes>,
);
const secondTab = rendered.queryAllByRole('tab')[1];
act(() => {
fireEvent.click(secondTab);
});
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument();
expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
});
describe('correctly delegates nested links', () => {
const renderRoute = (route: string) =>
renderInTestApp(
<Routes>
<Route
path="/*"
element={
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="/"
element={<div>tabbed-test-content</div>}
/>
<Tabbed.Content
title="tabbed-test-title-2"
path="/some-other-path/*"
element={
<div>
tabbed-test-content-2
<Routes>
<Route
path="/nested"
element={<div>tabbed-test-nested-content-2</div>}
/>
</Routes>
</div>
}
/>
</Tabbed.Layout>
}
/>
</Routes>,
{ routeEntries: [route] },
);
it('works for nested content', async () => {
const rendered = await renderRoute('/some-other-path/nested');
expect(
rendered.queryByText('tabbed-test-content'),
).not.toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
expect(
rendered.queryByText('tabbed-test-nested-content-2'),
).toBeInTheDocument();
});
it('works for non-nested content', async () => {
const rendered = await renderRoute('/some-other-path/');
expect(
rendered.queryByText('tabbed-test-content'),
).not.toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
expect(
rendered.queryByText('tabbed-test-nested-content-2'),
).not.toBeInTheDocument();
});
});
it('shows only one tab contents at a time', async () => {
const rendered = await renderInTestApp(
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="/"
element={<div>tabbed-test-content</div>}
/>
<Tabbed.Content
title="tabbed-test-title-2"
path="/some-other-path"
element={<div>tabbed-test-content-2</div>}
/>
</Tabbed.Layout>,
{ routeEntries: ['/some-other-path'] },
);
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument();
expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
});
it('redirects to the top level when no route is matching the url', async () => {
const rendered = await renderInTestApp(
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="/"
element={<div>tabbed-test-content</div>}
/>
<Tabbed.Content
title="tabbed-test-title-2"
path="/some-other-path"
element={<div>tabbed-test-content-2</div>}
/>
</Tabbed.Layout>,
{ routeEntries: ['/non-existing-path'] },
);
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument();
expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument();
expect(
rendered.queryByText('tabbed-test-content-2'),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,127 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
useParams,
useNavigate,
PartialRouteObject,
matchRoutes,
RouteObject,
useRoutes,
Navigate,
RouteMatch,
} from 'react-router';
import { Tab, HeaderTabs, Content } from '@backstage/core';
import { Helmet } from 'react-helmet';
const getSelectedIndexOrDefault = (
matchedRoute: RouteMatch,
tabs: Tab[],
defaultIndex = 0,
) => {
if (!matchedRoute) return defaultIndex;
const tabIndex = tabs.findIndex(t => t.id === matchedRoute.route.path);
return ~tabIndex ? tabIndex : defaultIndex;
};
/**
* Compound component, which allows you to define layout
* for EntityPage using Tabs as a subnavigation mechanism
* Constists of 2 parts: Tabbed.Layout and Tabbed.Content.
* Takes care of: tabs, routes, document titles, spacing around content
*
* @example
* ```jsx
* <Tabbed.Layout>
* <Tabbed.Content
* title="Example tab"
* route="/example/*"
* element={<div>This is rendered under /example/anything-here route</div>}
* />
* </TabbedLayout>
* ```
*/
export const Tabbed = {
Layout: ({ children }: { children: React.ReactNode }) => {
const routes: PartialRouteObject[] = [];
const tabs: Tab[] = [];
const params = useParams();
const navigate = useNavigate();
React.Children.forEach(children, child => {
if (!React.isValidElement(child)) {
// Skip conditionals resolved to falses/nulls/undefineds etc
return;
}
if (child.type !== Tabbed.Content) {
throw new Error(
'This component only accepts Content elements as direct children. Check the code of the EntityPage.',
);
}
const pathAndId = (child as JSX.Element).props.path;
// Child here must be then always a functional component without any wrappers
tabs.push({
id: pathAndId,
label: (child as JSX.Element).props.title,
});
routes.push({
path: pathAndId,
element: child.props.element,
});
});
// Add catch-all for incorrect sub-routes
if ((routes?.[0]?.path ?? '') !== '')
routes.push({
path: '/*',
element: <Navigate to={routes[0].path!} />,
});
const [matchedRoute] =
matchRoutes(routes as RouteObject[], `/${params['*']}`) ?? [];
const selectedIndex = getSelectedIndexOrDefault(matchedRoute, tabs);
const currentTab = tabs[selectedIndex];
const title = currentTab?.label;
const onTabChange = (index: number) =>
// Remove trailing /*
// And remove leading / for relative navigation
// Note! route resolves relative to the position in the React tree,
// not relative to current location
navigate(tabs[index].id.replace(/\/\*$/, '').replace(/^\//, ''));
const currentRouteElement = useRoutes(routes);
if (!currentTab) return null;
return (
<>
<HeaderTabs
tabs={tabs}
selectedIndex={selectedIndex}
onChange={onTabChange}
/>
<Content>
<Helmet title={title} />
{currentRouteElement}
</Content>
</>
);
},
Content: (_props: { path: string; title: string; element: JSX.Element }) =>
null,
};
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Tabbed } from './Tabbed';
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { EntityPageLayout } from './EntityPageLayout';
@@ -14,24 +14,27 @@
* limitations under the License.
*/
// TODO(shmidt-i): move to the app
import { Entity } from '@backstage/catalog-model';
import { Content } from '@backstage/core';
import { SentryIssuesWidget } from '@backstage/plugin-sentry';
import { Widget as GithubActionsWidget } from '@backstage/plugin-github-actions';
import {
LatestWorkflowRunCard,
GITHUB_ACTIONS_ANNOTATION,
} from '@backstage/plugin-github-actions';
import {
JenkinsBuildsWidget,
JenkinsLastBuildWidget,
} from '@backstage/plugin-jenkins';
import { Grid } from '@material-ui/core';
import React, { FC } from 'react';
import { EntityMetadataCard } from '../EntityMetadataCard/EntityMetadataCard';
import { AboutCard } from '../AboutCard';
export const EntityPageOverview: FC<{ entity: Entity }> = ({ entity }) => {
return (
<Content>
<Grid container spacing={3}>
<Grid item sm={4}>
<EntityMetadataCard entity={entity} />
<AboutCard entity={entity} />
</Grid>
{entity.metadata?.annotations?.[
'backstage.io/jenkins-github-folder'
@@ -47,17 +50,11 @@ export const EntityPageOverview: FC<{ entity: Entity }> = ({ entity }) => {
<JenkinsBuildsWidget entity={entity} />
</Grid>
)}
{entity.metadata?.annotations?.['backstage.io/github-actions-id'] && (
{entity.metadata?.annotations?.[GITHUB_ACTIONS_ANNOTATION] && (
<Grid item sm={3}>
<GithubActionsWidget entity={entity} branch="master" />
<LatestWorkflowRunCard entity={entity} branch="master" />
</Grid>
)}
<Grid item sm={8}>
<SentryIssuesWidget
sentryProjectId="sample-sentry-project-id"
statsFor="24h"
/>
</Grid>
</Grid>
</Content>
);
@@ -0,0 +1,27 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ReactNode } from 'react';
import { useEntityFromUrl, EntityContext } from '../../hooks/useEntity';
export const EntityProvider = ({ children }: { children: ReactNode }) => {
const { entity, loading, error } = useEntityFromUrl();
return (
<EntityContext.Provider value={{ entity, loading, error }}>
{children}
</EntityContext.Provider>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { EntityProvider } from './EntityProvider';
@@ -104,6 +104,7 @@ export const ResultsFilter = ({ availableTags }: Props) => {
>
<Checkbox
edge="start"
color="primary"
checked={selectedTags.includes(t)}
tabIndex={-1}
disableRipple
+73
View File
@@ -0,0 +1,73 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { ComponentType } from 'react';
import { CatalogPage } from './CatalogPage';
import { EntityPageLayout } from './EntityPageLayout';
import { Route, Routes } from 'react-router';
import { entityRoute, rootRoute } from '../routes';
import { Content } from '@backstage/core';
import { Typography, Link } from '@material-ui/core';
import { EntityProvider } from './EntityProvider';
import { useEntity } from '../hooks/useEntity';
const DefaultEntityPage = () => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/"
title="Overview"
element={
<Content>
<Typography variant="h2">This is default entity page. </Typography>
<Typography variant="body1">
To override this component with your custom implementation, read
docs on{' '}
<Link target="_blank" href="https://backstage.io/docs">
backstage.io/docs
</Link>
</Typography>
</Content>
}
/>
</EntityPageLayout>
);
const EntityPageSwitch = ({ EntityPage }: { EntityPage: ComponentType }) => {
const { entity } = useEntity();
// Loading and error states
if (!entity) return <EntityPageLayout />;
// Otherwise EntityPage provided from the App
// Note that EntityPage will include EntityPageLayout already
return <EntityPage />;
};
export const Router = ({
EntityPage = DefaultEntityPage,
}: {
EntityPage?: ComponentType;
}) => (
<Routes>
<Route path={`/${rootRoute.path}`} element={<CatalogPage />} />
<Route
path={`/${entityRoute.path}`}
element={
<EntityProvider>
<EntityPageSwitch EntityPage={EntityPage} />
</EntityProvider>
}
/>
</Routes>
);
+72
View File
@@ -0,0 +1,72 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { useEffect, createContext, useContext } from 'react';
import { useNavigate, useParams } from 'react-router';
import { useApi, errorApiRef } from '@backstage/core';
import { catalogApiRef } from '../api/types';
import { useAsync } from 'react-use';
import { Entity } from '@backstage/catalog-model';
const REDIRECT_DELAY = 2000;
type EntityLoadingStatus = {
entity?: Entity;
loading: boolean;
error?: Error;
};
export const EntityContext = createContext<EntityLoadingStatus>({
entity: undefined,
loading: true,
error: undefined,
});
export const useEntityFromUrl = (): EntityLoadingStatus => {
const { optionalNamespaceAndName, kind } = useParams();
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const navigate = useNavigate();
const errorApi = useApi(errorApiRef);
const catalogApi = useApi(catalogApiRef);
const { value: entity, error, loading } = useAsync(
() => catalogApi.getEntityByName({ kind, namespace, name }),
[catalogApi, kind, namespace, name],
);
useEffect(() => {
if (error || (!loading && !entity)) {
errorApi.post(new Error('Entity not found!'));
setTimeout(() => {
navigate('/');
}, REDIRECT_DELAY);
}
if (!name) {
errorApi.post(new Error('No name provided!'));
navigate('/');
}
}, [errorApi, navigate, error, loading, entity, name]);
return { entity, loading, error };
};
/**
* Always going to return an entity, or throw an error if not a descendant of a EntityProvider.
*/
export const useEntity = () => {
const { entity } = useContext<{ entity: Entity }>(EntityContext as any);
return { entity };
};
+4
View File
@@ -19,3 +19,7 @@ export * from './api/CatalogClient';
export * from './api/types';
export * from './routes';
export { useEntityCompoundName } from './components/useEntityCompoundName';
export { Router } from './components/Router';
export { useEntity } from './hooks/useEntity';
export { AboutCard } from './components/AboutCard';
export { EntityPageLayout } from './components/EntityPageLayout';
-8
View File
@@ -15,15 +15,7 @@
*/
import { createPlugin } from '@backstage/core';
import { CatalogPage } from './components/CatalogPage/CatalogPage';
import { EntityPage } from './components/EntityPage/EntityPage';
import { entityRoute, rootRoute, entityRouteDefault } from './routes';
export const plugin = createPlugin({
id: 'catalog',
register({ router }) {
router.addRoute(rootRoute, CatalogPage);
router.addRoute(entityRoute, EntityPage);
router.addRoute(entityRouteDefault, EntityPage);
},
});
+2 -7
View File
@@ -20,16 +20,11 @@ const NoIcon = () => null;
export const rootRoute = createRouteRef({
icon: NoIcon,
path: '/',
path: '',
title: 'Catalog',
});
export const entityRoute = createRouteRef({
icon: NoIcon,
path: '/catalog/:kind/:optionalNamespaceAndName/:selectedTabId/*',
title: 'Entity',
});
export const entityRouteDefault = createRouteRef({
icon: NoIcon,
path: '/catalog/:kind/:optionalNamespaceAndName',
path: ':kind/:optionalNamespaceAndName/*',
title: 'Entity',
});
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-circleci",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -21,8 +21,8 @@
"postpack": "backstage-cli postpack"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.20",
"@backstage/theme": "^0.1.1-alpha.20",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -36,8 +36,8 @@
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/dev-utils": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/dev-utils": "^0.1.1-alpha.21",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-explore",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -21,8 +21,8 @@
"start": "backstage-cli plugin:serve"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.20",
"@backstage/theme": "^0.1.1-alpha.20",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -32,9 +32,9 @@
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/dev-utils": "^0.1.1-alpha.20",
"@backstage/test-utils": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/dev-utils": "^0.1.1-alpha.21",
"@backstage/test-utils": "^0.1.1-alpha.21",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+13
View File
@@ -0,0 +1,13 @@
# gcp-projects
Welcome to the gcp-projects plugin!
_This plugin was created through the Backstage CLI_
## Getting started
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/gcp-projects](http://localhost:3000/gcp-projects).
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory.
@@ -14,16 +14,9 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { InfoCard, StructuredMetadataTable } from '@backstage/core';
import React, { FC } from 'react';
import { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
type Props = {
entity: Entity;
};
export const EntityMetadataCard: FC<Props> = ({ entity }) => (
<InfoCard title="Information">
<StructuredMetadataTable metadata={entity.metadata} />
</InfoCard>
);
createDevApp()
.registerPlugin(plugin)
.render();
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@backstage/plugin-gcp-projects",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "^5.2.0",
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/dev-utils": "^0.1.1-alpha.21",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist"
]
}
+34
View File
@@ -0,0 +1,34 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { createApiRef } from '@backstage/core';
import { Project, Operation } from './types';
export const GCPApiRef = createApiRef<GCPApi>({
id: 'plugin.gcpprojects.service',
description: 'Used by the GCP Projects plugin to make requests',
});
export type GCPApi = {
listProjects: ({ token }: { token: string }) => Promise<Project[]>;
getProject: (projectId: string, token: Promise<string>) => Promise<Project>;
createProject: (
projectName: string,
projectId: string,
owner: string,
token: string,
) => Promise<Operation>;
};
+124
View File
@@ -0,0 +1,124 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { GCPApi } from './GCPApi';
import { Project, Operation, Status } from './types';
const BaseURL =
'https://content-cloudresourcemanager.googleapis.com/v1/projects';
export class GCPClient implements GCPApi {
async listProjects({ token }: { token: string }): Promise<Project[]> {
const response = await fetch(BaseURL, {
headers: new Headers({
Accept: '*/*',
Authorization: `Bearer ${token}`,
}),
});
if (!response.ok) {
return [
{
name: 'Error',
projectNumber: 'Response status is not OK',
projectId: 'Error',
lifecycleState: 'error',
createTime: 'Error',
},
];
}
const data = await response.json();
return data.projects;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getProject(
projectId: string,
token: Promise<string>,
): Promise<Project> {
const url = `${BaseURL}/${projectId}`;
const response = await fetch(url, {
headers: new Headers({
Authorization: `Bearer ${await token}`,
}),
});
const dataBlank: Project = {
name: 'Error',
projectNumber: `Response status is ${response.status}`,
projectId: 'Error',
lifecycleState: 'error',
createTime: 'Error',
};
if (!response.ok) {
return dataBlank;
}
const data = await response.json();
const newData: Project = data;
return newData;
}
async createProject(
projectName: string,
projectId: string,
token: string,
): Promise<Operation> {
const status: Status = {
code: 0,
message: '',
details: [],
};
const op: Operation = {
name: '',
metadata: '',
done: true,
error: status,
response: '',
};
const newProject: Project = {
name: projectName,
projectId: projectId,
};
const body = JSON.stringify(newProject);
const response = await fetch(BaseURL, {
headers: new Headers({
Accept: '*/*',
Authorization: `Bearer ${token}`,
}),
body: body,
method: 'POST',
});
if (!response.ok) {
status.code = response.status;
return op;
}
const data = await response.json();
return data;
}
}
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './GCPApi';
export * from './GCPClient';
export * from './types';
+41
View File
@@ -0,0 +1,41 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type Project = {
name: string;
projectNumber?: string;
projectId: string;
lifecycleState?: string;
createTime?: string;
};
export type ProjectDetails = {
details: string;
};
export type Operation = {
name: string;
metadata: string;
done: boolean;
error: Status;
response: string;
};
export type Status = {
code: number;
message: string;
details: string[];
};
@@ -0,0 +1,131 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC, useState } from 'react';
import { Grid, Button, TextField } from '@material-ui/core';
import {
InfoCard,
Content,
ContentHeader,
SimpleStepper,
SimpleStepperStep,
StructuredMetadataTable,
HeaderLabel,
Page,
Header,
pageTheme,
SupportButton,
} from '@backstage/core';
export const Project: FC<{}> = () => {
const [projectName, setProjectName] = useState('');
const [projectId, setProjectId] = useState('');
const [disabled, setDisabled] = useState(true);
const metadata = {
ProjectName: projectName,
ProjectId: projectId,
};
return (
<Content>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<InfoCard title="Create new GCP Project">
<SimpleStepper>
<SimpleStepperStep title="Project Name">
<TextField
variant="outlined"
name="projectName"
label="Project Name"
helperText="The name of the new project."
inputProps={{ 'aria-label': 'Project Name' }}
onChange={e => setProjectName(e.target.value)}
value={projectName}
fullWidth
/>
</SimpleStepperStep>
<SimpleStepperStep title="Project ID">
<TextField
variant="outlined"
name="projectId"
label="projectId"
onChange={e => setProjectId(e.target.value)}
value={projectId}
fullWidth
/>
</SimpleStepperStep>
<SimpleStepperStep
title="Review"
actions={{
nextText: 'Confirm',
onNext: () => setDisabled(false),
}}
>
<StructuredMetadataTable metadata={metadata} />
</SimpleStepperStep>
</SimpleStepper>
<Button
variant="text"
data-testid="cancel-button"
color="primary"
href="/gcp-projects"
>
Cancel
</Button>
<Button
variant="contained"
color="primary"
disabled={disabled}
href={`newProject?projectName=${encodeURIComponent(
projectName,
)},projectId=${encodeURIComponent(projectId)}`}
>
Create
</Button>
</InfoCard>
</Grid>
</Grid>
</Content>
);
};
const labels = (
<>
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Production" />
</>
);
export const NewProjectPage = () => {
return (
<Page theme={pageTheme.service}>
<Header title="New GCP Project" type="tool">
{labels}
</Header>
<Content>
<ContentHeader title="">
<SupportButton>
This plugin allows you to view and interact with your gcp projects.
</SupportButton>
</ContentHeader>
<Project />
</Content>
</Page>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { NewProjectPage } from './NewProjectPage';
@@ -0,0 +1,171 @@
/*
* Copyright 2020 Spotify AB
*
* 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 {
Button,
ButtonGroup,
LinearProgress,
makeStyles,
Paper,
Table,
TableBody,
TableCell,
TableRow,
Theme,
Typography,
} from '@material-ui/core';
import {
useApi,
googleAuthApiRef,
HeaderLabel,
Page,
Header,
pageTheme,
SupportButton,
Content,
ContentHeader,
} from '@backstage/core';
import React from 'react';
import { useAsync } from 'react-use';
import { GCPApiRef } from '../../api';
const useStyles = makeStyles<Theme>(theme => ({
root: {
maxWidth: 720,
margin: theme.spacing(2),
},
title: {
padding: theme.spacing(1, 0, 2, 0),
},
table: {
padding: theme.spacing(1),
},
}));
const DetailsPage = () => {
const api = useApi(GCPApiRef);
const googleApi = useApi(googleAuthApiRef);
const token = googleApi.getAccessToken(
'https://www.googleapis.com/auth/cloud-platform.read-only',
);
const classes = useStyles();
const status = useAsync(
() =>
api.getProject(
decodeURIComponent(location.search.split('projectId=')[1]),
token,
),
[location.search],
);
if (status.loading) {
return <LinearProgress />;
} else if (status.error) {
return (
<Typography variant="h6" color="error">
Failed to load build, {status.error.message}
</Typography>
);
}
const details = status.value;
return (
<Table component={Paper} className={classes.table}>
<Table>
<TableBody>
<TableRow>
<TableCell>
<Typography noWrap>Name</Typography>
</TableCell>
<TableCell>{details?.name}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Project Number</Typography>
</TableCell>
<TableCell>{details?.projectNumber}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Project ID</Typography>
</TableCell>
<TableCell>{details?.projectId}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>State</Typography>
</TableCell>
<TableCell>{details?.lifecycleState}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Creation Time</Typography>
</TableCell>
<TableCell>{details?.createTime}</TableCell>
</TableRow>
<TableRow>
<TableCell>
<Typography noWrap>Links</Typography>
</TableCell>
<TableCell>
<ButtonGroup
variant="text"
color="primary"
aria-label="text primary button group"
>
{details?.name && (
<Button>
<a href={details.name}>GCP</a>
</Button>
)}
{details?.name && (
<Button>
<a href={details.name}>Logs</a>
</Button>
)}
</ButtonGroup>
</TableCell>
</TableRow>
</TableBody>
</Table>
</Table>
);
};
const labels = (
<>
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Production" />
</>
);
export const ProjectDetailsPage = () => {
return (
<Page theme={pageTheme.service}>
<Header title="GCP Project Details" type="other">
{labels}
</Header>
<Content>
<ContentHeader title="">
<SupportButton>Support Button</SupportButton>
</ContentHeader>
<DetailsPage />
</Content>
</Page>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ProjectDetailsPage } from './ProjectDetailsPage';
@@ -0,0 +1,164 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
// NEEDS WORK
import {
Link,
useApi,
googleAuthApiRef,
HeaderLabel,
Page,
Header,
pageTheme,
SupportButton,
Content,
ContentHeader,
} from '@backstage/core';
import {
LinearProgress,
Paper,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Tooltip,
Typography,
Button,
} from '@material-ui/core';
import React from 'react';
import { useAsync } from 'react-use';
import { GCPApiRef, Project } from '../../api';
const LongText = ({ text, max }: { text: string; max: number }) => {
if (text.length < max) {
return <span>{text}</span>;
}
return (
<Tooltip title={text}>
<span>{text.slice(0, max)}...</span>
</Tooltip>
);
};
const labels = (
<>
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Production" />
</>
);
const PageContents = () => {
const api = useApi(GCPApiRef);
const googleApi = useApi(googleAuthApiRef);
const { loading, error, value } = useAsync(async () => {
const token = await googleApi.getAccessToken(
'https://www.googleapis.com/auth/cloud-platform.read-only',
);
const projects = api.listProjects({ token });
return projects;
});
if (loading) {
return <LinearProgress />;
}
if (error) {
return (
<Typography variant="h2" color="error">
{error.message}{' '}
</Typography>
);
}
return (
<Table component={Paper}>
<Table aria-label="GCP Projects table">
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Project Number</TableCell>
<TableCell>Project ID</TableCell>
<TableCell>State</TableCell>
<TableCell>Creation Time</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value?.map((project: Project) => (
<TableRow key={project.projectId}>
<TableCell>
<Typography>
<LongText text={project.name} max={30} />
</Typography>
</TableCell>
<TableCell>
<Typography>
<LongText text={project?.projectNumber || 'Error'} max={30} />
</Typography>
</TableCell>
<TableCell>
<Link
to={`project?projectId=${encodeURIComponent(
project.projectId,
)}`}
>
<Typography color="primary">
<LongText text={project.projectId} max={60} />
</Typography>
</Link>
</TableCell>
<TableCell>
<Typography>
<LongText
text={project?.lifecycleState || 'Error'}
max={30}
/>
</Typography>
</TableCell>
<TableCell>
<Typography>
<LongText text={project?.createTime || 'Error'} max={30} />
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Table>
);
};
export const ProjectListPage = () => {
return (
<Page theme={pageTheme.service}>
<Header title="GCP Projects" type="tool">
{labels}
</Header>
<Content>
<ContentHeader title="">
<Button variant="contained" color="primary" href="/gcp-projects/new">
New Project
</Button>
<SupportButton>All your software catalog entities</SupportButton>
</ContentHeader>
<PageContents />
</Content>
</Page>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ProjectListPage } from './ProjectListPage';

Some files were not shown because too many files have changed in this diff Show More