Skip to content

Commit 636a1a1

Browse files
authored
Enable select queries on certain APIs (#188)
Don't return all documents full content when bypassing BOLA
1 parent 606e872 commit 636a1a1

File tree

3 files changed

+82
-3
lines changed

3 files changed

+82
-3
lines changed

src/api/routes/roomRequests.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
3030
import { z } from "zod";
3131
import { buildAuditLogTransactPut } from "api/functions/auditLog.js";
3232
import { Modules } from "common/modules.js";
33+
import {
34+
generateProjectionParams,
35+
getDefaultFilteringQuerystring,
36+
nonEmptyCommaSeparatedStringSchema,
37+
} from "common/utils.js";
3338

3439
const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
3540
await fastify.register(rateLimiter, {
@@ -182,12 +187,19 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
182187
example: "sp25",
183188
}),
184189
}),
190+
querystring: z.object(
191+
getDefaultFilteringQuerystring({
192+
defaultSelect: ["requestId", "title"],
193+
}),
194+
),
185195
}),
186196
),
187197
onRequest: fastify.authorizeFromSchema,
188198
},
189199
async (request, reply) => {
190200
const semesterId = request.params.semesterId;
201+
const { ProjectionExpression, ExpressionAttributeNames } =
202+
generateProjectionParams({ userFields: request.query.select });
191203
if (!request.username) {
192204
throw new InternalServerError({
193205
message: "Could not retrieve username.",
@@ -198,6 +210,8 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
198210
command = new QueryCommand({
199211
TableName: genericConfig.RoomRequestsTableName,
200212
KeyConditionExpression: "semesterId = :semesterValue",
213+
ProjectionExpression,
214+
ExpressionAttributeNames,
201215
ExpressionAttributeValues: {
202216
":semesterValue": { S: semesterId },
203217
},
@@ -209,8 +223,9 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
209223
"semesterId = :semesterValue AND begins_with(#sortKey, :username)",
210224
ExpressionAttributeNames: {
211225
"#sortKey": "userId#requestId",
226+
...ExpressionAttributeNames,
212227
},
213-
ProjectionExpression: "requestId, host, title, semester",
228+
ProjectionExpression,
214229
ExpressionAttributeValues: {
215230
":semesterValue": { S: semesterId },
216231
":username": { S: request.username },
@@ -224,6 +239,9 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
224239
});
225240
}
226241
const items = response.Items.map((x) => {
242+
if (!request.query.select.includes("status")) {
243+
return unmarshall(x);
244+
}
227245
const item = unmarshall(x) as {
228246
host: string;
229247
title: string;
@@ -403,20 +421,29 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
403421
example: "sp25",
404422
}),
405423
}),
424+
querystring: z.object(
425+
getDefaultFilteringQuerystring({
426+
defaultSelect: ["requestId", "title"],
427+
}),
428+
),
406429
}),
407430
),
408431
onRequest: fastify.authorizeFromSchema,
409432
},
410433
async (request, reply) => {
411434
const requestId = request.params.requestId;
412435
const semesterId = request.params.semesterId;
436+
const { ProjectionExpression, ExpressionAttributeNames } =
437+
generateProjectionParams({ userFields: request.query.select });
413438
let command;
414439
if (request.userRoles?.has(AppRoles.BYPASS_OBJECT_LEVEL_AUTH)) {
415440
command = new QueryCommand({
416441
TableName: genericConfig.RoomRequestsTableName,
417442
IndexName: "RequestIdIndex",
418443
KeyConditionExpression: "requestId = :requestId",
419444
FilterExpression: "semesterId = :semesterId",
445+
ProjectionExpression,
446+
ExpressionAttributeNames,
420447
ExpressionAttributeValues: {
421448
":requestId": { S: requestId },
422449
":semesterId": { S: semesterId },
@@ -426,6 +453,7 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
426453
} else {
427454
command = new QueryCommand({
428455
TableName: genericConfig.RoomRequestsTableName,
456+
ProjectionExpression,
429457
KeyConditionExpression:
430458
"semesterId = :semesterId AND #userIdRequestId = :userRequestId",
431459
ExpressionAttributeValues: {
@@ -434,6 +462,7 @@ const roomRequestRoutes: FastifyPluginAsync = async (fastify, _options) => {
434462
},
435463
ExpressionAttributeNames: {
436464
"#userIdRequestId": "userId#requestId",
465+
...ExpressionAttributeNames,
437466
},
438467
Limit: 1,
439468
});

src/common/utils.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { z } from "zod";
12
export function transformCommaSeperatedName(name: string) {
23
if (name.includes(",")) {
34
try {
@@ -12,3 +13,44 @@ export function transformCommaSeperatedName(name: string) {
1213
}
1314
return name;
1415
}
16+
17+
type GenerateProjectionParamsInput = {
18+
userFields?: string[];
19+
}
20+
/**
21+
* Generates DynamoDB projection parameters for select filters, while safely handle reserved keywords.
22+
*/
23+
export const generateProjectionParams = ({ userFields }: GenerateProjectionParamsInput) => {
24+
const attributes = userFields || [];
25+
const expressionAttributeNames: Record<string, string> = {};
26+
const projectionExpression = attributes
27+
.map((attr, index) => {
28+
const placeholder = `#proj${index}`;
29+
expressionAttributeNames[placeholder] = attr;
30+
return placeholder;
31+
})
32+
.join(',');
33+
return {
34+
ProjectionExpression: projectionExpression,
35+
ExpressionAttributeNames: expressionAttributeNames,
36+
};
37+
};
38+
39+
40+
export const nonEmptyCommaSeparatedStringSchema = z
41+
.string({ invalid_type_error: "Filter expression must be a string." })
42+
.min(1, { message: "Filter expression must be at least 1 character long." })
43+
.transform((val) => val.split(',').map(item => item.trim()))
44+
.pipe(z.array(z.string()).nonempty());
45+
46+
type GetDefaultFilteringQuerystringInput = {
47+
defaultSelect: string[];
48+
}
49+
export const getDefaultFilteringQuerystring = ({ defaultSelect }: GetDefaultFilteringQuerystringInput) => {
50+
return {
51+
select: z.optional(nonEmptyCommaSeparatedStringSchema).default(defaultSelect.join(',')).openapi({
52+
description: "Comma-seperated list of attributes to return",
53+
...(defaultSelect.length === 0 ? { default: "<ALL ATTRIBUTES>" } : {}),
54+
})
55+
}
56+
}

src/ui/pages/roomRequest/RoomRequestLanding.page.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
RoomRequestFormValues,
1212
RoomRequestGetAllResponse,
1313
RoomRequestPostResponse,
14+
type RoomRequestStatus,
1415
} from "@common/types/roomRequest";
1516

1617
export const ManageRoomRequestsPage: React.FC = () => {
@@ -29,8 +30,15 @@ export const ManageRoomRequestsPage: React.FC = () => {
2930
const getRoomRequests = async (
3031
semester: string,
3132
): Promise<RoomRequestGetAllResponse> => {
32-
const response = await api.get(`/api/v1/roomRequests/${semester}`);
33-
return response.data;
33+
const response = await api.get<
34+
{
35+
requestId: string;
36+
title: string;
37+
host: string;
38+
status: RoomRequestStatus;
39+
}[]
40+
>(`/api/v1/roomRequests/${semester}?select=requestId,title,host,status`);
41+
return response.data.map((x) => ({ ...x, semester }));
3442
};
3543

3644
useEffect(() => {

0 commit comments

Comments
 (0)