Repository: strawberry-graphql/strawberry

Issue:
Support for field permissions & visibility
Would be nice to have a simple way to require permission for specific fields and also hide them from the schema (visibility).

Permission example (borrowed from DRF)

```py
import strawberry

from strawberry.permission import BasePermission


class IsAuthenticated(BasePermission):
    def has_permission(self, info) -> bool:
        return False


@strawberry.type
class User:
    name: str
    age: int


@strawberry.type
class Query:
    @strawberry.field(permission_classes=[IsAuthenticated])
    def me(self, info) -> Optional[User]:
        return User(name="Patrick", age=100)
```

Visibility example, what this should do is to completely hide the field from the schema if the client is not able to see it (so not even shown when doing introspection).

```py
import strawberry

from strawberry.permission import BasePermission


class IsAdminClient(BasePermission):
    def has_permission(self, info) -> bool:
        return False


@strawberry.type
class User:
    name: str
    age: int


@strawberry.type
class Query:
    @strawberry.field(visiblity_classes=[IsAdminClient])
    def me(self, info) -> Optional[User]:
        return User(name="Patrick", age=100)
```


Discussion:
Hi @patrick91! great package you have here. I have been working with graphene-python for more than a year now. I really like how you are utilising python dataclasses in strawberry. I am interested in contributing to your project. Do you mind if I take a crack at this issue?
@johnchuks not at all :) let me know how it goes! :D

Are you on Graphene's slack? I'm usually there if you want to chat :)
Awesome! - Thank you. I am not on there actually - I will join it now

Resolving patch:
diff --git a/strawberry/__init__.py b/strawberry/__init__.py
--- a/strawberry/__init__.py
+++ b/strawberry/__init__.py
@@ -7,3 +7,4 @@
 from .scalars import ID  # noqa
 from .schema import Schema  # noqa
 from .type import input, type, interface  # noqa
+from .permission import BasePermission  # noqa
diff --git a/strawberry/field.py b/strawberry/field.py
--- a/strawberry/field.py
+++ b/strawberry/field.py
@@ -40,6 +40,7 @@ def __init__(
         resolver=None,
         name=None,
         description=None,
+        permission_classes=None
     ):
         self._wrapped_obj = obj
         self.is_subscription = is_subscription
@@ -47,6 +48,7 @@ def __init__(
         self.field_name = name
         self.field_resolver = resolver
         self.field_description = description
+        self.permission_classes = permission_classes or []
 
         if callable(self._wrapped_obj):
             self._check_has_annotations(self._wrapped_obj)
@@ -85,6 +87,23 @@ def __getattr__(self, attr):
     def __call__(self, *args, **kwargs):
         return self._wrapped_obj(self, *args, **kwargs)
 
+    def _get_permissions(self):
+        """
+        Gets all permissions defined in the permission classes
+        >>> strawberry.field(permission_classes=[IsAuthenticated])
+        """
+        return (permission() for permission in self.permission_classes)
+
+    def _check_permissions(self, info):
+        """
+        Checks if the permission should be accepted and
+        raises an exception if not
+        """
+        for permission in self._get_permissions():
+            if not permission.has_permission(info):
+                message = getattr(permission, "message", None)
+                raise PermissionError(message)
+
     @lazy_property
     def field(self):
         return _get_field(
@@ -93,6 +112,7 @@ def field(self):
             is_subscription=self.is_subscription,
             name=self.field_name,
             description=self.field_description,
+            check_permission=self._check_permissions,
         )
 
 
@@ -128,12 +148,14 @@ def __init__(
         name=None,
         description=None,
         metadata=None,
+        permission_classes=None
     ):
         self.field_name = name
         self.field_description = description
         self.field_resolver = resolver
         self.is_subscription = is_subscription
         self.is_input = is_input
+        self.field_permission_classes = permission_classes
 
         super().__init__(
             # TODO:
@@ -161,6 +183,7 @@ def __call__(self, wrap):
             resolver=self.field_resolver,
             name=self.field_name,
             description=self.field_description,
+            permission_classes=self.field_permission_classes,
         )
 
 
@@ -198,7 +221,13 @@ def convert_args(args, annotations):
 
 
 def _get_field(
-    wrap, *, is_input=False, is_subscription=False, name=None, description=None
+    wrap,
+    *,
+    is_input=False,
+    is_subscription=False,
+    name=None,
+    description=None,
+    check_permission=None
 ):
     name = wrap.__name__
 
@@ -220,8 +249,9 @@ def _get_field(
     }
 
     def resolver(source, info, **args):
+        if check_permission:
+            check_permission(info)
         args = convert_args(args, arguments_annotations)
-
         return wrap(source, info, **args)
 
     field_params = {}
@@ -232,6 +262,8 @@ def resolver(source, info, **args):
         if is_subscription:
 
             def _resolve(event, info):
+                if check_permission:
+                    check_permission(info)
                 return event
 
             field_params.update({"subscribe": resolver, "resolve": _resolve})
@@ -253,6 +285,7 @@ def field(
     resolver=None,
     is_input=False,
     is_subscription=False,
+    permission_classes=None
 ):
     """Annotates a method or property as a GraphQL field.
 
@@ -275,6 +308,7 @@ def field(
         resolver=resolver,
         is_input=is_input,
         is_subscription=is_subscription,
+        permission_classes=permission_classes,
     )
 
     # when calling this with parens we are going to return a strawberry_field
diff --git a/strawberry/permission.py b/strawberry/permission.py
new file mode 100644
--- /dev/null
+++ b/strawberry/permission.py
@@ -0,0 +1,9 @@
+class BasePermission:
+    """
+    Base class for creating permissions
+    """
+
+    def has_permission(self, info):
+        raise NotImplementedError(
+            "Permission classes should override has_permission method"
+        )


Test patch:
diff --git a/tests/test_permission.py b/tests/test_permission.py
new file mode 100644
--- /dev/null
+++ b/tests/test_permission.py
@@ -0,0 +1,78 @@
+import typing
+
+import pytest
+
+import strawberry
+from graphql import graphql_sync
+from graphql.language import parse
+from graphql.subscription import subscribe
+from strawberry.permission import BasePermission
+
+
+def test_raises_graphql_error_when_permission_method_is_missing():
+    class IsAuthenticated(BasePermission):
+        pass
+
+    @strawberry.type
+    class Query:
+        @strawberry.field(permission_classes=[IsAuthenticated])
+        def user(self, info) -> str:
+            return "patrick"
+
+    schema = strawberry.Schema(query=Query)
+
+    query = "{ user }"
+
+    result = graphql_sync(schema, query)
+    assert (
+        result.errors[0].message
+        == "Permission classes should override has_permission method"
+    )
+
+
+def test_raises_graphql_error_when_permission_is_denied():
+    class IsAuthenticated(BasePermission):
+        message = "User is not authenticated"
+
+        def has_permission(self, info):
+            return False
+
+    @strawberry.type
+    class Query:
+        @strawberry.field(permission_classes=[IsAuthenticated])
+        def user(self, info) -> str:
+            return "patrick"
+
+    schema = strawberry.Schema(query=Query)
+
+    query = "{ user }"
+
+    result = graphql_sync(schema, query)
+    assert result.errors[0].message == "User is not authenticated"
+
+
+@pytest.mark.asyncio
+async def test_raises_permission_error_for_subscription():
+    class IsAdmin(BasePermission):
+        message = "You are not authorized"
+
+        def has_permission(self, info):
+            return False
+
+    @strawberry.type
+    class Query:
+        name: str = "Andrew"
+
+    @strawberry.type
+    class Subscription:
+        @strawberry.subscription(permission_classes=[IsAdmin])
+        async def user(self, info) -> typing.AsyncGenerator[str, None]:
+            yield "Hello"
+
+    schema = strawberry.Schema(query=Query, subscription=Subscription)
+
+    query = "subscription { user }"
+
+    result = await subscribe(schema, parse(query))
+
+    assert result.errors[0].message == "You are not authorized"