Skip to content

Commit 9d87201

Browse files
authored
Store and retrieve paid membership from the user info table (#292)
1 parent e333dd9 commit 9d87201

File tree

10 files changed

+139
-67
lines changed

10 files changed

+139
-67
lines changed

onetime/uinHash-migration.py renamed to onetime/paidMember.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@
22
import boto3
33
import logging
44
from botocore.exceptions import ClientError
5+
from datetime import datetime, timezone
6+
57

68
# --- Configuration ---
7-
SOURCE_TABLE_NAME = "infra-core-api-uin-mapping"
9+
SOURCE_TABLE_NAME = "infra-core-api-membership-provisioning"
810
DESTINATION_TABLE_NAME = "infra-core-api-user-info"
9-
DESTINATION_ID_SUFFIX = "@illinois.edu"
1011

1112
# --- Logging Setup ---
1213
logging.basicConfig(
1314
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
1415
)
16+
utc_iso_timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
1517

1618

1719
def migrate_uin_hashes():
@@ -36,25 +38,27 @@ def migrate_uin_hashes():
3638
for page in page_iterator:
3739
for item in page.get("Items", []):
3840
scanned_count += 1
39-
net_id = item.get("netId")
40-
uin_hash = item.get("uinHash")
41+
email = item.get("email")
4142

42-
# Validate that the necessary fields exist
43-
if not net_id or not uin_hash:
44-
logging.warning(
45-
f"Skipping item with missing 'netId' or 'uinHash': {item}"
46-
)
43+
if not email:
44+
logging.warning(f"Skipping item with missing 'email': {item}")
4745
continue
4846

4947
# Construct the primary key and update parameters for the destination table
50-
destination_pk_id = f"{net_id}{DESTINATION_ID_SUFFIX}"
51-
update_expression = "SET uinHash = :uh, netId = :ne"
52-
expression_attribute_values = {":uh": uin_hash, ":ne": net_id}
48+
netId = email.replace("@illinois.edu", "")
49+
update_expression = (
50+
"SET isPaidMember = :uh, netId = :ne, updatedAt = :up"
51+
)
52+
expression_attribute_values = {
53+
":uh": True,
54+
":ne": netId,
55+
":up": utc_iso_timestamp,
56+
}
5357

5458
# Update the item in the destination DynamoDB table
5559
try:
5660
destination_table.update_item(
57-
Key={"id": destination_pk_id},
61+
Key={"id": email},
5862
UpdateExpression=update_expression,
5963
ExpressionAttributeValues=expression_attribute_values,
6064
)
@@ -64,9 +68,7 @@ def migrate_uin_hashes():
6468
f"Scanned {scanned_count} items, updated {updated_count} so far..."
6569
)
6670
except ClientError as e:
67-
logging.error(
68-
f"Failed to update item with id '{destination_pk_id}': {e}"
69-
)
71+
logging.error(f"Failed to update item with id '{email}': {e}")
7072

7173
logging.info("--- Script Finished ---")
7274
logging.info(f"Total items scanned from source: {scanned_count}")

src/api/functions/membership.ts

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
DynamoDBClient,
55
PutItemCommand,
66
QueryCommand,
7+
UpdateItemCommand,
78
} from "@aws-sdk/client-dynamodb";
89
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
910
import { genericConfig } from "common/config.js";
@@ -218,10 +219,10 @@ export async function checkPaidMembershipFromTable(
218219
): Promise<boolean> {
219220
const { Items } = await dynamoClient.send(
220221
new QueryCommand({
221-
TableName: genericConfig.MembershipTableName,
222+
TableName: genericConfig.UserInfoTable,
222223
KeyConditionExpression: "#pk = :pk",
223224
ExpressionAttributeNames: {
224-
"#pk": "email",
225+
"#pk": "id",
225226
},
226227
ExpressionAttributeValues: marshall({
227228
":pk": `${netId}@illinois.edu`,
@@ -231,6 +232,10 @@ export async function checkPaidMembershipFromTable(
231232
if (!Items || Items.length === 0) {
232233
return false;
233234
}
235+
const item = unmarshall(Items[0]);
236+
if (!item.isPaidMember) {
237+
return false;
238+
}
234239
return true;
235240
}
236241

@@ -256,20 +261,29 @@ export async function checkPaidMembershipFromEntra(
256261
export async function setPaidMembershipInTable(
257262
netId: string,
258263
dynamoClient: DynamoDBClient,
259-
actor: string = "core-api-queried",
260264
): Promise<{ updated: boolean }> {
261-
const obj = {
262-
email: `${netId}@illinois.edu`,
263-
inserted_at: new Date().toISOString(),
264-
inserted_by: actor,
265-
};
266-
265+
const email = `${netId}@illinois.edu`;
267266
try {
268267
await dynamoClient.send(
269-
new PutItemCommand({
270-
TableName: genericConfig.MembershipTableName,
271-
Item: marshall(obj),
272-
ConditionExpression: "attribute_not_exists(email)",
268+
new UpdateItemCommand({
269+
TableName: genericConfig.UserInfoTable,
270+
Key: {
271+
id: {
272+
S: email,
273+
},
274+
},
275+
UpdateExpression: `SET #netId = :netId, #updatedAt = :updatedAt, #isPaidMember = :isPaidMember`,
276+
ConditionExpression: `#isPaidMember <> :isPaidMember`,
277+
ExpressionAttributeNames: {
278+
"#netId": "netId",
279+
"#updatedAt": "updatedAt",
280+
"#isPaidMember": "isPaidMember",
281+
},
282+
ExpressionAttributeValues: {
283+
":netId": { S: netId },
284+
":isPaidMember": { BOOL: true },
285+
":updatedAt": { S: new Date().toISOString() },
286+
},
273287
}),
274288
);
275289
return { updated: true };
@@ -302,11 +316,7 @@ export async function setPaidMembership({
302316
firstName,
303317
lastName,
304318
}: SetPaidMembershipInput): Promise<SetPaidMembershipOutput> {
305-
const dynamoResult = await setPaidMembershipInTable(
306-
netId,
307-
dynamoClient,
308-
"core-api-provisioned",
309-
);
319+
const dynamoResult = await setPaidMembershipInTable(netId, dynamoClient);
310320
if (!dynamoResult.updated) {
311321
const inEntra = await checkPaidMembershipFromEntra(
312322
netId,

src/api/routes/iam.ts

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@ import {
1717
EntraInvitationError,
1818
InternalServerError,
1919
NotFoundError,
20+
ValidationError,
2021
} from "../../common/errors/index.js";
21-
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
22+
import {
23+
DynamoDBClient,
24+
PutItemCommand,
25+
UpdateItemCommand,
26+
} from "@aws-sdk/client-dynamodb";
2227
import {
2328
GENERIC_CACHE_SECONDS,
2429
genericConfig,
@@ -96,19 +101,59 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
96101
message: "Could not find token payload and/or username.",
97102
});
98103
}
104+
const netId = request.username.replace("@illinois.edu", "");
105+
if (netId.includes("@")) {
106+
request.log.error(
107+
`Found username ${request.username} which cannot be turned into NetID via simple replacement.`,
108+
);
109+
throw new ValidationError({
110+
message: "Username could not be parsed.",
111+
});
112+
}
99113
const userOid = request.tokenPayload.oid;
100114
const entraIdToken = await getEntraIdToken({
101115
clients: await getAuthorizedClients(),
102116
clientId: fastify.environmentConfig.AadValidClientId,
103117
secretName: genericConfig.EntraSecretName,
104118
logger: request.log,
105119
});
106-
await patchUserProfile(
120+
const { discordUsername } = request.body;
121+
const ddbUpdateCommand = fastify.dynamoClient.send(
122+
new UpdateItemCommand({
123+
TableName: genericConfig.UserInfoTable,
124+
Key: {
125+
id: {
126+
S: request.username,
127+
},
128+
},
129+
UpdateExpression: `SET #netId = :netId, #updatedAt = :updatedAt, #firstName = :firstName, #lastName = :lastName ${discordUsername ? ", #discordUsername = :discordUsername" : ""}`,
130+
ExpressionAttributeNames: {
131+
"#netId": "netId",
132+
"#updatedAt": "updatedAt",
133+
"#firstName": "firstName",
134+
"#lastName": "lastName",
135+
...(discordUsername
136+
? { "#discordUsername": "discordUsername" }
137+
: {}),
138+
},
139+
ExpressionAttributeValues: {
140+
":netId": { S: netId },
141+
":firstName": { S: request.body.givenName },
142+
":lastName": { S: request.body.surname },
143+
":updatedAt": { S: new Date().toISOString() },
144+
...(discordUsername
145+
? { ":discordUsername": { S: discordUsername } }
146+
: {}),
147+
},
148+
}),
149+
);
150+
const entraUpdateCommand = patchUserProfile(
107151
entraIdToken,
108152
request.username,
109153
userOid,
110154
request.body,
111155
);
156+
await Promise.all([ddbUpdateCommand, entraUpdateCommand]);
112157
reply.status(201).send();
113158
},
114159
);
@@ -170,7 +215,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
170215
});
171216
const groupMembers = listGroupMembers(entraIdToken, groupId);
172217
const command = new PutItemCommand({
173-
TableName: `${genericConfig.IAMTablePrefix}-grouproles`,
218+
TableName: `${genericConfig.IAMTablePrefix} - grouproles`,
174219
Item: marshall({
175220
groupUuid: groupId,
176221
roles: request.body.roles,
@@ -190,7 +235,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
190235
await fastify.dynamoClient.send(command);
191236
await logPromise;
192237
fastify.nodeCache.set(
193-
`grouproles-${groupId}`,
238+
`grouproles - ${groupId}`,
194239
request.body.roles,
195240
GENERIC_CACHE_SECONDS,
196241
);
@@ -202,7 +247,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
202247
});
203248
reply.send({ message: "OK" });
204249
} catch (e: unknown) {
205-
fastify.nodeCache.del(`grouproles-${groupId}`);
250+
fastify.nodeCache.del(`grouproles - ${groupId}`);
206251
if (e instanceof BaseError) {
207252
throw e;
208253
}
@@ -462,7 +507,7 @@ const iamRoutes: FastifyPluginAsync = async (fastify, _options) => {
462507
content: `
463508
Hello,
464509
465-
We're letting you know that you have been added to the "${groupMetadata.displayName}" access group by ${request.username}. Changes may take up to 2 hours to reflect in all systems.
510+
We're letting you know that you have been added to the "${groupMetadata.displayName}" access group by ${request.username}. Changes may take up to 2 hours to reflect in all systems.
466511
467512
No action is required from you at this time.
468513
`,
@@ -484,7 +529,7 @@ No action is required from you at this time.
484529
content: `
485530
Hello,
486531
487-
We're letting you know that you have been removed from the "${groupMetadata.displayName}" access group by ${request.username}.
532+
We're letting you know that you have been removed from the "${groupMetadata.displayName}" access group by ${request.username}.
488533
489534
No action is required from you at this time.
490535
`,

src/api/routes/v2/membership.ts

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -297,30 +297,34 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
297297
const batch = netIdsToCheck.slice(i, i + BATCH_SIZE);
298298
const command = new BatchGetItemCommand({
299299
RequestItems: {
300-
[genericConfig.MembershipTableName]: {
300+
[genericConfig.UserInfoTable]: {
301301
Keys: batch.map((netId) =>
302-
marshall({ email: `${netId}@illinois.edu` }),
302+
marshall({ id: `${netId}@illinois.edu` }),
303303
),
304+
AttributesToGet: ["id", "isPaidMember"],
304305
},
305306
},
306307
});
308+
307309
const { Responses } = await fastify.dynamoClient.send(command);
308-
const items = Responses?.[genericConfig.MembershipTableName] ?? [];
310+
const items = Responses?.[genericConfig.UserInfoTable] ?? [];
309311
for (const item of items) {
310-
const { email } = unmarshall(item);
311-
const netId = email.split("@")[0];
312-
members.add(netId);
312+
const { id, isPaidMember } = unmarshall(item);
313+
console.log(id, isPaidMember);
314+
const netId = id.split("@")[0];
313315
foundInDynamo.add(netId);
314-
cachePipeline.set(
315-
`membership:${netId}:${list}`,
316-
JSON.stringify({ isMember: true }),
317-
"EX",
318-
MEMBER_CACHE_SECONDS,
319-
);
316+
if (isPaidMember === true) {
317+
members.add(netId);
318+
cachePipeline.set(
319+
`membership:${netId}:${list}`,
320+
JSON.stringify({ isMember: true }),
321+
"EX",
322+
MEMBER_CACHE_SECONDS,
323+
);
324+
}
320325
}
321326
}
322327

323-
// 3. Fallback to Entra ID for remaining paid members
324328
const netIdsForEntra = netIdsToCheck.filter(
325329
(id) => !foundInDynamo.has(id),
326330
);
@@ -340,7 +344,6 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
340344
);
341345
if (isMember) {
342346
members.add(netId);
343-
// Fire-and-forget writeback to DynamoDB to warm it up
344347
setPaidMembershipInTable(netId, fastify.dynamoClient).catch(
345348
(err) =>
346349
request.log.error(

src/common/config.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ export type GenericConfigType = {
4141
MerchStorePurchasesTableName: string;
4242
TicketPurchasesTableName: string;
4343
TicketMetadataTableName: string;
44-
MembershipTableName: string;
4544
ExternalMembershipTableName: string;
4645
MerchStoreMetadataTableName: string;
4746
IAMTablePrefix: string;
@@ -86,7 +85,6 @@ const genericConfig: GenericConfigType = {
8685
TicketMetadataTableName: "infra-events-ticketing-metadata",
8786
IAMTablePrefix: "infra-core-api-iam",
8887
ProtectedEntraIDGroups: [infraChairsGroupId, officersGroupId],
89-
MembershipTableName: "infra-core-api-membership-provisioning",
9088
ExternalMembershipTableName: "infra-core-api-membership-external-v3",
9189
RoomRequestsTableName: "infra-core-api-room-requests",
9290
RoomRequestsStatusTableName: "infra-core-api-room-requests-status",

src/common/types/iam.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ export const entraProfilePatchRequest = z.object({
7979
displayName: z.string().min(1),
8080
givenName: z.string().min(1),
8181
surname: z.string().min(1),
82-
mail: z.email()
82+
mail: z.email(),
83+
discordUsername: z.optional(z.string().min(1))
8384
});
8485

8586
export type ProfilePatchRequest = z.infer<typeof entraProfilePatchRequest>;

terraform/modules/dynamo/main.tf

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
resource "null_resource" "onetime_paid_migration" {
2+
depends_on = [aws_dynamodb_table.user_info]
3+
provisioner "local-exec" {
4+
command = <<-EOT
5+
set -e
6+
python paidMember.py
7+
EOT
8+
interpreter = ["bash", "-c"]
9+
working_dir = "${path.module}/../../../onetime/"
10+
}
11+
}
12+
113
resource "aws_dynamodb_table" "app_audit_log" {
214
billing_mode = "PAY_PER_REQUEST"
315
name = "${var.ProjectId}-audit-log"
@@ -26,7 +38,7 @@ resource "aws_dynamodb_table" "app_audit_log" {
2638
resource "aws_dynamodb_table" "membership_provisioning_log" {
2739
billing_mode = "PAY_PER_REQUEST"
2840
name = "${var.ProjectId}-membership-provisioning"
29-
deletion_protection_enabled = true
41+
deletion_protection_enabled = false
3042
hash_key = "email"
3143
point_in_time_recovery {
3244
enabled = true

0 commit comments

Comments
 (0)