cerbos / query-plan-adapters

Repo of adapters converting a Cerbos Query Plan to a data fetching layer
Apache License 2.0
16 stars 9 forks source link

Many-to-many dependency condition check #85

Open anotheri opened 17 hours ago

anotheri commented 17 hours ago

I use Prisma and I have a problem to make a consistent solution for the "is any related item" check with both prisma-plan-adaptor and cerbos.checkResources implementations.

So I have N:M relations between User and Role models.

And policy like this:

# yaml-language-server: $schema=https://api.cerbos.dev/v0.38.1/cerbos/policy/v1/Policy.schema.json
---
apiVersion: api.cerbos.dev/v1
disabled: false

resourcePolicy:
  version: default
  resource: role
  variables:
    local:
      hasUsers1: ({} in R.attr.users)
      # hasUsers2: size(R.attr.users) > 0
      # hasUsers3: R.attr._count.users > 0

  rules:
    # admin can read the role(s)
    - actions:
        - read
      effect: EFFECT_ALLOW
      roles:
        - admin

    # admin can delete the role
    - actions:
        - delete
      effect: EFFECT_ALLOW
      roles:
        - admin

    #  but admin can't delete the role which has users
    - actions:
        - delete
      effect: EFFECT_DENY
      roles:
        - admin
      condition:
        match:
          expr: V.hasUsers1

Option 1

hasUsers1: ({} in R.attr.users) it works as expected with Prisma plan adapter and returns KIND_CONDITIONAL plan with {"NOT":{"users":{"some":{}}}} filters, but when i check the resource for delete action permissions, it returns EFFECT_ALLOW. I populate and pass the list of users into the checkResources request (ideally, i'd like to avoid this population if possible):

const { results } = await cerbos.checkResources({
  resources: [
    {
      "actions": [
        "read",
        "delete"
      ],
      "resource": {
        "kind": "role",
        "id": "1",
        "attr": {
          "id": 1,
          "name": "user",
          "users": [
            {
              "id": 1
            }
          ]
        }
      }
    }
  ]
});

and i'm get back results as:

{
  "resource": {
    "id": "1",
    "kind": "role",
    "policyVersion": "",
    "scope": ""
  },
  "actions": {
    "delete": "EFFECT_ALLOW", // <------- but i expect to have EFFECT_DENY here
    "read": "EFFECT_ALLOW"
  },
  "validationErrors": [],
  "outputs": []
}

Option 2

hasUsers2: size(R.attr.users) > 0 - i've tried to use it as alternative solution and it works as expected with cerbos.checkResources and the same payload as I mentioned above but it seems that Prisma plan adapter doesn't support size method and it throws the error with the following error stack:

exception: Error: Unexpected variable [object Object],[object Object]
    at mapOperand (/usr/src/app/node_modules/@cerbos/orm-prisma/lib/cjs/index.js:123:15)
    at /usr/src/app/node_modules/@cerbos/orm-prisma/lib/cjs/index.js:114:42
    at Array.map (<anonymous>)
    at mapOperand (/usr/src/app/node_modules/@cerbos/orm-prisma/lib/cjs/index.js:114:31)
    at queryPlanToPrisma (/usr/src/app/node_modules/@cerbos/orm-prisma/lib/cjs/index.js:40:26)
    at AccessControlService.getQueryPlan (/usr/src/app/src/access-control/access-control.service.ts:241:37)
    at processTicksAndRejections (node:internal/process/task_queues:95:5)
    at async CerbosInterceptor.intercept (/usr/src/app/src/access-control/interceptors/cerbos.interceptor.ts:144:18)

And the query plan i pass into queryPlanToPrisma looks like this:

"kind": "KIND_CONDITIONAL",
"condition": {
  "operator": "not",
  "operands": [
    {
      "operator": "gt",
      "operands": [
        {
          "operator": "size",
          "operands": [
            {
              "name": "request.resource.attr.users"
            }
          ]
        },
        {
          "value": 0
        }
      ]
    }
  ]
}

Option 3

hasUsers3: R.attr._count.users > 0 i've tried it as another approach (instead of actual population of the users, just to count the related users) and check this number, in this case it works as expected till the DB request. Prisma throws the error because Role model has no _count field, which exists in plan-adapter filters: KIND_CONDITIONAL {"NOT":{"_count":{"users":{"gt":0}}}}.

The error says:


Invalid `this.prisma.role.findUnique()` invocation in
/usr/src/app/src/roles/roles.service.ts:249:46

  246   });
  247 }
  248 
→ 249 const isAllowed = await this.prisma.role.findUnique({
        where: {
          id: 1,
          AND: {
            NOT: {
              _count: {
              ~~~~~~
                users: {
                  gt: 0
                }
              },
      ?       AND?: RoleWhereInput | RoleWhereInput[],
      ?       OR?: RoleWhereInput[],
      ?       NOT?: RoleWhereInput | RoleWhereInput[],
      ?       id?: IntFilter | Int,
      ?       name?: StringFilter | String,
      ?       users?: UserListRelationFilter
            }
          }
        }
      })

Unknown argument `_count`. Available options are marked with ?.
    at In (/usr/src/app/node_modules/@prisma/client/runtime/library.js:114:7526)
    at Ln.handleRequestError (/usr/src/app/node_modules/@prisma/client/runtime/library.js:121:7396)
    at Ln.handleAndLogRequestError (/usr/src/app/node_modules/@prisma/client/runtime/library.js:121:7061)
    at Ln.request (/usr/src/app/node_modules/@prisma/client/runtime/library.js:121:6745)
    at async l (/usr/src/app/node_modules/@prisma/client/runtime/library.js:130:9633)
    at async RolesService.checkAccessById (/usr/src/app/src/roles/roles.service.ts:249:23)
    at async RolesService.findOne (/usr/src/app/src/roles/roles.service.ts:108:5) {
  clientVersion: '5.19.0'
}

Solution?

I've tried to combine the options, like this hasUsers1or3: ({} in R.attr.users) || (R.attr._count.users > 0) which kind of makes sense to me, but i didn't find a way to say queryPlanToPrisma via fieldMapper/relationMapper that _count condition should be ignorred in this case. So I'm looking for advice on how to make it properly?

alexolivier commented 15 hours ago

Hey! Thanks for the detailed issue. I want to setup a test case for this and then work from there - could you share the relevant parts of your Prisma schema (or a representative example)?

anotheri commented 13 hours ago

@alexolivier sure,

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"

  // https://github.com/prisma/prisma-client-js/issues/616#issuecomment-616107821
  binaryTargets = ["native", "darwin", "debian-openssl-3.0.x", "linux-musl", "linux-musl-openssl-3.0.x"]

  previewFeatures = ["tracing"]
}

model User {
  id             Int            @id @default(autoincrement()) @db.UnsignedInt
  email          String         @unique @db.VarChar(50)
  passwordHash   String         @map("password_hash") @db.VarChar(255)

  // relation to many Roles
  roles          Role[]

  @@map("users")
}

model Role {
  id          Int      @id @default(autoincrement()) @map("ur_id") @db.UnsignedInt
  name        String   @unique @map("ur_name") @db.VarChar(50)

  // relation to many Users
  users       User[]

  @@map("roles")
}