Skip to content

Commit 635fb36

Browse files
committed
Create SQS function to setup the github org/sync for a team
1 parent dace2b7 commit 635fb36

File tree

7 files changed

+202
-95
lines changed

7 files changed

+202
-95
lines changed

src/api/functions/github.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ export async function createGithubTeam({
152152

153153
if (existingTeam) {
154154
logger.info(`Team "${name}" already exists with id: ${existingTeam.id}`);
155-
return existingTeam.id;
155+
return { updated: false, id: existingTeam.id };
156156
}
157157
logger.info(`Creating GitHub team "${name}"`);
158158
const response = await octokit.request("POST /orgs/{org}/teams", {
@@ -196,7 +196,7 @@ export async function createGithubTeam({
196196
logger.warn(`Failed to remove user from team ${newTeamId}:`, removeError);
197197
}
198198

199-
return newTeamId;
199+
return { updated: true, id: newTeamId };
200200
} catch (e) {
201201
if (e instanceof BaseError) {
202202
throw e;

src/api/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
"pino": "^9.6.0",
5858
"pluralize": "^8.0.0",
5959
"qrcode": "^1.5.4",
60-
"redlock-universal": "^0.6.0",
60+
"redlock-universal": "^0.6.4",
6161
"sanitize-html": "^2.17.0",
6262
"stripe": "^18.0.0",
6363
"uuid": "^11.1.0",
@@ -74,4 +74,4 @@
7474
"pino-pretty": "^13.1.1",
7575
"yaml": "^2.8.1"
7676
}
77-
}
77+
}

src/api/routes/organizations.ts

Lines changed: 26 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,15 @@ import {
5353
} from "api/functions/entraId.js";
5454
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
5555
import { getRoleCredentials } from "api/functions/sts.js";
56-
import { SQSClient } from "@aws-sdk/client-sqs";
56+
import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs";
5757
import { sendSqsMessagesInBatches } from "api/functions/sqs.js";
5858
import { retryDynamoTransactionWithBackoff } from "api/utils.js";
5959
import {
6060
assignIdpGroupsToTeam,
6161
createGithubTeam,
6262
} from "api/functions/github.js";
6363
import { SKIP_EXTERNAL_ORG_LEAD_UPDATE } from "common/overrides.js";
64+
import { AvailableSQSFunctions, SQSPayload } from "common/types/sqsMessage.js";
6465

6566
export const CLIENT_HTTP_CACHE_POLICY = `public, max-age=${ORG_DATA_CACHED_DURATION}, stale-while-revalidate=${Math.floor(ORG_DATA_CACHED_DURATION * 1.1)}, stale-if-error=3600`;
6667

@@ -413,12 +414,10 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
413414
? (unmarshall(metadataResponse.Item).leadsEntraGroupId as string)
414415
: undefined;
415416

416-
let githubTeamId = metadataResponse.Item
417+
const githubTeamId = metadataResponse.Item
417418
? (unmarshall(metadataResponse.Item).githubTeamId as number)
418419
: undefined;
419420

420-
let createdGithubTeam = false;
421-
422421
const entraIdToken = await getEntraIdToken({
423422
clients,
424423
clientId: fastify.environmentConfig.AadValidClientId,
@@ -428,8 +427,6 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
428427

429428
const shouldCreateNewEntraGroup =
430429
!entraGroupId && !shouldSkipEnhancedActions;
431-
const shouldCreateNewGithubGroup =
432-
!githubTeamId && !shouldSkipEnhancedActions;
433430
const grpDisplayName = `${request.params.orgId} Admin`;
434431
const orgInfo = getOrgByName(request.params.orgId);
435432
if (!orgInfo) {
@@ -524,65 +521,6 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
524521
}
525522
}
526523

527-
// Create GitHub team if needed
528-
if (shouldCreateNewGithubGroup) {
529-
request.log.info(
530-
`No GitHub team exists for ${request.params.orgId}. Creating new team...`,
531-
);
532-
const suffix = fastify.environmentConfig.GroupEmailSuffix;
533-
githubTeamId = await createGithubTeam({
534-
orgId: fastify.environmentConfig.GithubOrgName,
535-
githubToken: fastify.secretConfig.github_pat,
536-
parentTeamId: fastify.environmentConfig.ExecGithubTeam,
537-
name: `${grpShortName}${suffix === "" ? "" : `-${suffix}`}`,
538-
description: grpDisplayName,
539-
logger: request.log,
540-
});
541-
request.log.info(
542-
`Created GitHub team "${githubTeamId}" for ${request.params.orgId} leads.`,
543-
);
544-
createdGithubTeam = true;
545-
546-
// Store GitHub team ID immediately
547-
const logStatement = buildAuditLogTransactPut({
548-
entry: {
549-
module: Modules.ORG_INFO,
550-
message: `Created GitHub team "${githubTeamId}" for organization leads.`,
551-
actor: request.username!,
552-
target: request.params.orgId,
553-
},
554-
});
555-
556-
const storeGithubIdOperation = async () => {
557-
const commandTransaction = new TransactWriteItemsCommand({
558-
TransactItems: [
559-
...(logStatement ? [logStatement] : []),
560-
{
561-
Update: {
562-
TableName: genericConfig.SigInfoTableName,
563-
Key: marshall({
564-
primaryKey: `DEFINE#${request.params.orgId}`,
565-
entryId: "0",
566-
}),
567-
UpdateExpression:
568-
"SET leadsGithubTeamId = :githubTeamId, updatedAt = :updatedAt",
569-
ExpressionAttributeValues: marshall({
570-
":githubTeamId": githubTeamId,
571-
":updatedAt": new Date().toISOString(),
572-
}),
573-
},
574-
},
575-
],
576-
});
577-
return await clients.dynamoClient.send(commandTransaction);
578-
};
579-
580-
await retryDynamoTransactionWithBackoff(
581-
storeGithubIdOperation,
582-
request.log,
583-
`Store GitHub team ID for ${request.params.orgId}`,
584-
);
585-
}
586524
const commonArgs = {
587525
orgId: request.params.orgId,
588526
actorUsername: request.username!,
@@ -628,36 +566,37 @@ const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
628566
.map((r) => r.value)
629567
.filter((p): p is SQSMessage => p !== null);
630568

569+
if (!fastify.sqsClient) {
570+
fastify.sqsClient = new SQSClient({
571+
region: genericConfig.AwsRegion,
572+
});
573+
}
574+
575+
// Queue creating GitHub team if needed
576+
if (!githubTeamId) {
577+
const sqsPayload: SQSPayload<AvailableSQSFunctions.CreateOrgGithubTeam> =
578+
{
579+
function: AvailableSQSFunctions.CreateOrgGithubTeam,
580+
metadata: {
581+
initiator: request.username!,
582+
reqId: request.id,
583+
},
584+
payload: {
585+
orgName: request.params.orgId,
586+
githubTeamDescription: grpDisplayName,
587+
githubTeamName: grpShortName,
588+
},
589+
};
590+
sqsPayloads.push(sqsPayload);
591+
}
631592
if (sqsPayloads.length > 0) {
632-
if (!fastify.sqsClient) {
633-
fastify.sqsClient = new SQSClient({
634-
region: genericConfig.AwsRegion,
635-
});
636-
}
637593
await sendSqsMessagesInBatches({
638594
sqsClient: fastify.sqsClient,
639595
queueUrl: fastify.environmentConfig.SqsQueueUrl,
640596
logger: request.log,
641597
sqsPayloads,
642598
});
643599
}
644-
645-
if (
646-
createdGithubTeam &&
647-
githubTeamId &&
648-
fastify.environmentConfig.GithubIdpSyncEnabled
649-
) {
650-
request.log.info("Setting up IDP sync for Github team!");
651-
await assignIdpGroupsToTeam({
652-
githubToken: fastify.secretConfig.github_pat,
653-
teamId: githubTeamId,
654-
logger: request.log,
655-
groupsToSync: [entraGroupId].filter((x): x is string => !!x),
656-
orgId: fastify.environmentConfig.GithubOrgId,
657-
orgName: fastify.environmentConfig.GithubOrgName,
658-
});
659-
}
660-
661600
return reply.status(201).send();
662601
},
663602
);
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { AvailableSQSFunctions } from "common/types/sqsMessage.js";
2+
import { currentEnvironmentConfig, SQSHandlerFunction } from "../index.js";
3+
import {
4+
DynamoDBClient,
5+
GetItemCommand,
6+
TransactWriteItemsCommand,
7+
} from "@aws-sdk/client-dynamodb";
8+
import { genericConfig, SecretConfig } from "common/config.js";
9+
import { getSecretConfig } from "../utils.js";
10+
import RedisModule from "ioredis";
11+
import { createLock, IoredisAdapter } from "redlock-universal";
12+
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
13+
import { InternalServerError } from "common/errors/index.js";
14+
import {
15+
assignIdpGroupsToTeam,
16+
createGithubTeam,
17+
} from "api/functions/github.js";
18+
import { buildAuditLogTransactPut } from "api/functions/auditLog.js";
19+
import { Modules } from "common/modules.js";
20+
import { retryDynamoTransactionWithBackoff } from "api/utils.js";
21+
import { SKIP_EXTERNAL_ORG_LEAD_UPDATE } from "common/overrides.js";
22+
import { getOrgByName } from "@acm-uiuc/js-shared";
23+
24+
export const createOrgGithubTeamHandler: SQSHandlerFunction<
25+
AvailableSQSFunctions.CreateOrgGithubTeam
26+
> = async (payload, metadata, logger) => {
27+
const secretConfig: SecretConfig = await getSecretConfig({
28+
logger,
29+
commonConfig: { region: genericConfig.AwsRegion },
30+
});
31+
const redisClient = new RedisModule.default(secretConfig.redis_url);
32+
const { orgName, githubTeamName, githubTeamDescription } = payload;
33+
const orgImmutableId = getOrgByName(orgName)!.id;
34+
if (SKIP_EXTERNAL_ORG_LEAD_UPDATE.includes(orgImmutableId)) {
35+
logger.info(
36+
`Organization ${orgName} has external updates disabled, exiting.`,
37+
);
38+
return;
39+
}
40+
const dynamo = new DynamoDBClient({
41+
region: genericConfig.AwsRegion,
42+
});
43+
const lock = createLock({
44+
adapter: new IoredisAdapter(redisClient),
45+
key: `createOrgGithubTeamHandler:${orgImmutableId}`,
46+
retryAttempts: 5,
47+
retryDelay: 300,
48+
});
49+
return await lock.using(async (signal) => {
50+
const getMetadataCommand = new GetItemCommand({
51+
TableName: genericConfig.SigInfoTableName,
52+
Key: marshall({
53+
primaryKey: `DEFINE#${orgName}`,
54+
entryId: "0",
55+
}),
56+
AttributesToGet: ["leadsEntraGroupId", "leadsGithubTeamId"],
57+
ConsistentRead: true,
58+
});
59+
const existingData = await dynamo.send(getMetadataCommand);
60+
if (!existingData || !existingData.Item) {
61+
throw new InternalServerError({
62+
message: `Could not find org entry for ${orgName}`,
63+
});
64+
}
65+
const currentOrgInfo = unmarshall(existingData.Item) as {
66+
leadsEntraGroupId?: string;
67+
leadsGithubTeamId?: string;
68+
};
69+
if (!currentOrgInfo.leadsEntraGroupId) {
70+
logger.info(`${orgName} does not have an Entra group, skipping!`);
71+
return;
72+
}
73+
if (currentOrgInfo.leadsGithubTeamId) {
74+
logger.info("This org already has a GitHub team, skipping");
75+
return;
76+
}
77+
if (signal.aborted) {
78+
throw new InternalServerError({
79+
message:
80+
"Checked on lock before creating GitHub team, we've lost the lock!",
81+
});
82+
}
83+
logger.info(`Creating GitHub team for ${orgName}!`);
84+
const suffix = currentEnvironmentConfig.GroupEmailSuffix;
85+
const finalName = `${githubTeamName}${suffix === "" ? "" : `-${suffix}`}`;
86+
const { updated, id: teamId } = await createGithubTeam({
87+
orgId: currentEnvironmentConfig.GithubOrgName,
88+
githubToken: secretConfig.github_pat,
89+
parentTeamId: currentEnvironmentConfig.ExecGithubTeam,
90+
name: finalName,
91+
description: githubTeamDescription,
92+
logger,
93+
});
94+
if (!updated) {
95+
logger.info(
96+
`Github team "${finalName}" already existed. We're assuming team sync was already set up (if not, please configure manually).`,
97+
);
98+
} else {
99+
logger.info(
100+
`Github team "${finalName}" created with team ID "${teamId}".`,
101+
);
102+
if (currentEnvironmentConfig.GithubIdpSyncEnabled) {
103+
logger.info(
104+
`Setting up IDP sync for Github team from Entra ID group ${currentOrgInfo.leadsEntraGroupId}`,
105+
);
106+
await assignIdpGroupsToTeam({
107+
githubToken: secretConfig.github_pat,
108+
teamId,
109+
logger,
110+
groupsToSync: [currentOrgInfo.leadsEntraGroupId],
111+
orgId: currentEnvironmentConfig.GithubOrgId,
112+
orgName: currentEnvironmentConfig.GithubOrgName,
113+
});
114+
}
115+
}
116+
logger.info("Adding updates to audit log");
117+
const logStatement = updated
118+
? buildAuditLogTransactPut({
119+
entry: {
120+
module: Modules.ORG_INFO,
121+
message: `Created GitHub team "${finalName}" for organization leads.`,
122+
actor: metadata.initiator,
123+
target: orgName,
124+
},
125+
})
126+
: undefined;
127+
const storeGithubIdOperation = async () => {
128+
const commandTransaction = new TransactWriteItemsCommand({
129+
TransactItems: [
130+
...(logStatement ? [logStatement] : []),
131+
{
132+
Update: {
133+
TableName: genericConfig.SigInfoTableName,
134+
Key: marshall({
135+
primaryKey: `DEFINE#${orgName}`,
136+
entryId: "0",
137+
}),
138+
UpdateExpression:
139+
"SET leadsGithubTeamId = :githubTeamId, updatedAt = :updatedAt",
140+
ExpressionAttributeValues: marshall({
141+
":githubTeamId": teamId,
142+
":updatedAt": new Date().toISOString(),
143+
}),
144+
},
145+
},
146+
],
147+
});
148+
return await dynamo.send(commandTransaction);
149+
};
150+
151+
await retryDynamoTransactionWithBackoff(
152+
storeGithubIdOperation,
153+
logger,
154+
`Store GitHub team ID for ${orgName}`,
155+
);
156+
});
157+
};

src/api/sqs/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
import { ValidationError } from "../../common/errors/index.js";
2323
import { RunEnvironment } from "../../common/roles.js";
2424
import { environmentConfig } from "../../common/config.js";
25+
import { createOrgGithubTeamHandler } from "./handlers/createOrgGithubTeam.js";
2526

2627
export type SQSFunctionPayloadTypes = {
2728
[K in keyof typeof sqsPayloadSchemas]: SQSHandlerFunction<K>;
@@ -39,6 +40,7 @@ const handlers: SQSFunctionPayloadTypes = {
3940
[AvailableSQSFunctions.ProvisionNewMember]: provisionNewMemberHandler,
4041
[AvailableSQSFunctions.SendSaleEmail]: sendSaleEmailHandler,
4142
[AvailableSQSFunctions.EmailNotifications]: emailNotificationsHandler,
43+
[AvailableSQSFunctions.CreateOrgGithubTeam]: createOrgGithubTeamHandler,
4244
};
4345
export const runEnvironment = process.env.RunEnvironment as RunEnvironment;
4446
export const currentEnvironmentConfig = environmentConfig[runEnvironment];

0 commit comments

Comments
 (0)