Skip to content

Commit

Permalink
feat(proxy-sigv4-backend): migrate to aws-sdk v3 for js and remove de…
Browse files Browse the repository at this point in the history
…precations (#10)

Signed-off-by: Alec Jacobs <charles.jacobs@segment.com>
  • Loading branch information
alecjacobs5401 authored Aug 28, 2024
1 parent f4571f5 commit cac1d4a
Show file tree
Hide file tree
Showing 9 changed files with 5,905 additions and 6,838 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@
},
"resolutions": {
"@types/react": "^18",
"@types/react-dom": "^18"
"@types/react-dom": "^18",
"@azure/storage-blob": "12.17.0"
},
"prettier": "@spotify/prettier-config",
"lint-staged": {
Expand Down
7 changes: 7 additions & 0 deletions plugins/proxy-sigv4-backend/dev/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createBackend } from '@backstage/backend-defaults';

const backend = createBackend();

backend.add(import('../src'));

backend.start();
15 changes: 8 additions & 7 deletions plugins/proxy-sigv4-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,21 @@
"postpack": "backstage-cli package postpack"
},
"dependencies": {
"@backstage/backend-common": "^0.24.0",
"@aws-sdk/credential-providers": "^3.637.0",
"@backstage/backend-plugin-api": "^0.8.0",
"agentkeepalive": "^4.2.1",
"aws-sdk": "^2.1181.0",
"aws4": "^1.13.1",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"got4aws": "^1.2.1"
"node-fetch": "^2.6.7"
},
"devDependencies": {
"@backstage/backend-defaults": "^0.4.4",
"@backstage/backend-test-utils": "0.5.0",
"@backstage/cli": "^0.27.0",
"@backstage/config": "^1.2.0",
"@types/express": "^4.17.20",
"winston": "^3.2.1",
"yn": "^4.0.0"
"@smithy/types": "^3.3.0",
"@types/aws4": "^1.11.6",
"@types/express": "^4.17.20"
},
"files": [
"dist",
Expand Down
5 changes: 1 addition & 4 deletions plugins/proxy-sigv4-backend/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { loggerToWinstonLogger } from '@backstage/backend-common';
import {
coreServices,
createBackendPlugin,
Expand All @@ -16,9 +15,7 @@ export const proxySigV4Plugin = createBackendPlugin({
http: coreServices.httpRouter,
},
async init({ config, logger, http }) {
http.use(
await createRouter({ logger: loggerToWinstonLogger(logger), config }),
);
http.use(await createRouter({ logger, config }));

if (
config.getOptionalBoolean(
Expand Down
17 changes: 0 additions & 17 deletions plugins/proxy-sigv4-backend/src/run.ts

This file was deleted.

239 changes: 205 additions & 34 deletions plugins/proxy-sigv4-backend/src/service/router.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,36 @@
import { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { mockServices } from '@backstage/backend-test-utils';

import {
buildMiddleware,
createRouter,
normalizeRouteConfig,
normalizeRoutePath,
credentialsNeedRefresh,
} from './router';

const mockNodeProviderChainCredentials = jest.fn().mockResolvedValue({
accessKeyId: 'ACCESS_KEY_ID_NODE_PROVIDER_CHAIN',
secretAccessKey: 'SECRET_ACCESS_KEY',
});

const mockTemporaryCredentials = jest.fn().mockResolvedValue({
accessKeyId: 'ACCESS_KEY_ID_TEMPORARY_CREDENTIALS',
secretAccessKey: 'SECRET_ACCESS_KEY',
});

jest.mock('@aws-sdk/credential-providers', () => ({
fromNodeProviderChain: jest
.fn()
.mockImplementation(() => mockNodeProviderChainCredentials),
fromTemporaryCredentials: jest
.fn()
.mockImplementation(() => mockTemporaryCredentials),
}));

beforeEach(() => {
jest.clearAllMocks();
});

describe('normalizeRoutePath', () => {
describe('when path starts with `/`', () => {
it('returns normalized path', () => {
Expand Down Expand Up @@ -106,37 +129,181 @@ describe('normalizeRouteConfig', () => {
});
});

describe('credentialsNeedRefresh', () => {
beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date('2024-05-05T12:00:00Z'));
});

afterEach(() => {
jest.useRealTimers();
});
it('returns true when credentials are about to expire', () => {
expect(
credentialsNeedRefresh({
accessKeyId: 'ACCESS_KEY_ID',
secretAccessKey: 'SECRET_ACCESS_KEY',
expiration: new Date('2024-05-05T11:55:00Z'),
}),
).toBe(true);
});

it('returns true when credentials are past expiration', () => {
expect(
credentialsNeedRefresh({
accessKeyId: 'ACCESS_KEY_ID',
secretAccessKey: 'SECRET_ACCESS_KEY',
expiration: new Date('2024-05-05T00:00:00Z'),
}),
).toBe(true);
});

it('returns false when credentials do not have an expiration', () => {
expect(
credentialsNeedRefresh({
accessKeyId: 'ACCESS_KEY_ID',
secretAccessKey: 'SECRET_ACCESS_KEY',
}),
).toBe(false);
});

it('returns false when credentials are not expired', () => {
expect(
credentialsNeedRefresh({
accessKeyId: 'ACCESS_KEY_ID',
secretAccessKey: 'SECRET_ACCESS_KEY',
expiration: new Date('2024-05-05T20:00:00Z'),
}),
).toBe(false);
});
});

describe('buildMiddleware', () => {
const logger = getVoidLogger();
const logger = mockServices.rootLogger();

it('resolves a middleware-like function', async () => {
const mw = await buildMiddleware({
logger,
routePath: '/foo',
routeConfig: {
target: 'https://example.com',
},
});

it('returns a middleware-like function', () => {
const mw = buildMiddleware({
expect(mockNodeProviderChainCredentials).toHaveBeenCalled();
expect(mockTemporaryCredentials).not.toHaveBeenCalled();
expect(mw).toEqual(expect.any(Function));
expect(mw).toHaveProperty('length', 3); // req, res, next
});

it('resolves a middleware-like function when route config includes a role arn to assume', async () => {
const mw = await buildMiddleware({
logger,
routePath: '/foo',
routeConfig: {
target: 'https://example.com',
roleArn: 'arn:aws:iam::000000000000:role/valid-role',
},
});
expect(mockNodeProviderChainCredentials).not.toHaveBeenCalled();
expect(mockTemporaryCredentials).toHaveBeenCalled();
expect(mw).toEqual(expect.any(Function));
expect(mw).toHaveProperty('length', 3); // req, res, next
});

it('triggers a recurring interval to refresh credentials automatically', async () => {
jest.useFakeTimers().setSystemTime(new Date('2024-05-05T12:00:00Z'));
mockTemporaryCredentials
.mockResolvedValueOnce({
accessKeyId: 'ACCESS_KEY_ID_TEMPORARY_CREDENTIALS',
secretAccessKey: 'SECRET_ACCESS_KEY',
expiration: new Date('2024-05-05T11:30:00Z'),
})
.mockResolvedValue({
accessKeyId: 'ACCESS_KEY_ID_TEMPORARY_CREDENTIALS',
secretAccessKey: 'SECRET_ACCESS_KEY',
expiration: new Date('2024-05-05T20:30:00Z'),
});

// s
await buildMiddleware({
logger,
routePath: '/foo',
routeConfig: {
target: 'https://example.com',
roleArn: 'arn:aws:iam::000000000000:role/valid-role',
},
});
expect(mockTemporaryCredentials).toHaveBeenCalledTimes(1);

// advance 30 seconds to immediately trigger the interval
await jest.advanceTimersByTimeAsync(30 * 1000);

// should have a second execution now of the temporary credentials
expect(mockTemporaryCredentials).toHaveBeenCalledTimes(2);

// advance 65 seconds to ensure the interval would have run a few times, but not enough to trigger another refresh
await jest.advanceTimersByTimeAsync(60 * 1000);

// should not have another execution of the temporary credentials
expect(mockTemporaryCredentials).toHaveBeenCalledTimes(2);
});

it('handles errors refreshing credentials during recurring interval', async () => {
jest.useFakeTimers().setSystemTime(new Date('2024-05-05T12:00:00Z'));
mockTemporaryCredentials
.mockResolvedValueOnce({
accessKeyId: 'ACCESS_KEY_ID_TEMPORARY_CREDENTIALS',
secretAccessKey: 'SECRET_ACCESS_KEY',
expiration: new Date('2024-05-05T11:30:00Z'),
})
.mockRejectedValue(new Error('Failed to refresh credentials'));

await buildMiddleware({
logger,
routePath: '/foo',
routeConfig: {
target: 'https://example.com',
roleArn: 'arn:aws:iam::000000000000:role/valid-role',
},
});
expect(mockTemporaryCredentials).toHaveBeenCalledTimes(1);

const logErrorSpy = jest.spyOn(logger, 'error');

// advance 30 seconds to immediately trigger the interval
await jest.advanceTimersByTimeAsync(30 * 1000);

expect(mockTemporaryCredentials).toHaveBeenCalledTimes(2);

expect(logErrorSpy).toHaveBeenCalledWith(
'Failed to refresh temporary credentials with error: Failed to refresh credentials (routePath=/foo, roleArn=arn:aws:iam::000000000000:role/valid-role, roleSessionName=backstage-plugin-proxy-sigv4-backend)',
{
error: new Error('Failed to refresh credentials'),
roleArn: 'arn:aws:iam::000000000000:role/valid-role',
roleSessionName: 'backstage-plugin-proxy-sigv4-backend',
routePath: '/foo',
},
);
});
});

describe('createRouter', () => {
const logger = getVoidLogger();
const logger = mockServices.rootLogger();

describe('when all proxy config are valid', () => {
describe('and short form is used', () => {
it('works', async () => {
const config = new ConfigReader({
backend: {
baseUrl: 'https://example.com:7007',
listen: {
port: 7007,
const config = mockServices.rootConfig({
data: {
backend: {
baseUrl: 'https://example.com:7007',
listen: {
port: 7007,
},
},
proxysigv4: {
'/test': 'https://example.com',
},
},
proxysigv4: {
'/test': 'https://example.com',
},
});
const router = await createRouter({
Expand All @@ -150,16 +317,18 @@ describe('createRouter', () => {

describe('and expanded form is used', () => {
it('works', async () => {
const config = new ConfigReader({
backend: {
baseUrl: 'https://example.com:7007',
listen: {
port: 7007,
const config = mockServices.rootConfig({
data: {
backend: {
baseUrl: 'https://example.com:7007',
listen: {
port: 7007,
},
},
},
proxysigv4: {
'/test': {
target: 'https://example.com',
proxysigv4: {
'/test': {
target: 'https://example.com',
},
},
},
});
Expand All @@ -173,19 +342,21 @@ describe('createRouter', () => {

describe('and mixed short and expanded forms are used', () => {
it('works', async () => {
const config = new ConfigReader({
backend: {
baseUrl: 'https://example.com:7007',
listen: {
port: 7007,
const config = mockServices.rootConfig({
data: {
backend: {
baseUrl: 'https://example.com:7007',
listen: {
port: 7007,
},
},
},
proxysigv4: {
'/test': {
target: 'https://example.com',
roleArn: 'arn:aws:iam::000000000000:role/valid-role',
proxysigv4: {
'/test': {
target: 'https://example.com',
roleArn: 'arn:aws:iam::000000000000:role/valid-role',
},
'/test2': 'https://example2.com',
},
'/test2': 'https://example2.com',
},
});
const router = await createRouter({
Expand Down
Loading

0 comments on commit cac1d4a

Please sign in to comment.