diff options
author | Christian Fehmer <[email protected]> | 2024-09-10 11:35:57 +0200 |
---|---|---|
committer | GitHub <[email protected]> | 2024-09-10 11:35:57 +0200 |
commit | c7b3e2c916ee11c163feb60096caf9df942427d7 (patch) | |
tree | 932ef5a058601aa07ecacc994bd82c76aa78f39e /backend | |
parent | 14277538c307a5b2a0d03b183418e45304278846 (diff) | |
download | monkeytype-c7b3e2c916ee11c163feb60096caf9df942427d7.tar.gz monkeytype-c7b3e2c916ee11c163feb60096caf9df942427d7.zip |
impr: move permission checks to contracts (@fehmer, @miodec) (#5848)
!nuf
Diffstat (limited to 'backend')
-rw-r--r-- | backend/__tests__/middlewares/permission.spec.ts | 317 | ||||
-rw-r--r-- | backend/package.json | 7 | ||||
-rw-r--r-- | backend/scripts/openapi.ts | 87 | ||||
-rw-r--r-- | backend/scripts/tsconfig.json | 11 | ||||
-rw-r--r-- | backend/src/api/routes/admin.ts | 2 | ||||
-rw-r--r-- | backend/src/api/routes/ape-keys.ts | 8 | ||||
-rw-r--r-- | backend/src/api/routes/configuration.ts | 4 | ||||
-rw-r--r-- | backend/src/api/routes/index.ts | 7 | ||||
-rw-r--r-- | backend/src/api/routes/quotes.ts | 18 | ||||
-rw-r--r-- | backend/src/api/routes/users.ts | 6 | ||||
-rw-r--r-- | backend/src/middlewares/permission.ts | 219 |
11 files changed, 561 insertions, 125 deletions
diff --git a/backend/__tests__/middlewares/permission.spec.ts b/backend/__tests__/middlewares/permission.spec.ts new file mode 100644 index 000000000..19146ba2d --- /dev/null +++ b/backend/__tests__/middlewares/permission.spec.ts @@ -0,0 +1,317 @@ +import { Response } from "express"; +import { verifyPermissions } from "../../src/middlewares/permission"; +import { EndpointMetadata } from "@monkeytype/contracts/schemas/api"; +import * as Misc from "../../src/utils/misc"; +import * as AdminUids from "../../src/dal/admin-uids"; +import * as UserDal from "../../src/dal/user"; +import MonkeyError from "../../src/utils/error"; + +const uid = "123456789"; + +describe("permission middleware", () => { + const handler = verifyPermissions(); + const res: Response = {} as any; + const next = vi.fn(); + const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); + const isAdminMock = vi.spyOn(AdminUids, "isAdmin"); + const isDevMock = vi.spyOn(Misc, "isDevEnvironment"); + + beforeEach(() => { + next.mockReset(); + getPartialUserMock.mockReset().mockResolvedValue({} as any); + isDevMock.mockReset().mockReturnValue(false); + isAdminMock.mockReset().mockResolvedValue(false); + }); + afterEach(() => { + //next function must only be called once + expect(next).toHaveBeenCalledOnce(); + }); + + it("should bypass without requiredPermission", async () => { + //GIVEN + const req = givenRequest({}); + //WHEN + await handler(req, res, next); + + //THEN + expect(next).toHaveBeenCalledWith(); + }); + it("should bypass with empty requiredPermission", async () => { + //GIVEN + const req = givenRequest({ requirePermission: [] }); + //WHEN + await handler(req, res, next); + + //THE + expect(next).toHaveBeenCalledWith(); + }); + + describe("admin check", () => { + const requireAdminPermission: EndpointMetadata = { + requirePermission: "admin", + }; + + it("should fail without authentication", async () => { + //GIVEN + const req = givenRequest(requireAdminPermission); + //WHEN + await handler(req, res, next); + + //THEN + expect(next).toHaveBeenCalledWith( + new MonkeyError(403, "You don't have permission to do this.") + ); + }); + it("should pass without authentication if publicOnDev on dev", async () => { + //GIVEN + isDevMock.mockReturnValue(true); + const req = givenRequest( + { + ...requireAdminPermission, + authenticationOptions: { isPublicOnDev: true }, + }, + { uid } + ); + //WHEN + await handler(req, res, next); + + //THEN + expect(next).toHaveBeenCalledWith(); + }); + it("should fail without authentication if publicOnDev on prod ", async () => { + //GIVEN + const req = givenRequest( + { + ...requireAdminPermission, + authenticationOptions: { isPublicOnDev: true }, + }, + { uid } + ); + //WHEN + await handler(req, res, next); + + //THEN + expect(next).toHaveBeenCalledWith( + new MonkeyError(403, "You don't have permission to do this.") + ); + }); + it("should fail without admin permissions", async () => { + //GIVEN + const req = givenRequest(requireAdminPermission, { uid }); + + //WHEN + await handler(req, res, next); + + //THEN + expect(next).toHaveBeenCalledWith( + new MonkeyError(403, "You don't have permission to do this.") + ); + expect(isAdminMock).toHaveBeenCalledWith(uid); + }); + }); + describe("user checks", () => { + it("should fetch user only once", async () => { + //GIVEN + const req = givenRequest( + { + requirePermission: ["canReport", "canManageApeKeys"], + }, + { uid } + ); + + //WHEN + await handler(req, res, next); + + //THEN + expect(getPartialUserMock).toHaveBeenCalledOnce(); + expect(getPartialUserMock).toHaveBeenCalledWith( + uid, + "check user permissions", + ["canReport", "canManageApeKeys"] + ); + }); + it("should fail if authentication is missing", async () => { + //GIVEN + const req = givenRequest({ + requirePermission: ["canReport", "canManageApeKeys"], + }); + + //WHEN + await handler(req, res, next); + + //THEN + expect(next).toHaveBeenCalledWith( + new MonkeyError( + 403, + "Failed to check permissions, authentication required." + ) + ); + }); + }); + describe("quoteMod check", () => { + const requireQuoteMod: EndpointMetadata = { + requirePermission: "quoteMod", + }; + + it("should pass for quoteAdmin", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({ quoteMod: true } as any); + const req = givenRequest(requireQuoteMod, { uid }); + + //WHEN + await handler(req, res, next); + + //THEN + expect(next).toHaveBeenCalledWith(); + expect(getPartialUserMock).toHaveBeenCalledWith( + uid, + "check user permissions", + ["quoteMod"] + ); + }); + it("should pass for specific language", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({ quoteMod: "english" } as any); + const req = givenRequest(requireQuoteMod, { uid }); + + //WHEN + await handler(req, res, next); + + //THEN + expect(next).toHaveBeenCalledWith(); + expect(getPartialUserMock).toHaveBeenCalledWith( + uid, + "check user permissions", + ["quoteMod"] + ); + }); + it("should fail for empty string", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({ quoteMod: "" } as any); + const req = givenRequest(requireQuoteMod, { uid }); + + //WHEN + await handler(req, res, next); + + //THEN + expect(next).toHaveBeenCalledWith( + new MonkeyError(403, "You don't have permission to do this.") + ); + }); + it("should fail for missing quoteMod", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({} as any); + const req = givenRequest(requireQuoteMod, { uid }); + + //WHEN + await handler(req, res, next); + + //THEN + expect(next).toHaveBeenCalledWith( + new MonkeyError(403, "You don't have permission to do this.") + ); + }); + }); + describe("canReport check", () => { + const requireCanReport: EndpointMetadata = { + requirePermission: "canReport", + }; + + it("should fail if user cannot report", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({ canReport: false } as any); + const req = givenRequest(requireCanReport, { uid }); + + //WHEN + await handler(req, res, next); + + //THEN + expect(next).toHaveBeenCalledWith( + new MonkeyError(403, "You don't have permission to do this.") + ); + expect(getPartialUserMock).toHaveBeenCalledWith( + uid, + "check user permissions", + ["canReport"] + ); + }); + it("should pass if user can report", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({ canReport: true } as any); + const req = givenRequest(requireCanReport, { uid }); + + //WHEN + await handler(req, res, next); + + //THEN + expect(next).toHaveBeenCalledWith(); + }); + it("should pass if canReport is not set", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({} as any); + const req = givenRequest(requireCanReport, { uid }); + + //WHEN + await handler(req, res, next); + + //THEN + expect(next).toHaveBeenCalledWith(); + }); + }); + describe("canManageApeKeys check", () => { + const requireCanReport: EndpointMetadata = { + requirePermission: "canManageApeKeys", + }; + + it("should fail if user cannot report", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({ canManageApeKeys: false } as any); + const req = givenRequest(requireCanReport, { uid }); + + //WHEN + await handler(req, res, next); + + //THEN + expect(next).toHaveBeenCalledWith( + new MonkeyError( + 403, + "You have lost access to ape keys, please contact support" + ) + ); + expect(getPartialUserMock).toHaveBeenCalledWith( + uid, + "check user permissions", + ["canManageApeKeys"] + ); + }); + it("should pass if user can report", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({ canManageApeKeys: true } as any); + const req = givenRequest(requireCanReport, { uid }); + + //WHEN + await handler(req, res, next); + + //THEN + expect(next).toHaveBeenCalledWith(); + }); + it("should pass if canManageApeKeys is not set", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({} as any); + const req = givenRequest(requireCanReport, { uid }); + + //WHEN + await handler(req, res, next); + + //THEN + expect(next).toHaveBeenCalledWith(); + }); + }); +}); + +function givenRequest( + metadata: EndpointMetadata, + decodedToken?: Partial<MonkeyTypes.DecodedToken> +): TsRestRequest { + return { tsRestRoute: { metadata }, ctx: { decodedToken } } as any; +} diff --git a/backend/package.json b/backend/package.json index 6e976b455..d57191413 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", @@ -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", diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index ffa5648df..83c78277a 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -2,14 +2,14 @@ import { generateOpenApi } from "@ts-rest/open-api"; import { contract } from "@monkeytype/contracts/index"; import { writeFileSync, mkdirSync } from "fs"; import { - ApeKeyRateLimit, EndpointMetadata, + Permission, } from "@monkeytype/contracts/schemas/api"; -import type { OpenAPIObject } from "openapi3-ts"; +import type { OpenAPIObject, OperationObject } from "openapi3-ts"; import { + RateLimitIds, getLimits, - limits, - RateLimit, + RateLimiterId, Window, } from "@monkeytype/contracts/rate-limit/index"; import { formatDuration } from "date-fns"; @@ -143,55 +143,76 @@ 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 permissions = getRequiredPermissions(metadata) ?? []; const security: SecurityRequirementObject[] = []; - if (!auth.isPublic === true && !auth.isPublicOnDev === true) { - security.push({ BearerAuth: [] }); + if (!auth.isPublic && !auth.isPublicOnDev) { + security.push({ BearerAuth: permissions }); if (auth.acceptApeKeys === true) { - security.push({ ApeKey: [] }); + security.push({ ApeKey: permissions }); } } 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 (permissions.length !== 0) { + operation.description += `**Required permissions:** ${permissions.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 getRequiredPermissions( + metadata: EndpointMetadata | undefined +): Permission[] | undefined { + if (metadata === undefined || metadata.requirePermission === undefined) + return undefined; + + if (Array.isArray(metadata.requirePermission)) + return metadata.requirePermission; + return [metadata.requirePermission]; } -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"] = { @@ -211,10 +232,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`; @@ -224,7 +245,7 @@ function getRateLimitDescription(limit: RateLimit | ApeKeyRateLimit): string { )} with ApeKeys`; } - return result + "."; + return result + ".\n\n"; } function formatWindow(window: Window): string { diff --git a/backend/scripts/tsconfig.json b/backend/scripts/tsconfig.json new file mode 100644 index 000000000..4de721467 --- /dev/null +++ b/backend/scripts/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@monkeytype/typescript-config/base.json", + "compilerOptions": { + "target": "ES6" + }, + "ts-node": { + "files": true + }, + "files": ["../src/types/types.d.ts"], + "include": ["./**/*"] +} diff --git a/backend/src/api/routes/admin.ts b/backend/src/api/routes/admin.ts index 4bed00ea3..805fd7d28 100644 --- a/backend/src/api/routes/admin.ts +++ b/backend/src/api/routes/admin.ts @@ -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 = [ @@ -15,7 +14,6 @@ const commonMiddleware = [ }, invalidMessage: "Admin endpoints are currently disabled.", }), - checkIfUserIsAdmin(), ]; const s = initServer(); diff --git a/backend/src/api/routes/ape-keys.ts b/backend/src/api/routes/ape-keys.ts index d59aca12e..08ff067b6 100644 --- a/backend/src/api/routes/ape-keys.ts +++ b/backend/src/api/routes/ape-keys.ts @@ -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 = [ @@ -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(); diff --git a/backend/src/api/routes/configuration.ts b/backend/src/api/routes/configuration.ts index e0b5df40e..952cff290 100644 --- a/backend/src/api/routes/configuration.ts +++ b/backend/src/api/routes/configuration.ts @@ -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"; @@ -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), }, }); diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 786b488d8..c969eed59 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -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 { verifyPermissions } from "../../middlewares/permission"; const pathOverride = process.env["API_PATH_OVERRIDE"]; const BASE_ROUTE = pathOverride !== undefined ? `/${pathOverride}` : ""; @@ -112,7 +113,11 @@ function applyTsRestApiRoutes(app: IRouter): void { .status(422) .json({ message, validationErrors } as MonkeyValidationError); }, - globalMiddleware: [authenticateTsRestRequest(), rateLimitRequest()], + globalMiddleware: [ + authenticateTsRestRequest(), + rateLimitRequest(), + verifyPermissions(), + ], }); } diff --git a/backend/src/api/routes/quotes.ts b/backend/src/api/routes/quotes.ts index 84711a730..bc48cd4d1 100644 --- a/backend/src/api/routes/quotes.ts +++ b/backend/src/api/routes/quotes.ts @@ -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: { @@ -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: { @@ -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), }, diff --git a/backend/src/api/routes/users.ts b/backend/src/api/routes/users.ts index 1537149f3..af6ecf82c 100644 --- a/backend/src/api/routes/users.ts +++ b/backend/src/api/routes/users.ts @@ -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"; @@ -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), }, diff --git a/backend/src/middlewares/permission.ts b/backend/src/middlewares/permission.ts index 8288dee89..2c22a9153 100644 --- a/backend/src/middlewares/permission.ts +++ b/backend/src/middlewares/permission.ts @@ -1,82 +1,199 @@ import _ from "lodash"; import MonkeyError from "../utils/error"; -import type { Response, NextFunction, RequestHandler } from "express"; +import type { Response, NextFunction } from "express"; import { getPartialUser } from "../dal/user"; import { isAdmin } from "../dal/admin-uids"; -import type { ValidationOptions } from "./configuration"; import { TsRestRequestHandler } from "@ts-rest/express"; import { TsRestRequestWithCtx } from "./auth"; -import { RequestAuthenticationOptions } from "@monkeytype/contracts/schemas/api"; +import { + EndpointMetadata, + RequestAuthenticationOptions, + PermissionId, +} from "@monkeytype/contracts/schemas/api"; import { isDevEnvironment } from "../utils/misc"; -/** - * Check if the user is an admin before handling request. - * Note that this middleware must be used after authentication in the middleware stack. - */ -export function checkIfUserIsAdmin< +type RequestPermissionCheck = { + type: "request"; + criteria: ( + req: TsRestRequestWithCtx, + metadata: EndpointMetadata | undefined + ) => Promise<boolean>; + invalidMessage?: string; +}; + +type UserPermissionCheck = { + type: "user"; + fields: (keyof MonkeyTypes.DBUser)[]; + criteria: (user: MonkeyTypes.DBUser) => boolean; + invalidMessage?: string; +}; + +type PermissionCheck = UserPermissionCheck | RequestPermissionCheck; + +function buildUserPermission<K extends keyof MonkeyTypes.DBUser>( + fields: K[], + criteria: (user: Pick<MonkeyTypes.DBUser, K>) => boolean, + invalidMessage?: string +): UserPermissionCheck { + return { + type: "user", + fields, + criteria, + invalidMessage: invalidMessage, + }; +} + +const permissionChecks: Record<PermissionId, PermissionCheck> = { + admin: { + type: "request", + criteria: async (req, metadata) => + await checkIfUserIsAdmin( + req.ctx.decodedToken, + metadata?.authenticationOptions + ), + }, + quoteMod: buildUserPermission( + ["quoteMod"], + (user) => + user.quoteMod === true || + (typeof user.quoteMod === "string" && user.quoteMod !== "") + ), + canReport: buildUserPermission( + ["canReport"], + (user) => user.canReport !== false + ), + canManageApeKeys: buildUserPermission( + ["canManageApeKeys"], + (user) => user.canManageApeKeys ?? true, + "You have lost access to ape keys, please contact support" + ), +}; + +export function verifyPermissions< T extends AppRouter | AppRoute >(): TsRestRequestHandler<T> { return async ( req: TsRestRequestWithCtx, _res: Response, next: NextFunction - ) => { - try { - const options: RequestAuthenticationOptions = - req.tsRestRoute["metadata"]?.["authenticationOptions"] ?? {}; + ): Promise<void> => { + const metadata = req.tsRestRoute["metadata"] as + | EndpointMetadata + | undefined; + const requiredPermissionIds = getRequiredPermissionIds(metadata); + if ( + requiredPermissionIds === undefined || + requiredPermissionIds.length === 0 + ) { + next(); + return; + } - if (options.isPublicOnDev && isDevEnvironment()) { - next(); + const checks = requiredPermissionIds.map((id) => permissionChecks[id]); + + if (checks.some((it) => it === undefined)) { + next(new MonkeyError(500, "Unknown permission id.")); + return; + } + + //handle request checks + const requestChecks = checks.filter((it) => it.type === "request"); + for (const check of requestChecks) { + if (!(await check.criteria(req, metadata))) { + next( + new MonkeyError( + 403, + check.invalidMessage ?? "You don't have permission to do this." + ) + ); return; } + } - const { uid } = req.ctx.decodedToken; - const admin = await isAdmin(uid); + //handle user checks + const userChecks = checks.filter((it) => it.type === "user"); + const checkResult = await checkUserPermissions( + req.ctx.decodedToken, + userChecks + ); - if (!admin) { - throw new MonkeyError(403, "You don't have permission to do this."); - } - } catch (error) { - next(error); + if (!checkResult.passed) { + next( + new MonkeyError( + 403, + checkResult.invalidMessage ?? "You don't have permission to do this." + ) + ); + return; } + //all checks passed next(); + return; }; } -/** - * Check user permissions before handling request. - * Note that this middleware must be used after authentication in the middleware stack. - */ -export function checkUserPermissions<K extends keyof MonkeyTypes.DBUser>( - fields: K[], - options: ValidationOptions<Pick<MonkeyTypes.DBUser, K>> -): RequestHandler { - const { criteria, invalidMessage = "You don't have permission to do this." } = - options; +function getRequiredPermissionIds( + metadata: EndpointMetadata | undefined +): PermissionId[] | undefined { + if (metadata === undefined || metadata.requirePermission === undefined) + return undefined; - return async ( - req: MonkeyTypes.Request, - _res: Response, - next: NextFunction - ) => { - try { - const { uid } = req.ctx.decodedToken; - - const userData = await getPartialUser( - uid, - "check user permissions", - fields - ); - const hasPermission = criteria(userData); + if (Array.isArray(metadata.requirePermission)) + return metadata.requirePermission; + return [metadata.requirePermission]; +} - if (!hasPermission) { - throw new MonkeyError(403, invalidMessage); - } - } catch (error) { - next(error); +async function checkIfUserIsAdmin( + decodedToken: MonkeyTypes.DecodedToken | undefined, + options: RequestAuthenticationOptions | undefined +): Promise<boolean> { + if (decodedToken === undefined) return false; + if (options?.isPublicOnDev && isDevEnvironment()) return true; + + return await isAdmin(decodedToken.uid); +} + +type CheckResult = + | { + passed: true; } + | { + passed: false; + invalidMessage?: string; + }; - next(); +async function checkUserPermissions( + decodedToken: MonkeyTypes.DecodedToken | undefined, + checks: UserPermissionCheck[] +): Promise<CheckResult> { + if (checks === undefined || checks.length === 0) { + return { + passed: true, + }; + } + if (decodedToken === undefined) { + return { + passed: false, + invalidMessage: "Failed to check permissions, authentication required.", + }; + } + + const user = (await getPartialUser( + decodedToken.uid, + "check user permissions", + checks.flatMap((it) => it.fields) + )) as MonkeyTypes.DBUser; + + for (const check of checks) { + if (!check.criteria(user)) + return { + passed: false, + invalidMessage: check.invalidMessage, + }; + } + + return { + passed: true, }; } |