diff --git a/README.md b/README.md index 4bc6872..ae82d9b 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,13 @@ When defining an ability you need to provide the following properties: - `operation`: The operation that the ability is being applied to. This can be one of `CREATE`, `READ`, `UPDATE` or `DELETE`. +## Known limitations + +### Nested transactions + +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. + ## License The project is licensed under the MIT license. diff --git a/prisma/migrations/20231011122225_add_account_model/migration.sql b/prisma/migrations/20231011122225_add_account_model/migration.sql new file mode 100644 index 0000000..e00ba98 --- /dev/null +++ b/prisma/migrations/20231011122225_add_account_model/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "Account" ( + "id" SERIAL NOT NULL, + "amount" INTEGER NOT NULL DEFAULT 0, + "email" TEXT NOT NULL, + + CONSTRAINT "Account_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Account_email_key" ON "Account"("email"); diff --git a/prisma/migrations/20231011122429_match_fields_names_with_docs/migration.sql b/prisma/migrations/20231011122429_match_fields_names_with_docs/migration.sql new file mode 100644 index 0000000..b016dce --- /dev/null +++ b/prisma/migrations/20231011122429_match_fields_names_with_docs/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - You are about to drop the column `amount` on the `Account` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Account" DROP COLUMN "amount", +ADD COLUMN "balance" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 202a359..0447d29 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -51,6 +51,12 @@ model Hat { userId Int? @unique } +model Account { + id Int @id @default(autoincrement()) + balance Int @default(0) + email String @unique +} + enum Role { USER ADMIN diff --git a/test/integration/index.spec.ts b/test/integration/index.spec.ts index 72d99bd..63eb5cf 100644 --- a/test/integration/index.spec.ts +++ b/test/integration/index.spec.ts @@ -28,7 +28,7 @@ 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"]); + 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"]); diff --git a/test/integration/nested-transactions.spec.ts b/test/integration/nested-transactions.spec.ts new file mode 100644 index 0000000..4f19d3b --- /dev/null +++ b/test/integration/nested-transactions.spec.ts @@ -0,0 +1,149 @@ +import { PrismaClient } from "@prisma/client"; +import { v4 as uuid } from "uuid"; +import { setup } from "../../src"; + +// This function is setup to demonstrate the behaviour of nested transactions and rollbacks in Prisma. +// This example is based on interactive transaction docs on the Prisma website: +// https://www.prisma.io/docs/concepts/components/prisma-client/transactions#interactive-transactions +async function transfer(client: PrismaClient, from: string, to: string, amount: number) { + return await client.$transaction(async (tx) => { + // 1. Decrement amount from the sender. + const sender = await tx.account.update({ + data: { + balance: { + decrement: amount, + }, + }, + where: { + email: from, + }, + }); + + // 2. Verify that the sender's balance didn't go below zero. + if (sender.balance < 0) { + throw new Error(`${from} doesn't have enough to send ${amount}`); + } + + // 3. Increment the recipient's balance by amount + const recipient = await tx.account.update({ + data: { + balance: { + increment: amount, + }, + }, + where: { + email: to, + }, + }); + + return recipient; + }); +} + +describe("nested transactions", () => { + it("is expected to NOT rollback transactions if the outer transaction fails", async () => { + const role = `USER_${uuid()}`; + const client = await setup({ + prisma: new PrismaClient(), + getRoles(_abilities) { + return { + [role]: "*", + }; + }, + getContext: () => ({ + role, + context: {}, + }), + }); + + const email1 = `alice-${uuid()}@example.com`; + const account1 = await client.account.create({ + data: { + email: email1, + balance: 100, + }, + }); + const email2 = `bob-${uuid()}@example.com`; + const account2 = await client.account.create({ + data: { + email: email2, + balance: 100, + }, + }); + + // This transfer is successful + await transfer(client as PrismaClient, email1, email2, 100); + // This transfer fails because Alice doesn't have enough funds in her account + await expect(transfer(client as PrismaClient, email1, email2, 100)).rejects.toThrow(); + + // Due to lack of nested transaction support, the first transfer is not rolled back + // and the "from" account is still debited + const result1 = await client.account.findUniqueOrThrow({ + where: { + id: account1.id, + }, + }); + + expect(result1.balance).toBe(-100); + + const result2 = await client.account.findUniqueOrThrow({ + where: { + id: account2.id, + }, + }); + + expect(result2.balance).toBe(200); + }); + + it("should rollback transactions if the outer transaction fails if you bypass yates", async () => { + const role = `USER_${uuid()}`; + const client = await setup({ + prisma: new PrismaClient(), + getRoles(_abilities) { + return { + [role]: "*", + }; + }, + // Returning null here bypasses yates + getContext: () => null, + }); + + const email1 = `alice-${uuid()}@example.com`; + const account1 = await client.account.create({ + data: { + email: email1, + balance: 100, + }, + }); + const email2 = `bob-${uuid()}@example.com`; + const account2 = await client.account.create({ + data: { + email: email2, + balance: 100, + }, + }); + + // This transfer is successful + await transfer(client as PrismaClient, email1, email2, 100); + // This transfer fails because Alice doesn't have enough funds in her account + await expect(transfer(client as PrismaClient, email1, email2, 100)).rejects.toThrow(); + + // Because we bypassed the Yates internal transaction, the rollback is successful + // and the "from" account is never debited. + const result1 = await client.account.findUniqueOrThrow({ + where: { + id: account1.id, + }, + }); + + expect(result1.balance).toBe(0); + + const result2 = await client.account.findUniqueOrThrow({ + where: { + id: account2.id, + }, + }); + + expect(result2.balance).toBe(200); + }); +});