Skip to content

Commit e5060bf

Browse files
authored
Improve member check and IAM (#293)
- Remove dependence on Entra ID for membership checks - Get user and group roles from the same table
1 parent 8097daa commit e5060bf

File tree

9 files changed

+33
-158
lines changed

9 files changed

+33
-158
lines changed

src/api/functions/authorization.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ export async function getUserRoles(
1212
dynamoClient: DynamoDBClient,
1313
userId: string,
1414
): Promise<AppRoles[]> {
15-
const tableName = `${genericConfig.IAMTablePrefix}-userroles`;
15+
const tableName = `${genericConfig.IAMTablePrefix}-assignments`;
1616
const command = new GetItemCommand({
1717
TableName: tableName,
1818
Key: {
19-
userEmail: { S: userId },
19+
id: { S: `USER#${userId}` },
2020
},
2121
});
2222
const response = await dynamoClient.send(command);
@@ -42,11 +42,11 @@ export async function getGroupRoles(
4242
dynamoClient: DynamoDBClient,
4343
groupId: string,
4444
) {
45-
const tableName = `${genericConfig.IAMTablePrefix}-grouproles`;
45+
const tableName = `${genericConfig.IAMTablePrefix}-assignments`;
4646
const command = new GetItemCommand({
4747
TableName: tableName,
4848
Key: {
49-
groupUuid: { S: groupId },
49+
id: { S: `GROUP#${groupId}` },
5050
},
5151
});
5252
const response = await dynamoClient.send(command);

src/api/functions/membership.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import {
22
BatchWriteItemCommand,
33
ConditionalCheckFailedException,
44
DynamoDBClient,
5-
PutItemCommand,
65
QueryCommand,
76
UpdateItemCommand,
87
} from "@aws-sdk/client-dynamodb";
@@ -239,7 +238,10 @@ export async function checkPaidMembershipFromTable(
239238
return true;
240239
}
241240

242-
export async function checkPaidMembershipFromEntra(
241+
/**
242+
* This check is slow!! Don't use it unless you have a really good reason.
243+
* Membership data is replicated to DynamoDB on provision (and EntraID second) so just read from the user-info table. */
244+
async function checkPaidMembershipFromEntra(
243245
netId: string,
244246
entraToken: string,
245247
paidMemberGroup: string,

src/api/routes/membership.ts

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {
22
checkExternalMembership,
3-
checkPaidMembershipFromEntra,
43
checkPaidMembershipFromTable,
54
setPaidMembershipInTable,
65
MEMBER_CACHE_SECONDS,
@@ -188,41 +187,8 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => {
188187
.header("X-ACM-Data-Source", "dynamo")
189188
.send({ givenName, surname, netId, isPaidMember: true });
190189
}
191-
const entraIdToken = await getEntraIdToken({
192-
clients: await getAuthorizedClients(),
193-
clientId: fastify.environmentConfig.AadValidClientId,
194-
secretName: genericConfig.EntraSecretName,
195-
logger: request.log,
196-
});
197-
const paidMemberGroup = fastify.environmentConfig.PaidMemberGroupId;
198-
const isAadMember = await checkPaidMembershipFromEntra(
199-
netId,
200-
entraIdToken,
201-
paidMemberGroup,
202-
);
203-
if (isAadMember) {
204-
await setKey({
205-
redisClient: fastify.redisClient,
206-
key: cacheKey,
207-
data: JSON.stringify({ isMember: true }),
208-
expiresIn: MEMBER_CACHE_SECONDS,
209-
logger: request.log,
210-
});
211-
reply
212-
.header("X-ACM-Data-Source", "aad")
213-
.send({ givenName, surname, netId, isPaidMember: true });
214-
await setPaidMembershipInTable(netId, fastify.dynamoClient);
215-
return;
216-
}
217-
await setKey({
218-
redisClient: fastify.redisClient,
219-
key: cacheKey,
220-
data: JSON.stringify({ isMember: false }),
221-
expiresIn: MEMBER_CACHE_SECONDS,
222-
logger: request.log,
223-
});
224190
return reply
225-
.header("X-ACM-Data-Source", "aad")
191+
.header("X-ACM-Data-Source", "dynamo")
226192
.send({ givenName, surname, netId, isPaidMember: false });
227193
},
228194
);

src/api/routes/v2/membership.ts

Lines changed: 2 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
checkPaidMembershipFromRedis,
44
checkExternalMembership,
55
MEMBER_CACHE_SECONDS,
6-
checkPaidMembershipFromEntra,
76
setPaidMembershipInTable,
87
} from "api/functions/membership.js";
98
import { FastifyPluginAsync } from "fastify";
@@ -321,47 +320,10 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
321320
"EX",
322321
MEMBER_CACHE_SECONDS,
323322
);
324-
}
325-
}
326-
}
327-
328-
const netIdsForEntra = netIdsToCheck.filter(
329-
(id) => !foundInDynamo.has(id),
330-
);
331-
if (netIdsForEntra.length > 0) {
332-
const entraIdToken = await getEntraIdToken({
333-
clients: await getAuthorizedClients(),
334-
clientId: fastify.environmentConfig.AadValidClientId,
335-
secretName: genericConfig.EntraSecretName,
336-
logger: request.log,
337-
});
338-
const paidMemberGroup = fastify.environmentConfig.PaidMemberGroupId;
339-
const entraCheckPromises = netIdsForEntra.map(async (netId) => {
340-
const isMember = await checkPaidMembershipFromEntra(
341-
netId,
342-
entraIdToken,
343-
paidMemberGroup,
344-
);
345-
if (isMember) {
346-
members.add(netId);
347-
setPaidMembershipInTable(netId, fastify.dynamoClient).catch(
348-
(err) =>
349-
request.log.error(
350-
err,
351-
`Failed to write back Entra membership for ${netId}`,
352-
),
353-
);
354323
} else {
355324
notMembers.add(netId);
356325
}
357-
cachePipeline.set(
358-
`membership:${netId}:${list}`,
359-
JSON.stringify({ isMember }),
360-
"EX",
361-
MEMBER_CACHE_SECONDS,
362-
);
363-
});
364-
await Promise.all(entraCheckPromises);
326+
}
365327
}
366328
}
367329

@@ -491,32 +453,6 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
491453
.header("X-ACM-Data-Source", "dynamo")
492454
.send({ netId, isPaidMember: true });
493455
}
494-
const entraIdToken = await getEntraIdToken({
495-
clients: await getAuthorizedClients(),
496-
clientId: fastify.environmentConfig.AadValidClientId,
497-
secretName: genericConfig.EntraSecretName,
498-
logger: request.log,
499-
});
500-
const paidMemberGroup = fastify.environmentConfig.PaidMemberGroupId;
501-
const isAadMember = await checkPaidMembershipFromEntra(
502-
netId,
503-
entraIdToken,
504-
paidMemberGroup,
505-
);
506-
if (isAadMember) {
507-
await setKey({
508-
redisClient: fastify.redisClient,
509-
key: cacheKey,
510-
data: JSON.stringify({ isMember: true }),
511-
expiresIn: MEMBER_CACHE_SECONDS,
512-
logger: request.log,
513-
});
514-
reply
515-
.header("X-ACM-Data-Source", "aad")
516-
.send({ netId, isPaidMember: true });
517-
await setPaidMembershipInTable(netId, fastify.dynamoClient);
518-
return;
519-
}
520456
await setKey({
521457
redisClient: fastify.redisClient,
522458
key: cacheKey,
@@ -525,7 +461,7 @@ const membershipV2Plugin: FastifyPluginAsync = async (fastify, _options) => {
525461
logger: request.log,
526462
});
527463
return reply
528-
.header("X-ACM-Data-Source", "aad")
464+
.header("X-ACM-Data-Source", "dynamo")
529465
.send({ netId, isPaidMember: false });
530466
},
531467
);

terraform/modules/dynamo/main.tf

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,26 @@ resource "aws_dynamodb_table" "external_membership" {
159159

160160
}
161161

162+
163+
resource "aws_dynamodb_table" "iam_assignments" {
164+
billing_mode = "PAY_PER_REQUEST"
165+
name = "${var.ProjectId}-iam-assignments"
166+
deletion_protection_enabled = true
167+
hash_key = "id"
168+
point_in_time_recovery {
169+
enabled = true
170+
}
171+
attribute {
172+
name = "id"
173+
type = "S"
174+
}
175+
}
176+
177+
162178
resource "aws_dynamodb_table" "iam_group_roles" {
163179
billing_mode = "PAY_PER_REQUEST"
164180
name = "${var.ProjectId}-iam-grouproles"
165-
deletion_protection_enabled = true
181+
deletion_protection_enabled = false
166182
hash_key = "groupUuid"
167183
point_in_time_recovery {
168184
enabled = true
@@ -176,7 +192,7 @@ resource "aws_dynamodb_table" "iam_group_roles" {
176192
resource "aws_dynamodb_table" "iam_user_roles" {
177193
billing_mode = "PAY_PER_REQUEST"
178194
name = "${var.ProjectId}-iam-userroles"
179-
deletion_protection_enabled = true
195+
deletion_protection_enabled = false
180196
hash_key = "userEmail"
181197
point_in_time_recovery {
182198
enabled = true

terraform/modules/lambdas/main.tf

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,8 +221,7 @@ resource "aws_iam_policy" "shared_iam_policy" {
221221
"arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-events-tickets",
222222
"arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-events-ticketing-metadata",
223223
"arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-merchstore-metadata",
224-
"arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-iam-userroles",
225-
"arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-iam-grouproles",
224+
"arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-iam-assignments",
226225
"arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-stripe-links",
227226
"arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-stripe-links/index/*",
228227
"arn:aws:dynamodb:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:table/infra-core-api-membership-external-v3",

tests/live/membership.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ describe("Membership API basic checks", async () => {
5555
isPaidMember: true,
5656
});
5757

58-
const wasCached = (value: string | null) => value && value !== "aad";
58+
const wasCached = (value: string | null) => value && value !== "dynamo";
5959

6060
expect(wasCached(response.headers.get("x-acm-data-source"))).toBe(true);
6161
},

tests/unit/membership.test.ts

Lines changed: 2 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ describe("Test membership routes", async () => {
4646
expect(response.statusCode).toBe(200);
4747
const responseDataJson = (await response.json()) as EventGetResponse;
4848
expect(response.headers).toHaveProperty("x-acm-data-source");
49-
expect(response.headers["x-acm-data-source"]).toEqual("aad");
49+
expect(response.headers["x-acm-data-source"]).toEqual("dynamo");
5050
expect(responseDataJson).toEqual({
5151
givenName: "Infra",
5252
surname: "Testing",
@@ -87,42 +87,10 @@ describe("Test membership routes", async () => {
8787
expect(response.statusCode).toBe(200);
8888
const responseDataJson = (await response.json()) as EventGetResponse;
8989
expect(response.headers).toHaveProperty("x-acm-data-source");
90-
expect(response.headers["x-acm-data-source"]).toEqual("aad");
90+
expect(response.headers["x-acm-data-source"]).toEqual("dynamo");
9191
expect(responseDataJson).toEqual({ netId: "invalid", isPaidMember: false });
9292
});
9393

94-
test("Entra-only members are added to Dynamo", async () => {
95-
const testJwt = createJwt();
96-
let response = await app.inject({
97-
method: "GET",
98-
url: "/api/v2/membership/eadon2",
99-
headers: {
100-
authorization: `Bearer ${testJwt}`,
101-
},
102-
});
103-
104-
expect(response.statusCode).toBe(200);
105-
let responseDataJson = (await response.json()) as EventGetResponse;
106-
expect(response.headers).toHaveProperty("x-acm-data-source");
107-
expect(response.headers["x-acm-data-source"]).toEqual("aad");
108-
expect(responseDataJson).toEqual({ netId: "eadon2", isPaidMember: true });
109-
expect(spySetPaidMembership).toHaveBeenCalledWith(
110-
"eadon2",
111-
expect.any(Object),
112-
);
113-
response = await app.inject({
114-
method: "GET",
115-
url: "/api/v2/membership/eadon2",
116-
headers: {
117-
authorization: `Bearer ${testJwt}`,
118-
},
119-
});
120-
expect(response.statusCode).toBe(200);
121-
responseDataJson = (await response.json()) as EventGetResponse;
122-
expect(response.headers).toHaveProperty("x-acm-data-source");
123-
expect(response.headers["x-acm-data-source"]).toEqual("cache");
124-
expect(responseDataJson).toEqual({ netId: "eadon2", isPaidMember: true });
125-
});
12694
test("External list members are correctly found", async () => {
12795
ddbMock.on(QueryCommand).callsFake((command) => {
12896
if (

tests/unit/vitest.setup.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -132,18 +132,6 @@ vi.mock(
132132
return false;
133133
}
134134
}),
135-
checkPaidMembershipFromEntra: vi.fn(
136-
async (netId, _entraToken, _paidMemberGroup) => {
137-
switch (netId) {
138-
case "valid":
139-
return true;
140-
case "eadon2":
141-
return true;
142-
default:
143-
return false;
144-
}
145-
},
146-
),
147135
};
148136
},
149137
);

0 commit comments

Comments
 (0)