Skip to content

Commit 754e019

Browse files
authored
Implement Rate Limiter and Cloudfront Caching for API (#75)
* add cloudformation for table definition * fix IAM permission * build rate limiter logic * implement rate limiting on specific routes * remove log statement * mock rate limiter implementation * bypass rate limiter in unit tests * use more sensible rate limiting * fix linting issues * consolidate stages to fix concurrency * use cloudfront to cache events route * cache subpaths too * be more transparent about caching behavior to editors * change fetching behavior * fix cache behavior * enable custom domains on cloudfront * map the custom domain to cloudfront instead of the api gateway * fix domain behavior * fix domain * fix acm-cache-status header
1 parent 9aef776 commit 754e019

File tree

18 files changed

+515
-327
lines changed

18 files changed

+515
-327
lines changed

.github/workflows/deploy-dev.yml

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ jobs:
3838
- name: Run unit testing
3939
run: make test_unit
4040

41-
deploy-dev:
41+
deploy-test-dev:
4242
runs-on: ubuntu-latest
4343
permissions:
4444
id-token: write
@@ -47,7 +47,7 @@ jobs:
4747
group: ${{ github.event.repository.name }}-dev-env
4848
cancel-in-progress: false
4949
environment: "AWS DEV"
50-
name: Deploy to DEV
50+
name: Deploy to DEV and Run Tests
5151
needs:
5252
- test-unit
5353
steps:
@@ -90,38 +90,6 @@ jobs:
9090
HUSKY: "0"
9191
VITE_RUN_ENVIRONMENT: dev
9292

93-
test-dev:
94-
runs-on: ubuntu-latest
95-
name: Run Live Tests
96-
needs:
97-
- deploy-dev
98-
concurrency:
99-
group: ${{ github.event.repository.name }}-dev-env
100-
cancel-in-progress: false
101-
steps:
102-
- uses: actions/checkout@v4
103-
env:
104-
HUSKY: "0"
105-
cache: 'yarn'
106-
107-
- name: Set up Node
108-
uses: actions/setup-node@v4
109-
with:
110-
node-version: 22.x
111-
112-
- name: Restore Yarn Cache
113-
uses: actions/cache@v4
114-
with:
115-
path: node_modules
116-
key: yarn-modules-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}-dev
117-
restore-keys: |
118-
yarn-modules-${{ runner.os }}-
119-
120-
- name: Set up Python 3.11 for testing
121-
uses: actions/setup-python@v5
122-
with:
123-
python-version: 3.11
124-
12593
- name: Run health check
12694
run: make dev_health_check
12795

.github/workflows/deploy-prod.yml

Lines changed: 5 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ jobs:
2424
python-version: 3.11
2525
- name: Run unit testing
2626
run: make test_unit
27-
deploy-dev:
27+
28+
deploy-test-dev:
2829
runs-on: ubuntu-latest
2930
concurrency:
3031
group: ${{ github.event.repository.name }}-dev
@@ -33,7 +34,7 @@ jobs:
3334
id-token: write
3435
contents: read
3536
environment: "AWS DEV"
36-
name: Deploy to DEV
37+
name: Deploy to DEV and Run Tests
3738
needs:
3839
- test-unit
3940
steps:
@@ -62,26 +63,6 @@ jobs:
6263
HUSKY: "0"
6364
VITE_RUN_ENVIRONMENT: dev
6465

65-
test-dev:
66-
runs-on: ubuntu-latest
67-
name: Run Live Tests
68-
needs:
69-
- deploy-dev
70-
concurrency:
71-
group: ${{ github.event.repository.name }}-dev
72-
cancel-in-progress: false
73-
steps:
74-
- name: Set up Node
75-
uses: actions/setup-node@v4
76-
with:
77-
node-version: 22.x
78-
- uses: actions/checkout@v4
79-
env:
80-
HUSKY: "0"
81-
- name: Set up Python 3.11 for testing
82-
uses: actions/setup-python@v5
83-
with:
84-
python-version: 3.11
8566
- name: Run live testing
8667
run: make test_live_integration
8768
env:
@@ -94,15 +75,15 @@ jobs:
9475

9576
deploy-prod:
9677
runs-on: ubuntu-latest
97-
name: Deploy to Prod
78+
name: Deploy to Prod and Run Health Check
9879
concurrency:
9980
group: ${{ github.event.repository.name }}-prod
10081
cancel-in-progress: false
10182
permissions:
10283
id-token: write
10384
contents: read
10485
needs:
105-
- test-dev
86+
- deploy-test-dev
10687
environment: "AWS PROD"
10788
steps:
10889
- name: Set up Node for testing
@@ -129,22 +110,5 @@ jobs:
129110
env:
130111
HUSKY: "0"
131112
VITE_RUN_ENVIRONMENT: prod
132-
133-
health-check-prod:
134-
runs-on: ubuntu-latest
135-
name: Confirm services healthy
136-
needs:
137-
- deploy-prod
138-
concurrency:
139-
group: ${{ github.event.repository.name }}-prod
140-
cancel-in-progress: false
141-
steps:
142-
- name: Set up Node for testing
143-
uses: actions/setup-node@v4
144-
with:
145-
node-version: 22.x
146-
- uses: actions/checkout@v4
147-
env:
148-
HUSKY: "0"
149113
- name: Call the health check script
150114
run: make prod_health_check

cloudformation/custom-domain.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ Parameters:
1616
AllowedValues: [ 'dev', 'prod' ]
1717
RecordName:
1818
Type: String
19+
CloudfrontDomain:
20+
Type: String
1921

2022
Conditions:
2123
IsDev: !Equals [!Ref RunEnvironment, 'dev']
@@ -44,7 +46,7 @@ Resources:
4446
Properties:
4547
HostedZoneId: !Ref GWHostedZoneId
4648
Name: !Sub "${RecordName}.${GWBaseDomainName}"
47-
Type: A
48-
AliasTarget:
49-
DNSName: !GetAtt CustomDomainName.RegionalDomainName
50-
HostedZoneId: !GetAtt CustomDomainName.RegionalHostedZoneId
49+
Type: CNAME
50+
TTL: 300
51+
ResourceRecords:
52+
- !Ref CloudfrontDomain

cloudformation/iam.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ Resources:
9393
- expireAt
9494
- "*"
9595

96+
- Sid: DynamoDBRateLimitTableAccess
97+
Effect: Allow
98+
Action:
99+
- dynamodb:DescribeTable
100+
- dynamodb:UpdateItem
101+
Resource:
102+
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-rate-limiter
103+
96104
- Sid: DynamoDBIndexAccess
97105
Effect: Allow
98106
Action:

cloudformation/main.yml

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ Resources:
119119
GWApiId: !Ref AppApiGateway
120120
GWHostedZoneId:
121121
!FindInMap [ApiGwConfig, !Ref RunEnvironment, HostedZoneId]
122+
CloudfrontDomain: !GetAtt [AppFrontendCloudfrontDistribution, DomainName]
122123

123124
LinkryDomainProxy:
124125
Type: AWS::Serverless::Application
@@ -138,6 +139,7 @@ Resources:
138139
GWApiId: !Ref AppApiGateway
139140
GWHostedZoneId:
140141
!FindInMap [ApiGwConfig, !Ref RunEnvironment, HostedZoneId]
142+
CloudfrontDomain: !GetAtt [AppFrontendCloudfrontDistribution, DomainName]
141143

142144
CoreUrlProd:
143145
Type: AWS::Serverless::Application
@@ -158,6 +160,7 @@ Resources:
158160
GWApiId: !Ref AppApiGateway
159161
GWHostedZoneId:
160162
!FindInMap [ApiGwConfig, !Ref RunEnvironment, HostedZoneId]
163+
CloudfrontDomain: !GetAtt [AppFrontendCloudfrontDistribution, DomainName]
161164

162165
AppApiLambdaFunction:
163166
Type: AWS::Serverless::Function
@@ -306,6 +309,30 @@ Resources:
306309
- AttributeName: userEmail
307310
KeyType: HASH
308311

312+
RateLimiterTable:
313+
Type: "AWS::DynamoDB::Table"
314+
DeletionPolicy: "Delete"
315+
UpdateReplacePolicy: "Delete"
316+
Properties:
317+
BillingMode: "PAY_PER_REQUEST"
318+
TableName: infra-core-api-rate-limiter
319+
DeletionProtectionEnabled: true
320+
PointInTimeRecoverySpecification:
321+
PointInTimeRecoveryEnabled: false
322+
AttributeDefinitions:
323+
- AttributeName: PK
324+
AttributeType: S
325+
- AttributeName: SK
326+
AttributeType: S
327+
KeySchema:
328+
- AttributeName: PK
329+
KeyType: HASH
330+
- AttributeName: SK
331+
KeyType: RANGE
332+
TimeToLiveSpecification:
333+
AttributeName: ttl
334+
Enabled: true
335+
309336
EventRecordsTable:
310337
Type: "AWS::DynamoDB::Table"
311338
DeletionPolicy: "Retain"
@@ -562,6 +589,20 @@ Resources:
562589
- ApiGwConfig
563590
- !Ref RunEnvironment
564591
- UiDomainName
592+
- !Join
593+
- ""
594+
- - "go."
595+
- !FindInMap
596+
- ApiGwConfig
597+
- !Ref RunEnvironment
598+
- EnvDomainName
599+
- !Join
600+
- ""
601+
- - "ical."
602+
- !FindInMap
603+
- ApiGwConfig
604+
- !Ref RunEnvironment
605+
- EnvDomainName
565606

566607
DefaultCacheBehavior:
567608
TargetOriginId: S3WebsiteOrigin
@@ -578,6 +619,50 @@ Resources:
578619
Forward: none
579620
CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # caching-optimized
580621
CacheBehaviors:
622+
- PathPattern: "/api/v1/events"
623+
TargetOriginId: ApiGatewayOrigin
624+
ViewerProtocolPolicy: redirect-to-https
625+
AllowedMethods:
626+
- GET
627+
- HEAD
628+
- OPTIONS
629+
- PUT
630+
- POST
631+
- DELETE
632+
- PATCH
633+
CachedMethods:
634+
- GET
635+
- HEAD
636+
ForwardedValues:
637+
QueryString: true
638+
QueryStringCacheKeys:
639+
- host
640+
- ts
641+
- upcomingOnly
642+
Cookies:
643+
Forward: none
644+
- PathPattern: "/api/v1/events/*"
645+
TargetOriginId: ApiGatewayOrigin
646+
ViewerProtocolPolicy: redirect-to-https
647+
AllowedMethods:
648+
- GET
649+
- HEAD
650+
- OPTIONS
651+
- PUT
652+
- POST
653+
- DELETE
654+
- PATCH
655+
CachedMethods:
656+
- GET
657+
- HEAD
658+
ForwardedValues:
659+
QueryString: true
660+
QueryStringCacheKeys:
661+
- host
662+
- ts
663+
- upcomingOnly
664+
Cookies:
665+
Forward: none
581666
- PathPattern: "/api/*"
582667
TargetOriginId: ApiGatewayOrigin
583668
ViewerProtocolPolicy: redirect-to-https

src/api/functions/rateLimit.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import {
2+
ConditionalCheckFailedException,
3+
UpdateItemCommand,
4+
} from "@aws-sdk/client-dynamodb";
5+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
6+
import { genericConfig } from "common/config.js";
7+
8+
interface RateLimitParams {
9+
ddbClient: DynamoDBClient;
10+
rateLimitIdentifier: string;
11+
duration: number;
12+
limit: number;
13+
userIdentifier: string;
14+
}
15+
16+
export async function isAtLimit({
17+
ddbClient,
18+
rateLimitIdentifier,
19+
duration,
20+
limit,
21+
userIdentifier,
22+
}: RateLimitParams): Promise<{
23+
limited: boolean;
24+
resetTime: number;
25+
used: number;
26+
}> {
27+
const nowInSeconds = Math.floor(Date.now() / 1000);
28+
const timeWindow = Math.floor(nowInSeconds / duration) * duration;
29+
const PK = `rate-limit:${rateLimitIdentifier}:${userIdentifier}:${timeWindow}`;
30+
31+
try {
32+
const result = await ddbClient.send(
33+
new UpdateItemCommand({
34+
TableName: genericConfig.RateLimiterDynamoTableName,
35+
Key: {
36+
PK: { S: PK },
37+
SK: { S: "counter" },
38+
},
39+
UpdateExpression: "ADD #rateLimitCount :inc SET #ttl = :ttl",
40+
ConditionExpression:
41+
"attribute_not_exists(#rateLimitCount) OR #rateLimitCount <= :limit",
42+
ExpressionAttributeValues: {
43+
":inc": { N: "1" },
44+
":limit": { N: limit.toString() },
45+
":ttl": { N: (timeWindow + duration).toString() },
46+
},
47+
ExpressionAttributeNames: {
48+
"#rateLimitCount": "rateLimitCount",
49+
"#ttl": "ttl",
50+
},
51+
ReturnValues: "UPDATED_NEW",
52+
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
53+
}),
54+
);
55+
return {
56+
limited: false,
57+
used: parseInt(result.Attributes?.rateLimitCount.N || "1", 10),
58+
resetTime: timeWindow + duration,
59+
};
60+
} catch (error) {
61+
if (error instanceof ConditionalCheckFailedException) {
62+
return { limited: true, resetTime: timeWindow + duration, used: limit };
63+
}
64+
throw error;
65+
}
66+
}

src/api/functions/sts.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { AssumeRoleCommand } from "@aws-sdk/client-sts";
22
import { STSClient } from "@aws-sdk/client-sts";
33
import { genericConfig } from "common/config.js";
44
import { InternalServerError } from "common/errors/index.js";
5-
import { duration } from "moment";
65

76
export async function getRoleCredentials(
87
roleArn: string,

0 commit comments

Comments
 (0)