mercurius-js / auth

Mercurius Auth Plugin

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Directive filterSchema: true -- directive to prevent "message"

Bugs5382 opened this issue · comments

So... I did a "fork" of the repo, and no issue with the default package. However, maybe I am thinking what this "option" does.

Lets start with the GraphQL Query itself:

query user($first: Int, $after: String, $last: Int, $before: String) {
      user(first: $first, after: $after, last: $last, before: $before) {
          totalCount
          edges {
            cursor
            node {
              firstName
              roles
            }
          }
          pageInfo {
            hasNextPage
            hasPreviousPage
            startCursor
            endCursor
          }
      }
}

I have this directive:

directive @auth(requires: Role, explicit: Role, permissions: [String!]) on OBJECT | FIELD_DEFINITION

and this is i the type:

     type UserQuery {
      _id: String!
      dateCreated: String!
      email: String!
      firstName: String!
      lastName: String!
      localLogin: Boolean!
      roles: [String] @auth(explicit: ["OPS"])
      username: String!
     }

This is the directive code:

fastify.register(mercuriusAuth, {
    authContext(context) {
      return {
        identity: context.reply.request.headers.authorization?.split(' ')[1],
        refresh: context.reply.request.headers['x-refresh-token'],
      };
    },
    applyPolicy: async function hasPermissionPolicy (policy, parent, args, context, info) {
     /* DB Query Removed *
      switch (typeEnforcement) {
        case 'explicit': {
          const hasGrant = /* Removed */
          if (!hasGrant) {
           return false /// When this is sent, I suspect that if the user does not have the role "admin" this would be just removed out or return "Role" as null.
          }
          return true
        }
        default:
          return new Error(`Internal Error. Invalid Auth Enforcement Type: ${typeEnforcement}` );
      }
      return true;
    },
    authDirective: 'auth'
  })

So I would epect if "hasGrant" is false, and returning fale, it would just not give a response to the end user. Right now it's sending a GraphQL error sying that the "user" does not have the right permission, etc.

Ideas?

Hi @Bugs5382 thanks for getting in touch! :) Just so I can clarify my understanding, can you provide the following please:

  • The expected JSON response
  • The actual JSON response
  • The full GraphQL schema

@jonnydgreen Please note for the schema. The idea is that if the user is not "admin" the "role" output will be filtered out.

Expect Response:

{
    "data": {
        "user": {
            "totalCount": 2,
            "edges": [
                {
                    "cursor": "651bf9dbb7d2ad1b0723e11f",
                    "node": {
                        "firstName": "Shane",
                        "roles": null 
                        "__typename": "UserQuery"
                    },
                    "__typename": "UserEdge"
                },
                {
                    "cursor": "652a7c08e91ed0eb6e1a8923",
                    "node": {
                        "firstName": "Lucy",
                        "roles": null 
                        "__typename": "UserQuery"
                    },
                    "__typename": "UserEdge"
                }
            ],
            "pageInfo": {
                "hasNextPage": false,
                "hasPreviousPage": false,
                "startCursor": "",
                "endCursor": "",
                "__typename": "PageInfo"
            },
            "__typename": "UserPagination"
        },
        "tokenData": {
            "__typename": "UserRefreshResponse",
            "access_token": "",
            "refresh_token": "
        }
    }
}

Actual Response:

{
    "data": {
        "user": {
            "totalCount": 2,
            "edges": [
                {
                    "cursor": "651bf9dbb7d2ad1b0723e11f",
                    "node": {
                        "firstName": "Shane",
                        "roles": null,
                        "__typename": "UserQuery"
                    },
                    "__typename": "UserEdge"
                },
                {
                    "cursor": "652a7c08e91ed0eb6e1a8923",
                    "node": {
                        "firstName": "Lucy",
                        "roles": null,
                        "__typename": "UserQuery"
                    },
                    "__typename": "UserEdge"
                }
            ],
            "pageInfo": {
                "hasNextPage": false,
                "hasPreviousPage": false,
                "startCursor": "",
                "endCursor": "",
                "__typename": "PageInfo"
            },
            "__typename": "UserPagination"
        },
        "tokenData": {
            "__typename": "UserRefreshResponse",
            "access_token": "",
            "refresh_token": ""
        }
    },
    "errors": [
        {
            "message": "Failed auth policy check on roles",
            "locations": [
                {
                    "line": 8,
                    "column": 9
                }
            ],
            "path": [
                "user",
                "edges",
                "0",
                "node",
                "roles"
            ]
        },
        {
            "message": "Failed auth policy check on roles",
            "locations": [
                {
                    "line": 8,
                    "column": 9
                }
            ],
            "path": [
                "user",
                "edges",
                "1",
                "node",
                "roles"
            ]
        }
    ]
}

The error block keeps coming over which is messing up my check code when while t should be ignroed. I am not sending over a "throw new error" in my code at all in this directive check.

GraphQL Schema

const schema = gql`
     directive @login on OBJECT | FIELD_DEFINITION
     directive @loginNot on OBJECT | FIELD_DEFINITION
     directive @requireHeaders on OBJECT | FIELD_DEFINITION
     directive @auth(requires: Role, explicit: Role, permissions: [String!]) on OBJECT | FIELD_DEFINITION
    
     enum Role {
      ADMIN
     }

     type SchemaError {
      # Internal Code
      code: Int!
      # HTTP Status
      statusCode: String!
      # Message
      message: [String]
     }
     
     type FieldError {
      # Field Empty
      path: String!
      # Message Returned
      message: String!
     }
     
     type ValidationError {
      # Allow several errors at the same time!
      fieldErrors: [FieldError!]!
     }
    
     type BooleanResponse {
      # Translatable Code
      statusCode: String!
      # Result
      result: Boolean!
     }
    
     type User {
      _id: String!
      username: String!
      email: String!
      roles: [String!]
     }
     
     type UserQuery {
      _id: String!
      dateCreated: String!
      email: String!
      firstName: String!
      lastName: String!
      localLogin: Boolean!
      roles: [String] @auth(explicit: ["ADMIN"])
      username: String!
     }
    
     type UserLoginResponse {
      statusCode: String!
      user: User!
      access_token: String!
      refresh_token: String!
     }
     
     type UserRefreshResponse {
      access_token: String!
      refresh_token: String!
     }
     
     type UserRegistrationResponse {
      statusCode: String!
      email: String!
      username: String!
     }
     
     type PageInfo {
      hasNextPage: Boolean!
      hasPreviousPage: Boolean!
      startCursor: String!
      endCursor: String!
     }
    
     type UserEdge {
      node: UserQuery!
      cursor: String!
     }
     
     type UserPagination {
      totalCount: Int!
      edges: [UserEdge!]!
      pageInfo: PageInfo!
     }
     
     union ForgotPasswordResult = BooleanResponse | SchemaError | ValidationError
     union LoginResult = UserLoginResponse | SchemaError | ValidationError
     union LogoutResult = BooleanResponse | SchemaError | ValidationError
     union RefreshResult = UserRefreshResponse | SchemaError | ValidationError
     union ResetPasswordResult = BooleanResponse | SchemaError | ValidationError
     union RegistrationResult = UserRegistrationResponse | SchemaError | ValidationError
     union VerificationResult = BooleanResponse | SchemaError | ValidationError
     
     input UserRegistration {
      firstName: String!
      lastName: String!
      password: String!
      email: String!
      totp: String
      token: String
     }
     
     input UserVerify {
      username: String!
      password: String!
      verification: String!
     }
     
     extend type Mutation {
      forgottenPassword(username: String!): ForgotPasswordResult @loginNot
      generateExternalJwt(name: String!, url: String!, expire: String): String! @login @auth(requires: [ADMIN])
      refreshToken: RefreshResult! @login
      resetPassword(code: String!, username: String!, password: String!): ResetPasswordResult @loginNot
      userLogin(username: String!, password: String!, enterprise: Boolean): LoginResult! @loginNot
      userLogout(username: String!, refreshToken: String!): LogoutResult! @requireHeaders
      userRegister(input: UserRegistration): RegistrationResult! @loginNot
      userVerification(input: UserVerify): VerificationResult! @loginNot
     }
     
     extend type Query {
      refreshToken: RefreshResult! @login
      user(first: Int, after: String, last: Int, before: String): UserPagination @login
     }
`

So if the user had a role, like "OPS", but not "ADMIN", it would show role as "null".

Hi @Bugs5382 I have a couple of thoughts about your problem :)

Firstly, with the way Mercurius auth currently works, whenever an auth check fails (e.g. when you return false in the policy) on a field, a corresponding error will always be added in the error block indicating this. The field is set to null as well if the GraphQL field is nullable. This is the current and expected behaviour :)

However, I see no reason why we cannot introduce some behaviour to silently filter out these responses (e.g. show null but no errors in the error block). Would you be interested in contributing on this?

Alternatively, have you tried the filter introspection schema functionality? This means that users who don't have access to the field won't even see it in the schema and won't be able to select on it. Not sure if that solves your use-case, but I thought it was worth pointing out: https://github.com/mercurius-js/auth/blob/main/docs/schema-filtering.md

wdyt?

@jonnydgreen - Thanks for getting back to me. I thought about the 'filter introspection schema' but the schema is hard-coded into the frontend. Not something I want to "dynamicily" alter based off the user's JWT token. Kinda want that "transparent", etc.

So... I figured as much as I would have to possibility write something to do this... (already forked over). So now it comes down to, do we put in a new "block" in the options block to stay "do not send errors" or should the current filterSchema: true, already mean not to send the error block if returning false.

I did forget to mention that I added this in into the code, and I still get the error block....

Slept on this overnight.

I will work on this, but itstead of overriding filterSchema: true, I will create filterSchemaOutput: true. This will if set and your return:

  • true - Return as normal.
  • false - Return the value as blank/null.
  • string - Returning a 'string' will override the output. This could be useful for data obfuscation. The idea behind this, at least for me, I am incorporating a HR system into my app... at one point. If you do not have the "hr" flag your data would be obfuscated so in theory an IT personal could troubleshoot an HR issue, but not see sensitive data.

As I activity need this feature, I will be working on this today. More to come!

As I activity need this feature, I will be working on this today. More to come!

Hi @Bugs5382 awesome, thank you! :)

If it's okay, could we make the following changes to your suggestion please?

Could we have the following config, I think it's a bit closer to what the feature is actually doing and doesn't cause confusion with the filterSchema option:

register({
  // ...
  outputPolicyErrors: {
    enabled: boolean // optional, default  true
    valueOverride: string // optional and only works for string type fields
  } // If not specified, exhibit normal behaviour
})

I like the string suggestion for debugging purposes, could we make the following extra check only make this work for fields of type String? I.e. if a field that isn't of type String, it doesn't override as normal so we don't get loads of unintended errors

wdyt?

cc. @mcollina

Sounds good. I like this as well. Ok. Let me whip this up and expect a PR either by the end of the weekend. My code has come to a standstill right now, but this is the beauty of open source. :)

Fantastic! No rush on this at all and have a great weekend! :)

So... with some updates...

# Subtest: remove valid notes results and replace it with empty string without any errors
    1..2
    ok 1 - notes are empty string
    ok 2 - notes are empty string
ok 1 - remove valid notes results and replace it with empty string without any errors # time=10043.954ms

# Subtest: ensure that a user who doesn't have the role to filter, still sees the notes
    1..2
    ok 1 - notes are valid
    ok 2 - notes are valid
ok 2 - ensure that a user who doesn't have the role to filter, still sees the notes # time=62.425ms

foo
foo
# Subtest: remove valid notes results and replace it with "foo" without any errors
    1..2
    ok 1 - notes is foo
    ok 2 - notes is foo
ok 3 - remove valid notes results and replace it with "foo" without any errors # time=74.909ms

Keep in mind these are me running debugger so that's why they have high times

@jonnydgreen and @mcollina :-)

I am now ensuring the other units continue to pass 👍