Skip to content
This repository was archived by the owner on Apr 13, 2023. It is now read-only.

Commit 3e5fe2c

Browse files
carvantesssvegarajuzheyanyuBingjilingbrandroid-tw
authored
feat: add support for FHIR Subscriptions (#573)
* feat: add GSI to Resource DDB Table (#533) * feat: Add data validation for subscription (#543) * fix: remove _subsciptionStatus from export result field (#555) * feat: sns, sqs, dlq for Subscriptions (#554) * feat: Rest hook Lambda (#558) * feat: subscriptionReaper (#557) * feat: add subscriptionsMatcher Lambda (#559) * test: Add Subscriptions test infrastructure/helper (#569) * fix: update unit tests for subscription reaper (#567) * test: add subscriptions env vars in gh actions (#572) * fix: encrypt logs for new Lambda fns (#574) * test: add Subscription reaper tests (#575) * feat: emit end to end latency metric from rest-hook Lambda (#570) * test: add tests for tenant isolation of subscriptions (#577) * feat: add DLQ for matcher Lambda (#576) * test: add end to end tests for subscriptions (#578) * perf: partial failures for restHook Lambda (#579) * docs: add Subscription docs (#582) Co-authored-by: Sukeerth Vegaraju <[email protected]> Co-authored-by: zheyanyu <[email protected]> Co-authored-by: Yanyu Zheng <[email protected]> Co-authored-by: brndhpkn <[email protected]>
1 parent ad037a2 commit 3e5fe2c

34 files changed

+3022
-198
lines changed

.eslintrc.js

+5
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ module.exports = {
2525
'no-empty-function': 'off',
2626
'@typescript-eslint/no-empty-function': 'error',
2727
'import/no-extraneous-dependencies': ['error', { devDependencies: ['**/*.test.ts', 'integration-tests/*'] }],
28+
// @types/aws-lambda is special since aws-lambda is not the name of a package that we take as a dependency.
29+
// Making eslint recognize it would require several additional plugins and it's not worth setting it up right now.
30+
// See https://github.com/typescript-eslint/typescript-eslint/issues/1624
31+
// eslint-disable-next-line import/no-unresolved
32+
'import/no-unresolved': ['error', { ignore: ['aws-lambda'] }],
2833
'no-shadow': 'off', // replaced by ts-eslint rule below
2934
'@typescript-eslint/no-shadow': 'error',
3035
},

.github/workflows/deploy.yaml

+13-1
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,14 @@ jobs:
8080
- name: Install npm dependencies
8181
run: yarn install
8282
- name: Download US Core IG
83-
# NOTE if updateing the IG version. Please see update implementationGuides.test.ts test too.
83+
# NOTE if updating the IG version. Please see update implementationGuides.test.ts test too.
8484
run: |
8585
mkdir -p implementationGuides
8686
curl http://hl7.org/fhir/us/core/STU3.1.1/package.tgz | tar xz -C implementationGuides
8787
- name: Compile IGs
8888
run: yarn run compile-igs
89+
- name: Setup allowList for Subscriptions integ tests
90+
run: cp integration-tests/infrastructure/allowList-integTests.ts src/subscriptions/allowList.ts
8991
- name: Install serverless
9092
run: npm install -g [email protected]
9193
- name: Deploy Hapi validator
@@ -125,12 +127,18 @@ jobs:
125127
serviceUrlSecretName: SERVICE_URL
126128
cognitoClientIdSecretName: COGNITO_CLIENT_ID
127129
apiKeySecretName: API_KEY
130+
subscriptionsNotificationsTableSecretName: SUBSCRIPTIONS_NOTIFICATIONS_TABLE
131+
subscriptionsEndpointSecretName: SUBSCRIPTIONS_ENDPOINT
132+
subscriptionsApiKeySecretName: SUBSCRIPTIONS_API_KEY
128133
- enableMultiTenancy: true
129134
region: us-west-1
130135
serviceUrlSuffix: /tenant/tenant1
131136
serviceUrlSecretName: MULTITENANCY_SERVICE_URL
132137
cognitoClientIdSecretName: MULTITENANCY_COGNITO_CLIENT_ID
133138
apiKeySecretName: MULTITENANCY_API_KEY
139+
subscriptionsNotificationsTableSecretName: MULTITENANCY_SUBSCRIPTIONS_NOTIFICATIONS_TABLE
140+
subscriptionsEndpointSecretName: MULTITENANCY_SUBSCRIPTIONS_ENDPOINT
141+
subscriptionsApiKeySecretName: MULTITENANCY_SUBSCRIPTIONS_API_KEY
134142
steps:
135143
- uses: actions/checkout@v2
136144
with:
@@ -195,6 +203,10 @@ jobs:
195203
COGNITO_USERNAME_PRACTITIONER_ANOTHER_TENANT: ${{ secrets.COGNITO_USERNAME_PRACTITIONER_ANOTHER_TENANT }}
196204
COGNITO_PASSWORD: ${{ secrets.COGNITO_PASSWORD }}
197205
MULTI_TENANCY_ENABLED: ${{ matrix.enableMultiTenancy }}
206+
SUBSCRIPTIONS_ENABLED: 'true'
207+
SUBSCRIPTIONS_NOTIFICATIONS_TABLE: ${{ secrets.[matrix.subscriptionsNotificationsTableSecretName] }}
208+
SUBSCRIPTIONS_ENDPOINT: ${{ secrets.[matrix.subscriptionsEndpointSecretName] }}
209+
SUBSCRIPTIONS_API_KEY: ${{ secrets.[matrix.subscriptionsApiKeySecretName] }}
198210
run: yarn int-test
199211

200212
merge-develop-to-mainline:

.gitignore

-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ dist
1212
.idea
1313
yarn-error.log
1414

15-
1615
auditLogMover/.serverless
1716
auditLogMover/node_modules
1817

README.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,9 @@ git clone https://github.com/awslabs/fhir-works-on-aws-deployment.git
4040

4141
If you intend to use FHIR Implementation Guides read the [Using Implementation Guides](./USING_IMPLEMENTATION_GUIDES.md) documentation first.
4242

43-
If you intend to do a multi-tenant deployment read the [Using Multi-Tenancy](./USING_MULTI_TENANCY.md) documentation first.
43+
If you intend to do a multi-tenant deployment read the [Using Multi-Tenancy](./USING_MULTI_TENANCY.md) documentation first.
44+
45+
If you intend to use FHIR Subscriptions read the [Using Subscriptions](./USING_SUBSCRIPTIONS.md) documentation first.
4446

4547
## Architecture
4648

USING_SUBSCRIPTIONS.md

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# Subscriptions
2+
3+
The FHIR Subscription resource is used to define a push-based subscription from a server to another system.
4+
Once a subscription is registered with the server, the server checks every resource that is created or updated,
5+
and if the resource matches the given criteria, it sends a message on the defined channel so that another system can take an appropriate action.
6+
7+
FHIR Works on AWS implements Subscriptions v4.0.1: https://www.hl7.org/fhir/R4/subscription.html
8+
9+
![Architecture diagram](resources/FWoA-subscriptions.svg)
10+
11+
## Getting Started
12+
13+
1. As an additional security measure, all destination endpoints must be allow-listed before notifications can be delivered to them.
14+
Update [src/subscriptions/allowList.ts](src/subscriptions/allowList.ts) to configure your allow-list.
15+
16+
17+
2. Use the `enableSubscriptions` option when deploying the stack:
18+
19+
```bash
20+
serverless deploy --enableSubscriptions true
21+
```
22+
23+
24+
**Note**
25+
Enabling subscriptions incurs a cost even if there are no active subscriptions. It is recommended to only enable it if you intend to use it.
26+
27+
## Creating Subscriptions
28+
29+
A Subscription is a FHIR resource. Use the REST API to create, update or delete Subscriptions.
30+
Refer to the [FHIR documentation](https://www.hl7.org/fhir/R4/subscription.html#resource) for the details of the Subscription resource.
31+
32+
Create Subscription example:
33+
```
34+
POST <API_URL>/Subscription
35+
{
36+
"resourceType": "Subscription",
37+
"status": "requested",
38+
"end": "2022-01-01T00:00:00Z",
39+
"reason": "Monitor new neonatal function",
40+
"criteria": "Observation?code=http://loinc.org|1975-2",
41+
"channel": {
42+
"type": "rest-hook",
43+
"endpoint": "https://my-endpoint.com/on-result",
44+
"payload": "application/fhir+json"
45+
}
46+
}
47+
```
48+
49+
After the example Subscription is created, whenever an Observation is created or updated that matches the `criteria`,
50+
a notification will be sent to `https://my-endpoint.com/on-result`.
51+
52+
Consider the following when working with Subscriptions:
53+
54+
* Subscriptions start sending notifications within 1 minute of being created.
55+
* Notifications are delivered at-least-once and with best-effort ordering.
56+
57+
## Supported Features
58+
59+
Currently the only supported channel is **REST Hook**.
60+
61+
If a Subscription has an `end` date, it is automatically deleted on that date.
62+
63+
FWoA supports 2 types of notifications
64+
65+
- **Empty notification**
66+
67+
This kind of notification occurs for Subscriptions without a `channel.payload` defined. Example:
68+
```json
69+
{
70+
"resourceType": "Subscription",
71+
"criteria": "Observation?name=http://loinc.org|1975-2",
72+
"channel": {
73+
"type": "rest-hook",
74+
"endpoint": "https://my-endpoint.com/on-result"
75+
}
76+
}
77+
```
78+
When a matching Observation is created/updated, FWoA Sends a POST request with an **empty body** to:
79+
```
80+
POST https://my-endpoint.com/on-result
81+
```
82+
83+
- **Id-only notification**
84+
85+
This kind of notification occurs for Subscriptions with `channel.payload` set to `application/fhir+json`. Example:
86+
```json
87+
{
88+
"resourceType": "Subscription",
89+
"criteria": "Observation?name=http://loinc.org|1975-2",
90+
"channel": {
91+
"type": "rest-hook",
92+
"payload": "application/fhir+json",
93+
"endpoint": "https://my-endpoint.com/on-result"
94+
}
95+
}
96+
```
97+
When a matching Observation is created/updated, FWoA Sends a PUT request with an **empty body** to:
98+
```
99+
PUT https://my-endpoint.com/on-result/Observation/<matching ObservationId>
100+
```
101+
**Note**
102+
The Id-only notifications differ slightly from the FHIR spec.
103+
The spec indicates that the entire matching FHIR resource is sent in JSON format, but we chose to only send the Id since
104+
sending the entire resource poses a security risk.

bulkExport/glueScripts/export-script.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ def get_transitive_references(resource, transitive_reference_map, server_url):
216216

217217
# Drop fields that are not needed
218218
print('Dropping fields that are not needed')
219-
data_source_cleaned_dyn_frame = DropFields.apply(frame = filtered_dates_resource_dyn_frame, paths = ['documentStatus', 'lockEndTs', 'vid', '_references', '_tenantId', '_id'])
219+
data_source_cleaned_dyn_frame = DropFields.apply(frame = filtered_dates_resource_dyn_frame, paths = ['documentStatus', 'lockEndTs', 'vid', '_references', '_tenantId', '_id', '_subscriptionStatus'])
220220

221221
def add_dup_resource_type(record):
222222
record["resourceTypeDup"] = record["resourceType"]

cloudformation/subscriptions.yaml

+143
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
#
2+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
#
5+
6+
Resources:
7+
SubscriptionsKey:
8+
Type: 'AWS::KMS::Key'
9+
Properties:
10+
Description: Encryption key for rest hook queue that can be used by SNS
11+
EnableKeyRotation: true
12+
KeyPolicy:
13+
Statement:
14+
- Effect: Allow
15+
Principal:
16+
Service: 'sns.amazonaws.com'
17+
Action:
18+
- 'kms:Decrypt'
19+
- 'kms:GenerateDataKey*'
20+
Resource: '*'
21+
- Sid: Allow administration of the key
22+
Effect: Allow
23+
Principal:
24+
AWS: !Join ['', ['arn:aws:iam::', !Ref AWS::AccountId, ':root']]
25+
Action:
26+
- 'kms:*'
27+
Resource: '*'
28+
29+
RestHookQueue:
30+
Type: AWS::SQS::Queue
31+
Properties:
32+
KmsMasterKeyId: !Ref SubscriptionsKey
33+
RedrivePolicy:
34+
deadLetterTargetArn: !GetAtt RestHookDLQ.Arn
35+
maxReceiveCount: 2
36+
37+
RestHookDLQ:
38+
Type: AWS::SQS::Queue
39+
Properties:
40+
MessageRetentionPeriod: 1209600 # 14 days in seconds
41+
KmsMasterKeyId: 'alias/aws/sqs'
42+
43+
RestHookQueuePolicy:
44+
Type: AWS::SQS::QueuePolicy
45+
Properties:
46+
Queues: [!Ref RestHookQueue]
47+
PolicyDocument:
48+
Statement:
49+
- Effect: Deny
50+
Action:
51+
- SQS:*
52+
Resource:
53+
- !GetAtt RestHookQueue.Arn
54+
Principal: '*'
55+
Condition:
56+
Bool:
57+
'aws:SecureTransport': false
58+
- Effect: Allow
59+
Action:
60+
- SQS:SendMessage
61+
Resource:
62+
- !GetAtt RestHookQueue.Arn
63+
Principal:
64+
Service: 'sns.amazonaws.com'
65+
Condition:
66+
ArnEquals:
67+
aws:SourceArn: !Ref SubscriptionsTopic
68+
69+
RestHookDLQPolicy:
70+
Type: AWS::SQS::QueuePolicy
71+
Properties:
72+
Queues: [!Ref RestHookDLQ]
73+
PolicyDocument:
74+
Statement:
75+
- Effect: Deny
76+
Action:
77+
- SQS:*
78+
Resource:
79+
- !GetAtt RestHookDLQ.Arn
80+
Principal: '*'
81+
Condition:
82+
Bool:
83+
'aws:SecureTransport': false
84+
85+
SubscriptionsTopic:
86+
Type: AWS::SNS::Topic
87+
Properties:
88+
TopicName: 'SubscriptionsTopic'
89+
KmsMasterKeyId: !Ref SubscriptionsKey
90+
91+
RestHookSubscription:
92+
Type: 'AWS::SNS::Subscription'
93+
Properties:
94+
TopicArn: !Ref SubscriptionsTopic
95+
Endpoint: !GetAtt RestHookQueue.Arn
96+
Protocol: sqs
97+
FilterPolicy:
98+
channelType:
99+
- 'rest-hook'
100+
101+
RestHookLambdaRole:
102+
Type: AWS::IAM::Role
103+
Metadata:
104+
cfn_nag:
105+
rules_to_suppress:
106+
- id: W11
107+
reason: '* only applies to X-Ray statement which does not define a group or sampling-rule'
108+
Properties:
109+
AssumeRolePolicyDocument:
110+
Version: '2012-10-17'
111+
Statement:
112+
- Effect: 'Allow'
113+
Principal:
114+
Service: 'lambda.amazonaws.com'
115+
Action: 'sts:AssumeRole'
116+
Policies:
117+
- PolicyName: 'restHookLambdaPolicy'
118+
PolicyDocument:
119+
Version: '2012-10-17'
120+
Statement:
121+
- Effect: Allow
122+
Action:
123+
- logs:CreateLogStream
124+
- logs:CreateLogGroup
125+
- logs:PutLogEvents
126+
Resource: !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:*:*'
127+
- Effect: Allow
128+
Action:
129+
- 'xray:PutTraceSegments'
130+
- 'xray:PutTelemetryRecords'
131+
Resource:
132+
- '*'
133+
- Effect: Allow
134+
Action:
135+
- 'kms:Decrypt'
136+
Resource:
137+
- !GetAtt SubscriptionsKey.Arn
138+
- Effect: Allow
139+
Action:
140+
- 'sqs:DeleteMessage'
141+
- 'sqs:ReceiveMessage'
142+
- 'sqs:GetQueueAttributes'
143+
Resource: !GetAtt RestHookQueue.Arn
+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*
5+
*/
6+
import * as AWS from 'aws-sdk';
7+
import { DynamoDB } from 'aws-sdk';
8+
9+
export interface SubscriptionNotification {
10+
httpMethod: string;
11+
path: string;
12+
body?: string | null;
13+
headers?: string[];
14+
}
15+
16+
// eslint-disable-next-line import/prefer-default-export
17+
export class SubscriptionsHelper {
18+
private readonly notificationsTableName: string;
19+
20+
private readonly dynamodbClient: DynamoDB;
21+
22+
constructor(notificationsTableName: string) {
23+
this.notificationsTableName = notificationsTableName;
24+
this.dynamodbClient = new AWS.DynamoDB();
25+
}
26+
27+
/**
28+
* Gets all notifications received for a given path.
29+
* @param path - The path where the notifications were sent. It is recommended to use unique paths for each test run (e.g. by appending an uui to it)
30+
*/
31+
async getNotifications(path: string): Promise<SubscriptionNotification[]> {
32+
const { Items } = await this.dynamodbClient
33+
.query({
34+
TableName: this.notificationsTableName,
35+
KeyConditionExpression: '#path = :pathValue',
36+
ExpressionAttributeNames: { '#path': 'path' },
37+
ExpressionAttributeValues: { ':pathValue': { S: path } },
38+
})
39+
.promise();
40+
41+
if (Items === undefined) {
42+
return [];
43+
}
44+
45+
return Items.map((item) => AWS.DynamoDB.Converter.unmarshall(item) as SubscriptionNotification);
46+
}
47+
}

0 commit comments

Comments
 (0)