Repository: palkan/action_policy-graphql Branch: master Commit: 9338cefef3ab Files: 35 Total size: 52.1 KB Directory structure: gitextract_7d3k2x6f/ ├── .github/ │ ├── ISSUE_TEMPLATE.md │ ├── PULL_REQUEST_TEMPLATE.md │ └── workflows/ │ ├── release.yml │ ├── rubocop.yml │ ├── test-jruby.yml │ └── test.yml ├── .gitignore ├── .rubocop-md.yml ├── .rubocop.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── action_policy-graphql.gemspec ├── bin/ │ ├── console │ └── setup ├── gemfiles/ │ ├── action_policy/ │ │ └── master.gemfile │ ├── graphql/ │ │ └── master.gemfile │ ├── jruby.gemfile │ └── rubocop.gemfile ├── lib/ │ ├── action_policy/ │ │ ├── graphql/ │ │ │ ├── authorized_field.rb │ │ │ ├── behaviour.rb │ │ │ ├── fields.rb │ │ │ ├── types/ │ │ │ │ ├── authorization_result.rb │ │ │ │ └── failure_reasons.rb │ │ │ └── version.rb │ │ └── graphql.rb │ └── action_policy-graphql.rb └── spec/ ├── action_policy/ │ └── graphql/ │ ├── authorized_spec.rb │ ├── behaviour_spec.rb │ └── expose_authorization_rules_spec.rb ├── spec_helper.rb └── support/ ├── graphql_context.rb └── schema.rb ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE.md ================================================ ### Tell us about your environment **Ruby Version:** **Framework Version (Rails, whatever):** **Action Policy Version:** **Action Policy GraphQL Version:** ### What did you do? ### What did you expect to happen? ### What actually happened? ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ PR checklist: - [ ] Tests included - [ ] Documentation updated - [ ] Changelog entry added ================================================ FILE: .github/workflows/release.yml ================================================ name: Release gems on: workflow_dispatch: push: tags: - v* jobs: release: runs-on: ubuntu-latest permissions: contents: write id-token: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # Fetch current tag as annotated. See https://github.com/actions/checkout/issues/290 - uses: ruby/setup-ruby@v1 with: ruby-version: 3.2 bundler-cache: true - name: Configure RubyGems Credentials uses: rubygems/configure-rubygems-credentials@main - name: Publish to RubyGems run: | gem install gem-release gem release ================================================ FILE: .github/workflows/rubocop.yml ================================================ name: Lint Ruby on: push: branches: - master pull_request: jobs: rubocop: runs-on: ubuntu-latest env: BUNDLE_JOBS: 4 BUNDLE_RETRY: 3 BUNDLE_GEMFILE: "gemfiles/rubocop.gemfile" CI: true steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: 3.2 bundler-cache: true - name: Lint Ruby code with RuboCop run: | bundle exec rubocop ================================================ FILE: .github/workflows/test-jruby.yml ================================================ name: JRuby Build on: push: branches: - master pull_request: jobs: test: runs-on: ubuntu-latest env: BUNDLE_JOBS: 4 BUNDLE_RETRY: 3 CI: true steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: jruby bundler-cache: true - name: Run RSpec tests run: | bundle exec rspec --force-color ================================================ FILE: .github/workflows/test.yml ================================================ name: Build on: push: branches: - master pull_request: jobs: test: runs-on: ubuntu-latest env: BUNDLE_JOBS: 4 BUNDLE_RETRY: 3 CI: true strategy: fail-fast: false matrix: ruby: ["3.3"] gemfile: [ "Gemfile" ] include: - ruby: "3.2" gemfile: "gemfiles/action_policy/master.gemfile" - ruby: "3.0" gemfile: "Gemfile" - ruby: "3.0" gemfile: "gemfiles/action_policy/master.gemfile" - ruby: "2.7" gemfile: "Gemfile" steps: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - name: Run RSpec tests run: | bundle exec rspec --force-color ================================================ FILE: .gitignore ================================================ /.bundle/ /.yardoc /Gemfile.lock /_yardoc/ /coverage/ /doc/ /pkg/ /spec/reports/ /tmp/ Gemfile.local *.lock ================================================ FILE: .rubocop-md.yml ================================================ inherit_from: ".rubocop.yml" require: - rubocop-md AllCops: Include: - '**/*.md' Lint/Void: Exclude: - '**/*.md' Lint/DuplicateMethods: Exclude: - '**/*.md' ================================================ FILE: .rubocop.yml ================================================ require: - standard/cop/block_single_line_braces inherit_gem: standard: config/base.yml AllCops: Exclude: - 'bin/*' - 'tmp/**/*' - 'Gemfile' - 'vendor/**/*' - 'gemfiles/**/*' - 'lib/generators/**/templates/**/*' DisplayCopNames: true SuggestExtensions: false TargetRubyVersion: 2.6 Standard/BlockSingleLineBraces: Enabled: false Style/FrozenStringLiteralComment: Enabled: true Naming/FileName: Exclude: - '**/*.md' - 'lib/action_policy-graphql.rb' ================================================ FILE: CHANGELOG.md ================================================ # Change log ## master (unreleased) ## 0.6.0 (2024-07-08) - Fix compatibility with Action Policy 0.7.0. ([@palkan][]) ## 0.5.4 (2023-12-22) - Do not mutate passed field options. ([@palkan][]) Fixes compatibility with `with_options` approach. ## 0.5.3 (2021-02-26) - Fix compatibility with graphql-ruby 1.12.4 ([@haines][]) ## 0.5.2 (2020-10-20) - Fix modules reloading in development. ([@rzaharenkov][]) ## 0.5.1 (2020-10-08) - Fix mutations authorization (clean up around `authorize_mutation_raise_exception` configuration parameter). ([@rzaharenkov][]) - Add deprecation for using `authorize` for mutation fields. ([@rzaharenkov][]) ## 0.5.0 (2020-10-07) - Add `preauthorize_mutation_raise_exception` configuration parameter. ([@palkan][]) Similar to `preauthorize_raise_exception` but only for mutations. Fallbacks to `preauthorize_raise_exception` unless explicitly specified. - Add `preauthorize_raise_exception` configuration parameter. ([@palkan][]) Similar to `authorize_raise_exception` but for `preauthorize: true` fields. Fallbacks to `authorize_raise_exception` unless explicitly specified. - Add ability to specify custom field options for `expose_authorization_rules`. ([@bibendi][]) Now you can add additional options for underflying `field` call via `field_options` parameter: ```ruby expose_authorization_rules :show?, field_options: {camelize: false} # equals to field :can_show, ActionPolicy::GraphQL::Types::AuthorizationResult, null: false, camelize: false ``` ## 0.4.0 (2020-03-11) - **Require Ruby 2.5+**. ([@palkan][]) - Add `authorized_field: *` option to perform authorization on the base of the upper object policy prior to resolving fields. ([@sponomarev][]) ## 0.3.2 (2019-12-12) - Fix compatibility with Action Policy 0.4.0 ([@haines][]) ## 0.3.1 (2019-10-23) - Add support for using Action Policy methods in `self.authorized?`. ([@palkan][]) ## 0.3.0 (2019-10-21) - Add `preauthorize: *` option to perform authorization prior to resolving fields. ([@palkan][]) ## 0.2.0 (2019-08-15) - Add ability to specify a field name explicitly. ([@palkan][]) Now you can write, for example: ```ruby expose_authorization_rules :create?, with: PostPolicy, field_name: :can_create_post ``` - Add support for resolvers. ([@palkan][]) Now it's possible to `include ActionPolicy::GraphQL::Behaviour` into resolver class to use Action Policy helpers there. ## 0.1.0 (2019-05-20) - Initial version. ([@palkan][]) [@palkan]: https://github.com/palkan [@haines]: https://github.com/haines [@sponomarev]: https://github.com/sponomarev [@bibendi]: https://github.com/bibendi [@rzaharenkov]: https://github.com/rzaharenkov ================================================ FILE: Gemfile ================================================ source "https://rubygems.org" # Specify your gem's dependencies in action_policy-graphql.gemspec gemspec gem "debug", platform: :mri eval_gemfile "gemfiles/rubocop.gemfile" local_gemfile = File.join(__dir__, "Gemfile.local") if File.exist?(local_gemfile) # Specify custom action_policy/graphql-ruby version in Gemfile.local eval(File.read(local_gemfile)) # rubocop:disable Security/Eval else gem "action_policy", ">= 0.5.0" gem "graphql", ">= 1.9.3" end ================================================ FILE: LICENSE.txt ================================================ The MIT License (MIT) Copyright (c) 2019 Vladimir Dementyev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ [![Gem Version](https://badge.fury.io/rb/action_policy-graphql.svg)](https://badge.fury.io/rb/action_policy-graphql) ![Build](https://github.com/palkan/action_policy-graphql/workflows/Build/badge.svg) ![JRuby Build](https://github.com/palkan/action_policy-graphql/workflows/JRuby%20Build/badge.svg) [![Documentation](https://img.shields.io/badge/docs-link-brightgreen.svg)](https://actionpolicy.evilmartians.io/#/graphql) # Action Policy GraphQL This gem provides an integration for using [Action Policy](https://github.com/palkan/action_policy) as an authorization framework for GraphQL applications (built with [`graphql` ruby gem](https://graphql-ruby.org)). This integration includes the following features: - Fields & mutations authorization - List and connections scoping - [**Exposing permissions/authorization rules in the API**](https://evilmartians.com/chronicles/exposing-permissions-in-graphql-apis-with-action-policy). 📑 [Documentation](https://actionpolicy.evilmartians.io/#/graphql) Sponsored by Evil Martians ## Installation Add this line to your application's Gemfile: ```ruby gem "action_policy-graphql" ``` ## Usage **NOTE:** this is a quick overview of the functionality provided by the gem. For more information see the [documentation](https://actionpolicy.evilmartians.io/#/graphql). To start using Action Policy in GraphQL-related code, you need to enhance your base classes with `ActionPolicy::GraphQL::Behaviour`: ```ruby # For fields authorization, lists scoping and rules exposing class Types::BaseObject < GraphQL::Schema::Object include ActionPolicy::GraphQL::Behaviour end # For using authorization helpers in mutations class Types::BaseMutation < GraphQL::Schema::Mutation include ActionPolicy::GraphQL::Behaviour end # For using authorization helpers in resolvers class Types::BaseResolver < GraphQL::Schema::Resolver include ActionPolicy::GraphQL::Behaviour end ``` ### `authorize: *` You can add authorization to the fields by specifying the `authorize: *` option: ```ruby field :home, Home, null: false, authorize: true do argument :id, ID, required: true end # field resolver method def home(id:) Home.find(id) end ``` The code above is equal to: ```ruby field :home, Home, null: false do argument :id, ID, required: true end def home(id:) Home.find(id).tap { |home| authorize! home, to: :show? } end ``` You can customize the authorization options, e.g. `authorize: {to: :preview?, with: CustomPolicy}`. If you don't want to raise an exception but return a null instead, you should set a `raise: false` option. Note: it does not make too much sense to use `authorize` in mutations since it's checking authorization rules after mutation is executed. Therefore `authorize` marked as deprecated when used in mutations and will raise error in future releases. ### `authorized_scope: *` You can add `authorized_scope: true` option to the field (list or _connection_ field) to apply the corresponding policy rules to the data: ```ruby class CityType < ::Common::Graphql::Type # It would automatically apply the relation scope from the EventPolicy to # the relation (city.events) field :events, EventType.connection_type, null: false, authorized_scope: true # you can specify the policy explicitly field :events, EventType.connection_type, null: false, authorized_scope: {with: CustomEventPolicy} end ``` **NOTE:** you cannot use `authorize: *` and `authorized_scope: *` at the same time but you can combine `preauthorize: *` or `authorize_field: *` with `authorized_scope: *`. ### `preauthorize: *` If you want to perform authorization before resolving the field value, you can use `preauthorize: *` option: ```ruby field :homes, [Home], null: false, preauthorize: {with: HomePolicy} def homes Home.all end ``` The code above is equal to: ```ruby field :homes, [Home], null: false def homes authorize! "homes", to: :index?, with: HomePolicy Home.all end ``` **NOTE:** we pass the field's name as the `record` to the policy rule. We assume that preauthorization rules do not depend on the record itself and pass the field's name for debugging purposes only. You can customize the authorization options, e.g. `preauthorize: {to: :preview?, with: CustomPolicy}`. **NOTE:** unlike `authorize: *` you MUST specify the `with: SomePolicy` option. The default authorization rule depends on the type of the field: - for lists we use `index?` (configured by `ActionPolicy::GraphQL.default_preauthorize_list_rule` parameter) - for _singleton_ fields we use `show?` (configured by `ActionPolicy::GraphQL.default_preauthorize_node_rule` parameter) ### `authorize_field: *` If you want to perform authorization before resolving the field value _on the base of the upper object_, you can use `authorize_field: *` option: ```ruby field :homes, Home, null: false, authorize_field: true def homes Home.all end ``` The code above is equal to: ```ruby field :homes, [Home], null: false def homes authorize! object, to: :homes? Home.all end ``` By default we use `#{underscored_field_name}?` authorization rule. You can customize the authorization options, e.g. `authorize_field: {to: :preview?, with: CustomPolicy}`. ### `expose_authorization_rules` You can add permissions/authorization exposing fields to "tell" clients which actions could be performed against the object or not (and why). For example: ```ruby class ProfileType < ::Common::Graphql::Type # Adds can_edit, can_destroy fields with # AuthorizationResult type. # NOTE: prefix "can_" is used by default, no need to specify it explicitly expose_authorization_rules :edit?, :destroy?, prefix: "can_" end ``` Then the client could perform the following query: ```gql { post(id: $id) { canEdit { # (bool) true|false; not null value # top-level decline message ("Not authorized" by default); null if value is true message # detailed information about the decline reasons; null if value is true reasons { details # JSON-encoded hash of the failure reasons (e.g., {"event" => [:seats_available?]}) fullMessages # Array of human-readable reasons (e.g., ["This event is sold out"]) } } canDestroy { # ... } } } ``` You can specify a custom field name as well (only for a single rule): ```ruby class ProfileType < ::Common::Graphql::Type # Adds can_create_post field. expose_authorization_rules :create?, with: PostPolicy, field_name: "can_create_post" end ``` ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/palkan/action_policy-graphql. ## License The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). ================================================ FILE: Rakefile ================================================ # frozen_string_literal: true require "bundler/gem_tasks" require "rspec/core/rake_task" RSpec::Core::RakeTask.new(:spec) begin require "rubocop/rake_task" RuboCop::RakeTask.new RuboCop::RakeTask.new("rubocop:md") do |task| task.options << %w[-c .rubocop-md.yml] end rescue LoadError task(:rubocop) {} task("rubocop:md") {} end task default: %w[rubocop rubocop:md spec] ================================================ FILE: action_policy-graphql.gemspec ================================================ # frozen_string_literal: true require_relative "lib/action_policy/graphql/version" Gem::Specification.new do |spec| spec.name = "action_policy-graphql" spec.version = ActionPolicy::GraphQL::VERSION spec.authors = ["Vladimir Dementyev"] spec.email = ["dementiev.vm@gmail.com"] spec.summary = "Action Policy integration for GraphQL-Ruby" spec.description = "Action Policy integration for GraphQL-Ruby" spec.homepage = "https://github.com/palkan/action_policy-graphql" spec.license = "MIT" spec.files = Dir.glob("lib/**/*") + %w[README.md LICENSE.txt CHANGELOG.md] spec.metadata = { "bug_tracker_uri" => "https://github.com/palkan/action_policy-graphql/issues", "changelog_uri" => "https://github.com/palkan/action_policy-graphql/blob/master/CHANGELOG.md", "documentation_uri" => "https://actionpolicy.evilmartians.io/#/graphql", "homepage_uri" => "https://github.com/palkan/action_policy-graphql", "source_code_uri" => "https://github.com/palkan/action_policy-graphql" } spec.require_paths = ["lib"] spec.required_ruby_version = ">= 2.5.0" spec.add_dependency "action_policy", "~> 0.7" spec.add_dependency "ruby-next-core", "~> 1.0" spec.add_dependency "graphql", ">= 1.9.3" spec.add_development_dependency "bundler", ">= 1.15" spec.add_development_dependency "rake", ">= 13.0" spec.add_development_dependency "rspec", ">= 3.8" spec.add_development_dependency "i18n" end ================================================ FILE: bin/console ================================================ #!/usr/bin/env ruby require "bundler/setup" require "action_policy/graphql" # You can add fixtures and/or initialization code here to make experimenting # with your gem easier. You can also use a different console, if you like. # (If you use this, don't forget to add pry to your Gemfile!) # require "pry" # Pry.start require "irb" IRB.start(__FILE__) ================================================ FILE: bin/setup ================================================ #!/usr/bin/env bash set -euo pipefail IFS=$'\n\t' set -vx bundle install # Do any other automated setup that you need to do here ================================================ FILE: gemfiles/action_policy/master.gemfile ================================================ source "https://rubygems.org" gem "action_policy", github: "palkan/action_policy" gemspec path: "../.." ================================================ FILE: gemfiles/graphql/master.gemfile ================================================ source "https://rubygems.org" gem "graphql", github: "rmosolgo/graphql-ruby" gemspec path: "../.." ================================================ FILE: gemfiles/jruby.gemfile ================================================ source "https://rubygems.org" gemspec path: ".." ================================================ FILE: gemfiles/rubocop.gemfile ================================================ source "https://rubygems.org" do gem "rubocop-md", "~> 1.0" gem "standard", "~> 1.0" gem "ruby-next", ">= 0.15.0" end ================================================ FILE: lib/action_policy/graphql/authorized_field.rb ================================================ # frozen_string_literal: true module ActionPolicy module GraphQL # Add `authorized` option to the field # # Example: # # class PostType < ::GraphQL::Schema::Object # field :comments, null: false, authorized: true # # # or with options # field :comments, null: false, authorized: { type: :relation, with: MyPostPolicy } # end module AuthorizedField class Extension < ::GraphQL::Schema::FieldExtension def initialize(field:, options:) super(field: field, options: options&.dup || {}) end def extract_option(key, &default) value = options.fetch(key, &default) options.delete key value end end class AuthorizeExtension < Extension DEPRECATION_MESSAGE = "`authorize: *` for mutation fields is deprecated. Please use `preauthorize: *` instead." class << self def show_authorize_mutation_deprecation return if defined?(@authorize_mutation_deprecation_shown) if defined?(ActiveSupport::Deprecation) ActiveSupport::Deprecation.warn(DEPRECATION_MESSAGE) else warn(DEPRECATION_MESSAGE) end @authorize_mutation_deprecation_shown = true end end def apply self.class.show_authorize_mutation_deprecation if field.mutation && field.mutation < ::GraphQL::Schema::Mutation @to = extract_option(:to) { ::ActionPolicy::GraphQL.default_authorize_rule } @raise = extract_option(:raise) { ::ActionPolicy::GraphQL.authorize_raise_exception } end def after_resolve(value:, context:, object:, **_rest) return value if value.nil? if @raise object.authorize! value, to: @to, **options value else object.allowed_to?(@to, value, **options) ? value : nil end end end class PreauthorizeExtension < Extension def apply if options[:with].nil? raise ArgumentError, "You must specify the policy for preauthorization: " \ "`field :#{field.name}, preauthorize: {with: SomePolicy}`" end @to = extract_option(:to) do if field.type.list? ::ActionPolicy::GraphQL.default_preauthorize_list_rule else ::ActionPolicy::GraphQL.default_preauthorize_node_rule end end @raise = extract_option(:raise) do if field.mutation ::ActionPolicy::GraphQL.preauthorize_mutation_raise_exception else ::ActionPolicy::GraphQL.preauthorize_raise_exception end end end def resolve(context:, object:, arguments:, **_rest) if @raise object.authorize! field.name, to: @to, **options yield object, arguments elsif object.allowed_to?(@to, field.name, **options) yield object, arguments end end end class AuthorizeFieldExtension < Extension def apply @to = extract_option(:to) { underscored_field_name } @raise = extract_option(:raise) { ::ActionPolicy::GraphQL.authorize_raise_exception } end def resolve(context:, object:, arguments:, **_rest) if @raise object.authorize! object.object, to: @to, **options yield object, arguments elsif object.allowed_to?(@to, object.object, **options) yield object, arguments end end private def underscored_field_name :"#{field.instance_variable_get(:@underscored_name)}?" end end class ScopeExtension < Extension def resolve(context:, object:, arguments:, **_rest) value = yield(object, arguments) return value if value.nil? object.authorized_scope(value, **options) end end def initialize(*args, preauthorize: nil, authorize: nil, authorized_scope: nil, authorize_field: nil, **kwargs, &block) if authorize && authorized_scope raise ArgumentError, "Only one of `authorize` and `authorized_scope` " \ "options could be specified. You can use `preauthorize` or `authorize_field` along with scoping" end if (!!authorize == !!preauthorize) ? authorize : authorize_field raise ArgumentError, "Only one of `authorize`, `preauthorize` or `authorize_field` " \ "options could be specified." end extensions = (kwargs[:extensions] ||= []) add_extension! extensions, AuthorizeExtension, authorize add_extension! extensions, ScopeExtension, authorized_scope add_extension! extensions, PreauthorizeExtension, preauthorize add_extension! extensions, AuthorizeFieldExtension, authorize_field super(*args, **kwargs, &block) end private def add_extension!(extensions, extension_class, options) return unless options options = {} if options == true extension = {extension_class => options} if extensions.is_a?(Hash) extensions.merge!(extension) else extensions << extension end end end end end ================================================ FILE: lib/action_policy/graphql/behaviour.rb ================================================ # frozen_string_literal: true require "action_policy/graphql/fields" require "action_policy/graphql/authorized_field" module ActionPolicy module GraphQL module Behaviour require "action_policy/ext/module_namespace" using ActionPolicy::Ext::ModuleNamespace # When used with self.authorized? def self.extended(base) base.extend ActionPolicy::Behaviour base.extend ActionPolicy::Behaviours::ThreadMemoized base.extend ActionPolicy::Behaviours::Memoized base.extend ActionPolicy::Behaviours::Namespaced # Authorization context could't be defined for the class def base.build_authorization_context {} end # Override authorization_namespace to use the class itself def base.authorization_namespace return @authorization_namespace if instance_variable_defined?(:@authorization_namespace) @authorization_namespace = namespace end end def self.included(base) base.include ActionPolicy::Behaviour base.include ActionPolicy::Behaviours::ThreadMemoized base.include ActionPolicy::Behaviours::Memoized base.include ActionPolicy::Behaviours::Namespaced base.authorize :user, through: :current_user if base.respond_to?(:field_class) unless base.field_class < ActionPolicy::GraphQL::AuthorizedField base.field_class.prepend(ActionPolicy::GraphQL::AuthorizedField) end unless base < ActionPolicy::GraphQL::Fields base.include ActionPolicy::GraphQL::Fields end end base.extend self end def current_user context[:current_user] end end end end ================================================ FILE: lib/action_policy/graphql/fields.rb ================================================ # frozen_string_literal: true require "action_policy/graphql/types/authorization_result" module ActionPolicy using RubyNext module GraphQL # Add DSL to add policy rules as fields # # Example: # # class PostType < ::GraphQL::Schema::Object # # Adds can_edit, can_destroy fields with # # AuthorizationResult type. # # expose_authorization_rules :edit?, :destroy?, prefix: "can_" # end # # Prefix is "can_" by default. module Fields def self.included(base) base.extend ClassMethods end module ClassMethods def expose_authorization_rules(*rules, field_name: nil, prefix: ::ActionPolicy::GraphQL.default_authorization_field_prefix, field_options: {}, **options) raise ArgumentError, "Cannot specify field_name for multiple rules" if rules.size > 1 && !field_name.nil? rules.each do |rule| gql_field_name = field_name || "#{prefix}#{rule.to_s.delete("?")}" field gql_field_name, ActionPolicy::GraphQL::Types::AuthorizationResult, null: false, **field_options define_method(gql_field_name) do allowance_to(rule, object, **options) end end end end end end end ================================================ FILE: lib/action_policy/graphql/types/authorization_result.rb ================================================ # frozen_string_literal: true require "action_policy/graphql/types/failure_reasons" module ActionPolicy module GraphQL module Types class AuthorizationResult < ::GraphQL::Schema::Object field :value, Boolean, null: false, description: "Result of applying a policy rule" field :message, String, null: true, description: "Human-readable error message" field :reasons, FailureReasons, null: true, description: "Reasons of check failure" def message return if object.value == true object.message end def reasons return if object.value == true object.reasons end end end end end ================================================ FILE: lib/action_policy/graphql/types/failure_reasons.rb ================================================ # frozen_string_literal: true module ActionPolicy module GraphQL module Types class FailureReasons < ::GraphQL::Schema::Object field :details, String, null: false, description: "JSON-encoded map of reasons" field :full_messages, [String], null: false, description: "Human-readable errors" def details object.details.to_json end end end end end ================================================ FILE: lib/action_policy/graphql/version.rb ================================================ # frozen_string_literal: true module ActionPolicy module GraphQL VERSION = "0.6.0" end end ================================================ FILE: lib/action_policy/graphql.rb ================================================ # frozen_string_literal: true require "graphql" require "action_policy" require "action_policy/graphql/behaviour" module ActionPolicy module GraphQL class << self # Which rule to use when no specified (e.g. `authorize: true`) # Defaults to `:show?` attr_accessor :default_authorize_rule # Which rule to use when no specified for preauthorization (e.g. `preauthorize: true`) # of a list-like field. # Defaults to `:index?` attr_accessor :default_preauthorize_list_rule # Which rule to use when no specified for preauthorization (e.g. `preauthorize: true`) # of a singleton-like field. # Defaults to `:show?` attr_accessor :default_preauthorize_node_rule # Whether to raise an exeption if field is not authorized # or return `nil`. # Defaults to `true`. attr_accessor :authorize_raise_exception # Which prefix to use for authorization fields # Defaults to `"can_"` attr_accessor :default_authorization_field_prefix attr_writer :preauthorize_raise_exception # Whether to raise an exception if preauthorization fails # Equals to authorize_raise_exception unless explicitly set def preauthorize_raise_exception return authorize_raise_exception if @preauthorize_raise_exception.nil? @preauthorize_raise_exception end # Whether to raise an exception if preauthorization fails # Equals to preauthorize_raise_exception unless explicitly set attr_writer :preauthorize_mutation_raise_exception def preauthorize_mutation_raise_exception return preauthorize_raise_exception if @preauthorize_mutation_raise_exception.nil? @preauthorize_mutation_raise_exception end end self.default_authorize_rule = :show? self.default_preauthorize_list_rule = :index? self.default_preauthorize_node_rule = :show? self.authorize_raise_exception = true self.preauthorize_raise_exception = nil self.preauthorize_mutation_raise_exception = nil self.default_authorization_field_prefix = "can_" end end ================================================ FILE: lib/action_policy-graphql.rb ================================================ # frozen_string_literal: true require "ruby-next" require "action_policy/graphql" ================================================ FILE: spec/action_policy/graphql/authorized_spec.rb ================================================ # frozen_string_literal: true require "spec_helper" describe "field extensions", :aggregate_failures do include_context "common:graphql" let(:user) { :user } let(:schema) { Schema } let(:context) { {user: user} } context "authorized_scope: *" do let(:posts) { [Post.new("private-a"), Post.new("public-b")] } let(:query) do %({ posts { title } }) end before do allow(Schema).to receive(:posts) { PostList.new(posts) } end it "has authorized scope" do expect { subject }.to have_authorized_scope(:data) .with(PostPolicy) end specify "as user" do expect(data.size).to eq 1 expect(data.first.fetch("title")).to eq "public-b" end context "as admin" do let(:user) { :admin } specify do expect(data.size).to eq 2 expect(data.map { |v| v.fetch("title") }).to match_array( [ "private-a", "public-b" ] ) end end context "namespaced" do let(:posts) { [Post.new("not mine"), Post.new("story of my life")] } let(:query) do %({ me { posts { title } } }) end before do allow(Me).to receive(:posts) { PostList.new(posts) } end it "has authorized scope" do expect { subject }.to have_authorized_scope(:data) .with(Me::PostPolicy) end end context "connections" do let(:query) do %({ connectedPosts(first: 1) { nodes { title } } }) end it "has authorized scope" do expect { subject }.to have_authorized_scope(:data) .with(PostPolicy) end context "with connection: true" do let(:query) do %({ anotherConnectedPosts(first: 1) { totalCount nodes { title } } }) end it "has authorized scope" do expect { subject }.to have_authorized_scope(:data) .with(PostPolicy) end specify do expect(data["totalCount"]).to eq 1 expect(data["nodes"][0]["title"]).to eq "public-b" end end end end context "authorize: *" do let(:post) { Post.new("private-a") } let(:query) do %({ authPost { title } }) end before do allow(Schema).to receive(:post) { post } end it "is authorized" do expect { subject }.to be_authorized_to(:show?, post) .with(PostPolicy) end specify "as user" do expect { subject }.to raise_error(ActionPolicy::Unauthorized) end context "accessible resource" do let(:post) { Post.new("post-c-visible") } specify do expect(data.fetch("title")).to eq "post-c-visible" end end context "as admin" do let(:user) { :admin } specify do expect(data.fetch("title")).to eq "private-a" end end context "with options" do let(:query) do %({ anotherPost { title } }) end it "is authorized" do expect { subject }.to be_authorized_to(:preview?, post) .with(AnotherPostPolicy) end end context "non-raising authorize" do let(:query) do %({ nonRaisingPost { title } }) end it "returns nil" do expect(data).to be_nil end end context "namespaced" do let(:query) do %({ me { bio { title } } }) end before do allow(Me).to receive(:post) { post } end it "is authorized" do expect { subject }.to be_authorized_to(:show?, post) .with(Me::PostPolicy) end end context "with resolver" do let(:query) do %({ resolvedPost { title } }) end before do allow(Me).to receive(:post) { post } end it "is authorized" do expect { subject }.to be_authorized_to(:show?, post) .with(PostPolicy) end end context "with mutation" do let(:query) do %(mutation { createPost(title: "GQL") { post { title } } }) end it "is authorized" do expect { subject }.to be_authorized_to(:publish?, anything) .with(PostPolicy) end # See spec_helper.rb for default settings it "raises if authorize_raise_exception is set to true" do expect { subject }.to raise_error(ActionPolicy::Unauthorized) end end end context "preauthorize: *" do context "collection" do let(:posts) { [Post.new("private-a"), Post.new("public-b")] } let(:query) do %({ secretPosts { title } }) end before do allow(Schema).to receive(:posts) { PostList.new(posts) } end it "is authorized" do expect { subject }.to be_authorized_to(:view_secret_posts?, "secretPosts") .with(PostPolicy) end specify "as user" do expect { subject }.to raise_error(ActionPolicy::Unauthorized) end context "as admin" do let(:user) { :admin } specify do expect(data.size).to eq 2 end end context "with default collection rule" do let(:query) do %({ allPosts { title } }) end it "is authorized" do expect { subject }.to be_authorized_to(:index?, "allPosts") .with(PostPolicy) end end end context "field" do let(:post) { Post.new("private-a") } let(:query) do %({ secretPost { title } }) end before do allow(Schema).to receive(:post) { post } end it "doesn't resolve field if auth failed" do expect(data).to be_nil expect(Schema).to_not have_received(:post) end context "as admin" do let(:user) { :admin } specify do expect(data.fetch("title")).to eq(post.title) expect(Schema).to have_received(:post) end end end end context "authorize_field: *" do let(:post) { Post.new("title") } let(:query) do %({ post { secretTitle } }) end before do allow(post).to receive(:title).and_call_original allow(Schema).to receive(:post) { post } end it "is authorized with object policy" do expect { subject }.to be_authorized_to(:secret_title?, post) .with(PostPolicy) end it "doesn't resolve field if auth failed" do expect { subject }.to raise_error(ActionPolicy::Unauthorized) expect(post).to_not have_received(:title) end context "as admin" do let(:user) { :admin } specify do expect(data.fetch("secretTitle")).to eq("Secret #{post.title}") end end context "non-raising authorize" do let(:query) do %({ post { silentSecretTitle } }) end it "returns nil" do expect(data.fetch("silentSecretTitle")).to be_nil expect(post).to_not have_received(:title) end context "as admin" do let(:user) { :admin } specify do expect(data.fetch("silentSecretTitle")).to eq("Secret #{post.title}") end end end context "with options" do let(:query) do %({ post { anotherSecretTitle } }) end it "is authorized" do expect { subject }.to be_authorized_to(:preview?, post) .with(AnotherPostPolicy) end end end end ================================================ FILE: spec/action_policy/graphql/behaviour_spec.rb ================================================ # frozen_string_literal: true require "spec_helper" describe ActionPolicy::GraphQL::Behaviour do include_context "common:graphql" let(:user) { :user } let(:post) { Post.new("private") } let(:schema) { Schema } let(:context) { {current_user: user} } describe ".authorized? + allowed_to?" do let(:query) do %({ authorizedPost { title } }) end before { allow(Schema).to receive(:post) { post } } specify do expect(data).to be_nil end context "when admin" do let(:user) { :admin } specify do expect(data.fetch("title")).to eq "private" end end context "namespaced" do let(:post) { Post.new("namespaced") } let(:query) do %({ authorizedNamespacedPost { title } }) end specify do expect(data.fetch("title")).to eq "namespaced" end context "mutation" do let(:query) do %(mutation { adminCreatePost(title: "GQL") { post { title } } }) end it "is authorized" do expect { subject }.to be_authorized_to(:create?, Post) .with(MyNamespace::PostPolicy) end end context "authorized mutation" do let(:query) do %(mutation { adminCreatePostAuthorized(title: "GQL") { post { title } } }) end it "is authorized" do expect { subject }.to be_authorized_to(:create?, Post) .with(MyNamespace::PostPolicy) end end context "overriden namespace" do let(:post) { Post.new("deleted") } let(:query) do %(mutation { deletePost(id: "42") { deletedId } }) end it "is authorized" do expect { subject }.to be_authorized_to(:destroy?, post) .with(MyNamespace::PostPolicy) end end end end end ================================================ FILE: spec/action_policy/graphql/expose_authorization_rules_spec.rb ================================================ # frozen_string_literal: true require "spec_helper" describe "#expose_authorization_rules", :aggregate_failures do include_context "common:graphql" let(:post) { Post.new("private") } let(:schema) { Schema } let(:query) do %({ post { title canShow { value message reasons { details fullMessages } } canEdit { value message reasons { details fullMessages } } can_i_destroy { value message reasons { details fullMessages } } } }) end before { allow(Schema).to receive(:post) { post } } context "when failure" do specify do expect(data.fetch("canShow").fetch("value")).to eq false expect(data.fetch("canShow").fetch("message")).to eq "Cannot show post" expect(data.fetch("canEdit").fetch("value")).to eq false expect(data.fetch("canEdit").fetch("message")).to eq "You shall not do this" expect(data.fetch("can_i_destroy").fetch("value")).to eq false expect(data.fetch("can_i_destroy").fetch("message")).to eq "You shall not do this" end specify "#reasons" do reasons = data.fetch("can_i_destroy").fetch("reasons") expect(reasons.fetch("details")).to eq( {post: [:public?]}.to_json ) expect(reasons.fetch("fullMessages")).to eq( [ "Post is not public" ] ) end end context "when success" do let(:post) { Post.new("public-visible") } specify do expect(data.fetch("canShow").fetch("value")).to eq true expect(data.fetch("canShow").fetch("message")).to be_nil expect(data.fetch("canShow").fetch("reasons")).to be_nil expect(data.fetch("can_i_destroy").fetch("value")).to eq true expect(data.fetch("can_i_destroy").fetch("message")).to be_nil expect(data.fetch("can_i_destroy").fetch("reasons")).to be_nil end end context "namespaced" do let(:query) do %({ me { allPosts { title canShow { value message reasons { details fullMessages } } } } }) end let(:field) { "me->allPosts" } before do allow(Me).to receive(:posts) { [post] } end context "when failure" do let(:post) { Post.new("not mine") } specify do expect(data.first.fetch("canShow").fetch("value")).to eq false expect(data.first.fetch("canShow").fetch("message")).to eq "Cannot show post" end end context "when success" do let(:post) { Post.new("story of my life") } specify do expect(data.first.fetch("canShow").fetch("value")).to eq true expect(data.first.fetch("canShow").fetch("message")).to be_nil expect(data.first.fetch("canShow").fetch("reasons")).to be_nil end end end context "with field_name" do let(:query) do %({ me { canCreatePost { value message reasons { details fullMessages } } } }) end let(:field) { "me" } specify do expect(data.fetch("canCreatePost").fetch("value")).to eq true expect(data.fetch("canCreatePost").fetch("message")).to be_nil expect(data.fetch("canCreatePost").fetch("reasons")).to be_nil end end end ================================================ FILE: spec/spec_helper.rb ================================================ # frozen_string_literal: true $LOAD_PATH.unshift File.expand_path("../lib", __dir__) # This turns off per-thread caching in action_policy ENV["RACK_ENV"] = "test" require "i18n" require "action_policy-graphql" begin require "debug" unless ENV["CI"] rescue LoadError end require "action_policy/rspec" ActionPolicy::GraphQL.preauthorize_raise_exception = false ActionPolicy::GraphQL.preauthorize_mutation_raise_exception = true Dir["#{__dir__}/support/**/*.rb"].sort.each { |f| require f } RSpec.configure do |config| config.mock_with :rspec config.example_status_persistence_file_path = "tmp/rspec_examples.txt" config.filter_run :focus config.run_all_when_everything_filtered = true config.order = :random Kernel.srand config.seed end ================================================ FILE: spec/support/graphql_context.rb ================================================ # frozen_string_literal: true shared_context "common:graphql" do let(:context) { {} } let(:variables) { {} } let(:field) { result.fetch("data").keys.first } let(:schema) { raise NotImplementedError.new("Specify schema under test, e.g. `let(:schema) { MySchema }`") } let(:data) do raise "API Query failed:\n\tquery: #{query}\n\terrors: #{result["errors"]}" if result.key?("errors") result.fetch("data").dig(*field.split("->")) end let(:errors) { result["errors"]&.map { |err| err["message"] } } # for connection responses let(:edges) { data.fetch("edges").map { |node| node.fetch("node") } } let(:page_info) { data.fetch("pageInfo") } subject(:result) do schema.execute( query, context: context, variables: variables ) end end ================================================ FILE: spec/support/schema.rb ================================================ # frozen_string_literal: true class Post < Struct.new(:title); end class PostList < Array def policy_name "#{first.class}Policy" end end class PostPolicy < ActionPolicy::Base scope_matcher :data, PostList scope_for :data do |data| next data if admin? data.select { |post| post.title.start_with? "public" } end pre_check :allow_admins def index? true end def view_secret_posts? # allow_admins pre-check allows admin access false end def create? true end def publish? false end def public? record.title.start_with?("public") end def show? record.title.end_with?("visible") end def manage? check?(:public?) && allowed_to?(:show?) end def secret_title? false end def silent_secret_title? false end private def allow_admins allow! if admin? end def admin? user == :admin end end class AnotherPostPolicy < PostPolicy def preview? public? && show? end def maybe_preview? # only admins can preview (controlled by pre-check) false end end I18n.backend = I18n::Backend::Simple.new.tap do |backend| backend.store_translations( :en, { action_policy: { policy: { post: { manage?: "You shall not do this", edit?: "Cannot edit post", show?: "Cannot show post", public?: "Post is not public" } } } } ) end class BaseType < ::GraphQL::Schema::Object include ActionPolicy::GraphQL::Behaviour def current_user context.fetch(:user, :user) end end class BaseMutation < ::GraphQL::Schema::Mutation include ActionPolicy::GraphQL::Behaviour def current_user context.fetch(:user, :user) end end class BaseResolver < ::GraphQL::Schema::Resolver include ActionPolicy::GraphQL::Behaviour def current_user context.fetch(:user, :user) end end class PostType < BaseType field :title, String, null: false field :secret_title, String, null: false, authorize_field: true field :silent_secret_title, String, null: true, authorize_field: {raise: false} field :another_secret_title, String, null: true, authorize_field: {to: :preview?, with: AnotherPostPolicy} expose_authorization_rules :edit?, :show?, prefix: "can_" expose_authorization_rules :destroy?, prefix: "can_i_", field_options: {camelize: false} def secret_title "Secret #{object.title}" end alias_method :silent_secret_title, :secret_title alias_method :another_secret_title, :secret_title end class PostConnectionWithTotalCountType < GraphQL::Types::Relay::BaseConnection edge_type(PostType.edge_type) field :total_count, Integer, null: false def total_count object.nodes.size end end class AuthorizedPostType < PostType def self.authorized?(object, context) super && allowed_to?( :show?, object, context: {user: context[:current_user]} ) end end module MyNamespace class PostPolicy < ::PostPolicy def show? true end def create? admin? end def destroy? true end end class PostType < ::AuthorizedPostType graphql_name "MyNamespacePost" end end # Namespaced types module Me class << self attr_accessor :posts, :post end class PostPolicy < ::PostPolicy scope_for :data do |data| data.select { |post| post.title.match?(/\bmy\b/) } end def show? record.title.match?(/\bmy\b/) end end class PostType < BaseType graphql_name "MyPostType" field :title, String, null: false expose_authorization_rules :show?, prefix: "can_" def title "My #{object.title}" end end class RootType < BaseType field :bio, PostType, null: false, authorize: true field :posts, [PostType], null: false, authorized_scope: true field :all_posts, [PostType], null: false expose_authorization_rules :create?, with: PostPolicy, field_name: :can_create_post def bio Me.post end def posts Me.posts end alias_method :all_posts, :posts end end class CreatePostMutation < BaseMutation argument :title, String, required: true field :post, PostType, null: false def resolve(title:) {post: Post.new(title: title)} end end module WithMyNamespace def authorization_namespace ::MyNamespace end end class DeletePostMutation < BaseMutation include WithMyNamespace argument :id, ID, required: true field :deleted_id, String, null: false def resolve(id:) post = Schema.post authorize! post, to: :destroy? {deleted_id: post.title} end end # Namespaced mutation module Mutations module MyNamespace class CreatePostMutation < BaseMutation graphql_name "MyCreatePostMutation" argument :title, String, required: true field :post, PostType, null: false def resolve(title:) authorize! Post, to: :create? {post: Post.new(title: title)} end private def authorization_namespace ::MyNamespace end end end end class Schema < GraphQL::Schema class << self attr_accessor :posts, :post end class PostResolver < BaseResolver type PostType, null: false def resolve Schema.post.tap do |post| authorize! post, to: :show? end end end query(Class.new(BaseType) do def self.name "Query" end field :me, Me::RootType, null: false field :post, PostType, null: false field :authorized_post, AuthorizedPostType, null: true field :authorized_namespaced_post, MyNamespace::PostType, null: true field :resolved_post, resolver: PostResolver field :auth_post, PostType, null: false, authorize: true field :non_raising_post, PostType, null: true, authorize: {raise: false} field :secret_post, PostType, null: true, preauthorize: {with: AnotherPostPolicy, to: :maybe_preview?} field :another_post, PostType, null: false, authorize: {to: :preview?, with: AnotherPostPolicy} field :posts, [PostType], null: false, authorized_scope: {type: :data, with: PostPolicy} # Test that shared options object is not mutated base_options = {with: PostPolicy, raise: true} field :all_posts_new, [PostType], null: false, preauthorize: base_options field :all_posts, [PostType], null: false, preauthorize: base_options field :secret_posts, [PostType], null: false, preauthorize: {to: :view_secret_posts?, with: PostPolicy, raise: true} field :connected_posts, PostType.connection_type, null: false, authorized_scope: true field :another_connected_posts, PostConnectionWithTotalCountType, null: false, authorized_scope: true, connection: true def me {} end def post Schema.post end alias_method :authorized_post, :post alias_method :authorized_namespaced_post, :post alias_method :auth_post, :post alias_method :another_post, :post alias_method :non_raising_post, :post alias_method :secret_post, :post def posts Schema.posts end alias_method :secret_posts, :posts alias_method :all_posts, :posts alias_method :connected_posts, :posts alias_method :another_connected_posts, :posts end) mutation(Class.new(BaseType) do def self.name "Mutation" end field :create_post, mutation: CreatePostMutation, null: false, preauthorize: {with: PostPolicy, to: :publish?} field :delete_post, mutation: DeletePostMutation, null: false field :admin_create_post, mutation: Mutations::MyNamespace::CreatePostMutation, null: false field :admin_create_post_authorized, mutation: Mutations::MyNamespace::CreatePostMutation, null: false, authorize: {to: :create?} end) end