From d765fd57ffcaa9a9630e2e70cbec3546effbc854 Mon Sep 17 00:00:00 2001 From: Lucian Buzzo Date: Thu, 21 Mar 2024 17:18:26 +0000 Subject: [PATCH 1/2] fix: improve startup performance by unifying queries for pg policies and roles --- src/index.ts | 64 ++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index e01cf48..2dd798f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,17 @@ const debug = (...args: unknown[]) => { type Operation = (typeof VALID_OPERATIONS)[number]; export type Models = Prisma.ModelName; +interface PgPolicy { + policyname: string; + tablename: string; + qual: string | null; + with_check: string | null; +} + +interface PgRole { + rolname: string; +} + interface ClientOptions { /** The maximum amount of time Yates will wait to acquire a transaction from the database. The default value is 30 seconds. */ txMaxWait?: number; @@ -244,6 +255,7 @@ export const createClient = ( const setRLS = async ( prisma: PrismaClient, + pgPolicies: PgPolicy[], table: string, roleName: string, operation: Operation, @@ -255,10 +267,9 @@ const setRLS = async ( // Check if RLS exists const policyName = roleName; - // biome-ignore lint/suspicious/noExplicitAny: TODO fix this, by providing the correct type for the catalog - const rows: any[] = await prisma.$queryRawUnsafe(` - select * from pg_catalog.pg_policies where tablename = '${table}' AND policyname = '${policyName}'; - `); + const rows = pgPolicies.filter( + (row) => row.tablename === table && row.policyname === policyName, + ); debug("Creating RLS policy", policyName); debug("On table", table); @@ -266,7 +277,22 @@ const setRLS = async ( debug("To role", roleName); debug("With expression", expression); + // If the expression is a plain "true" it is not wrapped in parentheses + const normalizedExpression = + expression === "true" + ? expression + : `(${expression.replace(/(\r\n|\n|\r)/gm, "")})`; + + // If the op is INSERT, the expression is in the "with_check" column + const normalizedQual = + operation === "INSERT" + ? rows?.[0]?.with_check?.replace(/(\r\n|\n|\r)/gm, "") + : rows?.[0]?.qual?.replace(/(\r\n|\n|\r)/gm, ""); + // IF RLS doesn't exist or expression is different, set RLS + // Note that PG performs various optimizations and mods to the expression + // on write so we need to normalize it before comparing, and even then it + // might not be exactly the same if (rows.length === 0) { // If the operation is an insert or update, we need to use a different syntax as the "WITH CHECK" expression is used. if (operation === "INSERT") { @@ -278,7 +304,7 @@ const setRLS = async ( CREATE POLICY ${policyName} ON "public"."${table}" FOR ${operation} TO ${roleName} USING (${expression}); `); } - } else if (rows[0].qual !== expression) { + } else if (normalizedQual !== normalizedExpression) { if (operation === "INSERT") { await prisma.$queryRawUnsafe(` ALTER POLICY ${policyName} ON "public"."${table}" TO ${roleName} WITH CHECK (${expression}); @@ -381,6 +407,13 @@ export const createRoles = async < const roles = getRoles(abilities as T); + const pgRoles: PgRole[] = await prisma.$queryRawUnsafe(` + select * from pg_catalog.pg_roles + `); + const pgPolicies: PgPolicy[] = await prisma.$queryRawUnsafe(` + select * from pg_catalog.pg_policies; + `); + // For each of the models and abilities, create a role and a corresponding RLS policy // We can then mix & match these roles to create a user's permissions by granting them to a user role (like SUPER_ADMIN) for (const model in abilities) { @@ -405,9 +438,14 @@ export const createRoles = async < const roleName = createAbilityName(model, slug); // Check if role already exists - await prisma.$transaction([ - takeLock(prisma), - prisma.$queryRawUnsafe(` + if ( + pgRoles.find((role: { rolname: string }) => role.rolname === roleName) + ) { + debug("Role already exists", roleName); + } else { + await prisma.$transaction([ + takeLock(prisma), + prisma.$queryRawUnsafe(` do $$ begin @@ -418,14 +456,16 @@ export const createRoles = async < $$ ; `), - prisma.$queryRawUnsafe(` + prisma.$queryRawUnsafe(` GRANT ${ability.operation} ON "${table}" TO ${roleName}; `), - ]); + ]); + } if (ability.expression) { await setRLS( prisma, + pgPolicies, table, roleName, ability.operation, @@ -554,6 +594,8 @@ export const setup = async < >( params: SetupParams, ) => { + const start = performance.now(); + const { prisma, customAbilities, getRoles, getContext } = params; await createRoles({ prisma, @@ -562,5 +604,7 @@ export const setup = async < }); const client = createClient(prisma, getContext, params.options); + debug("Setup completed in", performance.now() - start, "ms"); + return client; }; From 07d9ec8f498fb4ce769d55eeb25280420899cf60 Mon Sep 17 00:00:00 2001 From: Lucian Buzzo Date: Thu, 21 Mar 2024 17:23:16 +0000 Subject: [PATCH 2/2] chore: switch to debug module --- README.md | 2 +- package-lock.json | 36 ++++++++++++++++++++++++++++++------ package.json | 4 +++- src/index.ts | 9 ++------- 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index f1edcaf..e318acb 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ When defining an ability you need to provide the following properties: ### Debug -To run Yates in debug mode, set the `YATES_DEBUG` environment variable to `true`. +To run Yates in debug mode, use the environment variable `DEBUG=yates`. ## Known limitations diff --git a/package-lock.json b/package-lock.json index 9fdf7ad..6c8e493 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "3.3.2", "license": "MIT", "dependencies": { + "@types/debug": "^4.1.12", + "debug": "^4.3.4", "lodash": "^4.17.21", "node-sql-parser": "^4.12.0", "type-fest": "^4.10.3" @@ -1337,6 +1339,14 @@ "@types/node": "*" } }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.6.tgz", @@ -1386,6 +1396,11 @@ "integrity": "sha512-km+Vyn3BYm5ytMO13k9KTp27O75rbQ0NFw+U//g+PX7VZyjCioXaRFisqSIJRECljcTv73G3i6BpglNGHgUQ5A==", "dev": true }, + "node_modules/@types/ms": { + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" + }, "node_modules/@types/node": { "version": "18.11.18", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", @@ -1873,7 +1888,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -3220,8 +3234,7 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/natural-compare": { "version": "1.4.0", @@ -5179,6 +5192,14 @@ "@types/node": "*" } }, + "@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "requires": { + "@types/ms": "*" + } + }, "@types/graceful-fs": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.6.tgz", @@ -5228,6 +5249,11 @@ "integrity": "sha512-km+Vyn3BYm5ytMO13k9KTp27O75rbQ0NFw+U//g+PX7VZyjCioXaRFisqSIJRECljcTv73G3i6BpglNGHgUQ5A==", "dev": true }, + "@types/ms": { + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" + }, "@types/node": { "version": "18.11.18", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.18.tgz", @@ -5593,7 +5619,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "requires": { "ms": "2.1.2" } @@ -6604,8 +6629,7 @@ "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "natural-compare": { "version": "1.4.0", diff --git a/package.json b/package.json index 86f76b5..6ccc388 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "uuid": "^9.0.0" }, "dependencies": { + "@types/debug": "^4.1.12", + "debug": "^4.3.4", "lodash": "^4.17.21", "node-sql-parser": "^4.12.0", "type-fest": "^4.10.3" @@ -46,4 +48,4 @@ "@prisma/client": "^5.0.0", "prisma": "^5.0.0" } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 2dd798f..816601b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import * as crypto from "crypto"; import { Prisma, PrismaClient } from "@prisma/client"; +import logger from "debug"; import difference from "lodash/difference"; import flatMap from "lodash/flatMap"; import map from "lodash/map"; @@ -8,13 +9,7 @@ import { Expression, RuntimeDataModel, expressionToSQL } from "./expressions"; const VALID_OPERATIONS = ["SELECT", "UPDATE", "INSERT", "DELETE"] as const; -const DEBUG = process.env.YATES_DEBUG === "1"; - -const debug = (...args: unknown[]) => { - if (DEBUG) { - console.log(...args); - } -}; +const debug = logger("yates"); type Operation = (typeof VALID_OPERATIONS)[number]; export type Models = Prisma.ModelName;