From d5bdfab7ae8d4963ea72b60ff6cfc446397c41de Mon Sep 17 00:00:00 2001 From: Falk Date: Mon, 6 Nov 2023 17:10:47 +0100 Subject: [PATCH 1/4] fix(graphql): disable introspection endpoint on production --- caluma/schema.py | 11 +++++++++++ caluma/settings/caluma.py | 1 + 2 files changed, 12 insertions(+) diff --git a/caluma/schema.py b/caluma/schema.py index 0fb6becd2..d2377e5a7 100644 --- a/caluma/schema.py +++ b/caluma/schema.py @@ -1,8 +1,12 @@ +from functools import partial + import graphene from django.conf import settings from graphene.relay import Node +from graphene.validation import DisableIntrospection from graphene_django.converter import convert_django_field, convert_field_to_string from graphene_django.debug import DjangoDebug +from graphql import validate from localized_fields.fields import LocalizedField from .caluma_analytics import schema as analytics_schema @@ -93,3 +97,10 @@ class Query(*query_inherit_from): # TODO: define what app exposes what types types=types, ) + +if settings.DISABLE_INTROSPECTION: + validate = partial(validate, rules=(DisableIntrospection,)) + +validation_errors = validate( + schema=schema.graphql_schema, +) diff --git a/caluma/settings/caluma.py b/caluma/settings/caluma.py index c063b95f0..6f13c0ecf 100644 --- a/caluma/settings/caluma.py +++ b/caluma/settings/caluma.py @@ -51,6 +51,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "MIDDLEWARE": [], "RELAY_CONNECTION_MAX_LIMIT": None, } +DISABLE_INTROSPECTION = env.bool("DISABLE_INTROSPECTION", default=default(False, True)) # OpenID connect From e9f837b3c80534fa36c50ab9ab5edf3320bf7ccd Mon Sep 17 00:00:00 2001 From: Falk Date: Fri, 22 Dec 2023 17:51:02 +0100 Subject: [PATCH 2/4] chore: update graphene-django and refactor disable introspection via validation rules property --- caluma/caluma_user/views.py | 4 ++++ caluma/schema.py | 11 ----------- poetry.lock | 17 +++++++++-------- pyproject.toml | 2 +- 4 files changed, 14 insertions(+), 20 deletions(-) diff --git a/caluma/caluma_user/views.py b/caluma/caluma_user/views.py index 87d91cea5..3417cd04b 100644 --- a/caluma/caluma_user/views.py +++ b/caluma/caluma_user/views.py @@ -8,6 +8,7 @@ from django.http.response import HttpResponse from django.utils.encoding import force_bytes, smart_str from django.utils.module_loading import import_string +from graphene.validation import DisableIntrospection from graphene_django.views import GraphQLView, HttpError from rest_framework.authentication import get_authorization_header @@ -19,6 +20,9 @@ class HttpResponseUnauthorized(HttpResponse): class AuthenticationGraphQLView(GraphQLView): + if settings.DISABLE_INTROSPECTION: # pragma: no cover + validation_rules = (DisableIntrospection,) + def get_bearer_token(self, request): auth = get_authorization_header(request).split() header_prefix = "Bearer" diff --git a/caluma/schema.py b/caluma/schema.py index d2377e5a7..0fb6becd2 100644 --- a/caluma/schema.py +++ b/caluma/schema.py @@ -1,12 +1,8 @@ -from functools import partial - import graphene from django.conf import settings from graphene.relay import Node -from graphene.validation import DisableIntrospection from graphene_django.converter import convert_django_field, convert_field_to_string from graphene_django.debug import DjangoDebug -from graphql import validate from localized_fields.fields import LocalizedField from .caluma_analytics import schema as analytics_schema @@ -97,10 +93,3 @@ class Query(*query_inherit_from): # TODO: define what app exposes what types types=types, ) - -if settings.DISABLE_INTROSPECTION: - validate = partial(validate, rules=(DisableIntrospection,)) - -validation_errors = validate( - schema=schema.graphql_schema, -) diff --git a/poetry.lock b/poetry.lock index c57af9c47..64952d95a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -933,26 +933,27 @@ test = ["coveralls (>=3.3,<4)", "iso8601 (>=1,<2)", "mock (>=4,<5)", "pytest (>= [[package]] name = "graphene-django" -version = "3.0.0b7" +version = "3.2.0" description = "Graphene Django integration" optional = false python-versions = "*" files = [ - {file = "graphene-django-3.0.0b7.tar.gz", hash = "sha256:b1a4ce1a2227b1e77f67f0bc2fadd59c1d05016cb9aced45ab65f8612fba2c87"}, - {file = "graphene_django-3.0.0b7-py2.py3-none-any.whl", hash = "sha256:0f226ec7db744a54dbc5d6db2aa52d945701ae800e1055046dec2b76a539550a"}, + {file = "graphene-django-3.2.0.tar.gz", hash = "sha256:9aca4a862f12912c2e611624bdbcf6b0f9bc7a41d240110a41bf95575a7bacab"}, + {file = "graphene_django-3.2.0-py2.py3-none-any.whl", hash = "sha256:b553ecdc1cd7fd5b2d71de1a729c03ae117321763a90ed48a7fb4fdbf7f0d43f"}, ] [package.dependencies] -Django = ">=2.2" -graphene = ">=3.0.0b5,<4" +Django = ">=3.2" +graphene = ">=3.0,<4" graphql-core = ">=3.1.0,<4" +graphql-relay = ">=3.1.1,<4" promise = ">=2.1" text-unidecode = "*" [package.extras] -dev = ["black (==19.10b0)", "coveralls", "django-filter (>=2)", "djangorestframework (>=3.6.3)", "flake8 (==3.7.9)", "flake8-black (==0.1.1)", "flake8-bugbear (==20.1.4)", "mock", "pytest (>=3.6.3)", "pytest-cov", "pytest-django (>=3.3.2)", "pytest-random-order", "pytz"] +dev = ["coveralls", "django-filter (>=22.1)", "djangorestframework (>=3.6.3)", "mock", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-django (>=4.5.2)", "pytest-random-order", "pytz", "ruff (==0.1.2)"] rest-framework = ["djangorestframework (>=3.6.3)"] -test = ["coveralls", "django-filter (>=2)", "djangorestframework (>=3.6.3)", "mock", "pytest (>=3.6.3)", "pytest-cov", "pytest-django (>=3.3.2)", "pytest-random-order", "pytz"] +test = ["coveralls", "django-filter (>=22.1)", "djangorestframework (>=3.6.3)", "mock", "pytest (>=7.3.1)", "pytest-cov", "pytest-django (>=4.5.2)", "pytest-random-order", "pytz"] [[package]] name = "graphql-core" @@ -2438,4 +2439,4 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "9f3c9155b69454d60e711bcb83292b2bb2267fffebb16aec111b7717073d8e14" +content-hash = "b4b8575ec1881250db8e6a775e2eb1084ea262cfc54a54ddcb2ed07dc14f3eb6" diff --git a/pyproject.toml b/pyproject.toml index 8c0f5e923..93d2d223e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ django-postgres-extra = "^2.0.4" django-watchman = "^1.2.0" djangorestframework = "^3.13.1" django-simple-history = "^3.0.0" -graphene-django = "3.0.0b7" +graphene-django = "3.2.0" graphql-relay = "^3.1.5" idna = "^3.3" minio = "^7.1.4" From 08a1fde51209ff94420cb0a6a24faff6356553a2 Mon Sep 17 00:00:00 2001 From: David Vogt Date: Tue, 30 Jan 2024 14:49:54 +0100 Subject: [PATCH 3/4] fix(graphene): add a custom metaclass factory for interface types Interface types, such as `Question`, `Answer`, and `Task` have subclasses, and connections using those types may return a mix of specific object types. Examples are form -> questions, which may return different question types. For these interface types, the `graphene` internals require access to a `FooType.Meta._meta.registry` property, to access the type registry. Somehow, the graphene metaclass system does not automatically build this up correctly, so we have to help a little bit to make it work --- caluma/caluma_core/filters.py | 30 +++++++++++++++++++++ caluma/caluma_form/schema.py | 5 ++++ caluma/caluma_workflow/schema.py | 3 +++ caluma/tests/__snapshots__/test_schema.ambr | 15 +++++++++++ poetry.lock | 6 ++--- 5 files changed, 56 insertions(+), 3 deletions(-) diff --git a/caluma/caluma_core/filters.py b/caluma/caluma_core/filters.py index dd78a7062..5ab8fd20f 100644 --- a/caluma/caluma_core/filters.py +++ b/caluma/caluma_core/filters.py @@ -200,6 +200,36 @@ def _should_include_filter(filt): return filter_coll() +def InterfaceMetaFactory(): + """ + Build a meta class suitable for the schema type classes that represent + "interface" types (those that have concrete subclasses, but could be mixed + in a connection type, such as Question, Answer, and Task). + + Usage: + + >>> class Foo(Node, graphene.Interface): + >>> ... + >>> Meta = InterfaceMetaFactory() + """ + + class _meta(graphene.types.interface.InterfaceOptions): + @classmethod + # This is kinda useless but required as graphene tries to freeze() + # it's meta class objects + def freeze(cls): + cls._frozen = True + + # This is what we're actually "fixing": On non-Interface types, + # this somehow works (or isn't needed), but here, if _meta.registry + # is not set, the whole schema construction fails + registry = get_global_registry() + + # We need a new type (= class) each time it's called, because reuse + # triggers some weird errors + return type("Meta", (), {"_meta": _meta}) + + def CollectionFilterSetFactory(filterset_class, orderset_class=None): """ Build single-filter filterset classes. diff --git a/caluma/caluma_form/schema.py b/caluma/caluma_form/schema.py index c72597ea3..37c6adb02 100644 --- a/caluma/caluma_form/schema.py +++ b/caluma/caluma_form/schema.py @@ -8,6 +8,7 @@ CollectionFilterSetFactory, DjangoFilterConnectionField, DjangoFilterInterfaceConnectionField, + InterfaceMetaFactory, ) from ..caluma_core.mutation import Mutation, UserDefinedPrimaryKeyMixin from ..caluma_core.relay import extract_global_id @@ -159,6 +160,8 @@ def get_queryset(cls, queryset, info): def resolve_type(cls, instance, info): return resolve_question(instance) + Meta = InterfaceMetaFactory() + class Option(FormDjangoObjectType): meta = generic.GenericScalar() @@ -810,6 +813,8 @@ class Answer(Node, graphene.Interface): def resolve_type(cls, instance, info): return resolve_answer(instance) + Meta = InterfaceMetaFactory() + class AnswerQuerysetMixin(object): """Mixin to combine all different answer types into one queryset.""" diff --git a/caluma/caluma_workflow/schema.py b/caluma/caluma_workflow/schema.py index 0b38c2ce8..1d0d07140 100644 --- a/caluma/caluma_workflow/schema.py +++ b/caluma/caluma_workflow/schema.py @@ -10,6 +10,7 @@ CollectionFilterSetFactory, DjangoFilterConnectionField, DjangoFilterInterfaceConnectionField, + InterfaceMetaFactory, ) from ..caluma_core.mutation import Mutation, UserDefinedPrimaryKeyMixin from ..caluma_core.types import ( @@ -97,6 +98,8 @@ def resolve_type(cls, instance, info): return TASK_TYPE[instance.type] + Meta = InterfaceMetaFactory() + class TaskConnection(CountableConnectionBase): class Meta: diff --git a/caluma/tests/__snapshots__/test_schema.ambr b/caluma/tests/__snapshots__/test_schema.ambr index cd64e5396..88c6f40e1 100644 --- a/caluma/tests/__snapshots__/test_schema.ambr +++ b/caluma/tests/__snapshots__/test_schema.ambr @@ -831,6 +831,21 @@ type DjangoDebug { """Executed SQL queries for this API query.""" sql: [DjangoDebugSQL] + + """Raise exceptions for this API query.""" + exceptions: [DjangoDebugException] + } + + """Represents a single exception raised.""" + type DjangoDebugException { + """The class of the exception""" + excType: String! + + """The message of the exception""" + message: String! + + """The stack trace""" + stack: String! } """Represents a single database query made to a Django managed DB.""" diff --git a/poetry.lock b/poetry.lock index 64952d95a..11f0a8ec3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -913,13 +913,13 @@ test = ["black", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mypy", "pre-commit" [[package]] name = "graphene" -version = "3.2.2" +version = "3.3" description = "GraphQL Framework for Python" optional = false python-versions = "*" files = [ - {file = "graphene-3.2.2-py2.py3-none-any.whl", hash = "sha256:753de13948cbf42e32cc87fb533167c88907066eb984251fdbb006c0aab8da00"}, - {file = "graphene-3.2.2.tar.gz", hash = "sha256:5b03e72770dc901f40be55784058d6bb1d952a49eb819a4a085962d5e1cf5fcf"}, + {file = "graphene-3.3-py2.py3-none-any.whl", hash = "sha256:bb3810be33b54cb3e6969506671eb72319e8d7ba0d5ca9c8066472f75bf35a38"}, + {file = "graphene-3.3.tar.gz", hash = "sha256:529bf40c2a698954217d3713c6041d69d3f719ad0080857d7ee31327112446b0"}, ] [package.dependencies] From 6af6fd97d3b793440fe0fb23bd259e8c046b069d Mon Sep 17 00:00:00 2001 From: David Vogt Date: Tue, 30 Jan 2024 14:55:28 +0100 Subject: [PATCH 4/4] chore: ignore cgi deprecation warning --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 93d2d223e..bae24f7f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,7 @@ filterwarnings = [ "error::DeprecationWarning", "error::PendingDeprecationWarning", "ignore:The 'arrayconnection' module is deprecated:DeprecationWarning", # deprecation in graphene + "ignore:'cgi' is deprecated:DeprecationWarning", "ignore:distutils Version classes are deprecated:DeprecationWarning", # deprecation in pytest-freezegun "ignore:'django_extensions' defines default_app_config:PendingDeprecationWarning", # deprecation in django_extensions "ignore::requests.packages.urllib3.exceptions.InsecureRequestWarning", # MinIO tests do "insecure" requests - that's ok