prisma / prisma1

💾 Database Tools incl. ORM, Migrations and Admin UI (Postgres, MySQL & MongoDB) [deprecated]
https://v1.prisma.io/docs/
Apache License 2.0
16.54k stars 862 forks source link

Implement permissions on nested writes #3901

Closed colinhacks closed 5 years ago

colinhacks commented 5 years ago

Disclaimer: this issue isn't directly related to Prisma but I think the issues described here may inform the next generation of Prisma Client.

Is your feature request related to a problem? Please describe.

For starters, here's some context: I'm exposing the entire Prisma Client to the, er, actual client. To do so, I'm generating resolvers for each query/mutation generated by prisma generate which pass the incoming args straight through to the Prisma Client.

I have a very low tolerance for boilerplate. If I was doing authorization "the right way", I'd need custom resolvers for creating/updating/deleting items of each data type, and additional mutations for each relation (addTodo(userId: String, todo: TodoInput)). I'm working on a system with a couple dozen highly connected models, so this would be extremely annoying.

Here's a toy example that I'll use to describe what I'm looking for.

type Folder { 
  id: ID!
  name: String!,
  children: [Folder!]!
  contents: [File!]!
  permissions: [Permission!]!
}

type File {
  id: ID!
  name: String!
  parent: Folder!
}

type Permission {
  folder: Folder!
  user: User!
  accessLevel: AccessLevel
}

enum AccessLevel { 
  READ
  WRITE
  READWRITE
}

Underneath each Folder is potentially an infinitely nested hierarchy of additional Folders and Files.

A user shouldn't be allowed to modify a File unless they have write access to a Folder somewhere in its ancestry.

So you see where nested writes get troublesome. Let's say there's a folder structure that looks like this:

Let's a say a user has READWRITE access to Folder B. The client may send a request like this:

mutation {
  updateFolder(
    where: { id: "FolderB" }, 
    data: {
      name: "UpdatedFolderB",
      parent: {
        update: {
          name: "MaliciouslyUpdatedFolderA",
          permissions: {
            create: [{
              user: { connect : { id: "EvilPerson" } },
              accessLevel: "READWRITE"
            }]
          }
        }
     }
  })
}

So now "EvilPerson" has full access to Folder A. The client can easily do a nested write operation to create permissions for folders they don't have access to. That's bad.

Describe the solution you'd like

Apply "mutation directives" on a per-field basis. To elaborate: I want to be able to run some code before any given field gets modified by a mutation, whether it's a scalar or a relation. I can already do this for reads, but not for writes! To implement this for writes requires a huge amount of boilerplate code.

In the particular instance I've described, I want to be able to specify which relations can be modified with nested writes. With directives, this would look something like this:

type Permission {
  ...
  folder: Folder! @hasAccess
}

Pretty straightforward. Now a directive will be called every time any Permission's folder field get's mutated and checks that the signed-in user has access to that folder. This will happen regardless of the nestedness of a write.

That's the "holy grail" solution, but unfortunately GraphQL doesn't really support "mutation directives" like that on a per-field basis. (If anyone knows why mutations aren't handled in a recursive way, please explain!). But Prisma Client could implement directives that cover a lot of scenarios, and make authorization soooo much easier.

Here's what that API could look like:

type Folder {
  ...
  permissions: [Permissions]! @disableNested(writes: [CREATE, UPDATE])

}

Now, if I'm trying to mutate a given folder, I won't be allowed to update or connect the related permissions. This functionality could be eliminated at the schema level in the prisma generate step. Then I could export the Prisma Client's built-in functions (updateFolder, createFolder, etc) without fear of malicious nested writes. I'd have to implement a custom addPermissionToFolder mutation, but that's a lot easier than re-implementing all of the Prisma Client functionality.

Describe alternatives you've considered

I've used graphql-shield by @maticzav extensively in the past for simpler projects but it doesn't work here. At the end of the day, graphql-shield is only really focusing on fine-grained read controls. You can apply graphql-shield rules on each mutation, but once someone has made it past the "front door" (the entry point to the mutation), they can do whatever they want with nested writes.

Additional context - a rant about GraphQL mutations

I think it's strange that GraphQL has this gorgeous recursive structure for doing reads, but not for writes. It seems very inelegant. I certainly don't understand the implementational complexities of something like this (enlighten me!) but why isn't a similar recursive call stack built into mutations? It seems like there should be mutationResolvers and queryResolvers for each GraphQL type in the schema.

Something like this:

query {
  files(where: FileWhereInput): [File]!
}

mutation {
  createFolder(data: FolderCreateInput): Folder!
}

type Folder {
  query {
    name: String!
    contents: File!
  }
  mutation {
    contents(data: [FileCreateInput!]!): [File]!
  }
}

Then, consider a mutation like this:

mutation {
  createFolder(data: {
    name: "CoolFolder",
    contents: {
      create: [{
        name: "NewFile"
      }]
    }
  })
}

The mutation could get processed in a recursive way. The resolver for mutation.createFolder could create a new folder with all the scalar values passed in via the data arg. Then, it would recursively call Folder.mutation.contents, passing down the id of the newly created Folder as parent and data.contents as args. Does that make any sense?

This way, you could have a line in the schema where you could apply custom permissions directives that get called every time any field of any type is getting mutated:

type Folder {
  ...
  mutation {
    name(data: String!): String! @isAdmin
    contents(data: [FileMutationInput!]!): [File]! @maxFiles(count: 3)
    parent(data: FolderMutationInput!): Folder! @deny
  }
}

The entire hierarchy of mutation resolvers needs to complete successfully in a transactional way, otherwise the write fails and changes are undone.

Sorry, this entire rant was totally unrelated to Prisma and possible non-sensical. But I really don't understand why GraphQL isn't designed this way. I'm sure there are good technical reasons involving ACID and other things I don't really understand - please let me know if there are!

colinhacks commented 5 years ago

This will likely be addressed in Prisma Nexus eventually: https://nexus.js.org/docs/future-features.

Closing.

mortenbo commented 5 years ago

@colinmcd94 Hey! Did you manage to find a proper solution to this? I'm trying to achieve the same thing.

nagman commented 5 years ago

Same question here. How can I prevent any nested create mutation?

In my case, I have clients who can create orders, but can't create products. An order always has products.

How can I prevent the client to create a product in the createOrder mutation?

I've found a way with nexus:

export const ProductCreateOneInput = prismaInputObjectType({
    name: 'ProductCreateOneInput',
    definition(t) {
        t.prismaFields({ filter: [ 'create' ] });
    },
});

But it will only wotk in this case. I have to do the same for all the product create nested mutations in all other mutations (like createProductCategory, updateProductCategory, createPrice, updatePrice, and so forth... a lot of boilerplate).