Core concepts

In this section, we’ll discuss how to set permissions, customize fields, and keep our GraphQL APIs secure and performant.

Entities

Entities in DjraphQL are the mechanism for exposing an underlying Django model in our GraphQL API.

If we have a Django model Album that we want to expose via our API, we must create an entity class wrapping Album and register it with our SchemaBuilder instance.

from songs.models import Album
from graphene import Schema
from djraphql import SchemaBuilder, Entity, AllModelFields

class AlbumEntity(Entity):
   class Meta:
      model = Album
      fields = (AllModelFields(),)

schema_builder = SchemaBuilder()
schema_builder.register_entity_classes(AlbumEntity)

schema = Schema(query=schema_builder.QueryRoot)

We haven’t customized our entity, which means our GraphQL API will contain a type representing the Album and one or more operations for reading (and not writing) Album data.

Read-only objects are a sensible default: we should knowingly opt in to having our objects be mutable via the API.

If we want create, update, or delete operations, we can override the access_permissions field on our entity class to control which operations are possible on Album. We’ll do this in the next section on securing our API.

Another sane default is that we must explicitly specify each model field we want to expose via our API. We can opt in to exposing all model fields by passing an instance of AllModelFields.

Note

Be wary of using AllModelFields. The convenience is nice, but if we build another (more sensitive) Entity via copy & paste and forget to remove AllModelFields, we risk exposing data we shouldn’t!

Security

DjraphQL allows flexible customization of what entities are exposed via the API, which operations are available on those entities, and who can access them.

The concepts in this section combine to enable us to build a robust and secure API.

Access permissions

To add operations for creation, mutation, and deletion of an Album, we must explicitly tell DjraphQL that this is what we want.

We do this by overriding the access_permissions field, which is set to (R,) by default.

What is R? It is defined in the djraphql.access_permissions module, and it is actually an instance of the Read class which is defined in the same module.

And in fact, there’s a constant for each C.R.U.D. operation:

  • C for creating objects

  • R for reading objects

  • U for updating objects

  • D for deleting objects

Schema-level control

We can use these to easily control whether our GraphQL schema contains C.R.U.D. operations for the Album entity.

Let’s add creation and mutation operations.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
from songs.models import Album
from graphene import Schema
from djraphql import SchemaBuilder, Entity, AllModelFields
from djraphql.access_permissions import C, R, U

class AlbumEntity(Entity):
    class Meta:
        model = Album
        fields = (AllModelFields(),)

    access_permissions = (C, R, U)

schema_builder = SchemaBuilder()
schema_builder.register_entity_classes(AlbumEntity)

schema = Schema(query=schema_builder.QueryRoot,
                mutation=schema_builder.MutationRoot)

This gives us high-level (i.e., at the schema-level), binary control over exposing types: if our entity’s access_permissions field contains C, the generated GraphQL schema will contain an operation for creating an object. And if it doesn’t, it won’t.

If that’s all your application needs, great! But most applications have more granular permissioning requirements. Let’s continue to the next section to see how DjraphQL tackles this use-case.

Request-level control

New feature request!

Our application’s admin users should be able to add or update Album objects, but regular users should only be able to read them.

We can’t depend on the mechanisms described above for this, because they merely change what types operations exist in the schema. Our new use case demands that the types for creation and updating exist, but that they are not available to non-admin users.

One way to accomplish this would be to generate a second schema, and serve it at an admin-only URL. But this isn’t ideal: there will inevitably be more roles, more request-level permission requirements. A schema for each permutation of those requirements will be confusing and cumbersome to maintain.

Instead, we can decide to fulfill a request or not based on the Django request object.

Recall that R is merely an instance of Read: the djraphql.access_permissions module literally defines R = Read(). This is convenient, as it avoids the verbosity of access_permissions = (Create(), Read(), Update(), Delete()).

However, when we want to deny or allow an operation based on the request context, we can provide arguments to Create and its counterparts that control whether or not a request is able to perform any operations available on a model.

As an example, let’s create a subclass of DjraphQLContextPermissionCheck and pass an instance of it to Create and Update.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from songs.models import Album
from graphene import Schema
from djraphql import SchemaBuilder, Entity, AllModelFields
from djraphql.access_permissions import (
    Create, R, Update, DjraphQLContextPermissionCheck
)

class IsAdmin(DjraphQLContextPermissionCheck):
    def is_authorized(self, context):
        return context.user.is_admin

class AlbumEntity(Entity):
    class Meta:
        model = Album
        fields = (AllModelFields(),)

    access_permissions = (
        Create(IsAdmin()),
        R,
        Update(IsAdmin())
    )

schema_builder = SchemaBuilder()
schema_builder.register_entity_classes(AlbumEntity)
schema = Schema(query=schema_builder.QueryRoot,
                mutation=schema_builder.MutationRoot)

Here, we’ve created an IsAdmin class that will verify the admin status of the user associated with the request. Mission accomplished!

Filter backends

Of course, our celebration is short-lived. We just got another request from leadership: for our music library Saas product to achieve unicorn status, we need to implement team accounts, and users within one team shouldn’t see playlists belonging to another team.

Let’s update our API schema to include our Playlist model and introduce a new construct: filter backends! This is the mechanism for achieving SQL-level control, and it’s how we’ll prevent playlists from leaking between teams.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from songs.models import Album, Playlist
from djraphql import (
    SchemaBuilder,
    Entity,
    FilterBackend,
    AllModelFields
)

class RequestHasSameTeam(FilterBackend):
    def __init__(self, path_to_team_id):
        self.path_to_team_id = path_to_team_id

    def filter_backend(self, request, queryset):
        return queryset.filter(**{
            self.path_to_team_id: request.user.team.pk})

class PlaylistEntity(Entity):
    class Meta:
        model = Playlist
        fields = (AllModelFields(),)

    access_permissions = (Create(), R, Update())
    filter_backends = (RequestHasSameTeam('team_id'),)

schema_builder = SchemaBuilder()
schema_builder.register_entity_classes(AlbumEntity, PlaylistEntity)
schema = Schema(query=schema_builder.QueryRoot,
                mutation=schema_builder.MutationRoot)

Let’s walk through what we’ve done here.

We’ve added an entity class for our Playlist model. It has a new filter_backends field defined as a tuple containing a single instance of our new RequestHasSameTeam class.

RequestHasSameTeam has a filter_backend method that takes the request object and QuerySet that will be used to fetch the Playlist(s), and returns a filtered QuerySet.

Any time a query attempts to read the Playlist model, DjraphQL will filter the Django QuerySet via our RequestHasSameTeam instance’s filter_backend method.

This has the effect that all SQL executed in the process of resolving a Playlist will contain an inner-join to the team table on playlist.team_id=team.id and a where-clause containing where team.id=X where X is request.user.team.id.

The team_id being passed to the RequestHasSameTeam instance is simply specifying where we can find the team_id for a Playlist – in this case, it’s right on the backing table. But if Playlist instead belonged to a User, which belongs to a Team, we would pass user__team_id.

Fields

Let’s pretend to be bad actors and try to access data across teams. How might we attack this API?

We could try guessing random identifiers and creating a Playlist under different teams.

mutation {
  createPlaylist(
    data: {
      name: "New Playlist"
      teamId: 2  # malicious guess at a team id!
    }
  ) {
    id
  }
}

Luckily, this would fail because DjraphQL checks that teamId references a Team that is included by the RequestHasSameTeam filter backend. That check would fail in this case, because no team would be returned by the queryset.

Note

This assumes that we have created and registered a TeamEntity (teamId wouldn’t be available otherwise), and that it has filter_backend = (RequestHasSameTeam('id'),).

This is good, but brings up a question: should we ever allow an API consumer to specify the Team that some object belongs to?

Probably not, right? It should always come from an authenticated request object. Read further to see how we can accomplish this.

Setting from a request

By overriding an entity’s get_for_insert handler, we can ensure that team_id is always set from the request object.

Let’s update our TeamEntity:

1
2
3
4
5
6
7
8
9
class TeamEntity(Entity):
    class Meta:
        model = Team
        fields = (AllModelFields(),)

    filter_backend = (RequestHasSameTeam('id'),)

    def get_for_insert(self, context):
        return context.user.team

By doing this, any time we are creating an object that has a team_id field, that field will be populated by our get_for_insert handler. In this case, we’re grabbing it straight from the user associated with the authenticated request.

This also has the effect that any teamId on any GraphQL object becomes read-only.

So our malicious actor’s query would actually fail to parse now, because teamId is no longer valid on the createPlaylist mutation!

Allow- & deny-lists

It’s often the case that a Django model has fields that we wouldn’t want to expose in our GraphQL schema. For example, User.password.

Allow-list is the default behavior of the fields property on the inner Meta class, and so we can simply define our list of ModelFields and leave out password.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
class UserEntity(Entity):
    class Meta:
        model = User
        # only expose listed fields in GraphQL schema
        fields = (
            ModelField('id'),
            ModelField('email'),
            ModelField('first_name'),
            ModelField('last_name'),
        )

Or, if it’s more natural to define a deny-list, we can pass an excluding argument to our AllModelFields instance. Any fields in our excluding deny-list will not be available in our schema.

1
2
3
4
5
6
7
class UserEntity(Entity):
    class Meta:
        model = User
        fields = (
            # password won't exist in GraphQL schema
            AllModelFields(excluding=('password',),
        )

Note that if a field is present in the excluding list and also has a ModelField, an error will be thrown.