Skip to content

Commit

Permalink
Implement authentication on GraphQL types, on a field level using dec…
Browse files Browse the repository at this point in the history
…orators
  • Loading branch information
supertom01 committed Dec 9, 2024
1 parent 3494332 commit df3ed6c
Show file tree
Hide file tree
Showing 2 changed files with 86 additions and 0 deletions.
63 changes: 63 additions & 0 deletions amelie/graphql/decorators.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from graphql import GraphQLError

from graphql_jwt.decorators import user_passes_test, login_required


def _get_attribute(obj, dotted_path):
value = obj
Expand All @@ -24,3 +26,64 @@ def wrapper_args_allow_only_self_or_board(self, info, *args, **kwargs):
return func(self, info, *args, **kwargs)
return wrapper_args_allow_only_self_or_board
return wrapper_allow_only_self_or_board


AUTHORIZATION_FIELD_TYPES = ["public_fields", "login_fields", "committee_fields", "board_fields", "private_fields"]

def is_board_or_www(user):
is_board = hasattr(user, 'person') and hasattr(user.person, 'is_board') and user.person.is_board
is_superuser = hasattr(user, 'is_superuser') and user.is_superuser
return is_board or is_superuser

def committee_required(committees: list):
return user_passes_test(lambda u:is_board_or_www(u) or (hasattr(u, 'person') and hasattr(u.person, 'is_in_committee') and any(u.person.is_in_committee(committee) for committee in committees)))

def board_required():
return user_passes_test(lambda u: is_board_or_www(u))

def no_access():
return user_passes_test(lambda u: False)

def check_authorization(cls):
# Make sure that at least one of the authorization fields is present.
if not any(hasattr(cls, authorization_field) for authorization_field in AUTHORIZATION_FIELD_TYPES):
raise ValueError(f"At least one authorization field type should be defined for a GraphQL type, choose from: {', '.join(AUTHORIZATION_FIELD_TYPES)}")

public_fields = getattr(cls, "public_fields", [])
login_fields = getattr(cls, "login_fields", [])
committee_fields = getattr(cls, "committee_fields", [])
board_fields = getattr(cls, "board_fields", [])
private_fields = getattr(cls, "private_fields", [])

allowed_committees = getattr(cls, "allowed_committees", [])

# If there are committee fields defined, then the allowed committee list cannot be non-empty
if len(committee_fields) > 0 and len(allowed_committees) == 0:
raise ValueError(f"The following fields are only visible by a committee: \"{','.join(committee_fields)}\", but there are no committees defined that can view this field. Make sure that \"allowed_committees\" has at least a single entry.")

# Make sure that all the fields in the authorization fields are mutually exclusive.
authorization_fields = [*public_fields, *login_fields, *committee_fields, *board_fields, *private_fields]
if len(authorization_fields) != len(set(authorization_fields)):
raise ValueError("Some of the authorization fields have overlapping Django fields. Make sure that they are all mutually exclusive!")

# Make sure that all the fields that are defined in the fields list are in the authorization fields.
if not all((missing_field := field) in authorization_fields for field in cls._meta.fields):
raise ValueError(f"The field \"{missing_field}\" is defined in the Django fields list, but not in an authorization field list. All the django fields must be present in the authorization fields.")

# Require a user to be signed in.
for login_field in login_fields:
setattr(cls, f"resolve_{login_field}", login_required(lambda self, info, field=login_field: getattr(self, login_field)))

# Require a user to be in a committee
for committee_field in committee_fields:
setattr(cls, f"resolve_{committee_field}", committee_required(allowed_committees)(lambda self, info, field=committee_field: getattr(self, committee_field)))

# Require a user to be in the board
for board_field in board_fields:
setattr(cls, f"resolve_{board_field}", board_required()(lambda self, info, field=board_field: getattr(self, board_field)))

# No-one can access these fields
for private_field in private_fields:
setattr(cls, f"resolve_{private_field}", no_access())

return cls
23 changes: 23 additions & 0 deletions amelie/members/graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
from django_filters import FilterSet
from django.db.models import Q
from django.utils.translation import gettext_lazy as _

from amelie.graphql.decorators import check_authorization
from amelie.graphql.pagination.connection_field import DjangoPaginationConnectionField
from amelie.members.models import Committee, Function, CommitteeCategory

Expand Down Expand Up @@ -69,7 +71,28 @@ def include_abolished_filter(self, qs, filter_field, value):
return qs.filter(abolished__isnull=True)


@check_authorization
class CommitteeType(DjangoObjectType):
public_fields = [
"id",
"name",
"category",
"parent_committees",
"slug",
"email",
"abolished",
"website",
"information_nl",
"information_en",
"group_picture",
"function_set"
]
committee_fields = [
"founded"
]
allowed_committees = ["WWW"]
private_fields = ["logo", "information"]

class Meta:
model = Committee
description = "Type definition for a single Committee"
Expand Down

0 comments on commit df3ed6c

Please sign in to comment.