Skip to content

Commit 04da14d

Browse files
committed
Added supplier ID lookup
1 parent 00ea227 commit 04da14d

File tree

7 files changed

+144
-130
lines changed

7 files changed

+144
-130
lines changed

infrastructure/terraform/components/api/module_authorizer_lambda.tf

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@ module "authorizer_lambda" {
3232
log_subscription_role_arn = local.acct.log_subscription_role_arn
3333

3434
lambda_env_vars = {
35-
CLOUDWATCH_NAMESPACE = "/aws/api-gateway/supplier/alarms"
36-
CLIENT_CERTIFICATE_EXPIRATION_ALERT_DAYS = 14
35+
CLOUDWATCH_NAMESPACE = "/aws/api-gateway/supplier/alarms",
36+
CLIENT_CERTIFICATE_EXPIRATION_ALERT_DAYS = 14,
37+
APIM_APPLICATION_ID_HEADER = "apim-application-id",
38+
SUPPLIERS_TABLE_NAME = aws_dynamodb_table.suppliers.name
3739
}
3840
}
3941

infrastructure/terraform/components/api/resources/spec.tmpl.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
"securitySchemes": {
44
"LambdaAuthorizer": {
55
"in": "header",
6-
"name": "NHSD-Supplier-ID",
6+
"name": "apim-application-id",
77
"type": "apiKey",
88
"x-amazon-apigateway-authorizer": {
99
"authorizerCredentials": "${APIG_EXECUTION_ROLE_ARN}",
1010
"authorizerResultTtlInSeconds": 0,
1111
"authorizerUri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${AUTHORIZER_LAMBDA_ARN}/invocations",
12-
"identitySource": "method.request.header.NHSD-Supplier-ID",
12+
"identitySource": "method.request.header.apim-application-id",
1313
"type": "request"
1414
},
1515
"x-amazon-apigateway-authtype": "custom"

lambdas/api-handler/src/config/deps.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,25 +19,21 @@ function createDocumentClient(): DynamoDBDocumentClient {
1919
}
2020

2121
function createLetterRepository(documentClient: DynamoDBDocumentClient, log: pino.Logger, envVars: EnvVars): LetterRepository {
22-
const ddbClient = new DynamoDBClient({});
23-
const docClient = DynamoDBDocumentClient.from(ddbClient);
2422
const config = {
2523
lettersTableName: envVars.LETTERS_TABLE_NAME,
2624
lettersTtlHours: envVars.LETTER_TTL_HOURS
2725
};
2826

29-
return new LetterRepository(docClient, log, config);
27+
return new LetterRepository(documentClient, log, config);
3028
}
3129

3230
function createMIRepository(documentClient: DynamoDBDocumentClient, log: pino.Logger, envVars: EnvVars): MIRepository {
33-
const ddbClient = new DynamoDBClient({});
34-
const docClient = DynamoDBDocumentClient.from(ddbClient);
3531
const config = {
3632
miTableName: envVars.MI_TABLE_NAME,
3733
miTtlHours: envVars.MI_TTL_HOURS
3834
};
3935

40-
return new MIRepository(docClient, log, config);
36+
return new MIRepository(documentClient, log, config);
4137
}
4238

4339
export function createDependenciesContainer(): Deps {

lambdas/authorizer/src/__tests__/index.test.ts

Lines changed: 89 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,15 @@ const mockedDeps: jest.Mocked<Deps> = {
88
logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger,
99
env: {
1010
CLOUDWATCH_NAMESPACE: 'cloudwatch-namespace',
11-
CLIENT_CERTIFICATE_EXPIRATION_ALERT_DAYS: 14
11+
CLIENT_CERTIFICATE_EXPIRATION_ALERT_DAYS: 14,
12+
APIM_APPLICATION_ID_HEADER: 'apim-application-id'
1213
} as unknown as EnvVars,
1314
cloudWatchClient: {
1415
send: jest.fn().mockResolvedValue({}),
1516
} as any,
17+
supplierRepo: {
18+
getSupplierByApimId: jest.fn(),
19+
} as any,
1620
} as Deps;
1721

1822

@@ -34,100 +38,6 @@ describe('Authorizer Lambda Function', () => {
3438
mockCallback = jest.fn();
3539
});
3640

37-
it('Should allow access when headers match', async() => {
38-
mockEvent.headers = { headerauth1: 'headervalue1' };
39-
40-
const handler = createAuthorizerHandler(mockedDeps);
41-
handler(mockEvent, mockContext, mockCallback);
42-
await new Promise(process.nextTick);
43-
44-
expect(mockCallback).toHaveBeenCalledWith(null, expect.objectContaining({
45-
policyDocument: expect.objectContaining({
46-
Statement: expect.arrayContaining([
47-
expect.objectContaining({
48-
Effect: 'Allow',
49-
}),
50-
]),
51-
}),
52-
}));
53-
});
54-
55-
it('Should deny access when headers do not match', async() => {
56-
mockEvent.headers = { headerauth1: 'wrongValue' };
57-
58-
const handler = createAuthorizerHandler(mockedDeps);
59-
handler(mockEvent, mockContext, mockCallback);
60-
await new Promise(process.nextTick);
61-
62-
expect(mockCallback).toHaveBeenCalledWith(null, expect.objectContaining({
63-
policyDocument: expect.objectContaining({
64-
Statement: expect.arrayContaining([
65-
expect.objectContaining({
66-
Effect: 'Deny',
67-
}),
68-
]),
69-
}),
70-
}));
71-
});
72-
73-
it('Should handle null headers gracefully', async() => {
74-
mockEvent.headers = null;
75-
76-
const handler = createAuthorizerHandler(mockedDeps);
77-
handler(mockEvent, mockContext, mockCallback);
78-
await new Promise(process.nextTick);
79-
80-
expect(mockCallback).toHaveBeenCalledWith(null, expect.objectContaining({
81-
policyDocument: expect.objectContaining({
82-
Statement: expect.arrayContaining([
83-
expect.objectContaining({
84-
Effect: 'Deny',
85-
}),
86-
]),
87-
}),
88-
}));
89-
});
90-
91-
it('Should handle defined headers correctly', async() => {
92-
mockEvent.headers = { headerauth1: 'headervalue1' };
93-
94-
const handler = createAuthorizerHandler(mockedDeps);
95-
handler(mockEvent, mockContext, mockCallback);
96-
await new Promise(process.nextTick);
97-
98-
expect(mockCallback).toHaveBeenCalledWith(null, expect.objectContaining({
99-
policyDocument: expect.objectContaining({
100-
Statement: expect.arrayContaining([
101-
expect.objectContaining({
102-
Effect: 'Allow',
103-
}),
104-
]),
105-
}),
106-
}));
107-
});
108-
109-
it('Should handle additional headers correctly', async() => {
110-
mockEvent.headers = {
111-
headerauth1: 'headervalue1' ,
112-
otherheader1: 'headervalue2',
113-
otherheader2: 'headervalue3'
114-
};
115-
116-
const handler = createAuthorizerHandler(mockedDeps);
117-
handler(mockEvent, mockContext, mockCallback);
118-
await new Promise(process.nextTick);
119-
120-
expect(mockCallback).toHaveBeenCalledWith(null, expect.objectContaining({
121-
policyDocument: expect.objectContaining({
122-
Statement: expect.arrayContaining([
123-
expect.objectContaining({
124-
Effect: 'Allow',
125-
}),
126-
]),
127-
}),
128-
}));
129-
});
130-
13141
describe('Certificate expiry check', () => {
13242

13343
beforeEach(() => {
@@ -140,7 +50,6 @@ describe('Authorizer Lambda Function', () => {
14050
})
14151

14252
it('Should not send CloudWatch metric when certificate is null', async () => {
143-
mockEvent.headers = { headerauth1: 'headervalue1' };
14453
mockEvent.requestContext.identity.clientCert = null;
14554

14655
const handler = createAuthorizerHandler(mockedDeps);
@@ -151,7 +60,6 @@ describe('Authorizer Lambda Function', () => {
15160
});
15261

15362
it('Should send CloudWatch metric when the certificate expiry threshold is reached', async () => {
154-
mockEvent.headers = { headerauth1: 'headervalue1' };
15563
mockEvent.requestContext.identity.clientCert = buildCertWithExpiry('2025-11-17T14:19:00Z');
15664

15765
const handler = createAuthorizerHandler(mockedDeps);
@@ -177,7 +85,6 @@ describe('Authorizer Lambda Function', () => {
17785
});
17886

17987
it('Should not send CloudWatch metric when the certificate expiry threshold is not yet reached', async () => {
180-
mockEvent.headers = { headerauth1: 'headervalue1' };
18188
mockEvent.requestContext.identity.clientCert = buildCertWithExpiry('2025-11-18T14:19:00Z');
18289

18390
const handler = createAuthorizerHandler(mockedDeps);
@@ -197,4 +104,88 @@ describe('Authorizer Lambda Function', () => {
197104
} as APIGatewayEventClientCertificate['validity'],
198105
} as APIGatewayEventClientCertificate;
199106
}
107+
108+
describe('Supplier ID lookup', () => {
109+
110+
it('Should deny the request when no headers are present', async () => {
111+
mockEvent.headers = null;
112+
113+
const handler = createAuthorizerHandler(mockedDeps);
114+
handler(mockEvent, mockContext, mockCallback);
115+
await new Promise(process.nextTick);
116+
117+
expect(mockCallback).toHaveBeenCalledWith(null, expect.objectContaining({
118+
policyDocument: expect.objectContaining({
119+
Statement: [
120+
expect.objectContaining({
121+
Effect: 'Deny',
122+
}),
123+
],
124+
}),
125+
}));
126+
});
127+
128+
it('Should deny the request when the APIM application ID header is absent', async () => {
129+
mockEvent.headers = {'x-apim-correlation-id': 'correlation-id'};
130+
131+
const handler = createAuthorizerHandler(mockedDeps);
132+
handler(mockEvent, mockContext, mockCallback);
133+
await new Promise(process.nextTick);
134+
135+
expect(mockCallback).toHaveBeenCalledWith(null, expect.objectContaining({
136+
policyDocument: expect.objectContaining({
137+
Statement: [
138+
expect.objectContaining({
139+
Effect: 'Deny',
140+
}),
141+
],
142+
}),
143+
}));
144+
});
145+
146+
it('Should deny the request when no supplier ID is found', async () => {
147+
mockEvent.headers = { 'apim-application-id': 'unknown-apim-id' };
148+
(mockedDeps.supplierRepo.getSupplierByApimId as jest.Mock).mockRejectedValue(new Error('Supplier not found'));
149+
150+
const handler = createAuthorizerHandler(mockedDeps);
151+
handler(mockEvent, mockContext, mockCallback);
152+
await new Promise(process.nextTick);
153+
154+
expect(mockCallback).toHaveBeenCalledWith(null, expect.objectContaining({
155+
policyDocument: expect.objectContaining({
156+
Statement: [
157+
expect.objectContaining({
158+
Effect: 'Deny',
159+
}),
160+
],
161+
}),
162+
}));
163+
});
164+
165+
it('Should allow the request when the supplier ID is found', async () => {
166+
mockEvent.headers = { 'apim-application-id': 'valid-apim-id' };
167+
(mockedDeps.supplierRepo.getSupplierByApimId as jest.Mock).mockResolvedValue({
168+
id: 'supplier-123',
169+
apimApplicationId: 'valid-apim-id',
170+
name: 'Test Supplier',
171+
});
172+
173+
const handler = createAuthorizerHandler(mockedDeps);
174+
handler(mockEvent, mockContext, mockCallback);
175+
await new Promise(process.nextTick);
176+
177+
expect(mockCallback).toHaveBeenCalledWith(null, expect.objectContaining({
178+
policyDocument: expect.objectContaining({
179+
Statement: [
180+
expect.objectContaining({
181+
Effect: 'Allow',
182+
}),
183+
],
184+
}),
185+
context: {
186+
supplierId: 'supplier-123',
187+
},
188+
}));
189+
});
190+
});
200191
});

lambdas/authorizer/src/authorizer.ts

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@
1111

1212
// See https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html for the original JS documentation
1313

14-
import {APIGatewayAuthorizerResult, APIGatewayEventClientCertificate, APIGatewayRequestAuthorizerEvent, APIGatewayRequestAuthorizerHandler,
14+
import {APIGatewayAuthorizerResult, APIGatewayAuthorizerResultContext, APIGatewayEventClientCertificate, APIGatewayRequestAuthorizerEvent, APIGatewayRequestAuthorizerEventHeaders, APIGatewayRequestAuthorizerHandler,
1515
Callback, Context } from 'aws-lambda';
1616
import { Deps } from './deps';
1717
import { PutMetricDataCommand } from '@aws-sdk/client-cloudwatch';
18+
import { Supplier } from '@internal/datastore';
1819

1920
export function createAuthorizerHandler(deps: Deps): APIGatewayRequestAuthorizerHandler {
2021

@@ -25,30 +26,38 @@ export function createAuthorizerHandler(deps: Deps): APIGatewayRequestAuthorizer
2526
): void => {
2627
deps.logger.info(event, 'Received event');
2728

28-
const headers = event.headers || {};
2929

30-
checkCertificateExpiry(event.requestContext.identity.clientCert, deps).then(() => {
31-
// Perform authorization to return the Allow policy for correct parameters and
32-
// the 'Unauthorized' error, otherwise.
33-
if (
34-
headers['headerauth1'] === 'headervalue1'
35-
) {
30+
checkCertificateExpiry(event.requestContext.identity.clientCert, deps)
31+
.then(() => deps.supplierRepo.getSupplierByApimId(extractApimId(event.headers, deps)))
32+
.then((supplier: Supplier) => {
3633
deps.logger.info('Allow event');
37-
callback(null, generateAllow('me', event.methodArn));
38-
} else {
39-
deps.logger.info('Deny event');
34+
callback(null, generateAllow('me', event.methodArn, supplier.id));
35+
})
36+
.catch((error) => {
37+
deps.logger.info('Deny event', {error});
4038
callback(null, generateDeny('me', event.methodArn));
41-
}
42-
});
39+
});
4340
};
4441
}
4542

4643

44+
function extractApimId(headers: APIGatewayRequestAuthorizerEventHeaders | null, deps: Deps): string {
45+
const apimId = Object.entries(headers || {})
46+
.find(([headerName, _]) => headerName.toLowerCase() === deps.env.APIM_APPLICATION_ID_HEADER)?.[1];
47+
48+
if(!apimId) {
49+
throw new Error("No APIM application ID found in header");
50+
}
51+
return apimId;
52+
}
53+
54+
4755
// Helper function to generate an IAM policy
4856
function generatePolicy(
4957
principalId: string,
5058
effect: 'Allow' | 'Deny',
51-
resource: string
59+
resource: string,
60+
context: APIGatewayAuthorizerResultContext
5261
): APIGatewayAuthorizerResult {
5362
// Required output:
5463
const authResponse: APIGatewayAuthorizerResult = {
@@ -63,21 +72,17 @@ export function createAuthorizerHandler(deps: Deps): APIGatewayRequestAuthorizer
6372
},
6473
],
6574
},
66-
context: {
67-
stringKey: 'stringval',
68-
numberKey: 123,
69-
booleanKey: true,
70-
},
75+
context: context,
7176
};
7277
return authResponse;
7378
}
7479

75-
function generateAllow(principalId: string, resource: string): APIGatewayAuthorizerResult {
76-
return generatePolicy(principalId, 'Allow', resource);
80+
function generateAllow(principalId: string, resource: string, supplierId: string): APIGatewayAuthorizerResult {
81+
return generatePolicy(principalId, 'Allow', resource, {supplierId: supplierId});
7782
}
7883

7984
function generateDeny(principalId: string, resource: string): APIGatewayAuthorizerResult {
80-
return generatePolicy(principalId, 'Deny', resource);
85+
return generatePolicy(principalId, 'Deny', resource, {});
8186
}
8287

8388
function getCertificateExpiryInDays(certificate: APIGatewayEventClientCertificate): number {

0 commit comments

Comments
 (0)