diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 1ecce454c..e3129c69c 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -6,10 +6,11 @@ from graphene.types import Field, List from graphene.relay import ConnectionField, PageInfo +from graphene.utils.get_unbound_function import get_unbound_function from graphql_relay.connection.arrayconnection import connection_from_list_slice from .settings import graphene_settings -from .utils import maybe_queryset +from .utils import maybe_queryset, auth_resolver class DjangoListField(Field): @@ -151,3 +152,21 @@ def get_resolver(self, parent_resolver): self.max_limit, self.enforce_first_or_last, ) + + +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(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(DjangoField, self).get_resolver(parent_resolver) + if self.permissions: + return partial(get_unbound_function(self.permissions_resolver), parent_resolver, self.permissions, None, + None, True) + return parent_resolver diff --git a/graphene_django/forms/converter.py b/graphene_django/forms/converter.py index 87180b291..023eca745 100644 --- a/graphene_django/forms/converter.py +++ b/graphene_django/forms/converter.py @@ -1,8 +1,10 @@ from django import forms from django.core.exceptions import ImproperlyConfigured -from graphene import ID, Boolean, Float, Int, List, String, UUID, Date, DateTime, Time +from graphene import ID, Boolean, Float, Int, List, String, UUID, Date, DateTime, Time, Enum +from graphene.utils.str_converters import to_camel_case +from graphene_django.converter import get_choices from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField from ..utils import import_single_dispatch @@ -82,3 +84,44 @@ def convert_form_field_to_time(field): @convert_form_field.register(GlobalIDFormField) def convert_form_field_to_id(field): return ID(required=field.required) + + +def get_form_name(form): + """Get form name""" + class_name = str(form.__class__).split('.')[-1] + return class_name[:-2] + + +def convert_form_field_with_choices(field, name=None, form=None): + """ + Helper method to convert a field to graphene Field type. + :param name: form field's name + :param field: form field to convert to + :param form: field's form + :return: graphene Field + """ + choices = getattr(field, 'choices', None) + + # If is a choice field, but not depends on models + if not isinstance(field, (forms.ModelMultipleChoiceField, forms.ModelChoiceField)) and choices: + if form: + name = to_camel_case("{}_{}".format(get_form_name(form), field.label or name)) + else: + name = field.label or name + name = to_camel_case(name.replace(' ', '_')) + choices = list(get_choices(choices)) + named_choices = [(c[0], c[1]) for c in choices] + named_choices_descriptions = {c[0]: c[2] for c in choices} + + class EnumWithDescriptionsType(object): + """Enum type definition""" + + @property + def description(self): + """Return field description""" + + return named_choices_descriptions[self.name] + + enum = Enum(name, list(named_choices), type=EnumWithDescriptionsType) + return enum(description=field.help_text, required=field.required) # pylint: disable=E1102 + return convert_form_field(field) diff --git a/graphene_django/forms/mutation.py b/graphene_django/forms/mutation.py index 63ea08981..ecf492c34 100644 --- a/graphene_django/forms/mutation.py +++ b/graphene_django/forms/mutation.py @@ -13,7 +13,7 @@ from graphene.types.utils import yank_fields_from_attrs from graphene_django.registry import get_global_registry -from .converter import convert_form_field +from .converter import convert_form_field_with_choices from .types import ErrorType @@ -30,7 +30,7 @@ def fields_for_form(form, only_fields, exclude_fields): if is_not_in_only or is_excluded: continue - fields[name] = convert_form_field(field) + fields[name] = convert_form_field_with_choices(field, name=name, form=form) return fields @@ -103,7 +103,7 @@ class Meta: @classmethod def __init_subclass_with_meta__( - cls, form_class=None, only_fields=(), exclude_fields=(), **options + cls, form_class=None, mirror_input=False, only_fields=(), exclude_fields=(), **options ): if not form_class: @@ -111,7 +111,10 @@ def __init_subclass_with_meta__( form = form_class() input_fields = fields_for_form(form, only_fields, exclude_fields) - output_fields = fields_for_form(form, only_fields, exclude_fields) + if mirror_input: + output_fields = fields_for_form(form, only_fields, exclude_fields) + else: + output_fields = {} _meta = DjangoFormMutationOptions(cls) _meta.form_class = form_class diff --git a/graphene_django/forms/tests/test_converter.py b/graphene_django/forms/tests/test_converter.py index 955b95290..e76c5c253 100644 --- a/graphene_django/forms/tests/test_converter.py +++ b/graphene_django/forms/tests/test_converter.py @@ -14,9 +14,10 @@ DateTime, Date, Time, + Enum, ) -from ..converter import convert_form_field +from ..converter import convert_form_field, convert_form_field_with_choices def assert_conversion(django_field, graphene_field, *args): @@ -112,3 +113,9 @@ def test_should_manytoone_convert_connectionorlist(): field = forms.ModelChoiceField(queryset=None) graphene_type = convert_form_field(field) assert isinstance(graphene_type, ID) + + +def test_should_typed_choice_convert_enum(): + field = forms.TypedChoiceField(choices=(('A', 'Choice A'), ('B', 'Choice B')), label='field') + graphene_type = convert_form_field_with_choices(field, name='field_name') + assert isinstance(graphene_type, Enum) diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py index df0ffd5ee..1f39afe78 100644 --- a/graphene_django/forms/tests/test_mutation.py +++ b/graphene_django/forms/tests/test_mutation.py @@ -139,3 +139,36 @@ class Meta: self.assertEqual(result.errors[0].messages, ["This field is required."]) self.assertIn("age", fields_w_error) self.assertEqual(result.errors[1].messages, ["This field is required."]) + + +class FormMutationTests(TestCase): + def test_default_meta_fields(self): + class MyMutation(DjangoFormMutation): + class Meta: + form_class = MyForm + self.assertNotIn("text", MyMutation._meta.fields) + + def test_mirror_meta_fields(self): + class MyMutation(DjangoFormMutation): + class Meta: + form_class = MyForm + mirror_input = True + + self.assertIn("text", MyMutation._meta.fields) + + def test_default_input_meta_fields(self): + class MyMutation(DjangoFormMutation): + class Meta: + form_class = MyForm + + self.assertIn("client_mutation_id", MyMutation.Input._meta.fields) + self.assertIn("text", MyMutation.Input._meta.fields) + + def test_exclude_fields_input_meta_fields(self): + class MyMutation(DjangoFormMutation): + class Meta: + form_class = MyForm + exclude_fields = ['text'] + + self.assertNotIn("text", MyMutation.Input._meta.fields) + self.assertIn("client_mutation_id", MyMutation.Input._meta.fields) diff --git a/graphene_django/tests/test_fields.py b/graphene_django/tests/test_fields.py new file mode 100644 index 000000000..e0478bdb0 --- /dev/null +++ b/graphene_django/tests/test_fields.py @@ -0,0 +1,46 @@ +from unittest import TestCase +from django.core.exceptions import PermissionDenied +from graphene_django.fields import DjangoField + + +class MyInstance(object): + value = "value" + + def resolver(self): + return "resolver method" + + +class PermissionFieldTests(TestCase): + + def test_permission_field(self): + MyType = object() + field = DjangoField(MyType, permissions=['perm1', 'perm2'], source='resolver') + resolver = field.get_resolver(None) + + class Viewer(object): + def has_perm(self, perm): + return perm == 'perm2' + + class Info(object): + class Context(object): + user = Viewer() + context = Context() + + self.assertEqual(resolver(MyInstance(), Info()), MyInstance().resolver()) + + def test_permission_field_without_permission(self): + MyType = object() + field = DjangoField(MyType, permissions=['perm1', 'perm2'], source='resolver') + resolver = field.get_resolver(field.resolver) + + class Viewer(object): + def has_perm(self, perm): + return False + + class Info(object): + class Context(object): + user = Viewer() + context = Context() + + with self.assertRaises(PermissionDenied): + resolver(MyInstance(), Info()) diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 8a8643b9b..c9245aedc 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 @@ class Meta: 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/tests/test_utils.py b/graphene_django/tests/test_utils.py index becd03188..e1068c17b 100644 --- a/graphene_django/tests/test_utils.py +++ b/graphene_django/tests/test_utils.py @@ -1,4 +1,4 @@ -from ..utils import get_model_fields +from ..utils import get_model_fields, has_permissions from .models import Film, Reporter @@ -10,3 +10,23 @@ def test_get_model_fields_no_duplication(): film_fields = get_model_fields(Film) film_name_set = set([field[0] for field in film_fields]) assert len(film_fields) == len(film_name_set) + + +def test_has_permissions(): + class Viewer(object): + @staticmethod + def has_perm(permission): + return permission + + viewer_as_perm = has_permissions(Viewer(), [False, True, False]) + assert viewer_as_perm + + +def test_viewer_without_permissions(): + class Viewer(object): + @staticmethod + def has_perm(permission): + return permission + + viewer_as_perm = has_permissions(Viewer(), [False, False, False]) + assert not viewer_as_perm diff --git a/graphene_django/types.py b/graphene_django/types.py index aa8b5a30c..6ccd3c86a 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 @@ def __init_subclass_with_meta__( connection_class=None, use_connection=None, interfaces=(), + field_to_permission=None, + permission_to_field=None, _meta=None, **options ): @@ -104,13 +134,78 @@ def __init_subclass_with_meta__( _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 560f60472..91d5709f6 100644 --- a/graphene_django/utils.py +++ b/graphene_django/utils.py @@ -1,10 +1,13 @@ import inspect +from django.core.exceptions import PermissionDenied from django.db import models 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 class LazyList(object): @@ -81,3 +84,57 @@ def import_single_dispatch(): ) return singledispatch + + +def has_permissions(viewer, permissions): + """ + Verify that at least one permission is accomplished + :param viewer: Field's viewer + :param permissions: Field permissions + :return: True if viewer has permission. False otherwise. + """ + if not permissions: + return True + return any([viewer.has_perm(perm) for perm in permissions]) + + +def resolve_bound_resolver(resolver, root, info, **args): + """ + Resolve provided resolver + :param resolver: Explicit field resolver + :param root: Schema root + :param info: Schema info + :param args: Schema args + :return: Resolved field + """ + resolver = get_unbound_function(resolver) + return resolver(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 + :param args: Schema args + :return: Resolved field. None if the viewer does not have permission to access the field. + """ + # Get viewer from context + if not hasattr(info.context, 'user'): + raise PermissionDenied() + user = info.context.user + + if has_permissions(user, permissions): + 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