Merge branch 'master' into camilaibs/nbs10-deprecate-legacy-system-commons

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2024-05-21 15:12:02 +02:00
committed by GitHub
238 changed files with 7330 additions and 1383 deletions
+6 -11
View File
@@ -19,7 +19,7 @@ import {
createBackendPlugin,
coreServices,
} from '@backstage/backend-plugin-api';
import { createRouter } from './service/router';
import { createRouterInternal } from './service/router';
/**
* The proxy backend plugin.
@@ -37,16 +37,11 @@ export default createBackendPlugin({
httpRouter: coreServices.httpRouter,
},
async init({ config, discovery, logger, httpRouter }) {
httpRouter.use(
await createRouter({
config,
discovery,
logger: loggerToWinstonLogger(logger),
}),
);
httpRouter.addAuthPolicy({
allow: 'unauthenticated',
path: '/',
await createRouterInternal({
config,
discovery,
logger: loggerToWinstonLogger(logger),
httpRouterService: httpRouter,
});
},
});
-33
View File
@@ -1,33 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { 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) : 7007;
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);
});
@@ -31,7 +31,7 @@ import { createRouter } from './router';
import { mockServices } from '@backstage/backend-test-utils';
// this test is stored in its own file to work around the mocked
// http-proxy-middleware module used in the rest of the tests
// http-proxy-middleware module used in the main test file
describe('createRouter reloadable configuration', () => {
const server = setupServer(
@@ -0,0 +1,305 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createBackend } from '@backstage/backend-defaults';
import {
mockServices,
setupRequestMockHandlers,
} from '@backstage/backend-test-utils';
import { ResponseError } from '@backstage/errors';
import { JsonObject } from '@backstage/types';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import fetch from 'node-fetch';
import portFinder from 'portfinder';
// this test is stored in its own file to work around the mocked
// http-proxy-middleware module used in the main test file
describe('credentials', () => {
const worker = setupServer();
setupRequestMockHandlers(worker);
it('handles all valid credentials settings', async () => {
const host = 'localhost';
const port = await portFinder.getPortPromise();
const baseUrl = `http://${host}:${port}`;
const config = {
backend: {
baseUrl,
listen: { host, port },
auth: {
externalAccess: [
{
type: 'static',
options: {
token: 'static-token',
subject: 'static-subject',
},
},
],
},
},
proxy: {
endpoints: {
'/simple': 'http://target.com',
'/default': {
target: 'http://target.com',
},
'/require': {
target: 'http://target.com',
credentials: 'require',
},
'/forward': {
target: 'http://target.com',
credentials: 'forward',
},
'/dangerously-allow-unauthenticated--no-forward': {
target: 'http://target.com',
credentials: 'dangerously-allow-unauthenticated',
},
'/dangerously-allow-unauthenticated--with-forward': {
target: 'http://target.com',
credentials: 'dangerously-allow-unauthenticated',
allowedHeaders: ['Authorization'],
},
},
},
};
worker.use(
rest.all(`${baseUrl}/*`, req => req.passthrough()),
rest.get('http://target.com/*', (req, res, ctx) => {
const auth = req.headers.get('authorization');
return res(
ctx.status(200),
ctx.json({ payload: { forwardedAuthorization: auth ?? false } }),
);
}),
);
async function call(options: {
endpoint: string;
authorization: string | false;
}): Promise<JsonObject> {
const { endpoint, authorization } = options;
return fetch(`${baseUrl}/api/proxy/${endpoint}/just-some-path`, {
headers: authorization ? { Authorization: authorization } : {},
}).then(async res => {
if (!res.ok) {
throw await ResponseError.fromResponse(res);
}
return res.json();
});
}
// Create an actual backend instead of a test backend, because we want to
// use the real HTTP server that provides the protection middleware etc. A
// bit harder to test, but at least we can use static external access tokens
// for it.
const backend = createBackend();
backend.add(import('../alpha'));
backend.add(mockServices.rootConfig.factory({ data: config }));
backend.add(mockServices.rootLogger.factory());
await backend.start();
try {
// simple credentials config
await expect(
call({ endpoint: 'simple', authorization: false }),
).rejects.toMatchObject({
body: {
error: {
message: 'Missing credentials',
name: 'AuthenticationError',
},
},
});
await expect(
call({ endpoint: 'simple', authorization: 'Bearer static-token' }),
).resolves.toMatchObject({
payload: {
forwardedAuthorization: false,
},
});
await expect(
call({ endpoint: 'simple', authorization: 'Bearer not-valid' }),
).rejects.toMatchObject({
body: {
error: {
message: 'Illegal token',
name: 'AuthenticationError',
},
},
});
// default credentials config
await expect(
call({ endpoint: 'default', authorization: false }),
).rejects.toMatchObject({
body: {
error: {
message: 'Missing credentials',
name: 'AuthenticationError',
},
},
});
await expect(
call({ endpoint: 'default', authorization: 'Bearer static-token' }),
).resolves.toMatchObject({
payload: {
forwardedAuthorization: false,
},
});
await expect(
call({ endpoint: 'default', authorization: 'Bearer not-valid' }),
).rejects.toMatchObject({
body: {
error: {
message: 'Illegal token',
name: 'AuthenticationError',
},
},
});
// require credentials config
await expect(
call({ endpoint: 'require', authorization: false }),
).rejects.toMatchObject({
body: {
error: {
message: 'Missing credentials',
name: 'AuthenticationError',
},
},
});
await expect(
call({ endpoint: 'require', authorization: 'Bearer static-token' }),
).resolves.toMatchObject({
payload: {
forwardedAuthorization: false,
},
});
await expect(
call({ endpoint: 'require', authorization: 'Bearer not-valid' }),
).rejects.toMatchObject({
body: {
error: {
message: 'Illegal token',
name: 'AuthenticationError',
},
},
});
// forward credentials config
await expect(
call({ endpoint: 'forward', authorization: false }),
).rejects.toMatchObject({
body: {
error: {
message: 'Missing credentials',
name: 'AuthenticationError',
},
},
});
await expect(
call({ endpoint: 'forward', authorization: 'Bearer static-token' }),
).resolves.toMatchObject({
payload: {
forwardedAuthorization: 'Bearer static-token',
},
});
await expect(
call({ endpoint: 'forward', authorization: 'Bearer not-valid' }),
).rejects.toMatchObject({
body: {
error: {
message: 'Illegal token',
name: 'AuthenticationError',
},
},
});
// dangerously-allow-unauthenticated credentials config, no forwarding
await expect(
call({
endpoint: 'dangerously-allow-unauthenticated--no-forward',
authorization: false,
}),
).resolves.toMatchObject({
payload: {
forwardedAuthorization: false,
},
});
await expect(
call({
endpoint: 'dangerously-allow-unauthenticated--no-forward',
authorization: 'Bearer static-token',
}),
).resolves.toMatchObject({
payload: {
forwardedAuthorization: false,
},
});
await expect(
call({
endpoint: 'dangerously-allow-unauthenticated--no-forward',
authorization: 'Bearer not-valid',
}),
).resolves.toMatchObject({
payload: {
forwardedAuthorization: false,
},
});
// dangerously-allow-unauthenticated credentials config, with forwarding
await expect(
call({
endpoint: 'dangerously-allow-unauthenticated--with-forward',
authorization: false,
}),
).resolves.toMatchObject({
payload: {
forwardedAuthorization: false,
},
});
await expect(
call({
endpoint: 'dangerously-allow-unauthenticated--with-forward',
authorization: 'Bearer static-token',
}),
).resolves.toMatchObject({
payload: {
forwardedAuthorization: 'Bearer static-token',
},
});
await expect(
call({
endpoint: 'dangerously-allow-unauthenticated--with-forward',
authorization: 'Bearer not-valid-for-backstage-but-valid-for-target',
}),
).resolves.toMatchObject({
payload: {
forwardedAuthorization:
'Bearer not-valid-for-backstage-but-valid-for-target',
},
});
} finally {
await backend.stop();
}
});
});
+76 -18
View File
@@ -26,6 +26,8 @@ import {
import { Logger } from 'winston';
import http from 'http';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { JsonObject } from '@backstage/types';
import { HttpRouterService } from '@backstage/backend-plugin-api';
// A list of headers that are always forwarded to the proxy targets.
const safeForwardHeaders = [
@@ -71,9 +73,37 @@ export function buildMiddleware(
route: string,
config: string | ProxyConfig,
reviveConsumedRequestBodies?: boolean,
httpRouterService?: HttpRouterService,
): RequestHandler {
const fullConfig =
typeof config === 'string' ? { target: config } : { ...config };
let fullConfig: ProxyConfig;
let credentialsPolicy: string;
if (typeof config === 'string') {
fullConfig = { target: config };
credentialsPolicy = 'require';
} else {
const { credentials, ...rest } = config as any;
fullConfig = rest;
credentialsPolicy = credentials ?? 'require';
}
const credentialsPolicyCandidates = [
'require',
'forward',
'dangerously-allow-unauthenticated',
];
if (!credentialsPolicyCandidates.includes(credentialsPolicy)) {
const valid = credentialsPolicyCandidates.map(c => `'${c}'`).join(', ');
throw new Error(
`Unknown credentials policy '${credentialsPolicy}' for proxy route '${route}'; expected one of ${valid}`,
);
}
if (credentialsPolicy === 'dangerously-allow-unauthenticated') {
httpRouterService?.addAuthPolicy({
path: route,
allow: 'unauthenticated',
});
}
// Validate that target is a valid URL.
const targetType = typeof fullConfig.target;
@@ -144,6 +174,10 @@ export function buildMiddleware(
].map(h => h.toLocaleLowerCase()),
);
if (credentialsPolicy === 'forward') {
requestHeaderAllowList.add('authorization');
}
// Use the custom middleware filter to do two things:
// 1. Remove any headers not in the allow list to stop them being forwarded
// 2. Only permit the allowed HTTP methods if configured
@@ -194,13 +228,15 @@ export function buildMiddleware(
return createProxyMiddleware(filter, fullConfig);
}
function readProxyConfig(config: Config, logger: Logger): unknown {
const endpoints = config.getOptionalConfig('proxy.endpoints')?.get();
function readProxyConfig(config: Config, logger: Logger): JsonObject {
const endpoints = config
.getOptionalConfig('proxy.endpoints')
?.get<JsonObject>();
if (endpoints) {
return endpoints;
}
const root = config.getOptionalConfig('proxy')?.get();
const root = config.getOptionalConfig('proxy')?.get<JsonObject>();
if (!root) {
return {};
}
@@ -220,25 +256,37 @@ function readProxyConfig(config: Config, logger: Logger): unknown {
}
/**
* Creates a new {@link https://expressjs.com/en/api.html#router | "express router"} that proxy each target configured under the `proxy` key of the config
* @example
* ```ts
* let router = await createRouter({logger, config, discovery});
* ```
* @config
* Creates a new
* {@link https://expressjs.com/en/api.html#router | "express router"} that
* proxies each target configured under the `proxy.endpoints` key of the config.
*
* @remarks
*
* Example configuration:
*
* ```yaml
* proxy:
* simple-example: http://simple.example.com:8080 # Opt 1 Simple URL String
* '/larger-example/v1': # Opt 2 `http-proxy-middleware` compatible object
* target: http://larger.example.com:8080/svc.v1
* headers:
* Authorization: Bearer ${EXAMPLE_AUTH_TOKEN}
*```
* endpoints:
* # Option 1: Simple URL String
* simple-example: http://simple.example.com:8080
* # Option 2: `http-proxy-middleware` compatible object
* '/larger-example/v1':
* target: http://larger.example.com:8080/svc.v1
* headers:
* Authorization: Bearer ${EXAMPLE_AUTH_TOKEN}
* ```
*
* @see https://backstage.io/docs/plugins/proxying
* @public
*/
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
return createRouterInternal(options);
}
export async function createRouterInternal(
options: RouterOptions & { httpRouterService?: HttpRouterService },
): Promise<express.Router> {
const router = Router();
let currentRouter = Router();
@@ -261,7 +309,13 @@ export async function createRouter(
const { pathname: pathPrefix } = new URL(externalUrl);
const proxyConfig = readProxyConfig(options.config, options.logger);
configureMiddlewares(proxyOptions, currentRouter, pathPrefix, proxyConfig);
configureMiddlewares(
proxyOptions,
currentRouter,
pathPrefix,
proxyConfig,
options.httpRouterService,
);
router.use((...args) => currentRouter(...args));
if (options.config.subscribe) {
@@ -279,11 +333,13 @@ export async function createRouter(
currentRouter,
pathPrefix,
newProxyConfig,
options.httpRouterService,
);
}
});
}
options.httpRouterService?.use(router);
return router;
}
@@ -296,6 +352,7 @@ function configureMiddlewares(
router: express.Router,
pathPrefix: string,
proxyConfig: any,
httpRouterService?: HttpRouterService,
) {
Object.entries<any>(proxyConfig).forEach(([route, proxyRouteConfig]) => {
try {
@@ -307,6 +364,7 @@ function configureMiddlewares(
route,
proxyRouteConfig,
options.reviveConsumedRequestBodies,
httpRouterService,
),
);
} catch (e) {
@@ -1,61 +0,0 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createServiceBuilder,
loadBackendConfig,
HostDiscovery,
} 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: 'proxy-backend' });
logger.debug('Creating application...');
const config = await loadBackendConfig({ logger, argv: process.argv });
const discovery = HostDiscovery.fromConfig(config);
const router = await createRouter({
config,
logger,
discovery,
});
let service = createServiceBuilder(module)
.setPort(options.port)
.addRouter('/proxy', router);
if (options.enableCors) {
service = service.enableCors({ origin: 'http://localhost:3000' });
}
logger.debug('Starting application server...');
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
module.hot?.accept();