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
23 changes: 20 additions & 3 deletions src/api/functions/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { getUserOrgRoles } from "./organizations.js";
export async function getUserRoles(
dynamoClient: DynamoDBClient,
userId: string,
logger: FastifyBaseLogger,
): Promise<AppRoles[]> {
const tableName = `${genericConfig.IAMTablePrefix}-assignments`;
const command = new GetItemCommand({
Expand All @@ -39,17 +40,33 @@ export async function getUserRoles(
message: "Could not get user roles",
});
}
// get user org roles and return if they lead at least one org
let baseRoles: AppRoles[];
try {
const orgRoles = await getUserOrgRoles({
username: userId,
dynamoClient,
logger,
});
const leadsOneOrg = orgRoles.filter((x) => x.role === "LEAD").length > 0;
baseRoles = leadsOneOrg ? [AppRoles.AT_LEAST_ONE_ORG_MANAGER] : [];
} catch (e) {
logger.error(e);
baseRoles = [];
}

if (!response.Item) {
return [];
return baseRoles;
}
const items = unmarshall(response.Item) as { roles: AppRoles[] | ["all"] };
if (!("roles" in items)) {
return [];
return baseRoles;
}
if (items.roles[0] === "all") {
return allAppRoles;
}
return items.roles as AppRoles[];

return [...new Set([...baseRoles, ...items.roles])] as AppRoles[];
}

export async function getGroupRoles(
Expand Down
8 changes: 6 additions & 2 deletions src/api/functions/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ export async function getOrgInfo({
};
try {
const responseMarshall = await dynamoClient.send(query);
if (!responseMarshall.Items || responseMarshall.Items.length === 0) {
if (
!responseMarshall ||
!responseMarshall.Items ||
responseMarshall.Items.length === 0
) {
logger.debug(
`Could not find SIG information for ${id}, returning default.`,
);
Expand Down Expand Up @@ -126,7 +130,7 @@ export async function getUserOrgRoles({
});
try {
const response = await dynamoClient.send(query);
if (!response.Items) {
if (!response || !response.Items) {
return [];
}
const unmarshalled = response.Items.map((x) => unmarshall(x)).map(
Expand Down
1 change: 1 addition & 0 deletions src/api/plugins/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,7 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
const userAuth = await getUserRoles(
fastify.dynamoClient,
request.username,
request.log,
);
for (const role of userAuth) {
userRoles.add(role);
Expand Down
2 changes: 1 addition & 1 deletion src/api/routes/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { withRoles, withTags } from "api/components/index.js";
import { z } from "zod/v4";
import {
getOrganizationInfoResponse,
ORG_DATA_CACHED_DURATION,
patchOrganizationLeadsBody,
setOrganizationMetaBody,
} from "common/types/organizations.js";
Expand Down Expand Up @@ -46,7 +47,6 @@ import { getRoleCredentials } from "api/functions/sts.js";
import { SQSClient } from "@aws-sdk/client-sqs";
import { sendSqsMessagesInBatches } from "api/functions/sqs.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) => {
Expand Down
22 changes: 21 additions & 1 deletion src/api/routes/protected.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { FastifyPluginAsync } from "fastify";
import rateLimiter from "api/plugins/rateLimiter.js";
import { withRoles, withTags } from "api/components/index.js";
import { getUserOrgRoles } from "api/functions/organizations.js";
import {
UnauthenticatedError,
UnauthorizedError,
} from "common/errors/index.js";

const protectedRoute: FastifyPluginAsync = async (fastify, _options) => {
await fastify.register(rateLimiter, {
Expand All @@ -20,7 +25,22 @@ const protectedRoute: FastifyPluginAsync = async (fastify, _options) => {
},
async (request, reply) => {
const roles = await fastify.authorize(request, reply, [], false);
reply.send({ username: request.username, roles: Array.from(roles) });
const { username, log: logger } = request;
const { dynamoClient } = fastify;
if (!username) {
throw new UnauthenticatedError({ message: "Username not found." });
}
const orgRolesPromise = getUserOrgRoles({
username,
dynamoClient,
logger,
});
const orgRoles = await orgRolesPromise;
reply.send({
username: request.username,
roles: Array.from(roles),
orgRoles,
});
},
);
};
Expand Down
7 changes: 5 additions & 2 deletions src/common/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ export enum AppRoles {
VIEW_INTERNAL_MEMBERSHIP_LIST = "view:internalMembershipList",
VIEW_EXTERNAL_MEMBERSHIP_LIST = "view:externalMembershipList",
MANAGE_EXTERNAL_MEMBERSHIP_LIST = "manage:externalMembershipList",
ALL_ORG_MANAGER = "manage:orgDefinitions"
ALL_ORG_MANAGER = "manage:orgDefinitions",
AT_LEAST_ONE_ORG_MANAGER = "manage:someOrg" // THIS IS A FAKE ROLE - DO NOT ASSIGN IT MANUALLY - only used for permissioning
}
export const PSUEDO_ROLES = [AppRoles.AT_LEAST_ONE_ORG_MANAGER]
export const orgRoles = ["LEAD", "MEMBER"] as const;
export type OrgRole = typeof orgRoles[number];
export type OrgRoleDefinition = {
Expand All @@ -31,7 +33,7 @@ export type OrgRoleDefinition = {

export const allAppRoles = Object.values(AppRoles).filter(
(value) => typeof value === "string",
);
).filter(value => !PSUEDO_ROLES.includes(value)); // don't assign psuedo roles by default

export const AppRoleHumanMapper: Record<AppRoles, string> = {
[AppRoles.EVENTS_MANAGER]: "Events Manager",
Expand All @@ -51,4 +53,5 @@ export const AppRoleHumanMapper: Record<AppRoles, string> = {
[AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST]: "External Membership List Viewer",
[AppRoles.MANAGE_EXTERNAL_MEMBERSHIP_LIST]: "External Membership List Manager",
[AppRoles.ALL_ORG_MANAGER]: "Organization Definition Manager",
[AppRoles.AT_LEAST_ONE_ORG_MANAGER]: "Manager of at least one org",
}
14 changes: 10 additions & 4 deletions src/common/types/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ import { z } from "zod/v4";

export const orgLeadEntry = z.object({
name: z.optional(z.string()),
username: z.email(),
username: z.email().refine(
(email) => email.endsWith('@illinois.edu'),
{ message: 'Email must be from the @illinois.edu domain' }
),
title: z.optional(z.string())
})

export const validOrgLinkTypes = ["DISCORD", "CAMPUSWIRE", "SLACK", "NOTION", "MATRIX", "OTHER"] as const as [string, ...string[]];
export type LeadEntry = z.infer<typeof orgLeadEntry>;

export const validOrgLinkTypes = ["DISCORD", "CAMPUSWIRE", "SLACK", "NOTION", "MATRIX", "INSTAGRAM", "OTHER"] as const as [string, ...string[]];

export const orgLinkEntry = z.object({
type: z.enum(validOrgLinkTypes),
Expand All @@ -18,7 +23,6 @@ export const orgLinkEntry = z.object({

export const enforcedOrgLeadEntry = orgLeadEntry.extend({ name: z.string().min(1), title: z.string().min(1) })


export const getOrganizationInfoResponse = z.object({
id: z.enum(AllOrganizationList),
description: z.optional(z.string()),
Expand All @@ -28,8 +32,10 @@ export const getOrganizationInfoResponse = z.object({
leadsEntraGroupId: z.optional(z.string().min(1)).meta({ description: `Only returned for users with the ${AppRoleHumanMapper[AppRoles.ALL_ORG_MANAGER]} role.` })
})

export const setOrganizationMetaBody = getOrganizationInfoResponse.omit({ id: true, leads: true });
export const setOrganizationMetaBody = getOrganizationInfoResponse.omit({ id: true, leads: true, leadsEntraGroupId: true });
export const patchOrganizationLeadsBody = z.object({
add: z.array(enforcedOrgLeadEntry),
remove: z.array(z.string())
});

export const ORG_DATA_CACHED_DURATION = 300;
7 changes: 6 additions & 1 deletion src/ui/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { ViewLogsPage } from "./pages/logs/ViewLogs.page";
import { TermsOfService } from "./pages/tos/TermsOfService.page";
import { ManageApiKeysPage } from "./pages/apiKeys/ManageKeys.page";
import { ManageExternalMembershipPage } from "./pages/membershipLists/MembershipListsPage";
import { OrgInfoPage } from "./pages/organization/OrgInfo.page";

const ProfileRediect: React.FC = () => {
const location = useLocation();
Expand Down Expand Up @@ -126,7 +127,7 @@ const authenticatedRouter = createBrowserRouter([
...commonRoutes,
{
path: "/",
element: <AcmAppShell>{null}</AcmAppShell>,
element: <Navigate to="/home" replace />,
},
{
path: "/login",
Expand Down Expand Up @@ -208,6 +209,10 @@ const authenticatedRouter = createBrowserRouter([
path: "/apiKeys",
element: <ManageApiKeysPage />,
},
{
path: "/orgInfo",
element: <OrgInfoPage />,
},
// Catch-all route for authenticated users shows 404 page
{
path: "*",
Expand Down
10 changes: 9 additions & 1 deletion src/ui/components/AppShell/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
IconKey,
IconExternalLink,
IconUser,
IconInfoCircle,
} from "@tabler/icons-react";
import { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
Expand Down Expand Up @@ -101,14 +102,21 @@ export const navItems = [
},
{
link: "/membershipLists",
name: "Membership Lists",
name: "Membership",
icon: IconUser,
description: null,
validRoles: [
AppRoles.VIEW_EXTERNAL_MEMBERSHIP_LIST,
AppRoles.MANAGE_EXTERNAL_MEMBERSHIP_LIST,
],
},
{
link: "/orgInfo",
name: "Organization Info",
icon: IconInfoCircle,
description: null,
validRoles: [AppRoles.AT_LEAST_ONE_ORG_MANAGER],
},
];

export const extLinks = [
Expand Down
Loading
Loading