zenstack icon indicating copy to clipboard operation
zenstack copied to clipboard

Invalid result when using take (LIMIT)

Open israelins85 opened this issue 1 year ago • 23 comments

Description and expected behavior Using take on enhanced prisma returns different result than prisma for the same expected result.

Exemple code:

  • usuarioId on Aluno table is the user id

` export async function getPrismaEnhanced(req: NextApiRequest, res: NextApiResponse) { const userId = await userIdFromRequest(req); let user: auth.Usuario | null = null;

if (userId != null) {
    user = { id: userId };
} else {
    sendError(res, StatusCodes.LOCKED, "LOCKED");
    return;
}

const dispositivoId =
    req.headers["x-dispositivo-id"] ?? req.headers["x-device-id"];
if (dispositivoId != null) {
    prisma.$executeRawUnsafe(`SET var.device_id = '${dispositivoId}'`);
}

const teste = await prisma.aluno.findMany({
    take: 10,
    where: {
        usuarioId: userId,
    },
});

console.log("teste", teste);

const prismaEnhanced = enhance(
    prisma,
    { user: user ?? undefined },
    {
        logPrismaQuery: true,
    },
);

const teste2 = await prismaEnhanced.aluno.findMany({
    take: 10,
});

console.log("teste2", teste2);

return prismaEnhanced;

}`

Logging the statements at prisma level on pure prisma: SELECT "public"."aluno"."id", "public"."aluno"."usuarioid", "public"."aluno"."dthrcriacao", "public"."aluno"."dthratualizacao", "public"."aluno"."nome", "public"."aluno"."sobrenome", "public"."aluno"."celular", "public"."aluno"."email", "public"."aluno"."observacoes", "public"."aluno"."saldodeaulas", "public"."aluno"."dtnascimento", "public"."aluno"."peso", "public"."aluno"."altura", "public"."aluno"."removido" FROM "public"."aluno" WHERE "public"."aluno"."usuarioid" = $1 ORDER BY "public"."aluno"."id" ASC LIMIT $2 offset $3

And on enhanced:

SELECT "public"."aluno"."id", "public"."aluno"."usuarioid", "public"."aluno"."dthrcriacao", "public"."aluno"."dthratualizacao", "public"."aluno"."nome", "public"."aluno"."sobrenome", "public"."aluno"."celular", "public"."aluno"."email", "public"."aluno"."observacoes", "public"."aluno"."saldodeaulas", "public"."aluno"."dtnascimento", "public"."aluno"."peso", "public"."aluno"."altura", "public"."aluno"."removido" FROM "public"."aluno" WHERE 1 = 1 ORDER BY "public"."aluno"."id" ASC LIMIT $1 offset $2

and some records are skipped: prisma:info [policy] dropping aluno entity due to entity checker prisma:info [policy] dropping aluno entity due to entity checker prisma:info [policy] dropping aluno entity due to entity checker prisma:info [policy] dropping aluno entity due to entity checker prisma:info [policy] dropping aluno entity due to entity checker prisma:info [policy] dropping aluno entity due to entity checker prisma:info [policy] dropping aluno entity due to entity checker prisma:info [policy] dropping aluno entity due to entity checker

the records from other user...

An workaround is using on enhanced the same where

Environment (please complete the following information):

  • ZenStack version: 2.2.3
  • Prisma version: 5.15.0
  • Database type: 16

Additional context I guess need be highlighted the fact that the where need be still used;

israelins85 avatar Jun 17 '24 20:06 israelins85

Hi @israelins85 , thanks for reporting this. Could you share a ZModel snippet that can be used to reproduce the problem?

ymc9 avatar Jun 18 '24 10:06 ymc9

abstract model AbsModelPadrao {
  id              String   @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid

  dtHrCriacao     DateTime @default(now()) @db.Timestamptz
  dtHrAtualizacao DateTime @default(now()) @updatedAt() @db.Timestamptz
}

model Usuario extends AbsModelPadrao {
  nome              String
  sobrenome         String?
  login             String                @unique
  senha             String                @password @omit
  codIdioma         String                @default("en_US")
  dtFimTeste        DateTime              @db.Date @default(dbgenerated("(CURRENT_DATE + '30 days'::INTERVAL)")) @deny("update", true)

  pin               String?               @omit
  status            UsuarioStatus         @default(NaoAtivado)

  // users can change its own data
  @@allow("all", auth() != null && auth().id == id)

  @@auth()

  alunos            Aluno[]
}

abstract model AbsModelDadosDoUsuario {
  id              String   @id @default(dbgenerated("uuid_generate_v7()")) @db.Uuid
  usuarioId       String   @db.Uuid @default(auth().id)

  dtHrCriacao     DateTime @default(now()) @db.Timestamptz
  dtHrAtualizacao DateTime @default(now()) @updatedAt() @db.Timestamptz

  // relations
  usuario         Usuario  @relation(fields: [usuarioId], references: [id], onDelete: Cascade)

  // allow acess to myself
  @@allow("all", auth() != null && auth().id == usuarioId)

  @@index([usuarioId])
}

model Aluno extends AbsModelDadosDoUsuario {
  nome          String
  sobrenome     String?
  celular       String?
  email         String?
  observacoes   String?
  saldoDeAulas  Int                @default(0)
  dtNascimento  DateTime?          @db.Date
  peso          Float?
  altura        Float?
  removido      Boolean            @default(false)

  @@prisma.passthrough("@@index([nome(ops: raw(\"gin_trgm_ops\"))], type: Gin, name: \"Aluno_nome_idx\")")
}

israelins85 avatar Jun 18 '24 12:06 israelins85

Hi @israelins85 , I had a try but couldn't reproduce this issue with v2.2.3. I've shared a project here: https://github.com/ymc9/issue-1519

Could you check what differences it may have with your project? Thanks!

ymc9 avatar Jun 20 '24 11:06 ymc9

Hey, I'm having same issue.

Example of such model would be:

model Space {
    id             String                   @id @default(cuid())
    createdAt      DateTime                 @default(now())
    updatedAt      DateTime                 @updatedAt
    name           String                   @unique @db.Text
    userAccess     UserAccess[]

    @@allow('read', userAccess?[(userId == auth().id && spaceId == this.id)])
}

model UserAccess {
    id      String  @id @default(cuid())
    user    User    @relation(fields: [userId], references: [id], onDelete: Cascade)
    userId  String
    space   Space   @relation(fields: [spaceId], references: [id], onDelete: Cascade)
    spaceId String

    @@allow('read', userId == auth().id)
}

model SpaceBot{
    id        String           @id @default(cuid())
    createdAt DateTime         @default(now())
    updatedAt DateTime         @updatedAt
    name      String
    space     Space            @relation(fields: [spaceId], references: [id])
    spaceId   String


    @@allow('read', space.userAccess?[userId == auth().id]
}

If I do:

db.spaceBot.findMany({take: 1})

and I have many SpaceBots where user has no access to, then result is just empty with one message:

prisma:info [policy] dropping patient entity due to entity checker

Prisma queries are like:

===================
prisma:query SELECT "public"."Space"."id", "public"."Space"."createdAt", "public"."Space"."updatedAt", "public"."Space"."name" FROM "public"."Space" WHERE 1=1 ORDER BY "public"."Space"."updatedAt" DESC LIMIT $1 OFFSET $2
prisma:query SELECT "public"."UserAccess"."id", "public"."UserAccess"."userId", "public"."UserAccess"."spaceId" FROM "public"."UserAccess" WHERE "public"."UserAccess"."spaceId" IN ($1,$2,$3,$4,$5) OFFSET $6
prisma:query SELECT "public"."Space"."id", "public"."Space"."createdAt", "public"."Space"."updatedAt", "public"."Space"."name" FROM "public"."Space" WHERE 1=1 ORDER BY "public"."Space"."updatedAt" DESC LIMIT $1 OFFSET $2

What I noticed is that generated queries do LIMIT and OFFSET furst on all records and then policy filters are applied which can lead to returning not expecting results.

igorsimko avatar Aug 05 '24 12:08 igorsimko

A workaround for this is always fill the where's with the expected filter of data, forgetting the fact zenstack will do this, because internally the limit runs without any where, and before returned to us they are filtered.

israelins85 avatar Aug 05 '24 13:08 israelins85

Thanks for suggestion. But I thought that's exactly what ZenStack is made for. I have feeling like something is off either with zmodel or there's bug. I'll try to reproduce it in new project and share it here.

igorsimko avatar Aug 05 '24 17:08 igorsimko

Hey, I'm having same issue.

Example of such model would be:

model Space {
    id             String                   @id @default(cuid())
    createdAt      DateTime                 @default(now())
    updatedAt      DateTime                 @updatedAt
    name           String                   @unique @db.Text
    userAccess     UserAccess[]

    @@allow('read', userAccess?[(userId == auth().id && spaceId == this.id)])
}

model UserAccess {
    id      String  @id @default(cuid())
    user    User    @relation(fields: [userId], references: [id], onDelete: Cascade)
    userId  String
    space   Space   @relation(fields: [spaceId], references: [id], onDelete: Cascade)
    spaceId String

    @@allow('read', userId == auth().id)
}

model SpaceBot{
    id        String           @id @default(cuid())
    createdAt DateTime         @default(now())
    updatedAt DateTime         @updatedAt
    name      String
    space     Space            @relation(fields: [spaceId], references: [id])
    spaceId   String


    @@allow('read', space.userAccess?[userId == auth().id]
}

If I do:

db.spaceBot.findMany({take: 1})

and I have many SpaceBots where user has no access to, then result is just empty with one message:

prisma:info [policy] dropping patient entity due to entity checker

Prisma queries are like:

===================
prisma:query SELECT "public"."Space"."id", "public"."Space"."createdAt", "public"."Space"."updatedAt", "public"."Space"."name" FROM "public"."Space" WHERE 1=1 ORDER BY "public"."Space"."updatedAt" DESC LIMIT $1 OFFSET $2
prisma:query SELECT "public"."UserAccess"."id", "public"."UserAccess"."userId", "public"."UserAccess"."spaceId" FROM "public"."UserAccess" WHERE "public"."UserAccess"."spaceId" IN ($1,$2,$3,$4,$5) OFFSET $6
prisma:query SELECT "public"."Space"."id", "public"."Space"."createdAt", "public"."Space"."updatedAt", "public"."Space"."name" FROM "public"."Space" WHERE 1=1 ORDER BY "public"."Space"."updatedAt" DESC LIMIT $1 OFFSET $2

What I noticed is that generated queries do LIMIT and OFFSET furst on all records and then policy filters are applied which can lead to returning not expecting results.

I tried this sample but still couldn't reproduce it. Could you guys see if you still run into it with the latest version?

ymc9 avatar Aug 05 '24 17:08 ymc9

Hey @ymc9, since I can't include real code/model where my problem occurs I missed one additional relation in example which I shared above.

However I managed to replicate an issue in forked repo from your example https://github.com/igorsimko/issue-1519. I can mainly see one difference: as soon as there is && in Collection Predicate Expression, it seems that take is not behaving as I'd expect. (issue occurs at https://github.com/igorsimko/issue-1519/blob/main/script.ts#L55 and previous queries with take)

Could you have a look at my repo, maybe you can spot what's wrong there.

igorsimko avatar Aug 05 '24 20:08 igorsimko

Hey @ymc9, did you by any chance have time to look into example I provided. It seems like quite a important bug to solve or at least to understand why it's behaving in that way. Any feedback would be very much approciated!

igorsimko avatar Sep 18 '24 13:09 igorsimko

Sorry I missed your reply @igorsimko . I'll check it and update back here.

ymc9 avatar Sep 18 '24 15:09 ymc9

This is a tricky problem ... The root comes from the fact that Prisma doesn't support comparing fields from different tables.

For example, in the rule in https://github.com/igorsimko/issue-1519/blob/main/schema.zmodel:

model SpaceEnvironmentBot {
  ...
  @@allow('read', space.userAccess?[userId == auth().id && environmentId == this.environmentId])
}

The environmentId of SpaceEnvironmentBot is compared against environmentId of UserAccess. Since there's no way to achieve this via native Prisma calls, ZenStack falls back to checking the condition post-read, and apparently, this fallback doesn't work with fetching with a limit 🥲 - a fixed number of items are read but some of them are dropped.

I really wish Prisma could implement this feature ... before that, maybe ZenStack has to do repeated fetch - which is more complex and less performant.

Btw, I'm not sure if the cross-model field comparison is a must-have for your rules @igorsimko

ymc9 avatar Sep 20 '24 04:09 ymc9

Btw, there's actually a check in ZenStack's validator that should capture comparisons between different models and report an error ... However your rules somehow didn't get caught by the check. I'll check why.

ymc9 avatar Sep 20 '24 05:09 ymc9

Hey @ymc9, thanks for looking into that and for explanation.

Btw, I'm not sure if the cross-model field comparison is a must-have for your rules @igorsimko

This model design was only which I could came up with to ensure that User must have access to Bots having combination of Space and Environment at the same time. Do you maybe see some obvious solution how can we change schema to ensure same functionality?

If not, do you see some other workaround than including explicitly UserAccess to each query? I understand that this will stop working anyway as soon as you fix ZenStack's validator, am I right?

igorsimko avatar Oct 14 '24 07:10 igorsimko

Hey @ymc9 did you have a chance to look into validator issue? And possibly suggest some workaround other than explicitly including relations?

igorsimko avatar Nov 27 '24 13:11 igorsimko

Hey @ymc9 did you have a chance to look into validator issue? And possibly suggest some workaround other than explicitly including relations?

Add explicitly the where's that are make by the access restrictions and you will be good.

israelins85 avatar Nov 27 '24 13:11 israelins85

@israelins85 thanks for suggestion again, but this is very error prone and it makes then whole zenstack need for this use case redundant. Only thing I can think of is to extend all find* queries of allModels and dynamically add where/include clause for UserAccess. But again this is not really maintainable since you would have to refine it with every new model in schema to make sure it still includes all relations.

igorsimko avatar Nov 28 '24 08:11 igorsimko

@israelins85 thanks for suggestion again, but this is very error prone and it makes then whole zenstack need for this use case redundant. Only thing I can think of is to extend all find* queries of allModels and dynamically add where/include clause for UserAccess. But again this is not really maintainable since you would have to refine it with every new model in schema to make sure it still includes all relations.

Hi @israelins85 , the blocker here is how to express the condition environmentId == this.environmentId with a Prisma query. Do you currently have a way to write the filter manually with Prisma without using ZenStack?

ymc9 avatar Nov 28 '24 09:11 ymc9

@ymc9 I think @israelins85 was refering to just always include (or add where) UserAccess to your queries like

instead of

    const shouldBeBot22withTake1 = await prismaEnhanced.spaceEnvironmentBot.findMany({
        take: 1,
    });

do

    const shouldBeBot22withTake1 = await prismaEnhanced.spaceEnvironmentBot.findMany({
        take: 1,
        include: {
            environment: { include: { userAccess: true } },
            space: { include: { userAccess: true } }
        }
    });

With this suggestion, generated query correctly includes filter on user id t2.userId = ?

SELECT
    `main`.`SpaceEnvironmentBot`.`id`,
    `main`.`SpaceEnvironmentBot`.`createdAt`,
    `main`.`SpaceEnvironmentBot`.`updatedAt`,
    `main`.`SpaceEnvironmentBot`.`name`,
    `main`.`SpaceEnvironmentBot`.`spaceId`,
    `main`.`SpaceEnvironmentBot`.`environmentId`
FROM
    `main`.`SpaceEnvironmentBot`
    LEFT JOIN `main`.`Environment` AS `j1` ON (`j1`.`id`) = (`main`.`SpaceEnvironmentBot`.`environmentId`)
    LEFT JOIN `main`.`Space` AS `j3` ON (`j3`.`id`) = (`main`.`SpaceEnvironmentBot`.`spaceId`)
WHERE
    (
        (
            (`j1`.`id`) IN (
                SELECT
                    `t2`.`environmentId`
                FROM
                    `main`.`UserAccess` AS `t2`
                WHERE
                    (
                        `t2`.`userId` = ?
                        AND `t2`.`environmentId` IS NOT NULL
                    )
            )
            AND (`j1`.`id` IS NOT NULL)
        )
        AND (
            (`j3`.`id`) IN (
                SELECT
                    `t4`.`spaceId`
                FROM
                    `main`.`UserAccess` AS `t4`
                WHERE
                    (
                        `t4`.`userId` = ?
                        AND `t4`.`spaceId` IS NOT NULL
                    )
            )
            AND (`j3`.`id` IS NOT NULL)
        )
    )
ORDER BY
    `main`.`SpaceEnvironmentBot`.`id` ASC
LIMIT
    ?
OFFSET
    ?

But doing this for every query and keeping in mind you always have to add include/where is very error prone 🫤

igorsimko avatar Nov 28 '24 09:11 igorsimko

Sorry I was clear in the previous comment. What I meant was for this specific rule:

model SpaceEnvironmentBot {
  id            String      @id @default(cuid())
  createdAt     DateTime    @default(now())
  updatedAt     DateTime    @updatedAt
  name          String
  space         Space       @relation(fields: [spaceId], references: [id])
  spaceId       String
  environment   Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
  environmentId String

  @@allow('read', space.userAccess?[userId == auth().id && environmentId == this.environmentId])  // <-- HERE
}

The condition environmentId == this.environmentId requires comparing fields from the SpaceEnvironmentBot and UserAccess tables. Just by using Prisma (without ZenStack), is there currently a way to express such a filter? Because AFAIK Prisma hasn't implemented cross-table field comparison yet.

ymc9 avatar Nov 28 '24 10:11 ymc9

Thanks for explanation, that I don't know. I also tried adding user access directly to user while ehancing prisma and changing condition to:

@@allow('read', auth().userAccess?[environmentId == this.environmentId && spaceId== this.spaceId])

but it was same issue. @ymc9 does example above also requires cross-table field comparison? I though it would behave similary like accessing/comparing auth().id or other user fields

igorsimko avatar Nov 28 '24 10:11 igorsimko

I'm afraid there's no way to express this with Prisma either ... considering it needs to be transformed into a where filter like:

prisma.spaceEnvironmentBot.findMany({
  where: {
    environmentId: { in: { auth.userAccess.map(a => a. environmentId) }},
    spaceId: ?? // somehow get the same `auth.userAccess` element that matches the `environmentId` field above ...
  }
});

ymc9 avatar Nov 28 '24 10:11 ymc9

prisma.spaceEnvironmentBot.findMany({
  where: {
    environment: { userAccess: { some: { userId: auth.userId } } },
    spaces: { userAccess: { some: { userId: auth.userId, environment: { userAccess: { some: { userId: auth.userId } } }  } } 
  }
});

israelins85 avatar Nov 28 '24 12:11 israelins85

prisma.spaceEnvironmentBot.findMany({
  where: {
    environment: { userAccess: { some: { userId: auth.userId } } },
    spaces: { userAccess: { some: { userId: auth.userId, environment: { userAccess: { some: { userId: auth.userId } } }  } } 
  }
});

But in your revised rule, the auth.userAccess (which is an array) is needed to determine the access, right?

@@allow('read', auth().userAccess?[environmentId == this.environmentId && spaceId== this.spaceId])

ymc9 avatar Nov 29 '24 00:11 ymc9