.. DjraphQL documentation master file, created by sphinx-quickstart on Tue Jan 12 21:31:26 2021. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Core concepts ============= .. toctree:: :maxdepth: 5 :caption: Contents: 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. .. code-block:: python 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. .. code-block:: python :linenos: :emphasize-lines: 4,11,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``. .. code-block:: python :linenos: :emphasize-lines: 5,8-10,18-20 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. .. code-block:: python :linenos: :emphasize-lines: 5,9-15,23 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. .. code-block:: graphql 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``: .. code-block:: python :linenos: :emphasize-lines: 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``. .. code-block:: python :linenos: :emphasize-lines: 6-9 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. .. code-block:: python :linenos: :emphasize-lines: 6 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.