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:
Cfor creating objects
Rfor reading objects
Ufor updating objects
Dfor 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.