Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions infracost-usage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,9 @@ resource_type_default_usage:
spectrum_data_scanned_tb: 1
backup_storage_gb: 217
aws_route53_record:
monthly_standard_queries: 12500000 # Monthly number of Standard queries.
monthly_latency_based_queries: 8333333 # Monthly number of Latency Based Routing queries.
monthly_geo_queries: 7142857 # Monthly number of Geo DNS and Geoproximity queries.
monthly_standard_queries: 1250000 # Monthly number of Standard queries.
monthly_latency_based_queries: 0 # Monthly number of Latency Based Routing queries.
monthly_geo_queries: 0 # Monthly number of Geo DNS and Geoproximity queries.
aws_route53_resolver_endpoint:
monthly_queries: 12500000 # Monthly number of DNS queries processed through the endpoints.
aws_s3_bucket_analytics_configuration:
Expand Down
155 changes: 155 additions & 0 deletions src/api/functions/organizations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { AllOrganizationList } from "@acm-uiuc/js-shared";
import { QueryCommand, type DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { unmarshall } from "@aws-sdk/util-dynamodb";
import { genericConfig } from "common/config.js";
import {
BaseError,
DatabaseFetchError,
ValidationError,
} from "common/errors/index.js";
import { OrgRole, orgRoles } from "common/roles.js";
import { getOrganizationInfoResponse } from "common/types/organizations.js";
import { type FastifyBaseLogger } from "fastify";
import pino from "pino";
import z from "zod";

export interface GetOrgInfoInputs {
id: string;
dynamoClient: DynamoDBClient;
logger: FastifyBaseLogger | pino.Logger;
}

export interface GetUserOrgRolesInputs {
username: string;
dynamoClient: DynamoDBClient;
logger: FastifyBaseLogger | pino.Logger;
}

export async function getOrgInfo({
id,
dynamoClient,
logger,
}: GetOrgInfoInputs) {
const query = new QueryCommand({
TableName: genericConfig.SigInfoTableName,
KeyConditionExpression: `primaryKey = :definitionId`,
ExpressionAttributeValues: {
":definitionId": { S: `DEFINE#${id}` },
},
});
let response = { leads: [] } as {
leads: { name: string; username: string; title: string | undefined }[];
};
try {
const responseMarshall = await dynamoClient.send(query);
if (!responseMarshall.Items || responseMarshall.Items.length === 0) {
logger.debug(
`Could not find SIG information for ${id}, returning default.`,
);
return { id };
}
const temp = unmarshall(responseMarshall.Items[0]);
temp.id = temp.primaryKey.replace("DEFINE#", "");
delete temp.primaryKey;
response = { ...temp, ...response };
} catch (e) {
if (e instanceof BaseError) {
throw e;
}
logger.error(e);
throw new DatabaseFetchError({
message: "Failed to get org metadata.",
});
}
// Get leads
const leadsQuery = new QueryCommand({
TableName: genericConfig.SigInfoTableName,
KeyConditionExpression: "primaryKey = :leadName",
ExpressionAttributeValues: {
":leadName": { S: `LEAD#${id}` },
},
});
try {
const responseMarshall = await dynamoClient.send(leadsQuery);
if (responseMarshall.Items) {
const unmarshalledLeads = responseMarshall.Items.map((x) => unmarshall(x))
.filter((x) => x.username)
.map(
(x) =>
({
name: x.name,
username: x.username,
title: x.title,
}) as { name: string; username: string; title: string | undefined },
);
response = { ...response, leads: unmarshalledLeads };
}
} catch (e) {
if (e instanceof BaseError) {
throw e;
}
logger.error(e);
throw new DatabaseFetchError({
message: "Failed to get org leads.",
});
}
return response as z.infer<typeof getOrganizationInfoResponse>;
}

export async function getUserOrgRoles({
username,
dynamoClient,
logger,
}: GetUserOrgRolesInputs) {
const query = new QueryCommand({
TableName: genericConfig.SigInfoTableName,
IndexName: "UsernameIndex",
KeyConditionExpression: `username = :username`,
ExpressionAttributeValues: {
":username": { S: username },
},
});
try {
const response = await dynamoClient.send(query);
if (!response.Items) {
return [];
}
const unmarshalled = response.Items.map((x) => unmarshall(x)).map(
(x) =>
({ username: x.username, rawRole: x.primaryKey }) as {
username: string;
rawRole: string;
},
);
const cleanedRoles = [];
for (const item of unmarshalled) {
const splits = item.rawRole.split("#");
if (splits.length !== 2) {
logger.warn(`Invalid PK in role definition: ${JSON.stringify(item)}`);
continue;
}
const [role, org] = splits;
if (!orgRoles.includes(role as OrgRole)) {
logger.warn(`Invalid role in role definition: ${JSON.stringify(item)}`);
continue;
}
if (!AllOrganizationList.includes(org)) {
logger.warn(`Invalid org in role definition: ${JSON.stringify(item)}`);
continue;
}
cleanedRoles.push({
org,
role,
} as { org: (typeof AllOrganizationList)[number]; role: OrgRole });
}
return cleanedRoles;
} catch (e) {
if (e instanceof BaseError) {
throw e;
}
logger.error(e);
throw new DatabaseFetchError({
message: "Could not get roles for user.",
});
}
}
1 change: 0 additions & 1 deletion src/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
"@azure/msal-node": "^3.5.1",
"@fastify/auth": "^5.0.1",
"@fastify/aws-lambda": "^6.0.0",
"@fastify/caching": "^9.0.1",
"@fastify/cors": "^11.0.1",
"@fastify/static": "^8.1.1",
"@fastify/swagger": "^9.5.0",
Expand Down
43 changes: 43 additions & 0 deletions src/api/plugins/verifyUserOrgRole.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { getUserOrgRoles } from "api/functions/organizations.js";
import { UnauthorizedError } from "common/errors/index.js";
import { OrgRoleDefinition } from "common/roles.js";
import { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
import fp from "fastify-plugin";

const orgRolePlugin: FastifyPluginAsync = async (fastify, _options) => {
fastify.decorate(
"verifyOrgRole",
async (
request: FastifyRequest,
_reply: FastifyReply,
validOrgRoles: OrgRoleDefinition[],
) => {
const username = request.username;
if (!username) {
throw new UnauthorizedError({
message: "Could not determine user identity.",
});
}
const userRoles = await getUserOrgRoles({
username,
dynamoClient: fastify.dynamoClient,
logger: request.log,
});
let isAuthorized = false;
for (const role of userRoles) {
if (validOrgRoles.includes(role)) {
isAuthorized = true;
break;
}
}
if (!isAuthorized) {
throw new UnauthorizedError({
message: "User does not have the required role in this organization.",
});
}
},
);
};

const fastifyOrgRolePlugin = fp(orgRolePlugin);
export default fastifyOrgRolePlugin;
31 changes: 0 additions & 31 deletions src/api/routes/membership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,37 +44,6 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => {
global: false,
runFirst: true,
});
const getAuthorizedClients = async () => {
if (roleArns.Entra) {
fastify.log.info(
`Attempting to assume Entra role ${roleArns.Entra} to get the Entra token...`,
);
const credentials = await getRoleCredentials(roleArns.Entra);
const clients = {
smClient: new SecretsManagerClient({
region: genericConfig.AwsRegion,
credentials,
}),
dynamoClient: new DynamoDBClient({
region: genericConfig.AwsRegion,
credentials,
}),
redisClient: fastify.redisClient,
};
fastify.log.info(
`Assumed Entra role ${roleArns.Entra} to get the Entra token.`,
);
return clients;
}
fastify.log.debug(
"Did not assume Entra role as no env variable was present",
);
return {
smClient: fastify.secretsManagerClient,
dynamoClient: fastify.dynamoClient,
redisClient: fastify.redisClient,
};
};
const limitedRoutes: FastifyPluginAsync = async (fastify) => {
await fastify.register(rateLimiter, {
limit: 20,
Expand Down
91 changes: 77 additions & 14 deletions src/api/routes/organizations.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,105 @@
import { FastifyPluginAsync } from "fastify";
import { AllOrganizationList } from "@acm-uiuc/js-shared";
import fastifyCaching from "@fastify/caching";
import rateLimiter from "api/plugins/rateLimiter.js";
import { withTags } from "api/components/index.js";
import { z } from "zod/v4";
import { getOrganizationInfoResponse } from "common/types/organizations.js";
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
import { BaseError, DatabaseFetchError } from "common/errors/index.js";
import { getOrgInfo } from "api/functions/organizations.js";

export const ORG_DATA_CACHED_DURATION = 300;
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`;

const organizationsPlugin: FastifyPluginAsync = async (fastify, _options) => {
fastify.register(fastifyCaching, {
privacy: fastifyCaching.privacy.PUBLIC,
serverExpiresIn: 60 * 60 * 4,
expiresIn: 60 * 60 * 4,
});
fastify.register(rateLimiter, {
limit: 60,
duration: 60,
rateLimitIdentifier: "organizations",
});
fastify.addHook("onSend", async (request, reply, payload) => {
if (request.method === "GET") {
reply.header("Cache-Control", CLIENT_HTTP_CACHE_POLICY);
}
return payload;
});
fastify.get(
"",
{
schema: withTags(["Generic"], {
summary: "Get a list of ACM @ UIUC sub-organizations.",
schema: withTags(["Organizations"], {
summary: "Get info for all of ACM @ UIUC's sub-organizations.",
response: {
200: {
description: "List of ACM @ UIUC sub-organizations and info.",
content: {
"application/json": {
schema: z.array(getOrganizationInfoResponse),
},
},
},
},
}),
},
async (request, reply) => {
const promises = AllOrganizationList.map((x) =>
getOrgInfo({
id: x,
dynamoClient: fastify.dynamoClient,
logger: request.log,
}),
);
try {
const data = await Promise.allSettled(promises);
const successOnly = data
.filter((x) => x.status === "fulfilled")
.map((x) => x.value);
// return just the ID for anything not in the DB.
const successIds = successOnly.map((x) => x.id);
const unknownIds = AllOrganizationList.filter(
(x) => !successIds.includes(x),
).map((x) => ({ id: x }));
return reply.send([...successOnly, ...unknownIds]);
} catch (e) {
if (e instanceof BaseError) {
throw e;
}
request.log.error(e);
throw new DatabaseFetchError({
message: "Failed to get org information.",
});
}
},
);
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().get(
"/:id",
{
schema: withTags(["Organizations"], {
summary:
"Get information about a specific ACM @ UIUC sub-organization.",
params: z.object({
id: z
.enum(AllOrganizationList)
.meta({ description: "ACM @ UIUC organization to query." }),
}),
response: {
200: {
description: "List of ACM @ UIUC sub-organizations.",
description: "ACM @ UIUC sub-organization info.",
content: {
"application/json": {
schema: z
.array(z.enum(AllOrganizationList))
.default(AllOrganizationList),
schema: getOrganizationInfoResponse,
},
},
},
},
}),
},
async (_request, reply) => {
reply.send(AllOrganizationList);
async (request, reply) => {
const response = await getOrgInfo({
id: request.params.id,
dynamoClient: fastify.dynamoClient,
logger: request.log,
});
return reply.send(response);
},
);
};
Expand Down
4 changes: 3 additions & 1 deletion src/common/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export type GenericConfigType = {
UinHashingSecret: string;
UinExtendedAttributeName: string;
UserInfoTable: string;
SigInfoTableName: string;
};

type EnvironmentConfigType = {
Expand Down Expand Up @@ -94,7 +95,8 @@ const genericConfig: GenericConfigType = {
TestingCredentialsSecret: "infra-core-api-testing-credentials",
UinHashingSecret: "infra-core-api-uin-pepper",
UinExtendedAttributeName: "extension_a70c2e1556954056a6a8edfb1f42f556_uiucEduUIN",
UserInfoTable: "infra-core-api-user-info"
UserInfoTable: "infra-core-api-user-info",
SigInfoTableName: "infra-core-api-sigs"
} as const;

const environmentConfig: EnvironmentConfigType = {
Expand Down
Loading
Loading