Skip to content

Commit b515f0d

Browse files
External Membership (#215)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent a35d106 commit b515f0d

File tree

19 files changed

+1369
-74
lines changed

19 files changed

+1369
-74
lines changed

cloudformation/iam.yml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,7 @@ Resources:
7474
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-iam-grouproles
7575
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-stripe-links
7676
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-membership-provisioning
77-
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-membership-external
78-
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-membership-external-v2
77+
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-membership-external-v3
7978
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-room-requests
8079
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-room-requests-status
8180
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-linkry
@@ -87,6 +86,8 @@ Resources:
8786
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-room-requests/index/*
8887
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-room-requests-status/index/*
8988
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-linkry/index/*
89+
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-membership-provisioning/index/*
90+
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-membership-external-v3/index/*
9091

9192
- Sid: DynamoDBCacheAccess
9293
Effect: Allow

cloudformation/main.yml

Lines changed: 6 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -398,23 +398,6 @@ Resources:
398398
AttributeName: expiresAt
399399
Enabled: true
400400

401-
ExternalMembershipRecordsTable:
402-
Type: "AWS::DynamoDB::Table"
403-
DeletionPolicy: "Retain"
404-
UpdateReplacePolicy: "Retain"
405-
Properties:
406-
BillingMode: "PAY_PER_REQUEST"
407-
TableName: infra-core-api-membership-external
408-
DeletionProtectionEnabled: true
409-
PointInTimeRecoverySpecification:
410-
PointInTimeRecoveryEnabled: !If [IsProd, true, false]
411-
AttributeDefinitions:
412-
- AttributeName: netid_list
413-
AttributeType: S
414-
KeySchema:
415-
- AttributeName: netid_list
416-
KeyType: HASH
417-
418401
ExternalMembershipV3RecordsTable:
419402
Type: "AWS::DynamoDB::Table"
420403
DeletionPolicy: "Retain"
@@ -444,6 +427,12 @@ Resources:
444427
KeyType: "RANGE"
445428
Projection:
446429
ProjectionType: "KEYS_ONLY"
430+
- IndexName: "keysOnlyIndex"
431+
KeySchema:
432+
- AttributeName: "memberList"
433+
KeyType: "HASH"
434+
Projection:
435+
ProjectionType: "KEYS_ONLY"
447436

448437

449438
RoomRequestsTable:

generate_jwt.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
GetSecretValueCommand,
55
} from "@aws-sdk/client-secrets-manager";
66
import { STSClient, GetCallerIdentityCommand } from "@aws-sdk/client-sts";
7+
import { randomUUID } from "crypto";
78

89
export const getSecretValue = async (secretId) => {
910
const smClient = new SecretsManagerClient();
@@ -56,7 +57,7 @@ const payload = {
5657
sub: "subject",
5758
tid: "tenant-id",
5859
unique_name: username,
59-
uti: "uti-value",
60+
uti: randomUUID(),
6061
ver: "1.0",
6162
};
6263

src/api/functions/membership.ts

Lines changed: 156 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import {
2+
BatchWriteItemCommand,
23
ConditionalCheckFailedException,
34
DynamoDBClient,
45
PutItemCommand,
56
QueryCommand,
67
} from "@aws-sdk/client-dynamodb";
7-
import { marshall } from "@aws-sdk/util-dynamodb";
8+
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
89
import { genericConfig } from "common/config.js";
910
import {
1011
addToTenant,
@@ -13,15 +14,161 @@ import {
1314
patchUserProfile,
1415
resolveEmailToOid,
1516
} from "./entraId.js";
16-
import { EntraGroupError } from "common/errors/index.js";
17+
import { EntraGroupError, ValidationError } from "common/errors/index.js";
1718
import { EntraGroupActions } from "common/types/iam.js";
1819
import { pollUntilNoError } from "./general.js";
1920
import Redis from "ioredis";
20-
import { getKey } from "./redisCache.js";
21+
import { getKey, setKey } from "./redisCache.js";
2122
import { FastifyBaseLogger } from "fastify";
23+
import type pino from "pino";
24+
import { createAuditLogEntry } from "./auditLog.js";
25+
import { Modules } from "common/modules.js";
2226

2327
export const MEMBER_CACHE_SECONDS = 43200; // 12 hours
2428

29+
export async function patchExternalMemberList({
30+
listId: oldListId,
31+
add: oldAdd,
32+
remove: oldRemove,
33+
clients: { dynamoClient, redisClient },
34+
logger,
35+
auditLogData: { actor, requestId },
36+
}: {
37+
listId: string;
38+
add: string[];
39+
remove: string[];
40+
clients: { dynamoClient: DynamoDBClient; redisClient: Redis.default };
41+
logger: pino.Logger | FastifyBaseLogger;
42+
auditLogData: { actor: string; requestId: string };
43+
}) {
44+
const listId = oldListId.toLowerCase();
45+
const add = oldAdd.map((x) => x.toLowerCase());
46+
const remove = oldRemove.map((x) => x.toLowerCase());
47+
if (add.length === 0 && remove.length === 0) {
48+
return;
49+
}
50+
const addSet = new Set(add);
51+
52+
const conflictingNetId = remove.find((netId) => addSet.has(netId));
53+
54+
if (conflictingNetId) {
55+
throw new ValidationError({
56+
message: `The netId '${conflictingNetId}' cannot be in both the 'add' and 'remove' lists simultaneously.`,
57+
});
58+
}
59+
const writeRequests = [];
60+
// Create PutRequest objects for each member to be added.
61+
for (const netId of add) {
62+
writeRequests.push({
63+
PutRequest: {
64+
Item: {
65+
memberList: { S: listId },
66+
netId: { S: netId },
67+
},
68+
},
69+
});
70+
}
71+
// Create DeleteRequest objects for each member to be removed.
72+
for (const netId of remove) {
73+
writeRequests.push({
74+
DeleteRequest: {
75+
Key: {
76+
memberList: { S: listId },
77+
netId: { S: netId },
78+
},
79+
},
80+
});
81+
}
82+
const BATCH_SIZE = 25;
83+
const batchPromises = [];
84+
for (let i = 0; i < writeRequests.length; i += BATCH_SIZE) {
85+
const batch = writeRequests.slice(i, i + BATCH_SIZE);
86+
const command = new BatchWriteItemCommand({
87+
RequestItems: {
88+
[genericConfig.ExternalMembershipTableName]: batch,
89+
},
90+
});
91+
batchPromises.push(dynamoClient.send(command));
92+
}
93+
const removeCacheInvalidation = remove.map((x) =>
94+
setKey({
95+
redisClient,
96+
key: `membership:${x}:${listId}`,
97+
data: JSON.stringify({ isMember: false }),
98+
expiresIn: MEMBER_CACHE_SECONDS,
99+
logger,
100+
}),
101+
);
102+
const addCacheInvalidation = add.map((x) =>
103+
setKey({
104+
redisClient,
105+
key: `membership:${x}:${listId}`,
106+
data: JSON.stringify({ isMember: true }),
107+
expiresIn: MEMBER_CACHE_SECONDS,
108+
logger,
109+
}),
110+
);
111+
const auditLogPromises = [];
112+
if (add.length > 0) {
113+
auditLogPromises.push(
114+
createAuditLogEntry({
115+
dynamoClient,
116+
entry: {
117+
module: Modules.EXTERNAL_MEMBERSHIP,
118+
actor,
119+
requestId,
120+
message: `Added ${add.length} member(s) to target list.`,
121+
target: listId,
122+
},
123+
}),
124+
);
125+
}
126+
if (remove.length > 0) {
127+
auditLogPromises.push(
128+
createAuditLogEntry({
129+
dynamoClient,
130+
entry: {
131+
module: Modules.EXTERNAL_MEMBERSHIP,
132+
actor,
133+
requestId,
134+
message: `Removed ${remove.length} member(s) from target list.`,
135+
target: listId,
136+
},
137+
}),
138+
);
139+
}
140+
await Promise.all([
141+
...removeCacheInvalidation,
142+
...addCacheInvalidation,
143+
...batchPromises,
144+
]);
145+
await Promise.all(auditLogPromises);
146+
}
147+
export async function getExternalMemberList(
148+
list: string,
149+
dynamoClient: DynamoDBClient,
150+
): Promise<string[]> {
151+
const { Items } = await dynamoClient.send(
152+
new QueryCommand({
153+
TableName: genericConfig.ExternalMembershipTableName,
154+
KeyConditionExpression: "#pk = :pk",
155+
ExpressionAttributeNames: {
156+
"#pk": "memberList",
157+
},
158+
ExpressionAttributeValues: marshall({
159+
":pk": list,
160+
}),
161+
}),
162+
);
163+
if (!Items || Items.length === 0) {
164+
return [];
165+
}
166+
return Items.map((x) => unmarshall(x))
167+
.filter((x) => !!x)
168+
.map((x) => x.netId)
169+
.sort();
170+
}
171+
25172
export async function checkExternalMembership(
26173
netId: string,
27174
list: string,
@@ -30,12 +177,15 @@ export async function checkExternalMembership(
30177
const { Items } = await dynamoClient.send(
31178
new QueryCommand({
32179
TableName: genericConfig.ExternalMembershipTableName,
33-
KeyConditionExpression: "#pk = :pk",
180+
KeyConditionExpression: "#pk = :pk and #sk = :sk",
181+
IndexName: "invertedIndex",
34182
ExpressionAttributeNames: {
35-
"#pk": "netid_list",
183+
"#pk": "netId",
184+
"#sk": "memberList",
36185
},
37186
ExpressionAttributeValues: marshall({
38-
":pk": `${netId}_${list}`,
187+
":pk": netId,
188+
":sk": list,
39189
}),
40190
}),
41191
);

0 commit comments

Comments
 (0)