nuwave / lighthouse

A framework for serving GraphQL from Laravel
https://lighthouse-php.com
MIT License
3.36k stars 437 forks source link

Limit access to fields based on a user role #325

Closed kevinvdburgt closed 6 years ago

kevinvdburgt commented 6 years ago

Is your feature request related to a problem? Please describe. As we have multiple guards in our Laravel app, so do we have quite some different queries and mutations for each user. Some of them, named the same but gives back different data.

Describe the solution you'd like I would like to split some schema, like https://example.org/graphql/schama-a, https://example.org/graphql/schama-band https://example.org/graphql/schama-c. With each of them their own resolvers.

Describe alternatives you've considered I've been using https://github.com/Folkloreatelier/laravel-graphql for a while now, where you can define multiple schemas per endpoint. But would be nice to have it in this package as well as our team is considering moving to Lighthouse instead.

chrissm79 commented 6 years ago

@kevinvdburgt Lighthouse could make this process a bit easier, however, currently you could swap out the Nuwave\Lighthouse\Schema\Source\SchemaSourceProvider (registered here) interface w/ an implementation of your own in your service provider's register method.

We may want to switch this over to a singleton and expose a setRootPath method which would allow outside access to swapping out the path if needed.

spawnia commented 6 years ago

First of all, switching to Lighthouse: Great idea! I did the same.

I think that multi-schema is most probably a bad idea, and also not trivial to implement on our side. View https://github.com/nuwave/lighthouse/issues/273 for some more discussion around this.

spawnia commented 6 years ago

Just throwing a bunch of ideas of how you can solve your use case in a single schema:

Benefits you get are:

jthomaschewski commented 6 years ago

Use @can or @middleware to protect fields

And @group for multiple queries/mutations at once. e.g:

extend type Query @group(middleware: ["can:access-functionality"]) {
  restrictedQuery1: ...
  restrictedQuery2: ...
}

I do that currently and its working fine - but I also do have fields and types I'd like to not expose (while still having introspection for most other parts of the API for 3rd party access).

One idea I had is excluding specific fields and types from introspection... Not sure if this would be efficiently possible without loosing the benefits of caching the AST.

Just a thought...

spawnia commented 6 years ago

@jbbr Unless you put sensitive information in the schema itself (which you should not do), i do not see a reason to not expose it. I would consider your whole schema public information, as you are indirectly publishing it anyways through the queries your Frontends contain.

Not a big fan of security through obscurity 😉

kevinvdburgt commented 6 years ago

Thanks for the replies! This is the first time for me, using a graphql schema file instead of the Folkloreatelier/laravel-graphql way.

How can i make it so, that an admin user (authorized by token) sees a different type and uses a different resolver then a normal user (in this example below). Also, not all our types are coming from a Eloquent model, most of them are from external resources (MongoDB e.d.) - how can we use @paginate without specifying a model?

scalar DateTime @scalar(class: "Nuwave\\Lighthouse\\Schema\\Types\\Scalars\\DateTime")

# Type for users only
type Notification {
    id: ID!
    name: String!
    created_at: DateTime!
}

# Type for admins only
type Notification {
    id: ID!
    name: String!
    description: String
    created_at: DateTime!
    updated_at: DateTime!
    deleted_at: DateTime
}

type Query {
    # Resolver: \App\Http\GraphQL\Queries\Notification::class
    # For users only
    notifications: [Notification!]!

    # Resolver: \App\Http\GraphQL\Queries\Admin\Notification::class
    # For admins only
    notifications: [Notification!]!
}
chrissm79 commented 6 years ago

In your example I would side w/ @spawnia and make a single schema if you're just adding additional fields for admin access. So in this case you would have a single definition for Notification like so:

type Notification {
    id: ID!
    name: String!
    description: String @admin
    created_at: DateTime!
    updated_at: DateTime! @admin
    deleted_at: DateTime @admin
}

And create a AdminDirective that only returned the value if the user has access:

namespace App\Http\GraphQL\Directives;

use Nuwave\Lighthouse\Support\Contracts\FieldMiddleware;

class AdminDirective implements FieldMiddleware
{
    /**
     * Name of the directive.
     *
     * @return string
     */
    public function name()
    {
        return 'admin';
    }

    /**
     * Resolve the field directive.
     *
     * @param FieldValue $value
     * @param \Closure   $next
     *
     * @return FieldValue
     */
    public function handleField(FieldValue $value, \Closure $next)
    {
        $resolver = $value->getResolver();

        return $next($value->setResolver(function ($root, $args, $context) use ($resolver) {
            if (!$context->user->isAdmin()) {
                return null;
            }

            return call_user_func_array($resolver, func_get_args());
        }));
    }
}
spawnia commented 6 years ago

how can we use @paginate without specifying a model?

https://github.com/nuwave/lighthouse/pull/326

How can i make it so, that an admin user (authorized by token) sees a different type and uses a different resolver then a normal user (in this example below).

I typed out my answer, but @chrissm79 beat me to it.

chrissm79 commented 6 years ago

However, I have seen use-cases for multiple schemas where some sort of admin schema is used to define and query types for internal use only.

For example, I've seen a schema dealing w/ Patient and Doctor records where where certain internal notes, procedure codes, etc were only defined and used in a "admin" schema which basically "extended" the base schema to add the new types and created a new Mutation and Query type.

Since it was a completely different endpoint, it made the introspection consistent and didn't expose internal business logic but allowed them to reuse their resolvers for shared types.

kevinvdburgt commented 6 years ago

@chrissm79 that seems to be working, however, as our GraphQL api is public for some specific user roles, is there a way, that we can hide fields/types/queries e.d. using directives?

kevinvdburgt commented 6 years ago

@spawnia #326 looks good 👍

chrissm79 commented 6 years ago

@kevinvdburgt It's highly discouraged to hide/show type fields in the same schema. However, with #327 you can create a different schema and use your controller to set the root path of the schema to your public/internal/etc schema files.

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Nuwave\Lighthouse\Schema\Source\SchemaSourceProvider;
use Nuwave\Lighthouse\Support\Http\Controllers\GraphQLController as BaseController;

class GraphQLController extends BaseController
{
    /**
     * Execute GraphQL query.
     *
     * @param Request $request
     *
     * @return Response
     */
    public function adminQuery(Request $request)
    {
        app(SchemaSourceProvider::class)->setRootPath("/path/to/admin-schema.graphql");

        return parent::query($request);
    }
}

If you had an internal only field on a type defined in your default schema you could extend it:

extend type Notification {
  description: String
}

Doing things this way allows you to serve different schema w/ different endpoints which keeps introspection consistent and predictable but also allows you to prevent exposure of internal logic you don't want to expose via your public api.

kevinvdburgt commented 6 years ago

The best approach is creating a new GraphQL controller for now, as it seems weird to publish admin only queries and mutations to a public API. Where introspection is enabled.

yaquawa commented 6 years ago

@kevinvdburgt That's very interesting approach🤤

kevinvdburgt commented 6 years ago

@chrissm79 i tried to make a new controller and give another schema file to it, however, when checking with xdebug, the SchemaSourceProvider's root path has been changed, but the queries are still executed agains the schema defined in config/lighthouse.php


Still unsure, if it is always the best approach to stich all schemas together, like, for example

As i can see, that in some cases stitching all schema's together to a single one is usefull. I still have trouble understanding why splitting it is considered a bad idea:

Example A

Having a public GraphQL schema for users with introspection enabled and having a private schema without introspection enabled.

Example B

When having just 4 queries/mutations for the user (public) and 200+ queries/mutations for admins only. Isnt it way to noisy in the documentation browser of the paremeters which is optional e.d.

Example C

When, for example, authorizing combining admin, users, api's e.d.

# Normal users
mutation {
  authorize(username: "foobar" password: "secret" tfa: "2 factor auth code") {
    user
  }
}

# Admin users
mutation {
  authorize(email: "user@example.com" password: "secret" tfa: "2 factor auth code") {
    admin
  }
}

# API (External services)
mutation {
  authorize(apikey: "hello-world-:)") {
    user
  }
}

As the user and API can be in the same schema ofcourse, but just as an example that the API has different access stuff than an normal user.

spawnia commented 6 years ago

Example A

Having a public GraphQL schema for users with introspection enabled and having a private schema without introspection enabled.

I really do not see the point in disabling introspection at all. Unless you do something ridiculous, e.g. put a password in a field description, you are not gaining a security advantage through that.

Example B

When having just 4 queries/mutations for the user (public) and 200+ queries/mutations for admins only. Isnt it way to noisy in the documentation browser of the paremeters which is optional e.d.

I think the example you are setting up is a bit extreme, although i do see the point of it. This might actually be the best reason i have heard yet for multi-schema.

Example C

How about you just name the fields differently then? authorizeAdmin, authorizeUser...

izorwebid commented 5 years ago

Just throwing a bunch of ideas of how you can solve your use case in a single schema:

  • Name fields that return different data differently
  • Use @can or @middleware to protect fields
  • Add a custom directive like @adminOnly to granularly restrict field access

Benefits you get are:

  • Idiomatic single-endpoint server
  • DRY schema definition
  • Ease of developing and switching around roles

can i use @middleware like this?

type User {
    id: ID! @globalId
    name: String
    email: String @middleware(checks: ["auth:api"])
}
'route' => [
        'middleware' => ['api'], 
    ],

because the email value still show without auth using @middleware.

or i can use @field to protect email, only user login can see the email?

type User {
    id: ID! @globalId
    name: String
    email: String @field(resolver: "App\\Http\\GraphQL\\Types\\UserType@email")
}
class UserType
{
    public function email($root, array $args)
    {
        if(auth()->guard('api')->user()->id === $root->id){
          return $root->email;
       }
       return null;
    }
}

how to use "can" in field email?

spawnia commented 5 years ago

can i use @middleware like this?

You should be able to, yes. Be aware that global middleware and field middleware run at different points of the execution lifecycle and do not have anything to do with each other.

If the field email can be seen despite the middleware on it, that seems like a bug. If you can provide a failing test case i would be happy to take care of that in Lighthouse.

izorwebid commented 5 years ago

If the field email can be seen despite the middleware on it. that seems like a bug. If you can provide a failing test case i would be happy to take care of that in Lighthouse.

I don't know how to provide a failing test to you. but this my code

my schema.graphql

type User {
    id: ID! @globalId
    name: String
    email: String @middleware(checks: ["auth:api"])
    stage_name: String
    phone: String @middleware(checks: ["auth:api"])
}

type AuthToken {
    token_type: String!
    expires_in: Int!
    access_token: String!
}

type LoginPayLoad {
    auth_token: AuthToken
    user: User
}

type Query {
    "Get data profile by login jwt.auth"
    me: User @auth

    "Single influencer data by argument name and ID"
    user(
        "You can search by name argument"
        name: String @where(operator: "like")
        "find by ID"
        id: ID @eq
    ): User @find(model: "App\\User")
}

"Mutation"
type Mutation{
    login(
        email: String!
        password: String!
    ): LoginPayLoad @field(resolver: "App\\Http\\GraphQL\\Mutations\\AccountMutator@login")
}

lighthouse.php

'route' => [
        'prefix' => '',
        // 'middleware' => ['web','api'],    // [ 'loghttp']
        'middleware' => ['api'], 
    ],

AccountMutator.php

class AccountMutator
{
    /**
     * guard to be used authenticated.
     *
     * @return \Illuminate\Contracts\Auth\Guard
     */
    public function guard()
    {
        return \Illuminate\Support\Facades\Auth::guard('api');
    }

    public function login($root, array $args, $context)
    {
        $credentials = array_only($args,['email','password']);

        try {
            $access_token = $this->guard()->attempt($credentials);
        } catch (\Tymon\JWTAuth\Exceptions\JWTException $e) {
            return null;
        }

        $auth_token = [
            'token_type' => 'bearer',
            'expires_in' => auth()->guard('api')->factory()->getTTL() * 1440,
            'access_token' => 'Bearer '.$access_token,
        ];

        //throw_if(array_key_exists('error', $auth_token), new \Exception(array_get($auth_token, 'error')));
        $user = $this->guard()->user();

        return compact('auth_token', 'user');
    }
}

and then i try the query

{
  me{
    id
    name
    email
  }

  user(id:5060){
    id
    name
    email
  }
}

and the result without authorization

{
  "data": {
    "me": null,
    "user": {
      "id": "SW5mbHVlbmNlcjo1MDYw",
      "name": "john doe",
      "email": "xyz@gmail.com"
    }
  }
}

with authorization

{
  "data": {
    "me": {
      "id": "SW5mbHVlbmNlcjo1MDYw",
      "name": "john doe",
      "email": "xyz@gmail.com"
    },
    "user": {
      "id": "SW5mbHVlbmNlcjo1MDYw",
      "name": "john doe",
      "email": "xyz@gmail.com"
    }
  }
}

the field email still can be seen without authorization

and then i try this schema

type User {
    id: ID! @globalId
    name: String
    email: String @middleware(checks: ["jwt.auth"])
    stage_name: String
    phone: String @middleware(checks: ["auth:api"])
}

kernel.php

protected $routeMiddleware = [
...
'jwt.auth' => \Tymon\JWTAuth\Http\Middleware\Authenticate::class,
...
]

the field email still can be seen

spawnia commented 5 years ago

I suggest you check if your auth:api middleware is being called and if it throws if you are not authenticated.

And make sure you are using the latest version of Lighthouse.

izorwebid commented 5 years ago

thanks @spawnia after update version from 2.4 to 2.6.1, `@middleware' is working good