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 ModelField
s 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.