Skip to content

Commit a78102c

Browse files
authored
Setup org metadata POST and audit log writing (#302)
1 parent a9b552e commit a78102c

File tree

17 files changed

+2258
-1887
lines changed

17 files changed

+2258
-1887
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
"@eslint/eslintrc": "^3.3.1",
4040
"@eslint/js": "^9.33.0",
4141
"@playwright/test": "^1.54.2",
42-
"@smithy/types": "^4.3.2",
42+
"@smithy/types": "^4.5.0",
4343
"@tsconfig/node22": "^22.0.1",
4444
"@types/ioredis-mock": "^8.2.5",
4545
"@types/node": "^24.3.0",
@@ -94,4 +94,4 @@
9494
"pdfjs-dist": "^4.8.69",
9595
"form-data": "^4.0.4"
9696
}
97-
}
97+
}

src/api/components/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export type RoleSchema = {
3636

3737
type RolesConfig = {
3838
disableApiKeyAuth: boolean;
39+
notes?: string;
3940
};
4041

4142
export function getCorrectJsonSchema<T, U>({
@@ -193,7 +194,7 @@ export const validationError = getCorrectJsonSchema({
193194
export function withRoles<T extends FastifyZodOpenApiSchema>(
194195
roles: AppRoles[],
195196
schema: T,
196-
{ disableApiKeyAuth }: RolesConfig = { disableApiKeyAuth: false },
197+
{ disableApiKeyAuth, notes }: RolesConfig = { disableApiKeyAuth: false },
197198
): T & RoleSchema {
198199
const security = [{ httpBearer: [] }] as any;
199200
if (!disableApiKeyAuth) {
@@ -231,6 +232,8 @@ ${schema.description}
231232
#### Authorization
232233
<hr />
233234
${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."}
235+
236+
${notes ? `${notes}\n` : ""}
234237
`,
235238
...schema,
236239
response: responses,

src/api/functions/authorization.ts

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,26 @@
11
import { DynamoDBClient, GetItemCommand } from "@aws-sdk/client-dynamodb";
22
import { unmarshall } from "@aws-sdk/util-dynamodb";
33
import { genericConfig } from "../../common/config.js";
4-
import { DatabaseFetchError } from "../../common/errors/index.js";
5-
import { allAppRoles, AppRoles } from "../../common/roles.js";
4+
import {
5+
BaseError,
6+
DatabaseFetchError,
7+
InternalServerError,
8+
} from "../../common/errors/index.js";
9+
import {
10+
allAppRoles,
11+
AppRoles,
12+
OrgRoleDefinition,
13+
} from "../../common/roles.js";
614
import type Redis from "ioredis";
715
import { AUTH_CACHE_PREFIX } from "api/plugins/auth.js";
816
import type pino from "pino";
9-
import { type FastifyBaseLogger } from "fastify";
17+
import {
18+
FastifyInstance,
19+
FastifyReply,
20+
FastifyRequest,
21+
type FastifyBaseLogger,
22+
} from "fastify";
23+
import { getUserOrgRoles } from "./organizations.js";
1024

1125
export async function getUserRoles(
1226
dynamoClient: DynamoDBClient,
@@ -91,3 +105,59 @@ export async function clearAuthCache({
91105
logger.debug(`Cleared ${result} auth cache keys.`);
92106
return result;
93107
}
108+
109+
type AuthConfig = {
110+
validRoles: OrgRoleDefinition[];
111+
};
112+
113+
/**
114+
* Authorizes a request by checking if the user has at least one of the specified organization roles.
115+
* This function can be used as a preHandler in Fastify routes.
116+
*
117+
* @param fastify The Fastify instance.
118+
* @param request The Fastify request object.
119+
* @param reply The Fastify reply object.
120+
* @param config An object containing an array of valid OrgRoleDefinition instances.
121+
*/
122+
export async function authorizeByOrgRoleOrSchema(
123+
fastify: FastifyInstance,
124+
request: FastifyRequest,
125+
reply: FastifyReply,
126+
config: AuthConfig,
127+
) {
128+
let originalError = new InternalServerError({
129+
message: "You do not have permission to perform this action.",
130+
});
131+
132+
try {
133+
await fastify.authorizeFromSchema(request, reply);
134+
return;
135+
} catch (e) {
136+
if (e instanceof BaseError) {
137+
originalError = e;
138+
} else {
139+
throw e;
140+
}
141+
}
142+
143+
if (!request.username) {
144+
throw originalError;
145+
}
146+
147+
const userRoles = await getUserOrgRoles({
148+
username: request.username,
149+
dynamoClient: fastify.dynamoClient,
150+
logger: request.log,
151+
});
152+
153+
const isAuthorized = userRoles.some((userRole) =>
154+
config.validRoles.some(
155+
(validRole) =>
156+
userRole.org === validRole.org && userRole.role === validRole.role,
157+
),
158+
);
159+
160+
if (!isAuthorized) {
161+
throw originalError;
162+
}
163+
}

src/api/functions/entraId.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,14 @@ export async function modifyGroup(
292292
) {
293293
return true;
294294
}
295+
if (
296+
action === EntraGroupActions.REMOVE &&
297+
errorData?.error?.message?.includes(
298+
"one of its queried reference-property objects are not present.",
299+
)
300+
) {
301+
return true;
302+
}
295303
throw new EntraGroupError({
296304
message: errorData?.error?.message ?? response.statusText,
297305
group,

src/api/functions/membership.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,3 +355,46 @@ export async function setPaidMembership({
355355

356356
return { updated: true };
357357
}
358+
359+
export async function checkPaidMembership({
360+
netId,
361+
redisClient,
362+
dynamoClient,
363+
logger,
364+
}: {
365+
netId: string;
366+
redisClient: Redis.Redis;
367+
dynamoClient: DynamoDBClient;
368+
logger: FastifyBaseLogger;
369+
}): Promise<boolean> {
370+
// 1. Check Redis cache
371+
const isMemberInCache = await checkPaidMembershipFromRedis(
372+
netId,
373+
redisClient,
374+
logger,
375+
);
376+
377+
if (isMemberInCache === true) {
378+
return true;
379+
}
380+
381+
// 2. If cache missed or was negative, query DynamoDB
382+
const isMemberInDB = await checkPaidMembershipFromTable(netId, dynamoClient);
383+
384+
// 3. If membership is confirmed, update the cache
385+
if (isMemberInDB) {
386+
const cacheKey = `membership:${netId}:acmpaid`;
387+
try {
388+
await redisClient.set(
389+
cacheKey,
390+
JSON.stringify({ isMember: true }),
391+
"EX",
392+
MEMBER_CACHE_SECONDS,
393+
);
394+
} catch (error) {
395+
logger.error({ err: error, netId }, "Failed to update membership cache");
396+
}
397+
}
398+
399+
return isMemberInDB;
400+
}

0 commit comments

Comments
 (0)