Skip to content

Commit eb80f51

Browse files
authored
Build a generic policy evaluator (#127)
* Build a generic policy evaluator currently only used for events * support multiple hosts in event host restriction * show in UI * fix types
1 parent 0490f55 commit eb80f51

File tree

12 files changed

+294
-9
lines changed

12 files changed

+294
-9
lines changed

src/api/functions/apiKey.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@ import {
1010
import { genericConfig } from "common/config.js";
1111
import { AUTH_DECISION_CACHE_SECONDS as API_KEY_DATA_CACHE_SECONDS } from "./authorization.js";
1212
import { unmarshall } from "@aws-sdk/util-dynamodb";
13-
import { ApiKeyDynamoEntry, DecomposedApiKey } from "common/types/apiKey.js";
13+
import { ApiKeyMaskedEntry, DecomposedApiKey } from "common/types/apiKey.js";
14+
import { AvailableAuthorizationPolicy } from "api/policies/definition.js";
15+
16+
export type ApiKeyDynamoEntry = ApiKeyMaskedEntry & {
17+
keyHash: string;
18+
restrictions?: AvailableAuthorizationPolicy[];
19+
};
1420

1521
function min(a: number, b: number) {
1622
return a < b ? a : b;

src/api/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import cors from "@fastify/cors";
1414
import { environmentConfig, genericConfig } from "../common/config.js";
1515
import organizationsPlugin from "./routes/organizations.js";
1616
import authorizeFromSchemaPlugin from "./plugins/authorizeFromSchema.js";
17+
import evaluatePoliciesPlugin from "./plugins/evaluatePolicies.js";
1718
import icalPlugin from "./routes/ics.js";
1819
import vendingPlugin from "./routes/vending.js";
1920
import * as dotenv from "dotenv";
@@ -99,6 +100,7 @@ async function init(prettyPrint: boolean = false) {
99100
await app.register(authorizeFromSchemaPlugin);
100101
await app.register(fastifyAuthPlugin);
101102
await app.register(FastifyAuthProvider);
103+
await app.register(evaluatePoliciesPlugin);
102104
await app.register(errorHandlerPlugin);
103105
await app.register(fastifyZodOpenApiPlugin);
104106
await app.register(fastifySwagger, {

src/api/plugins/auth.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
} from "@aws-sdk/client-dynamodb";
2222
import { getApiKeyData, getApiKeyParts } from "api/functions/apiKey.js";
2323
import { RequestThrottled } from "@aws-sdk/client-sqs";
24+
import { evaluatePolicy } from "api/policies/evaluator.js";
25+
import { AuthorizationPoliciesRegistry } from "api/policies/definition.js";
2426

2527
export function intersection<T>(setA: Set<T>, setB: Set<T>): Set<T> {
2628
const _intersection = new Set<T>();
@@ -120,6 +122,7 @@ const authPlugin: FastifyPluginAsync = async (fastify, _options) => {
120122
request.username = `acmuiuc_${apikeyId}`;
121123
request.userRoles = rolesSet;
122124
request.tokenPayload = undefined; // there's no token data
125+
request.policyRestrictions = keyData.restrictions;
123126
return new Set(keyData.roles);
124127
};
125128
fastify.decorate(

src/api/plugins/evaluatePolicies.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import fp from "fastify-plugin";
2+
import { FastifyPluginAsync, FastifyRequest } from "fastify";
3+
import { UnauthorizedError } from "../../common/errors/index.js";
4+
import {
5+
AuthorizationPoliciesRegistry,
6+
AvailableAuthorizationPolicies,
7+
} from "api/policies/definition.js";
8+
import { evaluatePolicy } from "api/policies/evaluator.js";
9+
10+
/**
11+
* Evaluates all policy restrictions for a request
12+
* @param {FastifyRequest} request - The Fastify request object
13+
* @returns {Promise<boolean>} - True if all policies pass, throws error otherwise
14+
*/
15+
export const evaluateAllRequestPolicies = async (
16+
request: FastifyRequest,
17+
): Promise<boolean | string> => {
18+
if (!request.policyRestrictions) {
19+
return true;
20+
}
21+
22+
for (const restriction of request.policyRestrictions) {
23+
if (
24+
AuthorizationPoliciesRegistry[
25+
restriction.name as keyof AvailableAuthorizationPolicies
26+
] === undefined
27+
) {
28+
request.log.warn(`Invalid policy name ${restriction.name}, skipping...`);
29+
continue;
30+
}
31+
32+
const policyFunction =
33+
AuthorizationPoliciesRegistry[
34+
restriction.name as keyof AvailableAuthorizationPolicies
35+
];
36+
const policyResult = evaluatePolicy(request, {
37+
policy: policyFunction,
38+
params: restriction.params,
39+
});
40+
41+
request.log.info(
42+
`Policy ${restriction.name} evaluated to ${policyResult.allowed}.`,
43+
);
44+
45+
if (!policyResult.allowed) {
46+
return policyResult.message;
47+
}
48+
}
49+
50+
return true;
51+
};
52+
53+
/**
54+
* Fastify plugin to evaluate authorization policies after the request body has been parsed
55+
*/
56+
const evaluatePoliciesPluginAsync: FastifyPluginAsync = async (
57+
fastify,
58+
_options,
59+
) => {
60+
// Register a hook that runs after body parsing but before route handler
61+
fastify.addHook("preHandler", async (request: FastifyRequest, _reply) => {
62+
const result = await evaluateAllRequestPolicies(request);
63+
if (typeof result === "string") {
64+
throw new UnauthorizedError({
65+
message: result,
66+
});
67+
}
68+
});
69+
};
70+
71+
// Export the plugin as a properly wrapped fastify-plugin
72+
export default fp(evaluatePoliciesPluginAsync, {
73+
name: "evaluatePolicies",
74+
});

src/api/policies/definition.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { FastifyRequest } from "fastify";
2+
import { hostRestrictionPolicy } from "./events.js";
3+
import { z } from "zod";
4+
import { AuthorizationPolicyResult } from "./evaluator.js";
5+
type Policy<TParamsSchema extends z.ZodObject<any>> = {
6+
name: string;
7+
paramsSchema: TParamsSchema;
8+
evaluator: (
9+
request: FastifyRequest,
10+
params: z.infer<TParamsSchema>,
11+
) => AuthorizationPolicyResult;
12+
};
13+
14+
// Type to get parameters type from a policy
15+
type PolicyParams<T> = T extends Policy<infer U> ? z.infer<U> : never;
16+
17+
// Type for a registry of policies
18+
type PolicyRegistry = {
19+
[key: string]: Policy<any>;
20+
};
21+
22+
// Type to generate a strongly-typed version of the policy registry
23+
type TypedPolicyRegistry<T extends PolicyRegistry> = {
24+
[K in keyof T]: {
25+
name: T[K]["name"];
26+
params: PolicyParams<T[K]>;
27+
};
28+
};
29+
30+
export type AvailableAuthorizationPolicies = TypedPolicyRegistry<
31+
typeof AuthorizationPoliciesRegistry
32+
>;
33+
export const AuthorizationPoliciesRegistry = {
34+
EventsHostRestrictionPolicy: hostRestrictionPolicy,
35+
} as const;
36+
37+
export type AvailableAuthorizationPolicy = {
38+
[K in keyof typeof AuthorizationPoliciesRegistry]: {
39+
name: K;
40+
params: PolicyParams<(typeof AuthorizationPoliciesRegistry)[K]>;
41+
};
42+
}[keyof typeof AuthorizationPoliciesRegistry];

src/api/policies/evaluator.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { z } from "zod";
2+
import { FastifyRequest } from "fastify";
3+
4+
export const AuthorizationPolicyResultSchema = z.object({
5+
allowed: z.boolean(),
6+
message: z.string(),
7+
cacheKey: z.string().nullable(),
8+
});
9+
export type AuthorizationPolicyResult = z.infer<
10+
typeof AuthorizationPolicyResultSchema
11+
>;
12+
13+
export function createPolicy<TParamsSchema extends z.ZodObject<any>>(
14+
name: string,
15+
paramsSchema: TParamsSchema,
16+
evaluatorFn: (
17+
request: FastifyRequest,
18+
params: z.infer<TParamsSchema>,
19+
) => AuthorizationPolicyResult,
20+
) {
21+
return {
22+
name,
23+
paramsSchema,
24+
evaluator: evaluatorFn,
25+
};
26+
}
27+
28+
export function applyPolicy<TParamsSchema extends z.ZodObject<any>>(
29+
policy: {
30+
name: string;
31+
paramsSchema: TParamsSchema;
32+
evaluator: (
33+
request: FastifyRequest,
34+
params: z.infer<TParamsSchema>,
35+
) => AuthorizationPolicyResult;
36+
},
37+
params: Record<string, string>,
38+
) {
39+
// Validate and transform parameters using the schema
40+
const validatedParams = policy.paramsSchema.parse(params);
41+
42+
return {
43+
policy,
44+
params: validatedParams,
45+
};
46+
}
47+
48+
export function evaluatePolicy<TParamsSchema extends z.ZodObject<any>>(
49+
request: FastifyRequest,
50+
policyConfig: {
51+
policy: {
52+
name: string;
53+
paramsSchema: TParamsSchema;
54+
evaluator: (
55+
request: FastifyRequest,
56+
params: z.infer<TParamsSchema>,
57+
) => AuthorizationPolicyResult;
58+
};
59+
params: z.infer<TParamsSchema>;
60+
},
61+
): AuthorizationPolicyResult {
62+
try {
63+
return policyConfig.policy.evaluator(request, policyConfig.params);
64+
} catch (error: any) {
65+
return {
66+
cacheKey: `error:${policyConfig.policy.name}:${error.message}`,
67+
allowed: false,
68+
message: `Error evaluating policy ${policyConfig.policy.name}: ${error.message}`,
69+
};
70+
}
71+
}

src/api/policies/events.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { z } from "zod";
2+
import { createPolicy } from "./evaluator.js";
3+
import { OrganizationList } from "common/orgs.js";
4+
import { FastifyRequest } from "fastify";
5+
import { EventPostRequest } from "api/routes/events.js";
6+
7+
export const hostRestrictionPolicy = createPolicy(
8+
"EventsHostRestrictionPolicy",
9+
z.object({ host: z.array(z.enum(OrganizationList)) }),
10+
(request: FastifyRequest, params) => {
11+
if (!request.url.startsWith("/api/v1/events")) {
12+
return {
13+
allowed: true,
14+
message: "Skipped as route not in scope.",
15+
cacheKey: null,
16+
};
17+
}
18+
const typedBody = request.body as EventPostRequest;
19+
if (!typedBody || !typedBody["host"]) {
20+
return {
21+
allowed: true,
22+
message: "Skipped as no host found.",
23+
cacheKey: null,
24+
};
25+
}
26+
if (!params.host.includes(typedBody["host"])) {
27+
return {
28+
allowed: false,
29+
message: `Denied by policy "EventsHostRestrictionPolicy".`,
30+
cacheKey: request.username || null,
31+
};
32+
}
33+
return {
34+
allowed: true,
35+
message: `Policy "EventsHostRestrictionPolicy". evaluated successfully.`,
36+
cacheKey: request.username || null,
37+
};
38+
},
39+
);

src/api/routes/apiKey.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import rateLimiter from "api/plugins/rateLimiter.js";
33
import { withRoles, withTags } from "api/components/index.js";
44
import { AppRoles } from "common/roles.js";
55
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
6-
import { ApiKeyDynamoEntry, apiKeyPostBody } from "common/types/apiKey.js";
6+
import { apiKeyPostBody } from "common/types/apiKey.js";
77
import { createApiKey } from "api/functions/apiKey.js";
88
import { buildAuditLogTransactPut } from "api/functions/auditLog.js";
99
import { Modules } from "common/modules.js";
@@ -22,6 +22,7 @@ import {
2222
ValidationError,
2323
} from "common/errors/index.js";
2424
import { z } from "zod";
25+
import { ApiKeyDynamoEntry } from "api/functions/apiKey.js";
2526

2627
const apiKeyRoute: FastifyPluginAsync = async (fastify, _options) => {
2728
await fastify.register(rateLimiter, {

src/api/routes/events.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ import {
1818
DatabaseFetchError,
1919
DatabaseInsertError,
2020
DiscordEventError,
21+
InternalServerError,
2122
NotFoundError,
2223
UnauthenticatedError,
24+
UnauthorizedError,
2325
ValidationError,
2426
} from "../../common/errors/index.js";
2527
import { randomUUID } from "crypto";
@@ -42,6 +44,8 @@ import {
4244
} from "fastify-zod-openapi";
4345
import { ts, withRoles, withTags } from "api/components/index.js";
4446
import { MAX_METADATA_KEYS, metadataSchema } from "common/types/events.js";
47+
import { evaluateAllRequestPolicies } from "api/plugins/evaluatePolicies.js";
48+
import { request } from "http";
4549

4650
const createProjectionParams = (includeMetadata: boolean = false) => {
4751
// Object mapping attribute names to their expression aliases
@@ -434,6 +438,37 @@ const eventsPlugin: FastifyPluginAsyncZodOpenApi = async (
434438
}),
435439
) satisfies FastifyZodOpenApiSchema,
436440
onRequest: fastify.authorizeFromSchema,
441+
preHandler: async (request, reply) => {
442+
if (request.policyRestrictions) {
443+
const response = await fastify.dynamoClient.send(
444+
new GetItemCommand({
445+
TableName: genericConfig.EventsDynamoTableName,
446+
Key: marshall({ id: request.params.id }),
447+
}),
448+
);
449+
const item = response.Item ? unmarshall(response.Item) : null;
450+
if (!item) {
451+
return reply.status(204).send();
452+
}
453+
const fakeBody = { ...request, body: item, url: request.url };
454+
try {
455+
const result = await evaluateAllRequestPolicies(fakeBody);
456+
if (typeof result === "string") {
457+
throw new UnauthorizedError({
458+
message: result,
459+
});
460+
}
461+
} catch (err) {
462+
if (err instanceof BaseError) {
463+
throw err;
464+
}
465+
fastify.log.error(err);
466+
throw new InternalServerError({
467+
message: "Failed to evaluate policies.",
468+
});
469+
}
470+
}
471+
},
437472
},
438473
async (request, reply) => {
439474
const id = request.params.id;

src/api/types.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
88
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
99
import { SQSClient } from "@aws-sdk/client-sqs";
1010
import { CloudFrontKeyValueStoreClient } from "@aws-sdk/client-cloudfront-keyvaluestore";
11+
import { AvailableAuthorizationPolicy } from "./policies/definition";
1112

1213
declare module "fastify" {
1314
interface FastifyInstance {
@@ -38,6 +39,7 @@ declare module "fastify" {
3839
username?: string;
3940
userRoles?: Set<AppRoles>;
4041
tokenPayload?: AadToken;
42+
policyRestrictions?: AvailableAuthorizationPolicy[];
4143
}
4244
}
4345

src/common/types/apiKey.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,9 @@ export type ApiKeyMaskedEntry = {
88
description: string;
99
createdAt: number;
1010
expiresAt?: number;
11+
restrictions?: Record<string, any>;
1112
}
1213

13-
export type ApiKeyDynamoEntry = ApiKeyMaskedEntry & {
14-
keyHash: string;
15-
};
16-
1714
export type DecomposedApiKey = {
1815
prefix: string;
1916
id: string;

0 commit comments

Comments
 (0)