diff --git a/graphene_django/fields.py b/graphene_django/fields.py index bbf1940..e078ba4 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -154,18 +154,18 @@ class DjangoConnectionField(ConnectionField): ) -class PermissionField(Field): +class DjangoField(Field): """Class to manage permission for fields""" def __init__(self, type, permissions=(), permissions_resolver=auth_resolver, *args, **kwargs): """Get permissions to access a field""" - super(PermissionField, self).__init__(type, *args, **kwargs) + super(DjangoField, self).__init__(type, *args, **kwargs) self.permissions = permissions self.permissions_resolver = permissions_resolver def get_resolver(self, parent_resolver): """Intercept resolver to analyse permissions""" - parent_resolver = super(PermissionField, self).get_resolver(parent_resolver) + parent_resolver = super(DjangoField, self).get_resolver(parent_resolver) if self.permissions: - return partial(get_unbound_function(self.permissions_resolver), parent_resolver, self.permissions, True) + return partial(get_unbound_function(self.permissions_resolver), parent_resolver, self.permissions, None, None, True) return parent_resolver diff --git a/graphene_django/tests/test_fields.py b/graphene_django/tests/test_fields.py index 23cce7f..e0478bd 100644 --- a/graphene_django/tests/test_fields.py +++ b/graphene_django/tests/test_fields.py @@ -1,6 +1,6 @@ from unittest import TestCase from django.core.exceptions import PermissionDenied -from graphene_django.fields import PermissionField +from graphene_django.fields import DjangoField class MyInstance(object): @@ -14,7 +14,7 @@ class PermissionFieldTests(TestCase): def test_permission_field(self): MyType = object() - field = PermissionField(MyType, permissions=['perm1', 'perm2'], source='resolver') + field = DjangoField(MyType, permissions=['perm1', 'perm2'], source='resolver') resolver = field.get_resolver(None) class Viewer(object): @@ -30,7 +30,7 @@ class PermissionFieldTests(TestCase): def test_permission_field_without_permission(self): MyType = object() - field = PermissionField(MyType, permissions=['perm1', 'perm2'], source='resolver') + field = DjangoField(MyType, permissions=['perm1', 'perm2'], source='resolver') resolver = field.get_resolver(field.resolver) class Viewer(object): diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 8a8643b..c9245ae 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -1,6 +1,6 @@ from mock import patch -from graphene import Interface, ObjectType, Schema, Connection, String +from graphene import Interface, ObjectType, Schema, Connection, String, Field from graphene.relay import Node from .. import registry @@ -224,3 +224,101 @@ def test_django_objecttype_exclude_fields(): fields = list(Reporter._meta.fields.keys()) assert "email" not in fields + + +def extra_field_resolver(root, info, **kwargs): + return 'extra field' + + +class PermissionArticle(DjangoObjectType): + """Basic Type to test""" + + class Meta(object): + """Meta Class""" + field_to_permission = { + 'headline': ('content_type.permission1',), + 'pub_date': ('content_type.permission2',) + } + permission_to_field = { + 'content_type.permission3': ('headline', 'reporter', 'extra_field',) + } + model = ArticleModel + + extra_field = Field(String, resolver=extra_field_resolver) + + def resolve_headline(self, info, **kwargs): + return 'headline' + + +def test_django_permissions(): + expected = { + 'headline': ('content_type.permission1', 'content_type.permission3'), + 'pub_date': ('content_type.permission2',), + 'reporter': ('content_type.permission3',), + 'extra_field': ('content_type.permission3',), + } + assert PermissionArticle.field_permissions == expected + + +def test_permission_resolver(): + MyType = object() + + class Viewer(object): + def has_perm(self, perm): + return perm == 'content_type.permission3' + + class Info(object): + class Context(object): + user = Viewer() + context = Context() + + resolved = PermissionArticle.resolve_headline(MyType, Info()) + assert resolved == 'headline' + + +def test_resolver_without_permission(): + MyType = object() + + class Viewer(object): + def has_perm(self, perm): + return False + + class Info(object): + class Context(object): + user = Viewer() + context = Context() + + resolved = PermissionArticle.resolve_headline(MyType, Info()) + assert resolved is None + + +def test_permission_resolver_to_field(): + MyType = object() + + class Viewer(object): + def has_perm(self, perm): + return perm == 'content_type.permission3' + + class Info(object): + class Context(object): + user = Viewer() + context = Context() + + resolved = PermissionArticle.resolve_extra_field(MyType, Info()) + assert resolved == 'extra field' + + +def test_resolver_to_field_without_permission(): + MyType = object() + + class Viewer(object): + def has_perm(self, perm): + return perm != 'content_type.permission3' + + class Info(object): + class Context(object): + user = Viewer() + context = Context() + + resolved = PermissionArticle.resolve_extra_field(MyType, Info()) + assert resolved is None diff --git a/graphene_django/types.py b/graphene_django/types.py index aa8b5a3..6ccd3c8 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -1,14 +1,15 @@ from collections import OrderedDict +from functools import partial from django.utils.functional import SimpleLazyObject -from graphene import Field +from graphene import Field, NonNull from graphene.relay import Connection, Node from graphene.types.objecttype import ObjectType, ObjectTypeOptions from graphene.types.utils import yank_fields_from_attrs from .converter import convert_django_field_with_choices from .registry import Registry, get_global_registry -from .utils import DJANGO_FILTER_INSTALLED, get_model_fields, is_valid_django_model +from .utils import DJANGO_FILTER_INSTALLED, get_model_fields, is_valid_django_model, auth_resolver def construct_fields(model, registry, only_fields, exclude_fields): @@ -32,6 +33,17 @@ def construct_fields(model, registry, only_fields, exclude_fields): return fields +def get_auth_resolver(name, permissions, resolver=None): + """ + Get middleware resolver to handle field permissions + :param name: Field name + :param permissions: List of permissions + :param resolver: Field resolver + :return: Middleware resolver to check permissions + """ + return partial(auth_resolver, resolver, permissions, name, None, False) + + class DjangoObjectTypeOptions(ObjectTypeOptions): model = None # type: Model registry = None # type: Registry @@ -41,6 +53,22 @@ class DjangoObjectTypeOptions(ObjectTypeOptions): class DjangoObjectType(ObjectType): + """ + DjangoObjectType inheritance to handle field authorization + Accepts field's permissions description as: + + class Meta: + + field_to_permission = { + 'restricted_field': ('permission1', 'permission2') + } + + permission_to_field = { + 'permission': ('restricted_field_1', 'restricted_field_2') + } + + At least one of the permissions must be accomplished in order to resolve the field. + """ @classmethod def __init_subclass_with_meta__( cls, @@ -54,6 +82,8 @@ class DjangoObjectType(ObjectType): connection_class=None, use_connection=None, interfaces=(), + field_to_permission=None, + permission_to_field=None, _meta=None, **options ): @@ -104,13 +134,78 @@ class DjangoObjectType(ObjectType): _meta.fields = django_fields _meta.connection = connection + field_permissions = cls.__get_field_permissions__(field_to_permission, permission_to_field) + if field_permissions: + cls.__set_as_nullable__(field_permissions, model, registry) + super(DjangoObjectType, cls).__init_subclass_with_meta__( _meta=_meta, interfaces=interfaces, **options ) + if field_permissions: + cls.__set_permissions_resolvers__(field_permissions) + + cls.field_permissions = field_permissions + if not skip_registry: registry.register(cls) + @classmethod + def __get_field_permissions__(cls, field_to_permission, permission_to_field): + """Combines permissions from meta""" + permissions = field_to_permission if field_to_permission else {} + if permission_to_field: + perm_to_field = cls.__get_permission_to_fields__(permission_to_field) + for field, perms in perm_to_field.items(): + if field in permissions: + permissions[field] += perms + else: + permissions[field] = perms + + return permissions + + @classmethod + def __get_permission_to_fields__(cls, permission_to_field): + """ + Accepts a dictionary like + { + permission: [fields] + } + :return: Mapping of fields to permissions like + { + field: [permissions] + } + """ + permissions = {} + for permission, fields in permission_to_field.items(): + for field in fields: + permissions[field] = permissions.get(field, ()) + (permission,) + return permissions + + @classmethod + def __set_permissions_resolvers__(cls, permissions): + """Set permission resolvers""" + for field_name, field_permissions in permissions.items(): + attr = 'resolve_{}'.format(field_name) + resolver = getattr(cls._meta.fields[field_name], 'resolver', None) or getattr(cls, attr, None) + + if not hasattr(field_permissions, '__iter__'): + field_permissions = tuple(field_permissions) + + setattr(cls, attr, get_auth_resolver(field_name, field_permissions, resolver)) + + @classmethod + def __set_as_nullable__(cls, field_permissions, model, registry): + """Set restricted fields as nullable""" + django_fields = yank_fields_from_attrs( + construct_fields(model, registry, field_permissions.keys(), ()), + _as=Field, + ) + for name, field in django_fields.items(): + if hasattr(field, '_type') and isinstance(field._type, NonNull): + field._type = field._type._of_type + setattr(cls, name, field) + def resolve_id(self, info): return self.pk diff --git a/graphene_django/utils.py b/graphene_django/utils.py index f4800eb..91d5709 100644 --- a/graphene_django/utils.py +++ b/graphene_django/utils.py @@ -6,6 +6,7 @@ from django.db.models.manager import Manager # from graphene.utils import LazyList +from graphene.types.resolver import get_default_resolver from graphene.utils.get_unbound_function import get_unbound_function @@ -110,11 +111,13 @@ def resolve_bound_resolver(resolver, root, info, **args): return resolver(root, info, **args) -def auth_resolver(parent_resolver, permissions, raise_exception, root, info, **args): +def auth_resolver(parent_resolver, permissions, attname, default_value, raise_exception, root, info, **args): """ Middleware resolver to check viewer's permissions :param parent_resolver: Field resolver :param permissions: Field permissions + :param attname: Field name + :param default_value: Default value to field if no resolver is provided :param raise_exception: If True a PermissionDenied is raised :param root: Schema root :param info: Schema info @@ -130,6 +133,8 @@ def auth_resolver(parent_resolver, permissions, raise_exception, root, info, **a if parent_resolver: # A resolver is provided in the class return resolve_bound_resolver(parent_resolver, root, info, **args) + # Get default resolver + return get_default_resolver()(attname, default_value, root, info, **args) elif raise_exception: raise PermissionDenied() return None