Skip to content

Commit ea4bc5f

Browse files
authored
Create a new generic user info table (#291)
Save all known user info to this table, including uinHash, eventually Discord, etc.
1 parent a0f3106 commit ea4bc5f

File tree

8 files changed

+211
-21
lines changed

8 files changed

+211
-21
lines changed

onetime/uinHash-migration.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import json
2+
import boto3
3+
import logging
4+
from botocore.exceptions import ClientError
5+
6+
# --- Configuration ---
7+
SOURCE_TABLE_NAME = "infra-core-api-uin-mapping"
8+
DESTINATION_TABLE_NAME = "infra-core-api-user-info"
9+
DESTINATION_ID_SUFFIX = "@illinois.edu"
10+
11+
# --- Logging Setup ---
12+
logging.basicConfig(
13+
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
14+
)
15+
16+
17+
def migrate_uin_hashes():
18+
"""
19+
Scans the source table for netId and uinHash mappings and updates
20+
the corresponding user records in the destination table.
21+
"""
22+
try:
23+
dynamodb = boto3.resource("dynamodb")
24+
destination_table = dynamodb.Table(DESTINATION_TABLE_NAME)
25+
26+
# A paginator is used to handle scanning tables of any size
27+
paginator = dynamodb.meta.client.get_paginator("scan")
28+
page_iterator = paginator.paginate(TableName=SOURCE_TABLE_NAME)
29+
30+
scanned_count = 0
31+
updated_count = 0
32+
logging.info(
33+
f"Starting migration from '{SOURCE_TABLE_NAME}' to '{DESTINATION_TABLE_NAME}'"
34+
)
35+
36+
for page in page_iterator:
37+
for item in page.get("Items", []):
38+
scanned_count += 1
39+
net_id = item.get("netId")
40+
uin_hash = item.get("uinHash")
41+
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+
)
47+
continue
48+
49+
# 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}
53+
54+
# Update the item in the destination DynamoDB table
55+
try:
56+
destination_table.update_item(
57+
Key={"id": destination_pk_id},
58+
UpdateExpression=update_expression,
59+
ExpressionAttributeValues=expression_attribute_values,
60+
)
61+
updated_count += 1
62+
if updated_count % 100 == 0:
63+
logging.info(
64+
f"Scanned {scanned_count} items, updated {updated_count} so far..."
65+
)
66+
except ClientError as e:
67+
logging.error(
68+
f"Failed to update item with id '{destination_pk_id}': {e}"
69+
)
70+
71+
logging.info("--- Script Finished ---")
72+
logging.info(f"Total items scanned from source: {scanned_count}")
73+
logging.info(f"Total items updated in destination: {updated_count}")
74+
75+
except ClientError as e:
76+
# This will catch errors like table not found, or credential issues
77+
logging.critical(f"A critical AWS error occurred: {e}")
78+
except Exception as e:
79+
logging.critical(f"An unexpected error occurred: {e}")
80+
81+
82+
if __name__ == "__main__":
83+
migrate_uin_hashes()

src/api/functions/sync.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {
2+
UpdateItemCommand,
3+
type DynamoDBClient,
4+
} from "@aws-sdk/client-dynamodb";
5+
import { genericConfig } from "common/config.js";
6+
7+
export interface SyncFullProfileInputs {
8+
uinHash: string;
9+
netId: string;
10+
firstName: string;
11+
lastName: string;
12+
dynamoClient: DynamoDBClient;
13+
}
14+
15+
export async function syncFullProfile({
16+
uinHash,
17+
netId,
18+
firstName,
19+
lastName,
20+
dynamoClient,
21+
}: SyncFullProfileInputs) {
22+
return dynamoClient.send(
23+
new UpdateItemCommand({
24+
TableName: genericConfig.UserInfoTable,
25+
Key: {
26+
id: { S: `${netId}@illinois.edu` },
27+
},
28+
UpdateExpression:
29+
"SET #uinHash = :uinHash, #netId = :netId, #updatedAt = :updatedAt, #firstName = :firstName, #lastName = :lastName",
30+
ExpressionAttributeNames: {
31+
"#uinHash": "uinHash",
32+
"#netId": "netId",
33+
"#updatedAt": "updatedAt",
34+
"#firstName": "firstName",
35+
"#lastName": "lastName",
36+
},
37+
ExpressionAttributeValues: {
38+
":uinHash": { S: uinHash },
39+
":netId": { S: netId },
40+
":firstName": { S: firstName },
41+
":lastName": { S: lastName },
42+
":updatedAt": { S: new Date().toISOString() },
43+
},
44+
}),
45+
);
46+
}

src/api/functions/uin.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
1+
import {
2+
DynamoDBClient,
3+
PutItemCommand,
4+
UpdateItemCommand,
5+
} from "@aws-sdk/client-dynamodb";
26
import { marshall } from "@aws-sdk/util-dynamodb";
37
import { argon2id, hash } from "argon2";
48
import { genericConfig } from "common/config.js";
@@ -160,13 +164,23 @@ export async function saveHashedUserUin({
160164
}: SaveHashedUserUin) {
161165
const uinHash = await getHashedUserUin({ uiucAccessToken, pepper });
162166
await dynamoClient.send(
163-
new PutItemCommand({
164-
TableName: genericConfig.UinHashTable,
165-
Item: marshall({
166-
uinHash,
167-
netId,
168-
updatedAt: new Date().toISOString(),
169-
}),
167+
new UpdateItemCommand({
168+
TableName: genericConfig.UserInfoTable,
169+
Key: {
170+
id: { S: `${netId}@illinois.edu` },
171+
},
172+
UpdateExpression:
173+
"SET #uinHash = :uinHash, #netId = :netId, #updatedAt = :updatedAt",
174+
ExpressionAttributeNames: {
175+
"#uinHash": "uinHash",
176+
"#netId": "netId",
177+
"#updatedAt": "updatedAt",
178+
},
179+
ExpressionAttributeValues: {
180+
":uinHash": { S: uinHash },
181+
":netId": { S: netId },
182+
":updatedAt": { S: new Date().toISOString() },
183+
},
170184
}),
171185
);
172186
}

src/api/routes/syncIdentity.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import rateLimiter from "api/plugins/rateLimiter.js";
88
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
99
import * as z from "zod/v4";
1010
import { notAuthenticatedError, withTags } from "api/components/index.js";
11-
import { verifyUiucAccessToken, saveHashedUserUin } from "api/functions/uin.js";
11+
import { verifyUiucAccessToken, getHashedUserUin } from "api/functions/uin.js";
1212
import { getRoleCredentials } from "api/functions/sts.js";
1313
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
1414
import { genericConfig, roleArns } from "common/config.js";
@@ -18,6 +18,7 @@ import {
1818
patchUserProfile,
1919
resolveEmailToOid,
2020
} from "api/functions/entraId.js";
21+
import { syncFullProfile } from "api/functions/sync.js";
2122

2223
const syncIdentityPlugin: FastifyPluginAsync = async (fastify, _options) => {
2324
const getAuthorizedClients = async () => {
@@ -98,11 +99,16 @@ const syncIdentityPlugin: FastifyPluginAsync = async (fastify, _options) => {
9899
message: "ID token could not be parsed.",
99100
});
100101
}
101-
await saveHashedUserUin({
102+
const uinHash = await getHashedUserUin({
102103
uiucAccessToken: accessToken,
103104
pepper: fastify.secretConfig.UIN_HASHING_SECRET_PEPPER,
104-
dynamoClient: fastify.dynamoClient,
105+
});
106+
await syncFullProfile({
107+
uinHash,
108+
firstName: givenName,
109+
lastName: surname,
105110
netId,
111+
dynamoClient: fastify.dynamoClient,
106112
});
107113
let isPaidMember = await checkPaidMembershipFromRedis(
108114
netId,

src/api/routes/v2/membership.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
withRoles,
2323
withTags,
2424
} from "api/components/index.js";
25-
import { verifyUiucAccessToken, saveHashedUserUin } from "api/functions/uin.js";
25+
import { verifyUiucAccessToken, getHashedUserUin } from "api/functions/uin.js";
2626
import { getKey, setKey } from "api/functions/redisCache.js";
2727
import { getEntraIdToken } from "api/functions/entraId.js";
2828
import { genericConfig, roleArns } from "common/config.js";
@@ -31,6 +31,7 @@ import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
3131
import { BatchGetItemCommand, DynamoDBClient } from "@aws-sdk/client-dynamodb";
3232
import { AppRoles } from "common/roles.js";
3333
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
34+
import { syncFullProfile } from "api/functions/sync.js";
3435

3536
const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
3637
const getAuthorizedClients = async () => {
@@ -115,11 +116,16 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
115116
});
116117
}
117118
request.log.debug("Saving user hashed UIN!");
118-
const saveHashPromise = saveHashedUserUin({
119+
const uinHash = await getHashedUserUin({
119120
uiucAccessToken: accessToken,
120121
pepper: fastify.secretConfig.UIN_HASHING_SECRET_PEPPER,
121-
dynamoClient: fastify.dynamoClient,
122+
});
123+
const savePromise = syncFullProfile({
124+
uinHash,
125+
firstName: givenName,
126+
lastName: surname,
122127
netId,
128+
dynamoClient: fastify.dynamoClient,
123129
});
124130
let isPaidMember = await checkPaidMembershipFromRedis(
125131
netId,
@@ -132,7 +138,7 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
132138
fastify.dynamoClient,
133139
);
134140
}
135-
await saveHashPromise;
141+
await savePromise;
136142
request.log.debug("Saved user hashed UIN!");
137143
if (isPaidMember) {
138144
throw new ValidationError({

src/common/config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export type GenericConfigType = {
5555
TestingCredentialsSecret: string;
5656
UinHashingSecret: string;
5757
UinExtendedAttributeName: string;
58-
UinHashTable: string;
58+
UserInfoTable: string;
5959
};
6060

6161
type EnvironmentConfigType = {
@@ -96,7 +96,7 @@ const genericConfig: GenericConfigType = {
9696
TestingCredentialsSecret: "infra-core-api-testing-credentials",
9797
UinHashingSecret: "infra-core-api-uin-pepper",
9898
UinExtendedAttributeName: "extension_a70c2e1556954056a6a8edfb1f42f556_uiucEduUIN",
99-
UinHashTable: "infra-core-api-uin-mapping",
99+
UserInfoTable: "infra-core-api-user-info"
100100
} as const;
101101

102102
const environmentConfig: EnvironmentConfigType = {

terraform/modules/dynamo/main.tf

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
resource "null_resource" "onetime_uin_migration" {
2+
depends_on = [aws_dynamodb_table.user_info]
3+
provisioner "local-exec" {
4+
command = <<-EOT
5+
set -e
6+
python uinHash-migration.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"
@@ -190,6 +202,28 @@ resource "aws_dynamodb_table" "iam_user_roles" {
190202
}
191203
}
192204

205+
resource "aws_dynamodb_table" "user_info" {
206+
billing_mode = "PAY_PER_REQUEST"
207+
name = "${var.ProjectId}-user-info"
208+
deletion_protection_enabled = true
209+
hash_key = "id"
210+
point_in_time_recovery {
211+
enabled = true
212+
}
213+
attribute {
214+
name = "id"
215+
type = "S"
216+
}
217+
attribute {
218+
name = "uinHash"
219+
type = "S"
220+
}
221+
global_secondary_index {
222+
name = "UinHashIndex"
223+
hash_key = "uinHash"
224+
projection_type = "KEYS_ONLY"
225+
}
226+
}
193227

194228
resource "aws_dynamodb_table" "events" {
195229
billing_mode = "PAY_PER_REQUEST"
@@ -298,7 +332,7 @@ resource "aws_dynamodb_table" "cache" {
298332
resource "aws_dynamodb_table" "app_uin_records" {
299333
billing_mode = "PAY_PER_REQUEST"
300334
name = "${var.ProjectId}-uin-mapping"
301-
deletion_protection_enabled = true
335+
deletion_protection_enabled = false
302336
hash_key = "uinHash"
303337
point_in_time_recovery {
304338
enabled = true

terraform/modules/lambdas/main.tf

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,16 +268,17 @@ resource "aws_iam_policy" "shared_iam_policy" {
268268
]
269269
},
270270
{
271-
Sid = "DynamoDBUINAccess",
271+
Sid = "DynamoDBUserInfoAccess",
272272
Effect = "Allow",
273273
Action = [
274274
"dynamodb:PutItem",
275+
"dynamodb:UpdateItem",
275276
"dynamodb:DescribeTable",
276277
"dynamodb:Query",
277278
],
278279
Resource = [
279-
"arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-uin-mapping",
280-
"arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-uin-mapping/index/*",
280+
"arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-user-info",
281+
"arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-user-info/index/*",
281282
]
282283
},
283284
{

0 commit comments

Comments
 (0)