Action policy scoping unable to deal with promises returned from graphql fragment cache library
linstantnoodles opened this issue · 4 comments
hey @palkan ! i've got a interesting one for you involving graphql fragment cache gem when using the automatic auth scoping (authorized_scope: true
). it's somewhat related to DmitryTsepelev/graphql-ruby-fragment_cache#68, but instead of unrecognized raw cached values we're now dealing with promises that fragment cache gem is returning since >= 1.12.x
Tell us about your environment
Ruby Version:
3.0.6
here's an executable test script with all the versions of dependencies (action policy, graphql fragment cache, ...). I've also included a potential workaround in comments inside the executable test. At the end i've also included an alternative approach
you can copy it to some file like some-test.rb
and run it
# frozen_string_literal: true
require "bundler/inline"
gemfile(true) do
source "https://rubygems.org"
gem "rails"
gem "sqlite3", "~> 1.4"
gem "graphql", "~> 2.3.4"
gem "action_policy-graphql", "~> 0.5.4"
gem "graphql-fragment_cache", "~> 1.12.0" # 1.12.0 has the lazy resolver
end
require "active_record"
require "active_support"
require "minitest/autorun"
require "logger"
# setup app configs
class TestApp < Rails::Application
config.load_defaults Rails::VERSION::STRING.to_f
config.root = __dir__
config.hosts << "example.org"
config.eager_load = false
config.session_store :cookie_store, key: "cookie_store_key"
config.secret_key_base = "secret_key_base"
config.graphql_fragment_cache.store = :file_store, "cache"
end
Rails.application.initialize!
# setup database & models
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)
ActiveRecord::Schema.define do
create_table :posts, force: true do |t|
end
create_table :comments, force: true do |t|
t.integer :post_id
end
end
class Post < ActiveRecord::Base
end
class PostPolicy < ActionPolicy::Base
relation_scope do |relation|
relation
end
# ========== POTENTIAL WORKAROUND ==========
# Warning: i think .. this bypasses all other existing matchers for `Path`
# scope_matcher :lazy_resolver, GraphQL::FragmentCache::Schema::LazyCacheResolver
# scope_for(:lazy_resolver) do |cached_collection|
# cached_collection.resolve
# end
def show?
true
end
end
# graphql
module Types
class PostType < GraphQL::Schema::Object
description "A blog post"
field :id, ID, null: false
end
end
class QueryType < GraphQL::Schema::Object
include ActionPolicy::GraphQL::Behaviour
include GraphQL::FragmentCache::Object
description "The query root of this schema"
field :post, Types::PostType, authorize: true do
argument :id, ID, required: true
end
field :posts, [Types::PostType], authorized_scope: { with: PostPolicy }
def posts
cache_fragment { Post.all }
end
end
class Schema < GraphQL::Schema
use GraphQL::FragmentCache
query QueryType
end
class BugTest < Minitest::Test
def test_multiple_posts
post1 = Post.create!
post2 = Post.create!
query_string = "
{
posts {
id
}
}
"
user = Struct.new(:username)
assert_raises ActionPolicy::UnrecognizedScopeTarget do
Schema.execute(query_string, context: { current_user: user.new("bob") })
end
end
end
What did you do?
we use both action policy and graphql fragment cache, it looks like >= 1.12 of fragment cache raises ActionPolicy::UnrecognizedScopeTarget
because the cache call cache_fragment
returns a promise-like object (GraphQL::FragmentCache::Schema::LazyCacheResolver
).
What did you expect to happen?
I expect the promises to resolve before reaching scoping rules in action policy, so that we don't have to write custom scope matchers to force promise resolution. i know that the default behavior of action policy for unrecognized scope objects (pretty much anything other than ActiveRecord::Relation) is to throw ActionPolicy::UnrecognizedScopeTarget
if it can't find scope matchers. That behavior makes sense - but i wonder if we should make an exception for promise-like objects?
the fragment cache's lazy resolver object represents an eventual value - i think it would be nice for action policy to ensure that it's always dealing with promise-resolved values!
What actually happened?
ActionPolicy::UnrecognizedScopeTarget
is raised because there is no built-in scope matcher for GraphQL::FragmentCache::Schema::LazyCacheResolver
issues with the workaround
one issue with the "workaround" i added above is that it's basically a complete bypass. For example, cache_fragment { Posts.all }
will return a lazy resolver and in this case match the lazy_resolver
matcher, but if i also had a relation scope, it will fail to run unless i call it explicitly
for example:
class PostPolicy < ActionPolicy::Base
# this will not match even if the `cached_collection.resolve` returns a relation
relation_scope do |relation|
relation.where(id: 1)
end
scope_matcher :lazy_resolver, GraphQL::FragmentCache::Schema::LazyCacheResolver
scope_for(:lazy_resolver) do |cached_collection|
cached_collection.resolve
end
def show?
true
end
end
a fix for this is to invoke authorized_scope
inside the lazy_resolver scope matcher call. we check if it resolved to a cached value - if so, return it since we've already previously authorized the response. If not, scope it again.
scope_for(:lazy_resolver) do |cached_collection|
result = cached_collection.resolve
if result.is_a?(GraphQL::Execution::Interpreter::RawValue)
result
else
authorized_scope(result)
end
end
alternative approach / hooking into graphql-ruby resolver
an alternative approach is to add an extension to underlying graphql-ruby
and ensure all promises are resolved before hitting any policy code (similar to your extensions in https://github.com/palkan/action_policy-graphql/blob/master/lib/action_policy/graphql/authorized_field.rb) . so if it resolves to a relation / list of Path
active record instances, the correct corresponding policy code will run
class BaseField < GraphQL::Schema::Field
include ApolloFederation::Field
class FragmentCacheResolver < GraphQL::Schema::FieldExtension
def resolve(object:, arguments:, **rest)
value = yield(object, arguments)
if value.kind_of?(GraphQL::FragmentCache::Schema::LazyCacheResolver)
return value.resolve
end
value
end
end
end
while this ensures more correct policy behavior, it's not obvious why this extension exists at a glance ... so there's a bit of an implicit dependency on action policy and gql fragment behavior
would love to hear your thoughts on how to address this. i'm wondering if there's a nice way of baking this into existing action policy gql hooks so that if we're dealing with a promise-like object that defines resolve
, those values are resolved before any calls to authorized_scope
we still need to write custom matcher to deal with the raw values returned re: DmitryTsepelev/graphql-ruby-fragment_cache#68, but i don't feel as weird about doing that
Thanks for such a detailed report!
I need a bit more time to thoroughly think through it, but there is a quick question: wouldn't the following be a solution?
def posts
cache_fragment { authorized_scope(Post.all) }
end
I think, scoping must happen during the value resolution and only if we do not return cache (i.e., the authorized field value is cached, not something before the authorization).
@palkan wow thanks for your prompt response! I hadn't thought of inlining that directly into the block. i think that can definitely work if we turn off authorized_scope: true
in our current field definitions and opt to manually scope instead.
like right now the authorized_scope
option kicks off the field resolver hook inside this gem
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
so if we keep that option as true
it'll still attempt to scope the response from cache_fragment
.
i'm gonna take a look and see if we can switch to manual calls for our current fields. i don't think turning off the auto-scoping is too much effort
it feels like the big problem with automatic hook behavior is that it's surprising when dealing with non-typical (not plain AR instances) values from gql field resolvers
Update: @palkan so i audited our code and turned off authorized_scope:
which was getting applied everywhere and replaced them with your inlined suggestion and was able to ditch some of our custom fragment cache object scope matchers. I think getting away from automatic hooks is a fine solution!
I think getting away from automatic hooks is a fine solution!
Yeah, I think that's the best solution for now.
I think, we can explore adding a custom option to do this automatically, smth like: cached_authorized_scope: true
and cached_authorized_scope: {with: SomePolicy, cache_options: {}}
. But I don't have enough capacity to dig into this right now.