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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.33.0",
"@playwright/test": "^1.54.2",
"@smithy/types": "^4.3.2",
"@smithy/types": "^4.5.0",
"@tsconfig/node22": "^22.0.1",
"@types/ioredis-mock": "^8.2.5",
"@types/node": "^24.3.0",
Expand Down Expand Up @@ -94,4 +94,4 @@
"pdfjs-dist": "^4.8.69",
"form-data": "^4.0.4"
}
}
}
5 changes: 4 additions & 1 deletion src/api/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@

type RolesConfig = {
disableApiKeyAuth: boolean;
notes?: string;
};

export function getCorrectJsonSchema<T, U>({
Expand Down Expand Up @@ -193,9 +194,9 @@
export function withRoles<T extends FastifyZodOpenApiSchema>(
roles: AppRoles[],
schema: T,
{ disableApiKeyAuth }: RolesConfig = { disableApiKeyAuth: false },
{ disableApiKeyAuth, notes }: RolesConfig = { disableApiKeyAuth: false },
): T & RoleSchema {
const security = [{ httpBearer: [] }] as any;

Check warning on line 199 in src/api/components/index.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

Unexpected any. Specify a different type
if (!disableApiKeyAuth) {
security.push({ apiKeyHeader: [] });
}
Expand Down Expand Up @@ -231,6 +232,8 @@
#### Authorization
<hr />
${roles.length > 0 ? `Requires any of the following roles:\n\n${roles.map((item) => `* ${AppRoleHumanMapper[item]} (<code>${item}</code>)`).join("\n")}` : "Requires valid authentication but no specific authorization."}

${notes ? `${notes}\n` : ""}
`,
...schema,
response: responses,
Expand Down
76 changes: 73 additions & 3 deletions src/api/functions/authorization.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
import { unmarshall } from "@aws-sdk/util-dynamodb";
import { genericConfig } from "../../common/config.js";
import { DatabaseFetchError } from "../../common/errors/index.js";
import { allAppRoles, AppRoles } from "../../common/roles.js";
import {
BaseError,
DatabaseFetchError,
InternalServerError,
} from "../../common/errors/index.js";
import {
allAppRoles,
AppRoles,
OrgRoleDefinition,
} from "../../common/roles.js";
import type Redis from "ioredis";
import { AUTH_CACHE_PREFIX } from "api/plugins/auth.js";
import type pino from "pino";
import { type FastifyBaseLogger } from "fastify";
import {
FastifyInstance,
FastifyReply,
FastifyRequest,
type FastifyBaseLogger,
} from "fastify";
import { getUserOrgRoles } from "./organizations.js";

export async function getUserRoles(
dynamoClient: DynamoDBClient,
Expand Down Expand Up @@ -91,3 +105,59 @@ export async function clearAuthCache({
logger.debug(`Cleared ${result} auth cache keys.`);
return result;
}

type AuthConfig = {
validRoles: OrgRoleDefinition[];
};

/**
* Authorizes a request by checking if the user has at least one of the specified organization roles.
* This function can be used as a preHandler in Fastify routes.
*
* @param fastify The Fastify instance.
* @param request The Fastify request object.
* @param reply The Fastify reply object.
* @param config An object containing an array of valid OrgRoleDefinition instances.
*/
export async function authorizeByOrgRoleOrSchema(
fastify: FastifyInstance,
request: FastifyRequest,
reply: FastifyReply,
config: AuthConfig,
) {
let originalError = new InternalServerError({
message: "You do not have permission to perform this action.",
});

try {
await fastify.authorizeFromSchema(request, reply);
return;
} catch (e) {
if (e instanceof BaseError) {
originalError = e;
} else {
throw e;
}
}

if (!request.username) {
throw originalError;
}

const userRoles = await getUserOrgRoles({
username: request.username,
dynamoClient: fastify.dynamoClient,
logger: request.log,
});

const isAuthorized = userRoles.some((userRole) =>
config.validRoles.some(
(validRole) =>
userRole.org === validRole.org && userRole.role === validRole.role,
),
);

if (!isAuthorized) {
throw originalError;
}
}
8 changes: 8 additions & 0 deletions src/api/functions/entraId.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
} from "../../common/config.js";
import {
BaseError,
DecryptionError,

Check warning on line 12 in src/api/functions/entraId.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'DecryptionError' is defined but never used. Allowed unused vars must match /^_/u
EntraFetchError,
EntraGroupError,
EntraGroupsFromEmailError,
Expand All @@ -25,7 +25,7 @@
EntraGroupActions,
EntraGroupMetadata,
EntraInvitationResponse,
ProfilePatchRequest,

Check warning on line 28 in src/api/functions/entraId.ts

View workflow job for this annotation

GitHub Actions / Run Unit Tests

'ProfilePatchRequest' is defined but never used. Allowed unused vars must match /^_/u
ProfilePatchWithUpnRequest,
} from "../../common/types/iam.js";
import { UserProfileData } from "common/types/msGraphApi.js";
Expand Down Expand Up @@ -292,6 +292,14 @@
) {
return true;
}
if (
action === EntraGroupActions.REMOVE &&
errorData?.error?.message?.includes(
"one of its queried reference-property objects are not present.",
)
) {
return true;
}
throw new EntraGroupError({
message: errorData?.error?.message ?? response.statusText,
group,
Expand Down
43 changes: 43 additions & 0 deletions src/api/functions/membership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,3 +355,46 @@ export async function setPaidMembership({

return { updated: true };
}

export async function checkPaidMembership({
netId,
redisClient,
dynamoClient,
logger,
}: {
netId: string;
redisClient: Redis.Redis;
dynamoClient: DynamoDBClient;
logger: FastifyBaseLogger;
}): Promise<boolean> {
// 1. Check Redis cache
const isMemberInCache = await checkPaidMembershipFromRedis(
netId,
redisClient,
logger,
);

if (isMemberInCache === true) {
return true;
}

// 2. If cache missed or was negative, query DynamoDB
const isMemberInDB = await checkPaidMembershipFromTable(netId, dynamoClient);

// 3. If membership is confirmed, update the cache
if (isMemberInDB) {
const cacheKey = `membership:${netId}:acmpaid`;
try {
await redisClient.set(
cacheKey,
JSON.stringify({ isMember: true }),
"EX",
MEMBER_CACHE_SECONDS,
);
} catch (error) {
logger.error({ err: error, netId }, "Failed to update membership cache");
}
}

return isMemberInDB;
}
Loading
Loading