maticzav / graphql-shield

🛡 A GraphQL tool to ease the creation of permission layer.
https://graphql-shield.com
MIT License
3.55k stars 172 forks source link

Permissions on Input Types #113

Closed frankdugan3 closed 5 years ago

frankdugan3 commented 6 years ago

A use case I'm interested in is putting permissions on input types and individual fields of input types. This would simplify mutation permissions because you could use a generic mutation like updateUser but limit access to modifying certain fields of User.

Is this supported or possible to add? I'd be happy to contribute if interested.

maticzav commented 6 years ago

@frankdugan3 this is so cool! I would love to work on this with you. I am not sure how input types are treated internally by GraphQL or how they come into play in schema, but let's explore and keep this issue open until we find out. Do you have any leads?

frankdugan3 commented 6 years ago

I'll do some digging this week.

boenni23 commented 6 years ago

I'm also interested in this feature. I'm just confused. The Readme says:

🎯 Per-Type: Write permissions for your schema, types or specific fields (check the example below).

Are write permissions on fields supported? If yes, how would it be differenet with input types?

if I try something like User: { password: deny }, it only effects the read permissions.

frankdugan3 commented 6 years ago

From what I was able to gather, it looks like input objects are treated pretty similarly to object types and can be processed with isInputObjectType. I think it's possible, but I haven't had a chance to do a mock-up.

vadistic commented 6 years ago

Hello, I'm just tackling this issue in my project and I thought I will present my progress for inspiration.

I would be glad for any pointers how to improve it. Also - I have literally no idea how this construct affect performance and caching...

So my idea is to treat each input field permissions as separate rule and join them with and(). Here's example (using ramda a bit, hope it's still readable)

// Let's prep some syntax sugar

interface FieldRuleMap {
  [key: string]: (field: string) => IRule
}

const fieldRules = (fieldRuleMap: FieldRuleMap) => {
  const rulesArr = Object.entries(fieldRuleMap).map(([key, fn]) => fn(key))
  return and(...rulesArr)
}
// Then define my input field rule

const hasPermission = (fieldName: string) =>
        rule()(async (parent, args, ctx: Context) => {
          // shortcircuit for performance if args has none of this prop
          if (!R.has(fieldName, args)) {
            return true
          }

          // check your privileges
          const res = complicatedCalculations()

          return res
        })
// And voila!

const rulesWithFields: IRules = {
  Mutation: {
    createUser: and(isAuthorized, fieldRules({
      name: hasPermission
    })),
  },
}

const permissions = shield(rulesWithFields)

If you don't mind wall of code - here's some real example - I'm forwarding a lot's of resolvers to Prisma, but I need to check input arguments for connections validity. Here I validate if some target user is a member of the same 'workspace` as user. It's one-to-many case, for one-to-one I will need to use slightly modified rule.


const verifyConnection = (target: string) => (field: string) =>
  rule()(async (parent, args, ctx: Context) => {
    // shortcircuit
    if (!R.has(field, args.data)) {
      return true
    }

  // I do a lot of this checks so I keep workspaceId in JWT
    const workspaceId = getId(ctx).workspaceId
  // Get id[] from args
    const targetConnect = args.data[field].connect

  // Now I mapping ids to array of promises checking each provided target id
    const validationP = targetConnect.map(connect => {
      return ctx.db.exists[target]({
        AND: {
          id: connect.id,
          workspace: { id: workspaceId },
        },
     // Later I'm using Promise.all() and I want it to stop executing on first `false` so I'm adding reject
      }).then(res => (res === true ? Promise.resolve(true) : Promise.reject('Not Authorized')))
    })

   // Await for all promises and test if all elements yield true 
   // (bit unnecessary since I'm rejecting those promises in case of false, but nvm)
    const result = R.all(R.equals(true), await Promise.all(validationP).catch(rej => [false]))

    return result
  })

// same syntetic sugar as previous example
interface ConnectionRuleMap {
  [key: string]: (field: string) => IRule
}

const verifyConnections = (connectionRuleMap: ConnectionRuleMap) => {
  const rulesArr = Object.entries(connectionRuleMap).map(([key, fn]) => fn(key))
  return and(...rulesArr)
}

// rules
export const rules: IRules = {
Mutation: {
    createTask: verifyConnections({
      owners: verifyConnection('User'),
      subscribers: verifyConnection('User'),
    })
  }
}

const permissions = shield(rules)

export default permissions

What y'all think? 😃

// EDIT Btw. I did not tackle how to nicely use logic rules on fields yet without rewiring them

// this will fullfil my needs, but there should be a better way
const fieldAnd = (fieldName: string) => (...args: FieldRule[]) =>
  and(...args.map(arg => arg(fieldName)))
Hitabis commented 5 years ago

@vadistic great inspiration. thanks!

I will look into this, but I would prefer a solution with existing rules. like


    Mutation: {
        createThing: fieldRules({
            field1: deny,
            field2: allow,
        }),
        }

I will write when I figured it out.

Hitabis commented 5 years ago

@vadistic what is this "R"-Object???

Hitabis commented 5 years ago

OK. Here my first attempt:


const fieldRules = (fieldRuleMap, path = []) => {
    const rulesArr = Object.entries(fieldRuleMap).map(([key, rule]) => {
        // Has no rule / is object
        if(!(rule.rules && Array.isArray(rule.rules))) {
            return fieldRules(rule, [...path, key])
        }
        // has rule
        return checkRule(key, rule, path)
    })
    return and(...rulesArr)
}

const checkRule = (fieldName, customRule, path) => {
    return rule()(async (parent, args) => {
        try {
            // exit if args dont have this field
            if (!objectValueByPath(args, fieldName, path)) {
                return true
            }
            return customRule
        } catch (error) {
            return false    
        }
    })
}

const objectValueByPath = (object, fieldName, path) => {
    let current = object
    path.forEach(key => {
        if(!current[key]) {
            // false = not in tree
            return false
        }
        current = current[key]
    })
    return current[fieldName]
}

You can use it with


        createThing: fieldRules({
            data: {
                field1: deny,
                subfields: {
                    subfield1: isOwner,
                }
            }
        }),

what do you all think?

boenni23 commented 5 years ago

@maticzav what do YOU think about this? Is this a good approach?

maticzav commented 5 years ago

Hey 👋,

I let this discussion evolve itself but I think we steered a bit out of the initial idea. Therefore, I would like to focus on tackling the overall problem that we are addressing not the implementation details themself.

Firstly, I want to clarify what I understood as a proposal to input type scoped permissions. GraphQL allows the definition of so-called input types. Their primary focus is making more complex data structures accessible in arguments and not only in types themselves. An example of input-types usage would be a signup mutation, for example, which accepts multiple arguments. Because data can be nested or in any other way co-dependent, it makes sense to allow JSON-like input structures which we can test before execution (validation step).

Hence, this issue does not address how one should go about implementing scalar arguments validation but rather how we can make permissions on complex cross-field arguments reusable.

To corroborate the idea, let's examine the schema below. At first sight, we can notice we are creating a simple social network where one can create a single or even multiple events at the same time. There's a GroupInput input type that we use in two different places.

type Mutation {
  createGroup(data: GroupInput): Group
  createMultipleGroups(data: [GroupInput!]!): [Group!]!
}

input GroupInput {
  name: String!
  members: [ID!]!
}

type Group {
  id: ID!
  name: String!
  members: [Member!]!
}

Furthermore, groups require a unique name. Currently one would tackle such a problem by implementing the same code in two different places. To give a brief notion of how one could achieve this with the current graphql-shield features, let us examine the following code;

const permissions = shield({
  Mutation: {
    createGroup: rule()(async (parent, { data }, ctx, info) => {
      if (canGroupBeCreated(data)) {
        return true
      }
      return false
    }),
    createMultipleGroups: rule()(async (parent, { data }, ctx, info) => {
      if (data.every(group => canGroupBeCreated(group))) {
        return true
      }
      return false
    }),
  },
})

Horrible!

Now, the idea of this issue is to find a way to make the above syntax far more appealing than the example we just witnessed. To give an idea of what seems the right direction, let's examine the last chunk of code on this particular topic;

const permissions = shield({
  InputTypes: {
    GroupInput: {
      name: val => isUniqueName(val)
    }
  },
  Mutation: {
    createGroup: rule()(async (parent, { data }, ctx, info) => {
      // if (canGroupBeCreated(data)) {
      //   return true
      // }
      // return false
      //
      // We already know that arguments passed - we can focus on other restrictions!
    }),
    createMultipleGroups: rule()(async (parent, { data }, ctx, info) => {
      // if (data.every(group => canGroupBeCreated(group))) {
      //   return true
      // }
      // return false
      //
      // We already know that arguments passed - we can focus on other restrictions!
    }),
  },
})

I hope I made it a bit more clear of what I believe this proposal is aiming for. I hope we can find a genuinely concise and ingenious approach to tackling the problem as I think this could help, as can also be seen from the activeness of the discussion, many developers.

I think I covered the first topic. 😄


Now, the second topic I want to discuss is a response to a comment made by... it seems like it's not here anymore... anyways, I think this is a great place to share it!

With GraphQL you are always querying.

There's been a numerous amount of questions addressing how one could foresee which fields the client is interested in and stop the execution of a query upfront if need be. This has been especially common in two particular scenarios; mutations and resolvers using schemaDelagation. I think this conceptual fatuity is a result of our approach to GraphQL - we are still thinking in REST.

I believe schemaDelegation is quite often considered a somehow similar concept to REST-designed application. However, it is not! It's quite evident why people make this obvious mistake; delegation happens in one of the resolvers, and its result is forwarded to the resolver execution chain. Addressing the issue we can see why the relation seems complementary. Long story short, don't be fooled into thinking schemaDelegation is in any way similar to REST. It's not! Every value returned by the remote server is reprocessed once again locally.

To conclude, we often mingle REST with GraphQL when it comes to schemaDelegation. Nevertheless, they are not the same thing, far from it in fact. Thinking about "foreseeing" requested fields is, therefore, redundant - your logic shouldn't depend on the content of the processed query.

Furthermore, as the quote above humbly suggests, we are always querying. There's no distinct difference between Query and Mutation - in fact, there's only one difference; one is processed serially and the other is processed asynchronously. The beauty of the graph is that one can compose a relational network and ideally access all fields from whichever vertex they desire. Fields have to be independent.

Summing it all up, I believe a notion of "foreseeing" request content and relating function execution based on it is ridiculous. When we make internal changes as a result of arguments of Query or Mutation they shouldn't depend on the queried content.

I hope this is somewhat helpful contribution to our discussion. I would love to hear your thoughts on it as well! 🙂

stale[bot] commented 5 years ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

maticzav commented 5 years ago

I believe this might be a good approach to implement permissions on input types https://github.com/jquense/yup.

maticzav commented 5 years ago

:tada: This issue has been resolved in version 5.3.0 :tada:

The release is available on:

Your semantic-release bot :package::rocket:

boenni23 commented 5 years ago

Thanks, man! Great feature!

Hitabis commented 5 years ago

Special thanks from us! This feature will come in very handy to us. We donated something for your work. Thanks again!