Skip to content

Commit c9501c5

Browse files
committed
Add v2 membership route
1 parent e1e35dc commit c9501c5

File tree

12 files changed

+453
-64
lines changed

12 files changed

+453
-64
lines changed

src/api/routes/membership.ts

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,20 +86,13 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => {
8686
"/",
8787
{
8888
schema: withTags(["Membership"], {
89-
querystring: z.object({
90-
list: z.string().min(1).optional().meta({
91-
description:
92-
"Membership list to check from (defaults to ACM Paid Member list).",
93-
}),
94-
}),
9589
headers: z.object({
9690
"x-uiuc-token": z.jwt().min(1).meta({
9791
description:
9892
"An access token for the user in the UIUC Entra ID tenant.",
9993
}),
10094
}),
101-
summary:
102-
"Authenticated check ACM @ UIUC paid membership (or partner organization membership) status.",
95+
summary: "Check self ACM @ UIUC paid membership.",
10396
response: {
10497
200: {
10598
description: "List membership status.",
@@ -110,7 +103,6 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => {
110103
givenName: z.string().min(1),
111104
surname: z.string().min(1),
112105
netId: illinoisNetId,
113-
list: z.optional(z.string().min(1)),
114106
isPaidMember: z.boolean(),
115107
})
116108
.meta({
@@ -143,7 +135,7 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => {
143135
message: "ID token could not be parsed.",
144136
});
145137
}
146-
const list = request.query.list || "acmpaid";
138+
const list = "acmpaid";
147139
const cacheKey = `membership:${netId}:${list}`;
148140
const result = await getKey<{ isMember: boolean }>({
149141
redisClient: fastify.redisClient,
@@ -238,6 +230,8 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => {
238230
"/:netId",
239231
{
240232
schema: withTags(["Membership"], {
233+
deprecated: true,
234+
description: "[DEPRECATED] DO NOT USE!",
241235
params: z.object({ netId: illinoisNetId }),
242236
querystring: z.object({
243237
list: z.string().min(1).optional().meta({

src/api/routes/v2/membership.ts

Lines changed: 205 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,68 @@
11
import {
22
checkPaidMembershipFromTable,
33
checkPaidMembershipFromRedis,
4+
checkExternalMembership,
5+
MEMBER_CACHE_SECONDS,
6+
checkPaidMembershipFromEntra,
7+
setPaidMembershipInTable,
48
} from "api/functions/membership.js";
59
import { FastifyPluginAsync } from "fastify";
6-
import { ValidationError } from "common/errors/index.js";
10+
import {
11+
InternalServerError,
12+
UnauthorizedError,
13+
ValidationError,
14+
} from "common/errors/index.js";
715
import rateLimiter from "api/plugins/rateLimiter.js";
816
import { createCheckoutSession } from "api/functions/stripe.js";
917
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
1018
import * as z from "zod/v4";
11-
import { notAuthenticatedError, withTags } from "api/components/index.js";
19+
import {
20+
illinoisNetId,
21+
notAuthenticatedError,
22+
withRoles,
23+
withTags,
24+
} from "api/components/index.js";
1225
import { verifyUiucAccessToken, saveHashedUserUin } from "api/functions/uin.js";
26+
import { getKey, setKey } from "api/functions/redisCache.js";
27+
import { getEntraIdToken } from "api/functions/entraId.js";
28+
import { genericConfig, roleArns } from "common/config.js";
29+
import { getRoleCredentials } from "api/functions/sts.js";
30+
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
31+
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
32+
import { AppRoles } from "common/roles.js";
1333

1434
const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
35+
const getAuthorizedClients = async () => {
36+
if (roleArns.Entra) {
37+
fastify.log.info(
38+
`Attempting to assume Entra role ${roleArns.Entra} to get the Entra token...`,
39+
);
40+
const credentials = await getRoleCredentials(roleArns.Entra);
41+
const clients = {
42+
smClient: new SecretsManagerClient({
43+
region: genericConfig.AwsRegion,
44+
credentials,
45+
}),
46+
dynamoClient: new DynamoDBClient({
47+
region: genericConfig.AwsRegion,
48+
credentials,
49+
}),
50+
redisClient: fastify.redisClient,
51+
};
52+
fastify.log.info(
53+
`Assumed Entra role ${roleArns.Entra} to get the Entra token.`,
54+
);
55+
return clients;
56+
}
57+
fastify.log.debug(
58+
"Did not assume Entra role as no env variable was present",
59+
);
60+
return {
61+
smClient: fastify.secretsManagerClient,
62+
dynamoClient: fastify.dynamoClient,
63+
redisClient: fastify.redisClient,
64+
};
65+
};
1566
const limitedRoutes: FastifyPluginAsync = async (fastify) => {
1667
await fastify.register(rateLimiter, {
1768
limit: 15,
@@ -109,6 +160,158 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
109160
);
110161
},
111162
);
163+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
164+
"/:netId",
165+
{
166+
schema: withRoles(
167+
[
168+
AppRoles.VIEW_INTERNAL_MEMBERSHIP_LIST,
169+
AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST,
170+
],
171+
withTags(["Membership"], {
172+
params: z.object({ netId: illinoisNetId }),
173+
querystring: z.object({
174+
list: z.string().min(1).optional().meta({
175+
example: "built",
176+
description:
177+
"Membership list to check from (defaults to ACM Paid Member list).",
178+
}),
179+
}),
180+
summary:
181+
"Check ACM @ UIUC paid membership (or partner organization membership) status.",
182+
response: {
183+
200: {
184+
description: "List membership status.",
185+
content: {
186+
"application/json": {
187+
schema: z
188+
.object({
189+
netId: illinoisNetId,
190+
list: z.optional(z.string().min(1)),
191+
isPaidMember: z.boolean(),
192+
})
193+
.meta({
194+
example: {
195+
netId: "rjjones",
196+
list: "built",
197+
isPaidMember: false,
198+
},
199+
}),
200+
},
201+
},
202+
},
203+
},
204+
}),
205+
),
206+
onRequest: async (request, reply) => {
207+
await fastify.authorizeFromSchema(request, reply);
208+
if (!request.userRoles) {
209+
throw new InternalServerError({});
210+
}
211+
const list = request.query.list || "acmpaid";
212+
if (
213+
list === "acmpaid" &&
214+
!request.userRoles.has(AppRoles.VIEW_INTERNAL_MEMBERSHIP_LIST)
215+
) {
216+
throw new UnauthorizedError({});
217+
}
218+
if (
219+
list !== "acmpaid" &&
220+
!request.userRoles.has(AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST)
221+
) {
222+
throw new UnauthorizedError({});
223+
}
224+
},
225+
},
226+
async (request, reply) => {
227+
const netId = request.params.netId.toLowerCase();
228+
const list = request.query.list || "acmpaid";
229+
const cacheKey = `membership:${netId}:${list}`;
230+
const result = await getKey<{ isMember: boolean }>({
231+
redisClient: fastify.redisClient,
232+
key: cacheKey,
233+
logger: request.log,
234+
});
235+
if (result) {
236+
return reply.header("X-ACM-Data-Source", "cache").send({
237+
netId,
238+
list: list === "acmpaid" ? undefined : list,
239+
isPaidMember: result.isMember,
240+
});
241+
}
242+
if (list !== "acmpaid") {
243+
const isMember = await checkExternalMembership(
244+
netId,
245+
list,
246+
fastify.dynamoClient,
247+
);
248+
await setKey({
249+
redisClient: fastify.redisClient,
250+
key: cacheKey,
251+
data: JSON.stringify({ isMember }),
252+
expiresIn: MEMBER_CACHE_SECONDS,
253+
logger: request.log,
254+
});
255+
return reply.header("X-ACM-Data-Source", "dynamo").send({
256+
netId,
257+
list,
258+
isPaidMember: isMember,
259+
});
260+
}
261+
const isDynamoMember = await checkPaidMembershipFromTable(
262+
netId,
263+
fastify.dynamoClient,
264+
);
265+
if (isDynamoMember) {
266+
await setKey({
267+
redisClient: fastify.redisClient,
268+
key: cacheKey,
269+
data: JSON.stringify({ isMember: true }),
270+
expiresIn: MEMBER_CACHE_SECONDS,
271+
logger: request.log,
272+
});
273+
return reply
274+
.header("X-ACM-Data-Source", "dynamo")
275+
.send({ netId, isPaidMember: true });
276+
}
277+
const entraIdToken = await getEntraIdToken({
278+
clients: await getAuthorizedClients(),
279+
clientId: fastify.environmentConfig.AadValidClientId,
280+
secretName: genericConfig.EntraSecretName,
281+
logger: request.log,
282+
});
283+
const paidMemberGroup = fastify.environmentConfig.PaidMemberGroupId;
284+
const isAadMember = await checkPaidMembershipFromEntra(
285+
netId,
286+
entraIdToken,
287+
paidMemberGroup,
288+
);
289+
if (isAadMember) {
290+
await setKey({
291+
redisClient: fastify.redisClient,
292+
key: cacheKey,
293+
data: JSON.stringify({ isMember: true }),
294+
expiresIn: MEMBER_CACHE_SECONDS,
295+
logger: request.log,
296+
});
297+
reply
298+
.header("X-ACM-Data-Source", "aad")
299+
.send({ netId, isPaidMember: true });
300+
await setPaidMembershipInTable(netId, fastify.dynamoClient);
301+
return;
302+
}
303+
await setKey({
304+
redisClient: fastify.redisClient,
305+
key: cacheKey,
306+
data: JSON.stringify({ isMember: false }),
307+
expiresIn: MEMBER_CACHE_SECONDS,
308+
logger: request.log,
309+
});
310+
return reply
311+
.header("X-ACM-Data-Source", "aad")
312+
.send({ netId, isPaidMember: false });
313+
},
314+
);
112315
};
113316
fastify.register(limitedRoutes);
114317
};

src/common/config.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ export type ConfigType = {
1919
LinkryBaseUrl: string
2020
PasskitIdentifier: string;
2121
PasskitSerialNumber: string;
22-
MembershipApiEndpoint: string;
2322
EmailDomain: string;
2423
SqsQueueUrl: string;
2524
PaidMemberGroupId: string;
@@ -115,8 +114,6 @@ const environmentConfig: EnvironmentConfigType = {
115114
LinkryBaseUrl: "https://core.aws.qa.acmuiuc.org",
116115
PasskitIdentifier: "pass.org.acmuiuc.qa.membership",
117116
PasskitSerialNumber: "0",
118-
MembershipApiEndpoint:
119-
"https://core.aws.qa.acmuiuc.org/api/v1/membership",
120117
EmailDomain: "aws.qa.acmuiuc.org",
121118
SqsQueueUrl:
122119
"https://sqs.us-east-1.amazonaws.com/427040638965/infra-core-api-sqs",
@@ -141,8 +138,6 @@ const environmentConfig: EnvironmentConfigType = {
141138
LinkryBaseUrl: "https://go.acm.illinois.edu/",
142139
PasskitIdentifier: "pass.edu.illinois.acm.membership",
143140
PasskitSerialNumber: "0",
144-
MembershipApiEndpoint:
145-
"https://core.acm.illinois.edu/api/v1/membership",
146141
EmailDomain: "acm.illinois.edu",
147142
SqsQueueUrl:
148143
"https://sqs.us-east-1.amazonaws.com/298118738376/infra-core-api-sqs",

src/common/errors/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,8 @@ export class NotImplementedError extends BaseError<"NotImplementedError"> {
5555
}
5656

5757
export class UnauthorizedError extends BaseError<"UnauthorizedError"> {
58-
constructor({ message }: { message: string }) {
59-
super({ name: "UnauthorizedError", id: 101, message, httpStatusCode: 401 });
58+
constructor({ message }: { message?: string }) {
59+
super({ name: "UnauthorizedError", id: 101, message: message || "Caller does not have permission.", httpStatusCode: 401 });
6060
}
6161
}
6262

src/common/policies/definition.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import { FastifyRequest } from "fastify";
22
import { hostRestrictionPolicy } from "./events.js";
33
import * as z from "zod/v4";
44
import { AuthorizationPolicyResult } from "./evaluator.js";
5+
import { membershipListPolicy } from "./membershipListPolicy.js";
56

67
type Policy<TParamsSchema extends z.ZodObject<any>> = {
78
name: string;
89
paramsSchema: TParamsSchema;
910
evaluator: (
10-
request: FastifyRequest,
11-
params: z.infer<TParamsSchema>)
12-
=> AuthorizationPolicyResult;
11+
request: FastifyRequest,
12+
params: z.infer<TParamsSchema>)
13+
=> AuthorizationPolicyResult;
1314
};
1415

1516
type PolicyParams<T> = T extends Policy<infer U> ? z.infer<U> : never;
@@ -20,14 +21,15 @@ type PolicyRegistry = {
2021

2122
// Type to generate a strongly-typed version of the policy registry
2223
type TypedPolicyRegistry<T extends PolicyRegistry> = { [K in
23-
keyof T]: {
24-
name: T[K]["name"];
25-
params: PolicyParams<T[K]>;
26-
} };
24+
keyof T]: {
25+
name: T[K]["name"];
26+
params: PolicyParams<T[K]>;
27+
} };
2728

2829

2930
export const AuthorizationPoliciesRegistry: PolicyRegistry = {
30-
EventsHostRestrictionPolicy: hostRestrictionPolicy
31+
EventsHostRestrictionPolicy: hostRestrictionPolicy,
32+
MembershipListQueryPolicy: membershipListPolicy
3133
} as const;
3234

3335
export type AvailableAuthorizationPolicies = TypedPolicyRegistry<
@@ -37,4 +39,4 @@ export type AvailableAuthorizationPolicies = TypedPolicyRegistry<
3739
export type AvailableAuthorizationPolicy = {
3840
name: keyof typeof AuthorizationPoliciesRegistry;
3941
params: PolicyParams<typeof AuthorizationPoliciesRegistry[keyof typeof AuthorizationPoliciesRegistry]>;
40-
};
42+
};

0 commit comments

Comments
 (0)