Skip to content

Commit

Permalink
impr: move permission checks to contracts (@fehmer)
Browse files Browse the repository at this point in the history
  • Loading branch information
fehmer committed Sep 9, 2024
1 parent b06b9f7 commit 97b18e1
Show file tree
Hide file tree
Showing 19 changed files with 229 additions and 157 deletions.
7 changes: 4 additions & 3 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@
"dependencies": {
"@date-fns/utc": "1.2.0",
"@monkeytype/contracts": "workspace:*",
"@ts-rest/core": "3.49.3",
"@ts-rest/express": "3.49.3",
"@ts-rest/open-api": "3.49.3",
"@ts-rest/core": "3.51.0",
"@ts-rest/express": "3.51.0",
"@ts-rest/open-api": "3.51.0",
"bcrypt": "5.1.1",
"bullmq": "1.91.1",
"chalk": "4.1.2",
Expand Down Expand Up @@ -87,6 +87,7 @@
"eslint": "8.57.0",
"eslint-watch": "8.0.0",
"ioredis-mock": "7.4.0",
"openapi3-ts": "2.0.2",
"readline-sync": "1.4.10",
"supertest": "6.2.3",
"tsx": "4.16.2",
Expand Down
87 changes: 51 additions & 36 deletions backend/scripts/openapi.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import { generateOpenApi } from "@ts-rest/open-api";
import { contract } from "@monkeytype/contracts/index";
import { writeFileSync, mkdirSync } from "fs";
import { EndpointMetadata, Role } from "@monkeytype/contracts/schemas/api";
import type { OpenAPIObject, OperationObject } from "openapi3-ts";
import {
ApeKeyRateLimit,
EndpointMetadata,
} from "@monkeytype/contracts/schemas/api";
import type { OpenAPIObject } from "openapi3-ts";
import {
RateLimitIds,
getLimits,
limits,
RateLimit,
RateLimiterId,
Window,
} from "@monkeytype/contracts/rate-limit/index";
import { formatDuration } from "date-fns";
Expand Down Expand Up @@ -143,55 +140,73 @@ export function getOpenApi(): OpenAPIObject {
operationMapper: (operation, route) => {
const metadata = route.metadata as EndpointMetadata;

addRateLimit(operation, metadata);
if (!operation.description?.trim()?.endsWith("."))
operation.description += ".";
operation.description += "\n\n";

const result = {
...operation,
...addAuth(metadata),
...addTags(metadata),
};
addAuth(operation, metadata);
addRateLimit(operation, metadata);
addTags(operation, metadata);

return result;
return operation;
},
}
);
return openApiDocument;
}

function addAuth(metadata: EndpointMetadata | undefined): object {
const auth = metadata?.["authenticationOptions"] ?? {};
function addAuth(
operation: OperationObject,
metadata: EndpointMetadata | undefined
): void {
const auth = metadata?.authenticationOptions ?? {};
const roles = getRequiredRoles(metadata) ?? [];
const security: SecurityRequirementObject[] = [];
if (!auth.isPublic === true && !auth.isPublicOnDev === true) {
security.push({ BearerAuth: [] });
if (!auth.isPublic && !auth.isPublicOnDev) {
security.push({ BearerAuth: roles });

if (auth.acceptApeKeys === true) {
security.push({ ApeKey: [] });
security.push({ ApeKey: roles });
}
}

const includeInPublic = auth.isPublic === true || auth.acceptApeKeys === true;
return {
"x-public": includeInPublic ? "yes" : "no",
security,
};
operation["x-public"] = includeInPublic ? "yes" : "no";
operation.security = security;

if (roles.length !== 0) {
operation.description += `**Required roles:** ${roles.join(", ")}\n\n`;
}
}

function addTags(metadata: EndpointMetadata | undefined): object {
if (metadata === undefined || metadata.openApiTags === undefined) return {};
return {
tags: Array.isArray(metadata.openApiTags)
? metadata.openApiTags
: [metadata.openApiTags],
};
function getRequiredRoles(
metadata: EndpointMetadata | undefined
): Role[] | undefined {
if (metadata === undefined || metadata.requireRole === undefined)
return undefined;

if (Array.isArray(metadata.requireRole)) return metadata.requireRole;
return [metadata.requireRole];
}

function addRateLimit(operation, metadata: EndpointMetadata | undefined): void {
function addTags(
operation: OperationObject,
metadata: EndpointMetadata | undefined
): void {
if (metadata === undefined || metadata.openApiTags === undefined) return;
operation.tags = Array.isArray(metadata.openApiTags)
? metadata.openApiTags
: [metadata.openApiTags];
}

function addRateLimit(
operation: OperationObject,
metadata: EndpointMetadata | undefined
): void {
if (metadata === undefined || metadata.rateLimit === undefined) return;
const okResponse = operation.responses["200"];
if (okResponse === undefined) return;

if (!operation.description.trim().endsWith(".")) operation.description += ".";

operation.description += getRateLimitDescription(metadata.rateLimit);

okResponse["headers"] = {
Expand All @@ -211,10 +226,10 @@ function addRateLimit(operation, metadata: EndpointMetadata | undefined): void {
};
}

function getRateLimitDescription(limit: RateLimit | ApeKeyRateLimit): string {
function getRateLimitDescription(limit: RateLimiterId | RateLimitIds): string {
const limits = getLimits(limit);

let result = ` This operation can be called up to ${
let result = `**Rate limit:** This operation can be called up to ${
limits.limiter.max
} times ${formatWindow(limits.limiter.window)} for regular users`;

Expand All @@ -224,7 +239,7 @@ function getRateLimitDescription(limit: RateLimit | ApeKeyRateLimit): string {
)} with ApeKeys`;
}

return result + ".";
return result + ".\n\n";
}

function formatWindow(window: Window): string {
Expand Down
11 changes: 11 additions & 0 deletions backend/scripts/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "@monkeytype/typescript-config/base.json",
"compilerOptions": {
"target": "ES6"
},
"ts-node": {
"files": true
},
"files": ["../src/types/types.d.ts"],
"include": ["./**/*"]
}
2 changes: 0 additions & 2 deletions backend/src/api/routes/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import * as AdminController from "../controllers/admin";
import { adminContract } from "@monkeytype/contracts/admin";
import { initServer } from "@ts-rest/express";
import { validate } from "../../middlewares/configuration";
import { checkIfUserIsAdmin } from "../../middlewares/permission";
import { callController } from "../ts-rest-adapter";

const commonMiddleware = [
Expand All @@ -15,7 +14,6 @@ const commonMiddleware = [
},
invalidMessage: "Admin endpoints are currently disabled.",
}),
checkIfUserIsAdmin(),
];

const s = initServer();
Expand Down
8 changes: 1 addition & 7 deletions backend/src/api/routes/ape-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { apeKeysContract } from "@monkeytype/contracts/ape-keys";
import { initServer } from "@ts-rest/express";
import * as ApeKeyController from "../controllers/ape-key";
import { callController } from "../ts-rest-adapter";
import { checkUserPermissions } from "../../middlewares/permission";

import { validate } from "../../middlewares/configuration";

const commonMiddleware = [
Expand All @@ -12,12 +12,6 @@ const commonMiddleware = [
},
invalidMessage: "ApeKeys are currently disabled.",
}),
checkUserPermissions(["canManageApeKeys"], {
criteria: (user) => {
return user.canManageApeKeys ?? true;
},
invalidMessage: "You have lost access to ape keys, please contact support",
}),
];

const s = initServer();
Expand Down
4 changes: 0 additions & 4 deletions backend/src/api/routes/configuration.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { configurationContract } from "@monkeytype/contracts/configuration";
import { initServer } from "@ts-rest/express";
import { checkIfUserIsAdmin } from "../../middlewares/permission";
import * as ConfigurationController from "../controllers/configuration";
import { callController } from "../ts-rest-adapter";

Expand All @@ -11,14 +10,11 @@ export default s.router(configurationContract, {
handler: async (r) =>
callController(ConfigurationController.getConfiguration)(r),
},

update: {
middleware: [checkIfUserIsAdmin()],
handler: async (r) =>
callController(ConfigurationController.updateConfiguration)(r),
},
getSchema: {
middleware: [checkIfUserIsAdmin()],
handler: async (r) => callController(ConfigurationController.getSchema)(r),
},
});
7 changes: 6 additions & 1 deletion backend/src/api/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { ZodIssue } from "zod";
import { MonkeyValidationError } from "@monkeytype/contracts/schemas/api";
import { authenticateTsRestRequest } from "../../middlewares/auth";
import { rateLimitRequest } from "../../middlewares/rate-limit";
import { checkRequiredRole } from "../../middlewares/permission";

const pathOverride = process.env["API_PATH_OVERRIDE"];
const BASE_ROUTE = pathOverride !== undefined ? `/${pathOverride}` : "";
Expand Down Expand Up @@ -112,7 +113,11 @@ function applyTsRestApiRoutes(app: IRouter): void {
.status(422)
.json({ message, validationErrors } as MonkeyValidationError);
},
globalMiddleware: [authenticateTsRestRequest(), rateLimitRequest()],
globalMiddleware: [
authenticateTsRestRequest(),
rateLimitRequest(),
checkRequiredRole(),
],
});
}

Expand Down
18 changes: 0 additions & 18 deletions backend/src/api/routes/quotes.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,12 @@
import { quotesContract } from "@monkeytype/contracts/quotes";
import { initServer } from "@ts-rest/express";
import { validate } from "../../middlewares/configuration";
import { checkUserPermissions } from "../../middlewares/permission";
import * as QuoteController from "../controllers/quote";
import { callController } from "../ts-rest-adapter";

const checkIfUserIsQuoteMod = checkUserPermissions(["quoteMod"], {
criteria: (user) => {
return (
user.quoteMod === true ||
(typeof user.quoteMod === "string" && user.quoteMod !== "")
);
},
});

const s = initServer();
export default s.router(quotesContract, {
get: {
middleware: [checkIfUserIsQuoteMod],
handler: async (r) => callController(QuoteController.getQuotes)(r),
},
isSubmissionEnabled: {
Expand All @@ -37,11 +26,9 @@ export default s.router(quotesContract, {
handler: async (r) => callController(QuoteController.addQuote)(r),
},
approveSubmission: {
middleware: [checkIfUserIsQuoteMod],
handler: async (r) => callController(QuoteController.approveQuote)(r),
},
rejectSubmission: {
middleware: [checkIfUserIsQuoteMod],
handler: async (r) => callController(QuoteController.refuseQuote)(r),
},
getRating: {
Expand All @@ -58,11 +45,6 @@ export default s.router(quotesContract, {
},
invalidMessage: "Quote reporting is unavailable.",
}),
checkUserPermissions(["canReport"], {
criteria: (user) => {
return user.canReport !== false;
},
}),
],
handler: async (r) => callController(QuoteController.reportQuote)(r),
},
Expand Down
6 changes: 0 additions & 6 deletions backend/src/api/routes/users.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { usersContract } from "@monkeytype/contracts/users";
import { initServer } from "@ts-rest/express";
import { validate } from "../../middlewares/configuration";
import { checkUserPermissions } from "../../middlewares/permission";
import * as UserController from "../controllers/user";
import { callController } from "../ts-rest-adapter";

Expand Down Expand Up @@ -167,11 +166,6 @@ export default s.router(usersContract, {
},
invalidMessage: "User reporting is unavailable.",
}),
checkUserPermissions(["canReport"], {
criteria: (user) => {
return user.canReport !== false;
},
}),
],
handler: async (r) => callController(UserController.reportUser)(r),
},
Expand Down
Loading

0 comments on commit 97b18e1

Please sign in to comment.