Skip to content

Commit 4800fd9

Browse files
authored
Store SIG info (#299)
Creates an API to get SIG info. Sample response to `GET /api/v1/organizations/ACM`: ```json { "id": "ACM", "website": "https://www.acm.illinois.edu", "leads": [ { "username": "[email protected]", "name": "Jacob Levine", "title": "Chair" } ], "links": [ { "type": "DISCORD", "url": "https://go.acm.illinois.edu/discord" } ] } ``` `GET /api/v1/organizations` returns an array of above for all the orgs.
1 parent 70faf49 commit 4800fd9

File tree

20 files changed

+676
-66
lines changed

20 files changed

+676
-66
lines changed

infracost-usage.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -286,9 +286,9 @@ resource_type_default_usage:
286286
spectrum_data_scanned_tb: 1
287287
backup_storage_gb: 217
288288
aws_route53_record:
289-
monthly_standard_queries: 12500000 # Monthly number of Standard queries.
290-
monthly_latency_based_queries: 8333333 # Monthly number of Latency Based Routing queries.
291-
monthly_geo_queries: 7142857 # Monthly number of Geo DNS and Geoproximity queries.
289+
monthly_standard_queries: 1250000 # Monthly number of Standard queries.
290+
monthly_latency_based_queries: 0 # Monthly number of Latency Based Routing queries.
291+
monthly_geo_queries: 0 # Monthly number of Geo DNS and Geoproximity queries.
292292
aws_route53_resolver_endpoint:
293293
monthly_queries: 12500000 # Monthly number of DNS queries processed through the endpoints.
294294
aws_s3_bucket_analytics_configuration:

src/api/functions/organizations.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { AllOrganizationList } from "@acm-uiuc/js-shared";
2+
import { QueryCommand, type DynamoDBClient } from "@aws-sdk/client-dynamodb";
3+
import { unmarshall } from "@aws-sdk/util-dynamodb";
4+
import { genericConfig } from "common/config.js";
5+
import {
6+
BaseError,
7+
DatabaseFetchError,
8+
ValidationError,
9+
} from "common/errors/index.js";
10+
import { OrgRole, orgRoles } from "common/roles.js";
11+
import { getOrganizationInfoResponse } from "common/types/organizations.js";
12+
import { type FastifyBaseLogger } from "fastify";
13+
import pino from "pino";
14+
import z from "zod";
15+
16+
export interface GetOrgInfoInputs {
17+
id: string;
18+
dynamoClient: DynamoDBClient;
19+
logger: FastifyBaseLogger | pino.Logger;
20+
}
21+
22+
export interface GetUserOrgRolesInputs {
23+
username: string;
24+
dynamoClient: DynamoDBClient;
25+
logger: FastifyBaseLogger | pino.Logger;
26+
}
27+
28+
export async function getOrgInfo({
29+
id,
30+
dynamoClient,
31+
logger,
32+
}: GetOrgInfoInputs) {
33+
const query = new QueryCommand({
34+
TableName: genericConfig.SigInfoTableName,
35+
KeyConditionExpression: `primaryKey = :definitionId`,
36+
ExpressionAttributeValues: {
37+
":definitionId": { S: `DEFINE#${id}` },
38+
},
39+
});
40+
let response = { leads: [] } as {
41+
leads: { name: string; username: string; title: string | undefined }[];
42+
};
43+
try {
44+
const responseMarshall = await dynamoClient.send(query);
45+
if (!responseMarshall.Items || responseMarshall.Items.length === 0) {
46+
logger.debug(
47+
`Could not find SIG information for ${id}, returning default.`,
48+
);
49+
return { id };
50+
}
51+
const temp = unmarshall(responseMarshall.Items[0]);
52+
temp.id = temp.primaryKey.replace("DEFINE#", "");
53+
delete temp.primaryKey;
54+
response = { ...temp, ...response };
55+
} catch (e) {
56+
if (e instanceof BaseError) {
57+
throw e;
58+
}
59+
logger.error(e);
60+
throw new DatabaseFetchError({
61+
message: "Failed to get org metadata.",
62+
});
63+
}
64+
// Get leads
65+
const leadsQuery = new QueryCommand({
66+
TableName: genericConfig.SigInfoTableName,
67+
KeyConditionExpression: "primaryKey = :leadName",
68+
ExpressionAttributeValues: {
69+
":leadName": { S: `LEAD#${id}` },
70+
},
71+
});
72+
try {
73+
const responseMarshall = await dynamoClient.send(leadsQuery);
74+
if (responseMarshall.Items) {
75+
const unmarshalledLeads = responseMarshall.Items.map((x) => unmarshall(x))
76+
.filter((x) => x.username)
77+
.map(
78+
(x) =>
79+
({
80+
name: x.name,
81+
username: x.username,
82+
title: x.title,
83+
}) as { name: string; username: string; title: string | undefined },
84+
);
85+
response = { ...response, leads: unmarshalledLeads };
86+
}
87+
} catch (e) {
88+
if (e instanceof BaseError) {
89+
throw e;
90+
}
91+
logger.error(e);
92+
throw new DatabaseFetchError({
93+
message: "Failed to get org leads.",
94+
});
95+
}
96+
return response as z.infer<typeof getOrganizationInfoResponse>;
97+
}
98+
99+
export async function getUserOrgRoles({
100+
username,
101+
dynamoClient,
102+
logger,
103+
}: GetUserOrgRolesInputs) {
104+
const query = new QueryCommand({
105+
TableName: genericConfig.SigInfoTableName,
106+
IndexName: "UsernameIndex",
107+
KeyConditionExpression: `username = :username`,
108+
ExpressionAttributeValues: {
109+
":username": { S: username },
110+
},
111+
});
112+
try {
113+
const response = await dynamoClient.send(query);
114+
if (!response.Items) {
115+
return [];
116+
}
117+
const unmarshalled = response.Items.map((x) => unmarshall(x)).map(
118+
(x) =>
119+
({ username: x.username, rawRole: x.primaryKey }) as {
120+
username: string;
121+
rawRole: string;
122+
},
123+
);
124+
const cleanedRoles = [];
125+
for (const item of unmarshalled) {
126+
const splits = item.rawRole.split("#");
127+
if (splits.length !== 2) {
128+
logger.warn(`Invalid PK in role definition: ${JSON.stringify(item)}`);
129+
continue;
130+
}
131+
const [role, org] = splits;
132+
if (!orgRoles.includes(role as OrgRole)) {
133+
logger.warn(`Invalid role in role definition: ${JSON.stringify(item)}`);
134+
continue;
135+
}
136+
if (!AllOrganizationList.includes(org)) {
137+
logger.warn(`Invalid org in role definition: ${JSON.stringify(item)}`);
138+
continue;
139+
}
140+
cleanedRoles.push({
141+
org,
142+
role,
143+
} as { org: (typeof AllOrganizationList)[number]; role: OrgRole });
144+
}
145+
return cleanedRoles;
146+
} catch (e) {
147+
if (e instanceof BaseError) {
148+
throw e;
149+
}
150+
logger.error(e);
151+
throw new DatabaseFetchError({
152+
message: "Could not get roles for user.",
153+
});
154+
}
155+
}

src/api/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
"@azure/msal-node": "^3.5.1",
2828
"@fastify/auth": "^5.0.1",
2929
"@fastify/aws-lambda": "^6.0.0",
30-
"@fastify/caching": "^9.0.1",
3130
"@fastify/cors": "^11.0.1",
3231
"@fastify/static": "^8.1.1",
3332
"@fastify/swagger": "^9.5.0",
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { getUserOrgRoles } from "api/functions/organizations.js";
2+
import { UnauthorizedError } from "common/errors/index.js";
3+
import { OrgRoleDefinition } from "common/roles.js";
4+
import { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
5+
import fp from "fastify-plugin";
6+
7+
const orgRolePlugin: FastifyPluginAsync = async (fastify, _options) => {
8+
fastify.decorate(
9+
"verifyOrgRole",
10+
async (
11+
request: FastifyRequest,
12+
_reply: FastifyReply,
13+
validOrgRoles: OrgRoleDefinition[],
14+
) => {
15+
const username = request.username;
16+
if (!username) {
17+
throw new UnauthorizedError({
18+
message: "Could not determine user identity.",
19+
});
20+
}
21+
const userRoles = await getUserOrgRoles({
22+
username,
23+
dynamoClient: fastify.dynamoClient,
24+
logger: request.log,
25+
});
26+
let isAuthorized = false;
27+
for (const role of userRoles) {
28+
if (validOrgRoles.includes(role)) {
29+
isAuthorized = true;
30+
break;
31+
}
32+
}
33+
if (!isAuthorized) {
34+
throw new UnauthorizedError({
35+
message: "User does not have the required role in this organization.",
36+
});
37+
}
38+
},
39+
);
40+
};
41+
42+
const fastifyOrgRolePlugin = fp(orgRolePlugin);
43+
export default fastifyOrgRolePlugin;

src/api/routes/membership.ts

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -44,37 +44,6 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => {
4444
global: false,
4545
runFirst: true,
4646
});
47-
const getAuthorizedClients = async () => {
48-
if (roleArns.Entra) {
49-
fastify.log.info(
50-
`Attempting to assume Entra role ${roleArns.Entra} to get the Entra token...`,
51-
);
52-
const credentials = await getRoleCredentials(roleArns.Entra);
53-
const clients = {
54-
smClient: new SecretsManagerClient({
55-
region: genericConfig.AwsRegion,
56-
credentials,
57-
}),
58-
dynamoClient: new DynamoDBClient({
59-
region: genericConfig.AwsRegion,
60-
credentials,
61-
}),
62-
redisClient: fastify.redisClient,
63-
};
64-
fastify.log.info(
65-
`Assumed Entra role ${roleArns.Entra} to get the Entra token.`,
66-
);
67-
return clients;
68-
}
69-
fastify.log.debug(
70-
"Did not assume Entra role as no env variable was present",
71-
);
72-
return {
73-
smClient: fastify.secretsManagerClient,
74-
dynamoClient: fastify.dynamoClient,
75-
redisClient: fastify.redisClient,
76-
};
77-
};
7847
const limitedRoutes: FastifyPluginAsync = async (fastify) => {
7948
await fastify.register(rateLimiter, {
8049
limit: 20,

src/api/routes/organizations.ts

Lines changed: 77 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,105 @@
11
import { FastifyPluginAsync } from "fastify";
22
import { AllOrganizationList } from "@acm-uiuc/js-shared";
3-
import fastifyCaching from "@fastify/caching";
43
import rateLimiter from "api/plugins/rateLimiter.js";
54
import { withTags } from "api/components/index.js";
65
import { z } from "zod/v4";
6+
import { getOrganizationInfoResponse } from "common/types/organizations.js";
7+
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
8+
import { BaseError, DatabaseFetchError } from "common/errors/index.js";
9+
import { getOrgInfo } from "api/functions/organizations.js";
10+
11+
export const ORG_DATA_CACHED_DURATION = 300;
12+
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`;
713

814
const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
9-
fastify.register(fastifyCaching, {
10-
privacy: fastifyCaching.privacy.PUBLIC,
11-
serverExpiresIn: 60 * 60 * 4,
12-
expiresIn: 60 * 60 * 4,
13-
});
1415
fastify.register(rateLimiter, {
1516
limit: 60,
1617
duration: 60,
1718
rateLimitIdentifier: "organizations",
1819
});
20+
fastify.addHook("onSend", async (request, reply, payload) => {
21+
if (request.method === "GET") {
22+
reply.header("Cache-Control", CLIENT_HTTP_CACHE_POLICY);
23+
}
24+
return payload;
25+
});
1926
fastify.get(
2027
"",
2128
{
22-
schema: withTags(["Generic"], {
23-
summary: "Get a list of ACM @ UIUC sub-organizations.",
29+
schema: withTags(["Organizations"], {
30+
summary: "Get info for all of ACM @ UIUC's sub-organizations.",
31+
response: {
32+
200: {
33+
description: "List of ACM @ UIUC sub-organizations and info.",
34+
content: {
35+
"application/json": {
36+
schema: z.array(getOrganizationInfoResponse),
37+
},
38+
},
39+
},
40+
},
41+
}),
42+
},
43+
async (request, reply) => {
44+
const promises = AllOrganizationList.map((x) =>
45+
getOrgInfo({
46+
id: x,
47+
dynamoClient: fastify.dynamoClient,
48+
logger: request.log,
49+
}),
50+
);
51+
try {
52+
const data = await Promise.allSettled(promises);
53+
const successOnly = data
54+
.filter((x) => x.status === "fulfilled")
55+
.map((x) => x.value);
56+
// return just the ID for anything not in the DB.
57+
const successIds = successOnly.map((x) => x.id);
58+
const unknownIds = AllOrganizationList.filter(
59+
(x) => !successIds.includes(x),
60+
).map((x) => ({ id: x }));
61+
return reply.send([...successOnly, ...unknownIds]);
62+
} catch (e) {
63+
if (e instanceof BaseError) {
64+
throw e;
65+
}
66+
request.log.error(e);
67+
throw new DatabaseFetchError({
68+
message: "Failed to get org information.",
69+
});
70+
}
71+
},
72+
);
73+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
74+
"/:id",
75+
{
76+
schema: withTags(["Organizations"], {
77+
summary:
78+
"Get information about a specific ACM @ UIUC sub-organization.",
79+
params: z.object({
80+
id: z
81+
.enum(AllOrganizationList)
82+
.meta({ description: "ACM @ UIUC organization to query." }),
83+
}),
2484
response: {
2585
200: {
26-
description: "List of ACM @ UIUC sub-organizations.",
86+
description: "ACM @ UIUC sub-organization info.",
2787
content: {
2888
"application/json": {
29-
schema: z
30-
.array(z.enum(AllOrganizationList))
31-
.default(AllOrganizationList),
89+
schema: getOrganizationInfoResponse,
3290
},
3391
},
3492
},
3593
},
3694
}),
3795
},
38-
async (_request, reply) => {
39-
reply.send(AllOrganizationList);
96+
async (request, reply) => {
97+
const response = await getOrgInfo({
98+
id: request.params.id,
99+
dynamoClient: fastify.dynamoClient,
100+
logger: request.log,
101+
});
102+
return reply.send(response);
40103
},
41104
);
42105
};

src/common/config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export type GenericConfigType = {
5555
UinHashingSecret: string;
5656
UinExtendedAttributeName: string;
5757
UserInfoTable: string;
58+
SigInfoTableName: string;
5859
};
5960

6061
type EnvironmentConfigType = {
@@ -94,7 +95,8 @@ const genericConfig: GenericConfigType = {
9495
TestingCredentialsSecret: "infra-core-api-testing-credentials",
9596
UinHashingSecret: "infra-core-api-uin-pepper",
9697
UinExtendedAttributeName: "extension_a70c2e1556954056a6a8edfb1f42f556_uiucEduUIN",
97-
UserInfoTable: "infra-core-api-user-info"
98+
UserInfoTable: "infra-core-api-user-info",
99+
SigInfoTableName: "infra-core-api-sigs"
98100
} as const;
99101

100102
const environmentConfig: EnvironmentConfigType = {

0 commit comments

Comments
 (0)