Permissions¶
DjangoModelType supports a permission layer (modeled after DRF's API),
checked per action (create / update / delete / retrieve / list)
before each operation runs.
DRF is not a dependency
"Modeled after DRF" refers only to the API style (permission_classes,
BasePermission, has_permission). Django REST Framework is not
required, imported, or used — there is nothing extra to install. All
permission classes below are implemented in
django_graphex.permissions.
Set permission_classes on the type:
from django_graphex.permissions import IsAuthenticatedOrReadOnly
from django_graphex.types import DjangoModelType
class OrderType(DjangoModelType):
permission_classes = [IsAuthenticatedOrReadOnly]
class Meta:
model = Order
With no permission_classes (the default) nothing is checked — every operation
runs. A denied action raises a GraphQLError and the operation does not run:
{
"errors": [{
"message": "You do not have permission to perform this action.",
"extensions": {"code": "PERMISSION_DENIED", "status_code": 403}
}]
}
Ready-made permissions¶
| Class | Allows |
|---|---|
AllowAny |
every action (explicit form of the default) |
IsAuthenticated |
only authenticated users |
IsAdmin |
only active staff superusers |
IsAuthenticatedOrReadOnly |
anyone may retrieve/list; authenticated users may write |
IsAdminOrReadOnly |
anyone may retrieve/list; admins may write |
DjangoModelPermissions |
users holding the matching Django model permission (see below) |
from django_graphex.permissions import AllowAny, IsAuthenticated, IsAdmin, IsAuthenticatedOrReadOnly, IsAdminOrReadOnly, DjangoModelPermissions
Multiple classes are combined with AND — every class must allow the action.
Writing a custom permission¶
Subclass BasePermission and override either has_permission(self, info, action,
model, **kwargs) (applies to every action) or a single has_<action>_permission(
self, info, model, **kwargs). info.context is the request, and kwargs carries
data= for create/update. Return a falsy value to deny.
from django_graphex.permissions import BasePermission
class IsOwnerOrReadOnly(BasePermission):
def has_permission(self, info, action, model, **kwargs):
if action in ("retrieve", "list"):
return True
return info.context.user.is_authenticated
CRUD permission hooks are synchronous
Define has_permission and has_<action>_permission with def, not
async def. If a hook returns any awaitable, django-graphex closes or
cancels it and raises ImproperlyConfigured before the operation runs.
It never applies truthiness to a coroutine and never bridges it with
async_to_sync.
This differs intentionally from subscription hooks such as
authorize_subscription, whose delivery pipeline explicitly supports both
synchronous and asynchronous implementations.
Any falsy value denies — you do not have to return False
The check fails closed on False, None, 0 and "" alike, so the
idiomatic one-liner is safe:
def has_permission(self, info, action, model, **kwargs):
user = getattr(info.context, "user", None)
return user and user.is_staff # -> None for an anonymous caller: DENIED
(Fixed in 2.2.0: 2.1.0 and earlier compared the result with the False
singleton, so this exact one-liner granted every action to an anonymous
caller.)
nested_parent: telling a nested write apart from a direct one¶
A child written through a parent's Meta.nested_fields runs the child's own
permission checks, and kwargs then carries nested_parent — the parent
model class. It is absent on the child's own mutation, so a policy can grant a
write only when it arrives through a parent:
class OnlyViaParent(BasePermission):
def has_create_permission(self, info, model, **kwargs):
return kwargs.get("nested_parent") is not None
A model whose rows only ever make sense inside their owner (comment lines of an
order, addresses of a user) can therefore drop its own create root without
losing the nested surface. See
Nested writes for the full
contract, including which paths are gated.
Checks with a closed signature never see it
Each extra is only passed to a check that can accept it, so an authorize
override or a permission class that spells its arguments out
(def authorize(cls, info, action, data=None),
def has_permission(self, info, action, model, data=None)) keeps working
unchanged — it simply never receives nested_parent, and therefore treats a
nested write exactly like a direct one. The narrowing happens at the call
that lands on your method, so the **kwargs on the built-in
has_<action>_permission in between does not leak the marker through.
Accept **kwargs to see it.
DjangoModelPermissions¶
DjangoModelPermissions maps each CRUD action to Django's built-in model
permissions (DRF-style) and checks them with user.has_perms. Set it on a
DjangoModelType like any other permission class:
from django_graphex.permissions import DjangoModelPermissions
from django_graphex.types import DjangoModelType
class OrderType(DjangoModelType):
permission_classes = [DjangoModelPermissions]
class Meta:
model = Order
The mapping is composite: because a mutation payload returns instance data,
each write action requires both its write permission and view. Read and
observe actions stay view-only:
| Action | Required permission(s) |
|---|---|
create |
{app_label}.add_{model_name} and {app_label}.view_{model_name} |
update |
{app_label}.change_{model_name} and {app_label}.view_{model_name} |
delete |
{app_label}.delete_{model_name} and {app_label}.view_{model_name} |
retrieve |
{app_label}.view_{model_name} |
list |
{app_label}.view_{model_name} |
subscribe |
{app_label}.view_{model_name} (plus the requested action's row — see below) |
Changed in 2.0.0 — write actions (
create/update/delete) now also require theviewpermission. A user who could previously write with only theadd/change/deletepermission must also be grantedview. To restore the old write-only behavior (e.g. a write-only inbox), overrideperms_mapfor that action with just the write verb (see below).
Notes:
- Relations are gated by the model they point at.
permission_classesruns on the CRUD/subscribe entry points of this type; it does not run again on a nested relation resolver. Reading a related model through a relation field (post { comments { results { text } } }) is instead gated at the schema layer: withPERMISSION_SCOPED_SCHEMAenabled, every generated relation and nested-list field requires the target model'sview_{model_name}, so a caller who cannot queryCommentdirectly cannot reach it throughPosteither. Without that flag the schema is never pruned and a relation stays reachable — enable it if relation traversal must be permission-checked. - Superusers pass automatically — Django's
ModelBackendgrants every permission to an active superuser. - Anonymous users are always denied (the class is fail-closed).
- Fail-closed when no model — the check denies when it has no model context
to map. That makes it suitable for
DjangoModelType.permission_classes(where a model is always supplied) but not for view-levelAuthenticatedGraphQLView.permission_classes(where no model is passed).
Customizing the codenames¶
The mapping lives in the perms_map class attribute (action → tuple of format
strings resolved against the model's app_label/model_name). Override it in a
subclass to require different codenames:
from django_graphex.permissions import DjangoModelPermissions
class PublishModelPermissions(DjangoModelPermissions):
perms_map = {
**DjangoModelPermissions.perms_map,
"create": ("{app_label}.publish_{model_name}",),
}
Because the default is composite, overriding an action with a single write verb is the escape hatch for a write-only flow (e.g. an inbox where a user may create records but not read them back):
class WriteOnlyInbox(DjangoModelPermissions):
perms_map = {
**DjangoModelPermissions.perms_map,
# create requires only `add` — the `view` requirement is dropped.
"create": ("{app_label}.add_{model_name}",),
}
For finer control, override get_required_permissions(self, action, model),
which returns the list of codenames an action requires (or None for an unknown
action).
A subscribe that forwards the action it observes (CREATE / UPDATE /
DELETE / ALL_ACTIONS, see Subscriptions) is gated by the
union of the subscribe row and every write row that action maps to via
subscribe_actions_map ("all_actions" maps to all three). Both halves go
through get_required_permissions, so a customized row applies on this path as
well:
class StreamModelPermissions(DjangoModelPermissions):
perms_map = {
**DjangoModelPermissions.perms_map,
# subscribing needs a dedicated codename instead of plain `view`.
"subscribe": ("{app_label}.stream_{model_name}",),
}
# subscribe(action: CREATE) now requires:
# {app}.stream_{model} + {app}.add_{model} + {app}.view_{model}
See the whole permission stack in action
DjangoModelPermissions is the runtime half of a larger model: the same
composite table also labels the schema so PERMISSION_SCOPED_SCHEMA can
serve each caller a schema pruned to their permissions. The
Permission-scoped schema guide walks a Blog
API through all three layers with worked examples.
Customizing the check¶
Each CRUD operation calls authorize(cls, info, action, **kwargs) first, which
runs the permission checks. Override it to customize — for example, to skip checks
in local development:
from django.conf import settings
class OrderType(DjangoModelType):
permission_classes = [IsAuthenticated]
class Meta:
model = Order
@classmethod
def authorize(cls, info, action, **kwargs):
if settings.DEBUG:
return # skip checks locally
super().authorize(info, action, **kwargs)
Per-request data scoping vs. permissions
Permissions answer "may this action run at all?". To scope the rows a
user can see (row-level filtering), use
filter_queryset instead.
That scope covers the rows a user can write too: update and delete
resolve their target through the same hook, and a row outside the scope is
reported as not found.