Skip to content

Commit d01a9dc

Browse files
committed
use redis cache for critical paths
1 parent 582a1a2 commit d01a9dc

File tree

14 files changed

+496
-220
lines changed

14 files changed

+496
-220
lines changed

cloudformation/iam.yml

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,6 @@ Resources:
9999
Resource:
100100
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache
101101

102-
- Sid: DynamoDBRateLimitTableAccess
103-
Effect: Allow
104-
Action:
105-
- dynamodb:DescribeTable
106-
- dynamodb:UpdateItem
107-
Resource:
108-
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-rate-limiter
109-
110102
- Sid: DynamoDBAuditLogTableAccess
111103
Effect: Allow
112104
Action:

cloudformation/main.yml

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -408,30 +408,6 @@ Resources:
408408
- AttributeName: userEmail
409409
KeyType: HASH
410410

411-
RateLimiterTable:
412-
Type: "AWS::DynamoDB::Table"
413-
DeletionPolicy: "Delete"
414-
UpdateReplacePolicy: "Delete"
415-
Properties:
416-
BillingMode: "PAY_PER_REQUEST"
417-
TableName: infra-core-api-rate-limiter
418-
DeletionProtectionEnabled: true
419-
PointInTimeRecoverySpecification:
420-
PointInTimeRecoveryEnabled: false
421-
AttributeDefinitions:
422-
- AttributeName: PK
423-
AttributeType: S
424-
- AttributeName: SK
425-
AttributeType: S
426-
KeySchema:
427-
- AttributeName: PK
428-
KeyType: HASH
429-
- AttributeName: SK
430-
KeyType: RANGE
431-
TimeToLiveSpecification:
432-
AttributeName: ttl
433-
Enabled: true
434-
435411
EventRecordsTable:
436412
Type: "AWS::DynamoDB::Table"
437413
DeletionPolicy: "Retain"

src/api/functions/authorization.ts

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,8 @@ export const AUTH_DECISION_CACHE_SECONDS = 180;
99

1010
export async function getUserRoles(
1111
dynamoClient: DynamoDBClient,
12-
fastifyApp: FastifyInstance,
1312
userId: string,
1413
): Promise<AppRoles[]> {
15-
const cachedValue = fastifyApp.nodeCache.get(`userroles-${userId}`);
16-
if (cachedValue) {
17-
fastifyApp.log.info(`Returning cached auth decision for user ${userId}`);
18-
return cachedValue as AppRoles[];
19-
}
2014
const tableName = `${genericConfig.IAMTablePrefix}-userroles`;
2115
const command = new GetItemCommand({
2216
TableName: tableName,
@@ -37,32 +31,13 @@ export async function getUserRoles(
3731
if (!("roles" in items)) {
3832
return [];
3933
}
40-
if (items.roles[0] === "all") {
41-
fastifyApp.nodeCache.set(
42-
`userroles-${userId}`,
43-
allAppRoles,
44-
AUTH_DECISION_CACHE_SECONDS,
45-
);
46-
return allAppRoles;
47-
}
48-
fastifyApp.nodeCache.set(
49-
`userroles-${userId}`,
50-
items.roles,
51-
AUTH_DECISION_CACHE_SECONDS,
52-
);
5334
return items.roles as AppRoles[];
5435
}
5536

5637
export async function getGroupRoles(
5738
dynamoClient: DynamoDBClient,
58-
fastifyApp: FastifyInstance,
5939
groupId: string,
6040
) {
61-
const cachedValue = fastifyApp.nodeCache.get(`grouproles-${groupId}`);
62-
if (cachedValue) {
63-
fastifyApp.log.info(`Returning cached auth decision for group ${groupId}`);
64-
return cachedValue as AppRoles[];
65-
}
6641
const tableName = `${genericConfig.IAMTablePrefix}-grouproles`;
6742
const command = new GetItemCommand({
6843
TableName: tableName,
@@ -77,34 +52,14 @@ export async function getGroupRoles(
7752
});
7853
}
7954
if (!response.Item) {
80-
fastifyApp.nodeCache.set(
81-
`grouproles-${groupId}`,
82-
[],
83-
AUTH_DECISION_CACHE_SECONDS,
84-
);
8555
return [];
8656
}
8757
const items = unmarshall(response.Item) as { roles: AppRoles[] | ["all"] };
8858
if (!("roles" in items)) {
89-
fastifyApp.nodeCache.set(
90-
`grouproles-${groupId}`,
91-
[],
92-
AUTH_DECISION_CACHE_SECONDS,
93-
);
9459
return [];
9560
}
9661
if (items.roles[0] === "all") {
97-
fastifyApp.nodeCache.set(
98-
`grouproles-${groupId}`,
99-
allAppRoles,
100-
AUTH_DECISION_CACHE_SECONDS,
101-
);
10262
return allAppRoles;
10363
}
104-
fastifyApp.nodeCache.set(
105-
`grouproles-${groupId}`,
106-
items.roles,
107-
AUTH_DECISION_CACHE_SECONDS,
108-
);
10964
return items.roles as AppRoles[];
11065
}

src/api/functions/discord.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import moment from "moment-timezone";
1414
import { FastifyBaseLogger } from "fastify";
1515
import { DiscordEventError } from "../../common/errors/index.js";
1616
import { getSecretValue } from "../plugins/auth.js";
17-
import { genericConfig } from "../../common/config.js";
17+
import { genericConfig, SecretConfig } from "../../common/config.js";
1818
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
1919

2020
// https://stackoverflow.com/a/3809435/5684541
@@ -26,19 +26,16 @@ export type IUpdateDiscord = EventPostRequest & { id: string };
2626
const urlRegex = /https:\/\/[a-z0-9.-]+\/calendar\?id=([a-f0-9-]+)/;
2727

2828
export const updateDiscord = async (
29-
smClient: SecretsManagerClient,
29+
secretApiConfig: SecretConfig,
3030
event: IUpdateDiscord,
3131
actor: string,
3232
isDelete: boolean = false,
3333
logger: FastifyBaseLogger,
3434
): Promise<null | GuildScheduledEventCreateOptions> => {
35-
const secretApiConfig =
36-
(await getSecretValue(smClient, genericConfig.ConfigSecretName)) || {};
3735
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
3836
let payload: GuildScheduledEventCreateOptions | null = null;
39-
4037
client.once(Events.ClientReady, async (readyClient: Client<true>) => {
41-
logger.info(`Logged in as ${readyClient.user.tag}`);
38+
logger.debug(`Logged in as ${readyClient.user.tag}`);
4239
const guildID = secretApiConfig.discord_guild_id;
4340
const guild = await client.guilds.fetch(guildID?.toString() || "");
4441
const discordEvents = await guild.scheduledEvents.fetch();
@@ -69,6 +66,7 @@ export const updateDiscord = async (
6966
logger.warn(`Event with id ${id} not found in Discord`);
7067
}
7168
await client.destroy();
69+
logger.debug("Logged out of Discord.");
7270
return null;
7371
}
7472

@@ -108,6 +106,7 @@ export const updateDiscord = async (
108106
}
109107

110108
await client.destroy();
109+
logger.debug("Logged out of Discord.");
111110
return payload;
112111
});
113112

src/api/functions/rateLimit.ts

Lines changed: 44 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,60 @@
1-
import {
2-
ConditionalCheckFailedException,
3-
UpdateItemCommand,
4-
DynamoDBClient,
5-
} from "@aws-sdk/client-dynamodb";
6-
import { genericConfig } from "common/config.js";
1+
import { Redis } from "ioredis"; // Make sure you have ioredis installed (npm install ioredis)
72

83
interface RateLimitParams {
9-
ddbClient: DynamoDBClient;
4+
redisClient: Redis;
105
rateLimitIdentifier: string;
116
duration: number;
127
limit: number;
138
userIdentifier: string;
149
}
1510

11+
interface RateLimitResult {
12+
limited: boolean;
13+
resetTime: number;
14+
used: number;
15+
}
16+
17+
const LUA_SCRIPT_INCREMENT_AND_EXPIRE = `
18+
local count = redis.call("INCR", KEYS[1])
19+
-- If the count is 1, this means the key was just created by INCR (first request in this window).
20+
-- So, we set its expiration time to the end of the current window.
21+
if tonumber(count) == 1 then
22+
redis.call("EXPIREAT", KEYS[1], ARGV[1])
23+
end
24+
return count
25+
`;
26+
1627
export async function isAtLimit({
17-
ddbClient,
28+
redisClient,
1829
rateLimitIdentifier,
1930
duration,
2031
limit,
2132
userIdentifier,
22-
}: RateLimitParams): Promise<{
23-
limited: boolean;
24-
resetTime: number;
25-
used: number;
26-
}> {
33+
}: RateLimitParams): Promise<RateLimitResult> {
34+
if (duration <= 0) {
35+
throw new Error("Rate limit duration must be a positive number.");
36+
}
37+
if (limit < 0) {
38+
throw new Error("Rate limit must be a non-negative number.");
39+
}
40+
2741
const nowInSeconds = Math.floor(Date.now() / 1000);
28-
const timeWindow = Math.floor(nowInSeconds / duration) * duration;
29-
const PK = `rate-limit:${rateLimitIdentifier}:${userIdentifier}:${timeWindow}`;
42+
const timeWindowStart = Math.floor(nowInSeconds / duration) * duration;
43+
const key = `rate-limit:${rateLimitIdentifier}:${userIdentifier}:${timeWindowStart}`;
44+
const expiryTimestamp = timeWindowStart + duration;
3045

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-
}
46+
const currentUsedCount = (await redisClient.eval(
47+
LUA_SCRIPT_INCREMENT_AND_EXPIRE,
48+
1, // Number of keys
49+
key, // KEYS[1]
50+
expiryTimestamp.toString(), // ARGV[1]
51+
)) as number; // The script returns the count, which is a number.
52+
const isLimited = currentUsedCount > limit;
53+
const resetTime = expiryTimestamp;
54+
55+
return {
56+
limited: isLimited,
57+
resetTime,
58+
used: isLimited ? limit : currentUsedCount,
59+
};
6660
}

src/api/index.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@ import { randomUUID } from "crypto";
44
import fastify, { FastifyInstance } from "fastify";
55
import FastifyAuthProvider from "@fastify/auth";
66
import fastifyStatic from "@fastify/static";
7-
import fastifyAuthPlugin from "./plugins/auth.js";
7+
import fastifyAuthPlugin, { getSecretValue } from "./plugins/auth.js";
88
import protectedRoute from "./routes/protected.js";
99
import errorHandlerPlugin from "./plugins/errorHandler.js";
1010
import { RunEnvironment, runEnvironments } from "../common/roles.js";
1111
import { InternalServerError } from "../common/errors/index.js";
1212
import eventsPlugin from "./routes/events.js";
1313
import cors from "@fastify/cors";
14-
import { environmentConfig, genericConfig } from "../common/config.js";
14+
import {
15+
environmentConfig,
16+
genericConfig,
17+
SecretConfig,
18+
} from "../common/config.js";
1519
import organizationsPlugin from "./routes/organizations.js";
1620
import authorizeFromSchemaPlugin from "./plugins/authorizeFromSchema.js";
1721
import evaluatePoliciesPlugin from "./plugins/evaluatePolicies.js";
@@ -43,6 +47,8 @@ import {
4347
import { ZodOpenApiVersion } from "zod-openapi";
4448
import { withTags } from "./components/index.js";
4549
import apiKeyRoute from "./routes/apiKey.js";
50+
import RedisModule from "ioredis";
51+
import fastifyCron from "fastify-cron";
4652

4753
dotenv.config();
4854

@@ -56,6 +62,12 @@ async function init(prettyPrint: boolean = false) {
5662
const secretsManagerClient = new SecretsManagerClient({
5763
region: genericConfig.AwsRegion,
5864
});
65+
const secret = (await getSecretValue(
66+
secretsManagerClient,
67+
genericConfig.ConfigSecretName,
68+
)) as SecretConfig;
69+
const redisClient = new RedisModule.default(secret.redis_url);
70+
5971
const transport = prettyPrint
6072
? {
6173
target: "pino-pretty",
@@ -224,6 +236,26 @@ async function init(prettyPrint: boolean = false) {
224236
app.nodeCache = new NodeCache({ checkperiod: 30 });
225237
app.dynamoClient = dynamoClient;
226238
app.secretsManagerClient = secretsManagerClient;
239+
app.redisClient = redisClient;
240+
app.secretConfig = secret;
241+
app.refreshSecretConfig = async () => {
242+
app.secretConfig = (await getSecretValue(
243+
app.secretsManagerClient,
244+
genericConfig.ConfigSecretName,
245+
)) as SecretConfig;
246+
};
247+
app.register(fastifyCron.default, {
248+
// refresh secrets config
249+
jobs: [
250+
{
251+
cronTime: "*/15 * * * *",
252+
onTick: async (server) => {
253+
server.log.info("Refreshing secrets manager config.");
254+
await server.refreshSecretConfig();
255+
},
256+
},
257+
],
258+
});
227259
app.addHook("onRequest", (req, _, done) => {
228260
req.startTime = now();
229261
const hostname = req.hostname;
@@ -250,7 +282,13 @@ async function init(prettyPrint: boolean = false) {
250282
summary: "Verify that the API server is healthy.",
251283
}),
252284
},
253-
(_, reply) => reply.send({ message: "UP" }),
285+
async (_, reply) => {
286+
const startTime = new Date().getTime();
287+
await app.redisClient.ping();
288+
const redisTime = new Date().getTime();
289+
app.log.debug(`Redis latency: ${redisTime - startTime} ms.`);
290+
return reply.send({ message: "UP" });
291+
},
254292
);
255293
await app.register(
256294
async (api, _options) => {
@@ -295,7 +333,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
295333
process.exit(1);
296334
}
297335
const app = await init(true);
298-
app.listen({ port: 8080 }, async (err) => {
336+
app.listen({ port: 8080 }, (err) => {
299337
/* eslint no-console: ["error", {"allow": ["log", "error"]}] */
300338
if (err) {
301339
console.error(err);

src/api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,12 @@
4141
"dotenv": "^16.5.0",
4242
"esbuild": "^0.25.3",
4343
"fastify": "^5.3.2",
44+
"fastify-cron": "^1.4.0",
4445
"fastify-plugin": "^5.0.1",
4546
"fastify-raw-body": "^5.0.0",
4647
"fastify-zod-openapi": "^4.1.1",
4748
"ical-generator": "^8.1.1",
49+
"ioredis": "^5.6.1",
4850
"jsonwebtoken": "^9.0.2",
4951
"jwks-rsa": "^3.2.0",
5052
"moment": "^2.30.1",

0 commit comments

Comments
 (0)