From 56cf3f8bebde7d77264f1aede8731999ceec7412 Mon Sep 17 00:00:00 2001 From: Sukeerth Vegaraju Date: Mon, 14 Feb 2022 09:56:51 -0800 Subject: [PATCH] feat: subscriptionReaper (#557) --- serverless.yaml | 51 ++++++++++++ src/subscriptions/index.ts | 12 +++ src/subscriptions/subscriptionReaper.test.ts | 83 ++++++++++++++++++++ src/subscriptions/subscriptionReaper.ts | 45 +++++++++++ 4 files changed, 191 insertions(+) create mode 100644 src/subscriptions/subscriptionReaper.test.ts create mode 100644 src/subscriptions/subscriptionReaper.ts diff --git a/serverless.yaml b/serverless.yaml index 8a6c12ff..eccbb588 100644 --- a/serverless.yaml +++ b/serverless.yaml @@ -191,6 +191,18 @@ functions: environment: ELASTICSEARCH_DOMAIN_ENDPOINT: !Join ['', ['https://', !GetAtt ElasticSearchDomain.DomainEndpoint]] NUMBER_OF_SHARDS: !If [isDev, 1, 3] # 133 indices, one per resource types + + subscriptionReaper: + timeout: 30 + runtime: nodejs14.x + description: 'Scheduled Lambda to remove expired Subscriptions' + role: SubscriptionReaperRole + handler: src/subscriptions/index.reaperHandler + events: + - schedule: rate(5 minutes) + - enable: ${self:custom.enableSubscriptions} # will only run if opted into subscription feature + environment: + ENABLE_MULTI_TENANCY: !Ref EnableMultiTenancy subscriptionsRestHook: timeout: 20 @@ -595,6 +607,7 @@ resources: - 'dynamodb:BatchWriteItem' Resource: - !GetAtt ResourceDynamoDBTableV2.Arn + - !Join ['', [!GetAtt ResourceDynamoDBTableV2.Arn, '/index/*']] - !GetAtt ExportRequestDynamoDBTable.Arn - Effect: Allow Action: @@ -769,6 +782,44 @@ resources: - 'kms:GenerateDataKeyWithoutPlaintext' Resource: - !GetAtt ElasticSearchKMSKey.Arn + SubscriptionReaperRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: 'Allow' + Principal: + Service: 'lambda.amazonaws.com' + Action: 'sts:AssumeRole' + Policies: + - PolicyName: 'SubscriptionReaperPolicy' + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - dynamodb:UpdateItem + - dynamodb:Query + Resource: + - !Join ['', [!GetAtt ResourceDynamoDBTableV2.Arn, '/index/*']] + - !GetAtt ResourceDynamoDBTableV2.Arn + - PolicyName: 'KMSPolicy' + PolicyDocument: + Version: '2012-10-17' + Statement: + - Effect: Allow + Action: + - 'kms:Describe*' + - 'kms:Get*' + - 'kms:List*' + - 'kms:Encrypt' + - 'kms:Decrypt' + - 'kms:ReEncrypt*' + - 'kms:GenerateDataKey' + - 'kms:GenerateDataKeyWithoutPlaintext' + Resource: + - !GetAtt DynamodbKMSKey.Arn DdbToEsDLQ: Type: AWS::SQS::Queue Properties: diff --git a/src/subscriptions/index.ts b/src/subscriptions/index.ts index 54ee60fb..f11d9a2f 100644 --- a/src/subscriptions/index.ts +++ b/src/subscriptions/index.ts @@ -1,3 +1,10 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + */ + +import reaperHandler from './subscriptionReaper'; import RestHookHandler from './restHook'; import { AllowListInfo, getAllowListInfo } from './allowListUtil'; @@ -12,3 +19,8 @@ const restHookHandler = new RestHookHandler({ enableMultitenancy }); exports.handler = async (event: any) => { return restHookHandler.sendRestHookNotification(event, allowListPromise); }; + +/** + * Custom lambda handler that handles deleting expired subscriptions. + */ +exports.reaperHandler = reaperHandler; diff --git a/src/subscriptions/subscriptionReaper.test.ts b/src/subscriptions/subscriptionReaper.test.ts new file mode 100644 index 00000000..62d9ea3a --- /dev/null +++ b/src/subscriptions/subscriptionReaper.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +import { DynamoDbDataService } from 'fhir-works-on-aws-persistence-ddb'; +import reaperHandler from './subscriptionReaper'; + +jest.mock('fhir-works-on-aws-persistence-ddb'); +describe('subscriptionReaper', () => { + test('no subscriptions to delete', async () => { + const expectedResponse: { + success: boolean; + message: string; + }[] = []; + const subResource = [ + { + resourceType: 'Subscription', + id: 'sub1', + status: 'requested', + end: '2121-01-01T00:00:00Z', + }, + ]; + const mockGetActiveSubscriptions = jest.fn(); + DynamoDbDataService.prototype.getActiveSubscriptions = mockGetActiveSubscriptions; + mockGetActiveSubscriptions.mockResolvedValueOnce(subResource); + const actualResponse = await reaperHandler({}); + expect(actualResponse).toEqual(expectedResponse); + }); + + test('subscriptions that have expired should be deleted', async () => { + const message = `Successfully deleted ResourceType: Subscription, Id: sub1, VersionId: 1`; + const expectedResponse: { + success: boolean; + message: string; + }[] = [ + { + success: true, + message, + }, + ]; + const subResource = [ + { + resourceType: 'Subscription', + id: 'sub1', + status: 'requested', + end: '2021-01-01T00:00:00Z', + }, + { + resourceType: 'Subscription', + id: 'sub2', + status: 'requested', + end: '2121-01-01T00:00:00Z', + }, + ]; + const mockGetActiveSubscriptions = jest.fn(); + const mockDeleteResource = jest.fn(); + DynamoDbDataService.prototype.getActiveSubscriptions = mockGetActiveSubscriptions; + DynamoDbDataService.prototype.deleteResource = mockDeleteResource; + mockGetActiveSubscriptions.mockResolvedValueOnce(subResource); + mockDeleteResource.mockResolvedValueOnce([{ success: true, message }]); + const actualResponse = await reaperHandler({}); + expect(actualResponse).toEqual(expectedResponse); + }); + + test('subscriptions that have no specified end should not be deleted', async () => { + const expectedResponse: { + success: boolean; + message: string; + }[] = []; + const subResource = [ + { + resourceType: 'Subscription', + id: 'sub1', + status: 'requested', + }, + ]; + const mockGetActiveSubscriptions = jest.fn(); + DynamoDbDataService.prototype.getActiveSubscriptions = mockGetActiveSubscriptions; + mockGetActiveSubscriptions.mockResolvedValueOnce(subResource); + const actualResponse = await reaperHandler({}); + expect(actualResponse).toEqual(expectedResponse); + }); +}); diff --git a/src/subscriptions/subscriptionReaper.ts b/src/subscriptions/subscriptionReaper.ts new file mode 100644 index 00000000..714d3cc3 --- /dev/null +++ b/src/subscriptions/subscriptionReaper.ts @@ -0,0 +1,45 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + * + */ +import { DynamoDbDataService, DynamoDb } from 'fhir-works-on-aws-persistence-ddb'; + +const enableMultiTenancy = process.env.ENABLE_MULTI_TENANCY === 'true'; +const dbServiceWithTenancy = new DynamoDbDataService(DynamoDb, false, { + enableMultiTenancy, +}); +const dbService = new DynamoDbDataService(DynamoDb); + +const reaperHandler = async (event: any) => { + console.log('subscriptionReaper event', event); + const subscriptions = await dbService.getActiveSubscriptions({}); + const currentTime = new Date(); + // filter out subscriptions without a defined end time. + // check if subscription is past its end date (ISO format) + // example format of subscriptions: https://www.hl7.org/fhir/subscription-example.json.html + return Promise.all( + subscriptions + .filter((s: Record) => { + const date = new Date(s.end); + if (date.toString() === 'Invalid Date') { + console.log(`Skipping subscription ${s.id} since the end date is not in a valid format: ${s.end}`); + return false; + } + return currentTime >= date; + }) + .map(async (subscription) => { + // delete the subscription as it has reached its end time + return dbServiceWithTenancy.deleteResource({ + resourceType: subscription.resourceType, + // eslint-disable-next-line no-underscore-dangle + id: subscription._id, + // _tenantId is an internal field, and getActiveSubscriptions returns the raw Record + // eslint-disable-next-line no-underscore-dangle + tenantId: subscription._tenantId, + }); + }), + ); +}; + +export default reaperHandler;