diff --git a/README.md b/README.md index ae82d9b..f889aaa 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,20 @@ When defining an ability you need to provide the following properties: Yates uses a transaction to apply the RLS policies to each query. This means that if you are using transactions in your application, rollbacks will not work as expected. This is because [Prisma has poor support for nested transactions](https://github.com/prisma/prisma/issues/15212) and will `COMMIT` the inner transaction even if the outer transaction is rolled back. If you need this functionality and you are using Yates, you can return `null` from the `getContext()` setup method to bypass the internal transaction, and therefore the RLS policies for the current request. see the `nested-transactions.spec.ts` test case for an example of how to do this. +### Unsupported Prisma Client query features + +If you are using the Prisma client to construct an ability expression, the following `where` keywords are not supported. + +- `AND` +- `OR` +- `NOT` +- `is` +- `isNot` + +Additionally, using context or row values to query Prisma Enums is not supported. + +If you need to use these expressions, you can use the `expression` property of the ability to write a raw SQL expression instead. + ## License The project is licensed under the MIT license. diff --git a/docker-compose.yml b/docker-compose.yml index 5b0e028..542dd32 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,4 +34,4 @@ services: interval: 5s timeout: 5s retries: 5 - # command: ["postgres", "-c", "log_statement=all", "-c", "log_destination=stderr"] + command: ["postgres", "-c", "log_statement=all", "-c", "log_destination=stderr"] diff --git a/prisma/migrations/20240115145122_add_test_model_for_multi_org_permission_structure/migration.sql b/prisma/migrations/20240115145122_add_test_model_for_multi_org_permission_structure/migration.sql new file mode 100644 index 0000000..e1c1e74 --- /dev/null +++ b/prisma/migrations/20240115145122_add_test_model_for_multi_org_permission_structure/migration.sql @@ -0,0 +1,57 @@ +/* + Warnings: + + - You are about to drop the column `role` on the `User` table. All the data in the column will be lost. + +*/ +-- CreateEnum +CREATE TYPE "RoleEnum" AS ENUM ('USER', 'ORGANIZATION_ADMIN', 'ADMIN'); + +-- AlterTable +ALTER TABLE "User" DROP COLUMN "role"; + +-- DropEnum +DROP TYPE "Role"; + +-- CreateTable +CREATE TABLE "Organization" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "name" TEXT, + + CONSTRAINT "Organization_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "RoleAssignment" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" INTEGER NOT NULL, + "organizationId" INTEGER NOT NULL, + "roleId" INTEGER NOT NULL, + + CONSTRAINT "RoleAssignment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Role" ( + "id" SERIAL NOT NULL, + "name" "RoleEnum" NOT NULL, + + CONSTRAINT "Role_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Organization_name_key" ON "Organization"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name"); + +-- AddForeignKey +ALTER TABLE "RoleAssignment" ADD CONSTRAINT "RoleAssignment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoleAssignment" ADD CONSTRAINT "RoleAssignment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoleAssignment" ADD CONSTRAINT "RoleAssignment_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20240115145706_/migration.sql b/prisma/migrations/20240115145706_/migration.sql new file mode 100644 index 0000000..0ef6aea --- /dev/null +++ b/prisma/migrations/20240115145706_/migration.sql @@ -0,0 +1,75 @@ +/* + Warnings: + + - The primary key for the `Organization` table will be changed. If it partially fails, the table could be left without primary key constraint. + - The primary key for the `Role` table will be changed. If it partially fails, the table could be left without primary key constraint. + - The primary key for the `RoleAssignment` table will be changed. If it partially fails, the table could be left without primary key constraint. + - The primary key for the `User` table will be changed. If it partially fails, the table could be left without primary key constraint. + +*/ +-- DropForeignKey +ALTER TABLE "Hat" DROP CONSTRAINT "Hat_userId_fkey"; + +-- DropForeignKey +ALTER TABLE "Post" DROP CONSTRAINT "Post_authorId_fkey"; + +-- DropForeignKey +ALTER TABLE "RoleAssignment" DROP CONSTRAINT "RoleAssignment_organizationId_fkey"; + +-- DropForeignKey +ALTER TABLE "RoleAssignment" DROP CONSTRAINT "RoleAssignment_roleId_fkey"; + +-- DropForeignKey +ALTER TABLE "RoleAssignment" DROP CONSTRAINT "RoleAssignment_userId_fkey"; + +-- AlterTable +ALTER TABLE "Hat" ALTER COLUMN "userId" SET DATA TYPE TEXT; + +-- AlterTable +ALTER TABLE "Organization" DROP CONSTRAINT "Organization_pkey", +ALTER COLUMN "id" DROP DEFAULT, +ALTER COLUMN "id" SET DATA TYPE TEXT, +ADD CONSTRAINT "Organization_pkey" PRIMARY KEY ("id"); +DROP SEQUENCE "Organization_id_seq"; + +-- AlterTable +ALTER TABLE "Post" ALTER COLUMN "authorId" SET DATA TYPE TEXT; + +-- AlterTable +ALTER TABLE "Role" DROP CONSTRAINT "Role_pkey", +ALTER COLUMN "id" DROP DEFAULT, +ALTER COLUMN "id" SET DATA TYPE TEXT, +ADD CONSTRAINT "Role_pkey" PRIMARY KEY ("id"); +DROP SEQUENCE "Role_id_seq"; + +-- AlterTable +ALTER TABLE "RoleAssignment" DROP CONSTRAINT "RoleAssignment_pkey", +ALTER COLUMN "id" DROP DEFAULT, +ALTER COLUMN "id" SET DATA TYPE TEXT, +ALTER COLUMN "userId" SET DATA TYPE TEXT, +ALTER COLUMN "organizationId" SET DATA TYPE TEXT, +ALTER COLUMN "roleId" SET DATA TYPE TEXT, +ADD CONSTRAINT "RoleAssignment_pkey" PRIMARY KEY ("id"); +DROP SEQUENCE "RoleAssignment_id_seq"; + +-- AlterTable +ALTER TABLE "User" DROP CONSTRAINT "User_pkey", +ALTER COLUMN "id" DROP DEFAULT, +ALTER COLUMN "id" SET DATA TYPE TEXT, +ADD CONSTRAINT "User_pkey" PRIMARY KEY ("id"); +DROP SEQUENCE "User_id_seq"; + +-- AddForeignKey +ALTER TABLE "RoleAssignment" ADD CONSTRAINT "RoleAssignment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoleAssignment" ADD CONSTRAINT "RoleAssignment_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "RoleAssignment" ADD CONSTRAINT "RoleAssignment_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "Role"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Hat" ADD CONSTRAINT "Hat_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20240115154022_drop_role_enum/migration.sql b/prisma/migrations/20240115154022_drop_role_enum/migration.sql new file mode 100644 index 0000000..7a0a114 --- /dev/null +++ b/prisma/migrations/20240115154022_drop_role_enum/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - Changed the type of `name` on the `Role` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- AlterTable +ALTER TABLE "Role" DROP COLUMN "name", +ADD COLUMN "name" TEXT NOT NULL; + +-- DropEnum +DROP TYPE "RoleEnum"; + +-- CreateIndex +CREATE UNIQUE INDEX "Role_name_key" ON "Role"("name"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0447d29..f1c6f82 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,13 +11,38 @@ generator client { } model User { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) - email String @unique - name String? - role Role @default(USER) - posts Post[] - hat Hat? + id String @id @default(uuid()) + createdAt DateTime @default(now()) + email String @unique + name String? + posts Post[] + hat Hat? + roleAssignment RoleAssignment[] +} + +model Organization { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + name String? @unique + roleAssignment RoleAssignment[] +} + +model RoleAssignment { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id]) + organization Organization @relation(fields: [organizationId], references: [id]) + role Role @relation(fields: [roleId], references: [id]) + + userId String + organizationId String + roleId String +} + +model Role { + id String @id @default(uuid()) + name String @unique + roleAssignment RoleAssignment[] } model Post { @@ -27,7 +52,7 @@ model Post { published Boolean @default(false) title String @db.VarChar(255) author User? @relation(fields: [authorId], references: [id]) - authorId Int? + authorId String? tags Tag[] } @@ -48,7 +73,7 @@ model Hat { id Int @id @default(autoincrement()) style String? user User? @relation(fields: [userId], references: [id]) - userId Int? @unique + userId String? @unique } model Account { @@ -56,8 +81,3 @@ model Account { balance Int @default(0) email String @unique } - -enum Role { - USER - ADMIN -} diff --git a/src/expressions.ts b/src/expressions.ts index f1a4382..f407af0 100644 --- a/src/expressions.ts +++ b/src/expressions.ts @@ -96,12 +96,31 @@ const tokenizeWhereExpression = ( // Check if the field is an object, if so, we need to recurse // This is a fairly simple approach but covers most cases like "some", "every", "none" etc. if (fieldData.kind === "object") { - for (const subField in value) { - const subValue = value[subField]; + // List queries will always have a sub-object of "every", "some" or "none", so we need to dropdown and iterate through them + if (fieldData.isList) { + for (const subField in value) { + const subValue = value[subField]; + + const { tokens: subTokens, where: subWhere } = tokenizeWhereExpression( + client, + subValue, + table, + fieldData.type, + tokens, + ); + + tokens = { + ...tokens, + ...subTokens, + }; + where[field][subField] = subWhere; + } + continue; + } else { const { tokens: subTokens, where: subWhere } = tokenizeWhereExpression( client, - subValue, + value, table, fieldData.type, tokens, @@ -112,9 +131,9 @@ const tokenizeWhereExpression = ( ...subTokens, }; - where[field][subField] = subWhere; + where[field] = subWhere; + continue; } - continue; } const isNumeric = PRISMA_NUMERIC_TYPES.includes(fieldData.type); const isColumnName = typeof value === "string" && !!value.match(/^___yates_row_/); diff --git a/test/integration/expressions.spec.ts b/test/integration/expressions.spec.ts index 9c1d6f5..bdca32e 100644 --- a/test/integration/expressions.spec.ts +++ b/test/integration/expressions.spec.ts @@ -9,6 +9,22 @@ let adminClient: PrismaClient; beforeAll(async () => { adminClient = new PrismaClient(); + + const roles = ["USER", "ORGANIZATION_ADMIN", "ADMIN"]; + + for (const role of roles) { + await adminClient.role.upsert({ + where: { + name: role, + }, + create: { + name: role, + }, + update: { + name: role, + }, + }); + } }); describe("expressions", () => { @@ -315,13 +331,13 @@ describe("expressions", () => { setup({ prisma: initial, customAbilities: { - User: { - numericIdSelect: { + Item: { + numericFieldSelect: { description: "Test ability", operation: "SELECT", expression: () => { return { - id: "escape'--", + stock: "escape'--", }; }, }, @@ -329,7 +345,7 @@ describe("expressions", () => { }, getRoles(abilities) { return { - [role]: [abilities.User.numericIdSelect], + [role]: [abilities.Item.numericFieldSelect], }; }, getContext: () => ({ @@ -896,6 +912,125 @@ describe("expressions", () => { expect(result2).toBeNull(); }); + it("should be able to handle expressions that are multi-level objects that traverse a 1:1 relationship", async () => { + const initial = new PrismaClient(); + + const role = `USER_${uuid()}`; + + const org1 = await adminClient.organization.create({ + data: { + name: `test org ${uuid()}`, + }, + }); + const org2 = await adminClient.organization.create({ + data: { + name: `test org ${uuid()}`, + }, + }); + + // Setup a user that is an organization admin for org1 and a regular user for org2 + const user = await adminClient.user.create({ + data: { + email: `test-user-${uuid()}@example.com`, + roleAssignment: { + create: [ + { + role: { + connect: { + name: "ORGANIZATION_ADMIN", + }, + }, + organization: { + connect: { + id: org1.id, + }, + }, + }, + { + role: { + connect: { + name: "USER", + }, + }, + organization: { + connect: { + id: org2.id, + }, + }, + }, + ], + }, + }, + include: { + roleAssignment: true, + }, + }); + + const client = await setup({ + prisma: initial, + customAbilities: { + Organization: { + customUpdateAbility: { + description: "Update organization where user has ORGANIZATION_ADMIN role", + operation: "UPDATE", + expression: (client, row, context) => { + return client.roleAssignment.findFirst({ + where: { + organizationId: row("id"), + userId: context("ctx.user_id"), + role: { + name: "ORGANIZATION_ADMIN", + }, + }, + }); + }, + }, + }, + }, + getRoles(abilities) { + return { + [role]: [ + abilities.Organization.read, + abilities.Organization.customUpdateAbility, + // RoleAssignment and Role need to be included in the role for the above ability to work + abilities.RoleAssignment.read, + abilities.Role.read, + ], + }; + }, + getContext: () => ({ + role, + context: { + "ctx.user_id": user.id, + }, + }), + }); + + // An update to org2 should fail, as the user only has the USER role for that organization + await expect( + client.organization.update({ + where: { + id: org2.id, + }, + data: { + name: `Acme Corp ${uuid()}`, + }, + }), + ).rejects.toThrow("Record to update not found"); + + // An update to org1 should succeed, as the user has the ORGANIZATION_ADMIN role for that organization + const result = await client.organization.update({ + where: { + id: org1.id, + }, + data: { + name: `Acme Corp ${uuid()}`, + }, + }); + + expect(result.id).toBe(org1.id); + }); + it("should be able to handle context values that are arrays", async () => { const initial = new PrismaClient(); diff --git a/test/integration/index.spec.ts b/test/integration/index.spec.ts index 63eb5cf..c6ea38a 100644 --- a/test/integration/index.spec.ts +++ b/test/integration/index.spec.ts @@ -12,6 +12,8 @@ describe("setup", () => { describe("params.getRoles()", () => { it("should provide a set of built-in abilities for CRUD operations", async () => { const prisma = new PrismaClient(); + const models = ["User", "Organization", "RoleAssignment", "Role", "Post", "Item", "Tag", "Hat", "Account"]; + expect.assertions(models.length + 2); const getRoles = jest.fn((_abilities) => { return { @@ -28,12 +30,21 @@ describe("setup", () => { expect(getRoles.mock.calls).toHaveLength(1); const abilities = getRoles.mock.calls[0][0]; - expect(Object.keys(abilities)).toStrictEqual(["User", "Post", "Item", "Tag", "Hat", "Account"]); - expect(Object.keys(abilities.User)).toStrictEqual(["create", "read", "update", "delete"]); - expect(Object.keys(abilities.Post)).toStrictEqual(["create", "read", "update", "delete"]); - expect(Object.keys(abilities.Item)).toStrictEqual(["create", "read", "update", "delete"]); - expect(Object.keys(abilities.Tag)).toStrictEqual(["create", "read", "update", "delete"]); - expect(Object.keys(abilities.Hat)).toStrictEqual(["create", "read", "update", "delete"]); + expect(Object.keys(abilities)).toStrictEqual([ + "User", + "Organization", + "RoleAssignment", + "Role", + "Post", + "Item", + "Tag", + "Hat", + "Account", + ]); + + for (const model of models) { + expect(Object.keys(abilities[model])).toStrictEqual(["create", "read", "update", "delete"]); + } }); });