Browse Source

Feature: Mobile - GraphQL ApplicationConfig query and exception handling.

Martin Gruner 3 years ago
parent
commit
951eb132cf

+ 2 - 5
app/controllers/graphql_controller.rb

@@ -6,11 +6,8 @@ class GraphqlController < ApplicationController
   # but you'll have to authenticate your user separately
   # protect_from_forgery with: :null_session
 
-  skip_before_action :verify_csrf_token, if: lambda {
-    # required for (extend list if you use this header):
-    # - GraphQL Code Generator
-    Rails.env.development? && request.headers['SkipAuthenticityTokenCheck'] == 'true'
-  }
+  # Handled in the GraphQL processing, not on controller level.
+  skip_before_action :verify_csrf_token
 
   prepend_before_action :authentication_check_only
 

+ 39 - 0
app/frontend/apps/mobile/graphql/api.ts

@@ -21,6 +21,13 @@ export type Scalars = {
   JSON: any;
 };
 
+/** Key/value type with complex values. */
+export type KeyComplexValue = {
+  __typename?: 'KeyComplexValue';
+  key: Scalars['String'];
+  value?: Maybe<Scalars['JSON']>;
+};
+
 /** Autogenerated return type of Login */
 export type LoginPayload = {
   __typename?: 'LoginPayload';
@@ -141,6 +148,8 @@ export type PageInfo = {
 /** All available queries */
 export type Queries = {
   __typename?: 'Queries';
+  /** Configuration required for front end operation (more results returned for authenticated users) */
+  applicationConfig: Array<KeyComplexValue>;
   /** Information about the authenticated user */
   currentUser: User;
   /** Fetches an object given its ID. */
@@ -238,6 +247,11 @@ export type LogoutMutationVariables = Exact<{ [key: string]: never; }>;
 
 export type LogoutMutation = { __typename?: 'Mutations', logout?: { __typename?: 'LogoutPayload', success: boolean } | null | undefined };
 
+export type ApplicationConfigQueryVariables = Exact<{ [key: string]: never; }>;
+
+
+export type ApplicationConfigQuery = { __typename?: 'Queries', applicationConfig: Array<{ __typename?: 'KeyComplexValue', key: string, value?: any | null | undefined }> };
+
 export type CurrentUserQueryVariables = Exact<{ [key: string]: never; }>;
 
 
@@ -318,6 +332,31 @@ export function useLogoutMutation(options: VueApolloComposable.UseMutationOption
   return VueApolloComposable.useMutation<LogoutMutation, LogoutMutationVariables>(LogoutDocument, options);
 }
 export type LogoutMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<LogoutMutation, LogoutMutationVariables>;
+export const ApplicationConfigDocument = gql`
+    query applicationConfig {
+  applicationConfig {
+    key
+    value
+  }
+}
+    `;
+
+/**
+ * __useApplicationConfigQuery__
+ *
+ * To run a query within a Vue component, call `useApplicationConfigQuery` and pass it any options that fit your needs.
+ * When your component renders, `useApplicationConfigQuery` returns an object from Apollo Client that contains result, loading and error properties
+ * you can use to render your UI.
+ *
+ * @param options that will be passed into the query, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/query.html#options;
+ *
+ * @example
+ * const { result, loading, error } = useApplicationConfigQuery();
+ */
+export function useApplicationConfigQuery(options: VueApolloComposable.UseQueryOptions<ApplicationConfigQuery, ApplicationConfigQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<ApplicationConfigQuery, ApplicationConfigQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<ApplicationConfigQuery, ApplicationConfigQueryVariables>> = {}) {
+  return VueApolloComposable.useQuery<ApplicationConfigQuery, ApplicationConfigQueryVariables>(ApplicationConfigDocument, {}, options);
+}
+export type ApplicationConfigQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<ApplicationConfigQuery, ApplicationConfigQueryVariables>;
 export const CurrentUserDocument = gql`
     query currentUser {
   currentUser {

+ 6 - 0
app/frontend/apps/mobile/graphql/queries/applicationConfig.graphql

@@ -0,0 +1,6 @@
+query applicationConfig {
+  applicationConfig {
+    key
+    value
+  }
+}

+ 63 - 0
app/frontend/apps/mobile/graphql/schema.json

@@ -68,6 +68,45 @@
         "enumValues": null,
         "possibleTypes": null
       },
+      {
+        "kind": "OBJECT",
+        "name": "KeyComplexValue",
+        "description": "Key/value type with complex values.",
+        "fields": [
+          {
+            "name": "key",
+            "description": null,
+            "args": [],
+            "type": {
+              "kind": "NON_NULL",
+              "name": null,
+              "ofType": {
+                "kind": "SCALAR",
+                "name": "String",
+                "ofType": null
+              }
+            },
+            "isDeprecated": false,
+            "deprecationReason": null
+          },
+          {
+            "name": "value",
+            "description": null,
+            "args": [],
+            "type": {
+              "kind": "SCALAR",
+              "name": "JSON",
+              "ofType": null
+            },
+            "isDeprecated": false,
+            "deprecationReason": null
+          }
+        ],
+        "inputFields": null,
+        "interfaces": [],
+        "enumValues": null,
+        "possibleTypes": null
+      },
       {
         "kind": "OBJECT",
         "name": "LoginPayload",
@@ -902,6 +941,30 @@
         "name": "Queries",
         "description": "All available queries",
         "fields": [
+          {
+            "name": "applicationConfig",
+            "description": "Configuration required for front end operation (more results returned for authenticated users)",
+            "args": [],
+            "type": {
+              "kind": "NON_NULL",
+              "name": null,
+              "ofType": {
+                "kind": "LIST",
+                "name": null,
+                "ofType": {
+                  "kind": "NON_NULL",
+                  "name": null,
+                  "ofType": {
+                    "kind": "OBJECT",
+                    "name": "KeyComplexValue",
+                    "ofType": null
+                  }
+                }
+              }
+            },
+            "isDeprecated": false,
+            "deprecationReason": null
+          },
           {
             "name": "currentUser",
             "description": "Information about the authenticated user",

+ 29 - 5
app/graphql/gql/concern/handles_authorization.rb

@@ -5,24 +5,48 @@ module Gql::Concern::HandlesAuthorization
 
   included do
 
+    #
+    # Customizable methods
+    #
+
+    # Override this method to specify if an object needs an authenticated user.
     def self.requires_authentication?
       true
     end
 
+    # Override this method if an object requires authorization, e.g. based on Pundit.
     def self.authorize(...)
       true
     end
 
-    # This may be called with 2 or 3 params.
+    #
+    # Internal methods
+    #
+
+    # This method is used by GraphQL to perform authorization on the various objects.
     def self.authorized?(*args)
-      ctx = args[-1]
+      ctx = args[-1] # This may be called with 2 or 3 params, context is last.
+
+      # CSRF
+      verify_csrf_token(ctx)
+
+      # Authenticate
       if requires_authentication? && !ctx[:current_user]
-        return false
+        # This exception actually means 'NotAuthenticated'
+        raise Exceptions::NotAuthorized, "Authentication required by #{name}"
       end
 
+      # Authorize
       authorize(*args)
-    rescue Pundit::NotAuthorizedError
-      false
+    rescue Pundit::NotAuthorizedError # Map to 'Forbidden'
+      raise Exceptions::Forbidden, "Access forbidden by #{name}"
+    end
+
+    def self.verify_csrf_token(ctx)
+      return true if ctx[:is_graphql_introspection_generator]
+      return true if Rails.env.development? && ctx[:controller].request.headers['SkipAuthenticityTokenCheck'] == 'true'
+
+      ctx[:controller].send(:verify_csrf_token) # verify_csrf_token is private :(
     end
 
   end

+ 30 - 0
app/graphql/gql/queries/application_config.rb

@@ -0,0 +1,30 @@
+# Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/
+
+module Gql::Queries
+  class ApplicationConfig < BaseQuery
+
+    def self.requires_authentication?
+      false
+    end
+
+    description 'Configuration required for front end operation (more results returned for authenticated users)'
+
+    type [Gql::Types::KeyComplexValueType, { null: false }], null: false
+
+    # Reimplemented from sessions_controller#config_frontend.
+    def resolve(...)
+      result = []
+      unauthenticated = context[:current_user].nil?
+      Setting.select('name, preferences').where(frontend: true).each do |setting|
+        next if setting.preferences[:authentication] && unauthenticated
+
+        value = Setting.get(setting.name)
+        next if unauthenticated && !value
+
+        result << { key: setting.name, value: value }
+      end
+      result
+    end
+
+  end
+end

+ 15 - 0
app/graphql/gql/types/key_complex_value_type.rb

@@ -0,0 +1,15 @@
+# Copyright (C) 2012-2021 Zammad Foundation, http://zammad-foundation.org/
+
+module Gql::Types
+  class KeyComplexValueType < Gql::Types::BaseObject
+
+    def self.requires_authentication?
+      false
+    end
+
+    description 'Key/value type with complex values.'
+
+    field :key, String, null: false
+    field :value, GraphQL::Types::JSON, null: true
+  end
+end

+ 21 - 4
app/graphql/gql/zammad_schema.rb

@@ -31,12 +31,29 @@ class Gql::ZammadSchema < GraphQL::Schema
   end
 
   def self.unauthorized_object(error)
-    # Add a top-level error to the response instead of returning nil:
-    raise GraphQL::ExecutionError, "An object of type #{error.type.graphql_name} was hidden due to permissions"
+    raise Exceptions::Forbidden, error.message # Add a top-level error to the response instead of returning nil.
   end
 
   def self.unauthorized_field(error)
-    # Add a top-level error to the response instead of returning nil:
-    raise GraphQL::ExecutionError, "The field #{error.field.graphql_name} on an object of type #{error.type.graphql_name} was hidden due to permissions"
+    raise Exceptions::Forbidden, error.message # Add a top-level error to the response instead of returning nil.
+  end
+
+  # Post-process errors and enrich them with meta information for processing on the client side.
+  rescue_from(StandardError) do |err, _obj, _args, _ctx, _field|
+
+    # Re-throw built-in errors that point to programming errors rather than problems with input or data - causes GraphQL processing to be aborted.
+    [ArgumentError, IndexError, NameError, RangeError, RegexpError, SystemCallError, ThreadError, TypeError, ZeroDivisionError].each do |klass|
+      raise err if err.is_a? klass
+    end
+
+    extensions = {
+      type: err.class.name,
+    }
+    if Rails.env.development? || Rails.env.test?
+      extensions[:backtrace] = Rails.backtrace_cleaner.clean(err.backtrace)
+    end
+
+    # We need to throw an ExecutionError, all others would cause the GraphQL processing to die.
+    raise GraphQL::ExecutionError.new(err.message, extensions: extensions)
   end
 end

+ 4 - 1
lib/generators/graphql_introspection/graphql_introspection_generator.rb

@@ -3,8 +3,11 @@
 class Generators::GraphqlIntrospection::GraphqlIntrospectionGenerator < Rails::Generators::Base
 
   def generate
+    result = Gql::ZammadSchema.execute(introspection_query, variables: {}, context: { is_graphql_introspection_generator: true })
+    raise 'GraphQL schema could not be successfully generated' if result['errors']
+
     # rubocop:disable Rails/Output
-    puts JSON.pretty_generate(Gql::ZammadSchema.execute(introspection_query, variables: {}, context: {}))
+    puts JSON.pretty_generate(result)
     # rubocop:enable Rails/Output
   end
 

+ 6 - 2
spec/requests/graphql/gql/mutations/logout_spec.rb

@@ -19,8 +19,12 @@ RSpec.describe Gql::Mutations::Logout, type: :request do
     end
 
     context 'without authenticated session' do
-      it 'fails' do
-        expect(graphql_response['errors'][0]['message']).to eq('The field logout on an object of type Mutations was hidden due to permissions')
+      it 'fails with error message' do
+        expect(graphql_response['errors'][0]['message']).to eq('Authentication required by Gql::Mutations::Logout')
+      end
+
+      it 'fails with error type' do
+        expect(graphql_response['errors'][0]['extensions']).to include({ 'type' => 'Exceptions::NotAuthorized' })
       end
     end
   end

Some files were not shown because too many files changed in this diff